Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 95 additions & 4 deletions apps/sim/app/api/cron/cleanup-stale-executions/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface MockCondition {
conditions?: unknown[]
left?: unknown
right?: unknown
column?: unknown
values?: unknown
toSQL?: () => { sql: string; params: unknown[] }
}
Expand Down Expand Up @@ -89,10 +90,11 @@ describe('stale execution cleanup deadline grace', () => {
condition.right.getTime() === expectedThreshold.getTime()
)

expect(deadlineComparisons).toHaveLength(2)
expect(deadlineComparisons).toHaveLength(3)
expect(deadlineComparisons.map(({ right }) => right)).toEqual([
expectedThreshold,
expectedThreshold,
expectedThreshold,
])

const executionUpdateIndex = dbChainMockFns.update.mock.calls.findIndex(
Expand Down Expand Up @@ -131,6 +133,21 @@ describe('stale execution cleanup deadline grace', () => {
}
})

it('terminalizes stale running and redacting execution logs', async () => {
const response = await GET(createRequest())

expect(response.status).toBe(200)
const statusPredicates = dbChainMockFns.where.mock.calls
.flatMap(([condition]) => flattenConditions(condition))
.filter(
(condition) => condition.type === 'eq' && condition.left === workflowExecutionLogs.status
)

expect(statusPredicates.map(({ right }) => right)).toEqual(
expect.arrayContaining(['running', 'redacting'])
)
})

it('reports a worker cleanup deadline while preserving the generic stale fallback', async () => {
queueTableRows(asyncJobs, [{ id: 'async-job-1' }])
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'async-job-1' }])
Expand Down Expand Up @@ -175,6 +192,80 @@ describe('stale execution cleanup deadline grace', () => {
)
})

it('leaves pending and processing schedule jobs to schedule recovery', async () => {
const response = await GET(createRequest())

expect(response.status).toBe(200)
const activeAsyncPredicates = dbChainMockFns.where.mock.calls
.map(([condition]) => flattenConditions(condition))
.filter((conditions) =>
conditions.some(
(condition) =>
condition.type === 'ne' &&
condition.left === asyncJobs.type &&
condition.right === 'schedule-execution'
)
)

expect(
activeAsyncPredicates.some((conditions) =>
conditions.some(
(condition) =>
condition.type === 'eq' &&
condition.left === asyncJobs.status &&
condition.right === 'processing'
)
)
).toBe(true)
expect(
activeAsyncPredicates.some((conditions) =>
conditions.some(
(condition) =>
condition.type === 'eq' &&
condition.left === asyncJobs.status &&
condition.right === 'pending'
)
)
).toBe(true)
})

it('retains terminal schedule carriers until reconciliation is recorded', async () => {
const response = await GET(createRequest())

expect(response.status).toBe(200)
const retentionConditions = dbChainMockFns.where.mock.calls.flatMap(([condition]) =>
flattenConditions(condition)
)
const reconciliationMarker = retentionConditions.find((condition) =>
condition.toSQL?.().sql.includes('scheduleReconciled')
)

expect(reconciliationMarker?.toSQL?.().params).toContain(asyncJobs.metadata)
expect(
retentionConditions.some(
(condition) =>
condition.type === 'ne' &&
condition.left === asyncJobs.type &&
condition.right === 'schedule-execution'
)
).toBe(true)
})

it('retains irrecoverable schedule carrier tombstones indefinitely', async () => {
const response = await GET(createRequest())

expect(response.status).toBe(200)
const retentionConditions = dbChainMockFns.where.mock.calls.flatMap(([condition]) =>
flattenConditions(condition)
)
const irrecoverableExclusion = retentionConditions.find((condition) =>
condition.toSQL?.().sql.includes('scheduleRecoveryIrrecoverable')
)

expect(irrecoverableExclusion?.toSQL?.().sql).toContain("<> 'true'")
expect(irrecoverableExclusion?.toSQL?.().params).toContain(asyncJobs.metadata)
})

