fix(sync-lock): key the lock per job so jobs stop starving each other - #879
Conversation
Every scheduled job called acquireLock with its own syncType, and the helper ignored the argument and looked up id "global". So all six mutually excluded each other through one row. Because the scheduler arms every job with the same startup delay, the 5/10/15/ 30-minute intervals stay phase-locked and the same jobs lose every race. Over one pod's lifetime: stale-work 9 x 200, 5 x 409 groomer 7 x 200, 0 x 409 (uses groomer-lock.ts, never on this lock) sync 5 x 200, 0 x 409 pr-followup 0 x 200, 5 x 409 reconcile 0 x 200, 3 x 409 pr-followup and reconcile never ran once, so no PR-fix work was ever queued and PRs sat on reviewer feedback with nothing acting on them. Key per job instead. No migration: SyncLock.id is a free-text primary key and groomer-lock.ts already keeps its own "groomer" row in this table, so the multi-row pattern is established rather than new. scheduled and manual deliberately keep sharing a key. Both run the full issue sync and makePrismaIssueStore().createIssue is a plain create, not an upsert, so concurrent runs race into P2002 — the #333 conflict is real. Everything else mutates disjoint tables or writes conditionally, and the unlocked heartbeat, webhook and prune-closed paths already run those same interleavings in production today. The opt-in automation block in /api/sync/scheduled now takes the automation key, since the split would otherwise let a manual {automation:true} call race a standalone automation sync into their shared upserts. It skips rather than fails so a lost race cannot take the issue sync down with it. releaseLock keys on the run id alone, so no caller needs to know its key. Verified against real Postgres, not mocks: the new starvation regression acquires all five distinct keys concurrently and asserts five winners and zero rejections. Mutation-checked by collapsing the map back to "global", which fails it with "expected length 5 but got 1" — one winner, four starved. 2419 passed. Claude-Session: https://claude.ai/code/session_01YSuDvZq9ncvyX85Uzx3cQh
There was a problem hiding this comment.
AI Automated Review
Full PR review.
Analysis engine: MiniMax-M3@https://litellm.jory.dev/v1 (anthropic) — escalated (fast_low_confidence)
Recommendation
Approve. This PR is a targeted, well-reasoned fix to a real starvation bug in the sync-lock layer. The change is internal — it touches a shared library plus the /api/sync/scheduled route handler — and does not alter route mounting, HTTP method exposure, or authentication. Both required checks for public_route_changes are satisfied by inspection of the diff and surrounding context.
Change-by-change findings
src/lib/sync-lock.ts
- Removes the hard-coded
LOCK_ID = "global"constant and replaces it with a per-SyncTypemap (LOCK_KEY_BY_TYPE). Two types (scheduled,manual) intentionally share theissue-synckey because they write the same Issue rows via a non-upsertingcreate(referenced issue PR 333 /P2002); the remaining five types each get their own key. claimableWhere()now takes the lock id as an argument so the atomicupdateManyclaim, the rawINSERT ... ON CONFLICT, and the post-insert retry all target the per-type row rather than a single shared row. This is the actual fix.releaseLock(runId)now keys onsyncRunIdalone. The justification (cuid forIssueSyncRunandrandomUUIDfor groomer-lock) is sound:syncRunIdis unique across every key in the table, so a conditional delete-by-run-id cannot free a different holder's row. Verified against the existingsync-lock.test.tscases that assertdeleteManyis called with{ where: { syncRunId: ... } }.- The module-level docblock is rewritten to explain the per-job model and reference the starvation evidence. Matches repository comment standards.
src/app/api/sync/scheduled/route.ts
- The opt-in automation block now
acquireLock("automation")under its own key instead of piggybacking on the issue-sync lock. If the lock is not acquired, the call skips the automation pass (automationSkipped = true) rather than returning a 5xx; the response surfacesautomationSkippedandautomationSkippedReason. The scheduler never setssyncAutomation, so only manual callers reach this branch — consistent with the PR description. - The
releaseLockis in afinallyblock, mirroring the establishedtry/finallypattern used in other sync routes in this codebase. - No new route, no new HTTP method exposure, no change to the request shape.
Test files
src/lib/sync-lock.test.tsandsrc/lib/sync-lock.integration.test.tsupdate existing assertions fromid: "global"to the new keys (mostly"issue-sync", with"automation"used in the stale-reclaim test to exercise a different key). A new regression test "different sync types acquire independently" exercises five concurrent acquisitions across five keys and asserts five winners and exactly five rows — this is the test that, per the description, was mutation-checked by collapsing the map back to"global"and observing exactly the bug's signature. A companion "releasing one key does not free another" test confirms thatreleaseLockscoped tosyncRunIdcannot free a different holder.src/app/api/sync/scheduled/route.test.tsupdates mocks to use"issue-sync"and adds a comment documenting the lock-key mapping; thedeleteManyassertion is updated to key onsyncRunIdonly, matching the new release semantics.
Lock-key mapping rationale
The mapping (scheduled/manual → issue-sync, everything else distinct) is the strongest design choice to scrutinize. The author justifies it with:
scheduledandmanualboth run the full issue sync, andmakePrismaIssueStore().createIssueis a plaincreatenot an upsert, so two concurrent runs race into P2002 (PR 333). Confirmed againstsrc/lib/issue-sync.ts:391(referenced in the PR body).- The other four write to disjoint tables or perform conditional/idempotent writes. The agent heartbeat, PR-followup webhook, and
prune-closedalready exercise those same interleavings unlocked, which is the strongest "this split is safe" evidence available. groomer-lock.tsalready uses the multi-row pattern (src/lib/groomer/groomer-lock.ts:18), so the table is already designed for it. Theprisma/schema.prisma:490idis a free-text@id, confirmed by the schema context in the standards file.
This is a reasonable design and the constraints are documented in the code.
Required checks
verify route access controls are in place
Verified. The only route handler touched is src/app/api/sync/scheduled/route.ts, an existing endpoint. The PR does not modify the route's mounting, its handler signature, or any authorizeRequest / getAuthorizedActor call. The internal addition of an acquireLock("automation") call is gated by the existing syncAutomation request flag and runs only for authenticated callers that already passed the existing checks. Auth posture is unchanged.
check for unintended public endpoints
Verified. No new files under src/app/api/** were created. The only API-handler file in the diff is the pre-existing src/app/api/sync/scheduled/route.ts, which is the same endpoint this PR modifies (it is already exposed; this PR does not introduce it). The other four files are the shared library and test files. No new route.ts is registered, no HTTP method exposure changed.
Standards Compliance
- Code standards (AGENTS.md §Code Standards): API routes continue to return appropriate JSON status codes; the new
automationSkippedblock is reported via JSON; no new secrets are introduced. The change follows the documentedtry/finallylock-release pattern used by other sync routes. - Concurrency / Prisma: No schema change (justified by the free-text
@idand the existing multi-row pattern ingroomer-lock.ts); no foreign-key weakening. - No
agent-specific names in generic docs: the docs added tosync-lock.tsrefer to job categories (pr-followup,reconcile, etc.) and theLOCK_KEY_BY_TYPEmap uses the sameSyncTypeenum already exported from the module — no new agent-specific names introduced. - Env vars: none added or modified.
Tool Harness Findings
The harness produced no tool output ("native tool-calling loop issued no tool calls"). No third-party evidence was gathered; the review is based entirely on the diff and the standards corpus.
CI Status
All CI checks reached success for the head commit: Database migrations, Coverage, Build, Database integration, Tests, Typecheck, Lint, npm audit, smoke, Docker Build (MCP), Docker Build. This corroborates the PR body claim of 2419 passing tests and a clean lint run.
Notes (non-blocking)
- The legacy
"global"row is left in thesync_locktable after deploy. The author flags this explicitly; it has no reader and can be cleaned up in a follow-up. This is intentional and documented. - One minor cosmetic observation (not blocking): in
src/app/api/sync/scheduled/route.ts, the newtry { ... } finally { ... }block is nested inside anif (!automationLock.locked) { ... } else { ... }. The indentation of thetrykeyword (try {) differs from the surrounding}alignment by two spaces inside theelse. This is purely a style nit and matches the rest of the file's indentation; flagging only for completeness.
Summary
"global"row. No migration.The bug
Every endpoint calls
acquireLock(syncType)with its own type, and the helper ignored the argument:So all six jobs mutually excluded each other through one row. The scheduler arms every job with the same startup delay, so the 5/10/15/30-minute intervals stay phase-locked and the same jobs lose every race. Measured over one pod's lifetime:
pr-followupnever completed a single run, which is why no PR-fix work was queued and PRs sat on reviewer feedback with nothing acting on them. It also explains the empty queue looking identical to "no work to do".Why no migration
SyncLock.idis a free-text@id(prisma/schema.prisma:490), andsrc/lib/groomer/groomer-lock.ts:18already keeps its own"groomer"row in this table. The multi-row pattern is established and running in production — that is also whygroomershows0 x 409above. This change extends an existing pattern rather than introducing one.What still shares a key, and why
scheduledandmanualboth map toissue-sync. Both run the full issue sync, andmakePrismaIssueStore().createIssueis a plaincreate, not an upsert (src/lib/issue-sync.ts:391), so two concurrent runs that both see a new issue as absent race into P2002. That is the conflict #333 was written for and it is still real.Everything else mutates disjoint tables or writes conditionally and idempotently. The strongest evidence the split is safe is that three unlocked paths already run those same interleavings in production: the agent heartbeat calls
syncIssuesForReposwith no lock (src/lib/heartbeat.ts:57), the PR-followup webhook writes the samePrFixQueueItemrows unlocked, andprune-closeddoesissue.deleteManyunlocked from the scheduler.Verification, against real Postgres rather than mocks
New starvation regression: acquire all five distinct keys concurrently, assert five winners, zero rejections, and exactly the five expected rows.
Mutation-checked by collapsing the map back to
"global":One winner, four starved — the bug, reproduced. The three same-key contention tests still pass under the mutation, which is what makes the new one meaningful rather than incidental.
Full suite: 2419 passed.
npm run lintclean.Also changed
The opt-in automation block in
/api/sync/schedulednow takes theautomationkey. Without it the split would let a manual{automation:true}call race a standalone automation sync into their shared upserts. It skips rather than fails, and reportsautomationSkipped, so a lost race cannot take the issue sync down with it. The scheduler never sets that flag, so only a manual call reaches it.releaseLockkeys on the run id alone, so no call site needs to know its key. Safe because a run id is either anIssueSyncRuncuid or groomer-lock'srandomUUIDtoken.Notes
"global"row is left orphaned after deploy. Nothing reads it; it can be deleted whenever.