Skip to content

fix(scm): Make PR lifecycle writes monotonic across reordered webhooks - #121059

Open
vaind wants to merge 6 commits into
masterfrom
fix/monotonic-pull-request-lifecycle-writes
Open

fix(scm): Make PR lifecycle writes monotonic across reordered webhooks#121059
vaind wants to merge 6 commits into
masterfrom
fix/monotonic-pull-request-lifecycle-writes

Conversation

@vaind

@vaind vaind commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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.

T+0:00  synchronize     → transient cell 500, rescheduled ~4 min out
T+1:00  closed (merged) → delivered: state=merged, merged_at, closed_at set
T+4:00  synchronize     → retry lands: state=open, merged_at=None

The PR is then shown as open in Sentry while it is merged upstream, permanently. The same write also rolls back closed_at, so run_deferred_emission reads the PR as reopened and cancels its metrics emission; flips open ↔ non-open, so pull_request_state_changing writes a spurious "reopened this pull request" onto every issue the PR resolves; and re-derives group links from the stale body, deleting the GroupLink.

Two independent sources of reordering, both addressed here: retry backoff (schedule_next_attempt — and for skip_on_failure_providers the drain skips the failed message outright), and concurrency — _run_parallel_delivery_batch hands the next hybridcloud.webhookpayload.worker_threads payloads from one mailbox to a single threadpool (16 in production), and GitHub buckets every pull_request event 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_atmigration 1150_pullrequest_provider_updated_at, additive and nullable, no backfill. date_added/date_updated are arrival time and can't order anything, and no existing column carried provider time. The provider_ prefix marks that the column holds someone else's clock: bare updated_at is 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 on ExternalIssue (provider_status_updated_at in #121084, provider_assignee_updated_at in #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 — merged is 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, because closed → open is a real transition that only provider time separates from a replay. Equal timestamps are not stale.

The row is locked with select_for_update across the decision, otherwise two concurrent deliveries for one PR both read the pre-write row and the older write lands last. update_or_create already takes FOR UPDATE internally, immediately after this read — the lock is widened, not introduced.

pr_metrics.handle_metrics writes PullRequestMetrics from 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 a CLOSED_UNMERGED that _claim_terminal_event makes permanent.

Skips report scm.webhook.pull_request.stale_snapshot (tagged by provider) and pr_metrics.metrics.stale_snapshot.

Landing order

Prerequisite for #121057, which widens skip_on_failure_providers to github_enterprise, bitbucket, and bitbucket_server. GitHub Enterprise inherits this guard by subclassing the GitHub handler.

Delivery and retry semantics in deliver_webhooks.py are untouched.

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.
@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

This PR has a migration; here is the generated SQL for src/sentry/migrations/1149_pullrequest_updated_at.py

for 1149_pullrequest_updated_at in sentry

--
-- 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.
vaind added 2 commits August 4, 2026 09:16
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
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

This PR has a migration; here is the generated SQL for src/sentry/migrations/1150_pullrequest_updated_at.py

for 1150_pullrequest_updated_at in sentry

--
-- 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.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

This PR has a migration; here is the generated SQL for src/sentry/migrations/1150_pullrequest_scm_updated_at.py

for 1150_pullrequest_scm_updated_at in sentry

--
-- 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.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

This PR has a migration; here is the generated SQL for src/sentry/migrations/1150_pullrequest_provider_updated_at.py

for 1150_pullrequest_provider_updated_at in sentry

--
-- Add field provider_updated_at to pullrequest
--
ALTER TABLE "sentry_pull_request" ADD COLUMN "provider_updated_at" timestamp with time zone NULL;

@vaind
vaind marked this pull request as ready for review August 4, 2026 21:41
@vaind
vaind requested review from a team as code owners August 4, 2026 21:41

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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,
)

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.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ac4a7ad. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant