world-postgres: step jobs are permanently retired by lost workers, not by failures
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 2.4k
- Forks
- 365
- Avg merge
- 2d 11h
- Merged PRs (30d)
- 169
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 null
Note 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.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with graphile-worker/sql/000011.sql:67 and the get_job function described in the issue, then reproduce a worker disappearing during a long-running step. Verify how claim attempts, stale-lock expiry, and last_error interact. Done means a lost worker can be reclaimed without silently exhausting the retry budget, while genuine failures still follow the retry limit.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- aws, postgresql, typescript
- Domain
- backend, cloud, databases, distributed-systems
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100