Skip to content

fix(sync-lock): key the lock per job so jobs stop starving each other - #879

Merged
joryirving merged 1 commit into
mainfrom
fix/per-job-sync-locks
Aug 27, 2026
Merged

fix(sync-lock): key the lock per job so jobs stop starving each other#879
joryirving merged 1 commit into
mainfrom
fix/per-job-sync-locks

Conversation

@joryirving

Copy link
Copy Markdown
Contributor

Summary

  • One lock row per job instead of one shared "global" row. No migration.

The bug

Every endpoint calls acquireLock(syncType) with its own type, and the helper ignored the argument:

const LOCK_ID = "global" as const;
...
const existing = await prisma.syncLock.findUnique({ where: { id: LOCK_ID } });

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:

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   <- never ran, once
reconcile    0 x 200,  3 x 409   <- never ran, once

pr-followup never 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.id is a free-text @id (prisma/schema.prisma:490), and src/lib/groomer/groomer-lock.ts:18 already keeps its own "groomer" row in this table. The multi-row pattern is established and running in production — that is also why groomer shows 0 x 409 above. This change extends an existing pattern rather than introducing one.

What still shares a key, and why

scheduled and manual both map to issue-sync. Both run the full issue sync, and makePrismaIssueStore().createIssue is a plain create, 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 syncIssuesForRepos with no lock (src/lib/heartbeat.ts:57), the PR-followup webhook writes the same PrFixQueueItem rows unlocked, and prune-closed does issue.deleteMany unlocked 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":

× different sync types acquire independently — the starvation regression
  AssertionError: expected [ { locked: true } ] to have a length of 5 but got 1
× releasing one key does not free another

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 lint clean.

Also changed

The opt-in automation block in /api/sync/scheduled now takes the automation key. 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 reports automationSkipped, 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.

releaseLock keys on the run id alone, so no call site needs to know its key. Safe because a run id is either an IssueSyncRun cuid or groomer-lock's randomUUID token.

Notes

  • The legacy "global" row is left orphaned after deploy. Nothing reads it; it can be deleted whenever.
  • This supersedes my earlier reading that the scheduler had stopped firing. It was firing and losing the lock every time. The supervisor in fix(scheduler): detect and recover when a job's timer stops firing #875 is still worth having, but it was not the fix for this.

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

@its-saffron its-saffron Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-SyncType map (LOCK_KEY_BY_TYPE). Two types (scheduled, manual) intentionally share the issue-sync key because they write the same Issue rows via a non-upserting create (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 atomic updateMany claim, the raw INSERT ... 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 on syncRunId alone. The justification (cuid for IssueSyncRun and randomUUID for groomer-lock) is sound: syncRunId is unique across every key in the table, so a conditional delete-by-run-id cannot free a different holder's row. Verified against the existing sync-lock.test.ts cases that assert deleteMany is 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 surfaces automationSkipped and automationSkippedReason. The scheduler never sets syncAutomation, so only manual callers reach this branch — consistent with the PR description.
  • The releaseLock is in a finally block, mirroring the established try/finally pattern 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.ts and src/lib/sync-lock.integration.test.ts update existing assertions from id: "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 that releaseLock scoped to syncRunId cannot free a different holder.
  • src/app/api/sync/scheduled/route.test.ts updates mocks to use "issue-sync" and adds a comment documenting the lock-key mapping; the deleteMany assertion is updated to key on syncRunId only, matching the new release semantics.

Lock-key mapping rationale

The mapping (scheduled/manualissue-sync, everything else distinct) is the strongest design choice to scrutinize. The author justifies it with:

  1. scheduled and manual both run the full issue sync, and makePrismaIssueStore().createIssue is a plain create not an upsert, so two concurrent runs race into P2002 (PR 333). Confirmed against src/lib/issue-sync.ts:391 (referenced in the PR body).
  2. The other four write to disjoint tables or perform conditional/idempotent writes. The agent heartbeat, PR-followup webhook, and prune-closed already exercise those same interleavings unlocked, which is the strongest "this split is safe" evidence available.
  3. groomer-lock.ts already uses the multi-row pattern (src/lib/groomer/groomer-lock.ts:18), so the table is already designed for it. The prisma/schema.prisma:490 id is 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 automationSkipped block is reported via JSON; no new secrets are introduced. The change follows the documented try/finally lock-release pattern used by other sync routes.
  • Concurrency / Prisma: No schema change (justified by the free-text @id and the existing multi-row pattern in groomer-lock.ts); no foreign-key weakening.
  • No agent-specific names in generic docs: the docs added to sync-lock.ts refer to job categories (pr-followup, reconcile, etc.) and the LOCK_KEY_BY_TYPE map uses the same SyncType enum 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 the sync_lock table 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 new try { ... } finally { ... } block is nested inside an if (!automationLock.locked) { ... } else { ... }. The indentation of the try keyword ( try {) differs from the surrounding } alignment by two spaces inside the else. This is purely a style nit and matches the rest of the file's indentation; flagging only for completeness.

@joryirving
joryirving merged commit 430e84a into main Aug 27, 2026
12 checks passed
@joryirving
joryirving deleted the fix/per-job-sync-locks branch August 27, 2026 17:04
@its-miso its-miso Bot mentioned this pull request Aug 27, 2026
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.

1 participant