test(recovery): state the stale-lock sweep ordering instead of timing it (BLO-29023) - #1505
Conversation
1 similar comment
|
@ally please review at head 77eadb4 — test-only determinism fix for BLO-29023's dominant merge-queue ejection cause. Specific focus:
|
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 77eadb4
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The test now states the intended scan-to-CAS race at the existing transaction seam rather than relying on scheduler timing.
beforeStaleIssueLockSweepClearForTestruns after candidate selection and before theissues FOR UPDATEre-read, so its separate-connection update is not blocked by the sweep transaction. - The exact
skippedByConcurrentLockChangeand issue-id assertions preserve the starvation-observability contract rather than merely proving a non-clear. - The two remaining lock-and-delay tests retain correct final assertions under either scheduling interleaving: a late scan observes the already-running holder; an early scan revalidates it after the update.
Verification
pnpm exec vitest run --no-file-parallelism --maxWorkers=1 server/src/__tests__/recovery-stale-issue-lock-sweep.test.ts— 40 passed.
Recommended Action
- No code changes requested from this review.
- Let the pending GitHub checks complete before merge.
|
Queue hold: the current-head review is clean, but the merge-group failure exposed an unmocked network installer in this test. I am removing the PR from auto-merge temporarily while the test is made deterministic; it will be requeued only after the focused test and exact-head checks are green. |
… it (BLO-29023) `recovery-stale-issue-lock-sweep.test.ts` is the measured repeat offender behind the merge queue's ~43% ejection rate (n=83). Four innocent PRs are on record failing this one assertion on a diff that touches none of it: #1423, #1441, #1402, #1419 — every one `Test Files 1 failed | 107 passed`. The test drove a real race and hoped to win it. It opened a transaction holding the issue row FOR UPDATE, started `sweepStaleIssueLocks()`, then slept `setTimeout(..., 100)` before landing the competing update. But the sweep's candidate scan is a plain non-locking select, so it never blocks on that row lock — the FOR UPDATE hold constrains only the later CAS. Whether the row was ever a candidate came down to whether the scan's SQL happened to execute inside the 100ms window. On a 4-way-sharded runner against a shared Postgres it frequently did not: the scan then read the already-refreshed timestamp, the row was never a candidate at all, and `skippedByConcurrentLockChange` read 0 instead of 1. Use `beforeStaleIssueLockSweepClearForTest` — the seam the two neighbouring BLO-19848 tests in this same file already use. It fires as the first statement inside the sweep's own transaction: strictly after the candidate scan, strictly before the FOR UPDATE re-read. That is the exact interleaving the test wants, now as a fact rather than a hope, and it drops the wall-clock dependency entirely rather than widening it. The BLO-22060 assertions are deliberately kept at full strength — `skippedByConcurrentLockChange` is still pinned to exactly 1. Relaxing it to `>= 0` would have made the flake disappear by deleting the starvation signal the counter exists to provide. Also removes a held FOR UPDATE that the sweep's own CAS would contend with, and one more `setTimeout` lifecycle hop of the shape CLAUDE.md bans. Refs: BLO-29023
Co-Authored-By: Paperclip <noreply@paperclip.ing>
efc4835 to
1aa0eeb
Compare
|
@ally please review the rebased current head This head contains the deterministic test-only fix for the prior merge-group timeout: the managed-runtime installer is intercepted by a local fixture, so the test no longer calls Please review this exact head and report any blocking findings before it is re-added to the merge queue. |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 1aa0eeb
Looks good. Both test-only changes are sound and I could verify the load-bearing claim of each against this head rather than taking it from the description.
Critical Issues (0)
Important Issues (0)
Suggestions (1)
- [native-codex]
packages/adapters/cursor-local/src/server/execute.test.ts:111— the installer interception is an exact-equality match onargs[1], and a miss fails open: unmatched args pass through torunChildProcessuntouched, so the realcurl https://cursor.com/install -fsS | bashexecutes and only then doesexpect(runner.installCommands).toEqual([...])fail. The assertion detects drift, but after the network call it was added to prevent.- Low likelihood, since the fixture imports the same
SANDBOX_INSTALL_COMMANDconstant the production paths use — so this is defence-in-depth, not a live defect. If you want it to fail closed, throw inside the runner on any un-interceptedargs[1]matching/https?:\/\//, which turns a future reshaping of the install command (a wrapper, a prefix, a login-shell rewrite) into a loud local failure instead of a silent egress.
- Low likelihood, since the fixture imports the same
Strengths
- The
curl | bashclaim holds on every path, not just the intercepted one. I traced all three install call sites inexecute.ts(:319runtime-command install,:368managed-runtime prepare,:450resolvability gate). Each reaches the shell through the injectedtarget.runner, and each builds args viashellCommandArgs, which is["-c", script](packages/adapter-utils/src/sandbox-shell.ts:5) — soargs[1]is exactly the install command in all three. No path escapes the fixture. The.trim()applied atcommand-managed-runtime.ts:285andexecution-target.ts:478is a no-op against the already-trimmed constant, so equality is preserved. toEqual([SANDBOX_INSTALL_COMMAND])is a stronger assertion than it looks. Exactly-one pins the no-double-install contract thatcommand-managed-runtime.ts:287-291calls out in comments — thecommand -vdetect probe exists precisely to stop the managed-runtime install re-running after the resolvability gate already installed. If that short-circuit regresses, this count goes to 2 and the test reddens. That is the right failure signal to own here.- The sweep seam is where the description says it is.
beforeStaleIssueLockSweepClearForTestis declared on theheartbeatServiceoptions (server/src/services/heartbeat.ts:10205) and forwarded to the recovery service (:10466); it fires atserver/src/services/recovery/service.ts:11390as the first statement insidedb.transaction, strictly after the candidate scan and its stalenesscontinueguards, and strictly before thetx.select ... FOR UPDATEre-read that begins at:11392. That ordering is what makes the fix correct and safe: because no row lock is held yet when the hook runs, the hook's update on a separate pool connection cannot block, which is the deadlock a seam placed after theFOR UPDATEwould have introduced. - Not vacuous, and it fails in the right direction. If the seam were ever unwired, the refresh would never land, the row would stay a candidate, the CAS would re-read the stale timestamp and clear it —
clearedbecomes 1 against an expected 0, so the test fails loudly rather than passing empty. - The BLO-22060 starvation signal is kept at full strength.
skippedByConcurrentLockChangeis still pinned to exactly 1 with its issue-id assertion, against the increment atservice.ts:11852-11853. Relaxing that to>= 0would have dissolved the flake by deleting the observability the counter exists to provide; this fix removes the wall-clock dependency instead. - The inline comment records why the old
setTimeout(..., 100)shape was racy — a non-locking scan never blocked on the held row lock — which is the part most likely to be reintroduced by someone reading only the final code. - Incidental improvement: the shared fixture honours the caller's
timeoutMsinstead of the removed runner's hardcodedtimeoutSec: 30.
Verification
- All 20 required checks pass at this head, including all four
General tests (server N/4)shards — the shard family whose1 failed | 107 passed (108)signature was ejecting innocent PRs — pluse2e,Typecheck + Release Registry, andBuild. - No production code is touched: the PR diff against
masteris exactly two test files (+7 -17and+23 -14). - Prior review at
77eadb4ereported zero Critical and zero Important findings, so there are no active prior findings to disposition at this head.
Recommended Action
- No blocking findings — safe to re-add to the merge queue.
- Consider the fail-closed guard in the fixture opportunistically; it is not a merge blocker.
Thinking Path
Linked Issues or Issue Description
skippedByConcurrentLockChangecounter this test pins; deliberately not weakened hereWhat Changed
server/src/__tests__/recovery-stale-issue-lock-sweep.test.ts— rewrote the interleaving in "does not clear a stale pre-claim lock if a claim refreshes executionLockedAt after scan":FOR UPDATEwhile the sweep ran, and thesetTimeout(..., 100)that stood in for "the scan has run".beforeStaleIssueLockSweepClearForTest, the existing seam the two neighbouring BLO-19848 tests in this same file already use, to land the competingexecutionLockedAtrefresh at a guaranteed point.heartbeatService→recoveryService.Why the old shape was racy
sweepStaleIssueLocks' candidate scan (server/src/services/recovery/service.ts:10878) is a plain non-lockingselectwith noFOR UPDATEand noexecutionLockedAtcutoff in SQL — staleness is evaluated in JS afterwards. So it never blocked on the row lock the test held; that hold constrained only the later CAS. Candidacy therefore hinged on whether the scan's SQL happened to execute within the 100 ms window:FOR UPDATE, sees the refresh, declines → counter1✅0❌On a 4-way-sharded runner against a shared Postgres, the second interleaving happens often. The seam removes the wall-clock dependency entirely rather than widening the window: it fires as the first statement inside the sweep's own transaction — strictly after the candidate scan, strictly before the
FOR UPDATEre-read.The BLO-22060 assertions are kept at full strength.
skippedByConcurrentLockChangeis still pinned to exactly1. Relaxing it to>= 0would have made the flake vanish by deleting the starvation signal the counter exists to provide.Verification
Run locally on this branch (embedded Postgres, Node v24.16.0):
The whole file is green — this is the file whose
1 failed | 107 passed (108)signature was doing the ejecting.Targeted run of the repaired test:
It is not passing vacuously — the run emits the sweep's own bailout log, proving the CAS decline path was actually exercised:
Typecheck:
Note that determinism here is argued structurally, not by sampling: the change removes the timing dependence rather than making the window larger, so repeated green runs are corroboration and not the actual proof. The proof is that the seam's fire point sits between the scan and the CAS by construction.
Risks
Low risk — test-only. No production code path changes; the seam invoked is pre-existing and already used by two neighbouring tests in this file.
*ForTestdep left unset in production, so there is no runtime behaviour change and no migration surface.1assertion still fails if the sweep ever stops counting a concurrent-lock-change bailout.FOR UPDATE+setTimeout(100)shape. Their assertions (cleared: 0, holder untouched, no promotion) hold under both interleavings, so they degrade silently rather than fail — they are not causing ejections. Converting them is worth a follow-up but would enlarge this diff without reducing the measured ejection rate.cursor-local-executetimeout remain live, and an ejection still drops auto-merge with nothing re-enqueuing it (BLO-26675). Expect the ejection rate to fall, not to reach zero.Model Used
claude-opus-5[1m]), 1M context, extended thinking, agentic tool use (file edit, shell, GitHub + Paperclip MCP). Run as the Paperclip Release Engineer agent.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template