Skip to content

Commit e0e6f24

Browse files
authored
improvement(deployments): shorten lock windows and add tx safety timeouts on the deploy path (#5841)
1 parent c708add commit e0e6f24

7 files changed

Lines changed: 273 additions & 102 deletions

File tree

apps/sim/app/api/cron/cleanup-stale-executions/route.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { asyncJobs, db } from '@sim/db'
2-
import { tableJobs, workflowExecutionLogs } from '@sim/db/schema'
2+
import { tableJobs, workflowDeploymentOperation, workflowExecutionLogs } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { toError } from '@sim/utils/errors'
5-
import { and, eq, inArray, lt, sql } from 'drizzle-orm'
5+
import { and, eq, exists, gt, inArray, lt, sql } from 'drizzle-orm'
6+
import { alias } from 'drizzle-orm/pg-core'
67
import { type NextRequest, NextResponse } from 'next/server'
78
import { verifyCronAuth } from '@/lib/auth/internal'
89
import { JOB_RETENTION_HOURS, JOB_STATUS } from '@/lib/core/async-jobs'
@@ -17,6 +18,14 @@ const STALE_THRESHOLD_MINUTES = Math.ceil(STALE_THRESHOLD_MS / 60000)
1718
const MAX_INT32 = 2_147_483_647
1819
/** Terminal table-jobs older than this are pruned; only the latest job per table is ever read. */
1920
const TABLE_JOB_RETENTION_HOURS = 24
21+
/**
22+
* Terminal deployment operations older than this are pruned. Every reader of
23+
* this table is latest-generation-only, and idempotency keys only need to
24+
* survive a client retry window, so 30 days is generous.
25+
*/
26+
const DEPLOYMENT_OPERATION_RETENTION_DAYS = 30
27+
const DEPLOYMENT_OPERATION_PRUNE_BATCH_SIZE = 2000
28+
const DEPLOYMENT_OPERATION_PRUNE_MAX_BATCHES = 10
2029

2130
export const GET = withRouteHandler(async (request: NextRequest) => {
2231
try {
@@ -221,6 +230,64 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
221230
})
222231
}
223232

233+
/**
234+
* Prune terminal deployment operations past retention. HARD INVARIANT:
235+
* the newest-generation row per workflow must always survive — the next
236+
* deploy computes `generation = MAX(generation) + 1`, and the webhook
237+
* registration store fences rows with lt/gt comparisons against stored
238+
* generations, so generation reuse after a full wipe would permanently
239+
* wedge that workflow's deployments. The `exists(newer)` predicate
240+
* guarantees the max-generation row is never eligible; the status filter
241+
* keeps in-flight rows (an outbox worker may still hold their fence).
242+
*/
243+
let deploymentOperationsPruned = 0
244+
try {
245+
const deploymentOpRetention = new Date(
246+
Date.now() - DEPLOYMENT_OPERATION_RETENTION_DAYS * 24 * 60 * 60 * 1000
247+
)
248+
const newerOperation = alias(workflowDeploymentOperation, 'newer_operation')
249+
for (let batch = 0; batch < DEPLOYMENT_OPERATION_PRUNE_MAX_BATCHES; batch++) {
250+
const prunable = db
251+
.select({ id: workflowDeploymentOperation.id })
252+
.from(workflowDeploymentOperation)
253+
.where(
254+
and(
255+
inArray(workflowDeploymentOperation.status, ['active', 'failed', 'superseded']),
256+
lt(workflowDeploymentOperation.completedAt, deploymentOpRetention),
257+
exists(
258+
db
259+
.select({ id: newerOperation.id })
260+
.from(newerOperation)
261+
.where(
262+
and(
263+
eq(newerOperation.workflowId, workflowDeploymentOperation.workflowId),
264+
gt(newerOperation.generation, workflowDeploymentOperation.generation)
265+
)
266+
)
267+
)
268+
)
269+
)
270+
.limit(DEPLOYMENT_OPERATION_PRUNE_BATCH_SIZE)
271+
272+
const deleted = await db
273+
.delete(workflowDeploymentOperation)
274+
.where(inArray(workflowDeploymentOperation.id, prunable))
275+
.returning({ id: workflowDeploymentOperation.id })
276+
277+
deploymentOperationsPruned += deleted.length
278+
if (deleted.length < DEPLOYMENT_OPERATION_PRUNE_BATCH_SIZE) break
279+
}
280+
if (deploymentOperationsPruned > 0) {
281+
logger.info(
282+
`Pruned ${deploymentOperationsPruned} old deployment operations (retention: ${DEPLOYMENT_OPERATION_RETENTION_DAYS}d)`
283+
)
284+
}
285+
} catch (error) {
286+
logger.error('Failed to prune old deployment operations:', {
287+
error: toError(error).message,
288+
})
289+
}
290+
224291
return NextResponse.json({
225292
success: true,
226293
executions: {
@@ -239,6 +306,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
239306
tableJobs: {
240307
staleMarkedFailed: staleTableJobsMarkedFailed,
241308
},
309+
deploymentOperations: {
310+
pruned: deploymentOperationsPruned,
311+
retentionDays: DEPLOYMENT_OPERATION_RETENTION_DAYS,
312+
},
242313
})
243314
} catch (error) {
244315
logger.error('Error in stale execution cleanup job:', error)

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

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,18 @@ vi.mock('@sim/db', () => ({
2525
vi.mock('drizzle-orm', () => ({
2626
and: (...conditions: Condition[]) => ({ kind: 'and', conditions }),
2727
eq: (column: unknown, value: unknown) => ({ kind: 'eq', column, value }),
28+
exists: (subquery: unknown) => ({ kind: 'exists', subquery }),
2829
gt: (column: unknown, value: unknown) => ({ kind: 'gt', column, value }),
2930
inArray: (column: unknown, value: unknown) => ({ kind: 'inArray', column, value }),
3031
isNull: (column: unknown) => ({ kind: 'isNull', column }),
3132
lt: (column: unknown, value: unknown) => ({ kind: 'lt', column, value }),
3233
lte: (column: unknown, value: unknown) => ({ kind: 'lte', column, value }),
34+
notExists: (subquery: unknown) => ({ kind: 'notExists', subquery }),
35+
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({
36+
kind: 'sql',
37+
strings: [...strings],
38+
values,
39+
}),
3340
}))
3441

3542
vi.mock('@/lib/webhooks/provider-subscriptions', () => ({
@@ -42,6 +49,7 @@ vi.mock('@/lib/webhooks/path-claims', () => ({
4249

4350
vi.mock('@/lib/workflows/persistence/deployment-operations', () => ({
4451
isDeploymentOperationCurrent: mockIsDeploymentOperationCurrent,
52+
setDeploymentTxTimeouts: vi.fn(),
4553
}))
4654

4755
import type { DbOrTx } from '@sim/workflow-persistence/types'
@@ -182,22 +190,22 @@ describe('activateWebhookRegistrations', () => {
182190

183191
await activateWebhookRegistrations(tx, FENCE)
184192

185-
expect(updates).toHaveLength(3)
186-
expect(updates[0].payload).toEqual(
187-
expect.objectContaining({ registrationStatus: 'retired', isActive: false })
193+
expect(updates).toHaveLength(2)
194+
/**
195+
* Retire + repoint fold into one generation-conditional statement over
196+
* the active rows: every mutated column is a CASE keyed on the fence
197+
* generation, and the WHERE covers both phases via lte.
198+
*/
199+
expect(updates[0].payload.registrationStatus).toEqual(expect.objectContaining({ kind: 'sql' }))
200+
expect(updates[0].payload.deploymentVersionId).toEqual(
201+
expect.objectContaining({ kind: 'sql', values: expect.arrayContaining(['version-3']) })
188202
)
189-
expect(updates[0].payload.archivedAt).toBeInstanceOf(Date)
190-
expect(JSON.stringify(updates[0].condition)).toContain('"lt"')
203+
expect(updates[0].payload.isActive).toEqual(expect.objectContaining({ kind: 'sql' }))
204+
expect(updates[0].payload.archivedAt).toEqual(expect.objectContaining({ kind: 'sql' }))
205+
expect(updates[0].payload.updatedAt).toBeInstanceOf(Date)
206+
expect(JSON.stringify(updates[0].condition)).toContain('"lte"')
191207

192208
expect(updates[1].payload).toEqual(
193-
expect.objectContaining({
194-
deploymentVersionId: 'version-3',
195-
isActive: true,
196-
archivedAt: null,
197-
})
198-
)
199-
200-
expect(updates[2].payload).toEqual(
201209
expect.objectContaining({
202210
registrationStatus: 'active',
203211
deploymentVersionId: 'version-3',

0 commit comments

Comments
 (0)