Skip to content

fix(deployments): stop superseded activations from dead-lettering - #6522

Merged
icecrasher321 merged 6 commits into
stagingfrom
staging-v33
Aug 23, 2026
Merged

fix(deployments): stop superseded activations from dead-lettering#6522
icecrasher321 merged 6 commits into
stagingfrom
staging-v33

Conversation

@icecrasher321

@icecrasher321 icecrasher321 commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

What

29 workflow.deployment.prepare.v2 outbox events dead-lettered with Webhook registration operation is stale, spanning 2026-07-21 → 2026-08-09 across 13 workflows. Every one sat at attempts = max_attempts.

That full retry budget is the tell: the failure is deterministic, which rules out the preparation path. An attempt superseded while preparing is marked superseded, so its next attempt short-circuits at the top of the handler and completes — it would die at attempts = 1, never 4.

The branch a retry re-enters is the other one. isTerminalNonActiveOperation covers failed and superseded but not active, so an attempt that activated and was then superseded by the next deploy keeps its own active status, re-enters post-activation work on every retry, and re-fails the same generation fence until the event dies.

The fence it fails is correct — it takes the same workflow row lock the generation bump takes, and compares generations exactly. Nothing about the detection is racy; only the reaction to it was wrong. Reaching it at all needs a handler timeout, which parks the row for the 10-minute reaper instead of the 2s/4s/8s backoff, opening a window wide enough for a redeploy to land. That is why the same workflow dominates both this class and the Outbox handler timed out after 90000ms class.

Changes

Gate the resume branch on still owning the current generation, matching the sibling cleanup that already does this, and complete the event as a no-op when it does not. The newer generation adopts the leftover work anyway — it collects every retired registration below its own fence, so nothing leaks.

Reverse the post-activation order. The audit entry, analytics event, socket notification, and workspace event describe a cutover that is already durable, and each is separately checkpointed — but they ran behind retiring the previous generation's external subscriptions, one provider call per retired row and by far the most failure-prone step there. A single flaky provider silently cost the deploy its audit trail and left clients on the old version until something else refreshed them. Both call sites now share one helper so the order cannot drift apart again.

Notes

  • No new guards, try/catch, or fallbacks — the gate completes an existing terminal-state short-circuit that was missing a case, and the reorder moves existing work.
  • Zero pre-existing tests changed; all test diffs are additive. The one deletion hoists a shared runInTx helper to module scope for a new describe block.
  • Three new tests, each verified to fail against the unfixed source: superseded-resume no-op, still-current resume, and notify-before-cleanup ordering.
  • Not covered: whatever makes that workflow's prepare step slow enough to hit 90s. This stops it manufacturing dead letters; it does not make it faster.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 23, 2026 12:22am

Request Review

@cursor

cursor Bot commented Aug 11, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches the deployment outbox cutover path: generation fencing, notification vs cleanup order, and analytics delivery. Correctness of live deploys and webhook retirement depends on the new skip/order behavior.

Overview
Stops workflow.deployment.prepare.v2 outbox events from exhausting retries after an already-active attempt is superseded. That path kept active status, re-entered post-activation work, and hit the webhook generation fence on every retry until dead-letter.

Fenced cleanup is now a no-op when a newer generation owns the workflow (isDeploymentOperationCurrent with active). Notifications still run: they describe a cutover that already happened, and a newer attempt that has not activated will not emit them. The newer generation still retires leftover webhook rows under its own fence.

Post-activation order is reversed via shared runPostActivationWork: audit, analytics, socket, and workspace events run before retiring old provider subscriptions. Cleanup is the slowest, most failure-prone step; it no longer can drop the audit trail or leave clients on the old version.

Analytics is fire-and-forget again: deliverOutboxServerEvent (capture + flush) is removed in favor of captureServerEvent, which never throws. The analytics checkpoint advances on capture so PostHog outages cannot fail a durable activation.

Reviewed by Cursor Bugbot for commit def9273. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR prevents superseded deployment activations from repeatedly failing generation-fenced cleanup and moves durable cutover notifications ahead of failure-prone external cleanup.

  • Adds generation ownership checks before retired-webhook cleanup.
  • Consolidates post-activation work so audit, analytics, socket, and workspace notifications run before cleanup.
  • Restores fire-and-forget server analytics capture and adds regression coverage for retry, supersession, ordering, and capture behavior.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/sim/lib/workflows/deployment-outbox.ts Centralizes post-activation work, emits checkpointed notifications before cleanup, and skips generation-fenced cleanup for superseded activations.
apps/sim/lib/workflows/deployment-outbox.test.ts Adds regression coverage for superseded resumes, current-generation resumes, notification ordering, and fire-and-forget analytics.
apps/sim/lib/posthog/server.ts Removes the outbox-specific flush helper in favor of the existing non-throwing synchronous capture contract.
apps/sim/lib/posthog/server.test.ts Pins synchronous capture, error swallowing, and PostHog insert-ID forwarding.
apps/sim/lib/workflows/deployment-lifecycle.ts Adds a log-oriented error-code identity for benign operation supersession.
apps/sim/lib/webhooks/registration-store.test.ts Adds coverage showing a stale deployment cannot write registrations while the succeeding generation can prepare and activate them.

Sequence Diagram

sequenceDiagram
  participant O as Deployment Outbox
  participant N as Post-activation Notifications
  participant G as Generation Fence
  participant W as Webhook Cleanup
  O->>N: Emit checkpointed cutover side effects
  N-->>O: Audit, analytics, socket, workspace event
  O->>G: Is this active operation current?
  alt Current generation
    G-->>O: Yes
    O->>W: Retire previous webhook registrations
  else Superseded generation
    G-->>O: No
    O-->>O: Complete cleanup as a no-op
  end
Loading

Reviews (4): Last reviewed commit: "fix(deployments): fence the cleanup, not..." | Re-trigger Greptile

Comment thread apps/sim/lib/billing/entitlement-drift.ts Outdated
Comment thread apps/sim/lib/billing/entitlement-drift.ts Outdated
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 8fc4c4c. Configure here.

29 workflow.deployment.prepare.v2 events dead-lettered with "Webhook
registration operation is stale", every one at attempts = max_attempts. A full
retry budget means the failure is deterministic, which rules out the
preparation path: an attempt superseded while preparing is marked superseded,
so its next attempt short-circuits at the top of the handler and completes.

The branch a retry re-enters is the other one. isTerminalNonActiveOperation
covers failed and superseded but not active, so an attempt that activated and
was then superseded by the next deploy keeps its own active status, re-enters
post-activation work on every retry, and re-fails the same generation fence
until the event dies. The fence it fails is correct — it takes the same
workflow row lock the generation bump takes, and compares generations exactly
— so nothing about the detection is racy; only the reaction to it was wrong.
Reaching it needs a handler timeout, which parks the row for the 10-minute
reaper instead of the 2s/4s/8s backoff, opening a window wide enough for a
redeploy to land.

Gate the resume branch on the operation still owning the current generation,
matching the sibling cleanup that already does this, and complete the event as
a no-op when it does not. The newer generation adopts the leftover work
anyway: it collects every retired registration below its own fence.

Also reverse the post-activation order. The audit entry, analytics event,
socket notification, and workspace event describe a cutover that is already
durable, and each is separately checkpointed, but they ran behind retiring the
previous generation's external subscriptions — one provider call per retired
row, and by far the most failure-prone step there. A single flaky provider
silently cost the deploy its audit trail and left clients on the old version
until something else refreshed them. Both call sites now share one helper so
the order cannot drift apart again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@icecrasher321 icecrasher321 changed the title fix(billing,deployments): recover silently dropped post-event writes fix(deployments): stop superseded activations from dead-lettering Aug 11, 2026
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

Dropped the billing reconciler from this PR — the root cause it backstopped is already fixed by #6510, and the three affected users need a one-time backfill rather than a permanent sweep. This PR is now deployment-only: 4 files, +212/-33.

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit c6c5f56. Configure here.

icecrasher321 and others added 2 commits August 22, 2026 16:47
…eneration

Two tests that staging added alongside durable PostHog delivery drive the
`operation.status === 'active'` resume branch this PR now gates, and neither
overrides `mockIsDeploymentOperationCurrent` — the suite's `beforeEach`
defaults it to `false`. Under the new gate they read as superseded, so the
handler short-circuits: the delivery test sees a resolve where it asserts a
rejection, and the unconsumed workflow row leaks into the next test.

Both are still-current resumes by intent, so they say so explicitly. The
checkpoints, not the generation gate, remain what keeps analytics from being
captured twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

bugbot run

`deliverOutboxServerEvent` awaited `client.flush()` before letting the
deployment outbox checkpoint advance, so an unreachable PostHog failed the
event, retried it, and eventually dead-lettered it — while holding the socket
notification, the workspace event, and retired-subscription cleanup behind a
third party. `flush()` also drains the whole shared client queue, so an
unrelated event's network error surfaced here as a failed deploy.

It bought no durability the process did not already have: the outbox handler
runs in the long-lived app container, where the client flushes on its own
10s interval and again from the `SIGTERM`/`SIGINT` hook in
`instrumentation-node.ts`. The helper had one caller and arrived inside an
unrelated squashed PR (#5273) with no rationale, against 161 fire-and-forget
`captureServerEvent` call sites.

Deleted it and restored `captureServerEvent`, whose contract is already
"never throws". `insertId` still collapses retried captures. That contract
was untested, which is why this regressed unnoticed, so `server.test.ts` now
pins it — spying on the real client, since the lazy `require` defeats
`vi.mock` and a disabled client would pass every assertion vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread apps/sim/lib/workflows/deployment-outbox.ts Outdated
icecrasher321 and others added 2 commits August 22, 2026 17:15
The resume guard sat at the top of the `active` branch, so it skipped every
post-activation step whenever `isDeploymentOperationCurrent` went false. That
predicate goes false as soon as any newer generation row exists — including one
still `preparing` or already `failed` — and in that window this activation is
still the live cutover. Nothing newer would ever adopt its audit entry,
analytics event, socket notification, or workspace event, so the guard
permanently dropped them and completed the outbox event as if they were owed to
someone else.

Only the two cleanups are generation-fenced. `cleanupInactiveDeploymentsForOperation`
already gated itself on that exact predicate and returned quietly; the retired
webhook cleanup was the one that instead let the store's `assertCurrentOperation`
throw. It now carries the same guard, on the same fence the store asserts —
`deploymentVersionId` and `statuses: ['active']` included, so passing the gate
actually implies passing the assert. The notifications run unconditionally,
still idempotent through their checkpoints.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit def9273. Configure here.

@icecrasher321
icecrasher321 merged commit 763f3aa into staging Aug 23, 2026
30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant