You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(locks): renew a held lock while its work runs, so a slow pass cannot be claimed out from under (#9467) (#9507)
* fix(locks): renew a held lock while its work runs, so a slow pass cannot be claimed out from under (#9467)
The transient-lock TTLs were sized when the actuation lock covered a short plan-and-execute
section. #9013 moved the claim to BEFORE maybePublishPrPublicSurface, so the 600s actuation lock
now spans the whole publish -> AI review -> maintain unit -- and the AI review alone can exceed
it (3 attempts x up to 600s per attempt per model, with an operator override clamped at 30
minutes PER ATTEMPT). When the TTL lapsed mid-work the holder was never told: a second worker
claimed the same PR and both actuated it, producing a duplicate close plus a duplicate
explanation comment and duplicate decision records, or a losing pass's placeholder republishing
over a real verdict -- the exact thrash #9013 existed to eliminate. The AI-review lock has the
same shape: its 1800s TTL is exactly one model's max-effort retry budget, so a second reviewer
or a per-repo timeout override runs past it and the two passes' ai_review_cache upserts race.
Renewal is compare-and-extend, mirroring the compare-and-delete release and for the same
reason: a holder whose key already lapsed and was re-claimed must NOT extend the new owner's
lock. It learns it lost instead, so a caller can abort before mutating anything.
Fails open throughout, matching every other operation in this module: an adapter without
renewIfValue, a fail-open claim with no token, or a throwing renewal all leave the lock on its
original fixed TTL -- never worse than before, and never a reason to block real work. A
transient renewal error is deliberately NOT treated as losing the lock, since that would abort
work that is still legitimately holding it.
The end-to-end tests drive claim -> heartbeat -> competing claim against a cache that genuinely
expires keys, so the invariant asserted is the one that matters -- a competing pass stays
refused while the holder works -- and the paired regression shows the same lock lapsing without
the heartbeat.
* refactor(locks): drop an unreachable pre-await stopped guard in the heartbeat (#9467)
stop() clears the interval, so a callback can never START after it -- the guard before the first
await was dead. The check that matters is the one after the renewal await, where stop() can
genuinely have landed mid-call, and that stays.
0 commit comments