effect-mq 0.1.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 (67) hide show
  1. package/README.md +305 -25
  2. package/dist/Job.d.ts +118 -5
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +119 -4
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +260 -9
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js +115 -1
  9. package/dist/JobStore.js.map +1 -1
  10. package/dist/MemoryJobStore.d.ts +38 -6
  11. package/dist/MemoryJobStore.d.ts.map +1 -1
  12. package/dist/MemoryJobStore.js +351 -47
  13. package/dist/MemoryJobStore.js.map +1 -1
  14. package/dist/Worker.d.ts +5 -1
  15. package/dist/Worker.d.ts.map +1 -1
  16. package/dist/Worker.js +117 -10
  17. package/dist/Worker.js.map +1 -1
  18. package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.d.ts +35 -2
  19. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -0
  20. package/dist/drizzle-postgres/DrizzleJobStore.js +941 -0
  21. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -0
  22. package/dist/drizzle-postgres/index.d.ts.map +1 -0
  23. package/dist/drizzle-postgres/index.js.map +1 -0
  24. package/dist/drizzle-postgres/schema.d.ts +670 -0
  25. package/dist/drizzle-postgres/schema.d.ts.map +1 -0
  26. package/dist/drizzle-postgres/schema.js +150 -0
  27. package/dist/drizzle-postgres/schema.js.map +1 -0
  28. package/dist/redis/RedisJobStore.d.ts +58 -0
  29. package/dist/redis/RedisJobStore.d.ts.map +1 -0
  30. package/dist/redis/RedisJobStore.js +424 -0
  31. package/dist/redis/RedisJobStore.js.map +1 -0
  32. package/dist/redis/index.d.ts +9 -0
  33. package/dist/redis/index.d.ts.map +1 -0
  34. package/dist/redis/index.js +9 -0
  35. package/dist/redis/index.js.map +1 -0
  36. package/dist/redis/scripts.d.ts +181 -0
  37. package/dist/redis/scripts.d.ts.map +1 -0
  38. package/dist/redis/scripts.js +940 -0
  39. package/dist/redis/scripts.js.map +1 -0
  40. package/dist/testing/conformance.d.ts.map +1 -1
  41. package/dist/testing/conformance.js +502 -8
  42. package/dist/testing/conformance.js.map +1 -1
  43. package/package.json +8 -4
  44. package/src/Job.ts +301 -10
  45. package/src/JobStore.ts +373 -9
  46. package/src/MemoryJobStore.ts +440 -53
  47. package/src/Worker.ts +153 -10
  48. package/src/drizzle-postgres/DrizzleJobStore.ts +1311 -0
  49. package/src/drizzle-postgres/schema.ts +279 -0
  50. package/src/redis/RedisJobStore.ts +652 -0
  51. package/src/redis/index.ts +8 -0
  52. package/src/redis/scripts.ts +1055 -0
  53. package/src/testing/conformance.ts +665 -8
  54. package/dist/drizzle/DrizzleJobStore.d.ts.map +0 -1
  55. package/dist/drizzle/DrizzleJobStore.js +0 -426
  56. package/dist/drizzle/DrizzleJobStore.js.map +0 -1
  57. package/dist/drizzle/index.d.ts.map +0 -1
  58. package/dist/drizzle/index.js.map +0 -1
  59. package/dist/drizzle/schema.d.ts +0 -464
  60. package/dist/drizzle/schema.d.ts.map +0 -1
  61. package/dist/drizzle/schema.js +0 -68
  62. package/dist/drizzle/schema.js.map +0 -1
  63. package/src/drizzle/DrizzleJobStore.ts +0 -599
  64. package/src/drizzle/schema.ts +0 -116
  65. /package/dist/{drizzle → drizzle-postgres}/index.d.ts +0 -0
  66. /package/dist/{drizzle → drizzle-postgres}/index.js +0 -0
  67. /package/src/{drizzle → drizzle-postgres}/index.ts +0 -0
@@ -10,20 +10,32 @@
10
10
  *
11
11
  * @since 0.1.0
12
12
  */
13
- import { Clock, type Context, Deferred, Effect, Exit, Layer, Option } from "effect"
13
+ import { Clock, type Context, Deferred, Duration, Effect, Exit, Layer, Option, type Scope } from "effect"
14
14
  import {
15
15
  type AttemptRecord,
16
16
  type ClaimResult,
17
17
  type EnqueueRequest,
18
+ type ExtendLocksResult,
19
+ type HistoryTtlByState,
20
+ type HistoryTtlInput,
21
+ type IdGenerator,
18
22
  JobId,
23
+ JobNotCancellableError,
19
24
  JobNotFoundError,
25
+ JobNotPromotableError,
20
26
  JobNotRetryableError,
21
27
  type JobRecord,
22
28
  type JobState,
29
+ type KeepStatePolicy,
23
30
  JobStore,
24
31
  type ListResult,
25
32
  LockLostError,
33
+ JobStoreError,
34
+ normalizeHistoryTtl,
35
+ type TerminalState,
26
36
  type QueueName,
37
+ type ScheduleKey,
38
+ type ScheduleRecord,
27
39
  type Service
28
40
  } from "./JobStore.ts"
29
41
 
@@ -31,15 +43,18 @@ interface MemJob {
31
43
  readonly id: JobId
32
44
  readonly name: string
33
45
  readonly queue: QueueName
34
- readonly payload: unknown
35
- readonly metadata: Readonly<Record<string, string>>
46
+ payload: unknown
47
+ metadata: Readonly<Record<string, string>>
36
48
  state: JobState
37
- readonly priority: number
38
- readonly attemptsMax: number
49
+ priority: number
50
+ attemptsMax: number
39
51
  attemptsMade: number
40
52
  stalledCount: number
41
- readonly backoff: JobRecord["backoff"]
42
- readonly keep: JobRecord["keep"]
53
+ backoff: JobRecord["backoff"]
54
+ keep: JobRecord["keep"]
55
+ timeoutMs: number | undefined
56
+ cancelRequested: boolean
57
+ readonly dedupeKey: string | undefined
43
58
  runAt: number
44
59
  readonly enqueuedAt: number
45
60
  processedAt: number | undefined
@@ -52,6 +67,8 @@ interface MemJob {
52
67
  lockExpiresAt: number | undefined
53
68
  }
54
69
 
70
+ const TERMINAL_STATES: ReadonlySet<JobState> = new Set(["completed", "failed", "cancelled"])
71
+
55
72
  const snapshot = (job: MemJob): JobRecord => ({
56
73
  id: job.id,
57
74
  name: job.name,
@@ -65,6 +82,9 @@ const snapshot = (job: MemJob): JobRecord => ({
65
82
  stalledCount: job.stalledCount,
66
83
  backoff: job.backoff,
67
84
  keep: job.keep,
85
+ timeoutMs: job.timeoutMs,
86
+ cancelRequested: job.cancelRequested,
87
+ dedupeKey: job.dedupeKey,
68
88
  runAt: job.runAt,
69
89
  enqueuedAt: job.enqueuedAt,
70
90
  processedAt: job.processedAt,
@@ -83,26 +103,55 @@ const metadataMatches = (
83
103
  return true
84
104
  }
85
105
 
86
- /**
87
- * Build a fresh in-memory `JobStore` implementation.
88
- *
89
- * @since 0.1.0
90
- */
91
- export const make: Effect.Effect<Service> = Effect.sync(() => {
106
+ interface MemoryStore {
107
+ readonly service: Service
108
+ readonly sweepHistory: (now: number, ttlByState: HistoryTtlByState) => void
109
+ }
110
+
111
+ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemoryStore => {
92
112
  const jobs = new Map<string, MemJob>()
113
+ const schedules = new Map<ScheduleKey, ScheduleRecord>()
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}`
93
119
  let seq = 0
94
120
  let idCounter = 0
95
121
  let wakeVersion = 0
96
- 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)
97
130
 
98
131
  // Synchronous on purpose: it is called inside the same synchronous block as
99
132
  // the state mutation, so no effect-op boundary (where an interrupt could
100
- // land) can separate a mutation from its wake-up signal.
101
- 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) => {
102
136
  wakeVersion += 1
103
- const current = wake
104
- wake = Deferred.makeUnsafe<void>()
105
- 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
+ }
106
155
  }
107
156
 
108
157
  const promoteDue = (now: number) => {
@@ -133,23 +182,71 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
133
182
  })
134
183
  }
135
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
+
199
+ const markCancelled = (job: MemJob, now: number) => {
200
+ clearLock(job)
201
+ job.cancelRequested = false
202
+ job.state = "cancelled"
203
+ job.finishedAt = now
204
+ recordAttempt(job, "cancelled", now, undefined)
205
+ releaseDedupe(job, now)
206
+ applyKeep(job, now)
207
+ }
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
+
136
231
  // Terminal retention: keep at most `count` and drop older than `ageMs`
137
- // 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).
138
234
  const applyKeep = (job: MemJob, now: number) => {
139
- if (job.keep === undefined) return
235
+ const policy = keepPolicyFor(job.keep, job.state)
236
+ if (policy === undefined) return
140
237
  const peers = Array.from(jobs.values())
141
238
  .filter((peer) => peer.name === job.name && peer.state === job.state)
142
239
  .toSorted((a, b) => ((b.finishedAt ?? 0) - (a.finishedAt ?? 0)) || (b.seq - a.seq))
143
240
  const remove = new Set<string>()
144
- if (job.keep.ageMs !== undefined) {
241
+ if (policy.ageMs !== undefined) {
145
242
  for (const peer of peers) {
146
- if (peer.finishedAt !== undefined && peer.finishedAt <= now - job.keep.ageMs) {
243
+ if (peer.finishedAt !== undefined && peer.finishedAt <= now - policy.ageMs) {
147
244
  remove.add(peer.id)
148
245
  }
149
246
  }
150
247
  }
151
- if (job.keep.count !== undefined) {
152
- 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))) {
153
250
  remove.add(peer.id)
154
251
  }
155
252
  }
@@ -158,18 +255,99 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
158
255
  }
159
256
  }
160
257
 
161
- return JobStore.of({
258
+ const sweepHistory = (now: number, ttlByState: HistoryTtlByState) => {
259
+ for (const job of jobs.values()) {
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) {
269
+ jobs.delete(job.id)
270
+ }
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
+ }
279
+ }
280
+
281
+ const service: Service = JobStore.of({
162
282
  enqueue: (request: EnqueueRequest) =>
163
283
  Effect.gen(function*() {
164
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
+ }
165
327
  let id = request.id
166
328
  if (id === undefined) {
167
- // Store-assigned ids must never collide with user-supplied ones.
168
- do {
169
- id = JobId(`j-${++idCounter}`)
170
- } while (jobs.has(id))
171
- } else if (jobs.has(id)) {
172
- return { id, duplicate: true }
329
+ const generate = options?.idGenerator
330
+ if (generate === undefined) {
331
+ // Store-assigned ids must never collide with user-supplied ones.
332
+ do {
333
+ id = JobId(`j-${++idCounter}`)
334
+ } while (jobs.has(id))
335
+ } else {
336
+ // A user generator gets a bounded number of collision retries; a
337
+ // healthy generator's entropy makes even one retry pathological.
338
+ for (let i = 0; i < 5 && id === undefined; i++) {
339
+ const raw = generate(request)
340
+ const candidate = JobId(Effect.isEffect(raw) ? yield* raw : raw)
341
+ if (!jobs.has(candidate)) {
342
+ id = candidate
343
+ }
344
+ }
345
+ if (id === undefined) {
346
+ return yield* new JobStoreError({
347
+ message: "enqueue failed: could not generate a unique job id"
348
+ })
349
+ }
350
+ }
173
351
  }
174
352
  jobs.set(id, {
175
353
  id,
@@ -184,6 +362,9 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
184
362
  stalledCount: 0,
185
363
  backoff: request.backoff,
186
364
  keep: request.keep,
365
+ timeoutMs: request.timeoutMs,
366
+ cancelRequested: false,
367
+ dedupeKey: request.dedupe?.key,
187
368
  runAt: now + Math.max(0, request.delayMs),
188
369
  enqueuedAt: now,
189
370
  processedAt: undefined,
@@ -195,7 +376,13 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
195
376
  lockToken: undefined,
196
377
  lockExpiresAt: undefined
197
378
  })
198
- 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)
199
386
  return { id, duplicate: false }
200
387
  }),
201
388
 
@@ -222,7 +409,7 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
222
409
  }
223
410
  }
224
411
  }
225
- if (best === undefined) {
412
+ if (best === undefined || paused.has(options.queue)) {
226
413
  const empty: ClaimResult = { _tag: "Empty", nextRunAt, wakeToken: wakeVersion }
227
414
  return empty
228
415
  }
@@ -248,27 +435,47 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
248
435
  job.attemptsMade += 1
249
436
  switch (outcome._tag) {
250
437
  case "Complete": {
438
+ job.cancelRequested = false
251
439
  job.state = "completed"
252
440
  job.exit = outcome.exit
253
441
  job.finishedAt = now
254
442
  recordAttempt(job, "completed", now, outcome.exit)
443
+ releaseDedupe(job, now)
255
444
  applyKeep(job, now)
256
445
  break
257
446
  }
258
447
  case "Fail": {
448
+ job.cancelRequested = false
259
449
  job.state = "failed"
260
450
  job.exit = outcome.exit
261
451
  job.finishedAt = now
262
452
  recordAttempt(job, "failed", now, outcome.exit)
453
+ releaseDedupe(job, now)
454
+ applyKeep(job, now)
455
+ break
456
+ }
457
+ case "Cancelled": {
458
+ job.cancelRequested = false
459
+ job.state = "cancelled"
460
+ job.finishedAt = now
461
+ recordAttempt(job, "cancelled", now, undefined)
462
+ releaseDedupe(job, now)
263
463
  applyKeep(job, now)
264
464
  break
265
465
  }
266
466
  case "Retry": {
467
+ if (job.cancelRequested) {
468
+ // A cancel raced a natural failure before the heartbeat could
469
+ // interrupt the run: cancellation wins over revival (mirrors
470
+ // release/recoverStalled).
471
+ markCancelled(job, now)
472
+ break
473
+ }
267
474
  recordAttempt(job, "retried", now, outcome.exit)
268
475
  job.runAt = now + Math.max(0, outcome.delayMs)
269
476
  job.state = outcome.delayMs > 0 ? "delayed" : "waiting"
270
477
  job.seq = ++seq
271
- signalWake()
478
+ signalWake(job.queue)
272
479
  break
273
480
  }
274
481
  }
@@ -276,6 +483,7 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
276
483
 
277
484
  release: (id, token) =>
278
485
  Effect.gen(function*() {
486
+ const now = yield* Clock.currentTimeMillis
279
487
  const job = jobs.get(id)
280
488
  if (job === undefined) {
281
489
  return yield* new JobNotFoundError({ jobId: id })
@@ -283,28 +491,38 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
283
491
  if (job.state !== "active" || job.lockToken !== token) {
284
492
  return yield* new LockLostError({ jobId: id })
285
493
  }
494
+ if (job.cancelRequested) {
495
+ // A cancel arrived while the worker was shutting down: honour it
496
+ // instead of reviving the job.
497
+ markCancelled(job, now)
498
+ return
499
+ }
286
500
  clearLock(job)
287
501
  job.state = "waiting"
288
- signalWake()
502
+ signalWake(job.queue)
289
503
  }),
290
504
 
291
505
  extendLocks: (locks, durationMs) =>
292
506
  Effect.gen(function*() {
293
507
  const now = yield* Clock.currentTimeMillis
294
508
  const lost: Array<JobId> = []
509
+ const cancelRequested: Array<JobId> = []
295
510
  for (const lock of locks) {
296
511
  const job = jobs.get(lock.id)
297
512
  if (
298
- job !== undefined &&
299
- job.state === "active" &&
300
- job.lockToken === lock.token
513
+ job === undefined ||
514
+ job.state !== "active" ||
515
+ job.lockToken !== lock.token
301
516
  ) {
302
- job.lockExpiresAt = now + durationMs
303
- } else {
304
517
  lost.push(lock.id)
518
+ } else if (job.cancelRequested) {
519
+ cancelRequested.push(lock.id)
520
+ } else {
521
+ job.lockExpiresAt = now + durationMs
305
522
  }
306
523
  }
307
- return lost
524
+ const result: ExtendLocksResult = { lost, cancelRequested }
525
+ return result
308
526
  }),
309
527
 
310
528
  recoverStalled: (options) =>
@@ -319,6 +537,11 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
319
537
  ) {
320
538
  continue
321
539
  }
540
+ if (job.cancelRequested) {
541
+ // The owning worker died before honouring the cancel: finish it.
542
+ markCancelled(job, now)
543
+ continue
544
+ }
322
545
  clearLock(job)
323
546
  job.stalledCount += 1
324
547
  recordAttempt(job, "stalled", now, undefined)
@@ -326,6 +549,7 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
326
549
  job.state = "failed"
327
550
  job.finishedAt = now
328
551
  job.failedReason = "job stalled more than allowable limit"
552
+ releaseDedupe(job, now)
329
553
  recovered.push({ id: job.id, failed: true })
330
554
  } else {
331
555
  job.state = "waiting"
@@ -338,10 +562,14 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
338
562
  return recovered
339
563
  }),
340
564
 
341
- awaitWake: (_queues, wakeToken) =>
565
+ awaitWake: (queues, wakeToken) =>
342
566
  Effect.suspend(() => {
343
- if (wakeVersion > wakeToken) return Effect.void
344
- 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
+ )
345
573
  }),
346
574
 
347
575
  getJob: (id) =>
@@ -414,13 +642,109 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
414
642
  job.state = "waiting"
415
643
  job.attemptsMade = 0
416
644
  job.stalledCount = 0
645
+ job.cancelRequested = false
417
646
  job.exit = undefined
418
647
  job.failedReason = undefined
419
648
  job.finishedAt = undefined
420
649
  job.processedAt = undefined
421
650
  job.runAt = now
422
651
  job.seq = ++seq
423
- signalWake()
652
+ signalWake(job.queue)
653
+ }),
654
+
655
+ cancel: (id) =>
656
+ Effect.gen(function*() {
657
+ const now = yield* Clock.currentTimeMillis
658
+ const job = jobs.get(id)
659
+ if (job === undefined) {
660
+ return yield* new JobNotFoundError({ jobId: id })
661
+ }
662
+ switch (job.state) {
663
+ case "waiting":
664
+ case "delayed": {
665
+ markCancelled(job, now)
666
+ return
667
+ }
668
+ case "active": {
669
+ job.cancelRequested = true
670
+ return
671
+ }
672
+ default: {
673
+ return yield* new JobNotCancellableError({ jobId: id, state: job.state })
674
+ }
675
+ }
676
+ }),
677
+
678
+ promote: (id) =>
679
+ Effect.gen(function*() {
680
+ const now = yield* Clock.currentTimeMillis
681
+ const job = jobs.get(id)
682
+ if (job === undefined) {
683
+ return yield* new JobNotFoundError({ jobId: id })
684
+ }
685
+ if (job.state !== "delayed") {
686
+ return yield* new JobNotPromotableError({ jobId: id, state: job.state })
687
+ }
688
+ job.state = "waiting"
689
+ job.runAt = now
690
+ signalWake(job.queue)
691
+ }),
692
+
693
+ pause: (queue) =>
694
+ Effect.sync(() => {
695
+ paused.add(queue)
696
+ }),
697
+
698
+ resume: (queue) =>
699
+ Effect.sync(() => {
700
+ if (paused.delete(queue)) {
701
+ signalWake(queue)
702
+ }
703
+ }),
704
+
705
+ pausedQueues: () => Effect.sync(() => Array.from(paused)),
706
+
707
+ upsertSchedule: (schedule) =>
708
+ Effect.sync(() => {
709
+ // An unchanged cadence keeps its next occurrence: deploy-time
710
+ // re-registration must not re-anchor `every` grids or drop a pending
711
+ // catch-up run. A changed cadence takes the caller's fresh nextRunAt.
712
+ const existing = schedules.get(schedule.key)
713
+ const sameCadence = existing !== undefined &&
714
+ existing.cron === schedule.cron &&
715
+ existing.tz === schedule.tz &&
716
+ existing.everyMs === schedule.everyMs
717
+ schedules.set(
718
+ schedule.key,
719
+ sameCadence ? { ...schedule, nextRunAt: existing.nextRunAt } : schedule
720
+ )
721
+ signalWake(schedule.queue)
722
+ }),
723
+
724
+ removeSchedule: (key) => Effect.sync(() => schedules.delete(key)),
725
+
726
+ listSchedules: (options) =>
727
+ Effect.sync(() =>
728
+ Array.from(schedules.values()).filter((schedule) =>
729
+ (options?.jobName === undefined || schedule.jobName === options.jobName) &&
730
+ (options?.queue === undefined || schedule.queue === options.queue)
731
+ )
732
+ ),
733
+
734
+ dueSchedules: () =>
735
+ Effect.gen(function*() {
736
+ const now = yield* Clock.currentTimeMillis
737
+ return Array.from(schedules.values())
738
+ .filter((schedule) => schedule.nextRunAt <= now)
739
+ .toSorted((a, b) => a.nextRunAt - b.nextRunAt)
740
+ }),
741
+
742
+ advanceSchedule: (key, expectedRunAt, nextRunAt) =>
743
+ Effect.sync(() => {
744
+ const schedule = schedules.get(key)
745
+ if (schedule !== undefined && schedule.nextRunAt === expectedRunAt) {
746
+ schedules.set(key, { ...schedule, nextRunAt })
747
+ }
424
748
  }),
425
749
 
426
750
  counts: (queue) =>
@@ -430,7 +754,8 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
430
754
  delayed: 0,
431
755
  active: 0,
432
756
  completed: 0,
433
- failed: 0
757
+ failed: 0,
758
+ cancelled: 0
434
759
  } satisfies Record<JobState, number>
435
760
  for (const job of jobs.values()) {
436
761
  if (queue !== undefined && job.queue !== queue) continue
@@ -447,15 +772,76 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
447
772
  return true
448
773
  })
449
774
  })
450
- })
775
+
776
+ return { service, sweepHistory }
777
+ }
451
778
 
452
779
  /**
453
- * A fresh in-memory `JobStore` layer. Pass a named store key to provide a
454
- * `JobStore.named(...)` slot instead of the default.
780
+ * @since 0.2.0
781
+ */
782
+ export interface MemoryJobStoreOptions {
783
+ /**
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.
788
+ */
789
+ readonly historyTtl?: HistoryTtlInput | undefined
790
+ /** Sweep cadence (default 1 minute). */
791
+ readonly historySweepInterval?: Duration.Input | undefined
792
+ /**
793
+ * Generator for store-assigned job ids (e.g. `() => \`job_${ulid()}\``).
794
+ * Default: a `j-<n>` counter. See `JobStore.IdGenerator`.
795
+ */
796
+ readonly idGenerator?: IdGenerator | undefined
797
+ }
798
+
799
+ /**
800
+ * Build a fresh in-memory `JobStore` implementation (no history sweeper —
801
+ * use `makeWith` for `historyTtl` support).
455
802
  *
456
803
  * @since 0.1.0
457
804
  */
458
- export const layer: Layer.Layer<JobStore> = Layer.effect(JobStore, make)
805
+ export const make: Effect.Effect<Service> = Effect.sync(() => makeStoreUnsafe().service)
806
+
807
+ /**
808
+ * Build a fresh in-memory `JobStore` with options; the history sweeper (when
809
+ * configured) lives in the surrounding `Scope`.
810
+ *
811
+ * @since 0.2.0
812
+ */
813
+ export const makeWith = (
814
+ options?: MemoryJobStoreOptions | undefined
815
+ ): Effect.Effect<Service, never, Scope.Scope> =>
816
+ Effect.gen(function*() {
817
+ const { service, sweepHistory } = makeStoreUnsafe(options)
818
+ if (options?.historyTtl !== undefined) {
819
+ const ttlByState = normalizeHistoryTtl(options.historyTtl)
820
+ const intervalMs = Duration.toMillis(options.historySweepInterval ?? "1 minute")
821
+ yield* Effect.gen(function*() {
822
+ yield* Effect.sleep(intervalMs)
823
+ const now = yield* Clock.currentTimeMillis
824
+ sweepHistory(now, ttlByState)
825
+ }).pipe(Effect.forever, Effect.forkScoped)
826
+ }
827
+ return service
828
+ })
829
+
830
+ /**
831
+ * A fresh in-memory `JobStore` layer.
832
+ *
833
+ * @since 0.1.0
834
+ */
835
+ export const layer: Layer.Layer<JobStore> = Layer.effect(JobStore, makeWith())
836
+
837
+ /**
838
+ * An in-memory layer with options (e.g. `historyTtl`).
839
+ *
840
+ * @since 0.2.0
841
+ */
842
+ export const layerWith = (
843
+ options?: MemoryJobStoreOptions | undefined
844
+ ): Layer.Layer<JobStore> => Layer.effect(JobStore, makeWith(options))
459
845
 
460
846
  /**
461
847
  * An in-memory layer for a specific store key.
@@ -463,5 +849,6 @@ export const layer: Layer.Layer<JobStore> = Layer.effect(JobStore, make)
463
849
  * @since 0.1.0
464
850
  */
465
851
  export const layerFor = <Id>(
466
- store: Context.Key<Id, Service>
467
- ): Layer.Layer<Id> => Layer.effect(store, make)
852
+ store: Context.Key<Id, Service>,
853
+ options?: MemoryJobStoreOptions | undefined
854
+ ): Layer.Layer<Id> => Layer.effect(store, makeWith(options))