Merge pull request #104 from christianlouis/copilot/update-processing-log-entries
Fix processing log entries: stale runs, error handler crash, and duration display bug
This commit is contained in:
@@ -22,9 +22,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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.
|
- **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
|
## [0.2.1] - 2026-03-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -90,10 +90,15 @@ async def process_mail_account(account_id: int):
|
|||||||
logger.warning(f"Account {account_id} not found or disabled")
|
logger.warning(f"Account {account_id} not found or disabled")
|
||||||
return
|
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
|
# Create processing run
|
||||||
run = ProcessingRun(
|
run = ProcessingRun(
|
||||||
mail_account_id=account.id,
|
mail_account_id=account.id,
|
||||||
started_at=datetime.now(timezone.utc),
|
started_at=_run_started_at,
|
||||||
status="running",
|
status="running",
|
||||||
)
|
)
|
||||||
db.add(run)
|
db.add(run)
|
||||||
@@ -189,6 +194,11 @@ async def process_mail_account(account_id: int):
|
|||||||
)
|
)
|
||||||
run.status = "failed" # type: ignore[assignment]
|
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.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()
|
await db.commit()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -411,8 +421,10 @@ async def process_mail_account(account_id: int):
|
|||||||
run.status = "failed" # type: ignore[assignment]
|
run.status = "failed" # type: ignore[assignment]
|
||||||
run.error_message = str(e) # type: ignore[assignment]
|
run.error_message = str(e) # type: ignore[assignment]
|
||||||
run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||||
run.duration_seconds = (
|
# Use the locally-captured start time to avoid accessing an
|
||||||
run.completed_at - _as_utc(run.started_at)
|
# expired ORM attribute after the session rollback above.
|
||||||
|
run.duration_seconds = ( # type: ignore[assignment]
|
||||||
|
run.completed_at - _run_started_at
|
||||||
).total_seconds()
|
).total_seconds()
|
||||||
|
|
||||||
# Update account error status
|
# Update account error status
|
||||||
@@ -456,6 +468,32 @@ async def process_all_enabled_accounts():
|
|||||||
_task_start = time.monotonic()
|
_task_start = time.monotonic()
|
||||||
async with async_session_maker() as db:
|
async with async_session_maker() as db:
|
||||||
try:
|
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
|
# Fetch all enabled accounts regardless of operational status so
|
||||||
# that accounts in ERROR state are retried automatically.
|
# that accounts in ERROR state are retried automatically.
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ const STATUS_STYLES: Record<string, string> = {
|
|||||||
function formatDuration(seconds?: number | null): string {
|
function formatDuration(seconds?: number | null): string {
|
||||||
if (seconds == null) return '—';
|
if (seconds == null) return '—';
|
||||||
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
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 {
|
function formatDate(iso: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user