Skip to content

fix(review): serialize concurrent triggers per worker to stop reviewer double-spawn#246

Merged
Vaibhaav-Tiwari merged 2 commits into
mainfrom
fix/review-trigger-dedup
Jun 16, 2026
Merged

fix(review): serialize concurrent triggers per worker to stop reviewer double-spawn#246
Vaibhaav-Tiwari merged 2 commits into
mainfrom
fix/review-trigger-dedup

Conversation

@codebanditssss

Copy link
Copy Markdown
Collaborator

Closes #242.

Problem

Engine.Trigger is a read-then-write — idempotency check (GetReviewRunBySessionAndSHA) → reviewer spawn → InsertReviewRun — with no serialization and no backing DB constraint. Two near-simultaneous triggers for the same worker at the same head SHA both pass the idempotency check (neither has inserted yet), both spawn a reviewer against the same deterministic review-<id> handle, and both insert a running run for one commit — defeating the idempotency design and leaving the UI's reviewer handle / run list inconsistent.

Fix

Two layers:

  1. Per-worker keyed mutex (lockWorker) held across the whole Trigger body. Concurrent triggers for the same worker now serialize; the loser re-reads the freshly-recorded run and short-circuits to Created: false instead of spawning. Distinct workers never contend.
  2. Partial unique index on review_run(session_id, target_sha) (migration 0013) as a cross-restart safety net. Rows with an empty target_sha (head not yet observed) are excluded so they aren't blocked — the engine lock still serializes those.

Test

TestTriggerConcurrentSameWorkerSpawnsOnce fires 8 simultaneous triggers for one worker and asserts exactly one spawn, one recorded run, and one Created: true result. (Race-safe by construction: all shared access happens inside the serialized Trigger body; CI's build-test -race job validates — no local C compiler here.)

Gate: go build ./..., gofmt -l, golangci-lint run (0 issues), full go test ./..., and internal/storage/sqlite tests (which apply migration 0013) — all green.

🤖 Generated with Claude Code

…spawn

Engine.Trigger was a read-then-write (idempotency check -> reviewer spawn ->
InsertReviewRun) with no serialization and no backing constraint. Two near-
simultaneous triggers for the same worker at the same head SHA both passed the
GetReviewRunBySessionAndSHA check, both spawned a reviewer against the same
deterministic review-<id> handle, and both inserted a running run for one commit.

Add a per-worker keyed mutex (lockWorker) held across the whole Trigger body, so
the loser re-reads the freshly-recorded run and short-circuits to Created:false
instead of spawning. Back it with a partial unique index on
review_run(session_id, target_sha) (migration 0013) as a cross-restart safety
net; rows with an empty target_sha (head not yet observed) are excluded so they
are not blocked.

Adds a concurrency test asserting N simultaneous triggers spawn once and record
one run.

Closes #242

@greptile-apps greptile-apps 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.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@Vaibhaav-Tiwari

Copy link
Copy Markdown
Collaborator

I’d like two changes before merge:

  1. Make migration 0013 safe for existing duplicate data.
    If a user already hit fix(review): concurrent Trigger double-spawns reviewers (no serialization, no unique constraint) #242, their DB may already have multiple review_run rows with the same session_id +
    target_sha. CREATE UNIQUE INDEX will fail on that data and can stop daemon startup. The migration should
    dedupe those rows before creating the index, probably keeping the newest or most useful run and deleting/
    marking the others.

  2. Handle unique-constraint fallback in Trigger.
    If InsertReviewRun hits the new unique constraint anyway, Trigger should re-read GetReviewRunBySessionAndSHA
    and return that existing run with Created: false, instead of surfacing an error after the reviewer may
    already have launched.

…flict in Trigger

Pre-#242 daemons can already hold duplicate (session_id, target_sha)
review_run rows, on which CREATE UNIQUE INDEX fails and wedges startup.
Migration 0013 now collapses each duplicate group to a single survivor
(a completed pass over a still-running one, then newest by created_at)
before building the index.

Trigger now treats a unique-constraint hit as a fallback rather than an
error: InsertReviewRun maps it to the new domain.ErrDuplicateReviewRun
sentinel, and Trigger re-reads GetReviewRunBySessionAndSHA and returns
that run with Created:false instead of surfacing a raw error after the
reviewer may already have launched.

@greptile-apps greptile-apps 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.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@Vaibhaav-Tiwari Vaibhaav-Tiwari merged commit 96d1649 into main Jun 16, 2026
9 checks passed
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.

fix(review): concurrent Trigger double-spawns reviewers (no serialization, no unique constraint)

2 participants