Skip to content

Commit 141d697

Browse files
fix(api): guard cancelled job transitions
1 parent 0892cdb commit 141d697

2 files changed

Lines changed: 69 additions & 18 deletions

File tree

apps/sim/lib/core/async-jobs/backends/database.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
4+
import { dbChainMock, dbChainMockFns, flattenMockConditions, resetDbChainMock } from '@sim/testing'
55
import { sleep } from '@sim/utils/helpers'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77

88
vi.mock('@sim/db', () => ({
99
asyncJobs: {
1010
attempts: 'attempts',
1111
id: 'id',
12+
status: 'status',
1213
},
1314
db: dbChainMock.db,
1415
}))
@@ -120,6 +121,7 @@ describe('DatabaseJobQueue cancelJob', () => {
120121
})
121122

122123
it('persists cancellation as its own terminal status', async () => {
124+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'workflow:1' }])
123125
const queue = new DatabaseJobQueue()
124126

125127
await queue.cancelJob('workflow:1')
@@ -128,4 +130,30 @@ describe('DatabaseJobQueue cancelJob', () => {
128130
expect.objectContaining({ status: 'cancelled', error: 'Cancelled' })
129131
)
130132
})
133+
134+
it('does not let worker failure overwrite a terminal cancellation', async () => {
135+
const queue = new DatabaseJobQueue()
136+
137+
await queue.markJobFailed('workflow:1', 'aborted')
138+
139+
const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0])
140+
expect(conditions).toContainEqual({
141+
type: 'inArray',
142+
column: 'status',
143+
values: ['pending', 'processing'],
144+
})
145+
})
146+
147+
it('does not let worker completion overwrite a terminal cancellation', async () => {
148+
const queue = new DatabaseJobQueue()
149+
150+
await queue.completeJob('workflow:1', { ok: true })
151+
152+
const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0])
153+
expect(conditions).toContainEqual({
154+
type: 'inArray',
155+
column: 'status',
156+
values: ['pending', 'processing'],
157+
})
158+
})
131159
})

apps/sim/lib/core/async-jobs/backends/database.ts

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { asyncJobs, db } from '@sim/db'
22
import { createLogger } from '@sim/logger'
33
import { toError } from '@sim/utils/errors'
44
import { generateShortId } from '@sim/utils/id'
5-
import { eq, sql } from 'drizzle-orm'
5+
import { and, eq, inArray, sql } from 'drizzle-orm'
66
import {
77
AsyncJobEnqueueError,
88
type EnqueueOptions,
@@ -261,7 +261,7 @@ export class DatabaseJobQueue implements JobQueueBackend {
261261
attempts: sql`${asyncJobs.attempts} + 1`,
262262
updatedAt: now,
263263
})
264-
.where(eq(asyncJobs.id, jobId))
264+
.where(and(eq(asyncJobs.id, jobId), eq(asyncJobs.status, JOB_STATUS.PENDING)))
265265

266266
logger.debug('Started job', { jobId })
267267
}
@@ -277,7 +277,12 @@ export class DatabaseJobQueue implements JobQueueBackend {
277277
output: output as Record<string, unknown>,
278278
updatedAt: now,
279279
})
280-
.where(eq(asyncJobs.id, jobId))
280+
.where(
281+
and(
282+
eq(asyncJobs.id, jobId),
283+
inArray(asyncJobs.status, [JOB_STATUS.PENDING, JOB_STATUS.PROCESSING])
284+
)
285+
)
281286

282287
logger.debug('Completed job', { jobId })
283288
}
@@ -293,33 +298,45 @@ export class DatabaseJobQueue implements JobQueueBackend {
293298
error,
294299
updatedAt: now,
295300
})
296-
.where(eq(asyncJobs.id, jobId))
301+
.where(
302+
and(
303+
eq(asyncJobs.id, jobId),
304+
inArray(asyncJobs.status, [JOB_STATUS.PENDING, JOB_STATUS.PROCESSING])
305+
)
306+
)
297307

298308
logger.debug('Marked job as failed', { jobId })
299309
}
300310

301311
async cancelJob(jobId: string): Promise<void> {
302-
// Abort any in-process inline execution first so the running workflow
303-
// observes the signal and stops mid-flight. Then mark the row failed so
304-
// any future poller skips it.
305-
const controller = inlineAbortControllers.get(jobId)
306-
let aborted = false
307-
if (controller) {
308-
controller.abort('Cancelled')
309-
inlineAbortControllers.delete(jobId)
310-
aborted = true
311-
}
312-
313312
const now = new Date()
314-
await db
313+
const cancelledJobs = await db
315314
.update(asyncJobs)
316315
.set({
317316
status: JOB_STATUS.CANCELLED,
318317
completedAt: now,
319318
error: 'Cancelled',
320319
updatedAt: now,
321320
})
322-
.where(eq(asyncJobs.id, jobId))
321+
.where(
322+
and(
323+
eq(asyncJobs.id, jobId),
324+
inArray(asyncJobs.status, [JOB_STATUS.PENDING, JOB_STATUS.PROCESSING])
325+
)
326+
)
327+
.returning({ id: asyncJobs.id })
328+
329+
if (cancelledJobs.length === 0) {
330+
logger.debug('Cancel target is no longer active in DB queue', { jobId })
331+
return
332+
}
333+
334+
const controller = inlineAbortControllers.get(jobId)
335+
const aborted = Boolean(controller)
336+
if (controller) {
337+
controller.abort('Cancelled')
338+
inlineAbortControllers.delete(jobId)
339+
}
323340

324341
logger.debug('Marked job as cancelled (DB queue)', { jobId, abortedInline: aborted })
325342
}
@@ -353,10 +370,16 @@ export class DatabaseJobQueue implements JobQueueBackend {
353370
await acquireSlot(concurrencyKey, concurrencyLimit)
354371
}
355372
try {
373+
abortController.signal.throwIfAborted()
356374
await this.startJob(jobId)
375+
abortController.signal.throwIfAborted()
357376
await runner(payload, abortController.signal)
358377
await this.completeJob(jobId, null)
359378
} catch (err) {
379+
if (abortController.signal.aborted) {
380+
logger.info(`[${type}] Inline job ${jobId} cancelled`)
381+
return
382+
}
360383
const message = toError(err).message
361384
logger.error(`[${type}] Inline job ${jobId} failed`, { error: message })
362385
try {

0 commit comments

Comments
 (0)