it('keeps table-job heartbeat cleanup independent from workflow timeout policy', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-03T12:00:00.000Z'))
Expand Down Expand Up @@ -220,8 +311,8 @@ describe('stale execution cleanup deadline grace', () => {
const response = await GET(createRequest())

expect(response.status).toBe(200)
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(7)
expect(dbChainMockFns.for).toHaveBeenCalledTimes(7)
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(8)
expect(dbChainMockFns.for).toHaveBeenCalledTimes(8)
for (const [strength, options] of dbChainMockFns.for.mock.calls) {
expect(strength).toBe('update')
expect(options).toEqual({ skipLocked: true })
Expand Down Expand Up @@ -291,7 +382,7 @@ describe('stale execution cleanup deadline grace', () => {
expect(mockDeleteFile).toHaveBeenCalledTimes(1000)

const limits = dbChainMockFns.limit.mock.calls.map(([limit]) => limit)
expect(limits.filter((limit) => limit === 100)).toHaveLength(20)
expect(limits.filter((limit) => limit === 100)).toHaveLength(21)
expect(limits.filter((limit) => limit === 1000)).toHaveLength(30)
expect(limits.filter((limit) => limit === 2000)).toHaveLength(11)

Expand Down
156 changes: 86 additions & 70 deletions apps/sim/app/api/cron/cleanup-stale-executions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, exists, gt, inArray, isNull, lt, or, sql } from 'drizzle-orm'
import { and, eq, exists, gt, inArray, isNull, lt, ne, or, sql } from 'drizzle-orm'
import { alias } from 'drizzle-orm/pg-core'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
Expand Down Expand Up @@ -140,85 +140,92 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
let currentWorkflowBatchSize = 0

try {
const staleExecutionPredicate = and(
eq(workflowExecutionLogs.status, 'running'),
or(
lt(workflowExecutionLogs.executionDeadlineAt, staleDeadlineThreshold),
and(
isNull(workflowExecutionLogs.executionDeadlineAt),
lt(workflowExecutionLogs.startedAt, staleThreshold)
)
const staleExecutionTimePredicate = or(
lt(workflowExecutionLogs.executionDeadlineAt, staleDeadlineThreshold),
and(
isNull(workflowExecutionLogs.executionDeadlineAt),
lt(workflowExecutionLogs.startedAt, staleThreshold)
)
)
const cleanupTimestamp = sql.param(now, workflowExecutionLogs.startedAt)
const staleDurationMinutes = sql<number>`ROUND(
EXTRACT(EPOCH FROM (${cleanupTimestamp} - ${workflowExecutionLogs.startedAt})) / 60
)::integer`
const totalDurationMs = elapsedDurationMsSql(now)
let workflowRowsConsidered = 0
while (workflowRowsConsidered < WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN) {
const limit = Math.min(
WORKFLOW_EXECUTION_MUTATION_BATCH_SIZE,
WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN - workflowRowsConsidered
for (const executionStatus of ['running', 'redacting'] as const) {
const staleExecutionPredicate = and(
eq(workflowExecutionLogs.status, executionStatus),
staleExecutionTimePredicate
)
currentWorkflowBatchSize = 0
const { candidates, updatedExecutions } = await db.transaction(async (tx) => {
const candidates = await tx
.select({ id: workflowExecutionLogs.id })
.from(workflowExecutionLogs)
.where(staleExecutionPredicate)
.limit(limit)
.for('update', { skipLocked: true })
currentWorkflowBatchSize = candidates.length
if (candidates.length === 0) return { candidates, updatedExecutions: [] }

const updatedExecutions = await tx
.update(workflowExecutionLogs)
.set({
status: 'failed',
endedAt: now,
executionDeadlineAt: null,
totalDurationMs,
executionData: sql`jsonb_set(
COALESCE(execution_data, '{}'::jsonb),
ARRAY['error'],
to_jsonb(
CASE
WHEN ${workflowExecutionLogs.executionDeadlineAt} IS NOT NULL
THEN ${EXECUTION_DEADLINE_ERROR}::text
ELSE ${'Execution terminated: worker timeout or crash after '}::text
|| ${staleDurationMinutes}::text
|| ' minutes'
END
)
)`,
})
.where(
and(
staleExecutionPredicate,
inArray(
workflowExecutionLogs.id,
candidates.map(({ id }) => id)
let workflowRowsConsidered = 0
while (workflowRowsConsidered < WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN) {
const limit = Math.min(
WORKFLOW_EXECUTION_MUTATION_BATCH_SIZE,
WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN - workflowRowsConsidered
)
currentWorkflowBatchSize = 0
const { candidates, updatedExecutions } = await db.transaction(async (tx) => {
const candidates = await tx
.select({ id: workflowExecutionLogs.id })
.from(workflowExecutionLogs)
.where(staleExecutionPredicate)
.limit(limit)
.for('update', { skipLocked: true })
currentWorkflowBatchSize = candidates.length
if (candidates.length === 0) return { candidates, updatedExecutions: [] }

const updatedExecutions = await tx
.update(workflowExecutionLogs)
.set({
status: 'failed',
endedAt: now,
executionDeadlineAt: null,
totalDurationMs,
executionData: sql`jsonb_set(
COALESCE(execution_data, '{}'::jsonb),
ARRAY['error'],
to_jsonb(
CASE
WHEN ${workflowExecutionLogs.executionDeadlineAt} IS NOT NULL
THEN ${EXECUTION_DEADLINE_ERROR}::text
ELSE ${'Execution terminated: worker timeout or crash after '}::text
|| ${staleDurationMinutes}::text
|| ' minutes'
END
)
)`,
})
.where(
and(
staleExecutionPredicate,
inArray(
workflowExecutionLogs.id,
candidates.map(({ id }) => id)
)
)
)
)
.returning({ id: workflowExecutionLogs.id })

return { candidates, updatedExecutions }
})
currentWorkflowBatchSize = 0
staleExecutionsFound += candidates.length
if (candidates.length === 0) break

cleaned += updatedExecutions.length
workflowRowsConsidered += candidates.length
if (candidates.length < limit) break
}

if (workflowRowsConsidered >= WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN) {
logger.info('Deferred remaining stale workflow executions after reaching the per-run cap', {
maxRowsPerRun: WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN,
})
.returning({ id: workflowExecutionLogs.id })

return { candidates, updatedExecutions }
})
currentWorkflowBatchSize = 0
staleExecutionsFound += candidates.length
if (candidates.length === 0) break

cleaned += updatedExecutions.length
workflowRowsConsidered += candidates.length
if (candidates.length < limit) break
}

if (workflowRowsConsidered >= WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN) {
logger.info(
'Deferred remaining stale workflow executions after reaching the per-run cap',
{
status: executionStatus,
maxRowsPerRun: WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN,
}
)
}
}
} catch (error) {
logger.error('Failed to clean up stale workflow executions:', {
Expand Down Expand Up @@ -253,6 +260,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
END`
const staleProcessingPredicate = and(
eq(asyncJobs.status, JOB_STATUS.PROCESSING),
ne(asyncJobs.type, 'schedule-execution'),
staleProcessingDurationPredicate
)
const staleProcessingResult = await runBatchedMutation({
Expand Down Expand Up @@ -395,6 +403,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const stalePendingPredicate = and(
eq(asyncJobs.status, JOB_STATUS.PENDING),
ne(asyncJobs.type, 'schedule-execution'),
lt(asyncJobs.createdAt, stalePendingThreshold)
)
const stalePendingResult = await runBatchedMutation({
Expand Down Expand Up @@ -441,6 +450,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const retainedJobPredicate = and(
inArray(asyncJobs.status, [JOB_STATUS.COMPLETED, JOB_STATUS.FAILED, JOB_STATUS.CANCELLED]),
or(
ne(asyncJobs.type, 'schedule-execution'),
and(
sql`${asyncJobs.metadata}->>'scheduleReconciled' = 'true'`,
sql`COALESCE(${asyncJobs.metadata}->>'scheduleRecoveryIrrecoverable', 'false') <> 'true'`
)
),
lt(asyncJobs.completedAt, retentionThreshold)
)
const retainedJobResult = await runBatchedMutation({
Expand Down
Loading
Loading