Skip to content

Fix/stale delivery reclaim - #1

Merged
pravit-amp merged 5 commits into
mainfrom
fix/stale-delivery-reclaim
Aug 11, 2026
Merged

Fix/stale delivery reclaim#1
pravit-amp merged 5 commits into
mainfrom
fix/stale-delivery-reclaim

Conversation

@pravit-amp

Copy link
Copy Markdown
Owner

No description provided.

Pravit Ampapathini and others added 5 commits August 10, 2026 22:23
RefreshJobStatus aggregated task counts with a SELECT, computed the status
in Go, then wrote it with a separate UPDATE. Nothing wrapped the two, so
concurrent workers finishing tasks of the same job could lose an update:

  W1 SELECTs (2 of 3 done) -> computes "running"
  W2 SELECTs (3 of 3 done) -> computes "completed"
  W2 UPDATEs status = "completed"
  W1 UPDATEs status = "running"   <-- stale write wins

The job then stays "running" forever, since once every task is terminal
nothing triggers another refresh. Workers call this on every task
completion and failure, so jobs with parallel tasks hit it regularly.

Compute the status in SQL with a CTE and update in the same statement.
The read of tasks and the write to jobs now share one timestamp, and as
an implicit transaction CockroachDB retries it internally on conflict.
A job with no tasks leaves the row untouched and returns n_total so the
"no tasks" and "no job row" errors stay distinguishable.

Tests (gated on CRDB_DSN, run against the three-node cluster):

  - ParallelTaskCompletions completes every task of a job from parallel
    workers and asserts the job ends "completed".
  - StaleReadCannotWinTheLastWrite forces the interleaving above. Parallel
    completions alone do not reproduce it: writes to the contended jobs
    row are served in arrival order, so readers write in the order they
    read. Running one refresh on a single-connection pool and queueing
    behind that connection catches it between its read and its write,
    which fails on the split read-then-write form every run.
  - TerminalStates pins the status mapping across a rewrite of the SQL,
    and UnknownJob covers the missing-row path that no longer comes from
    RowsAffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pending marker is created when a task id is pushed to the ready list and
cleared only after a worker successfully claims it. Two failure paths end a
delivery without ever reaching that claim, and both leave the marker behind.
The task is then queued, due, and absent from the ready list, while
EnqueueDueTaskID reads the surviving marker as "already enqueued" and skips the
LPUSH - so nothing re-delivers it and nothing releases the marker.

Reclaim path: a worker dies mid-flight, ReclaimStaleRunningOnce returns the task
to queued, but the marker from the original enqueue survives.

