fix: a restart no longer strands downloads — the download lane closes its predecessor's runs as interrupted and frees the platform locks at boot (#4433)
CI and images / lint (push) Successful in 4s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 19s
CI and images / frontend-build (push) Successful in 21s
CI and images / backend-lint-and-test (push) Failing after 31s
CI and images / integration (push) Successful in 2m23s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped

A walk that outlives the 90s stop grace is SIGKILLed: its event never
finalizes and its platform lock is held for the 27-min TTL. The 30-min
sweep then errored every stranded event and bumped consecutive_failures,
backing sources off (and blocking backfills) as if the platform failed.

On worker_ready, the process consuming 'download' ends pre-boot
pending/running events as skipped with error_type 'interrupted', leaves
the source untouched so the next tick resumes it, and releases the
serialized platforms' locks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-25 10:29:05 -04:00
co-authored by Claude Opus 5.5
parent 32874ca678
commit 0fad744bfb
5 changed files with 232 additions and 2 deletions
+47 -1
View File
@@ -23,7 +23,13 @@ import logging
from datetime import UTC, datetime
from celery.exceptions import SoftTimeLimitExceeded
from celery.signals import task_failure, task_postrun, task_prerun, task_retry
from celery.signals import (
task_failure,
task_postrun,
task_prerun,
task_retry,
worker_ready,
)
from .models import TaskRun
from .tasks._sync_engine import sync_session_factory
@@ -213,3 +219,43 @@ def _on_retry(sender=None, request=None, reason=None, einfo=None, **_):
error_message=str(reason) if reason else None,
retry_count=getattr(request, "retries", 0),
)
def _consumed_queues(consumer) -> set[str]:
"""The queue names this worker process consumes (its `-Q`), or empty when
they can't be read — which makes the boot hook below a no-op, not a guess."""
try:
return {q.name for q in consumer.task_consumer.queues}
except Exception: # noqa: BLE001 — shape varies across celery versions
return set()
@worker_ready.connect
def _on_worker_ready(sender=None, **_):
"""The download lane clears what its previous process left behind (#4433).
A restart SIGKILLs any walk that outlives the stop grace, so its event is
never finalized and its platform lock is never released. Both used to wait
out timers — a 30-min stall sweep that then blamed the source, and a 27-min
lock TTL that stalled every other source on the platform. Only the process
consuming `download` does this; the other lanes booting beside it must not.
Best-effort: a failure here is logged and the worker still starts.
"""
if "download" not in _consumed_queues(sender):
return
booted_at = datetime.now(UTC)
try:
from .services.platform_lock import release_all_platform_locks
from .tasks.maintenance import interrupt_orphaned_download_events
with sync_session_factory()() as session:
closed = interrupt_orphaned_download_events(session, booted_at=booted_at)
session.commit()
released = release_all_platform_locks()
if closed or released:
log.info(
"download lane boot: closed %d orphaned download event(s) as "
"interrupted, released %d platform lock(s)", closed, released,
)
except Exception: # noqa: BLE001 — never block the worker from starting
log.exception("download lane boot recovery failed")
+19
View File
@@ -60,3 +60,22 @@ def platform_lock(platform: str, *, ttl_seconds: int):
except redis.RedisError as exc: # pragma: no cover - broker outage
log.warning("platform_lock unavailable for %s: %s", platform, exc)
return None
def release_all_platform_locks() -> int:
"""Drop every serialized platform's lock. Returns how many were held.
Only for the download lane's boot (#4433). A worker that restarts mid-walk
is SIGKILLed past its stop grace, so its `finally` never releases the lock,
and the TTL keeps every other source on that platform bouncing for up to
27 minutes after the new worker is ready. At boot no walk of ours can be
running, so a held lock names a dead one. Assumes one download consumer —
the only shape FC deploys; a second replica booting would free a live
walk's lock (not corrupt it: the walk runs on, a second walk may overlap it).
"""
try:
client = _redis()
return int(client.delete(*(f"{_LOCK_PREFIX}{p}" for p in SERIALIZED_PLATFORMS)))
except redis.RedisError as exc: # pragma: no cover - broker outage
log.warning("could not release platform locks at boot: %s", exc)
return 0
+44 -1
View File
@@ -7,7 +7,8 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path
from PIL import Image
from sqlalchemy import Integer, and_, cast, delete, func, or_, select, update
from sqlalchemy import Integer, and_, cast, delete, func, literal, or_, select, update
from sqlalchemy.dialects.postgresql import JSONB
from ..celery_app import celery
from ..models import (
@@ -731,6 +732,48 @@ def recover_stalled_download_events() -> int:
return events_recovered
DOWNLOAD_INTERRUPTED_MESSAGE = (
"interrupted by a worker restart — the next check picks it up where it left off"
)
def interrupt_orphaned_download_events(session, *, booted_at: datetime) -> int:
"""Close the download events a restart orphaned, without blaming the source.
Called when the download lane comes up (#4433). Anything still
pending/running from before this boot belongs to the previous process:
a walk that outlived the 90s stop grace was SIGKILLed, and a queued or
serialize-deferred task is held unacked until Redis redelivers it about an
hour later. Left alone, the 30-min stall sweep would error each one and
bump `consecutive_failures`, backing the source off as if the platform had
failed.
Instead they end as `skipped` (terminal, not a failure) and the source is
not touched: `last_checked_at` keeps its old value, so the next tick finds
it due and the walk resumes from its checkpoint. A redelivered message
that arrives later finds no pending event and opens a fresh one.
An event promoted to running after the boot has `started_at` reset to its
real start (download_service), so it is never caught here. Does NOT commit.
"""
now = datetime.now(UTC)
result = session.execute(
update(DownloadEvent)
.where(DownloadEvent.status.in_(["pending", "running"]))
.where(DownloadEvent.started_at < booted_at)
.values(
status="skipped",
finished_at=now,
error=DOWNLOAD_INTERRUPTED_MESSAGE,
metadata_=DownloadEvent.metadata_.op("||")(
literal({"error_type": "interrupted"}, JSONB)
),
)
.returning(DownloadEvent.id)
)
return len(result.all())
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_backup_runs")
def recover_stalled_backup_runs() -> int:
"""Flip BackupRun rows stuck in running/restoring past the hard limit