Skip to content

Commit def9273

Browse files
icecrasher321claude
andcommitted
fix(deployments): fence the cleanup, not the notifications
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>
1 parent 3f13f9c commit def9273

2 files changed

Lines changed: 65 additions & 34 deletions

File tree

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@ describe('versioned deployment preparation outbox', () => {
222222
})
223223

224224
it('activates only after every preparation component is ready', async () => {
225+
/** Nothing newer has been enqueued, so this deploy owns its generation. */
226+
mockIsDeploymentOperationCurrent.mockResolvedValue(true)
225227
const preparing = operation()
226228
const webhooksReady = operation({
227229
componentReadiness: {
@@ -318,11 +320,10 @@ describe('versioned deployment preparation outbox', () => {
318320
)
319321

320322
/**
321-
* The resume still owns the latest generation, so it re-enters
322-
* post-activation work and the checkpoints — not the generation gate —
323-
* are what must keep analytics from being captured twice.
323+
* The resume re-enters post-activation work, so the checkpoints — not the
324+
* generation fence — are what must keep analytics from being captured
325+
* twice.
324326
*/
325-
mockIsDeploymentOperationCurrent.mockResolvedValue(true)
326327
mockGetDeploymentOperation.mockResolvedValue(active)
327328
queueTableRows(schemaMock.workflow, [
328329
{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' },
@@ -545,7 +546,7 @@ describe('versioned deployment preparation outbox', () => {
545546
* requeues it. Every resumed attempt then re-fails the same generation
546547
* fence, so without the guard it exhausts the retry budget and dead-letters.
547548
*/
548-
it('skips post-activation work once a newer deploy supersedes an activated attempt', async () => {
549+
it('skips the fenced cleanup once a newer deploy supersedes an activated attempt', async () => {
549550
mockGetDeploymentOperation.mockResolvedValue(operation({ status: 'active', completedAt: NOW }))
550551
queueTableRows(schemaMock.workflow, [
551552
{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' },
@@ -557,11 +558,30 @@ describe('versioned deployment preparation outbox', () => {
557558
await expect(handler()(payload(), context(new AbortController(), 3))).resolves.toBeUndefined()
558559

559560
expect(mockCleanupRetiredWebhookRegistrations).not.toHaveBeenCalled()
560-
expect(mockRecordAudit).not.toHaveBeenCalled()
561561
expect(mockMarkDeploymentOperationFailed).not.toHaveBeenCalled()
562562
expect(mockRecordDeploymentOperationRetry).not.toHaveBeenCalled()
563563
})
564564

565+
/**
566+
* `isDeploymentOperationCurrent` goes false the moment any newer generation
567+
* row exists, including one still `preparing` or already `failed`. This
568+
* activation is the live cutover in that window and no newer attempt will
569+
* adopt its notifications, so the fence must cost it only the cleanup.
570+
*/
571+
it('still notifies when the newer generation has not activated', async () => {
572+
mockGetDeploymentOperation.mockResolvedValue(operation({ status: 'active', completedAt: NOW }))
573+
queueTableRows(schemaMock.workflow, [
574+
{ id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' },
575+
])
576+
577+
await expect(handler()(payload(), context())).resolves.toBeUndefined()
578+
579+
expect(mockRecordAudit).toHaveBeenCalledTimes(1)
580+
expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1)
581+
expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledTimes(1)
582+
expect(mockCleanupRetiredWebhookRegistrations).not.toHaveBeenCalled()
583+
})
584+
565585
it('resumes post-activation work while the activated attempt is still current', async () => {
566586
mockIsDeploymentOperationCurrent.mockResolvedValue(true)
567587
mockGetDeploymentOperation.mockResolvedValue(operation({ status: 'active', completedAt: NOW }))

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

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -330,31 +330,14 @@ async function prepareDeploymentOperation(
330330

331331
if (operation.status === 'active') {
332332
/**
333-
* Resuming an attempt that already activated: every remaining step is
334-
* fenced to this generation, so once a newer one exists they can only
335-
* fail, identically, on every retry until the event dead-letters. The
336-
* terminal short circuit above cannot catch this — a superseded-after-
337-
* activation attempt keeps its own `active` status — and the newer
338-
* generation adopts the leftover work anyway, retired registrations
339-
* included (it collects every retired row below its own fence).
333+
* Resuming an attempt that already activated. The terminal short circuit
334+
* above cannot catch this case — a superseded-after-activation attempt
335+
* keeps its own `active` status — so the generation fence is applied per
336+
* step inside {@link runPostActivationWork} rather than here: the
337+
* notifications describe a cutover that really happened and stay owed
338+
* whatever else has started since, while only the fenced cleanup is
339+
* skipped.
340340
*/
341-
context.signal.throwIfAborted()
342-
const isCurrent = await isDeploymentOperationCurrent({
343-
workflowId: payload.workflowId,
344-
operationId: payload.operationId,
345-
generation: payload.generation,
346-
})
347-
context.signal.throwIfAborted()
348-
if (!isCurrent) {
349-
logger.info('Skipping post-activation work for a superseded generation', {
350-
workflowId: payload.workflowId,
351-
operationId: payload.operationId,
352-
generation: payload.generation,
353-
errorCode: DEPLOYMENT_ERROR_CODES.operationSuperseded,
354-
})
355-
return
356-
}
357-
358341
await runPostActivationWork({
359342
payload,
360343
operation,
@@ -528,6 +511,12 @@ async function prepareDeploymentOperation(
528511
* timeout its latency burns through — silently cost the deploy its audit
529512
* trail and left clients on the old version until something else refreshed
530513
* them. Nothing below depends on the cleanup having run.
514+
*
515+
* It also decides where the generation fence goes. Both cleanups carry their
516+
* own, because only they are fenced; the notifications are not, and gating
517+
* them on the same predicate would drop them for good in the window where a
518+
* newer generation exists but has not activated — this activation is still
519+
* the live one there, and nothing else will emit them.
531520
*/
532521
async function runPostActivationWork(params: {
533522
payload: PrepareDeploymentV2Payload
@@ -598,13 +587,35 @@ async function cleanupRetiredWebhooksForOperation(params: {
598587
context: OutboxEventContext
599588
}): Promise<void> {
600589
params.context.signal.throwIfAborted()
601-
await cleanupRetiredWebhookRegistrationsAfterActivation({
602-
fence: {
590+
const fence = {
591+
workflowId: params.payload.workflowId,
592+
operationId: params.payload.operationId,
593+
generation: params.payload.generation,
594+
deploymentVersionId: params.payload.deploymentVersionId,
595+
}
596+
597+
/**
598+
* Gated exactly like {@link cleanupInactiveDeploymentsForOperation} below,
599+
* and on the same predicate the store asserts internally — the store throws
600+
* where this returns, so a superseded attempt would otherwise fail here
601+
* identically on every retry until the event dead-lettered. Skipping loses
602+
* nothing: a newer generation collects every retired row below its own
603+
* fence, this one included.
604+
*/
605+
const isCurrent = await isDeploymentOperationCurrent({ ...fence, statuses: ['active'] })
606+
params.context.signal.throwIfAborted()
607+
if (!isCurrent) {
608+
logger.info('Skipping retired webhook cleanup for a superseded generation', {
603609
workflowId: params.payload.workflowId,
604610
operationId: params.payload.operationId,
605611
generation: params.payload.generation,
606-
deploymentVersionId: params.payload.deploymentVersionId,
607-
},
612+
errorCode: DEPLOYMENT_ERROR_CODES.operationSuperseded,
613+
})
614+
return
615+
}
616+
617+
await cleanupRetiredWebhookRegistrationsAfterActivation({
618+
fence,
608619
workflow: params.workflow,
609620
requestId: params.payload.requestId,
610621
signal: params.context.signal,

0 commit comments

Comments
 (0)