fix(integrations): Guard inbound assignee sync against reordered webhooks - #121157
fix(integrations): Guard inbound assignee sync against reordered webhooks#121157vaind wants to merge 4 commits into
Conversation
…ooks Webhook payloads are forwarded control silo -> cell as WebhookPayload rows and drained without ordering: a failed delivery is rescheduled with exponential backoff and lands behind events that were originally after it, backlogged mailboxes drain through a thread pool that explicitly sacrifices ordering, and some providers skip a failed message outright. Inbound assignee sync runs synchronously in the webhook handler, so mailbox order decides the outcome. Every provider sends the issue's full assignee snapshot rather than a delta, which is safer than a delta but not sufficient: a stale snapshot applied last still wins, so reordering two assignment changes leaves the earlier one as the final state. Inbound status sync is unaffected because it hands off to a task one call later. Each handler now passes its provider's own timestamp for the change (issue.updated_at, object_attributes.updated_at, issue.fields.updated), and ExternalIssue.assignee_updated_at holds the newest one already applied. Provider time is only ever compared to provider time -- delivery latency is the same order as real user actions, so comparing against arrival time would suppress legitimate follow-ups. Only strictly older events are dropped: snapshots make re-applying a same-instant event harmless, while providers' coarse timestamps make dropping one a real risk. The guard is inert when either side is missing. Assignment gets its own watermark rather than sharing the status one. The two are independent state machines driven by the same webhook, so a shared column would let a status event censor an assignment change that is not late relative to any other assignment -- losing it permanently, since no corrective event follows. Unassignment is the same code path and is covered by the same guard; GitHub Enterprise inherits the GitHub handler and is covered too.
|
This PR has a migration; here is the generated SQL for for --
-- Add field assignee_updated_at to externalissue
--
ALTER TABLE "sentry_externalissue" ADD COLUMN "assignee_updated_at" timestamp with time zone NULL; |
The timestamp guard read the watermark, assigned, then advanced the watermark with no mutual exclusion in between. Two deliveries for the same issue could both read the pre-write watermark and both pass the staleness check; if the newer one then committed first, the older one still overwrote the assignment while its own conditional watermark write correctly affected zero rows. The result is a stored assignee from the older event under a watermark from the newer one, which no later event carrying an intermediate timestamp can repair. A backlogged mailbox draining through the delivery thread pool is exactly the condition that produces overlapping deliveries for one issue, so this is the case the guard exists for. The staleness check now takes a row lock on the issue and holds it through the assignment and the watermark write, matching how PR 121059 fixed the same shape. Every candidate row is locked rather than only the stale ones, since the rows that pass the check are the ones about to be written; rows are locked in id order so two deliveries covering the same set cannot deadlock. Both entry points were restructured so the critical section is cell-local: the affected groups and the users to assign are resolved before the lock is taken. Sentry bans hybrid cloud RPC inside a transaction, and both lookups are RPCs -- the test harness asserts on this, which is what surfaced the requirement. What remains inside is DB writes, a cache invalidation and task enqueues; the issue_assigned signal that fans out to Sentry App webhooks is deferred by transaction.on_commit and so runs after the lock is released. Without a provider timestamp the guard is inert, so no transaction is opened and lock-free behaviour is preserved.
…' into worktree-agent-a5f64b42a090780fb
|
This PR has a migration; here is the generated SQL for for --
-- Add field provider_assignee_updated_at to externalissue
--
ALTER TABLE "sentry_externalissue" ADD COLUMN "provider_assignee_updated_at" timestamp with time zone NULL; |
| # If there is no assignee, assume it was unassigned. | ||
| fields = data["issue"]["fields"] | ||
| assignee = fields.get("assignee") | ||
| # `assignee` is a snapshot of the issue's current assignee, only the newest state if it |
There was a problem hiding this comment.
Unguarded data["changelog"]["items"] access crashes on missing key
Direct dict access to data["changelog"]["items"] will raise KeyError if Jira sends a webhook where the changelog dict exists but omits the items key.
Evidence
JiraIssueUpdatedWebhook.postreturns early only whendata.get("changelog")is missing or falsy, but a truthychangelogdict that lacks anitemskey proceeds past the guard.- The endpoint's own diagnostic logging explicitly reads
(data.get("changelog") or {}).get("items") or [], treatingitemsas optional in the upstream payload. handle_assignee_changeat line 62 injira/utils/api.pyunconditionally iteratesdata["changelog"]["items"]inside anany()generator with no fallback or existence check.- Neither the webhook endpoint nor the handler wraps this call in
try/except KeyError, so a malformed or edge-case payload raises an unhandled exception. - The same pattern exists in
jira_server/utils/api.pyline 30, which is also modified in this PR.
Identified by Warden · sentry-backend-bugs · Q4V-Q5G
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 27b1dab. Configure here.
| integration, external_issue_key, affected_groups, event_updated_at | ||
| ) as fresh_groups: | ||
| groups_deassigned = _handle_deassign(fresh_groups, integration) | ||
| return groups_deassigned |
There was a problem hiding this comment.
Unassign signal runs under row lock
Medium Severity
_ordered_assignment now holds ExternalIssue select_for_update locks through deassign, but issue_unassigned still fires synchronously inside that critical section. Assign carefully defers issue_assigned with transaction.on_commit; unassign does not, so its Kafka snapshot work can run while the issue row lock is held and stall concurrent deliveries for the same issue.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 27b1dab. Configure here.


