Skip to content

Guard job rescue against stale snapshots - #1373

Open
bgentry wants to merge 1 commit into
masterfrom
bg/fix-stale-job-rescue
Open

Guard job rescue against stale snapshots#1373
bgentry wants to merge 1 commit into
masterfrom
bg/fix-stale-job-rescue

Conversation

@bgentry

@bgentry bgentry commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

A worker can complete or release a job after JobRescuer fetches it but before the rescue update executes. The job can also be claimed by another worker during that gap, returning it to running with a fresh attempt. A stale rescue must not overwrite either outcome.

Require jobs to still be running with attempted_at before the original rescue horizon in both PostgreSQL and SQLite updates. Forward the existing horizon parameter through all drivers so completed jobs and fresh attempts retain their state, errors, metadata, and timestamps, while eligible jobs in the same batch are rescued normally.

Add shared driver regression coverage for completion, immediate retry, and worker interruption between fetch and rescue, along with strict horizon boundaries. Update the unreleased changelog entry to cover reclaimed jobs.

Fixes #1302.

@bgentry
bgentry requested a review from brandur September 8, 2026 00:34
@bgentry
bgentry marked this pull request as ready for review September 8, 2026 00:34
@@ -503,7 +503,8 @@ SET
) + 1
),
state = @state

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This closes the completed-in-the-gap case but not the changed-hands case. Between JobGetStuck and this write a job can fail, go straight to available (JobSetStateErrorAvailable / JobSetStateInterrupted), and be claimed by another worker, so the row is running again with a fresh attempted_at and this predicate still matches. Re-checking the read predicate in full would cover both:

AND river_job.state = 'running'
AND river_job.attempted_at < @stuck_horizon::timestamptz;

StuckHorizon is already on JobRescueManyParams (the new test passes it too); it just isn't forwarded in riverpgxv5's JobRescueMany.

state = @state
WHERE id = @id;
WHERE id = @id
AND state = 'running';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same re-claim gap as the Postgres query; AND attempted_at < cast(@stuck_horizon AS text) here, and forward params.StuckHorizon in the driver loop.

Comment thread CHANGELOG.md Outdated

### Fixed

- Fixed `JobRescuer` overwriting jobs that complete or otherwise leave the running state after being fetched for rescue, preserving their state, errors, metadata, and timestamps across PostgreSQL and SQLite drivers. [Issue #1302](https://github.com/riverqueue/river/issues/1302).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the horizon check goes in, worth a clause that a job re-claimed by another worker during the rescue window is also left alone. "Leave the running state" reads as if re-entering it is not covered.

@sashu2310

Copy link
Copy Markdown

Small follow-up, not blocking: runOnce bumps NumJobsRetried / NumJobsDiscarded / NumJobsCancelled before the write, so with rows now skipped inside the UPDATE the logged counts can overstate. Returning ids from JobRescueMany would let them reflect what actually changed.

A worker can complete or release a job after the rescuer fetches it but
before the rescue update executes. The job can also be claimed again,
leaving it running under a new worker when the stale rescue arrives.

Require jobs to still be `running` with `attempted_at` before the
original rescue horizon in both PostgreSQL and SQLite updates. Forward
the horizon through every driver so stale rescues preserve completed
jobs and fresh attempts, including their errors, metadata, and
timestamps.

Add shared driver coverage for completion, immediate retry, and worker
interruption between fetch and rescue, plus strict horizon boundaries
and mixed batches containing eligible jobs. Document the fix in the
changelog.

Fixes #1302.
@bgentry
bgentry force-pushed the bg/fix-stale-job-rescue branch from 0baef16 to 20b7323 Compare September 9, 2026 14:58
@bgentry bgentry changed the title Prevent stale rescue from overwriting finished jobs Guard job rescue against stale snapshots Sep 9, 2026
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.

JobRescuer can overwrite a job that completed after the stuck-job fetch

2 participants