fix(scm): Make PR lifecycle writes monotonic across reordered webhooks - #121059
fix(scm): Make PR lifecycle writes monotonic across reordered webhooks#121059vaind wants to merge 6 commits into
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.
| repository_id=repository_id, | ||
| key=key, | ||
| defaults=defaults, | ||
| ) |
There was a problem hiding this comment.
Create race skips staleness guard
High Severity
select_for_update only serializes when a PullRequest row already exists. Concurrent first deliveries both see no row, skip is_stale_pull_request_snapshot, and call update_or_create; Django then applies the loser's defaults onto the winner's insert. An older open snapshot can still overwrite a newer merged one under the parallel mailbox delivery this change aims to fix.
Reviewed by Cursor Bugbot for commit ac4a7ad. Configure here.


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.The row is locked with
select_for_updateacross the decision, otherwise two concurrent deliveries for one PR both read the pre-write row and the older write lands last.update_or_createalready takesFOR UPDATEinternally, immediately after this read — the lock is widened, not introduced.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.