Skip to content

Fix JobCancel returning a stale row to the loser of a concurrent cancel race - #1409

Merged
brandur merged 4 commits into
riverqueue:masterfrom
JackDanger:fix/job-cancel-stale-return
Sep 30, 2026
Merged

brandur merged 4 commits into
riverqueue:masterfrom
JackDanger:fix/job-cancel-stale-return

Conversation

@JackDanger

Copy link
Copy Markdown
Contributor

JobCancel documents "Returns the up-to-date JobRow", but when two callers cancel the same job at the same time, whichever call loses the race returns the job's pre-cancel state — e.g. still scheduled — while the cancel is durably committed. The loser's UPDATE matches zero rows and the CTE falls through to its UNION fallback arm, a plain read that runs under the loser's pre-commit snapshot.

Fix (second commit): make that fallback read take a row lock (FOR UPDATE) so it re-reads past the winner's commit — the same mechanism locked_job already applies at the top of the same statement, so this adds no new locking surface. (Under REPEATABLE READ/SERIALIZABLE a raced cancel becomes an error instead of a stale read; River's own transactions run READ COMMITTED.)

First commit is the failing test: two cancels of one scheduled-far-out job, 20 rounds, each round requiring both callers to observe cancelled with the winner's finalized_at. On current master, ~2–3 rounds of 10 observe the stale row (measured over three -count=10 invocations on darwin/arm64), and all rounds pass with the fix.

Pin the documented "Returns the up-to-date JobRow" guarantee: two callers
cancel the same job at the same moment, twenty rounds, and each loser
must report `cancelled` with the winner's `finalized_at` — not its own
pre-commit snapshot of the row.

These tests fail until the locking fallback read lands.
Signed-off-by: Jack Danger <github@jackcanty.com>
`JobCancel` documents "Returns the up-to-date JobRow". When two cancels
race, the loser's update matches zero rows and the query falls through
to its UNION fallback arm — a plain (non-locking) scan that runs on the
loser's pre-commit snapshot, so the loser is returned e.g. still
"scheduled" though "cancelled" is durably committed.

Lock that fallback read (FOR UPDATE) so it re-reads the committed row:
the same mechanism `locked_job` already applies at the top of the same
statement, so no new lock ordering, wait, or deadlock surface. At
REPEATABLE READ/SERIALIZABLE the locking read turns a raced cancel into
a serialization error instead of a stale row; River runs READ COMMITTED.
Regenerated both Postgres dialects with sqlc; SQLite's JobCancel is a
single UPDATE .. RETURNING and unaffected.
Signed-off-by: Jack Danger <github@jackcanty.com>
Comment thread client_test.go Outdated
client.producersByQueueName[QueueDefault].testSignals.QueueControlEventTriggered.RequireEmpty()
})

t.Run("ConcurrentCancelSingleFinalizerAndFreshReturn", func(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as #1410, could probably use it in the shared driver test suite.

Per review: the test now lives in riverdrivertest and runs under every
driver fixture (pgxv5, both database/sql Postgres dialects, and the
SQLite family). The fixed-wait negative-event assertion does not
survive the move; noted inline. Signed-off-by: Jack Danger <github@jackcanty.com>
@JackDanger

Copy link
Copy Markdown
Contributor Author

Addressed feedback here, too.

@brandur
brandur marked this pull request as ready for review September 30, 2026 18:32
@brandur

brandur commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

Excellent, thanks!

@brandur
brandur merged commit 18eb1e2 into riverqueue:master Sep 30, 2026
12 checks passed
brandur added a commit to JackDanger/river that referenced this pull request Sep 30, 2026
brandur added a commit that referenced this pull request Sep 30, 2026
…ry race (#1410)

* Test `JobRetry` return under a concurrent-retry race

JobRetry documents that a retried job becomes visible on commit of the
retrier's transaction. When two retries race over one finalized row the
CTE gets the database semantics right — the loser's update matches zero
rows after EvalPlanQual re-checks the guards against the winner's
committed version — but the loser is then served by the query's fallback
UNION arm, a plain non-locking re-read running on the loser's statement
snapshot. That snapshot predates the winner's commit, so the loser
receives the stale pre-commit row: still cancelled with finalized_at
set, for a retry that is already committed.

The new subtest forces the interleaving deterministically (winner holds
the row lock in an open transaction; the loser is confirmed parked on a
lock wait via pg_stat_activity before the winner commits), so it fails
on every run rather than probabilistically. A companion fix wraps the
fallback read in a locking subquery, the mechanism the query's own
locked read already relies on.

* Test concurrent `JobRetry` calls see the committed retried row

Pin `JobRetryTx`'s contract that a caller waits on the commit, not on its
pre-commit snapshot: the winner retries inside an open transaction, the
loser's retry is held on the row lock (observed via pg_stat_activity),
then the winner commits — the loser must see the committed `available`
row, not a stale finalized one.

These tests fail until the locking fallback read lands.
Signed-off-by: Jack Danger <github@jackcanty.com>

* Fix stale `JobRetry` return to the loser of a concurrent retry race

Two concurrent `JobRetryTx` calls (or a retry racing a cancel) hit the
same shape the previous `JobCancel` fix: the loser's update matches zero
rows and the CTE falls through to its non-locking fallback arm, which
runs on the loser's pre-commit snapshot — the loser is served e.g. still
`cancelled`/finalized even though the retry just committed and the row
is `available`. EvalPlanQual re-checks the update's guard correctly; only
the fallback read is stale.

Same fix as `JobCancel`: lock the fallback read (`FOR UPDATE`). At
REPEATABLE READ/SERIALIZABLE a raced retry becomes a serialization error
instead of a stale read; River runs READ COMMITTED. Regenerated both
Postgres dialects with sqlc; SQLite's JobRetry is a single UPDATE ..
RETURNING and unaffected.
Signed-off-by: Jack Danger <github@jackcanty.com>

* Tighten retry race test per review

Collapse the test comment to its two load-bearing sentences, bound the
loser's wait and the winner's commit with testtimeout-style budgets, and
receive the loser via the house `riversharedtest.WaitOrTimeout` helper.
Signed-off-by: Jack Danger <github@jackcanty.com>

* Move retry race test to the shared driver suite

Per review: the regression now lives in riverdrivertest (client-level
exercise, Postgres-gate skipping the SQLite variants since their
`JobRetry` is a single non-locking UPDATE) and runs under both pgxv5 and
riverdatabasesql — the latter's regenerated CTE previously had no
regression coverage. Also drops the root copy, and strips the bespoke
5s budget in favor of `riversharedtest.WaitTimeout()`.

Signed-off-by: Jack Danger <github@jackcanty.com>

* Add missing test helper invocation

* Consolidate two very specific driver test helpers in one

Consolidates new test helpers from:

* #1409
* #1410

---------

Signed-off-by: Jack Danger <github@jackcanty.com>
Co-authored-by: river-rs verifier <verifier@river-rs.local>
Co-authored-by: Brandur <brandur@brandur.org>
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.

2 participants