effect-mq 0.2.0 → 0.3.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 (45) hide show
  1. package/README.md +153 -18
  2. package/dist/Job.d.ts +51 -1
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +44 -2
  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/Worker.d.ts.map +1 -1
  15. package/dist/Worker.js +1 -0
  16. package/dist/Worker.js.map +1 -1
  17. package/dist/drizzle-postgres/DrizzleJobStore.d.ts +20 -3
  18. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  19. package/dist/drizzle-postgres/DrizzleJobStore.js +352 -84
  20. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  21. package/dist/drizzle-postgres/schema.d.ts +255 -351
  22. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  23. package/dist/drizzle-postgres/schema.js +75 -27
  24. package/dist/drizzle-postgres/schema.js.map +1 -1
  25. package/dist/redis/RedisJobStore.d.ts +4 -2
  26. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  27. package/dist/redis/RedisJobStore.js +67 -28
  28. package/dist/redis/RedisJobStore.js.map +1 -1
  29. package/dist/redis/scripts.d.ts +19 -6
  30. package/dist/redis/scripts.d.ts.map +1 -1
  31. package/dist/redis/scripts.js +208 -23
  32. package/dist/redis/scripts.js.map +1 -1
  33. package/dist/testing/conformance.d.ts.map +1 -1
  34. package/dist/testing/conformance.js +172 -7
  35. package/dist/testing/conformance.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/Job.ts +113 -6
  38. package/src/JobStore.ts +122 -6
  39. package/src/MemoryJobStore.ts +184 -44
  40. package/src/Worker.ts +1 -0
  41. package/src/drizzle-postgres/DrizzleJobStore.ts +431 -91
  42. package/src/drizzle-postgres/schema.ts +177 -63
  43. package/src/redis/RedisJobStore.ts +90 -35
  44. package/src/redis/scripts.ts +217 -24
  45. package/src/testing/conformance.ts +246 -7
@@ -17,7 +17,7 @@
17
17
  * @since 0.1.0
18
18
  */
19
19
  import * as JobStore from "../JobStore.js";
20
- import { asc, eq, sql } from "drizzle-orm";
20
+ import { asc, eq, getTableColumns, sql } from "drizzle-orm";
21
21
  import * as PgDrizzle from "drizzle-orm/effect-postgres";
22
22
  import { getTableConfig } from "drizzle-orm/pg-core";
23
23
  import { Clock, Deferred, Duration, Effect, Layer, Option, Stream } from "effect";
@@ -55,6 +55,7 @@ const toRecord = (row) => ({
55
55
  keep: row.keep ?? undefined,
56
56
  timeoutMs: row.timeoutMs === null || row.timeoutMs === undefined ? undefined : Number(row.timeoutMs),
57
57
  cancelRequested: row.cancelRequested,
58
+ dedupeKey: row.dedupeKey ?? undefined,
58
59
  runAt: row.runAt.getTime(),
59
60
  enqueuedAt: row.enqueuedAt.getTime(),
60
61
  processedAt: row.processedAt?.getTime(),
@@ -75,51 +76,149 @@ export const make = (options) => Effect.gen(function* () {
75
76
  const attempts = options.attempts;
76
77
  const schedules = options.schedules;
77
78
  const queues = options.queues;
79
+ const dedupe = options.dedupe;
78
80
  const jobsName = getTableConfig(jobs).name;
79
81
  const attemptsName = getTableConfig(attempts).name;
80
82
  const wakeChannel = `effect_mq_wake_${jobsName}`;
83
+ // Columns the user added via `mqJobs({ extend })`: everything beyond the
84
+ // factory's own set. They are written at enqueue (and on dedupe replace)
85
+ // from `extraValues` or the metadata entry with the same TS key.
86
+ const BASE_JOB_COLUMNS = new Set([
87
+ "id",
88
+ "name",
89
+ "queue",
90
+ "state",
91
+ "priority",
92
+ "seq",
93
+ "payload",
94
+ "metadata",
95
+ "attemptsMax",
96
+ "attemptsMade",
97
+ "stalledCount",
98
+ "backoff",
99
+ "keep",
100
+ "timeoutMs",
101
+ "cancelRequested",
102
+ "dedupeKey",
103
+ "runAt",
104
+ "enqueuedAt",
105
+ "processedAt",
106
+ "finishedAt",
107
+ "exit",
108
+ "failedReason",
109
+ "lockToken",
110
+ "lockExpiresAt"
111
+ ]);
112
+ const extendedColumns = Object.entries(getTableColumns(jobs))
113
+ .filter(([key]) => !BASE_JOB_COLUMNS.has(key))
114
+ .map(([key, column]) => ({ key, name: column.name }));
115
+ const extraColumnNames = extendedColumns.length === 0
116
+ ? sql ``
117
+ : sql.join(extendedColumns.map((column) => sql `, ${sql.identifier(column.name)}`));
118
+ const extraColumnValues = (request) => {
119
+ if (extendedColumns.length === 0)
120
+ return sql ``;
121
+ const mapped = options.extraValues?.(request) ?? {};
122
+ return sql.join(extendedColumns.map((column) => sql `, ${Object.hasOwn(mapped, column.key) ? mapped[column.key] : request.metadata[column.key] ?? null}`));
123
+ };
124
+ const extraColumnAssignments = (request) => {
125
+ if (extendedColumns.length === 0)
126
+ return sql ``;
127
+ const mapped = options.extraValues?.(request) ?? {};
128
+ return sql.join(extendedColumns.map((column) => sql `, ${sql.identifier(column.name)} = ${Object.hasOwn(mapped, column.key) ? mapped[column.key] : request.metadata[column.key] ?? null}`));
129
+ };
81
130
  if (options.validate ?? true) {
82
131
  yield* Effect.all([
83
132
  db.select({ id: jobs.id }).from(jobs).limit(0),
84
133
  db.select({ jobId: attempts.jobId }).from(attempts).limit(0),
85
134
  db.select({ key: schedules.key }).from(schedules).limit(0),
86
- db.select({ queue: queues.queue }).from(queues).limit(0)
135
+ db.select({ queue: queues.queue }).from(queues).limit(0),
136
+ db.select({ key: dedupe.key }).from(dedupe).limit(0)
87
137
  ]).pipe(Effect.mapError(storeError(`effect-mq: tables "${jobsName}"/"${attemptsName}" are missing or mismatched — ` +
88
138
  `re-export the effect-mq/drizzle schema factories from your drizzle schema and run your migrations (drizzle-kit generate)`)));
89
139
  }
90
- // Wake plumbing: a local version + deferred chain (same protocol as the
140
+ // Wake plumbing: a queue-filtered waiter registry (same protocol as the
91
141
  // memory driver), fed by (a) local store operations and (b) cross-process
92
- // LISTEN notifications.
142
+ // LISTEN notifications whose payload names the queue ("*" broadcasts).
143
+ // Filtering matters at scale: without it every enqueue wakes every idle
144
+ // taker of every queue on the store.
93
145
  let wakeVersion = 0;
94
- let wake = Deferred.makeUnsafe();
95
- const signalWake = () => {
146
+ let lastBroadcast = 0;
147
+ const lastWake = new Map();
148
+ const waiters = new Set();
149
+ const lastWakeFor = (queue) => Math.max(lastWake.get(queue) ?? 0, lastBroadcast);
150
+ const signalWake = (queue) => {
96
151
  wakeVersion += 1;
97
- const current = wake;
98
- wake = Deferred.makeUnsafe();
99
- Deferred.doneUnsafe(current, Effect.void);
152
+ if (queue === undefined) {
153
+ lastBroadcast = wakeVersion;
154
+ }
155
+ else {
156
+ lastWake.set(queue, wakeVersion);
157
+ }
158
+ // Snapshot-and-clear BEFORE resolving: doneUnsafe resumes waiting
159
+ // fibers synchronously, and a woken taker that re-parks registers a
160
+ // NEW waiter — resolving inside the live Set iteration would visit it
161
+ // and livelock.
162
+ const toWake = [];
163
+ for (const waiter of waiters) {
164
+ if (queue === undefined || waiter.queues.has(queue)) {
165
+ waiters.delete(waiter);
166
+ toWake.push(waiter);
167
+ }
168
+ }
169
+ for (const waiter of toWake) {
170
+ Deferred.doneUnsafe(waiter.deferred, Effect.void);
171
+ }
100
172
  };
101
173
  // Resubscribe forever: if the LISTEN stream ends or fails, wake-ups
102
174
  // degrade to the worker's pollInterval until the next attempt succeeds.
103
- yield* client.listen(wakeChannel).pipe(Stream.runForEach(() => Effect.sync(signalWake)), Effect.catchCause((cause) => Effect.logWarning("effect-mq: LISTEN subscription failed; wake-ups degraded to polling until resubscribe", cause)), Effect.andThen(Effect.sleep("1 second")), Effect.forever, Effect.forkScoped);
175
+ yield* client.listen(wakeChannel).pipe(Stream.runForEach((payload) => Effect.sync(() => signalWake(payload === "*" ? undefined : JobStore.QueueName(payload)))), Effect.catchCause((cause) => Effect.logWarning("effect-mq: LISTEN subscription failed; wake-ups degraded to polling until resubscribe", cause)), Effect.andThen(Effect.sleep("1 second")), Effect.forever, Effect.forkScoped);
104
176
  if (options.historyTtl !== undefined) {
105
- const ttlMs = Duration.toMillis(options.historyTtl);
177
+ const ttlByState = JobStore.normalizeHistoryTtl(options.historyTtl);
106
178
  const sweepMs = Duration.toMillis(options.historySweepInterval ?? "1 minute");
107
179
  yield* Effect.gen(function* () {
108
180
  yield* Effect.sleep(sweepMs);
109
181
  const now = yield* nowDate;
182
+ // Per-state ceilings, refined by stricter per-row keep ages — a quiet
183
+ // job name is pruned on the timer, not only when its group is acked.
184
+ for (const state of ["completed", "failed", "cancelled"]) {
185
+ const ttl = ttlByState[state];
186
+ yield* db.execute(sql `
187
+ DELETE FROM ${jobs}
188
+ WHERE ${jobs.state} = ${state} AND (
189
+ ${ttl !== undefined ? sql `${jobs.finishedAt} <= ${new Date(now.getTime() - ttl)}` : sql `FALSE`}
190
+ OR (
191
+ COALESCE(
192
+ ${jobs.keep}->${state}->>'ageMs',
193
+ CASE WHEN ${jobs.keep} ?| array['completed', 'failed', 'cancelled'] THEN NULL
194
+ ELSE ${jobs.keep}->>'ageMs' END
195
+ ) IS NOT NULL
196
+ AND ${jobs.finishedAt} <= ${now}::timestamptz
197
+ - make_interval(secs => (COALESCE(
198
+ ${jobs.keep}->${state}->>'ageMs',
199
+ CASE WHEN ${jobs.keep} ?| array['completed', 'failed', 'cancelled'] THEN NULL
200
+ ELSE ${jobs.keep}->>'ageMs' END
201
+ )::double precision) / 1000.0))
202
+ )
203
+ `);
204
+ }
205
+ // Dead dedup rows: expired windows, or pointers at vanished jobs.
110
206
  yield* db.execute(sql `
111
- DELETE FROM ${jobs}
112
- WHERE ${jobs.state} IN ('completed', 'failed', 'cancelled')
113
- AND ${jobs.finishedAt} <= ${new Date(now.getTime() - ttlMs)}
207
+ DELETE FROM ${dedupe}
208
+ WHERE (${dedupe.windowExpiresAt} IS NOT NULL AND ${dedupe.windowExpiresAt} <= ${now})
209
+ OR (${dedupe.windowExpiresAt} IS NULL AND NOT EXISTS (
210
+ SELECT 1 FROM ${jobs} WHERE ${jobs.id} = ${dedupe.jobId}
211
+ AND ${jobs.state} IN ('waiting', 'delayed', 'active')
212
+ ))
114
213
  `);
115
214
  }).pipe(Effect.catchCause((cause) => Effect.logWarning("effect-mq: history sweep failed", cause)), Effect.forever, Effect.forkScoped);
116
215
  }
117
216
  // Local mutation wake-up: bump synchronously, then best-effort NOTIFY so
118
- // workers in other processes wake promptly too.
119
- const wakeUp = Effect.suspend(() => {
120
- signalWake();
121
- // The payload must be non-empty: @effect/sql-pg drops falsy payloads.
122
- return client.notify(wakeChannel, "1").pipe(Effect.ignore);
217
+ // workers in other processes wake promptly too. The payload names the
218
+ // queue (and must be non-empty: @effect/sql-pg drops falsy payloads).
219
+ const wakeUp = (queue) => Effect.suspend(() => {
220
+ signalWake(queue);
221
+ return client.notify(wakeChannel, queue !== undefined && queue.length > 0 ? queue : "*").pipe(Effect.ignore);
123
222
  });
124
223
  const nowDate = Effect.map(Clock.currentTimeMillis, (ms) => new Date(ms));
125
224
  // quote_ident handles quoted/mixed-case table names safely.
@@ -131,9 +230,30 @@ export const make = (options) => Effect.gen(function* () {
131
230
  FROM ${attempts} WHERE ${attempts.jobId} = ${jobId}
132
231
  `);
133
232
  // Retention: drop terminal peers (same name + state) beyond count/age.
134
- const applyKeep = (tx, row, now) => Effect.gen(function* () {
135
- const keep = row.keep;
233
+ const keepPolicyFor = (keep, state) => {
136
234
  if (keep === null || keep === undefined)
235
+ return undefined;
236
+ const policy = state === "completed"
237
+ ? keep.completed
238
+ : state === "failed"
239
+ ? keep.failed
240
+ : state === "cancelled"
241
+ ? keep.cancelled
242
+ : undefined;
243
+ if (policy !== undefined)
244
+ return policy;
245
+ // Rows persisted by 0.2.x carry the flat {count, ageMs} shape — honour
246
+ // it as an all-states policy so upgrades keep pruning.
247
+ if (keep.completed === undefined && keep.failed === undefined && keep.cancelled === undefined &&
248
+ ("count" in keep || "ageMs" in keep)) {
249
+ // SAFETY: the flat legacy shape carries KeepStatePolicy fields.
250
+ return keep;
251
+ }
252
+ return undefined;
253
+ };
254
+ const applyKeep = (tx, row, now) => Effect.gen(function* () {
255
+ const keep = keepPolicyFor(row.keep, row.state);
256
+ if (keep === undefined)
137
257
  return;
138
258
  if (keep.ageMs !== undefined) {
139
259
  yield* tx.execute(sql `
@@ -159,52 +279,186 @@ export const make = (options) => Effect.gen(function* () {
159
279
  const explainMiss = (id) => db.select({ id: jobs.id }).from(jobs).where(eq(jobs.id, id)).pipe(Effect.mapError(storeError("failed to inspect job")), Effect.flatMap((rows) => Effect.fail(rows.length === 0
160
280
  ? new JobStore.JobNotFoundError({ jobId: id })
161
281
  : new JobStore.LockLostError({ jobId: id }))));
282
+ // The shared INSERT: store-assigned ids come from the configured
283
+ // generator (or the seq sequence); loop on the (unlikely) collision with
284
+ // an existing id — ON CONFLICT DO NOTHING makes the retry safe. Returns
285
+ // the result or undefined when the caller-supplied id already exists.
286
+ const insertJob = (exec, request, now) => Effect.gen(function* () {
287
+ const runAt = new Date(now.getTime() + Math.max(0, request.delayMs));
288
+ const state = request.delayMs > 0 ? "delayed" : "waiting";
289
+ const generate = options.idGenerator;
290
+ for (let i = 0; i < 5; i++) {
291
+ const generated = request.id === undefined && generate !== undefined
292
+ ? yield* Effect.suspend(() => {
293
+ const raw = generate(request);
294
+ return Effect.isEffect(raw) ? raw : Effect.succeed(raw);
295
+ })
296
+ : undefined;
297
+ const idExpr = request.id !== undefined
298
+ ? sql `${request.id}`
299
+ : generated !== undefined
300
+ ? sql `${generated}`
301
+ : sql `'j-' || ${seqExpr}::text`;
302
+ const rows = rowsOf(yield* exec.execute(sql `
303
+ INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
304
+ attempts_max, backoff, keep, timeout_ms, dedupe_key, run_at, enqueued_at${extraColumnNames})
305
+ VALUES (${idExpr}, ${request.name}, ${request.queue}, ${state}, ${request.priority},
306
+ ${JSON.stringify(request.payload ?? null)}::jsonb, ${JSON.stringify(request.metadata)}::jsonb,
307
+ ${request.attemptsMax},
308
+ ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
309
+ ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
310
+ ${request.timeoutMs ?? null}, ${request.dedupe?.key ?? null}, ${runAt}, ${now}${extraColumnValues(request)})
311
+ ON CONFLICT (id) DO NOTHING
312
+ RETURNING ${jobs.id} AS id
313
+ `).pipe(Effect.mapError(storeError("enqueue failed"))));
314
+ const inserted = rows[0];
315
+ if (inserted !== undefined) {
316
+ return { id: JobId(inserted.id), duplicate: false };
317
+ }
318
+ if (request.id !== undefined) {
319
+ return { id: request.id, duplicate: true };
320
+ }
321
+ // generated id collided with an existing user id; try again
322
+ }
323
+ return yield* new JobStore.JobStoreError({
324
+ message: "enqueue failed: could not generate a unique job id"
325
+ });
326
+ });
327
+ // Enqueue with a dedup policy: one transaction locks the (name, key) row
328
+ // and applies the decision tree (replace-while-delayed, throttle window,
329
+ // pending dedup) before falling through to a fresh insert.
330
+ const enqueueDeduped = (request, policy) => db.transaction((tx) => Effect.gen(function* () {
331
+ const now = yield* nowDate;
332
+ // The explicit-id duplicate check precedes the dedup tree, matching
333
+ // the memory and redis drivers.
334
+ if (request.id !== undefined) {
335
+ const existing = rowsOf(yield* tx.execute(sql `
336
+ SELECT ${jobs.id} AS id FROM ${jobs} WHERE ${jobs.id} = ${request.id}
337
+ `));
338
+ if (existing.length > 0) {
339
+ return { id: request.id, duplicate: true, wake: false };
340
+ }
341
+ }
342
+ // A SELECT FOR UPDATE on a missing row locks nothing, so two
343
+ // concurrent first-enqueues would both insert. The no-op upsert
344
+ // always takes the row lock: a fresh placeholder (job_id = '')
345
+ // reads as "no entry" and falls through to the insert below.
346
+ const rows = rowsOf(yield* tx.execute(sql `
347
+ INSERT INTO ${dedupe} (name, key, job_id, window_expires_at)
348
+ VALUES (${request.name}, ${policy.key}, '', NULL)
349
+ ON CONFLICT (name, key) DO UPDATE SET name = EXCLUDED.name
350
+ RETURNING ${dedupe.jobId} AS "jobId", ${dedupe.windowExpiresAt} AS "windowExpiresAt"
351
+ `));
352
+ const entry = rows[0];
353
+ if (entry !== undefined && entry.jobId !== "") {
354
+ // Plain read (no FOR UPDATE): locking the job row here would
355
+ // invert the jobs-then-dedupe lock order every terminal
356
+ // transition uses and deadlock under load. The replace branch
357
+ // compensates with a state-conditional UPDATE.
358
+ const keyed = rowsOf(yield* tx.execute(sql `
359
+ SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${entry.jobId}
360
+ `));
361
+ const keyedState = keyed[0]?.state;
362
+ const windowLive = entry.windowExpiresAt !== null &&
363
+ entry.windowExpiresAt.getTime() > now.getTime();
364
+ const bumpWindow = policy.extend && policy.ttlMs !== undefined
365
+ ? tx.execute(sql `
366
+ UPDATE ${dedupe} SET window_expires_at = ${new Date(now.getTime() + policy.ttlMs)}
367
+ WHERE ${dedupe.name} = ${request.name} AND ${dedupe.key} = ${policy.key}
368
+ `).pipe(Effect.asVoid)
369
+ : Effect.void;
370
+ // Latest-wins while the keyed job is still delayed. The UPDATE
371
+ // re-checks the state so a concurrent claim degrades this to a
372
+ // plain dedup instead of rewriting an active job.
373
+ if (policy.replace && keyedState === "delayed") {
374
+ const replaced = rowsOf(yield* tx.execute(sql `
375
+ UPDATE ${jobs} SET
376
+ payload = ${JSON.stringify(request.payload ?? null)}::jsonb,
377
+ metadata = ${JSON.stringify(request.metadata)}::jsonb,
378
+ priority = ${request.priority},
379
+ attempts_max = ${request.attemptsMax},
380
+ backoff = ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
381
+ keep = ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
382
+ timeout_ms = ${request.timeoutMs ?? null},
383
+ run_at = ${new Date(now.getTime() + Math.max(0, request.delayMs))}${extraColumnAssignments(request)}
384
+ WHERE ${jobs.id} = ${entry.jobId} AND ${jobs.state} = 'delayed'
385
+ RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
386
+ `));
387
+ if (replaced.length > 0) {
388
+ // A landed replace re-arms the ttl window (the entry must
389
+ // outlive the chain it is deduplicating).
390
+ if (policy.ttlMs !== undefined) {
391
+ yield* tx.execute(sql `
392
+ UPDATE ${dedupe} SET window_expires_at = ${new Date(now.getTime() + policy.ttlMs)}
393
+ WHERE ${dedupe.name} = ${request.name} AND ${dedupe.key} = ${policy.key}
394
+ `);
395
+ }
396
+ // The replace does not move the job between queues — wake
397
+ // the queue that actually holds the now-rescheduled job.
398
+ return {
399
+ id: JobId(entry.jobId),
400
+ duplicate: true,
401
+ wake: true,
402
+ wakeQueue: JobStore.QueueName(replaced[0]?.queue ?? request.queue)
403
+ };
404
+ }
405
+ return { id: JobId(entry.jobId), duplicate: true, wake: false };
406
+ }
407
+ if (windowLive) {
408
+ yield* bumpWindow;
409
+ return { id: JobId(entry.jobId), duplicate: true, wake: false };
410
+ }
411
+ const pending = keyedState !== undefined && keyedState !== "completed" &&
412
+ keyedState !== "failed" && keyedState !== "cancelled";
413
+ if (entry.windowExpiresAt === null && pending) {
414
+ return { id: JobId(entry.jobId), duplicate: true, wake: false };
415
+ }
416
+ // Dead entry: the new job takes over the key below.
417
+ }
418
+ const result = yield* insertJob(tx, request, now);
419
+ if (!result.duplicate) {
420
+ yield* tx.execute(sql `
421
+ INSERT INTO ${dedupe} (name, key, job_id, window_expires_at)
422
+ VALUES (${request.name}, ${policy.key}, ${result.id},
423
+ ${policy.ttlMs === undefined ? null : new Date(now.getTime() + policy.ttlMs)})
424
+ ON CONFLICT (name, key) DO UPDATE SET
425
+ job_id = EXCLUDED.job_id, window_expires_at = EXCLUDED.window_expires_at
426
+ `);
427
+ }
428
+ return { ...result, wake: !result.duplicate };
429
+ })).pipe(
430
+ // Residual lock-order inversions (replace vs cancel of the same
431
+ // delayed job) surface as Postgres deadlocks (40P01); one side is
432
+ // killed and safe to retry.
433
+ Effect.retry({
434
+ times: 3,
435
+ while: (error) => String(error).includes("40P01") || String(error).includes("deadlock detected")
436
+ }), Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("enqueue failed")(error)));
437
+ // A job leaving the pending states frees its pending-mode dedup row; live
438
+ // throttle windows deliberately outlast the job.
439
+ const releaseDedupe = (exec, name, dedupeKey, jobId, now) => dedupeKey === null
440
+ ? Effect.void
441
+ : exec.execute(sql `
442
+ DELETE FROM ${dedupe}
443
+ WHERE ${dedupe.name} = ${name} AND ${dedupe.key} = ${dedupeKey}
444
+ AND ${dedupe.jobId} = ${jobId}
445
+ AND (${dedupe.windowExpiresAt} IS NULL OR ${dedupe.windowExpiresAt} <= ${now})
446
+ `).pipe(Effect.asVoid);
162
447
  const store = {
163
448
  enqueue: (request) => Effect.gen(function* () {
164
- const now = yield* nowDate;
165
- const runAt = new Date(now.getTime() + Math.max(0, request.delayMs));
166
- const state = request.delayMs > 0 ? "delayed" : "waiting";
167
- // Store-assigned ids come from the configured generator (or the
168
- // seq sequence); loop on the (unlikely) collision with an existing
169
- // id — ON CONFLICT DO NOTHING makes the retry safe.
170
- const generate = options.idGenerator;
171
- for (let i = 0; i < 5; i++) {
172
- const generated = request.id === undefined && generate !== undefined
173
- ? yield* Effect.suspend(() => {
174
- const raw = generate(request);
175
- return Effect.isEffect(raw) ? raw : Effect.succeed(raw);
176
- })
177
- : undefined;
178
- const idExpr = request.id !== undefined
179
- ? sql `${request.id}`
180
- : generated !== undefined
181
- ? sql `${generated}`
182
- : sql `'j-' || ${seqExpr}::text`;
183
- const rows = rowsOf(yield* db.execute(sql `
184
- INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
185
- attempts_max, backoff, keep, timeout_ms, run_at, enqueued_at)
186
- VALUES (${idExpr}, ${request.name}, ${request.queue}, ${state}, ${request.priority},
187
- ${JSON.stringify(request.payload ?? null)}::jsonb, ${JSON.stringify(request.metadata)}::jsonb,
188
- ${request.attemptsMax},
189
- ${request.backoff === undefined ? null : JSON.stringify(request.backoff)}::jsonb,
190
- ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
191
- ${request.timeoutMs ?? null}, ${runAt}, ${now})
192
- ON CONFLICT (id) DO NOTHING
193
- RETURNING ${jobs.id} AS id
194
- `).pipe(Effect.mapError(storeError("enqueue failed"))));
195
- const inserted = rows[0];
196
- if (inserted !== undefined) {
197
- yield* wakeUp;
198
- return { id: JobId(inserted.id), duplicate: false };
199
- }
200
- if (request.id !== undefined) {
201
- return { id: request.id, duplicate: true };
449
+ if (request.dedupe !== undefined) {
450
+ const result = yield* enqueueDeduped(request, request.dedupe);
451
+ if (result.wake) {
452
+ yield* wakeUp("wakeQueue" in result && result.wakeQueue !== undefined ? result.wakeQueue : request.queue);
202
453
  }
203
- // generated id collided with an existing user id; try again
454
+ return { id: result.id, duplicate: result.duplicate };
204
455
  }
205
- return yield* new JobStore.JobStoreError({
206
- message: "enqueue failed: could not generate a unique job id"
207
- });
456
+ const now = yield* nowDate;
457
+ const result = yield* insertJob(db, request, now);
458
+ if (!result.duplicate) {
459
+ yield* wakeUp(request.queue);
460
+ }
461
+ return result;
208
462
  }),
209
463
  claim: (claimOptions) => Effect.suspend(() => {
210
464
  // Snapshot BEFORE the transaction: any wake that fires while the
@@ -244,7 +498,7 @@ export const make = (options) => Effect.gen(function* () {
244
498
  ${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
245
499
  ${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
246
500
  ${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
247
- ${jobs.cancelRequested} AS "cancelRequested",
501
+ ${jobs.cancelRequested} AS "cancelRequested", ${jobs.dedupeKey} AS "dedupeKey",
248
502
  ${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
249
503
  ${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
250
504
  ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
@@ -289,7 +543,8 @@ export const make = (options) => Effect.gen(function* () {
289
543
  attempts_made = ${jobs.attemptsMade} + 1, lock_token = NULL, lock_expires_at = NULL
290
544
  WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
291
545
  RETURNING ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name",
292
- ${jobs.state} AS "state", ${jobs.keep} AS "keep"
546
+ ${jobs.state} AS "state", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey",
547
+ ${jobs.queue} AS "queue"
293
548
  `));
294
549
  const row = rows[0];
295
550
  if (row === undefined) {
@@ -305,14 +560,18 @@ export const make = (options) => Effect.gen(function* () {
305
560
  : "retried";
306
561
  yield* insertAttempt(tx, id, ledgerOutcome, row.processedAt, now, outcome._tag === "Cancelled" || cancelledRetry ? undefined : outcome.exit);
307
562
  if (outcome._tag !== "Retry" || cancelledRetry) {
563
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
308
564
  yield* applyKeep(tx, row, now);
309
565
  }
566
+ return outcome._tag === "Retry" && !cancelledRetry
567
+ ? JobStore.QueueName(row.queue)
568
+ : undefined;
310
569
  })).pipe(Effect.mapError((error) => error instanceof JobStore.JobNotFoundError || error instanceof JobStore.LockLostError ||
311
570
  error instanceof JobStore.JobStoreError
312
571
  ? error
313
- : storeError("ack failed")(error)), Effect.tap(() => outcome._tag === "Retry" ? wakeUp : Effect.void)),
572
+ : storeError("ack failed")(error)), Effect.tap((queue) => queue !== undefined ? wakeUp(queue) : Effect.void), Effect.asVoid),
314
573
  release: (id, token) => Effect.gen(function* () {
315
- const cancelled = yield* db.transaction((tx) => Effect.gen(function* () {
574
+ const released = yield* db.transaction((tx) => Effect.gen(function* () {
316
575
  const now = yield* nowDate;
317
576
  // A cancel that arrived while the worker was shutting down is
318
577
  // honoured instead of reviving the job.
@@ -324,22 +583,24 @@ export const make = (options) => Effect.gen(function* () {
324
583
  lock_token = NULL, lock_expires_at = NULL
325
584
  WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
326
585
  RETURNING ${jobs.id} AS id, (${jobs.state} = 'cancelled') AS cancelled,
327
- ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep"
586
+ ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
587
+ ${jobs.dedupeKey} AS "dedupeKey", ${jobs.queue} AS "queue"
328
588
  `));
329
589
  const row = rows[0];
330
590
  if (row === undefined)
331
591
  return undefined;
332
592
  if (row.cancelled) {
333
593
  yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined);
594
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
334
595
  yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now);
335
596
  }
336
- return row.cancelled;
597
+ return { cancelled: row.cancelled, queue: JobStore.QueueName(row.queue) };
337
598
  })).pipe(Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("release failed")(error)));
338
- if (cancelled === undefined) {
599
+ if (released === undefined) {
339
600
  return yield* explainMiss(id);
340
601
  }
341
- if (!cancelled) {
342
- yield* wakeUp;
602
+ if (!released.cancelled) {
603
+ yield* wakeUp(released.queue);
343
604
  }
344
605
  }),
345
606
  extendLocks: (locks, durationMs) => Effect.gen(function* () {
@@ -399,11 +660,14 @@ export const make = (options) => Effect.gen(function* () {
399
660
  cancel_requested = FALSE
400
661
  WHERE ${jobs.state} = 'active' AND ${jobs.lockExpiresAt} <= ${now}::timestamptz
401
662
  RETURNING ${jobs.id} AS "id", ${jobs.state} AS "state", ${jobs.processedAt} AS "processedAt",
402
- ${jobs.name} AS "name", ${jobs.keep} AS "keep"
663
+ ${jobs.name} AS "name", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey"
403
664
  `));
404
665
  const recovered = [];
405
666
  for (const row of rows) {
406
667
  yield* insertAttempt(tx, row.id, row.state === "cancelled" ? "cancelled" : "stalled", row.processedAt, now, undefined);
668
+ if (row.state === "cancelled" || row.state === "failed") {
669
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, row.id, now);
670
+ }
407
671
  if (row.state === "cancelled") {
408
672
  yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now);
409
673
  }
@@ -412,11 +676,13 @@ export const make = (options) => Effect.gen(function* () {
412
676
  }
413
677
  }
414
678
  return recovered;
415
- })).pipe(Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("recoverStalled failed")(error)), Effect.tap((recovered) => recovered.some((entry) => !entry.failed) ? wakeUp : Effect.void)),
416
- awaitWake: (_queues, wakeToken) => Effect.suspend(() => {
417
- if (wakeVersion > wakeToken)
679
+ })).pipe(Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("recoverStalled failed")(error)), Effect.tap((recovered) => recovered.some((entry) => !entry.failed) ? wakeUp() : Effect.void)),
680
+ awaitWake: (queues, wakeToken) => Effect.suspend(() => {
681
+ if (queues.some((queue) => lastWakeFor(queue) > wakeToken))
418
682
  return Effect.void;
419
- return Deferred.await(wake);
683
+ const waiter = { queues: new Set(queues), deferred: Deferred.makeUnsafe() };
684
+ waiters.add(waiter);
685
+ return Deferred.await(waiter.deferred).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(waiter))));
420
686
  }),
421
687
  getJob: (id) => db.select().from(jobs).where(eq(jobs.id, id)).pipe(Effect.mapError(storeError("getJob failed")), Effect.map((rows) => {
422
688
  const row = rows[0];
@@ -483,7 +749,7 @@ export const make = (options) => Effect.gen(function* () {
483
749
  exit = NULL, failed_reason = NULL, finished_at = NULL, processed_at = NULL,
484
750
  run_at = ${now}, seq = ${seqExpr}
485
751
  WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'failed'
486
- RETURNING ${jobs.id} AS id
752
+ RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
487
753
  `).pipe(Effect.mapError(storeError("retry failed"))));
488
754
  if (rows.length === 0) {
489
755
  const existing = yield* db.select({ state: jobs.state }).from(jobs)
@@ -494,7 +760,7 @@ export const make = (options) => Effect.gen(function* () {
494
760
  }
495
761
  return yield* new JobStore.JobNotRetryableError({ jobId: id, state: found.state });
496
762
  }
497
- yield* wakeUp;
763
+ yield* wakeUp(JobStore.QueueName(rows[0]?.queue ?? ""));
498
764
  }),
499
765
  cancel: (id) => db.transaction((tx) => Effect.gen(function* () {
500
766
  const now = yield* nowDate;
@@ -507,7 +773,8 @@ export const make = (options) => Effect.gen(function* () {
507
773
  cancel_requested = CASE WHEN ${jobs.state} = 'active' THEN TRUE ELSE ${jobs.cancelRequested} END
508
774
  WHERE ${jobs.id} = ${id} AND ${jobs.state} IN ('waiting', 'delayed', 'active')
509
775
  RETURNING ${jobs.id} AS id, ${jobs.state} AS state,
510
- ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep"
776
+ ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
777
+ ${jobs.dedupeKey} AS "dedupeKey"
511
778
  `));
512
779
  const row = rows[0];
513
780
  if (row === undefined) {
@@ -522,6 +789,7 @@ export const make = (options) => Effect.gen(function* () {
522
789
  }
523
790
  if (row.state === "cancelled") {
524
791
  yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined);
792
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
525
793
  yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now);
526
794
  }
527
795
  })).pipe(Effect.mapError((error) => error instanceof JobStore.JobNotFoundError ||
@@ -534,7 +802,7 @@ export const make = (options) => Effect.gen(function* () {
534
802
  const rows = rowsOf(yield* db.execute(sql `
535
803
  UPDATE ${jobs} SET state = 'waiting', run_at = ${now}
536
804
  WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'delayed'
537
- RETURNING ${jobs.id} AS id
805
+ RETURNING ${jobs.id} AS id, ${jobs.queue} AS "queue"
538
806
  `).pipe(Effect.mapError(storeError("promote failed"))));
539
807
  if (rows.length === 0) {
540
808
  const existing = rowsOf(yield* db.execute(sql `
@@ -546,7 +814,7 @@ export const make = (options) => Effect.gen(function* () {
546
814
  }
547
815
  return yield* new JobStore.JobNotPromotableError({ jobId: id, state: found.state });
548
816
  }
549
- yield* wakeUp;
817
+ yield* wakeUp(JobStore.QueueName(rows[0]?.queue ?? ""));
550
818
  }),
551
819
  pause: (queue) => db.execute(sql `
552
820
  INSERT INTO ${queues} (queue, paused) VALUES (${queue}, TRUE)
@@ -554,7 +822,7 @@ export const make = (options) => Effect.gen(function* () {
554
822
  `).pipe(Effect.mapError(storeError("pause failed")), Effect.asVoid),
555
823
  resume: (queue) => db.execute(sql `
556
824
  UPDATE ${queues} SET paused = FALSE WHERE ${queues.queue} = ${queue}
557
- `).pipe(Effect.mapError(storeError("resume failed")), Effect.andThen(wakeUp)),
825
+ `).pipe(Effect.mapError(storeError("resume failed")), Effect.andThen(wakeUp(queue))),
558
826
  pausedQueues: () => db.execute(sql `
559
827
  SELECT ${queues.queue} AS queue FROM ${queues} WHERE ${queues.paused} = TRUE
560
828
  `).pipe(Effect.mapError(storeError("pausedQueues failed")), Effect.map((result) => rowsOf(result).map((row) => JobStore.QueueName(row.queue)))),
@@ -580,7 +848,7 @@ export const make = (options) => Effect.gen(function* () {
580
848
  AND ${schedules.everyMs} IS NOT DISTINCT FROM EXCLUDED.every_ms
581
849
  THEN ${schedules.nextRunAt}
582
850
  ELSE EXCLUDED.next_run_at END
583
- `).pipe(Effect.mapError(storeError("upsertSchedule failed")), Effect.asVoid),
851
+ `).pipe(Effect.mapError(storeError("upsertSchedule failed")), Effect.andThen(wakeUp(schedule.queue))),
584
852
  removeSchedule: (key) => db.execute(sql `
585
853
  DELETE FROM ${schedules} WHERE ${schedules.key} = ${key}
586
854
  RETURNING ${schedules.key} AS key