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
@@ -16,6 +16,8 @@ import {
16
16
  type ClaimResult,
17
17
  type EnqueueRequest,
18
18
  type ExtendLocksResult,
19
+ type HistoryTtlByState,
20
+ type HistoryTtlInput,
19
21
  type IdGenerator,
20
22
  JobId,
21
23
  JobNotCancellableError,
@@ -24,10 +26,13 @@ import {
24
26
  JobNotRetryableError,
25
27
  type JobRecord,
26
28
  type JobState,
29
+ type KeepStatePolicy,
27
30
  JobStore,
28
31
  type ListResult,
29
32
  LockLostError,
30
33
  JobStoreError,
34
+ normalizeHistoryTtl,
35
+ type TerminalState,
31
36
  type QueueName,
32
37
  type ScheduleKey,
33
38
  type ScheduleRecord,
@@ -38,17 +43,18 @@ interface MemJob {
38
43
  readonly id: JobId
39
44
  readonly name: string
40
45
  readonly queue: QueueName
41
- readonly payload: unknown
42
- readonly metadata: Readonly<Record<string, string>>
46
+ payload: unknown
47
+ metadata: Readonly<Record<string, string>>
43
48
  state: JobState
44
- readonly priority: number
45
- readonly attemptsMax: number
49
+ priority: number
50
+ attemptsMax: number
46
51
  attemptsMade: number
47
52
  stalledCount: number
48
- readonly backoff: JobRecord["backoff"]
49
- readonly keep: JobRecord["keep"]
50
- readonly timeoutMs: number | undefined
53
+ backoff: JobRecord["backoff"]
54
+ keep: JobRecord["keep"]
55
+ timeoutMs: number | undefined
51
56
  cancelRequested: boolean
57
+ readonly dedupeKey: string | undefined
52
58
  runAt: number
53
59
  readonly enqueuedAt: number
54
60
  processedAt: number | undefined
@@ -78,6 +84,7 @@ const snapshot = (job: MemJob): JobRecord => ({
78
84
  keep: job.keep,
79
85
  timeoutMs: job.timeoutMs,
80
86
  cancelRequested: job.cancelRequested,
87
+ dedupeKey: job.dedupeKey,
81
88
  runAt: job.runAt,
82
89
  enqueuedAt: job.enqueuedAt,
83
90
  processedAt: job.processedAt,
@@ -98,26 +105,53 @@ const metadataMatches = (
98
105
 
99
106
  interface MemoryStore {
100
107
  readonly service: Service
101
- readonly sweepHistory: (now: number, ttlMs: number) => void
108
+ readonly sweepHistory: (now: number, ttlByState: HistoryTtlByState) => void
102
109
  }
103
110
 
104
111
  const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemoryStore => {
105
112
  const jobs = new Map<string, MemJob>()
106
113
  const schedules = new Map<ScheduleKey, ScheduleRecord>()
107
114
  const paused = new Set<QueueName>()
115
+ // Dedup registry: one entry per (name, key). `expiresAt` is set for
116
+ // ttl/throttle windows; pending-mode entries live as long as their job.
117
+ const dedupes = new Map<string, { jobId: JobId; expiresAt: number | undefined }>()
118
+ const dedupeMapKey = (name: string, key: string) => `${name}\u0000${key}`
108
119
  let seq = 0
109
120
  let idCounter = 0
110
121
  let wakeVersion = 0
111
- let wake = Deferred.makeUnsafe<void>()
122
+ let lastBroadcast = 0
123
+ const lastWake = new Map<QueueName, number>()
124
+ interface Waiter {
125
+ readonly queues: ReadonlySet<QueueName>
126
+ readonly deferred: Deferred.Deferred<void>
127
+ }
128
+ const waiters = new Set<Waiter>()
129
+ const lastWakeFor = (queue: QueueName) => Math.max(lastWake.get(queue) ?? 0, lastBroadcast)
112
130
 
113
131
  // Synchronous on purpose: it is called inside the same synchronous block as
114
132
  // the state mutation, so no effect-op boundary (where an interrupt could
115
- // land) can separate a mutation from its wake-up signal.
116
- const signalWake = () => {
133
+ // land) can separate a mutation from its wake-up signal. A queue targets
134
+ // only waiters watching it; no queue broadcasts (rare maintenance verbs).
135
+ const signalWake = (queue?: QueueName) => {
117
136
  wakeVersion += 1
118
- const current = wake
119
- wake = Deferred.makeUnsafe<void>()
120
- Deferred.doneUnsafe(current, Exit.succeed<void>(void 0))
137
+ if (queue === undefined) {
138
+ lastBroadcast = wakeVersion
139
+ } else {
140
+ lastWake.set(queue, wakeVersion)
141
+ }
142
+ // Snapshot-and-clear BEFORE resolving: doneUnsafe resumes waiting fibers
143
+ // synchronously, and a woken taker that re-parks registers a NEW waiter —
144
+ // resolving inside the live Set iteration would visit it and livelock.
145
+ const toWake: Array<Waiter> = []
146
+ for (const waiter of waiters) {
147
+ if (queue === undefined || waiter.queues.has(queue)) {
148
+ waiters.delete(waiter)
149
+ toWake.push(waiter)
150
+ }
151
+ }
152
+ for (const waiter of toWake) {
153
+ Deferred.doneUnsafe(waiter.deferred, Exit.succeed<void>(void 0))
154
+ }
121
155
  }
122
156
 
123
157
  const promoteDue = (now: number) => {
@@ -148,32 +182,71 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
148
182
  })
149
183
  }
150
184
 
185
+ // A job leaving the pending states frees its pending-mode dedup entry;
186
+ // live throttle windows deliberately outlast the job.
187
+ const releaseDedupe = (job: MemJob, now: number) => {
188
+ if (job.dedupeKey === undefined) return
189
+ const key = dedupeMapKey(job.name, job.dedupeKey)
190
+ const entry = dedupes.get(key)
191
+ if (
192
+ entry !== undefined && entry.jobId === job.id &&
193
+ (entry.expiresAt === undefined || entry.expiresAt <= now)
194
+ ) {
195
+ dedupes.delete(key)
196
+ }
197
+ }
198
+
151
199
  const markCancelled = (job: MemJob, now: number) => {
152
200
  clearLock(job)
153
201
  job.cancelRequested = false
154
202
  job.state = "cancelled"
155
203
  job.finishedAt = now
156
204
  recordAttempt(job, "cancelled", now, undefined)
205
+ releaseDedupe(job, now)
157
206
  applyKeep(job, now)
158
207
  }
159
208
 
209
+ const keepPolicyFor = (keep: JobRecord["keep"], state: JobState) => {
210
+ if (keep === undefined) return undefined
211
+ const policy = state === "completed"
212
+ ? keep.completed
213
+ : state === "failed"
214
+ ? keep.failed
215
+ : state === "cancelled"
216
+ ? keep.cancelled
217
+ : undefined
218
+ if (policy !== undefined) return policy
219
+ // Records persisted by 0.2.x carry the flat {count, ageMs} shape — honour
220
+ // it as an all-states policy so upgrades keep pruning.
221
+ if (
222
+ keep.completed === undefined && keep.failed === undefined && keep.cancelled === undefined &&
223
+ ("count" in keep || "ageMs" in keep)
224
+ ) {
225
+ // SAFETY: the flat legacy shape carries KeepStatePolicy fields.
226
+ return keep as KeepStatePolicy
227
+ }
228
+ return undefined
229
+ }
230
+
160
231
  // Terminal retention: keep at most `count` and drop older than `ageMs`
161
- // among terminal jobs sharing this job's name + state.
232
+ // among terminal jobs sharing this job's name + state (policies are split
233
+ // per terminal state).
162
234
  const applyKeep = (job: MemJob, now: number) => {
163
- if (job.keep === undefined) return
235
+ const policy = keepPolicyFor(job.keep, job.state)
236
+ if (policy === undefined) return
164
237
  const peers = Array.from(jobs.values())
165
238
  .filter((peer) => peer.name === job.name && peer.state === job.state)
166
239
  .toSorted((a, b) => ((b.finishedAt ?? 0) - (a.finishedAt ?? 0)) || (b.seq - a.seq))
167
240
  const remove = new Set<string>()
168
- if (job.keep.ageMs !== undefined) {
241
+ if (policy.ageMs !== undefined) {
169
242
  for (const peer of peers) {
170
- if (peer.finishedAt !== undefined && peer.finishedAt <= now - job.keep.ageMs) {
243
+ if (peer.finishedAt !== undefined && peer.finishedAt <= now - policy.ageMs) {
171
244
  remove.add(peer.id)
172
245
  }
173
246
  }
174
247
  }
175
- if (job.keep.count !== undefined) {
176
- for (const peer of peers.slice(Math.max(0, job.keep.count))) {
248
+ if (policy.count !== undefined) {
249
+ for (const peer of peers.slice(Math.max(0, policy.count))) {
177
250
  remove.add(peer.id)
178
251
  }
179
252
  }
@@ -182,22 +255,75 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
182
255
  }
183
256
  }
184
257
 
185
- const sweepHistory = (now: number, ttlMs: number) => {
258
+ const sweepHistory = (now: number, ttlByState: HistoryTtlByState) => {
186
259
  for (const job of jobs.values()) {
187
- if (
188
- TERMINAL_STATES.has(job.state) &&
189
- job.finishedAt !== undefined &&
190
- job.finishedAt <= now - ttlMs
191
- ) {
260
+ if (!TERMINAL_STATES.has(job.state) || job.finishedAt === undefined) continue
261
+ // SAFETY: TERMINAL_STATES membership was checked above.
262
+ const state = job.state as TerminalState
263
+ const ttl = ttlByState[state]
264
+ const keepAge = keepPolicyFor(job.keep, state)?.ageMs
265
+ // The sweep honours min(per-row keep age, store ceiling) — a quiet job
266
+ // name is pruned on the timer, not only when its group is acked.
267
+ const effective = keepAge !== undefined && (ttl === undefined || keepAge < ttl) ? keepAge : ttl
268
+ if (effective !== undefined && job.finishedAt <= now - effective) {
192
269
  jobs.delete(job.id)
193
270
  }
194
271
  }
272
+ // Dead dedup entries: expired window, or a pointer at a vanished job.
273
+ for (const [key, entry] of dedupes) {
274
+ const alive = entry.expiresAt !== undefined
275
+ ? entry.expiresAt > now
276
+ : jobs.has(entry.jobId) && !TERMINAL_STATES.has(jobs.get(entry.jobId)?.state ?? "completed")
277
+ if (!alive) dedupes.delete(key)
278
+ }
195
279
  }
196
280
 
197
281
  const service: Service = JobStore.of({
198
282
  enqueue: (request: EnqueueRequest) =>
199
283
  Effect.gen(function*() {
200
284
  const now = yield* Clock.currentTimeMillis
285
+ if (request.id !== undefined && jobs.has(request.id)) {
286
+ return { id: request.id, duplicate: true }
287
+ }
288
+ // The dedup decision tree runs BEFORE id generation, so a
289
+ // deduplicated enqueue never consults the id generator.
290
+ if (request.dedupe !== undefined) {
291
+ const mapKey = dedupeMapKey(request.name, request.dedupe.key)
292
+ const entry = dedupes.get(mapKey)
293
+ if (entry !== undefined) {
294
+ const keyed = jobs.get(entry.jobId)
295
+ const windowLive = entry.expiresAt !== undefined && now < entry.expiresAt
296
+ // Latest-wins while the keyed job is still delayed.
297
+ if (request.dedupe.replace && keyed !== undefined && keyed.state === "delayed") {
298
+ keyed.payload = request.payload
299
+ keyed.metadata = request.metadata
300
+ keyed.priority = request.priority
301
+ keyed.attemptsMax = request.attemptsMax
302
+ keyed.backoff = request.backoff
303
+ keyed.keep = request.keep
304
+ keyed.timeoutMs = request.timeoutMs
305
+ keyed.runAt = now + Math.max(0, request.delayMs)
306
+ // A landed replace re-arms the ttl window.
307
+ if (request.dedupe.ttlMs !== undefined) {
308
+ entry.expiresAt = now + request.dedupe.ttlMs
309
+ }
310
+ signalWake(keyed.queue)
311
+ return { id: keyed.id, duplicate: true }
312
+ }
313
+ if (windowLive) {
314
+ if (request.dedupe.extend && request.dedupe.ttlMs !== undefined) {
315
+ entry.expiresAt = now + request.dedupe.ttlMs
316
+ }
317
+ return { id: entry.jobId, duplicate: true }
318
+ }
319
+ const pending = keyed !== undefined && !TERMINAL_STATES.has(keyed.state)
320
+ if (entry.expiresAt === undefined && pending) {
321
+ return { id: entry.jobId, duplicate: true }
322
+ }
323
+ // Dead entry (expired window / finished job): fall through and
324
+ // let the new job take over the key below.
325
+ }
326
+ }
201
327
  let id = request.id
202
328
  if (id === undefined) {
203
329
  const generate = options?.idGenerator
@@ -222,8 +348,6 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
222
348
  })
223
349
  }
224
350
  }
225
- } else if (jobs.has(id)) {
226
- return { id, duplicate: true }
227
351
  }
228
352
  jobs.set(id, {
229
353
  id,
@@ -240,6 +364,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
240
364
  keep: request.keep,
241
365
  timeoutMs: request.timeoutMs,
242
366
  cancelRequested: false,
367
+ dedupeKey: request.dedupe?.key,
243
368
  runAt: now + Math.max(0, request.delayMs),
244
369
  enqueuedAt: now,
245
370
  processedAt: undefined,
@@ -251,7 +376,13 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
251
376
  lockToken: undefined,
252
377
  lockExpiresAt: undefined
253
378
  })
254
- signalWake()
379
+ if (request.dedupe !== undefined) {
380
+ dedupes.set(dedupeMapKey(request.name, request.dedupe.key), {
381
+ jobId: id,
382
+ expiresAt: request.dedupe.ttlMs !== undefined ? now + request.dedupe.ttlMs : undefined
383
+ })
384
+ }
385
+ signalWake(request.queue)
255
386
  return { id, duplicate: false }
256
387
  }),
257
388
 
@@ -309,6 +440,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
309
440
  job.exit = outcome.exit
310
441
  job.finishedAt = now
311
442
  recordAttempt(job, "completed", now, outcome.exit)
443
+ releaseDedupe(job, now)
312
444
  applyKeep(job, now)
313
445
  break
314
446
  }
@@ -318,6 +450,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
318
450
  job.exit = outcome.exit
319
451
  job.finishedAt = now
320
452
  recordAttempt(job, "failed", now, outcome.exit)
453
+ releaseDedupe(job, now)
321
454
  applyKeep(job, now)
322
455
  break
323
456
  }
@@ -326,6 +459,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
326
459
  job.state = "cancelled"
327
460
  job.finishedAt = now
328
461
  recordAttempt(job, "cancelled", now, undefined)
462
+ releaseDedupe(job, now)
329
463
  applyKeep(job, now)
330
464
  break
331
465
  }
@@ -341,7 +475,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
341
475
  job.runAt = now + Math.max(0, outcome.delayMs)
342
476
  job.state = outcome.delayMs > 0 ? "delayed" : "waiting"
343
477
  job.seq = ++seq
344
- signalWake()
478
+ signalWake(job.queue)
345
479
  break
346
480
  }
347
481
  }
@@ -365,7 +499,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
365
499
  }
366
500
  clearLock(job)
367
501
  job.state = "waiting"
368
- signalWake()
502
+ signalWake(job.queue)
369
503
  }),
370
504
 
371
505
  extendLocks: (locks, durationMs) =>
@@ -415,6 +549,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
415
549
  job.state = "failed"
416
550
  job.finishedAt = now
417
551
  job.failedReason = "job stalled more than allowable limit"
552
+ releaseDedupe(job, now)
418
553
  recovered.push({ id: job.id, failed: true })
419
554
  } else {
420
555
  job.state = "waiting"
@@ -427,10 +562,14 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
427
562
  return recovered
428
563
  }),
429
564
 
430
- awaitWake: (_queues, wakeToken) =>
565
+ awaitWake: (queues, wakeToken) =>
431
566
  Effect.suspend(() => {
432
- if (wakeVersion > wakeToken) return Effect.void
433
- return Deferred.await(wake)
567
+ if (queues.some((queue) => lastWakeFor(queue) > wakeToken)) return Effect.void
568
+ const waiter: Waiter = { queues: new Set(queues), deferred: Deferred.makeUnsafe<void>() }
569
+ waiters.add(waiter)
570
+ return Deferred.await(waiter.deferred).pipe(
571
+ Effect.ensuring(Effect.sync(() => waiters.delete(waiter)))
572
+ )
434
573
  }),
435
574
 
436
575
  getJob: (id) =>
@@ -510,7 +649,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
510
649
  job.processedAt = undefined
511
650
  job.runAt = now
512
651
  job.seq = ++seq
513
- signalWake()
652
+ signalWake(job.queue)
514
653
  }),
515
654
 
516
655
  cancel: (id) =>
@@ -548,7 +687,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
548
687
  }
549
688
  job.state = "waiting"
550
689
  job.runAt = now
551
- signalWake()
690
+ signalWake(job.queue)
552
691
  }),
553
692
 
554
693
  pause: (queue) =>
@@ -559,7 +698,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
559
698
  resume: (queue) =>
560
699
  Effect.sync(() => {
561
700
  if (paused.delete(queue)) {
562
- signalWake()
701
+ signalWake(queue)
563
702
  }
564
703
  }),
565
704
 
@@ -579,7 +718,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
579
718
  schedule.key,
580
719
  sameCadence ? { ...schedule, nextRunAt: existing.nextRunAt } : schedule
581
720
  )
582
- signalWake()
721
+ signalWake(schedule.queue)
583
722
  }),
584
723
 
585
724
  removeSchedule: (key) => Effect.sync(() => schedules.delete(key)),
@@ -642,11 +781,12 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
642
781
  */
643
782
  export interface MemoryJobStoreOptions {
644
783
  /**
645
- * Store-level retention ceiling: terminal records (completed, failed,
646
- * cancelled) older than this are removed by a periodic sweep. Per-job
647
- * `keep` policies may only be stricter.
784
+ * Store-level retention ceiling: terminal records older than this are
785
+ * removed by a periodic sweep — one duration for all terminal states or a
786
+ * per-state split (`{ completed: "1 day", failed: "30 days" }`). The sweep
787
+ * also honours stricter per-job `keep.age` rules.
648
788
  */
649
- readonly historyTtl?: Duration.Input | undefined
789
+ readonly historyTtl?: HistoryTtlInput | undefined
650
790
  /** Sweep cadence (default 1 minute). */
651
791
  readonly historySweepInterval?: Duration.Input | undefined
652
792
  /**
@@ -676,12 +816,12 @@ export const makeWith = (
676
816
  Effect.gen(function*() {
677
817
  const { service, sweepHistory } = makeStoreUnsafe(options)
678
818
  if (options?.historyTtl !== undefined) {
679
- const ttlMs = Duration.toMillis(options.historyTtl)
819
+ const ttlByState = normalizeHistoryTtl(options.historyTtl)
680
820
  const intervalMs = Duration.toMillis(options.historySweepInterval ?? "1 minute")
681
821
  yield* Effect.gen(function*() {
682
822
  yield* Effect.sleep(intervalMs)
683
823
  const now = yield* Clock.currentTimeMillis
684
- sweepHistory(now, ttlMs)
824
+ sweepHistory(now, ttlByState)
685
825
  }).pipe(Effect.forever, Effect.forkScoped)
686
826
  }
687
827
  return service
package/src/Worker.ts CHANGED
@@ -534,6 +534,7 @@ export const make = <StoreId = JobStore>(
534
534
  backoff: schedule.backoff,
535
535
  keep: schedule.keep,
536
536
  timeoutMs: schedule.timeoutMs,
537
+ dedupe: undefined,
537
538
  delayMs: 0
538
539
  }))
539
540
  yield* retryStore(store.advanceSchedule(schedule.key, slot, next))