Integration webhooks are forwarded control silo → cell as
WebhookPayloadrows and drained without ordering: a failed delivery is rescheduled with exponential backoff and lands behind events that originally followed it, backlogged mailboxes drain through a thread pool that explicitly sacrifices ordering, and for some providers the drain skips a failed message outright.Inbound assignee sync runs synchronously in the webhook handler, so mailbox order decides the outcome. All four handlers read the issue's full assignee snapshot rather than a delta, which is better than a delta but not sufficient — a stale snapshot applied last still wins, so reordering two assignment changes leaves the earlier one as the final state. Inbound status sync is not exposed the same way: it hands off to
sync_status_inbound_taskone call later, so ordering was already gone by then.Each handler now passes its provider's own timestamp for the change (
issue.updated_at,object_attributes.updated_at,issue.fields.updated), andExternalIssue.provider_assignee_updated_atholds the newest one already applied. Anything strictly older is dropped. Provider time is only ever compared to provider time — delivery latency is the same order as a real user action, so comparing against arrival time would suppress legitimate follow-ups. The guard is inert when either side is missing, so a payload with no usable timestamp behaves exactly as it does today.Covers GitHub, GitLab, Jira, and Jira Server; GitHub Enterprise inherits the GitHub handler. Unassignment is the same code path (empty snapshot → deassign) and needed no separate treatment. VSTS is deliberately left alone: its timestamp lives in
System.ChangedDate, which is the same extraction #121084 adds, so touching it here would collide for no benefit.Equal timestamps: snapshot vs delta
The three ordering PRs deliberately use two different comparisons, and the rule is about what the payload contains rather than about the provider:
<is stale) — this PR and fix(scm): Make PR lifecycle writes monotonic across reordered webhooks #121059. The payload carries the whole state, so re-applying a same-instant event is idempotent when it is a redelivery and correct-by-recency when it is a distinct change the provider's second-resolution clock cannot separate. Dropping it would be a real loss with nothing to repair it.<=is stale) — fix(integrations): Ignore reordered and replayed inbound status webhooks #121084. The payload carries a transition (closed,reopened, a from/to pair), so re-running one over an intervening human action is destructive.Why assignment gets its own watermark
Sharing
provider_status_updated_atlooked plausible — both syncs are driven by the sameissueswebhook carrying the sameupdated_at— but they are independent state machines, and one watermark lets either censor the other. A status event at T2 would drop an assignment change at T1 even when that change is not late relative to any other assignment, and it is then lost permanently, because no corrective assignment event follows. Same-timestamp events make this concrete (Jira sends a singlejira:issue_updatedwhose changelog can carry both a status and an assignee item under onefields.updated), but the censoring exists regardless of timestamp resolution. The differing comparisons above are the second reason: one column cannot carry both coherently.Concurrency
The check and the write have to be one critical section. Two deliveries for the same issue can both read the pre-write watermark and both pass the staleness check; if the newer one commits first, the older one still overwrites the assignment while its own conditional watermark write correctly affects zero rows — leaving a stored assignee from the older event under a watermark from the newer one, which no later event carrying an intermediate timestamp can repair. A backlogged mailbox draining through the delivery thread pool is exactly what produces overlapping deliveries for one issue.
The staleness check therefore takes
select_for_updateon the issue rows and holds it through the assignment and the watermark write, as #121059 does. Both entry points were restructured so that critical section is cell-local — the affected groups and the users to assign are resolved beforehand, since hybrid cloud RPC inside a transaction is a banned pattern and both lookups are RPCs. What remains under the lock is DB writes, a cache invalidation and task enqueues; theissue_assignedsignal that fans out to Sentry App webhooks is deferred bytransaction.on_commitand runs after the lock is released.Interaction with #121084
That PR solves the same hazard for status sync, and this one deliberately mirrors its shape: normalize provider time in the handler, keep the watermark on
ExternalIssue, compare provider time to provider time, stay inert when either side is missing.This is built against master and does not depend on it, but the two overlap and whoever merges second should expect it. Both add a field to
ExternalIssueon adjacent lines, both touchmigrations_lockfile.txt, and both add a migration off head1149. This migration is numbered1152to sit clear of1151, so if #121084 lands first the only fix-up is repointing this migration's dependency to its migration.parse_provider_event_timethen exists in bothutils/status_sync.pyandutils/assignee_sync.pyand is worth unifying once both have landed.