Skip to content

Commit c6c5f56

Browse files
icecrasher321claude
andcommitted
fix(deployments): stop superseded activations from dead-lettering
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>
1 parent 4bafd14 commit c6c5f56

4 files changed

Lines changed: 212 additions & 33 deletions

File tree

apps/sim/lib/webhooks/registration-store.test.ts

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ const FENCE: WebhookRegistrationOperationFence = {
6363
deploymentVersionId: 'version-3',
6464
}
6565

66+
/** The redeploy that lands seconds after {@link FENCE} and supersedes it. */
67+
const NEXT_FENCE: WebhookRegistrationOperationFence = {
68+
workflowId: 'workflow-1',
69+
operationId: 'operation-2',
70+
generation: 4,
71+
deploymentVersionId: 'version-4',
72+
}
73+
6674
interface UpdateCall {
6775
payload: Record<string, unknown>
6876
condition: Condition
@@ -125,6 +133,15 @@ function createTx(selectResults: unknown[][]) {
125133
return { tx: tx as unknown as DbOrTx, updates, inserts, updateResults }
126134
}
127135

136+
/** Routes `db.transaction` at a queue-driven tx so store writes are observable. */
137+
function runInTx(selectResults: unknown[][]) {
138+
const harness = createTx(selectResults)
139+
dbChainMockFns.transaction.mockImplementation(
140+
async (callback: (tx: DbOrTx) => Promise<unknown>) => callback(harness.tx)
141+
)
142+
return harness
143+
}
144+
128145
function activeRow(overrides: Record<string, unknown> = {}) {
129146
return {
130147
id: 'wh-active',
@@ -224,14 +241,6 @@ describe('prepareWebhookRegistrationIntents', () => {
224241
})
225242
})
226243

227-
function runInTx(selectResults: unknown[][]) {
228-
const harness = createTx(selectResults)
229-
dbChainMockFns.transaction.mockImplementation(
230-
async (callback: (tx: DbOrTx) => Promise<unknown>) => callback(harness.tx)
231-
)
232-
return harness
233-
}
234-
235244
const desired = {
236245
blockId: 'block-1',
237246
provider: 'slack',
@@ -341,3 +350,67 @@ describe('prepareWebhookRegistrationIntents', () => {
341350
expect(updates).toHaveLength(0)
342351
})
343352
})
353+
354+
describe('redeploys racing within seconds', () => {
355+
beforeEach(() => {
356+
vi.clearAllMocks()
357+
resetDbChainMock()
358+
mockClaimWebhookPath.mockResolvedValue('hooks/a')
359+
dbChainMockFns.transaction.mockImplementation(async () => {
360+
throw new Error('db.transaction not configured for this test')
361+
})
362+
})
363+
364+
const desired = {
365+
blockId: 'block-1',
366+
provider: 'slack',
367+
path: 'hooks/a',
368+
routingKey: null,
369+
providerConfig: { url: 'https://example.test' },
370+
configFingerprint: 'fp-new',
371+
}
372+
373+
it('no-ops the superseded attempt and still lands the newer registration', async () => {
374+
mockIsDeploymentOperationCurrent.mockResolvedValue(false)
375+
const superseded = runInTx([[{ id: 'workflow-1' }]])
376+
377+
await expect(
378+
prepareWebhookRegistrationIntents({ fence: FENCE, desired: [desired] })
379+
).rejects.toBeInstanceOf(StaleWebhookRegistrationOperationError)
380+
expect(superseded.inserts).toHaveLength(0)
381+
expect(superseded.updates).toHaveLength(0)
382+
expect(mockClaimWebhookPath).not.toHaveBeenCalled()
383+
384+
mockIsDeploymentOperationCurrent.mockResolvedValue(true)
385+
const winner = runInTx([[{ id: 'workflow-1' }], [], [activeRow()], [], []])
386+
387+
const work = await prepareWebhookRegistrationIntents({ fence: NEXT_FENCE, desired: [desired] })
388+
389+
expect(mockClaimWebhookPath).toHaveBeenCalledWith(expect.anything(), {
390+
path: 'hooks/a',
391+
workflowId: 'workflow-1',
392+
generation: 4,
393+
})
394+
expect(work.candidates).toHaveLength(1)
395+
expect(winner.inserts).toHaveLength(1)
396+
expect(winner.inserts[0].values).toEqual(
397+
expect.objectContaining({
398+
registrationStatus: 'candidate',
399+
registrationGeneration: 4,
400+
deploymentVersionId: 'version-4',
401+
})
402+
)
403+
404+
const activation = createTx([[{ id: 'workflow-1' }], [], []])
405+
await activateWebhookRegistrations(activation.tx, NEXT_FENCE)
406+
407+
expect(activation.updates[1].payload).toEqual(
408+
expect.objectContaining({
409+
registrationStatus: 'active',
410+
deploymentVersionId: 'version-4',
411+
isActive: true,
412+
archivedAt: null,
413+
})
414+
)
415+
})
416+
})

