Reset modules stuck in a transient state when their worker dies - #145
Conversation
reset_stale_tasks flipped the Task row to failed for every stale task but only recovered the owning module for DESTROY tasks -- "deploy tasks keep their existing reset behaviour (Task row flipped, nothing else)", as the comment put it. So an apply/plan/init whose worker died between the route setting `applying` and the task's error handler left the module transient forever: the UI showed a perpetually-applying module with no error and no Retry button (Retry only renders for *_failed states), recoverable only by a manual UPDATE against the database. Deploy-side modules are now driven to the matching terminal state -- initializing -> init_failed, planning -> plan_failed, applying -> apply_failed -- reusing the janitor that already computes the live Celery task set, so "dead worker" means exactly what it already means elsewhere. This is option 2 from the issue; it needs no new schedule, since the janitor already runs at boot and periodically. DESTROYING is deliberately absent from the mapping: that path re-drives the destroy chain rather than doing a plain reset, because the next module in the reverse DAG depends on the transition. A real deployment_error is preserved if one was recorded -- the janitor's note only fills an empty field, so the actual cause is never overwritten by a generic message. Modules already in a terminal state are left alone, and a module whose task is still live is never reset out from under itself. Fixes #6 Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
mwiget
left a comment
There was a problem hiding this comment.
Code Review Summary
Goal: Fixes issue #6 where killed Celery workers left modules stuck indefinitely in transient states (initializing, planning, applying).
Key Technical Observations:
- Janitor Cleanup: Extends
execution_janitorto transition modules with dead Celery workers from transient states to corresponding terminal failure states (init_failed,plan_failed,apply_failed), restoring the UI Retry button. - Safety Exclusions: Excludes
DESTROYINGstate to prevent skipping DAG reverse destroy chain triggers. Preserves existing non-emptydeployment_errormessages. - Test Coverage: Added component tests in
test_execution_janitor.pycovering transient-to-failed mappings, live worker non-interference, and error message preservation.
Verdict: LGTM! Approved.
Self-reviewNo defects found. Three things I checked that were not obviously safe: Checked — the module change is always committed
if tasks_reset or destroy_reset["stale_stack_ids"]:
db.commit()A module reset outside that condition would be silently discarded, and the bug would look unfixed intermittently. It cannot happen: a module only enters Checked —
|
Description
reset_stale_tasksflipped theTaskrow tofailedfor every stale task, but only recovered the owning module for DESTROY tasks. The code said so outright:So an apply/plan/init whose worker died between the route setting
applyingand the task's error handler left the module transient forever — the UI showed a perpetually-applying module with no error and no Retry button (Retry only renders for*_failedstates), recoverable only by a manualUPDATE project_modules SET status=..., exactly as the issue reports.Deploy-side modules are now driven to the matching terminal state:
initializing → init_failed,planning → plan_failed,applying → apply_failed.Fixes / Implements: #6
Why this shape
The issue offers three options. This is option 2 (a background sweep), because the infrastructure already exists and already runs —
execution_janitorcomputes the live Celery task set at boot and periodically, and is where the identical DESTROY recovery already lives. So "the worker is dead" means exactly what it already means elsewhere in the codebase, and no new schedule, table, or config is introduced.Option 1 (a
finallyin each task function) does not actually cover the reported cause: a worker killed by SIGKILL/OOM never runs any Python handler, so afinallyblock never executes. It would help the narrower "unexpected exception type" case, and remains worth doing, but it cannot be the backstop on its own.Two deliberate exclusions:
DESTROYINGis not in the mapping. That path re-drives the destroy chain rather than doing a plain reset, because the next module in the reverse DAG depends on the transition. Adding it here would bypass_trigger_next_destroy_module.deployment_erroris preserved. The janitor's note only fills an empty field, so a genuine cause recorded before the worker died is never overwritten with a generic message.Architectural Decision Record (ADR)
Type of Change
Verification & Testing
Six cases added to
backend/tests/component/test_execution_janitor.py(14 passed in that file):deployment_errorwins over the janitor's note.Regression diff on the
janitor/stale/lifecycleselection inbnk-forge-test:latest:Environment validation needed
Yes — the trigger is a worker death, which I can only simulate. My tests pass a dead
celery_task_idrather than actually killing a worker. Worth reproducing the original scenario on a real deployment:docker killthe celery worker mid-run (SIGKILL, so no Python handler runs);apply_failedwith the note, and the Retry button should appear;get_live_task_ids()being accurate under real broker conditions — something no unit test here covers.Checklist
_TRANSIENT_TO_FAILEDmapping carries a comment explaining whyDESTROYINGis excluded, since that is the non-obvious part.