You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 7903661
Browse filesBrowse the repository at this point in the historyBrowse files
fix(core): terminalize unfinished tasks when runs fail (#663)
## Summary
Failed runs archive their PGMQ messages but leave sibling `step_tasks` rows `queued` or `started`, so stale recovery can requeue undispatchable work and late callbacks can misrepresent terminal state (#645).
## Invariant
```text
runs.status = 'failed'
implies
no step_tasks row for that run has status IN ('queued', 'started')
```
Task outcomes stay truthful:
- task that caused the failure → `failed`
- task completed before the failure → `completed`
- unfinished queued or started task → `cancelled`
## Why `cancelled` and not `failed`/`skipped`
- `failed` counts real handler failures; reusing it corrupts failure metrics.
- `skipped` is step-skip policy (`whenUnmet`/`whenExhausted`); reusing it conflates run failure with explicit skip decisions.
- `cancelled` is orchestration state invalidated by the run failure.
- `runs.failed_at` is the cancellation time; no `cancelled_at` column and no reason column were added.
Database cancellation does not terminate JavaScript already executing in a worker and does not undo external side effects the handler already performed. Worker-side abort behavior remains out of scope (#646).
## Changed failure paths
1. **`fail_task()` with exhausted `when_exhausted = 'fail'`** — the culprit stays `failed`; every remaining queued/started task in the run becomes `cancelled` with `UPDATE ... RETURNING` message capture, then archived. Retries before exhaustion are preserved.
2. **`complete_task()` type violation** — the directly invalid task is marked `failed` first (output and `[TYPE_VIOLATION]` error preserved), then every other queued/started task is cancelled and archived afterward.
3. **`cascade_resolve_conditions()` with `when_unmet = 'fail'`** — the condition step and run still fail; unfinished tasks across all independent branches become cancelled, then archived. The run transition is now conditional (`WHERE status = 'started'`) so replayed or concurrent calls cannot duplicate events.
4. **Already-failed-run `fail_task()` path** — no longer rewrites a `started`/`cancelled` task to `failed`; it only archives any still-active message and returns the row.
All three paths reuse the #638/#649 lock-order pattern: update task rows first, capture message IDs with `RETURNING`, archive queue rows only afterward.
## Late-callback and concurrency guards
- `complete_task()` rechecks `runs.status` under the `FOR UPDATE` lock and returns unchanged when the run is failed — closes the race where the pre-lock guard saw `started` but the run failed while the callback waited.
- `fail_task()` captures run status under the existing run/step lock and rechecks it after lock acquisition for the same race.
- Cancellation wins over late `complete_task()` and `fail_task()`; repeated callbacks are idempotent and do not revive cancelled rows, rewrite history, clear `runs.failed_at`, duplicate events, or drift counters.
- A committed completed task remains `completed`; a genuine failure remains `failed`.
## Stalled recovery guards
`requeue_stalled_tasks()` now requires `run.status = 'started'` AND `step_state.status = 'started'` AND `task.status = 'started'`. Genuine recovery, requeue counts, max-requeue and permanent-stall behavior, and the current timeout calculation are unchanged (#621 owns the timeout bug).
## Migration and backfill
`20260901183450_pgflow_temp_failed_run_terminalization.sql` (Atlas-generated, temp-prefixed for the stacked #645 → #621 → #656 order):
1. allows `cancelled` in `step_tasks.valid_status`;
2. recreates the four changed functions;
3. repairs historical rows — only `queued`/`started` tasks on `failed` runs flip to `cancelled`; completed/failed outcomes and every history field (attempts, requeue history, worker identity, timestamps, outputs, errors) are preserved.
Verified with an upgrade fixture: DB reset through `20260827180017`, a failed run holding completed/failed/started/queued tasks, migration applied → completed/failed/cancelled/cancelled with all history fields byte-identical.
## Tests
- `fail_task/archive_sibling_map_tasks.test.sql` — exhausted map: task 0 failed, task 1 started→cancelled, task 2 queued→cancelled; run/step failed; zero active tasks/messages; all archived.
- `type_violations/cancels_unfinished_tasks.test.sql` — invalid task failed with preserved output/error; completed work stays completed; unrelated queued/started tasks cancelled.
- `condition_evaluation/dependent_unmet_fail_archives_active_messages.test.sql` — completed trigger task stays completed; condition step fails; independent unfinished work cancelled; replay emits no duplicate `run:failed`.
- `complete_task/no_mutations_on_failed_run.test.sql` — late `complete_task()` after run failure leaves the task cancelled with history intact.
- `fail_task_when_exhausted/cancelled_task_late_callbacks_are_idempotent.test.sql` — repeated late `complete_task()`/`fail_task()` leave cancelled rows, history, parent state, counters, and events unchanged.
- `fail_task_when_exhausted/late_callbacks_post_lock_race.test.sql` — two-session dblink test for the post-lock race: a ctrl session holds `FOR UPDATE` on the run row; late `complete_task()` and `fail_task()` — both aimed at the task the winning failure cancels — block after their pre-lock checks (proven blocked by ctrl via `pg_blocking_pids()`); ctrl then fails the run and commits. Both callbacks return the cancelled row unchanged: no output, no error, no `failed_at`, no moved `runs.failed_at`, no duplicate events, counters untouched.
- `requeue_stalled_tasks/ignores_terminal_parent_state.test.sql` — started run/step recovery still requeues; failed-run and terminal-step stale tasks are ignored.
- Each failure-path regression installs the #638 queue-table trigger that fails deterministically if archival ever precedes task terminalization.
## Checks run
- `pnpm nx test:pgtap core --skip-nx-cache` — 284 files, 1316 tests, all pass
- `pnpm nx verify-migrations core --skip-nx-cache` — pass
- `pnpm nx gen-types core --skip-nx-cache` / `pnpm nx verify-gen-types core --skip-nx-cache` — pass (types unchanged; status columns are `string`)
- `pnpm nx test core --skip-nx-cache` — pass
- `pnpm nx build core --skip-nx-cache` — pass
- `pnpm nx lint core --skip-nx-cache` — pass (`fix-sql` hit the known sqruff `--force` incompatibility; equivalent direct `sqruff fix schemas/` ran clean)
- `pnpm changeset status`, `git diff --check` — clean
## Out of scope
#621 (effective-timeout calculation), #656 (queue identity / visibility extension), #646 (worker-side handler cancellation), migration consolidation, manual tasks.
Closes#645.
Mark unfinished tasks as cancelled when their run fails, prevent late callbacks and stalled recovery from reviving them, and repair active tasks on historical failed runs.
Copy file name to clipboardExpand all lines: pkgs/core/README.md
+5-2Lines changed: 5 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -348,9 +348,12 @@ The system handles failures by:
348
348
- Marking the step as 'failed'
349
349
- Marking the run as 'failed'
350
350
- Archiving the message in PGMQ
351
-
-**Archiving all queued messages for the failed run**(preventing orphaned messages)
351
+
-**Marking every remaining queued or started task in the run as 'cancelled'**and archiving their messages (the failed-run invariant: a failed run has no task rows left in `queued` or `started`)
352
352
4. Additional failure handling:
353
-
-**No retries on already-failed runs** - tasks are immediately marked as failed
353
+
-**No retries on already-failed runs** - late `fail_task()` callbacks keep cancelled tasks cancelled
354
+
-**Cancellation wins over late callbacks** - late `complete_task()` or `fail_task()` cannot revive or rewrite a cancelled task; a committed completed task stays completed
355
+
-**`cancelled` differs from `failed` and `skipped`** - `failed` counts real handler failures, `skipped` is step-skip policy, `cancelled` is orchestration state invalidated by the run failure; `runs.failed_at` is the cancellation time
356
+
-**Database cancellation does not terminate handlers** - JavaScript already executing in a worker is not forcibly stopped and external side effects are not undone
354
357
-**Graceful type constraint violations** - handled without exceptions when single steps feed map steps
355
358
-**Stores invalid output on type violations** - captures the output that caused the violation for debugging
356
359
-**Performance-optimized message archiving** using indexed queries
0 commit comments