Repository navigation
Description
Packages: @workflow/world-postgres@4.3.0, @workflow/world@4.2.1, workflow@4.6.0
Queue: graphile-worker@0.16.6
Runtime: AWS ECS Fargate, rolling deploys
What happens
world-postgres enqueues every step job with maxAttempts: 3. The underlying queue counts deliveries, not failures — get_job increments attempts as part of the claim. That is normal for an at-least-once queue, but it composes badly with long-lived workflow steps: a worker replaced mid-step consumes an attempt and records nothing.
Three such losses retire the job permanently. Availability is a stored generated column, so once attempts are exhausted the row can never be claimed again regardless of its lock (graphile-worker/sql/000011.sql:67):
is_available boolean generated always as
(((locked_at is null) and (attempts < max_attempts))) stored not nullNote the 4-hour stale-lock expiry in get_job(worker_id, task_identifiers, job_expiry interval = interval '4 hours') cannot rescue these rows, because the attempts condition is ANDed in.
Why this matters for workflow specifically
A step can run for minutes — in our case a model call — so "the worker disappears mid-step" is not an edge case on a platform with rolling deploys. ECS caps its stop timeout well below a step's duration, so every deploy landing mid-step costs one attempt on an otherwise healthy job.
The loss is also silent: last_error stays null, so a lost worker is indistinguishable from a genuine failure, in the queue or anywhere else.
Evidence
From one incident, a single workflow run's model-call step:
id attempts locked_at locked_by last_error
8765 3/3 2026-09-07 09:53:39 worker-5e36c7e39c794d6abe (null)
8767 3/3 2026-09-07 09:54:14 worker-0bb74f306ada48ace3 (null)
One attempt went to a genuine provider error; two went to container replacements during a deploy. Neither of the latter recorded anything.
The end state is permanent. A row in the same table, untouched for 28 days:
id attempts locked_at created_at run_at
2116 3/3 (null) 2026-08-10 09:33:20 2026-08-10 13:36:19
run_at is exactly 4h03m after created_at — the lock did expire and release. The row was still never reclaimed, because the attempts were spent.
Reproduction
- Start a workflow whose step takes longer than the platform's shutdown grace period.
- Replace the process mid-step, three times.
- The job sits at
attempts = max_attemptswithlast_errornull and is never picked up again. The run stays non-terminal indefinitely.
What we would like
Either increment attempts on failure rather than on claim, or distinguish a lost claim from a failure so it does not spend the budget. A visibility timeout or lease renewal would achieve the same thing: a worker that stops renewing releases the job without consuming an attempt.
This compounds with two related reports we are filing alongside: the retry cap is not configurable, and nothing recovers the run afterwards except a process boot.
Related: #3993 (recovery only runs at startup) and #3994 (the retry cap is not configurable). The three compound: a lost worker spends an attempt, the cap cannot be raised, and nothing recovers the run afterwards except a process boot.
Activity
zachares commentedon Sep 7, 2026
One narrowing, having found #3064 after filing this.
#3064 ("fix(world-postgres): abort stalled HTTP delivery on shutdown", merged 2026-07-24) substantially reduces the exposure described above: with the loopback delivery abortable and application-managed shutdown available, a worker being replaced can release the job instead of exiting while holding it. We are running that as a backported patch on 4.3.0 and it does resolve the four-hour lock.
What it does not change is the accounting: the aborted delivery still consumes one of the three attempts, with no error recorded. So the failure mode above is rarer but not removed — three interruptions in one run still retires the row permanently, and genuine failures spend from the same budget (in our incident the first attempt went to a provider
Overloaded, the other two to container replacements).So the request stands, just narrower: an attempt spent on a lost or aborted delivery should be distinguishable from one spent on a failure, whether by not counting it, by recording a reason, or by a lease/visibility-timeout model where a delivery that stops being renewed is simply redelivered.
[world-postgres] Renew running deliveries' job locks and redeliver ab…