effect-mq 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +167 -22
  2. package/dist/Job.d.ts +67 -4
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +76 -3
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +174 -3
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js +89 -1
  9. package/dist/JobStore.js.map +1 -1
  10. package/dist/MemoryJobStore.d.ts +37 -6
  11. package/dist/MemoryJobStore.d.ts.map +1 -1
  12. package/dist/MemoryJobStore.js +201 -25
  13. package/dist/MemoryJobStore.js.map +1 -1
  14. package/dist/Worker.d.ts +5 -1
  15. package/dist/Worker.d.ts.map +1 -1
  16. package/dist/Worker.js +116 -10
  17. package/dist/Worker.js.map +1 -1
  18. package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.d.ts +18 -2
  19. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -0
  20. package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.js +287 -40
  21. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -0
  22. package/dist/drizzle-postgres/index.d.ts.map +1 -0
  23. package/dist/drizzle-postgres/index.js.map +1 -0
  24. package/dist/{drizzle → drizzle-postgres}/schema.d.ts +306 -4
  25. package/dist/drizzle-postgres/schema.d.ts.map +1 -0
  26. package/dist/{drizzle → drizzle-postgres}/schema.js +36 -2
  27. package/dist/drizzle-postgres/schema.js.map +1 -0
  28. package/dist/redis/RedisJobStore.d.ts +56 -0
  29. package/dist/redis/RedisJobStore.d.ts.map +1 -0
  30. package/dist/redis/RedisJobStore.js +385 -0
  31. package/dist/redis/RedisJobStore.js.map +1 -0
  32. package/dist/redis/index.d.ts +9 -0
  33. package/dist/redis/index.d.ts.map +1 -0
  34. package/dist/redis/index.js +9 -0
  35. package/dist/redis/index.js.map +1 -0
  36. package/dist/redis/scripts.d.ts +168 -0
  37. package/dist/redis/scripts.d.ts.map +1 -0
  38. package/dist/redis/scripts.js +755 -0
  39. package/dist/redis/scripts.js.map +1 -0
  40. package/dist/testing/conformance.d.ts.map +1 -1
  41. package/dist/testing/conformance.js +332 -3
  42. package/dist/testing/conformance.js.map +1 -1
  43. package/package.json +8 -4
  44. package/src/Job.ts +189 -5
  45. package/src/JobStore.ts +252 -4
  46. package/src/MemoryJobStore.ts +273 -26
  47. package/src/Worker.ts +152 -10
  48. package/src/{drizzle → drizzle-postgres}/DrizzleJobStore.ts +412 -40
  49. package/src/{drizzle → drizzle-postgres}/schema.ts +52 -3
  50. package/src/redis/RedisJobStore.ts +597 -0
  51. package/src/redis/index.ts +8 -0
  52. package/src/redis/scripts.ts +862 -0
  53. package/src/testing/conformance.ts +421 -3
  54. package/dist/drizzle/DrizzleJobStore.d.ts.map +0 -1
  55. package/dist/drizzle/DrizzleJobStore.js.map +0 -1
  56. package/dist/drizzle/index.d.ts.map +0 -1
  57. package/dist/drizzle/index.js.map +0 -1
  58. package/dist/drizzle/schema.d.ts.map +0 -1
  59. package/dist/drizzle/schema.js.map +0 -1
  60. /package/dist/{drizzle → drizzle-postgres}/index.d.ts +0 -0
  61. /package/dist/{drizzle → drizzle-postgres}/index.js +0 -0
  62. /package/src/{drizzle → drizzle-postgres}/index.ts +0 -0
@@ -21,8 +21,8 @@ import type { PgClient } from "@effect/sql-pg"
21
21
  import { asc, eq, sql } from "drizzle-orm"
22
22
  import * as PgDrizzle from "drizzle-orm/effect-postgres"
23
23
  import { getTableConfig } from "drizzle-orm/pg-core"
24
- import { Clock, type Context, Deferred, Effect, Layer, Option, type Scope, Stream } from "effect"
25
- import type { MqJobAttemptsTable, MqJobsTable } from "./schema.ts"
24
+ import { Clock, type Context, Deferred, Duration, Effect, Layer, Option, type Scope, Stream } from "effect"
25
+ import type { MqJobAttemptsTable, MqJobsTable, MqQueueControlTable, MqSchedulesTable } from "./schema.ts"
26
26
 
27
27
  const { JobId } = JobStore
28
28
 
@@ -34,8 +34,24 @@ export interface DrizzleJobStoreOptions<StoreId = JobStore.JobStore> {
34
34
  readonly jobs: MqJobsTable
35
35
  /** The run-ledger table instance (from `mqJobAttempts`). */
36
36
  readonly attempts: MqJobAttemptsTable
37
+ /** The schedules table instance (from `mqSchedules`). */
38
+ readonly schedules: MqSchedulesTable
39
+ /** The queue pause/resume flags table (from `mqQueueControl`). */
40
+ readonly queues: MqQueueControlTable
37
41
  /** Bind to a `JobStore.named(...)` key; default: the default `JobStore`. */
38
42
  readonly store?: Context.Key<StoreId, JobStore.Service> | undefined
43
+ /**
44
+ * Store-level retention ceiling: terminal records older than this are
45
+ * removed by a periodic sweep. Per-job `keep` may only be stricter.
46
+ */
47
+ readonly historyTtl?: Duration.Input | undefined
48
+ /** History sweep cadence (default 1 minute). */
49
+ readonly historySweepInterval?: Duration.Input | undefined
50
+ /**
51
+ * Generator for store-assigned job ids (e.g. `() => \`job_${ulid()}\``).
52
+ * Default: `j-<seq>` from the jobs sequence. See `JobStore.IdGenerator`.
53
+ */
54
+ readonly idGenerator?: JobStore.IdGenerator | undefined
39
55
  /**
40
56
  * Probe the tables at startup and fail fast when the schema is missing
41
57
  * (default true). Migrations are owned by your drizzle-kit pipeline.
@@ -74,6 +90,8 @@ type JobRow = {
74
90
  readonly stalledCount: number
75
91
  readonly backoff: JobStore.BackoffPolicy | null
76
92
  readonly keep: JobStore.KeepPolicy | null
93
+ readonly timeoutMs: number | string | null
94
+ readonly cancelRequested: boolean
77
95
  readonly runAt: Date
78
96
  readonly enqueuedAt: Date
79
97
  readonly processedAt: Date | null
@@ -82,6 +100,40 @@ type JobRow = {
82
100
  readonly failedReason: string | null
83
101
  }
84
102
 
103
+ type ScheduleRow = {
104
+ readonly key: string
105
+ readonly jobName: string
106
+ readonly queue: string
107
+ readonly cron: string | null
108
+ readonly tz: string | null
109
+ readonly everyMs: number | string | null
110
+ readonly payload: unknown
111
+ readonly metadata: Record<string, string>
112
+ readonly priority: number
113
+ readonly attemptsMax: number
114
+ readonly backoff: JobStore.BackoffPolicy | null
115
+ readonly keep: JobStore.KeepPolicy | null
116
+ readonly timeoutMs: number | string | null
117
+ readonly nextRunAt: Date
118
+ }
119
+
120
+ const toSchedule = (row: ScheduleRow): JobStore.ScheduleRecord => ({
121
+ key: JobStore.ScheduleKey(row.key),
122
+ jobName: row.jobName,
123
+ queue: JobStore.QueueName(row.queue),
124
+ cron: row.cron ?? undefined,
125
+ tz: row.tz ?? undefined,
126
+ everyMs: row.everyMs === null ? undefined : Number(row.everyMs),
127
+ payload: row.payload,
128
+ metadata: row.metadata ?? {},
129
+ priority: row.priority,
130
+ attemptsMax: row.attemptsMax,
131
+ backoff: row.backoff ?? undefined,
132
+ keep: row.keep ?? undefined,
133
+ timeoutMs: row.timeoutMs === null ? undefined : Number(row.timeoutMs),
134
+ nextRunAt: row.nextRunAt.getTime()
135
+ })
136
+
85
137
  const toRecord = (row: JobRow): JobStore.JobRecord => ({
86
138
  id: JobId(row.id),
87
139
  name: row.name,
@@ -95,6 +147,8 @@ const toRecord = (row: JobRow): JobStore.JobRecord => ({
95
147
  stalledCount: row.stalledCount,
96
148
  backoff: row.backoff ?? undefined,
97
149
  keep: row.keep ?? undefined,
150
+ timeoutMs: row.timeoutMs === null || row.timeoutMs === undefined ? undefined : Number(row.timeoutMs),
151
+ cancelRequested: row.cancelRequested,
98
152
  runAt: row.runAt.getTime(),
99
153
  enqueuedAt: row.enqueuedAt.getTime(),
100
154
  processedAt: row.processedAt?.getTime(),
@@ -117,6 +171,8 @@ export const make = (
117
171
  const client = db.$client
118
172
  const jobs = options.jobs
119
173
  const attempts = options.attempts
174
+ const schedules = options.schedules
175
+ const queues = options.queues
120
176
  const jobsName = getTableConfig(jobs).name
121
177
  const attemptsName = getTableConfig(attempts).name
122
178
  const wakeChannel = `effect_mq_wake_${jobsName}`
@@ -124,7 +180,9 @@ export const make = (
124
180
  if (options.validate ?? true) {
125
181
  yield* Effect.all([
126
182
  db.select({ id: jobs.id }).from(jobs).limit(0),
127
- db.select({ jobId: attempts.jobId }).from(attempts).limit(0)
183
+ db.select({ jobId: attempts.jobId }).from(attempts).limit(0),
184
+ db.select({ key: schedules.key }).from(schedules).limit(0),
185
+ db.select({ queue: queues.queue }).from(queues).limit(0)
128
186
  ]).pipe(
129
187
  Effect.mapError(storeError(
130
188
  `effect-mq: tables "${jobsName}"/"${attemptsName}" are missing or mismatched — ` +
@@ -158,6 +216,24 @@ export const make = (
158
216
  Effect.forever,
159
217
  Effect.forkScoped
160
218
  )
219
+ if (options.historyTtl !== undefined) {
220
+ const ttlMs = Duration.toMillis(options.historyTtl)
221
+ const sweepMs = Duration.toMillis(options.historySweepInterval ?? "1 minute")
222
+ yield* Effect.gen(function*() {
223
+ yield* Effect.sleep(sweepMs)
224
+ const now = yield* nowDate
225
+ yield* db.execute(sql`
226
+ DELETE FROM ${jobs}
227
+ WHERE ${jobs.state} IN ('completed', 'failed', 'cancelled')
228
+ AND ${jobs.finishedAt} <= ${new Date(now.getTime() - ttlMs)}
229
+ `)
230
+ }).pipe(
231
+ Effect.catchCause((cause) => Effect.logWarning("effect-mq: history sweep failed", cause)),
232
+ Effect.forever,
233
+ Effect.forkScoped
234
+ )
235
+ }
236
+
161
237
  // Local mutation wake-up: bump synchronously, then best-effort NOTIFY so
162
238
  // workers in other processes wake promptly too.
163
239
  const wakeUp: Effect.Effect<void> = Effect.suspend(() => {
@@ -237,21 +313,31 @@ export const make = (
237
313
  const now = yield* nowDate
238
314
  const runAt = new Date(now.getTime() + Math.max(0, request.delayMs))
239
315
  const state = request.delayMs > 0 ? "delayed" : "waiting"
240
- // Store-assigned ids come from the seq sequence; loop on the
241
- // (unlikely) collision with a user-supplied id.
316
+ // Store-assigned ids come from the configured generator (or the
317
+ // seq sequence); loop on the (unlikely) collision with an existing
318
+ // id — ON CONFLICT DO NOTHING makes the retry safe.
319
+ const generate = options.idGenerator
242
320
  for (let i = 0; i < 5; i++) {
321
+ const generated = request.id === undefined && generate !== undefined
322
+ ? yield* Effect.suspend(() => {
323
+ const raw = generate(request)
324
+ return Effect.isEffect(raw) ? raw : Effect.succeed(raw)
325
+ })
326
+ : undefined
243
327
  const idExpr = request.id !== undefined
244
328
  ? sql`${request.id}`
329
+ : generated !== undefined
330
+ ? sql`${generated}`
245
331
  : sql`'j-' || ${seqExpr}::text`
246
332
  const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
247
333
  INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
248
- attempts_max, backoff, keep, run_at, enqueued_at)
334
+ attempts_max, backoff, keep, timeout_ms, run_at, enqueued_at)
249
335
  VALUES (${idExpr}, ${request.name}, ${request.queue}, ${state}, ${request.priority},
250
336
  ${JSON.stringify(request.payload ?? null)}::jsonb, ${JSON.stringify(request.metadata)}::jsonb,
251
337
  ${request.attemptsMax},
252
338
  ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
253
339
  ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
254
- ${runAt}, ${now})
340
+ ${request.timeoutMs ?? null}, ${runAt}, ${now})
255
341
  ON CONFLICT (id) DO NOTHING
256
342
  RETURNING ${jobs.id} AS id
257
343
  `).pipe(Effect.mapError(storeError("enqueue failed"))))
@@ -286,7 +372,12 @@ export const make = (
286
372
  WHERE ${jobs.queue} = ${claimOptions.queue} AND ${jobs.state} = 'delayed'
287
373
  AND ${jobs.runAt} <= ${now}
288
374
  `)
289
- const claimed = rowsOf(yield* tx.execute<JobRow>(sql`
375
+ const pausedRows = rowsOf(yield* tx.execute<{ paused: boolean }>(sql`
376
+ SELECT ${queues.paused} AS "paused" FROM ${queues}
377
+ WHERE ${queues.queue} = ${claimOptions.queue}
378
+ `))
379
+ const isPaused = pausedRows[0]?.paused === true
380
+ const claimed = isPaused ? [] : rowsOf(yield* tx.execute<JobRow>(sql`
290
381
  WITH candidate AS (
291
382
  SELECT ${jobs.id} AS id FROM ${jobs}
292
383
  WHERE ${jobs.queue} = ${claimOptions.queue} AND ${jobs.state} = 'waiting'
@@ -304,7 +395,9 @@ export const make = (
304
395
  ${jobs.payload} AS "payload", ${jobs.metadata} AS "metadata",
305
396
  ${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
306
397
  ${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
307
- ${jobs.keep} AS "keep", ${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
398
+ ${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
399
+ ${jobs.cancelRequested} AS "cancelRequested",
400
+ ${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
308
401
  ${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
309
402
  ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
310
403
  `))
@@ -335,12 +428,20 @@ export const make = (
335
428
  Effect.gen(function*() {
336
429
  const now = yield* nowDate
337
430
  const update = outcome._tag === "Complete"
338
- ? sql`state = 'completed', exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
431
+ ? sql`state = 'completed', cancel_requested = FALSE, exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
339
432
  : outcome._tag === "Fail"
340
- ? sql`state = 'failed', exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
341
- : sql`state = ${outcome.delayMs > 0 ? "delayed" : "waiting"},
342
- run_at = ${new Date(now.getTime() + Math.max(0, outcome.delayMs))},
343
- seq = ${seqExpr}`
433
+ ? sql`state = 'failed', cancel_requested = FALSE, exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
434
+ : outcome._tag === "Cancelled"
435
+ ? sql`state = 'cancelled', cancel_requested = FALSE, finished_at = ${now}`
436
+ // A cancel that raced this natural failure wins over revival
437
+ // (mirrors release/recoverStalled).
438
+ : sql`state = CASE WHEN ${jobs.cancelRequested} THEN 'cancelled'
439
+ ELSE ${outcome.delayMs > 0 ? "delayed" : "waiting"} END,
440
+ finished_at = CASE WHEN ${jobs.cancelRequested} THEN ${now}::timestamptz ELSE NULL END,
441
+ run_at = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.runAt}
442
+ ELSE ${new Date(now.getTime() + Math.max(0, outcome.delayMs))}::timestamptz END,
443
+ seq = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.seq} ELSE ${seqExpr} END,
444
+ cancel_requested = FALSE`
344
445
  const rows = rowsOf(yield* tx.execute<
345
446
  { processedAt: Date | null; name: string; state: string; keep: JobStore.KeepPolicy | null }
346
447
  >(sql`
@@ -354,13 +455,23 @@ export const make = (
354
455
  if (row === undefined) {
355
456
  return yield* explainMiss(id)
356
457
  }
458
+ const cancelledRetry = outcome._tag === "Retry" && row.state === "cancelled"
357
459
  const ledgerOutcome = outcome._tag === "Complete"
358
460
  ? "completed" as const
359
461
  : outcome._tag === "Fail"
360
462
  ? "failed" as const
463
+ : outcome._tag === "Cancelled" || cancelledRetry
464
+ ? "cancelled" as const
361
465
  : "retried" as const
362
- yield* insertAttempt(tx, id, ledgerOutcome, row.processedAt, now, outcome.exit)
363
- if (outcome._tag !== "Retry") {
466
+ yield* insertAttempt(
467
+ tx,
468
+ id,
469
+ ledgerOutcome,
470
+ row.processedAt,
471
+ now,
472
+ outcome._tag === "Cancelled" || cancelledRetry ? undefined : outcome.exit
473
+ )
474
+ if (outcome._tag !== "Retry" || cancelledRetry) {
364
475
  yield* applyKeep(tx, row, now)
365
476
  }
366
477
  })
@@ -376,22 +487,56 @@ export const make = (
376
487
 
377
488
  release: (id, token) =>
378
489
  Effect.gen(function*() {
379
- const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
380
- UPDATE ${jobs} SET state = 'waiting', lock_token = NULL, lock_expires_at = NULL
381
- WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
382
- RETURNING ${jobs.id} AS id
383
- `).pipe(Effect.mapError(storeError("release failed"))))
384
- if (rows.length === 0) {
490
+ const cancelled = yield* db.transaction((tx) =>
491
+ Effect.gen(function*() {
492
+ const now = yield* nowDate
493
+ // A cancel that arrived while the worker was shutting down is
494
+ // honoured instead of reviving the job.
495
+ const rows = rowsOf(yield* tx.execute<
496
+ {
497
+ id: string
498
+ cancelled: boolean
499
+ processedAt: Date | null
500
+ name: string
501
+ keep: JobStore.KeepPolicy | null
502
+ }
503
+ >(sql`
504
+ UPDATE ${jobs} SET
505
+ state = CASE WHEN ${jobs.cancelRequested} THEN 'cancelled' ELSE 'waiting' END,
506
+ finished_at = CASE WHEN ${jobs.cancelRequested} THEN ${now}::timestamptz ELSE NULL END,
507
+ cancel_requested = FALSE,
508
+ lock_token = NULL, lock_expires_at = NULL
509
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
510
+ RETURNING ${jobs.id} AS id, (${jobs.state} = 'cancelled') AS cancelled,
511
+ ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep"
512
+ `))
513
+ const row = rows[0]
514
+ if (row === undefined) return undefined
515
+ if (row.cancelled) {
516
+ yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined)
517
+ yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now)
518
+ }
519
+ return row.cancelled
520
+ })
521
+ ).pipe(Effect.mapError((error) =>
522
+ error instanceof JobStore.JobStoreError ? error : storeError("release failed")(error)
523
+ ))
524
+ if (cancelled === undefined) {
385
525
  return yield* explainMiss(id)
386
526
  }
387
- yield* wakeUp
527
+ if (!cancelled) {
528
+ yield* wakeUp
529
+ }
388
530
  }),
389
531
 
390
532
  extendLocks: (locks, durationMs) =>
391
533
  Effect.gen(function*() {
392
- if (locks.length === 0) return []
534
+ if (locks.length === 0) {
535
+ const empty: JobStore.ExtendLocksResult = { lost: [], cancelRequested: [] }
536
+ return empty
537
+ }
393
538
  const now = yield* nowDate
394
- const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
539
+ const rows = rowsOf(yield* db.execute<{ id: string; status: "lost" | "cancel" }>(sql`
395
540
  WITH input AS (
396
541
  SELECT ids.job_id, toks.token
397
542
  FROM unnest(${sql.param(locks.map((lock) => lock.id))}::text[]) WITH ORDINALITY AS ids(job_id, ord)
@@ -400,39 +545,78 @@ export const make = (
400
545
  updated AS (
401
546
  UPDATE ${jobs} SET lock_expires_at = ${new Date(now.getTime() + durationMs)}
402
547
  FROM input
403
- WHERE ${jobs.id} = input.job_id AND ${jobs.state} = 'active' AND ${jobs.lockToken} = input.token
548
+ WHERE ${jobs.id} = input.job_id AND ${jobs.state} = 'active'
549
+ AND ${jobs.lockToken} = input.token AND ${jobs.cancelRequested} = FALSE
404
550
  RETURNING ${jobs.id} AS id
405
551
  )
406
- SELECT input.job_id AS id FROM input
552
+ SELECT input.job_id AS id,
553
+ CASE WHEN ${jobs.id} IS NOT NULL AND ${jobs.state} = 'active'
554
+ AND ${jobs.lockToken} = input.token AND ${jobs.cancelRequested} THEN 'cancel'
555
+ ELSE 'lost' END AS status
556
+ FROM input
407
557
  LEFT JOIN updated ON updated.id = input.job_id
558
+ LEFT JOIN ${jobs} ON ${jobs.id} = input.job_id
408
559
  WHERE updated.id IS NULL
409
560
  `).pipe(Effect.mapError(storeError("extendLocks failed"))))
410
- return rows.map((row) => JobId(row.id))
561
+ const result: JobStore.ExtendLocksResult = {
562
+ lost: rows.filter((row) => row.status === "lost").map((row) => JobId(row.id)),
563
+ cancelRequested: rows.filter((row) => row.status === "cancel").map((row) => JobId(row.id))
564
+ }
565
+ return result
411
566
  }),
412
567
 
413
568
  recoverStalled: (recoverOptions) =>
414
569
  db.transaction((tx) =>
415
570
  Effect.gen(function*() {
416
571
  const now = yield* nowDate
572
+ // A stalled job whose worker died before honouring a cancel
573
+ // request is finished as cancelled rather than revived.
417
574
  const rows = rowsOf(yield* tx.execute<
418
- { id: string; state: string; processedAt: Date | null }
575
+ {
576
+ id: string
577
+ state: string
578
+ processedAt: Date | null
579
+ name: string
580
+ keep: JobStore.KeepPolicy | null
581
+ }
419
582
  >(sql`
420
583
  UPDATE ${jobs} SET
421
- stalled_count = ${jobs.stalledCount} + 1,
584
+ stalled_count = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.stalledCount}
585
+ ELSE ${jobs.stalledCount} + 1 END,
422
586
  lock_token = NULL, lock_expires_at = NULL,
423
- state = CASE WHEN ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int
424
- THEN 'failed' ELSE 'waiting' END,
425
- finished_at = CASE WHEN ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int
587
+ state = CASE
588
+ WHEN ${jobs.cancelRequested} THEN 'cancelled'
589
+ WHEN ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int THEN 'failed'
590
+ ELSE 'waiting' END,
591
+ finished_at = CASE
592
+ WHEN ${jobs.cancelRequested} OR ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int
426
593
  THEN ${now}::timestamptz ELSE NULL END,
427
- failed_reason = CASE WHEN ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int
428
- THEN 'job stalled more than allowable limit' ELSE NULL END
594
+ failed_reason = CASE
595
+ WHEN ${jobs.cancelRequested} THEN NULL
596
+ WHEN ${jobs.stalledCount} + 1 > ${recoverOptions.maxStalledCount}::int
597
+ THEN 'job stalled more than allowable limit' ELSE NULL END,
598
+ cancel_requested = FALSE
429
599
  WHERE ${jobs.state} = 'active' AND ${jobs.lockExpiresAt} <= ${now}::timestamptz
430
- RETURNING ${jobs.id} AS "id", ${jobs.state} AS "state", ${jobs.processedAt} AS "processedAt"
600
+ RETURNING ${jobs.id} AS "id", ${jobs.state} AS "state", ${jobs.processedAt} AS "processedAt",
601
+ ${jobs.name} AS "name", ${jobs.keep} AS "keep"
431
602
  `))
603
+ const recovered: Array<{ id: JobStore.JobId; failed: boolean }> = []
432
604
  for (const row of rows) {
433
- yield* insertAttempt(tx, row.id, "stalled", row.processedAt, now, undefined)
605
+ yield* insertAttempt(
606
+ tx,
607
+ row.id,
608
+ row.state === "cancelled" ? "cancelled" : "stalled",
609
+ row.processedAt,
610
+ now,
611
+ undefined
612
+ )
613
+ if (row.state === "cancelled") {
614
+ yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now)
615
+ } else {
616
+ recovered.push({ id: JobId(row.id), failed: row.state === "failed" })
617
+ }
434
618
  }
435
- return rows.map((row) => ({ id: JobId(row.id), failed: row.state === "failed" }))
619
+ return recovered
436
620
  })
437
621
  ).pipe(
438
622
  Effect.mapError((error) =>
@@ -500,7 +684,9 @@ export const make = (
500
684
  ${jobs.payload} AS "payload", ${jobs.metadata} AS "metadata",
501
685
  ${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
502
686
  ${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
503
- ${jobs.keep} AS "keep", ${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
687
+ ${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
688
+ ${jobs.cancelRequested} AS "cancelRequested",
689
+ ${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
504
690
  ${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
505
691
  ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
506
692
  FROM ${jobs}
@@ -523,6 +709,7 @@ export const make = (
523
709
  const now = yield* nowDate
524
710
  const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
525
711
  UPDATE ${jobs} SET state = 'waiting', attempts_made = 0, stalled_count = 0,
712
+ cancel_requested = FALSE,
526
713
  exit = NULL, failed_reason = NULL, finished_at = NULL, processed_at = NULL,
527
714
  run_at = ${now}, seq = ${seqExpr}
528
715
  WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'failed'
@@ -540,6 +727,190 @@ export const make = (
540
727
  yield* wakeUp
541
728
  }),
542
729
 
730
+ cancel: (id) =>
731
+ db.transaction((tx) =>
732
+ Effect.gen(function*() {
733
+ const now = yield* nowDate
734
+ // One guarded statement: waiting/delayed become terminal, active
735
+ // gets the cancel-request flag; anything else is reported by state.
736
+ const rows = rowsOf(yield* tx.execute<
737
+ {
738
+ id: string
739
+ state: string
740
+ processedAt: Date | null
741
+ name: string
742
+ keep: JobStore.KeepPolicy | null
743
+ }
744
+ >(sql`
745
+ UPDATE ${jobs} SET
746
+ state = CASE WHEN ${jobs.state} IN ('waiting', 'delayed') THEN 'cancelled' ELSE ${jobs.state} END,
747
+ finished_at = CASE WHEN ${jobs.state} IN ('waiting', 'delayed') THEN ${now}::timestamptz ELSE ${jobs.finishedAt} END,
748
+ cancel_requested = CASE WHEN ${jobs.state} = 'active' THEN TRUE ELSE ${jobs.cancelRequested} END
749
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} IN ('waiting', 'delayed', 'active')
750
+ RETURNING ${jobs.id} AS id, ${jobs.state} AS state,
751
+ ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep"
752
+ `))
753
+ const row = rows[0]
754
+ if (row === undefined) {
755
+ const existing = rowsOf(yield* tx.execute<{ state: JobStore.JobState }>(sql`
756
+ SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${id}
757
+ `))
758
+ const found = existing[0]
759
+ if (found === undefined) {
760
+ return yield* new JobStore.JobNotFoundError({ jobId: id })
761
+ }
762
+ return yield* new JobStore.JobNotCancellableError({ jobId: id, state: found.state })
763
+ }
764
+ if (row.state === "cancelled") {
765
+ yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined)
766
+ yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now)
767
+ }
768
+ })
769
+ ).pipe(
770
+ Effect.mapError((error) =>
771
+ error instanceof JobStore.JobNotFoundError ||
772
+ error instanceof JobStore.JobNotCancellableError ||
773
+ error instanceof JobStore.JobStoreError
774
+ ? error
775
+ : storeError("cancel failed")(error)
776
+ ),
777
+ Effect.asVoid
778
+ ),
779
+
780
+ promote: (id) =>
781
+ Effect.gen(function*() {
782
+ const now = yield* nowDate
783
+ const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
784
+ UPDATE ${jobs} SET state = 'waiting', run_at = ${now}
785
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'delayed'
786
+ RETURNING ${jobs.id} AS id
787
+ `).pipe(Effect.mapError(storeError("promote failed"))))
788
+ if (rows.length === 0) {
789
+ const existing = rowsOf(yield* db.execute<{ state: JobStore.JobState }>(sql`
790
+ SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${id}
791
+ `).pipe(Effect.mapError(storeError("promote failed"))))
792
+ const found = existing[0]
793
+ if (found === undefined) {
794
+ return yield* new JobStore.JobNotFoundError({ jobId: id })
795
+ }
796
+ return yield* new JobStore.JobNotPromotableError({ jobId: id, state: found.state })
797
+ }
798
+ yield* wakeUp
799
+ }),
800
+
801
+ pause: (queue) =>
802
+ db.execute(sql`
803
+ INSERT INTO ${queues} (queue, paused) VALUES (${queue}, TRUE)
804
+ ON CONFLICT (queue) DO UPDATE SET paused = TRUE
805
+ `).pipe(
806
+ Effect.mapError(storeError("pause failed")),
807
+ Effect.asVoid
808
+ ),
809
+
810
+ resume: (queue) =>
811
+ db.execute(sql`
812
+ UPDATE ${queues} SET paused = FALSE WHERE ${queues.queue} = ${queue}
813
+ `).pipe(
814
+ Effect.mapError(storeError("resume failed")),
815
+ Effect.andThen(wakeUp)
816
+ ),
817
+
818
+ pausedQueues: () =>
819
+ db.execute<{ queue: string }>(sql`
820
+ SELECT ${queues.queue} AS queue FROM ${queues} WHERE ${queues.paused} = TRUE
821
+ `).pipe(
822
+ Effect.mapError(storeError("pausedQueues failed")),
823
+ Effect.map((result) => rowsOf(result).map((row) => JobStore.QueueName(row.queue)))
824
+ ),
825
+
826
+ upsertSchedule: (schedule) =>
827
+ db.execute(sql`
828
+ INSERT INTO ${schedules} (key, job_name, queue, cron, tz, every_ms, payload, metadata,
829
+ priority, attempts_max, backoff, keep, timeout_ms, next_run_at)
830
+ VALUES (${schedule.key}, ${schedule.jobName}, ${schedule.queue},
831
+ ${schedule.cron ?? null}, ${schedule.tz ?? null}, ${schedule.everyMs ?? null},
832
+ ${JSON.stringify(schedule.payload ?? null)}::jsonb, ${JSON.stringify(schedule.metadata)}::jsonb,
833
+ ${schedule.priority}, ${schedule.attemptsMax},
834
+ ${schedule.backoff === undefined ? null : JSON.stringify(schedule.backoff)}::jsonb,
835
+ ${schedule.keep === undefined ? null : JSON.stringify(schedule.keep)}::jsonb,
836
+ ${schedule.timeoutMs ?? null}, ${new Date(schedule.nextRunAt)})
837
+ ON CONFLICT (key) DO UPDATE SET
838
+ job_name = EXCLUDED.job_name, queue = EXCLUDED.queue, cron = EXCLUDED.cron,
839
+ tz = EXCLUDED.tz, every_ms = EXCLUDED.every_ms, payload = EXCLUDED.payload,
840
+ metadata = EXCLUDED.metadata, priority = EXCLUDED.priority,
841
+ attempts_max = EXCLUDED.attempts_max, backoff = EXCLUDED.backoff,
842
+ keep = EXCLUDED.keep, timeout_ms = EXCLUDED.timeout_ms,
843
+ next_run_at = CASE
844
+ WHEN ${schedules.cron} IS NOT DISTINCT FROM EXCLUDED.cron
845
+ AND ${schedules.tz} IS NOT DISTINCT FROM EXCLUDED.tz
846
+ AND ${schedules.everyMs} IS NOT DISTINCT FROM EXCLUDED.every_ms
847
+ THEN ${schedules.nextRunAt}
848
+ ELSE EXCLUDED.next_run_at END
849
+ `).pipe(
850
+ Effect.mapError(storeError("upsertSchedule failed")),
851
+ Effect.asVoid
852
+ ),
853
+
854
+ removeSchedule: (key) =>
855
+ db.execute<{ key: string }>(sql`
856
+ DELETE FROM ${schedules} WHERE ${schedules.key} = ${key}
857
+ RETURNING ${schedules.key} AS key
858
+ `).pipe(
859
+ Effect.mapError(storeError("removeSchedule failed")),
860
+ Effect.map((result) => rowsOf(result).length > 0)
861
+ ),
862
+
863
+ listSchedules: (listOptions) =>
864
+ Effect.gen(function*() {
865
+ const conditions = [sql`TRUE`]
866
+ if (listOptions?.jobName !== undefined) {
867
+ conditions.push(sql`${schedules.jobName} = ${listOptions.jobName}`)
868
+ }
869
+ if (listOptions?.queue !== undefined) {
870
+ conditions.push(sql`${schedules.queue} = ${listOptions.queue}`)
871
+ }
872
+ const rows = rowsOf(yield* db.execute<ScheduleRow>(sql`
873
+ SELECT ${schedules.key} AS "key", ${schedules.jobName} AS "jobName",
874
+ ${schedules.queue} AS "queue", ${schedules.cron} AS "cron", ${schedules.tz} AS "tz",
875
+ ${schedules.everyMs} AS "everyMs", ${schedules.payload} AS "payload",
876
+ ${schedules.metadata} AS "metadata", ${schedules.priority} AS "priority",
877
+ ${schedules.attemptsMax} AS "attemptsMax", ${schedules.backoff} AS "backoff",
878
+ ${schedules.keep} AS "keep", ${schedules.timeoutMs} AS "timeoutMs",
879
+ ${schedules.nextRunAt} AS "nextRunAt"
880
+ FROM ${schedules}
881
+ WHERE ${sql.join(conditions, sql` AND `)}
882
+ ORDER BY ${schedules.key}
883
+ `).pipe(Effect.mapError(storeError("listSchedules failed"))))
884
+ return rows.map(toSchedule)
885
+ }),
886
+
887
+ dueSchedules: () =>
888
+ Effect.gen(function*() {
889
+ const now = yield* nowDate
890
+ const rows = rowsOf(yield* db.execute<ScheduleRow>(sql`
891
+ SELECT ${schedules.key} AS "key", ${schedules.jobName} AS "jobName",
892
+ ${schedules.queue} AS "queue", ${schedules.cron} AS "cron", ${schedules.tz} AS "tz",
893
+ ${schedules.everyMs} AS "everyMs", ${schedules.payload} AS "payload",
894
+ ${schedules.metadata} AS "metadata", ${schedules.priority} AS "priority",
895
+ ${schedules.attemptsMax} AS "attemptsMax", ${schedules.backoff} AS "backoff",
896
+ ${schedules.keep} AS "keep", ${schedules.timeoutMs} AS "timeoutMs",
897
+ ${schedules.nextRunAt} AS "nextRunAt"
898
+ FROM ${schedules}
899
+ WHERE ${schedules.nextRunAt} <= ${now}
900
+ ORDER BY ${schedules.nextRunAt} ASC
901
+ `).pipe(Effect.mapError(storeError("dueSchedules failed"))))
902
+ return rows.map(toSchedule)
903
+ }),
904
+
905
+ advanceSchedule: (key, expectedRunAt, nextRunAt) =>
906
+ db.execute(sql`
907
+ UPDATE ${schedules} SET next_run_at = ${new Date(nextRunAt)}
908
+ WHERE ${schedules.key} = ${key} AND ${schedules.nextRunAt} = ${new Date(expectedRunAt)}
909
+ `).pipe(
910
+ Effect.mapError(storeError("advanceSchedule failed")),
911
+ Effect.asVoid
912
+ ),
913
+
543
914
  counts: (queue) =>
544
915
  db.execute<{ state: JobStore.JobState; count: number }>(sql`
545
916
  SELECT ${jobs.state} AS "state", count(*)::int AS "count" FROM ${jobs}
@@ -554,7 +925,8 @@ export const make = (
554
925
  delayed: 0,
555
926
  active: 0,
556
927
  completed: 0,
557
- failed: 0
928
+ failed: 0,
929
+ cancelled: 0
558
930
  } satisfies Record<JobStore.JobState, number>
559
931
  for (const row of rows) counts[row.state] = row.count
560
932
  return counts