effect-mq 0.2.0 → 0.3.1

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 (56) hide show
  1. package/README.md +182 -18
  2. package/dist/Job.d.ts +51 -1
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +52 -6
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +86 -6
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js +27 -1
  9. package/dist/JobStore.js.map +1 -1
  10. package/dist/MemoryJobStore.d.ts +6 -5
  11. package/dist/MemoryJobStore.d.ts.map +1 -1
  12. package/dist/MemoryJobStore.js +160 -32
  13. package/dist/MemoryJobStore.js.map +1 -1
  14. package/dist/Metrics.d.ts +98 -0
  15. package/dist/Metrics.d.ts.map +1 -0
  16. package/dist/Metrics.js +124 -0
  17. package/dist/Metrics.js.map +1 -0
  18. package/dist/Worker.d.ts +6 -0
  19. package/dist/Worker.d.ts.map +1 -1
  20. package/dist/Worker.js +63 -3
  21. package/dist/Worker.js.map +1 -1
  22. package/dist/drizzle-postgres/DrizzleJobStore.d.ts +20 -3
  23. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  24. package/dist/drizzle-postgres/DrizzleJobStore.js +352 -84
  25. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  26. package/dist/drizzle-postgres/schema.d.ts +255 -351
  27. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  28. package/dist/drizzle-postgres/schema.js +75 -27
  29. package/dist/drizzle-postgres/schema.js.map +1 -1
  30. package/dist/index.d.ts +7 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +7 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/redis/RedisJobStore.d.ts +4 -2
  35. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  36. package/dist/redis/RedisJobStore.js +67 -28
  37. package/dist/redis/RedisJobStore.js.map +1 -1
  38. package/dist/redis/scripts.d.ts +19 -6
  39. package/dist/redis/scripts.d.ts.map +1 -1
  40. package/dist/redis/scripts.js +208 -23
  41. package/dist/redis/scripts.js.map +1 -1
  42. package/dist/testing/conformance.d.ts.map +1 -1
  43. package/dist/testing/conformance.js +172 -7
  44. package/dist/testing/conformance.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/Job.ts +128 -9
  47. package/src/JobStore.ts +122 -6
  48. package/src/MemoryJobStore.ts +184 -44
  49. package/src/Metrics.ts +135 -0
  50. package/src/Worker.ts +102 -3
  51. package/src/drizzle-postgres/DrizzleJobStore.ts +431 -91
  52. package/src/drizzle-postgres/schema.ts +177 -63
  53. package/src/index.ts +8 -0
  54. package/src/redis/RedisJobStore.ts +90 -35
  55. package/src/redis/scripts.ts +217 -24
  56. package/src/testing/conformance.ts +246 -7
@@ -18,11 +18,11 @@
18
18
  */
19
19
  import * as JobStore from "../JobStore.ts"
20
20
  import type { PgClient } from "@effect/sql-pg"
21
- import { asc, eq, sql } from "drizzle-orm"
21
+ import { asc, eq, getTableColumns, sql } from "drizzle-orm"
22
22
  import * as PgDrizzle from "drizzle-orm/effect-postgres"
23
23
  import { getTableConfig } from "drizzle-orm/pg-core"
24
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"
25
+ import type { MqDedupeTable, MqJobAttemptsTable, MqJobsTable, MqQueueControlTable, MqSchedulesTable } from "./schema.ts"
26
26
 
27
27
  const { JobId } = JobStore
28
28
 
@@ -38,13 +38,26 @@ export interface DrizzleJobStoreOptions<StoreId = JobStore.JobStore> {
38
38
  readonly schedules: MqSchedulesTable
39
39
  /** The queue pause/resume flags table (from `mqQueueControl`). */
40
40
  readonly queues: MqQueueControlTable
41
+ /** The dedup-key registry table (from `mqDedupe`). */
42
+ readonly dedupe: MqDedupeTable
43
+ /**
44
+ * Values for columns added via `mqJobs({ extend })`, evaluated at enqueue
45
+ * (and on dedupe `replace`). Keys are the extended columns' TS names.
46
+ * Default: each extended column fills from `request.metadata[<TS name>]`,
47
+ * NULL when absent.
48
+ */
49
+ readonly extraValues?:
50
+ | ((request: JobStore.EnqueueRequest) => Readonly<Record<string, ExtraColumnValue>>)
51
+ | undefined
41
52
  /** Bind to a `JobStore.named(...)` key; default: the default `JobStore`. */
42
53
  readonly store?: Context.Key<StoreId, JobStore.Service> | undefined
43
54
  /**
44
55
  * Store-level retention ceiling: terminal records older than this are
45
- * removed by a periodic sweep. Per-job `keep` may only be stricter.
56
+ * removed by a periodic sweep one duration for all terminal states or a
57
+ * per-state split (`{ completed: "1 day", failed: "30 days" }`). The sweep
58
+ * also honours stricter per-job `keep.age` rules.
46
59
  */
47
- readonly historyTtl?: Duration.Input | undefined
60
+ readonly historyTtl?: JobStore.HistoryTtlInput | undefined
48
61
  /** History sweep cadence (default 1 minute). */
49
62
  readonly historySweepInterval?: Duration.Input | undefined
50
63
  /**
@@ -61,6 +74,13 @@ export interface DrizzleJobStoreOptions<StoreId = JobStore.JobStore> {
61
74
 
62
75
  type Db = PgDrizzle.EffectPgDatabase & { readonly $client: PgClient.PgClient }
63
76
 
77
+ /**
78
+ * A value the driver can bind directly into an extended column.
79
+ *
80
+ * @since 0.3.0
81
+ */
82
+ export type ExtraColumnValue = string | number | boolean | Date | null
83
+
64
84
  const storeError = (message: string) => (cause: unknown) =>
65
85
  new JobStore.JobStoreError({ message, cause })
66
86
 
@@ -92,6 +112,7 @@ type JobRow = {
92
112
  readonly keep: JobStore.KeepPolicy | null
93
113
  readonly timeoutMs: number | string | null
94
114
  readonly cancelRequested: boolean
115
+ readonly dedupeKey: string | null
95
116
  readonly runAt: Date
96
117
  readonly enqueuedAt: Date
97
118
  readonly processedAt: Date | null
@@ -149,6 +170,7 @@ const toRecord = (row: JobRow): JobStore.JobRecord => ({
149
170
  keep: row.keep ?? undefined,
150
171
  timeoutMs: row.timeoutMs === null || row.timeoutMs === undefined ? undefined : Number(row.timeoutMs),
151
172
  cancelRequested: row.cancelRequested,
173
+ dedupeKey: row.dedupeKey ?? undefined,
152
174
  runAt: row.runAt.getTime(),
153
175
  enqueuedAt: row.enqueuedAt.getTime(),
154
176
  processedAt: row.processedAt?.getTime(),
@@ -173,16 +195,70 @@ export const make = (
173
195
  const attempts = options.attempts
174
196
  const schedules = options.schedules
175
197
  const queues = options.queues
198
+ const dedupe = options.dedupe
176
199
  const jobsName = getTableConfig(jobs).name
177
200
  const attemptsName = getTableConfig(attempts).name
178
201
  const wakeChannel = `effect_mq_wake_${jobsName}`
179
202
 
203
+ // Columns the user added via `mqJobs({ extend })`: everything beyond the
204
+ // factory's own set. They are written at enqueue (and on dedupe replace)
205
+ // from `extraValues` or the metadata entry with the same TS key.
206
+ const BASE_JOB_COLUMNS = new Set([
207
+ "id",
208
+ "name",
209
+ "queue",
210
+ "state",
211
+ "priority",
212
+ "seq",
213
+ "payload",
214
+ "metadata",
215
+ "attemptsMax",
216
+ "attemptsMade",
217
+ "stalledCount",
218
+ "backoff",
219
+ "keep",
220
+ "timeoutMs",
221
+ "cancelRequested",
222
+ "dedupeKey",
223
+ "runAt",
224
+ "enqueuedAt",
225
+ "processedAt",
226
+ "finishedAt",
227
+ "exit",
228
+ "failedReason",
229
+ "lockToken",
230
+ "lockExpiresAt"
231
+ ])
232
+ const extendedColumns = Object.entries(getTableColumns(jobs))
233
+ .filter(([key]) => !BASE_JOB_COLUMNS.has(key))
234
+ .map(([key, column]) => ({ key, name: column.name }))
235
+ const extraColumnNames = extendedColumns.length === 0
236
+ ? sql``
237
+ : sql.join(extendedColumns.map((column) => sql`, ${sql.identifier(column.name)}`))
238
+ const extraColumnValues = (request: JobStore.EnqueueRequest) => {
239
+ if (extendedColumns.length === 0) return sql``
240
+ const mapped = options.extraValues?.(request) ?? {}
241
+ return sql.join(extendedColumns.map((column) =>
242
+ sql`, ${Object.hasOwn(mapped, column.key) ? mapped[column.key] : request.metadata[column.key] ?? null}`
243
+ ))
244
+ }
245
+ const extraColumnAssignments = (request: JobStore.EnqueueRequest) => {
246
+ if (extendedColumns.length === 0) return sql``
247
+ const mapped = options.extraValues?.(request) ?? {}
248
+ return sql.join(extendedColumns.map((column) =>
249
+ sql`, ${sql.identifier(column.name)} = ${
250
+ Object.hasOwn(mapped, column.key) ? mapped[column.key] : request.metadata[column.key] ?? null
251
+ }`
252
+ ))
253
+ }
254
+
180
255
  if (options.validate ?? true) {
181
256
  yield* Effect.all([
182
257
  db.select({ id: jobs.id }).from(jobs).limit(0),
183
258
  db.select({ jobId: attempts.jobId }).from(attempts).limit(0),
184
259
  db.select({ key: schedules.key }).from(schedules).limit(0),
185
- db.select({ queue: queues.queue }).from(queues).limit(0)
260
+ db.select({ queue: queues.queue }).from(queues).limit(0),
261
+ db.select({ key: dedupe.key }).from(dedupe).limit(0)
186
262
  ]).pipe(
187
263
  Effect.mapError(storeError(
188
264
  `effect-mq: tables "${jobsName}"/"${attemptsName}" are missing or mismatched — ` +
@@ -191,21 +267,48 @@ export const make = (
191
267
  )
192
268
  }
193
269
 
194
- // Wake plumbing: a local version + deferred chain (same protocol as the
270
+ // Wake plumbing: a queue-filtered waiter registry (same protocol as the
195
271
  // memory driver), fed by (a) local store operations and (b) cross-process
196
- // LISTEN notifications.
272
+ // LISTEN notifications whose payload names the queue ("*" broadcasts).
273
+ // Filtering matters at scale: without it every enqueue wakes every idle
274
+ // taker of every queue on the store.
197
275
  let wakeVersion = 0
198
- let wake = Deferred.makeUnsafe<void>()
199
- const signalWake = () => {
276
+ let lastBroadcast = 0
277
+ const lastWake = new Map<JobStore.QueueName, number>()
278
+ interface Waiter {
279
+ readonly queues: ReadonlySet<JobStore.QueueName>
280
+ readonly deferred: Deferred.Deferred<void>
281
+ }
282
+ const waiters = new Set<Waiter>()
283
+ const lastWakeFor = (queue: JobStore.QueueName) => Math.max(lastWake.get(queue) ?? 0, lastBroadcast)
284
+ const signalWake = (queue?: JobStore.QueueName) => {
200
285
  wakeVersion += 1
201
- const current = wake
202
- wake = Deferred.makeUnsafe<void>()
203
- Deferred.doneUnsafe(current, Effect.void)
286
+ if (queue === undefined) {
287
+ lastBroadcast = wakeVersion
288
+ } else {
289
+ lastWake.set(queue, wakeVersion)
290
+ }
291
+ // Snapshot-and-clear BEFORE resolving: doneUnsafe resumes waiting
292
+ // fibers synchronously, and a woken taker that re-parks registers a
293
+ // NEW waiter — resolving inside the live Set iteration would visit it
294
+ // and livelock.
295
+ const toWake: Array<Waiter> = []
296
+ for (const waiter of waiters) {
297
+ if (queue === undefined || waiter.queues.has(queue)) {
298
+ waiters.delete(waiter)
299
+ toWake.push(waiter)
300
+ }
301
+ }
302
+ for (const waiter of toWake) {
303
+ Deferred.doneUnsafe(waiter.deferred, Effect.void)
304
+ }
204
305
  }
205
306
  // Resubscribe forever: if the LISTEN stream ends or fails, wake-ups
206
307
  // degrade to the worker's pollInterval until the next attempt succeeds.
207
308
  yield* client.listen(wakeChannel).pipe(
208
- Stream.runForEach(() => Effect.sync(signalWake)),
309
+ Stream.runForEach((payload) =>
310
+ Effect.sync(() => signalWake(payload === "*" ? undefined : JobStore.QueueName(payload)))
311
+ ),
209
312
  Effect.catchCause((cause) =>
210
313
  Effect.logWarning(
211
314
  "effect-mq: LISTEN subscription failed; wake-ups degraded to polling until resubscribe",
@@ -217,15 +320,42 @@ export const make = (
217
320
  Effect.forkScoped
218
321
  )
219
322
  if (options.historyTtl !== undefined) {
220
- const ttlMs = Duration.toMillis(options.historyTtl)
323
+ const ttlByState = JobStore.normalizeHistoryTtl(options.historyTtl)
221
324
  const sweepMs = Duration.toMillis(options.historySweepInterval ?? "1 minute")
222
325
  yield* Effect.gen(function*() {
223
326
  yield* Effect.sleep(sweepMs)
224
327
  const now = yield* nowDate
328
+ // Per-state ceilings, refined by stricter per-row keep ages — a quiet
329
+ // job name is pruned on the timer, not only when its group is acked.
330
+ for (const state of ["completed", "failed", "cancelled"] as const) {
331
+ const ttl = ttlByState[state]
332
+ yield* db.execute(sql`
333
+ DELETE FROM ${jobs}
334
+ WHERE ${jobs.state} = ${state} AND (
335
+ ${ttl !== undefined ? sql`${jobs.finishedAt} <= ${new Date(now.getTime() - ttl)}` : sql`FALSE`}
336
+ OR (
337
+ COALESCE(
338
+ ${jobs.keep}->${state}->>'ageMs',
339
+ CASE WHEN ${jobs.keep} ?| array['completed', 'failed', 'cancelled'] THEN NULL
340
+ ELSE ${jobs.keep}->>'ageMs' END
341
+ ) IS NOT NULL
342
+ AND ${jobs.finishedAt} <= ${now}::timestamptz
343
+ - make_interval(secs => (COALESCE(
344
+ ${jobs.keep}->${state}->>'ageMs',
345
+ CASE WHEN ${jobs.keep} ?| array['completed', 'failed', 'cancelled'] THEN NULL
346
+ ELSE ${jobs.keep}->>'ageMs' END
347
+ )::double precision) / 1000.0))
348
+ )
349
+ `)
350
+ }
351
+ // Dead dedup rows: expired windows, or pointers at vanished jobs.
225
352
  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)}
353
+ DELETE FROM ${dedupe}
354
+ WHERE (${dedupe.windowExpiresAt} IS NOT NULL AND ${dedupe.windowExpiresAt} <= ${now})
355
+ OR (${dedupe.windowExpiresAt} IS NULL AND NOT EXISTS (
356
+ SELECT 1 FROM ${jobs} WHERE ${jobs.id} = ${dedupe.jobId}
357
+ AND ${jobs.state} IN ('waiting', 'delayed', 'active')
358
+ ))
229
359
  `)
230
360
  }).pipe(
231
361
  Effect.catchCause((cause) => Effect.logWarning("effect-mq: history sweep failed", cause)),
@@ -235,12 +365,13 @@ export const make = (
235
365
  }
236
366
 
237
367
  // Local mutation wake-up: bump synchronously, then best-effort NOTIFY so
238
- // workers in other processes wake promptly too.
239
- const wakeUp: Effect.Effect<void> = Effect.suspend(() => {
240
- signalWake()
241
- // The payload must be non-empty: @effect/sql-pg drops falsy payloads.
242
- return client.notify(wakeChannel, "1").pipe(Effect.ignore)
243
- })
368
+ // workers in other processes wake promptly too. The payload names the
369
+ // queue (and must be non-empty: @effect/sql-pg drops falsy payloads).
370
+ const wakeUp = (queue?: JobStore.QueueName): Effect.Effect<void> =>
371
+ Effect.suspend(() => {
372
+ signalWake(queue)
373
+ return client.notify(wakeChannel, queue !== undefined && queue.length > 0 ? queue : "*").pipe(Effect.ignore)
374
+ })
244
375
 
245
376
  const nowDate = Effect.map(Clock.currentTimeMillis, (ms) => new Date(ms))
246
377
 
@@ -263,14 +394,39 @@ export const make = (
263
394
  `)
264
395
 
265
396
  // Retention: drop terminal peers (same name + state) beyond count/age.
397
+ const keepPolicyFor = (
398
+ keep: JobStore.KeepPolicy | null | undefined,
399
+ state: string
400
+ ): JobStore.KeepStatePolicy | undefined => {
401
+ if (keep === null || keep === undefined) return undefined
402
+ const policy = state === "completed"
403
+ ? keep.completed
404
+ : state === "failed"
405
+ ? keep.failed
406
+ : state === "cancelled"
407
+ ? keep.cancelled
408
+ : undefined
409
+ if (policy !== undefined) return policy
410
+ // Rows persisted by 0.2.x carry the flat {count, ageMs} shape — honour
411
+ // it as an all-states policy so upgrades keep pruning.
412
+ if (
413
+ keep.completed === undefined && keep.failed === undefined && keep.cancelled === undefined &&
414
+ ("count" in keep || "ageMs" in keep)
415
+ ) {
416
+ // SAFETY: the flat legacy shape carries KeepStatePolicy fields.
417
+ return keep as JobStore.KeepStatePolicy
418
+ }
419
+ return undefined
420
+ }
421
+
266
422
  const applyKeep = (
267
423
  tx: Pick<Db, "execute">,
268
424
  row: { name: string; state: string; keep: JobStore.KeepPolicy | null },
269
425
  now: Date
270
426
  ) =>
271
427
  Effect.gen(function*() {
272
- const keep = row.keep
273
- if (keep === null || keep === undefined) return
428
+ const keep = keepPolicyFor(row.keep, row.state)
429
+ if (keep === undefined) return
274
430
  if (keep.ageMs !== undefined) {
275
431
  yield* tx.execute(sql`
276
432
  DELETE FROM ${jobs}
@@ -307,53 +463,209 @@ export const make = (
307
463
  )
308
464
  )
309
465
 
310
- const store: JobStore.Service = {
311
- enqueue: (request) =>
466
+ // The shared INSERT: store-assigned ids come from the configured
467
+ // generator (or the seq sequence); loop on the (unlikely) collision with
468
+ // an existing id — ON CONFLICT DO NOTHING makes the retry safe. Returns
469
+ // the result or undefined when the caller-supplied id already exists.
470
+ const insertJob = (
471
+ exec: Pick<Db, "execute">,
472
+ request: JobStore.EnqueueRequest,
473
+ now: Date
474
+ ) =>
475
+ Effect.gen(function*() {
476
+ const runAt = new Date(now.getTime() + Math.max(0, request.delayMs))
477
+ const state = request.delayMs > 0 ? "delayed" : "waiting"
478
+ const generate = options.idGenerator
479
+ for (let i = 0; i < 5; i++) {
480
+ const generated = request.id === undefined && generate !== undefined
481
+ ? yield* Effect.suspend(() => {
482
+ const raw = generate(request)
483
+ return Effect.isEffect(raw) ? raw : Effect.succeed(raw)
484
+ })
485
+ : undefined
486
+ const idExpr = request.id !== undefined
487
+ ? sql`${request.id}`
488
+ : generated !== undefined
489
+ ? sql`${generated}`
490
+ : sql`'j-' || ${seqExpr}::text`
491
+ const rows = rowsOf(yield* exec.execute<{ id: string }>(sql`
492
+ INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
493
+ attempts_max, backoff, keep, timeout_ms, dedupe_key, run_at, enqueued_at${extraColumnNames})
494
+ VALUES (${idExpr}, ${request.name}, ${request.queue}, ${state}, ${request.priority},
495
+ ${JSON.stringify(request.payload ?? null)}::jsonb, ${JSON.stringify(request.metadata)}::jsonb,
496
+ ${request.attemptsMax},
497
+ ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
498
+ ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
499
+ ${request.timeoutMs ?? null}, ${request.dedupe?.key ?? null}, ${runAt}, ${now}${extraColumnValues(request)})
500
+ ON CONFLICT (id) DO NOTHING
501
+ RETURNING ${jobs.id} AS id
502
+ `).pipe(Effect.mapError(storeError("enqueue failed"))))
503
+ const inserted = rows[0]
504
+ if (inserted !== undefined) {
505
+ return { id: JobId(inserted.id), duplicate: false }
506
+ }
507
+ if (request.id !== undefined) {
508
+ return { id: request.id, duplicate: true }
509
+ }
510
+ // generated id collided with an existing user id; try again
511
+ }
512
+ return yield* new JobStore.JobStoreError({
513
+ message: "enqueue failed: could not generate a unique job id"
514
+ })
515
+ })
516
+
517
+ // Enqueue with a dedup policy: one transaction locks the (name, key) row
518
+ // and applies the decision tree (replace-while-delayed, throttle window,
519
+ // pending dedup) before falling through to a fresh insert.
520
+ const enqueueDeduped = (request: JobStore.EnqueueRequest, policy: JobStore.DedupePolicy) =>
521
+ db.transaction((tx) =>
312
522
  Effect.gen(function*() {
313
523
  const now = yield* nowDate
314
- const runAt = new Date(now.getTime() + Math.max(0, request.delayMs))
315
- const state = request.delayMs > 0 ? "delayed" : "waiting"
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
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
327
- const idExpr = request.id !== undefined
328
- ? sql`${request.id}`
329
- : generated !== undefined
330
- ? sql`${generated}`
331
- : sql`'j-' || ${seqExpr}::text`
332
- const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
333
- INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
334
- attempts_max, backoff, keep, timeout_ms, run_at, enqueued_at)
335
- VALUES (${idExpr}, ${request.name}, ${request.queue}, ${state}, ${request.priority},
336
- ${JSON.stringify(request.payload ?? null)}::jsonb, ${JSON.stringify(request.metadata)}::jsonb,
337
- ${request.attemptsMax},
338
- ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
339
- ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
340
- ${request.timeoutMs ?? null}, ${runAt}, ${now})
341
- ON CONFLICT (id) DO NOTHING
342
- RETURNING ${jobs.id} AS id
343
- `).pipe(Effect.mapError(storeError("enqueue failed"))))
344
- const inserted = rows[0]
345
- if (inserted !== undefined) {
346
- yield* wakeUp
347
- return { id: JobId(inserted.id), duplicate: false }
524
+ // The explicit-id duplicate check precedes the dedup tree, matching
525
+ // the memory and redis drivers.
526
+ if (request.id !== undefined) {
527
+ const existing = rowsOf(yield* tx.execute<{ id: string }>(sql`
528
+ SELECT ${jobs.id} AS id FROM ${jobs} WHERE ${jobs.id} = ${request.id}
529
+ `))
530
+ if (existing.length > 0) {
531
+ return { id: request.id, duplicate: true, wake: false }
348
532
  }
349
- if (request.id !== undefined) {
350
- return { id: request.id, duplicate: true }
533
+ }
534
+ // A SELECT FOR UPDATE on a missing row locks nothing, so two
535
+ // concurrent first-enqueues would both insert. The no-op upsert
536
+ // always takes the row lock: a fresh placeholder (job_id = '')
537
+ // reads as "no entry" and falls through to the insert below.
538
+ const rows = rowsOf(yield* tx.execute<{ jobId: string; windowExpiresAt: Date | null }>(sql`
539
+ INSERT INTO ${dedupe} (name, key, job_id, window_expires_at)
540
+ VALUES (${request.name}, ${policy.key}, '', NULL)
541
+ ON CONFLICT (name, key) DO UPDATE SET name = EXCLUDED.name
542
+ RETURNING ${dedupe.jobId} AS "jobId", ${dedupe.windowExpiresAt} AS "windowExpiresAt"
543
+ `))
544
+ const entry = rows[0]
545
+ if (entry !== undefined && entry.jobId !== "") {
546
+ // Plain read (no FOR UPDATE): locking the job row here would
547
+ // invert the jobs-then-dedupe lock order every terminal
548
+ // transition uses and deadlock under load. The replace branch
549
+ // compensates with a state-conditional UPDATE.
550
+ const keyed = rowsOf(yield* tx.execute<{ state: JobStore.JobState }>(sql`
551
+ SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${entry.jobId}
552
+ `))
553
+ const keyedState = keyed[0]?.state
554
+ const windowLive = entry.windowExpiresAt !== null &&
555
+ entry.windowExpiresAt.getTime() > now.getTime()
556
+ const bumpWindow = policy.extend && policy.ttlMs !== undefined
557
+ ? tx.execute(sql`
558
+ UPDATE ${dedupe} SET window_expires_at = ${new Date(now.getTime() + policy.ttlMs)}
559
+ WHERE ${dedupe.name} = ${request.name} AND ${dedupe.key} = ${policy.key}
560
+ `).pipe(Effect.asVoid)
561
+ : Effect.void
562
+ // Latest-wins while the keyed job is still delayed. The UPDATE
563
+ // re-checks the state so a concurrent claim degrades this to a
564
+ // plain dedup instead of rewriting an active job.
565
+ if (policy.replace && keyedState === "delayed") {
566
+ const replaced = rowsOf(yield* tx.execute<{ id: string; queue: string }>(sql`
567
+ UPDATE ${jobs} SET
568
+ payload = ${JSON.stringify(request.payload ?? null)}::jsonb,
569
+ metadata = ${JSON.stringify(request.metadata)}::jsonb,
570
+ priority = ${request.priority},
571
+ attempts_max = ${request.attemptsMax},
572
+ backoff = ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
573
+ keep = ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
574
+ timeout_ms = ${request.timeoutMs ?? null},
575
+ run_at = ${new Date(now.getTime() + Math.max(0, request.delayMs))}${extraColumnAssignments(request)}
576
+ WHERE ${jobs.id} = ${entry.jobId} AND ${jobs.state} = 'delayed'
577
+ RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
578
+ `))
579
+ if (replaced.length > 0) {
580
+ // A landed replace re-arms the ttl window (the entry must
581
+ // outlive the chain it is deduplicating).
582
+ if (policy.ttlMs !== undefined) {
583
+ yield* tx.execute(sql`
584
+ UPDATE ${dedupe} SET window_expires_at = ${new Date(now.getTime() + policy.ttlMs)}
585
+ WHERE ${dedupe.name} = ${request.name} AND ${dedupe.key} = ${policy.key}
586
+ `)
587
+ }
588
+ // The replace does not move the job between queues — wake
589
+ // the queue that actually holds the now-rescheduled job.
590
+ return {
591
+ id: JobId(entry.jobId),
592
+ duplicate: true,
593
+ wake: true,
594
+ wakeQueue: JobStore.QueueName(replaced[0]?.queue ?? request.queue)
595
+ }
596
+ }
597
+ return { id: JobId(entry.jobId), duplicate: true, wake: false }
598
+ }
599
+ if (windowLive) {
600
+ yield* bumpWindow
601
+ return { id: JobId(entry.jobId), duplicate: true, wake: false }
602
+ }
603
+ const pending = keyedState !== undefined && keyedState !== "completed" &&
604
+ keyedState !== "failed" && keyedState !== "cancelled"
605
+ if (entry.windowExpiresAt === null && pending) {
606
+ return { id: JobId(entry.jobId), duplicate: true, wake: false }
351
607
  }
352
- // generated id collided with an existing user id; try again
608
+ // Dead entry: the new job takes over the key below.
353
609
  }
354
- return yield* new JobStore.JobStoreError({
355
- message: "enqueue failed: could not generate a unique job id"
356
- })
610
+ const result = yield* insertJob(tx, request, now)
611
+ if (!result.duplicate) {
612
+ yield* tx.execute(sql`
613
+ INSERT INTO ${dedupe} (name, key, job_id, window_expires_at)
614
+ VALUES (${request.name}, ${policy.key}, ${result.id},
615
+ ${policy.ttlMs === undefined ? null : new Date(now.getTime() + policy.ttlMs)})
616
+ ON CONFLICT (name, key) DO UPDATE SET
617
+ job_id = EXCLUDED.job_id, window_expires_at = EXCLUDED.window_expires_at
618
+ `)
619
+ }
620
+ return { ...result, wake: !result.duplicate }
621
+ })
622
+ ).pipe(
623
+ // Residual lock-order inversions (replace vs cancel of the same
624
+ // delayed job) surface as Postgres deadlocks (40P01); one side is
625
+ // killed and safe to retry.
626
+ Effect.retry({
627
+ times: 3,
628
+ while: (error) => String(error).includes("40P01") || String(error).includes("deadlock detected")
629
+ }),
630
+ Effect.mapError((error) =>
631
+ error instanceof JobStore.JobStoreError ? error : storeError("enqueue failed")(error)
632
+ )
633
+ )
634
+
635
+ // A job leaving the pending states frees its pending-mode dedup row; live
636
+ // throttle windows deliberately outlast the job.
637
+ const releaseDedupe = (
638
+ exec: Pick<Db, "execute">,
639
+ name: string,
640
+ dedupeKey: string | null,
641
+ jobId: string,
642
+ now: Date
643
+ ) =>
644
+ dedupeKey === null
645
+ ? Effect.void
646
+ : exec.execute(sql`
647
+ DELETE FROM ${dedupe}
648
+ WHERE ${dedupe.name} = ${name} AND ${dedupe.key} = ${dedupeKey}
649
+ AND ${dedupe.jobId} = ${jobId}
650
+ AND (${dedupe.windowExpiresAt} IS NULL OR ${dedupe.windowExpiresAt} <= ${now})
651
+ `).pipe(Effect.asVoid)
652
+
653
+ const store: JobStore.Service = {
654
+ enqueue: (request) =>
655
+ Effect.gen(function*() {
656
+ if (request.dedupe !== undefined) {
657
+ const result = yield* enqueueDeduped(request, request.dedupe)
658
+ if (result.wake) {
659
+ yield* wakeUp("wakeQueue" in result && result.wakeQueue !== undefined ? result.wakeQueue : request.queue)
660
+ }
661
+ return { id: result.id, duplicate: result.duplicate }
662
+ }
663
+ const now = yield* nowDate
664
+ const result = yield* insertJob(db, request, now)
665
+ if (!result.duplicate) {
666
+ yield* wakeUp(request.queue)
667
+ }
668
+ return result
357
669
  }),
358
670
 
359
671
  claim: (claimOptions) =>
@@ -396,7 +708,7 @@ export const make = (
396
708
  ${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
397
709
  ${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
398
710
  ${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
399
- ${jobs.cancelRequested} AS "cancelRequested",
711
+ ${jobs.cancelRequested} AS "cancelRequested", ${jobs.dedupeKey} AS "dedupeKey",
400
712
  ${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
401
713
  ${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
402
714
  ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
@@ -443,13 +755,21 @@ export const make = (
443
755
  seq = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.seq} ELSE ${seqExpr} END,
444
756
  cancel_requested = FALSE`
445
757
  const rows = rowsOf(yield* tx.execute<
446
- { processedAt: Date | null; name: string; state: string; keep: JobStore.KeepPolicy | null }
758
+ {
759
+ processedAt: Date | null
760
+ name: string
761
+ state: string
762
+ keep: JobStore.KeepPolicy | null
763
+ dedupeKey: string | null
764
+ queue: string
765
+ }
447
766
  >(sql`
448
767
  UPDATE ${jobs} SET ${update},
449
768
  attempts_made = ${jobs.attemptsMade} + 1, lock_token = NULL, lock_expires_at = NULL
450
769
  WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
451
770
  RETURNING ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name",
452
- ${jobs.state} AS "state", ${jobs.keep} AS "keep"
771
+ ${jobs.state} AS "state", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey",
772
+ ${jobs.queue} AS "queue"
453
773
  `))
454
774
  const row = rows[0]
455
775
  if (row === undefined) {
@@ -472,8 +792,12 @@ export const make = (
472
792
  outcome._tag === "Cancelled" || cancelledRetry ? undefined : outcome.exit
473
793
  )
474
794
  if (outcome._tag !== "Retry" || cancelledRetry) {
795
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now)
475
796
  yield* applyKeep(tx, row, now)
476
797
  }
798
+ return outcome._tag === "Retry" && !cancelledRetry
799
+ ? JobStore.QueueName(row.queue)
800
+ : undefined
477
801
  })
478
802
  ).pipe(
479
803
  Effect.mapError((error) =>
@@ -482,12 +806,13 @@ export const make = (
482
806
  ? error
483
807
  : storeError("ack failed")(error)
484
808
  ),
485
- Effect.tap(() => outcome._tag === "Retry" ? wakeUp : Effect.void)
809
+ Effect.tap((queue) => queue !== undefined ? wakeUp(queue) : Effect.void),
810
+ Effect.asVoid
486
811
  ),
487
812
 
488
813
  release: (id, token) =>
489
814
  Effect.gen(function*() {
490
- const cancelled = yield* db.transaction((tx) =>
815
+ const released = yield* db.transaction((tx) =>
491
816
  Effect.gen(function*() {
492
817
  const now = yield* nowDate
493
818
  // A cancel that arrived while the worker was shutting down is
@@ -499,6 +824,8 @@ export const make = (
499
824
  processedAt: Date | null
500
825
  name: string
501
826
  keep: JobStore.KeepPolicy | null
827
+ dedupeKey: string | null
828
+ queue: string
502
829
  }
503
830
  >(sql`
504
831
  UPDATE ${jobs} SET
@@ -508,24 +835,26 @@ export const make = (
508
835
  lock_token = NULL, lock_expires_at = NULL
509
836
  WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
510
837
  RETURNING ${jobs.id} AS id, (${jobs.state} = 'cancelled') AS cancelled,
511
- ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep"
838
+ ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
839
+ ${jobs.dedupeKey} AS "dedupeKey", ${jobs.queue} AS "queue"
512
840
  `))
513
841
  const row = rows[0]
514
842
  if (row === undefined) return undefined
515
843
  if (row.cancelled) {
516
844
  yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined)
845
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now)
517
846
  yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now)
518
847
  }
519
- return row.cancelled
848
+ return { cancelled: row.cancelled, queue: JobStore.QueueName(row.queue) }
520
849
  })
521
850
  ).pipe(Effect.mapError((error) =>
522
851
  error instanceof JobStore.JobStoreError ? error : storeError("release failed")(error)
523
852
  ))
524
- if (cancelled === undefined) {
853
+ if (released === undefined) {
525
854
  return yield* explainMiss(id)
526
855
  }
527
- if (!cancelled) {
528
- yield* wakeUp
856
+ if (!released.cancelled) {
857
+ yield* wakeUp(released.queue)
529
858
  }
530
859
  }),
531
860
 
@@ -578,6 +907,7 @@ export const make = (
578
907
  processedAt: Date | null
579
908
  name: string
580
909
  keep: JobStore.KeepPolicy | null
910
+ dedupeKey: string | null
581
911
  }
582
912
  >(sql`
583
913
  UPDATE ${jobs} SET
@@ -598,7 +928,7 @@ export const make = (
598
928
  cancel_requested = FALSE
599
929
  WHERE ${jobs.state} = 'active' AND ${jobs.lockExpiresAt} <= ${now}::timestamptz
600
930
  RETURNING ${jobs.id} AS "id", ${jobs.state} AS "state", ${jobs.processedAt} AS "processedAt",
601
- ${jobs.name} AS "name", ${jobs.keep} AS "keep"
931
+ ${jobs.name} AS "name", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey"
602
932
  `))
603
933
  const recovered: Array<{ id: JobStore.JobId; failed: boolean }> = []
604
934
  for (const row of rows) {
@@ -610,6 +940,9 @@ export const make = (
610
940
  now,
611
941
  undefined
612
942
  )
943
+ if (row.state === "cancelled" || row.state === "failed") {
944
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, row.id, now)
945
+ }
613
946
  if (row.state === "cancelled") {
614
947
  yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now)
615
948
  } else {
@@ -623,14 +956,18 @@ export const make = (
623
956
  error instanceof JobStore.JobStoreError ? error : storeError("recoverStalled failed")(error)
624
957
  ),
625
958
  Effect.tap((recovered) =>
626
- recovered.some((entry) => !entry.failed) ? wakeUp : Effect.void
959
+ recovered.some((entry) => !entry.failed) ? wakeUp() : Effect.void
627
960
  )
628
961
  ),
629
962
 
630
- awaitWake: (_queues, wakeToken) =>
963
+ awaitWake: (queues, wakeToken) =>
631
964
  Effect.suspend(() => {
632
- if (wakeVersion > wakeToken) return Effect.void
633
- return Deferred.await(wake)
965
+ if (queues.some((queue) => lastWakeFor(queue) > wakeToken)) return Effect.void
966
+ const waiter: Waiter = { queues: new Set(queues), deferred: Deferred.makeUnsafe<void>() }
967
+ waiters.add(waiter)
968
+ return Deferred.await(waiter.deferred).pipe(
969
+ Effect.ensuring(Effect.sync(() => waiters.delete(waiter)))
970
+ )
634
971
  }),
635
972
 
636
973
  getJob: (id) =>
@@ -707,13 +1044,13 @@ export const make = (
707
1044
  retry: (id) =>
708
1045
  Effect.gen(function*() {
709
1046
  const now = yield* nowDate
710
- const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
1047
+ const rows = rowsOf(yield* db.execute<{ id: string; queue: string }>(sql`
711
1048
  UPDATE ${jobs} SET state = 'waiting', attempts_made = 0, stalled_count = 0,
712
1049
  cancel_requested = FALSE,
713
1050
  exit = NULL, failed_reason = NULL, finished_at = NULL, processed_at = NULL,
714
1051
  run_at = ${now}, seq = ${seqExpr}
715
1052
  WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'failed'
716
- RETURNING ${jobs.id} AS id
1053
+ RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
717
1054
  `).pipe(Effect.mapError(storeError("retry failed"))))
718
1055
  if (rows.length === 0) {
719
1056
  const existing = yield* db.select({ state: jobs.state }).from(jobs)
@@ -724,7 +1061,7 @@ export const make = (
724
1061
  }
725
1062
  return yield* new JobStore.JobNotRetryableError({ jobId: id, state: found.state })
726
1063
  }
727
- yield* wakeUp
1064
+ yield* wakeUp(JobStore.QueueName(rows[0]?.queue ?? ""))
728
1065
  }),
729
1066
 
730
1067
  cancel: (id) =>
@@ -740,6 +1077,7 @@ export const make = (
740
1077
  processedAt: Date | null
741
1078
  name: string
742
1079
  keep: JobStore.KeepPolicy | null
1080
+ dedupeKey: string | null
743
1081
  }
744
1082
  >(sql`
745
1083
  UPDATE ${jobs} SET
@@ -748,7 +1086,8 @@ export const make = (
748
1086
  cancel_requested = CASE WHEN ${jobs.state} = 'active' THEN TRUE ELSE ${jobs.cancelRequested} END
749
1087
  WHERE ${jobs.id} = ${id} AND ${jobs.state} IN ('waiting', 'delayed', 'active')
750
1088
  RETURNING ${jobs.id} AS id, ${jobs.state} AS state,
751
- ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep"
1089
+ ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
1090
+ ${jobs.dedupeKey} AS "dedupeKey"
752
1091
  `))
753
1092
  const row = rows[0]
754
1093
  if (row === undefined) {
@@ -763,6 +1102,7 @@ export const make = (
763
1102
  }
764
1103
  if (row.state === "cancelled") {
765
1104
  yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined)
1105
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now)
766
1106
  yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now)
767
1107
  }
768
1108
  })
@@ -780,10 +1120,10 @@ export const make = (
780
1120
  promote: (id) =>
781
1121
  Effect.gen(function*() {
782
1122
  const now = yield* nowDate
783
- const rows = rowsOf(yield* db.execute<{ id: string }>(sql`
1123
+ const rows = rowsOf(yield* db.execute<{ id: string; queue: string }>(sql`
784
1124
  UPDATE ${jobs} SET state = 'waiting', run_at = ${now}
785
1125
  WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'delayed'
786
- RETURNING ${jobs.id} AS id
1126
+ RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
787
1127
  `).pipe(Effect.mapError(storeError("promote failed"))))
788
1128
  if (rows.length === 0) {
789
1129
  const existing = rowsOf(yield* db.execute<{ state: JobStore.JobState }>(sql`
@@ -795,7 +1135,7 @@ export const make = (
795
1135
  }
796
1136
  return yield* new JobStore.JobNotPromotableError({ jobId: id, state: found.state })
797
1137
  }
798
- yield* wakeUp
1138
+ yield* wakeUp(JobStore.QueueName(rows[0]?.queue ?? ""))
799
1139
  }),
800
1140
 
801
1141
  pause: (queue) =>
@@ -812,7 +1152,7 @@ export const make = (
812
1152
  UPDATE ${queues} SET paused = FALSE WHERE ${queues.queue} = ${queue}
813
1153
  `).pipe(
814
1154
  Effect.mapError(storeError("resume failed")),
815
- Effect.andThen(wakeUp)
1155
+ Effect.andThen(wakeUp(queue))
816
1156
  ),
817
1157
 
818
1158
  pausedQueues: () =>
@@ -848,7 +1188,7 @@ export const make = (
848
1188
  ELSE EXCLUDED.next_run_at END
849
1189
  `).pipe(
850
1190
  Effect.mapError(storeError("upsertSchedule failed")),
851
- Effect.asVoid
1191
+ Effect.andThen(wakeUp(schedule.queue))
852
1192
  ),
853
1193
 
854
1194
  removeSchedule: (key) =>