Skip to content

Do not start a second review while one is running - #79

Open
maxime-pharmania wants to merge 1 commit into
seoes:mainfrom
pharmania:fix/duplicate-review-guard
Open

maxime-pharmania wants to merge 1 commit into
seoes:mainfrom
pharmania:fix/duplicate-review-guard

Conversation

@maxime-pharmania

Copy link
Copy Markdown

The review guard reads completed reviews only, so two GitHub events that are seconds apart both pass it and start a full review each. Seen in production on a pull request that was opened and then received a commit 11 seconds later: activity 1 (created 4 seconds after the pull request) and activity 2 (created 18 seconds later) ran in parallel for ~56 minutes, consumed ~10.7M and ~11.8M input tokens, and both published a review plus inline comments on the same pull request. The same pull request also shows two activities pointing at the same head, 2 seconds apart.

  • hasRunningPullRequestReview() counts reviews still in started state for that repository and pull request.
  • The GitHub, GitLab and Forgejo pull request handlers stop on it before reading the review mode, so it protects both on_first_push and on_every_push.
  • A run left in started by a crash is already reset to failed at boot (apps/api/src/index.ts), so the guard cannot block a repository forever.

Tests (apps/api/src/api/activity/pr-review-guard.test.ts, real in-memory database, isolated child process like the other integration tests): a review in flight is reported; ended reviews of any outcome are ignored; another activity type, another pull request and another repository are ignored; and hasCompletedPullRequestReview() stays false while the only review of that pull request is running, which is the exact gap this closes.

This is a best-effort guard, not a lock: the two events are read before the activity row is created, so a sub-millisecond interleaving could still pass. Compared to a review that runs for minutes, that window is negligible.

The first-push guard counts completed reviews only, so any event arriving
before the running review ends starts a duplicate. GitHub sends opened and then
synchronize seconds apart when a commit lands while the pull request is created,
and both events passed the guard: two reviews ran in parallel on the same pull
request and both published a comment.

hasRunningPullRequestReview() counts reviews still in started state, and the
GitHub, GitLab and Forgejo review handlers now stop on it. A run left in started
by a crash is already reset to failed at boot, so the guard cannot block a
repository forever.
if (accessSkip) return accessSkip;

