Skip to content

Skip paths archive sibling messages but never terminalize sibling step_tasks rows, leaving status='started' tasks on completed runs #638

Description

@cpursley

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 8ffc889schemas/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

  1. A flow with a map step (initialTasks > 1) configured whenExhausted: 'skip' and maxAttempts: 1.
  2. Start a run; let task 0 of the map step fail its only attempt while tasks 1..n are still queued/started.
  3. fail_task skips the step; the run completes.
  4. 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:

  1. Phantom in-flight work on terminal runs. Any dashboard, health check, or metric of the shape WHERE status = 'started' over-counts forever.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions