Skip to content

fix(integrations): Guard inbound assignee sync against reordered webhooks - #121157

Open
vaind wants to merge 4 commits into
masterfrom
worktree-agent-a5f64b42a090780fb
Open

fix(integrations): Guard inbound assignee sync against reordered webhooks#121157
vaind wants to merge 4 commits into
masterfrom
worktree-agent-a5f64b42a090780fb

Conversation

@vaind

@vaind vaind commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Integration webhooks 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 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_task one 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), and ExternalIssue.provider_assignee_updated_at holds 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:

Why assignment gets its own watermark

Sharing provider_status_updated_at looked plausible — both syncs are driven by the same issues webhook carrying the same updated_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 single jira:issue_updated whose changelog can carry both a status and an assignee item under one fields.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_update on 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; the issue_assigned signal that fans out to Sentry App webhooks is deferred by transaction.on_commit and 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 ExternalIssue on adjacent lines, both touch migrations_lockfile.txt, and both add a migration off head 1149. This migration is numbered 1152 to sit clear of 1151, so if #121084 lands first the only fix-up is repointing this migration's dependency to its migration. parse_provider_event_time then exists in both utils/status_sync.py and utils/assignee_sync.py and is worth unifying once both have landed.

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

for 1152_externalissue_assignee_updated_at in sentry

--
-- Add field assignee_updated_at to externalissue
--
ALTER TABLE "sentry_externalissue" ADD COLUMN "assignee_updated_at" timestamp with time zone NULL;

Comment thread src/sentry/integrations/utils/sync.py Outdated
vaind added 2 commits August 4, 2026 22:25
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.
@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/1152_externalissue_provider_assignee_updated_at.py

for 1152_externalissue_provider_assignee_updated_at in sentry

--
-- 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

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.

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.post returns early only when data.get("changelog") is missing or falsy, but a truthy changelog dict that lacks an items key proceeds past the guard.
  • The endpoint's own diagnostic logging explicitly reads (data.get("changelog") or {}).get("items") or [], treating items as optional in the upstream payload.
  • handle_assignee_change at line 62 in jira/utils/api.py unconditionally iterates data["changelog"]["items"] inside an any() 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.py line 30, which is also modified in this PR.

Identified by Warden · sentry-backend-bugs · Q4V-Q5G

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

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

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.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 27b1dab. 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