const activityService = new ActivityService();
if (await activityService.hasRunningPullRequestReview(repository.id, prNumber)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚫️ [PROBLEM] Skipped synchronize in on_every_push is never re-reviewed

In on_every_push mode, a synchronize event that lands while a review is already in flight is skipped with a 200 and never revisited. The head-SHA dedup at apps/api/src/webhook/github/github.controller.ts:180 only looks at completed reviews (findLastReviewedHeadSha filters status = "completed" in apps/api/src/api/activity/activity.service.ts:416), and the completion path in apps/api/src/api/activity/activity.runner.ts:10-22 does not re-check whether the pushed head moved. So for the common "push, review starts, push again minutes later" loop, the second push's head receives no review until a third push arrives — before this change, that synchronize at least started a review. The same guard shape exists in gitlab.controller.ts:147 and forgejo.controller.ts:180.

Suggested direction: only apply the running-guard in on_first_push mode, or remember the skipped head and start a follow-up review when the in-flight one completes.


const activityService = new ActivityService();
const prIid = pullRequest.iid;
if (await activityService.hasRunningPullRequestReview(repository.id, prIid)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚫️ [PROBLEM] Guard is check-then-run; started row lands only after network I/O

The guard is a check-then-run with a wide window: it checks for a status = "started" row here, but that row is only inserted by activityService.start inside runWithActivity (apps/api/src/api/activity/activity.runner.ts:9), which runs fire-and-forget after awaited network I/O in the handler (in the GitHub controller: fetchPullRequestVersion at line 175, then fetchPullRequestChangedFileCount, then runWithActivity at line 208). Two near-simultaneous events — a webhook redelivery, or a push landing while the opened handler is still fetching — both pass this check and both launch reviews, which is the exact duplicate-launch behavior this PR aims to prevent.

Consider reserving the in-flight state synchronously at guard time (e.g. an in-process Set keyed by ${repositoryId}:${targetIid} inserted before the version fetches and cleared in runWithActivity's finally), or inserting the started activity row before the network I/O and reusing it as the run's row. This same window exists in the GitHub and Forgejo controllers.

@proval-0e6d07

proval-0e6d07 Bot commented Oct 11, 2026

Copy link
Copy Markdown

Thanks for adding the in-flight review guard — this closes a real duplicate-review window that the completed-only check left open, and the new test suite exercises the actual SQLite query semantics well.

a. Summary

This PR adds hasRunningPullRequestReview to the activity service and wires it into all three webhook controllers (GitHub, GitLab, Forgejo) so a second event arriving while a PR review is still running gets skipped with a 200 instead of launching a duplicate review. The guard's query mirrors the existing completed-check, the crash-recovery dependency (stuck started rows reset to failed at boot in apps/api/src/index.ts:49) holds, and the isolated-child-process test harness covers the guard's semantics thoroughly. Two behavioral gaps remain: the skip is permanent for the skipped event (a coverage hole in on_every_push mode), and the check-then-run window is wide enough that near-simultaneous events can still defeat the guard.

b. Good points

  • 🟢 The doc comment on hasRunningPullRequestReview (apps/api/src/api/activity/activity.service.ts:425) states the exact failure mode it closes and names its crash-recovery dependency, which I verified is satisfied by apps/api/src/index.ts:49.
  • 🟢 The new test (apps/api/src/api/activity/pr-review-guard.test.ts:7) reuses the established isolated-child-process harness with real migrations, covering started vs. each finished status plus exclusion by type, targetIid, and repository — so it tests real SQLite semantics without module-mock poisoning.
  • 🟢 The guard is placed uniformly across all three providers after cheap local checks and before the version fetch / runWithActivity (github.controller.ts:161, gitlab.controller.ts:147, forgejo.controller.ts:180), short-circuiting before the most expensive work with a consistent 200 response shape.

c. Main Issues

  • ⚫️ apps/api/src/webhook/github/github.controller.ts:161 — In on_every_push mode, a synchronize arriving mid-review is skipped permanently: the head-SHA dedup only sees completed reviews and nothing re-triggers after the in-flight review finishes, so the new head goes unreviewed until yet another push.
  • ⚫️ apps/api/src/webhook/gitlab/gitlab.controller.ts:147 — The guard is check-then-run: the started row it checks is only inserted inside runWithActivity after awaited network I/O in the handler, so two near-simultaneous events (redelivery, or a push during the opened handler's fetches) both pass the check and launch duplicate reviews anyway.

d. Additional Notes

  • apps/api/src/webhook/github/github.controller.ts:161 and apps/api/src/webhook/gitlab/gitlab.controller.ts:147 return the skip silently, while apps/api/src/webhook/forgejo/forgejo.controller.ts:181 logs it with PR context — aligning logging across providers would help debuggability.
  • apps/api/src/api/activity/pr-review-guard.test.ts:60 iterates statuses within one test case; splitting into parameterized cases would make a failure easier to localize.

Comment on lines +425 to +446
/**
* True while a pull request review of this repository and pull request is
* still running. The completed check alone lets a second event started
* before the first review ends launch a duplicate review: GitHub sends
* "opened" and a following "synchronize" seconds apart, and both would pass.
* A run left in "started" by a crash is reset to "failed" at boot.
*/
public async hasRunningPullRequestReview(repositoryId: number, targetIid: number): Promise<boolean> {
const [{ total }] = await db
.select({ total: count() })
.from(activityTable)
.where(
and(
eq(activityTable.repositoryId, repositoryId),
eq(activityTable.targetIid, targetIid),
eq(activityTable.type, "pr_review"),
eq(activityTable.status, "started"),
),
);
return total > 0;
}

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.

This PR looks great! But I think checking in-flight PR review from database is not the best approach. Maybe storing the in-flight PRs in the memory and checking them from memory is better.

@seoes

seoes commented Oct 13, 2026

Copy link
Copy Markdown
Owner

Hi @maxime-pharmania!

This is really a bug. Thanks for reporting and making PRs for this.

Proval actually got some guards about this, but it only checks if there is a completed one. Second event while the first run is still started can still start another full review which matches what you saw in production. (yeah it's a bug)

Your hasRunningPullRequestReview guard fixes the case. It stops a second job while one is in flight so we do not get two long full reviews on the same PR.

The tradeoff is that option with on_every_push, any push during a long run is skipped, so that commit is not reviewed until someone pushes again later.(most of them wouldn't get a review in real use I think)

I added a small change on your branch. treat started or completed as already reviewed and use the latest head from findLastReviewedHeadSha for the baseline. A push with a new head during a running review becomes a follow up with push scope instead of another full review, or it is skipped when the head is unchanged.

So we still avoid duplicate full reviews, but we do not drop every mid run push. We may run an extra smaller incremental review instead. A follow up comment might appear before the first one finishes. That is the tradeoff we accept.

@seoes

seoes commented Oct 13, 2026

Copy link
Copy Markdown
Owner

@maxime-pharmania I can't push my commit to your fork (https://github.com/pharmania/proval.git)

Could you add me as a collaborator on that fork (or enable maintainer edits on your PRs) so I can push?

I pushed my updates to my repo so you can check my updates

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.

3 participants