fix(integrations): Ignore reordered and replayed inbound status webhooks - #121084
Open
vaind wants to merge 5 commits into
Open
fix(integrations): Ignore reordered and replayed inbound status webhooks#121084vaind wants to merge 5 commits into
vaind wants to merge 5 commits into
Conversation
Every provider converts an issue open/close event into a Sentry resolve/unresolve as a delta — GitHub and GitLab map the action verb, VSTS and Jira compare a from/to state pair — so a webhook delivered out of order writes an old status over a newer one. Delivery is not ordered: a failed delivery is retried with exponential backoff and lands behind events that were originally after it, and for providers in skip_on_failure_providers the drain skips a failed message outright. A close and reopen three seconds apart, delivered in reverse, leaves the group resolved with a GroupResolution and resolution notifications sent while the issue is open upstream. sync_status_inbound now compares the provider's own timestamp for the change against the newest event already processed for the same issue, held on a new nullable ExternalIssue.status_updated_at, and drops anything not strictly newer. Comparing provider time to provider time is what makes this safe: comparing against Sentry-side arrival time would suppress a legitimate follow-up whose provider timestamp precedes the previous event's apply time. The webhook handlers normalize their own timestamp into the task payload, so the shared task stays free of per-provider shapes. The guard is inert when either side is missing, which covers payloads enqueued before the key existed. The unresolve path, which had no guard at all, is additionally narrowed to the groups the event actually changes, so issue_unresolved no longer fans out for groups that were already unresolved. Refs #121057, #121059
Contributor
|
This PR has a migration; here is the generated SQL for for --
-- Add field status_updated_at to externalissue
--
ALTER TABLE "sentry_externalissue" ADD COLUMN "status_updated_at" timestamp with time zone NULL; |
Keep the why — provider-clock comparison, inert on a missing timestamp — and drop the restatement.
121059 took 1150 after its own renumber, and it is further along. The dependency still points at 1149 and will need repointing once 1150 lands.
Contributor
|
This PR has a migration; here is the generated SQL for for --
-- Add field status_updated_at to externalissue
--
ALTER TABLE "sentry_externalissue" ADD COLUMN "status_updated_at" timestamp with time zone NULL; |
A bare `status_updated_at` reads as when Sentry updated the status; the column holds the provider's clock. `provider_` rather than `scm_` because ExternalIssue spans Jira and VSTS.
Contributor
|
This PR has a migration; here is the generated SQL for for --
-- Add field provider_status_updated_at to externalissue
--
ALTER TABLE "sentry_externalissue" ADD COLUMN "provider_status_updated_at" timestamp with time zone NULL; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Inbound issue status sync turns a provider's issue open/close event into a Sentry resolve/unresolve as a delta — GitHub and GitLab map the action verb, VSTS and Jira compare a from/to state pair — and
sync_status_inboundapplies whatever it is handed. Webhook delivery is not ordered, so an old delta gets applied on top of a newer one.This is an active production bug for GitHub, not a hypothetical.
githubis the default value ofhybridcloud.webhookpayload.skip_on_failure_providers, so a failed delivery in an issues mailbox is skipped and the rest drains past it. A user who closes an issue and reopens it three seconds later can have the pair land in reverse:The group is now resolved while the GitHub issue is open —
GroupResolutionwritten,issue_resolvedfired so resolution notifications go out, issue dropped from the unresolved stream, and nothing reconciles it afterwards. Symmetrically, a stale replay can reopen an issue a human just resolved in Sentry.The fix
Each webhook handler normalizes its provider's own timestamp for the change into the task payload (
issue.updated_at,object_attributes.updated_at,System.ChangedDate,issue.fields.updated).sync_status_inboundcompares it against the newest event already processed for that issue and drops anything not strictly newer. The watermark is a new nullableExternalIssue.provider_status_updated_at— migration1151_externalissue_provider_status_updated_at, additive, no backfill. Theprovider_prefix carries the provenance in the name: a barestatus_updated_atwould read as when Sentry updated the status. #121059 usesscm_onPullRequestsince that model is SCM-only;provider_here becauseExternalIssuealso spans Jira and VSTS.Provider time on both sides is the design decision worth questioning. Comparing against Sentry-side arrival time instead (
date_added, activity timestamps) cannot work: delivery latency is seconds, the same scale as rapid user actions, so a legitimate reopen whose provider timestamp precedes the previous event's apply time would be suppressed — a worse failure than the bug. Provider-to-provider comparison is immune to how long delivery took. Reading payload state (issue.state) instead of the action verb does not help either: the stale close payload also saysstate: "closed", because that is what it was when the provider generated it.On the comparison operator. This guard treats equal timestamps as stale (
event_time <= last_event_time), while the sibling guards in #121059 and #121157 treat equal as fresh (<). That divergence is deliberate. This path consumes a delta — an action verb, or achangelog.from/.topair — and re-applying a delta on top of an intervening human action is destructive, so a redelivery at the same timestamp has to be dropped. Those two consume snapshots, which are idempotent to re-apply, so letting the later delivery win costs nothing.A missing timestamp makes the guard inert, so payloads enqueued before this key existed keep syncing as they do today. The unresolve path, which had no guard at all, additionally now narrows to the groups the event actually changes so
issue_unresolvedstops firing for groups that were already unresolved.Rollout
Prerequisite for #121057, which widens
skip_on_failure_providers— GitLab and VSTS must not be added until this lands. #121059 is the pull-request-side equivalent; the two are deliberately consistent in deriving order from provider time rather than arrival time.Migration numbering: #121059 renumbered to
1150_pullrequest_updated_atafter master landed1149, so this moved to1151to sit behind it rather than collide. The dependency here still points at1149, because1150is not on master yet — once it lands,./bin/update-migrationrepoints it. If master lands another migration before either merges, the lockfile will conflict again and the same command resolves it.The red
migration driftcheck is pre-existing, not this PR.tools/migrations/squash.pydeletes every migration of each app whose lockfile head is not0001_squashed_*(always includingsentry), but_cleared_depsonly rewrites cross-app dependencies inside each already-squashed app's0001_squashed_*.py.discoverandexploreare squashed while their pre-squash originals are still checked in, so those leftovers keep pointing at deleted nodes and Django's graph validation fails beforemakemigrationsruns. Runningsquash.pyon a cleanorigin/masterwith no added migration reproduces it (discover.0002 → explore.0006, versusdiscover.0001 → sentry.0945here — same failure, whichever dangling edge Django hits first). The workflow has failed on all of its last 100 runs since 2026-07-20, across dozens of unrelated branches.check migration,backend migration tests, andcheck if any migration changesare green here.