Summary
When a map step is skipped via when_exhausted: 'skip' / 'skip-cascade' (and via _cascade_force_skip_steps generally), the skip transaction updates step_states, decrements runs.remaining_steps, and archives the sibling tasks' pgmq messages — but it never updates the sibling step_tasks rows themselves. Those rows keep status = 'started' (with started_at set) forever, on a run that goes on to reach status = 'completed'.
Observed in the SQL at commit 5c132f3 (we vendor pgflow's core SQL into an Elixir port and verified this with integration tests there), and confirmed still present on main as of 8ffc889 — schemas/0100_function_fail_task.sql and schemas/0100_function__cascade_force_skip_steps.sql both archive sibling messages without touching the sibling task rows.
Where in the code
All references are to the compiled core SQL (function bodies as installed):
fail_task, skip branch (when_exhausted in ('skip','skip-cascade')):
-
maybe_fail_step sets the step_state to 'skipped' (skip_reason = 'handler_failed', skipped_at = now(), remaining_tasks = NULL).
-
run_update decrements runs.remaining_steps, so the run can still complete.
-
The skip branch then archives sibling messages only:
PERFORM pgmq.archive(r.flow_slug, ARRAY_AGG(st.message_id))
FROM pgflow.step_tasks st ... WHERE st.status IN ('queued','started') ...
There is no UPDATE pgflow.step_tasks SET status = ... anywhere in this branch.
_cascade_force_skip_steps has the same shape: its skipped CTE updates step_states only, and its archived_messages CTE archives messages only. Task rows are untouched.
maybe_complete_run then fires, so the run reaches 'completed' with 'started' task rows still attached to the skipped step.
The message handling is correct and race-free (done in-transaction under FOR UPDATE); it's specifically the task rows that are left behind.
Reproduction sketch
- A flow with a map step (
initialTasks > 1) configured whenExhausted: 'skip' and maxAttempts: 1.
- Start a run; let task 0 of the map step fail its only attempt while tasks 1..n are still
queued/started.
fail_task skips the step; the run completes.
SELECT status, count(*) FROM pgflow.step_tasks WHERE run_id = $1 GROUP BY 1 — the skipped step's sibling tasks are still 'started' (or 'queued'), on a 'completed' run.
Why it matters
The rows are permanently undispatchable by construction — start_tasks requires task.status = 'queued' AND run.status = 'started' AND a step_states row at 'started' — so no handler ever runs. The damage is to the data model, not execution:
- Phantom in-flight work on terminal runs. Any dashboard, health check, or metric of the shape
WHERE status = 'started' over-counts forever.
- Recovery/requeue tooling built on
status = 'started' churns. We hit this concretely: a stalled-task sweeper keyed on started + age repeatedly "recovered" these rows on completed runs — flipping them started → queued, incrementing requeue counters until a permanent-stall marker fired, calling pgmq.archive on already-archived message ids, and logging recovery work that never happened. The requeue is pure no-op churn (the messages were archived by the skip, and set_vt on an archived id matches nothing), but the operational signal is permanently noisy and wrong for any deployment using whenExhausted: 'skip' on a map step.
- Invariant asymmetry. Every other terminal step outcome terminalizes its task rows; skip is the one path that doesn't, which future consumers won't expect.
Suggested fix
In the same transaction that skips the step (both the fail_task skip branch and _cascade_force_skip_steps), terminalize the sibling rows alongside the message archive — e.g.:
UPDATE pgflow.step_tasks st
SET status = 'skipped' -- or whatever terminal marker fits the schema
FROM ...
WHERE st.run_id = ... AND st.step_slug = ... AND st.status IN ('queued', 'started');
If adding a task-level 'skipped' status is undesirable, any terminal status with a marker would resolve the phantom-row problem equally well.
Summary
When a map step is skipped via
when_exhausted: 'skip'/'skip-cascade'(and via_cascade_force_skip_stepsgenerally), the skip transaction updatesstep_states, decrementsruns.remaining_steps, and archives the sibling tasks' pgmq messages — but it never updates the siblingstep_tasksrows themselves. Those rows keepstatus = 'started'(withstarted_atset) forever, on a run that goes on to reachstatus = 'completed'.Observed in the SQL at commit
5c132f3(we vendor pgflow's core SQL into an Elixir port and verified this with integration tests there), and confirmed still present onmainas of8ffc889—schemas/0100_function_fail_task.sqlandschemas/0100_function__cascade_force_skip_steps.sqlboth archive sibling messages without touching the sibling task rows.Where in the code
All references are to the compiled core SQL (function bodies as installed):
fail_task, skip branch (when_exhaustedin('skip','skip-cascade')):maybe_fail_stepsets the step_state to'skipped'(skip_reason = 'handler_failed',skipped_at = now(),remaining_tasks = NULL).run_updatedecrementsruns.remaining_steps, so the run can still complete.The skip branch then archives sibling messages only:
There is no
UPDATE pgflow.step_tasks SET status = ...anywhere in this branch._cascade_force_skip_stepshas the same shape: itsskippedCTE updatesstep_statesonly, and itsarchived_messagesCTE archives messages only. Task rows are untouched.maybe_complete_runthen fires, so the run reaches'completed'with'started'task rows still attached to the skipped step.The message handling is correct and race-free (done in-transaction under
FOR UPDATE); it's specifically the task rows that are left behind.Reproduction sketch
initialTasks > 1) configuredwhenExhausted: 'skip'andmaxAttempts: 1.queued/started.fail_taskskips the step; the run completes.SELECT status, count(*) FROM pgflow.step_tasks WHERE run_id = $1 GROUP BY 1— the skipped step's sibling tasks are still'started'(or'queued'), on a'completed'run.Why it matters
The rows are permanently undispatchable by construction —
start_tasksrequirestask.status = 'queued'ANDrun.status = 'started'AND astep_statesrow at'started'— so no handler ever runs. The damage is to the data model, not execution:WHERE status = 'started'over-counts forever.status = 'started'churns. We hit this concretely: a stalled-task sweeper keyed onstarted+ age repeatedly "recovered" these rows on completed runs — flipping themstarted → queued, incrementing requeue counters until a permanent-stall marker fired, callingpgmq.archiveon already-archived message ids, and logging recovery work that never happened. The requeue is pure no-op churn (the messages were archived by the skip, andset_vton an archived id matches nothing), but the operational signal is permanently noisy and wrong for any deployment usingwhenExhausted: 'skip'on a map step.Suggested fix
In the same transaction that skips the step (both the
fail_taskskip branch and_cascade_force_skip_steps), terminalize the sibling rows alongside the message archive — e.g.:If adding a task-level
'skipped'status is undesirable, any terminal status with a marker would resolve the phantom-row problem equally well.