Skip to content

fix(locks): renew a held lock while its work runs, so a slow pass cannot be claimed out from under (#9467) - #9507

Merged
JSONbored merged 2 commits into
mainfrom
fix/lock-renewal-heartbeat
Jul 28, 2026
Merged

fix(locks): renew a held lock while its work runs, so a slow pass cannot be claimed out from under (#9467)#9507
JSONbored merged 2 commits into
mainfrom
fix/lock-renewal-heartbeat

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Summary

Keeps a held lock alive for as long as its owner is actually working, instead of betting the work finishes inside a fixed TTL.

Closes #9467

This is the holder side of the lock epic. The contender side and the lifecycle edges are #9506. It is deliberately its own PR: it changes the primitive every actuation path depends on, and getting it wrong causes double-merges rather than preventing them — so it carries invariant + regression coverage rather than being bundled to hit an issue count.

The defect

The TTLs were sized when the actuation lock covered only a short plan-and-execute section. #9013 moved the claim to before maybePublishPrPublicSurface, so the 600s actuation lock now wraps the entire publish → AI-review → maintain unit — and the AI review alone can exceed it: 3 attempts × up to 600s per attempt per model, with an operator override clamped at 1,800,000 ms per attempt (#8458). At default medium effort, one timeout-retry on each of two reviewers is already 720s of LLM time before grounding, RAG, enrichment, publish and the entire maintenance half.

When the TTL lapsed mid-work, the holder was never told. A second worker claimed the same PR and both actuated it:

The executor's freshness check is read-then-act, so both passes read "open" within the same seconds and neither can detect the other.

The AI-review lock has the same shape at a different scale: its 1800s TTL is exactly one model's max-effort retry budget (3 × 600s), so any second reviewer, fallback chain, or per-repo claudeTimeoutMs override runs past it. A second pass then fires a duplicate LLM call and the two passes' ai_review_cache upserts race — last writer wins, so two contradictory verdicts can alternate across passes at an unchanged head.

The fix

A startLockHeartbeat primitive that re-asserts the TTL every TTL/3 (two chances to recover from a transient failure before a lapse), wired into both long-hold call sites and stopped in the same finally that releases the lock.

Renewal is compare-and-extend (renewIfValue), mirroring the existing compare-and-delete release and for exactly the same reason: a holder whose key already lapsed and was re-claimed by someone else must not extend the new owner's lock. It learns it lost instead, via onLost.

The queue proved this shape for its own processing lease in #9023; this is the same idea for transient locks.

Fail-open posture, unchanged from the rest of the module

The timer is unref'd so a heartbeat can never hold the process open.

Validation

  • npx tsc --noEmit -p tsconfig.json — clean
  • 223 passed across transient-locks, selfhost-redis-cache, held-lock-registry, queue-3

Invariants (the properties that must hold, not just "the function was called"):

  • A holder still working past its TTL keeps the lock.
  • A competing claim stays refused for the whole time the holder works — driven end-to-end (claim → heartbeat → competing claim) against a cache that genuinely expires keys, so a lapse would be observable rather than simulated.
  • Stopping halts renewal, so a completed job never extends a key it released.
  • Once the holder stops, the lock lapses normally and the next pass can claim it.
  • Releasing while the heartbeat ran still frees the lock immediately.
  • The default interval is sub-TTL, and a tiny TTL is floored so it cannot spin the event loop.

Regressions:

  • A holder that lost the key is told, stops renewing, and the new owner's claim is untouched — with a paired test asserting no further renewals after onLost.
  • Without a heartbeat the same lock lapses and a second pass claims it — the double-actuation window this PR closes, pinned so the fix cannot silently regress.
  • Adapter-level: renewIfValue extends only on a token match, returns false for a missing key so a stale holder learns it lost, and does not resurrect a released lock.

The fakeRedis test double was upgraded to model TTLs and to distinguish the two compare-and-act Lua evals by the command they call, so ownership is genuinely exercised rather than assumed.

Merge-order note

This touches lines adjacent to #9506 in ai-review-orchestration.ts (both are around the registerHeldLock call). Both are branched from main, so whichever merges second will need a trivial conflict resolution there — flagging so it is expected rather than surprising. No logical conflict: #9506 makes the registry token-scoped, this adds the heartbeat beside it.

…not 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.
@loopover-orb

loopover-orb Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Warning

⏸️ LoopOver review result - manual review recommended

Review updated: 2026-07-28 01:59:10 UTC

7 files · 1 AI reviewer · no blockers · CI green · clean

⏸️ Suggested Action - Manual Review

Review summary
This PR adds a compare-and-extend heartbeat that periodically renews the PR-actuation and AI-review transient locks while their underlying work runs, replacing the previous fixed-TTL bet with a live-extension model backed by a new atomic Redis renewIfValue Lua op. The wiring is traced end-to-end: startLockHeartbeat is invoked at both call sites (reReviewStoredPullRequest and maybePublishPrPublicSurface) and stopped in the corresponding finally blocks alongside the existing release calls, and the fail-open posture (missing adapter, null token, throwing renewal) is preserved and covered by dedicated tests. The redis-cache renewIfValue is correctly implemented as a single atomic Lua eval (GET+EXPIRE) mirroring the existing releaseIfValue compare-and-delete pattern, avoiding the TOCTOU race a separate GET+EXPIRE pair would introduce. Test coverage is thorough, including an end-to-end regression test demonstrating the pre-fix double-claim bug against a real-expiry fake cache.

Nits — 5 non-blocking
  • src/queue/transient-locks.ts: the heartbeat interval and lock TTLs (600, 1800, floor 1000ms) are magic numbers scattered across files; consider centralizing them or at least commenting the relationship (e.g. AI_REVIEW_LOCK_TTL_SECONDS/3) in one place.
  • codecov/patch failed at 96.15% vs 99% target — worth checking which changed branch is uncovered (likely the `timer.unref` guard or the adapter-without-renewIfValue branch in processors.ts) before merging.
  • src/queue/ai-review-orchestration.ts: startAiReviewLockHeartbeat's `onLost` callback is accepted but not wired to any caller-supplied abort logic at either call site in the diff — confirm a lost-lock mid-review is actually surfaced/logged somewhere, or that silently continuing after loss is the intended fail-open behavior.
  • Add a brief note in maybePublishPrPublicSurface's heartbeat call site (processors.ts:11143) about what happens if onLost fires without a handler, since the current diff omits an onLost callback there while the module capability exists.
  • Consider extracting the interval-derivation formula (ttlSeconds*1000/3, floored at 1000ms) into a small named helper/constant so the '3' and '1000' aren't unexplained magic numbers per the static scan.

Decision drivers

  • ✅ Code review — No blockers (1 reviewer)
  • ✅ Gate result — Passing (No configured blocker found.)
Context & advisory signals — never blocks the verdict
Signal Result Evidence
Linked issue ✅ Linked #9467
Related work ✅ No active overlap found No same-issue or scoped active PR overlap found.
Change scope ✅ 20/20 Low review scope from cached public metadata (1 linked issue).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 14 registered-repo PR(s), 13 merged, 334 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 14 PR(s), 334 issue(s).
Improvement ✅ Minor risk: clean · value: minor
Linked issue satisfaction

Partially addressed
The PR delivers the core renewal primitive (compare-and-extend renewIfValue, startLockHeartbeat, wiring into both the actuation and AI-review lock call sites, finally-block cleanup, and strong test coverage), directly satisfying most of the issue's requirements and tests. However it explicitly defers the SubmissionLock DO path parity, the 'onLost abort before mutation via #9465 deferral' wiring, t

Review context
  • Author: JSONbored
  • Role context: owner (maintainer lane)
  • Public audience mode: oss maintainer
  • Lane context: Repository is configured for direct PR review.
  • Public profile languages: Python, TypeScript, Ruby, Go, MDX, Shell, Solidity, JavaScript
  • Official Gittensor activity: 14 PR(s), 334 issue(s).
  • PR-specific overlap: none found.
Contributor next steps
  • Start here: Treat this as maintainer-lane context rather than normal contributor-lane activity.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.
🧪 Chat with LoopOver

Ask LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.

  • @loopover ask <question> answers contribution-quality Q&A with source citations and freshness.
  • @loopover chat <question> answers in natural prose from cached decision-pack facts via local inference (maintainer/collaborator; read-only).
  • A plain-language @loopover mention with a real question is routed to the closest matching read-only command automatically — no exact syntax required.

Full command reference: https://loopover.ai/docs/loopover-commands

🧪 Experimental — new and may change.

Decision record
  • action: hold · clause: success
  • config: 279216eee5b1deb954bac755792fc2d85ccb35a25c9261da6c37baadf102328c · pack: oss-anti-slop · ci: failed
  • record: 10608d8a5b5531fbf16e9e388f7b516f97e3bcae2800306b916c47e6f9e189ba (schema v5, head 00f5f97)

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.

  • Re-run LoopOver review

@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.65%. Comparing base (0f6c6b8) to head (4d9736e).
⚠️ Report is 4 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9507      +/-   ##
==========================================
- Coverage   89.55%   88.65%   -0.90%     
==========================================
  Files         843      843              
  Lines      110073   110097      +24     
  Branches    26194    26196       +2     
==========================================
- Hits        98573    97606     -967     
- Misses      10238    11520    +1282     
+ Partials     1262      971     -291     
Flag Coverage Δ
backend 93.62% <100.00%> (-1.64%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/queue/ai-review-orchestration.ts 98.79% <100.00%> (+<0.01%) ⬆️
src/queue/processors.ts 94.82% <100.00%> (+<0.01%) ⬆️
src/queue/transient-locks.ts 100.00% <100.00%> (ø)
src/selfhost/redis-cache.ts 97.36% <100.00%> (+0.14%) ⬆️

... and 3 files with indirect coverage changes

@loopover-orb loopover-orb Bot added the gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. label Jul 28, 2026
@loopover-orb loopover-orb Bot added the manual-review Gittensor contributor context label Jul 28, 2026
…eartbeat (#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.
@JSONbored
JSONbored merged commit bb21354 into main Jul 28, 2026
7 checks passed
@JSONbored
JSONbored deleted the fix/lock-renewal-heartbeat branch July 28, 2026 02:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. manual-review Gittensor contributor context

Projects

None yet

Development

Successfully merging this pull request may close these issues.

locks: 600s actuation TTL underruns the AI review it wraps since #9013, with no renewal → concurrent double actuation

1 participant