|
1 | 1 | import { asyncJobs, db } from '@sim/db' |
2 | 2 | import { createLogger } from '@sim/logger' |
3 | 3 | import { toError } from '@sim/utils/errors' |
4 | | -import { generateId } from '@sim/utils/id' |
| 4 | +import { generateShortId } from '@sim/utils/id' |
5 | 5 | import { eq, sql } from 'drizzle-orm' |
6 | 6 | import { |
7 | 7 | type EnqueueOptions, |
@@ -37,6 +37,14 @@ function rowToJob(row: AsyncJobRow): Job { |
37 | 37 |
|
38 | 38 | const inlineAbortControllers = new Map<string, AbortController>() |
39 | 39 |
|
| 40 | +/** |
| 41 | + * Per-cancel-key abort controllers for the `batchEnqueueAndWait` direct-call |
| 42 | + * path. Distinct from `inlineAbortControllers` (which keys by jobId) — this |
| 43 | + * map keys by the domain `cancelKey` callers pass in, since the await-blocking |
| 44 | + * path skips `async_jobs` entirely and has no jobId to cancel by. |
| 45 | + */ |
| 46 | +const inlineCancelKeyControllers = new Map<string, AbortController>() |
| 47 | + |
40 | 48 | interface Semaphore { |
41 | 49 | available: number |
42 | 50 | waiters: Array<() => void> |
@@ -73,7 +81,7 @@ export class DatabaseJobQueue implements JobQueueBackend { |
73 | 81 | payload: TPayload, |
74 | 82 | options?: EnqueueOptions |
75 | 83 | ): Promise<string> { |
76 | | - const jobId = options?.jobId ?? `run_${generateId().replace(/-/g, '').slice(0, 20)}` |
| 84 | + const jobId = options?.jobId ?? `run_${generateShortId(20)}` |
77 | 85 | const now = new Date() |
78 | 86 |
|
79 | 87 | await db |
@@ -112,7 +120,7 @@ export class DatabaseJobQueue implements JobQueueBackend { |
112 | 120 | if (items.length === 0) return [] |
113 | 121 | const now = new Date() |
114 | 122 | const rows = items.map(({ payload, options }) => ({ |
115 | | - id: `run_${generateId().replace(/-/g, '').slice(0, 20)}`, |
| 123 | + id: `run_${generateShortId(20)}`, |
116 | 124 | type, |
117 | 125 | payload: payload as Record<string, unknown>, |
118 | 126 | status: JOB_STATUS.PENDING, |
@@ -144,6 +152,44 @@ export class DatabaseJobQueue implements JobQueueBackend { |
144 | 152 | return rows.map((r) => r.id) |
145 | 153 | } |
146 | 154 |
|
| 155 | + /** Skips `async_jobs` entirely — ids are returned empty since callers can't |
| 156 | + * look up rows that don't exist. Cancel goes through `cancelByKey`. */ |
| 157 | + async batchEnqueueAndWait<TPayload>( |
| 158 | + type: JobType, |
| 159 | + items: Array<{ payload: TPayload; options?: EnqueueOptions }> |
| 160 | + ): Promise<string[]> { |
| 161 | + if (items.length === 0) return [] |
| 162 | + const tracked: Array<{ key: string; controller: AbortController }> = [] |
| 163 | + const runs = items.map((item) => { |
| 164 | + const runner = item.options?.runner |
| 165 | + if (!runner) return Promise.resolve() |
| 166 | + const controller = new AbortController() |
| 167 | + const cancelKey = item.options?.cancelKey |
| 168 | + if (cancelKey) { |
| 169 | + inlineCancelKeyControllers.set(cancelKey, controller) |
| 170 | + tracked.push({ key: cancelKey, controller }) |
| 171 | + } |
| 172 | + return runner(item.payload, controller.signal).catch((err) => { |
| 173 | + logger.error(`[${type}] Inline run failed`, { |
| 174 | + cancelKey, |
| 175 | + error: toError(err).message, |
| 176 | + }) |
| 177 | + }) |
| 178 | + }) |
| 179 | + try { |
| 180 | + await Promise.all(runs) |
| 181 | + } finally { |
| 182 | + // Compare-and-delete guards against a re-enqueue under the same key |
| 183 | + // racing with our cleanup. |
| 184 | + for (const t of tracked) { |
| 185 | + if (inlineCancelKeyControllers.get(t.key) === t.controller) { |
| 186 | + inlineCancelKeyControllers.delete(t.key) |
| 187 | + } |
| 188 | + } |
| 189 | + } |
| 190 | + return items.map(() => '') |
| 191 | + } |
| 192 | + |
147 | 193 | async getJob(jobId: string): Promise<Job | null> { |
148 | 194 | const [row] = await db.select().from(asyncJobs).where(eq(asyncJobs.id, jobId)).limit(1) |
149 | 195 |
|
@@ -224,6 +270,14 @@ export class DatabaseJobQueue implements JobQueueBackend { |
224 | 270 | logger.debug('Marked job as cancelled (DB queue)', { jobId, abortedInline: aborted }) |
225 | 271 | } |
226 | 272 |
|
| 273 | + cancelByKey(cancelKey: string): boolean { |
| 274 | + const controller = inlineCancelKeyControllers.get(cancelKey) |
| 275 | + if (!controller) return false |
| 276 | + controller.abort('Cancelled') |
| 277 | + inlineCancelKeyControllers.delete(cancelKey) |
| 278 | + return true |
| 279 | + } |
| 280 | + |
227 | 281 | /** |
228 | 282 | * Fire-and-forget IIFE that owns the lifecycle for an inline job: registers |
229 | 283 | * the abort controller (so `cancelJob` can interrupt mid-flight), acquires |
|
0 commit comments