diff --git a/CHANGELOG.md b/CHANGELOG.md index 342b24b..a4c4813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,9 +22,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Processing log durations**: Runs that were killed by SIGKILL or failed before updating their + own status (e.g. missing SMTP credentials) now always record a correct `completed_at` and + `duration_seconds`. The error handler no longer accesses an expired SQLAlchemy ORM attribute + (`run.started_at`) after a session rollback, which previously caused the handler to crash and + left runs stuck in the `running` state indefinitely. +- **Processing log early-exit path**: When a mail account has no delivery method configured + (SMTP credentials missing and Gmail API not set up), the processing run now correctly sets + `completed_at`, `duration_seconds`, and `account.last_check_at`, preventing the account from + being re-queued on every scheduler tick and generating a flood of failed runs. +- **Stale-run detection moved to scheduler**: `process_all_enabled_accounts` (runs every 5 min) + now marks orphaned `running` runs as `failed` immediately. Previously this only happened in the + daily `cleanup_old_logs` task, meaning stale runs could show huge durations (hours/days). +- **Duration display rounding bug**: `formatDuration` in the frontend Processing Logs page now + uses `Math.floor` instead of `Math.round` for the seconds component, eliminating the "60s" + artefact that appeared for durations very close to a whole minute boundary. + ### Changed - **Decoupled Gmail permissions from Google Sign-In**: The "Sign in with Google" OAuth flow now only requests basic profile scopes (`openid`, `email`, `profile`) instead of also requesting Gmail API scopes (`gmail.insert`, `gmail.labels`, `gmail.readonly`). Users can grant Gmail access separately via the "Connect Gmail" button in Settings. This results in a simpler, permission-free login experience. + ## [0.2.1] - 2026-03-27 ### Fixed diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 1848958..116d4e0 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -90,10 +90,15 @@ async def process_mail_account(account_id: int): logger.warning(f"Account {account_id} not found or disabled") return + # Capture start time in a local variable so the error handler can + # compute duration_seconds without touching the (expired) ORM + # attribute after a session rollback. + _run_started_at = datetime.now(timezone.utc) + # Create processing run run = ProcessingRun( mail_account_id=account.id, - started_at=datetime.now(timezone.utc), + started_at=_run_started_at, status="running", ) db.add(run) @@ -189,6 +194,11 @@ async def process_mail_account(account_id: int): ) run.status = "failed" # type: ignore[assignment] run.error_message = "No delivery method configured (SMTP credentials missing and Gmail API not set up)" # type: ignore[assignment] + run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment] + run.duration_seconds = ( # type: ignore[assignment] + run.completed_at - _run_started_at + ).total_seconds() + account.last_check_at = datetime.now(timezone.utc) # type: ignore[assignment] await db.commit() return @@ -411,8 +421,10 @@ async def process_mail_account(account_id: int): run.status = "failed" # type: ignore[assignment] run.error_message = str(e) # type: ignore[assignment] run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment] - run.duration_seconds = ( - run.completed_at - _as_utc(run.started_at) + # Use the locally-captured start time to avoid accessing an + # expired ORM attribute after the session rollback above. + run.duration_seconds = ( # type: ignore[assignment] + run.completed_at - _run_started_at ).total_seconds() # Update account error status @@ -456,6 +468,32 @@ async def process_all_enabled_accounts(): _task_start = time.monotonic() async with async_session_maker() as db: try: + # Mark stale "running" runs as failed. A run is considered stale + # when it has been in the "running" state longer than the Celery + # hard time limit (30 min) plus a small buffer – this recovers from + # worker crashes or SIGKILL events faster than waiting for the + # daily cleanup_old_logs task. + stale_threshold = datetime.now(timezone.utc) - timedelta(minutes=35) + stale_result = await db.execute( + select(ProcessingRun).where( + ProcessingRun.status == "running", + ProcessingRun.started_at < stale_threshold, + ) + ) + stale_runs = stale_result.scalars().all() + for stale_run in stale_runs: + stale_run.status = "failed" # type: ignore[assignment] + stale_run.error_message = ( # type: ignore[assignment] + "Run timed out or worker was killed before completion" + ) + stale_run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment] + stale_run.duration_seconds = ( # type: ignore[assignment] + stale_run.completed_at - _as_utc(stale_run.started_at) + ).total_seconds() + if stale_runs: + await db.commit() + logger.info(f"Marked {len(stale_runs)} stale processing runs as failed") + # Fetch all enabled accounts regardless of operational status so # that accounts in ERROR state are retried automatically. result = await db.execute( diff --git a/frontend/src/app/logs/page.tsx b/frontend/src/app/logs/page.tsx index a591935..4dc8e3c 100644 --- a/frontend/src/app/logs/page.tsx +++ b/frontend/src/app/logs/page.tsx @@ -27,7 +27,8 @@ const STATUS_STYLES: Record = { function formatDuration(seconds?: number | null): string { if (seconds == null) return '—'; if (seconds < 60) return `${seconds.toFixed(1)}s`; - return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`; + const totalSecs = Math.floor(seconds); + return `${Math.floor(totalSecs / 60)}m ${totalSecs % 60}s`; } function formatDate(iso: string): string {