Fix processing log bugs: error handler crash, early-exit path, stale-run detection, and duration formatting
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/3f1ca3bd-627e-4c78-a652-0262d63638fc Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -27,7 +27,8 @@ const STATUS_STYLES: Record<string, string> = {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user