Skip to content

Re-check review state inside the downgrade apply transaction - #2376

Merged
jakebromberg merged 3 commits into
mainfrom
orchestrator/station-signup-re-check-review-state-inside-the-do
Sep 6, 2026
Merged

Re-check review state inside the downgrade apply transaction#2376
jakebromberg merged 3 commits into
mainfrom
orchestrator/station-signup-re-check-review-state-inside-the-do

Conversation

@jakebromberg

@jakebromberg jakebromberg commented Sep 6, 2026

Copy link
Copy Markdown
Member

Closes #2373

What

applyDowngrades re-checked only member.role = 'dj' inside its transaction. Neither auth_user.self_signup_reviewed_at nor auth_user.self_signup_downgraded_at was re-evaluated, so the plan-phase snapshot — taken before the SES round trip — was trusted for the write. A review landing during that notify window was still followed by a downgrade, and the marker stamp made it terminal: the account leaves the digest cohort once reviewed, so nothing ever surfaced the incorrect flip. #2362 makes the approve endpoint the first real writer of self_signup_reviewed_at, which is why this lands first.

How

The apply transaction now re-selects the auth_user row FOR UPDATE and aborts the role flip and the marker stamp when either column is non-NULL, before touching anything. The row lock is what closes the race rather than just narrowing it: db.transaction() runs at READ COMMITTED, so a bare re-select would still be check-then-act — a review committing between the re-select and the auth_member UPDATE is invisible to both writes, since the UPDATE's guard is role = 'dj' and the marker stamp's is self_signup_downgraded_at IS NULL, and a review touches neither. With FOR UPDATE, a concurrent writer of self_signup_reviewed_at either waits on the lock or holds it first, and READ COMMITTED's EvalPlanQual recheck re-applies the IS NULL predicates against the newly committed row version once the lock is granted, so the row filters out and the transaction aborts. A re-select rather than folding the columns into the guarded UPDATE's predicate, because those columns live on auth_user and the role flip writes auth_member — a different table. The docstring records the resulting lock order (auth_user first, then auth_member) so #2362's approve endpoint, which will touch both, adopts the same order and does not deadlock against this job. An aborted flip reports through the existing applied.raced accounting: a warning, not a failure, and the run still exits 0. The downgrade_raced log line is reworded, since "matched no auth_member row" is no longer the only cause. Kill switch, on-air guard, and the plan and digest semantics are unchanged — only the apply step moves.

Tests

Unit: the re-check clause is pinned by shape (both isNulls against the auth_user table object), and both abort paths — review landed, marker stamped by a competing run — assert no role flip, no marker, and a raced outcome. Every pre-existing apply test now steers the re-check to "still pending" first, so the happy-path invariants still read as before.

Integration (real Postgres): a review stamped between plan and apply produces no flip and no marker; a concurrent run's role flip plus marker produces no double work and no overwrite of that run's marker instant; and the discriminating case — marker stamped but the account re-promoted back to dj, where the WHERE role = 'dj' guard alone would happily flip a second time — aborts as raced with the re-promotion and the original marker instant both intact. All assert the auth_user.role sentinel stays untouched — the invariant this whole epic turns on.

jakebromberg and others added 3 commits September 6, 2026 13:19
The plan phase reads `self_signup_reviewed_at IS NULL AND self_signup_downgraded_at IS NULL` once, and the SES round trip then sits between that read and the write, so a review landing during the notify window was still followed by a downgrade — and because the marker stamp is terminal and the account leaves the digest cohort once reviewed, nothing ever reported the incorrect flip. `applyDowngrades` now re-selects the `auth_user` row inside its transaction and aborts both the role flip and the marker stamp when either column is non-NULL, so a manager review or a competing run of this same job that lands mid-window wins instead of losing. An aborted flip joins the existing `applied.raced` accounting — a warning, not a failure, and the run still exits 0 — with the `downgrade_raced` log line reworded to cover the broadened cause. Kill switch, on-air guard, and the plan and digest semantics are untouched; only the apply step changes.
…race guard is atomic

The in-transaction re-check added for BS#2373 was a bare SELECT, and `db.transaction()` runs at READ COMMITTED, so it was check-then-act rather than an atomic check-and-act: a review committing between the re-select and the `auth_member` UPDATE is invisible to both of the writes that follow, because the UPDATE's own guard is `role = 'dj'` and the marker stamp's guard is `self_signup_downgraded_at IS NULL`, and a review touches neither column. The window was narrower than trusting the plan-phase snapshot, but it still landed on the reviewed-AND-downgraded terminal state the re-check exists to prevent.

Appending `.for('update')` to the re-select closes it. A concurrent writer of `self_signup_reviewed_at` either waits on the row lock or holds it first, and when it holds it first READ COMMITTED's EvalPlanQual recheck re-applies the `IS NULL` predicates against the newly committed row version once the lock is granted -- the row filters out, and the transaction aborts into `raced` instead of flipping the role.

The docstring now records the lock order this creates: `auth_user` first, then `auth_member`. The BS#2362 approve endpoint will be the first real writer of `self_signup_reviewed_at` and touches both tables, so it has to take them in the same order or the two deadlock.

Adds a discriminating integration case: the marker stamped but the member role re-promoted back to `dj` -- production-reachable when a concurrent run downgrades and stamps, a manager re-promotes, and this run's stale apply lands. The `WHERE role = 'dj'` guard alone cannot tell that apart from a fresh downgrade and would flip a second time; the re-check aborts, leaving the re-promotion and the original marker instant intact. The unit abort test now asserts the strong form (`mockUpdate` never called at all) that its sibling review-landed test already used, and the mocked select chain exposes `.for()` off `.limit()` so both shapes resolve to the same queued result.
…dlock bound

The unit shim's .for() is now a jest.fn and the re-check spec asserts it was called with 'update' -- without this, deleting the lock (or weakening it to for('share'), which does not conflict with a reviewer's row update) would reintroduce the plan->apply race with a fully green suite, since the integration test is single-session and discriminates only the predicate re-check.

The lock-order docstring now records what bounds the wait when a future both-table writer takes the locks in the wrong order: the connection-level statement_timeout (5s default, no override in this job) throws, landing the account in failed with a non-zero exit for retry next run, rather than silently dropping into raced.
@jakebromberg
jakebromberg merged commit 0ceb278 into main Sep 6, 2026
6 checks 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.

Station signup: re-check review state inside the downgrade apply transaction

1 participant