apps/sim/lib/workflows/deployment-lifecycle.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,13 @@ export function parseDeploymentReadiness(value: unknown): DeploymentReadiness |
129129
export const DEPLOYMENT_ERROR_CODES = {
130130
webhookPathConflict: 'webhook_path_conflict',
131131
invalidTriggerConfiguration: 'invalid_trigger_configuration',
132+
/**
133+
* A newer generation took over the workflow while this attempt was running.
134+
* Never a failure — the newer attempt owns the outcome — so it is neither
135+
* persisted on the operation nor counted as non-retryable; it exists to give
136+
* the benign hand-off a greppable identity in logs.
137+
*/
138+
operationSuperseded: 'deployment_operation_superseded',
132139
} as const
133140

134141
const NON_RETRYABLE_DEPLOYMENT_ERROR_CODES = new Set<string>([

apps/sim/lib/workflows/deployment-outbox.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,66 @@ describe('versioned deployment preparation outbox', () => {
473473
expect(mockActivateDeploymentOperation).not.toHaveBeenCalled()
474474
})
475475

476+
/**
477+
* The production shape: an attempt activates, its post-activation phase is
478+
* interrupted (handler timeout), and a redeploy lands before the reaper
479+
* requeues it. Every resumed attempt then re-fails the same generation
480+
* fence, so without the guard it exhausts the retry budget and dead-letters.
481+
*/
482+
it('skips post-activation work once a newer deploy supersedes an activated attempt', async () => {
483+
mockGetDeploymentOperation.mockResolvedValue(operation({ status: 'active', completedAt: NOW }))
484+
queueTableRows(schemaMock.workflow, [
485+
{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' },
486+
])
487+
mockCleanupRetiredWebhookRegistrations.mockRejectedValue(
488+
new Error('Webhook registration operation is stale')
489+
)
490+
491+
await expect(handler()(payload(), context(new AbortController(), 3))).resolves.toBeUndefined()
492+
493+
expect(mockCleanupRetiredWebhookRegistrations).not.toHaveBeenCalled()
494+
expect(mockRecordAudit).not.toHaveBeenCalled()
495+
expect(mockMarkDeploymentOperationFailed).not.toHaveBeenCalled()
496+
expect(mockRecordDeploymentOperationRetry).not.toHaveBeenCalled()
497+
})
498+
499+
it('resumes post-activation work while the activated attempt is still current', async () => {
500+
mockIsDeploymentOperationCurrent.mockResolvedValue(true)
501+
mockGetDeploymentOperation.mockResolvedValue(operation({ status: 'active', completedAt: NOW }))
502+
queueTableRows(schemaMock.workflow, [
503+
{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' },
504+
])
505+
506+
await handler()(payload(), context())
507+
508+
expect(mockCleanupRetiredWebhookRegistrations).toHaveBeenCalledTimes(1)
509+
expect(mockRecordAudit).toHaveBeenCalledTimes(1)
510+
expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledTimes(1)
511+
})
512+
513+
/**
514+
* Retiring the previous generation's provider subscriptions is the slowest
515+
* step after cutover; a deploy that already went live must not lose its
516+
* audit trail or its "deployment changed" notification when that step fails.
517+
*/
518+
it('records and notifies an activated deploy before retiring old subscriptions', async () => {
519+
mockIsDeploymentOperationCurrent.mockResolvedValue(true)
520+
mockGetDeploymentOperation.mockResolvedValue(operation({ status: 'active', completedAt: NOW }))
521+
queueTableRows(schemaMock.workflow, [
522+
{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' },
523+
])
524+
mockCleanupRetiredWebhookRegistrations.mockRejectedValue(new Error('provider unavailable'))
525+
526+
await expect(handler()(payload(), context())).rejects.toThrow('provider unavailable')
527+
528+
expect(mockRecordAudit).toHaveBeenCalledTimes(1)
529+
expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1)
530+
expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledTimes(1)
531+
expect(mockRecordAudit.mock.invocationCallOrder[0]).toBeLessThan(
532+
mockCleanupRetiredWebhookRegistrations.mock.invocationCallOrder[0]
533+
)
534+
})
535+
476536
it('keeps v1 cleanup from deleting a candidate owned by the current v2 operation', async () => {
477537
queueTableRows(schemaMock.workflow, [
478538
{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' },

apps/sim/lib/workflows/deployment-outbox.ts

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -326,19 +326,33 @@ async function prepareDeploymentOperation(
326326
}
327327

328328
if (operation.status === 'active') {
329-
await cleanupRetiredWebhooksForOperation({
330-
payload,
331-
workflow: workflowRecord as Record<string, unknown>,
332-
context,
333-
})
334-
await cleanupInactiveDeploymentsForOperation({
335-
payload,
336-
workflow: workflowRecord as Record<string, unknown>,
337-
checkpoints,
338-
checkpoint,
339-
context,
329+
/**
330+
* Resuming an attempt that already activated: every remaining step is
331+
* fenced to this generation, so once a newer one exists they can only
332+
* fail, identically, on every retry until the event dead-letters. The
333+
* terminal short circuit above cannot catch this — a superseded-after-
334+
* activation attempt keeps its own `active` status — and the newer
335+
* generation adopts the leftover work anyway, retired registrations
336+
* included (it collects every retired row below its own fence).
337+
*/
338+
context.signal.throwIfAborted()
339+
const isCurrent = await isDeploymentOperationCurrent({
340+
workflowId: payload.workflowId,
341+
operationId: payload.operationId,
342+
generation: payload.generation,
340343
})
341-
await emitPostActivationSideEffects({
344+
context.signal.throwIfAborted()
345+
if (!isCurrent) {
346+
logger.info('Skipping post-activation work for a superseded generation', {
347+
workflowId: payload.workflowId,
348+
operationId: payload.operationId,
349+
generation: payload.generation,
350+
errorCode: DEPLOYMENT_ERROR_CODES.operationSuperseded,
351+
})
352+
return
353+
}
354+
355+
await runPostActivationWork({
342356
payload,
343357
operation,
344358
workflow: workflowRecord as Record<string, unknown>,
@@ -488,19 +502,7 @@ async function prepareDeploymentOperation(
488502
notifyMcpToolServers(affectedMcpServers)
489503
context.signal.throwIfAborted()
490504

491-
await cleanupRetiredWebhooksForOperation({
492-
payload,
493-
workflow: workflowRecord as Record<string, unknown>,
494-
context,
495-
})
496-
await cleanupInactiveDeploymentsForOperation({
497-
payload,
498-
workflow: workflowRecord as Record<string, unknown>,
499-
checkpoints,
500-
checkpoint,
501-
context,
502-
})
503-
await emitPostActivationSideEffects({
505+
await runPostActivationWork({
504506
payload,
505507
operation,
506508
workflow: workflowRecord as Record<string, unknown>,
@@ -510,6 +512,43 @@ async function prepareDeploymentOperation(
510512
})
511513
}
512514

515+
/**
516+
* Runs everything that follows a committed cutover — notifications first.
517+
*
518+
* The ordering is load-bearing. The audit entry, analytics event, socket
519+
* notification, and workspace event all describe an activation that is
520+
* already durable, and each is individually checkpointed. Retiring the
521+
* previous generation's external subscriptions is best-effort cleanup that
522+
* makes one provider call per retired row and is by far the slowest, most
523+
* failure-prone step here. Running cleanup first put every one of those
524+
* notifications behind it, so a single flaky provider — or the handler
525+
* timeout its latency burns through — silently cost the deploy its audit
526+
* trail and left clients on the old version until something else refreshed
527+
* them. Nothing below depends on the cleanup having run.
528+
*/
529+
async function runPostActivationWork(params: {
530+
payload: PrepareDeploymentV2Payload
531+
operation: WorkflowDeploymentOperation
532+
workflow: Record<string, unknown>
533+
checkpoints: DeploymentPreparationCheckpoints
534+
checkpoint: (patch: Partial<DeploymentPreparationCheckpoints>) => Promise<void>
535+
context: OutboxEventContext
536+
}): Promise<void> {
537+
await emitPostActivationSideEffects(params)
538+
await cleanupRetiredWebhooksForOperation({
539+
payload: params.payload,
540+
workflow: params.workflow,
541+
context: params.context,
542+
})
543+
await cleanupInactiveDeploymentsForOperation({
544+
payload: params.payload,
545+
workflow: params.workflow,
546+
checkpoints: params.checkpoints,
547+
checkpoint: params.checkpoint,
548+
context: params.context,
549+
})
550+
}
551+
513552
async function prepareReadinessComponent(params: {
514553
payload: PrepareDeploymentV2Payload
515554
operation: WorkflowDeploymentOperation

0 commit comments

Comments
 (0)