fix(scm): Make PR lifecycle writes monotonic across reordered webhooks - #121059
Conversation
SCM webhooks are not ordered. Control silo forwards them to the cells as WebhookPayload rows, and a delivery that fails is rescheduled with exponential backoff — so it lands minutes later, behind events that were originally after it. For providers in hybridcloud.webhookpayload.skip_on_failure_providers the drain skips the failed message outright, guaranteeing the reordering. Both the GitHub and GitLab handlers wrote the payload straight through with an unguarded update_or_create, and each payload is a full snapshot of the PR. Replaying an older one rewrote the row backwards: a merge lands, then the retried synchronize/update from before it rewrites state to open and merged_at to None, leaving the PR shown as open in Sentry while it is merged upstream. The damage is not limited to the state column — rolling back closed_at makes pr_metrics read the PR as reopened and cancels its emission, the open/non-open flip fabricates "reopened this pull request" entries on every linked issue, and re-deriving group links from a stale title/body unlinks issues the PR resolves. Guard on the provider's own updated_at, stored in a new nullable PullRequest.updated_at. date_added/date_updated record arrival time and cannot detect reordering. A snapshot older than the stored high-water mark is dropped whole rather than field by field, since every mutable column comes from the same point in time and applying part of an outdated snapshot would leave the row inconsistent. A second rule — merged is terminal at both providers — backs it up where the timestamp cannot: rows written before the column existed, and events colliding at the one-second resolution GitHub reports. Equal timestamps still apply, preserving today's last-write-wins behaviour, and both rules are inert when either timestamp is missing. Delivery and retry semantics in deliver_webhooks.py are untouched.
|
This PR has a migration; here is the generated SQL for for --
-- Add field updated_at to pullrequest
--
ALTER TABLE "sentry_pull_request" ADD COLUMN "updated_at" timestamp with time zone NULL; |
The PullRequest row and the PullRequestMetrics counters are written from the same pull_request payload by different processors. Guarding only the former would create a new inconsistency: an out-of-order replay is rejected for the PR row but still clobbers the counters, so the two rows disagree about the same point in time — and the PR row looking correct makes the bad counters more likely to be believed. select_verdict reads comments_count/review_comments_count off that row, so a PR with real reviewer discussion falls through to a deterministic CLOSED_UNMERGED (abandoned), which _claim_terminal_event then makes permanent. handle_metrics runs after PullRequestEventWebhook._handle and re-reads the row, so re-evaluating the same predicate on the same payload reproduces that handler's verdict exactly: an accepted snapshot left the row carrying the event's own updated_at/state (equal, not stale), a rejected one left the newer stored values (still stale). That avoids threading derived values through the processor signature every processor shares.
A backlogged mailbox is drained by _run_parallel_delivery_batch, which submits the next hybridcloud.webhookpayload.worker_threads payloads from that one mailbox to a single threadpool — 16 in production, not the registered default of 4. GitHub buckets every pull_request event for a repo into one mailbox, so two deliveries for the same PR run concurrently as a matter of course under backlog. The staleness check read the row and wrote it in separate statements, so both writers could see the pre-write row, both conclude they are current, and the older write land last — reproducing exactly the corruption this guard exists to prevent, precisely under the load that causes the reordering in the first place. Django's update_or_create already opens a transaction and takes FOR UPDATE, but only around its own get-then-save, which is after the staleness read. Taking the lock on that read extends an existing lock earlier rather than introducing locking to a lock-free path, so the incremental cost is one locked SELECT; contention is per row, between deliveries that have to serialize anyway. A conditional UPDATE ... WHERE was rejected: QuerySet.update() fires no signals, and PullRequestManager.update_or_create exists specifically to guarantee post_save so GroupLink and the PR lifecycle activity feed keep working. It would also split the predicate across Python and SQL.
…request-lifecycle-writes # Conflicts: # migrations_lockfile.txt
|
This PR has a migration; here is the generated SQL for for --
-- Add field updated_at to pullrequest
--
ALTER TABLE "sentry_pull_request" ADD COLUMN "updated_at" timestamp with time zone NULL; |
updated_at is the near-universal ORM convention for a row-modification timestamp, so a reader assumes Sentry-local time unless they read the comment disclaiming it. Its siblings don't have that problem — opened_at, closed_at and merged_at name PR lifecycle events and are unambiguous. This was the one field whose name actively suggested the wrong thing. scm_ makes the provenance part of the name, so the comment above it can drop the disclaimer and keep only what the name can't say: which provider fields it is sourced from. The migration has not merged, so this is a free rename — no data migration and no db_column alias. Kept at 1150 because 121084 has already moved to 1151 to sit behind it. The provider payload keys stay updated_at (GitHub pull_request.updated_at, GitLab object_attributes.updated_at), as does PullRequestComment.updated_at, which is a genuine row-modification timestamp on a different model.
|
This PR has a migration; here is the generated SQL for for --
-- Add field scm_updated_at to pullrequest
--
ALTER TABLE "sentry_pull_request" ADD COLUMN "scm_updated_at" timestamp with time zone NULL; |
Uniform prefix with the sibling guards on ExternalIssue (provider_status_updated_at in 121084, provider_assignee_updated_at in 121157). The prefix has one job: marking that the column holds someone else's clock rather than ours. provider_ does that completely; scm_ adds a taxonomic fact irrelevant to why the prefix exists, at the cost of a reader learning that two prefixes mean the same thing. These three guards get read together and cite each other, and they already carry one legitimate divergence in their comparison operators. A second divergence that only looks meaningful makes the real one harder to spot. The set now reads provider_<aspect>_updated_at, with the aspect omitted here because this watermark covers the whole record rather than one facet of it. Still unmerged, so no data migration and no db_column alias. Kept at 1150; 121084 holds 1151 and 121157 holds 1152. scm stays where it classifies a domain rather than naming this column: update_pull_request_from_scm_snapshot, parse_scm_timestamp (which also parses created_at/closed_at/merged_at), and the scm.webhook.* metric namespace.
|
This PR has a migration; here is the generated SQL for for --
-- Add field provider_updated_at to pullrequest
--
ALTER TABLE "sentry_pull_request" ADD COLUMN "provider_updated_at" timestamp with time zone NULL; |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ac4a7ad. Configure here.
SELECT ... FOR UPDATE grants no exclusion when the row is absent, so two first deliveries for the same PR both found nothing, both skipped the staleness guard, and both called update_or_create. Django's implementation catches the IntegrityError from the losing insert, re-gets, returns created=False, and applies that caller's defaults over the winner's row — so an older open snapshot could overwrite a newer merged one. That self-heals on any later event carrying a newer timestamp, but an opened/closed pair seconds apart in one parallel batch has no later event: the PR stays open permanently, which is the exact corruption this guard exists to prevent. An advisory lock is keyed on a value rather than a tuple, so it exists before the row does. Keyed on (repository_id, key) — the unique constraint that arbitrates the race — and transaction-scoped, so it releases on commit or rollback, cannot expire while the write is in flight, and leaves no session state for a pooler to lose. It is taken before the row lock and by this path alone; every other writer of the row takes only the row lock and never waits on it, so no cycle can form. A raw update() or INSERT ... ON CONFLICT DO UPDATE would bypass post_save, which PullRequestManager.update_or_create exists to guarantee for GroupLink and the lifecycle activity feed.
giovanni-guidini
left a comment
There was a problem hiding this comment.
makes sense to me... that's the only place we create PullRequest objects?
|
|
||
| Neither rule subsumes the other. Timestamps are absent on rows predating the column | ||
| and coarse at GitHub's one-second resolution, which terminal-``merged`` covers; and | ||
| ``closed`` -> ``open`` is a real transition (a reopen) that only provider time |
There was a problem hiding this comment.
I thought GitHub in particular had a reopened action so we could in theory guard on closed -> open transition (assuming the valid one is closed -> reopened.
Not sure how worth it that it.
Not sure if other providers support it.
There was a problem hiding this comment.
tldr; it's not a clear cut because the same status is caried on other events we shouldn't skip
Claude's version:
The premise checks out on both providers — GitHub sends reopened, and the GitLab MR handler already branches on action in ("reopen", "open") (gitlab/webhooks.py:519). So the rule is writable for both. I went and worked through whether it holds up, and I don't think it can replace the timestamp — but the reasoning isn't obvious, so it's worth writing down rather than just asserting.
The rule would have to be "stored is closed + payload says open ⇒ require action: reopened." reopened isn't the only action carrying state: open — after a reopen, so does every one of the ~17 actions in _ACTIVITY_ACTIONS, plus edited. So it can only fire on the transition itself.
Where it breaks: it's correct only if we're guaranteed to have processed the reopened event, and we aren't. The comment on these columns already concedes "a late-installed integration, a missed/dropped webhook". If a reopened is dropped, the next synchronize arrives with state: open under a non-reopen action and gets rejected — and since a rejected write doesn't advance provider_updated_at, so does the one after it, and the one after that. The PR is stuck closed permanently and silently. The timestamp gets this case right: that synchronize really is newer than the close, so it's applied.
That asymmetry is what decided it for me. The action tells you what triggered this event, not whether you saw everything before it — and lossy delivery is precisely the condition the guard exists for.
It's also narrower than it looks. The action only speaks to lifecycle transitions. Two reordered synchronize payloads carry the same state and the same action, differing only in head_sha / title / message; the timestamp orders them, the action can't. So at best it complements.
The one place it would genuinely add something, since this is the strongest case for it: the timestamp guard is inert while provider_updated_at is NULL, which is every row until its first write after the migration. But that window is smaller than it first appears — the first guarded write sets the mark from its own payload whether or not the guard fired, so a reordered pair comes out right even starting from NULL (older first → applied, mark = T1, newer applied; newer first → applied, mark = T2, older rejected). What's actually unguarded is a row whose state was set without setting the mark: a pre-migration closed row receiving a replay that straddles the deploy. Merged rows are covered by the terminal-merged rule, and stub rows have state = NULL so there's nothing to regress. Retry backoff is bounded, so this is PRs closed shortly before the deploy with an in-flight retry, and it closes permanently once each PR takes one webhook.
So the trade is a narrow, self-closing, deploy-boundary window against a permanent stuck-closed failure mode. I don't think that's worth it. Confining the action rule to just the NULL window doesn't rescue it either — a rejection leaves the mark NULL, so the row stays in the fallback regime and keeps rejecting.
I've updated the docstring on this line so the next reader doesn't have to re-derive it:
The event action can't stand in there: it says what triggered this event, not whether the
reopenedbefore it was delivered, so requiring that action would permanently reject every later payload on a PR whose reopen we missed.
Happy to be argued out of this. If you'd want visibility rather than enforcement, the cheap version is a counter for when a NULL-mark row takes an open-state payload onto a closed row under a non-reopen action, which would measure whether that deploy window ever actually bites, with no behavioural risk. I lean against even that — it's instrumentation for something that closes on its own — but I'll add it if you'd rather have the data.
Review raised that GitHub has a reopened action, so closed -> open could in theory be guarded on the action rather than provider time. It can't: reopened is not the only action carrying state open — after a reopen so does every other pull_request action — so the rule could only require it on the transition, which is correct only if the reopened event was actually delivered. A dropped reopen would then reject every later payload, and since a rejected write leaves provider_updated_at unadvanced, permanently. Stated on the line that made the closed -> open claim, so the next reader doesn't re-derive it.
yes |

SCM webhooks arrive out of order, and every pull-request / merge-request payload is a full snapshot of the PR — so an older delivery silently overwrites a newer one. Both the GitHub and GitLab handlers wrote theirs through an unguarded
update_or_create.The PR is then shown as open in Sentry while it is merged upstream, permanently. The same write also rolls back
closed_at, sorun_deferred_emissionreads the PR as reopened and cancels its metrics emission; flips open ↔ non-open, sopull_request_state_changingwrites a spurious "reopened this pull request" onto every issue the PR resolves; and re-derives group links from the stale body, deleting theGroupLink.Two independent sources of reordering, both addressed here: retry backoff (
schedule_next_attempt— and forskip_on_failure_providersthe drain skips the failed message outright), and concurrency —_run_parallel_delivery_batchhands the nexthybridcloud.webhookpayload.worker_threadspayloads from one mailbox to a single threadpool (16 in production), and GitHub buckets everypull_requestevent for a repo into one mailbox.The fix
Staleness is decided on the provider's own last-modified time, stored in a new nullable
PullRequest.provider_updated_at— migration1150_pullrequest_provider_updated_at, additive and nullable, no backfill.date_added/date_updatedare arrival time and can't order anything, and no existing column carried provider time. Theprovider_prefix marks that the column holds someone else's clock: bareupdated_atis the usual ORM convention for a row-modification timestamp, so it would read as Sentry-local time. The same prefix is used by the sibling guards onExternalIssue(provider_status_updated_atin #121084,provider_assignee_updated_atin #121157) — those three are read together, so a naming divergence between them would look meaningful when it isn't.A stale snapshot is dropped whole rather than field by field: every mutable column comes from the same point in time, and a
state-only guard would still fabricate the timeline entries and delete the issue links listed above. A second rule —mergedis terminal — covers what timestamps can't: rows predating the column (no backfill, so all of them at first) and GitHub's one-second resolution. It isn't sufficient alone, becauseclosed → openis a real transition that only provider time separates from a replay. Equal timestamps are not stale.Writers are serialized across the whole decision, otherwise two concurrent deliveries for one PR both read the pre-write row and the older write lands last. That takes two locks, because a row lock alone can't cover the case where the row doesn't exist yet:
SELECT ... FOR UPDATEgrants no exclusion on a first delivery, so both writers would skip the guard and Django'supdate_or_createwould apply the loser'sdefaultsover the winner's insert. Apg_advisory_xact_lockkeyed on(repository_id, key)— the unique constraint that arbitrates that race — is keyed on a value rather than a tuple, so it exists before the row does; it is transaction-scoped, so it releases on commit or rollback, cannot expire mid-write, and leaves no session state for a pooler to lose.select_for_updatestays as a second line of defence for writers that create the row by another path (the pr_metrics stub). The advisory lock is always taken first and by this path alone, and every other writer of the row takes only the row lock and never waits on it, so no cycle can form.pr_metrics.handle_metricswritesPullRequestMetricsfrom the same payload and is held to the same verdict. Without that,select_verdict(src/sentry/pr_metrics/emit.py:175-178) reads zeroed discussion counts off a PR that had real reviewer engagement and emits aCLOSED_UNMERGEDthat_claim_terminal_eventmakes permanent.Skips report
scm.webhook.pull_request.stale_snapshot(tagged by provider) andpr_metrics.metrics.stale_snapshot.Landing order
Prerequisite for #121057, which widens
skip_on_failure_providerstogithub_enterprise,bitbucket, andbitbucket_server. GitHub Enterprise inherits this guard by subclassing the GitHub handler.Delivery and retry semantics in
deliver_webhooks.pyare untouched.