Claim path: BRPOP takes the id off the ready list and the claim then fails to
commit (during a CockroachDB node outage the worker logs "claim <id>:
unexpected EOF"). The task never reaches 'running', so reclaim cannot rescue it
either.

Both sites now release the marker at the point the delivery is abandoned. The
worker releases on a context independent of the caller's, since the task context
is typically already cancelled or failing in exactly these cases. Releasing on a
lost claim race is safe: the winner has already released the marker itself, and
EnqueueDueTaskID re-checks status before pushing, so a running task is not
re-enqueued.

The 5 minute marker TTL bounded the damage - a stranded task recovered when the
marker expired, not never - but a 5 minute stall on a due task is still a
failure. The TTL stays as a backstop and is deliberately not raised: an early
expiry only costs a duplicate BRPOP, because ClaimQueuedTask is atomic.

Verified on the three-node cluster with `docker kill` under load. Before: 4
tasks stranded, all recovering exactly 5:00 later at marker expiry. After: 7354
tasks submitted, zero stranded, no markers left behind. Reclaim was exercised
separately with a marker armed ahead of the task - recovery took ~10s against a
live 300s marker, so the release did it, not the TTL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…invariant

Reclaiming a stale running task rolls tasks.attempt back by one so losing a
worker does not cost the task a retry. The next claim therefore re-issues the
same attempt number, and the retried run writes a second task_runs row sharing
(task_id, attempt_number) with its abandoned predecessor. That is intended, but
it left history ambiguous: both rows were status 'failed', tellable apart only
by matching on the reason string in error.

Reclaim now closes the abandoned row as 'dead' instead of 'failed'. 'failed'
means the handler ran and reported an error; 'dead' means no result was ever
reported. Nothing reads task_runs.status today - RefreshJobStatus aggregates
over tasks - and the column is a plain STRING with no constraint, so no
migration is needed.

The non-uniqueness of (task_id, attempt_number) is now stated everywhere someone
might assume otherwise: a Conventions bullet and the status enum in
INSTRUCTIONS.md, doc comments on ClaimQueuedTask, InsertTaskRun and
ReclaimStaleRunningTask, and a comment above the task_runs DDL - the place
someone stands when about to add a unique index.

reclaim_dead_run_test.go asserts both halves: the abandoned run is 'dead' with
finished_at and error set, attempt rolls back, the re-claim re-issues the same
number, and inserting the retry's run under it succeeds. That last assertion is
what fails if anyone later adds UNIQUE (task_id, attempt_number).

Runs reclaimed before this change still read 'failed' and are not backfilled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TestIntegration_SubmitDelayedTask asserted on ReconcileOnce's return value
and the ready list's length. Both aggregate over every due task in the
database, which is shared with the rest of the suite and retains rows from
earlier runs: measured on consecutive runs the count was 111, then 112, then
113, of which this test's task was one. The assertion passed on the strength
of unrelated leftovers and could fail for reasons having nothing to do with
delayed scheduling.

Assert instead that this task id reaches the ready list, and poll to a
deadline rather than sleeping a fixed 3s and reconciling exactly once: run_at
is set from the test process's clock but due-ness is decided by the
database's. On timeout report status, scheduled_at and now(), so a real
regression does not read as a slow machine. Also assert the enqueue happens
exactly once and clears the scheduled ZSET, which exercises the pending
marker suppression this test did not cover before.

Checked as a strengthening rather than a rewrite: with a backlog exceeding
reconcileBatch and the ZSET path disabled, the old assertions pass (n=2,
entirely from leftovers) while the new one fails.

Both tests that submit now clean up their job row and Redis keys. They leaked
one queued, due task per run - the pollution the old count assertions
depended on - plus a ready list, which has no TTL and so never expired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…urface

The spec described the pending marker as "Released when a worker successfully
claims the task". That sentence was the deadlock: it names only the happy path,
so the two paths that end a delivery without a claim - a failed claim, and a
reclaim abandoning an in-flight delivery - read as having no release obligation,
which is exactly how tasks ended up queued, due, and unreachable.

The marker is now documented as a lock on the delivery that whoever ends a
delivery must release, with all three release paths listed, in INSTRUCTIONS.md
and on ReleasePending itself. ReleasePending also notes that failure paths must
pass a context the failure has not already cancelled.

TryReservePending gains the TTL rationale, because "raise the TTL to be safe" is
the intuitive move and the wrong one: the TTL is a backstop for a release that
never happened, expiring early only costs a duplicate BRPOP that ClaimQueuedTask
rejects, and expiring late stalls a task for its full duration. INSTRUCTIONS.md
also states the asymmetry behind this: Redis claiming there is more work than
there is self-corrects at claim time, but a marker claiming a delivery is in
flight suppresses recovery instead of triggering it.

EnqueueTaskIDs advertised "after TryReservePending" while ignoring the
reservation result. That is correct on the submit path, where ids are new and
cannot be in flight, but it reads as a dedup guarantee; the comment now says so
and warns against copying it into a re-delivery path.

Gaps found while sweeping, all pre-existing:

- The three-node cluster was undocumented - no mention of
  docker-compose.multinode.yml or the make cluster-* targets anywhere. Added
  the multi-host DSN, port map, and the docker kill failover procedure.
- WORKER_HEARTBEAT_INTERVAL is implemented and validated but appeared in no
  doc and no .env.example.
- Two heartbeats share a name. WORKER_HEARTBEAT_INTERVAL is the per-process
  workers-table registry beat; the lease heartbeat that decides whether a
  running task gets reclaimed is Options.HeartbeatEvery, 5s, set in code, and
  cmd/worker passes the zero value so no environment variable reaches it.
- GET /v1/workers and its active_since parameter were in neither doc.

Verified against a running orchestrator rather than by reading: the endpoints
answer 200/200/400 as documented, /metrics exposes the orchestrator_reconcile_*
counters, HeartbeatEvery defaults to 5s. Comment-only in the Go files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pravit-amp
pravit-amp merged commit adfcd42 into main Aug 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant