effect-mq 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +167 -22
  2. package/dist/Job.d.ts +67 -4
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +76 -3
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +174 -3
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js +89 -1
  9. package/dist/JobStore.js.map +1 -1
  10. package/dist/MemoryJobStore.d.ts +37 -6
  11. package/dist/MemoryJobStore.d.ts.map +1 -1
  12. package/dist/MemoryJobStore.js +201 -25
  13. package/dist/MemoryJobStore.js.map +1 -1
  14. package/dist/Worker.d.ts +5 -1
  15. package/dist/Worker.d.ts.map +1 -1
  16. package/dist/Worker.js +116 -10
  17. package/dist/Worker.js.map +1 -1
  18. package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.d.ts +18 -2
  19. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -0
  20. package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.js +287 -40
  21. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -0
  22. package/dist/drizzle-postgres/index.d.ts.map +1 -0
  23. package/dist/drizzle-postgres/index.js.map +1 -0
  24. package/dist/{drizzle → drizzle-postgres}/schema.d.ts +306 -4
  25. package/dist/drizzle-postgres/schema.d.ts.map +1 -0
  26. package/dist/{drizzle → drizzle-postgres}/schema.js +36 -2
  27. package/dist/drizzle-postgres/schema.js.map +1 -0
  28. package/dist/redis/RedisJobStore.d.ts +56 -0
  29. package/dist/redis/RedisJobStore.d.ts.map +1 -0
  30. package/dist/redis/RedisJobStore.js +385 -0
  31. package/dist/redis/RedisJobStore.js.map +1 -0
  32. package/dist/redis/index.d.ts +9 -0
  33. package/dist/redis/index.d.ts.map +1 -0
  34. package/dist/redis/index.js +9 -0
  35. package/dist/redis/index.js.map +1 -0
  36. package/dist/redis/scripts.d.ts +168 -0
  37. package/dist/redis/scripts.d.ts.map +1 -0
  38. package/dist/redis/scripts.js +755 -0
  39. package/dist/redis/scripts.js.map +1 -0
  40. package/dist/testing/conformance.d.ts.map +1 -1
  41. package/dist/testing/conformance.js +332 -3
  42. package/dist/testing/conformance.js.map +1 -1
  43. package/package.json +8 -4
  44. package/src/Job.ts +189 -5
  45. package/src/JobStore.ts +252 -4
  46. package/src/MemoryJobStore.ts +273 -26
  47. package/src/Worker.ts +152 -10
  48. package/src/{drizzle → drizzle-postgres}/DrizzleJobStore.ts +412 -40
  49. package/src/{drizzle → drizzle-postgres}/schema.ts +52 -3
  50. package/src/redis/RedisJobStore.ts +597 -0
  51. package/src/redis/index.ts +8 -0
  52. package/src/redis/scripts.ts +862 -0
  53. package/src/testing/conformance.ts +421 -3
  54. package/dist/drizzle/DrizzleJobStore.d.ts.map +0 -1
  55. package/dist/drizzle/DrizzleJobStore.js.map +0 -1
  56. package/dist/drizzle/index.d.ts.map +0 -1
  57. package/dist/drizzle/index.js.map +0 -1
  58. package/dist/drizzle/schema.d.ts.map +0 -1
  59. package/dist/drizzle/schema.js.map +0 -1
  60. /package/dist/{drizzle → drizzle-postgres}/index.d.ts +0 -0
  61. /package/dist/{drizzle → drizzle-postgres}/index.js +0 -0
  62. /package/src/{drizzle → drizzle-postgres}/index.ts +0 -0
@@ -10,20 +10,27 @@
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 IdGenerator,
18
20
  JobId,
21
+ JobNotCancellableError,
19
22
  JobNotFoundError,
23
+ JobNotPromotableError,
20
24
  JobNotRetryableError,
21
25
  type JobRecord,
22
26
  type JobState,
23
27
  JobStore,
24
28
  type ListResult,
25
29
  LockLostError,
30
+ JobStoreError,
26
31
  type QueueName,
32
+ type ScheduleKey,
33
+ type ScheduleRecord,
27
34
  type Service
28
35
  } from "./JobStore.ts"
29
36
 
@@ -40,6 +47,8 @@ interface MemJob {
40
47
  stalledCount: number
41
48
  readonly backoff: JobRecord["backoff"]
42
49
  readonly keep: JobRecord["keep"]
50
+ readonly timeoutMs: number | undefined
51
+ cancelRequested: boolean
43
52
  runAt: number
44
53
  readonly enqueuedAt: number
45
54
  processedAt: number | undefined
@@ -52,6 +61,8 @@ interface MemJob {
52
61
  lockExpiresAt: number | undefined
53
62
  }
54
63
 
64
+ const TERMINAL_STATES: ReadonlySet<JobState> = new Set(["completed", "failed", "cancelled"])
65
+
55
66
  const snapshot = (job: MemJob): JobRecord => ({
56
67
  id: job.id,
57
68
  name: job.name,
@@ -65,6 +76,8 @@ const snapshot = (job: MemJob): JobRecord => ({
65
76
  stalledCount: job.stalledCount,
66
77
  backoff: job.backoff,
67
78
  keep: job.keep,
79
+ timeoutMs: job.timeoutMs,
80
+ cancelRequested: job.cancelRequested,
68
81
  runAt: job.runAt,
69
82
  enqueuedAt: job.enqueuedAt,
70
83
  processedAt: job.processedAt,
@@ -83,13 +96,15 @@ const metadataMatches = (
83
96
  return true
84
97
  }
85
98
 
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(() => {
99
+ interface MemoryStore {
100
+ readonly service: Service
101
+ readonly sweepHistory: (now: number, ttlMs: number) => void
102
+ }
103
+
104
+ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemoryStore => {
92
105
  const jobs = new Map<string, MemJob>()
106
+ const schedules = new Map<ScheduleKey, ScheduleRecord>()
107
+ const paused = new Set<QueueName>()
93
108
  let seq = 0
94
109
  let idCounter = 0
95
110
  let wakeVersion = 0
@@ -133,6 +148,15 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
133
148
  })
134
149
  }
135
150
 
151
+ const markCancelled = (job: MemJob, now: number) => {
152
+ clearLock(job)
153
+ job.cancelRequested = false
154
+ job.state = "cancelled"
155
+ job.finishedAt = now
156
+ recordAttempt(job, "cancelled", now, undefined)
157
+ applyKeep(job, now)
158
+ }
159
+
136
160
  // Terminal retention: keep at most `count` and drop older than `ageMs`
137
161
  // among terminal jobs sharing this job's name + state.
138
162
  const applyKeep = (job: MemJob, now: number) => {
@@ -158,16 +182,46 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
158
182
  }
159
183
  }
160
184
 
161
- return JobStore.of({
185
+ const sweepHistory = (now: number, ttlMs: number) => {
186
+ for (const job of jobs.values()) {
187
+ if (
188
+ TERMINAL_STATES.has(job.state) &&
189
+ job.finishedAt !== undefined &&
190
+ job.finishedAt <= now - ttlMs
191
+ ) {
192
+ jobs.delete(job.id)
193
+ }
194
+ }
195
+ }
196
+
197
+ const service: Service = JobStore.of({
162
198
  enqueue: (request: EnqueueRequest) =>
163
199
  Effect.gen(function*() {
164
200
  const now = yield* Clock.currentTimeMillis
165
201
  let id = request.id
166
202
  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))
203
+ const generate = options?.idGenerator
204
+ if (generate === undefined) {
205
+ // Store-assigned ids must never collide with user-supplied ones.
206
+ do {
207
+ id = JobId(`j-${++idCounter}`)
208
+ } while (jobs.has(id))
209
+ } else {
210
+ // A user generator gets a bounded number of collision retries; a
211
+ // healthy generator's entropy makes even one retry pathological.
212
+ for (let i = 0; i < 5 && id === undefined; i++) {
213
+ const raw = generate(request)
214
+ const candidate = JobId(Effect.isEffect(raw) ? yield* raw : raw)
215
+ if (!jobs.has(candidate)) {
216
+ id = candidate
217
+ }
218
+ }
219
+ if (id === undefined) {
220
+ return yield* new JobStoreError({
221
+ message: "enqueue failed: could not generate a unique job id"
222
+ })
223
+ }
224
+ }
171
225
  } else if (jobs.has(id)) {
172
226
  return { id, duplicate: true }
173
227
  }
@@ -184,6 +238,8 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
184
238
  stalledCount: 0,
185
239
  backoff: request.backoff,
186
240
  keep: request.keep,
241
+ timeoutMs: request.timeoutMs,
242
+ cancelRequested: false,
187
243
  runAt: now + Math.max(0, request.delayMs),
188
244
  enqueuedAt: now,
189
245
  processedAt: undefined,
@@ -222,7 +278,7 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
222
278
  }
223
279
  }
224
280
  }
225
- if (best === undefined) {
281
+ if (best === undefined || paused.has(options.queue)) {
226
282
  const empty: ClaimResult = { _tag: "Empty", nextRunAt, wakeToken: wakeVersion }
227
283
  return empty
228
284
  }
@@ -248,6 +304,7 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
248
304
  job.attemptsMade += 1
249
305
  switch (outcome._tag) {
250
306
  case "Complete": {
307
+ job.cancelRequested = false
251
308
  job.state = "completed"
252
309
  job.exit = outcome.exit
253
310
  job.finishedAt = now
@@ -256,6 +313,7 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
256
313
  break
257
314
  }
258
315
  case "Fail": {
316
+ job.cancelRequested = false
259
317
  job.state = "failed"
260
318
  job.exit = outcome.exit
261
319
  job.finishedAt = now
@@ -263,7 +321,22 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
263
321
  applyKeep(job, now)
264
322
  break
265
323
  }
324
+ case "Cancelled": {
325
+ job.cancelRequested = false
326
+ job.state = "cancelled"
327
+ job.finishedAt = now
328
+ recordAttempt(job, "cancelled", now, undefined)
329
+ applyKeep(job, now)
330
+ break
331
+ }
266
332
  case "Retry": {
333
+ if (job.cancelRequested) {
334
+ // A cancel raced a natural failure before the heartbeat could
335
+ // interrupt the run: cancellation wins over revival (mirrors
336
+ // release/recoverStalled).
337
+ markCancelled(job, now)
338
+ break
339
+ }
267
340
  recordAttempt(job, "retried", now, outcome.exit)
268
341
  job.runAt = now + Math.max(0, outcome.delayMs)
269
342
  job.state = outcome.delayMs > 0 ? "delayed" : "waiting"
@@ -276,6 +349,7 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
276
349
 
277
350
  release: (id, token) =>
278
351
  Effect.gen(function*() {
352
+ const now = yield* Clock.currentTimeMillis
279
353
  const job = jobs.get(id)
280
354
  if (job === undefined) {
281
355
  return yield* new JobNotFoundError({ jobId: id })
@@ -283,6 +357,12 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
283
357
  if (job.state !== "active" || job.lockToken !== token) {
284
358
  return yield* new LockLostError({ jobId: id })
285
359
  }
360
+ if (job.cancelRequested) {
361
+ // A cancel arrived while the worker was shutting down: honour it
362
+ // instead of reviving the job.
363
+ markCancelled(job, now)
364
+ return
365
+ }
286
366
  clearLock(job)
287
367
  job.state = "waiting"
288
368
  signalWake()
@@ -292,19 +372,23 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
292
372
  Effect.gen(function*() {
293
373
  const now = yield* Clock.currentTimeMillis
294
374
  const lost: Array<JobId> = []
375
+ const cancelRequested: Array<JobId> = []
295
376
  for (const lock of locks) {
296
377
  const job = jobs.get(lock.id)
297
378
  if (
298
- job !== undefined &&
299
- job.state === "active" &&
300
- job.lockToken === lock.token
379
+ job === undefined ||
380
+ job.state !== "active" ||
381
+ job.lockToken !== lock.token
301
382
  ) {
302
- job.lockExpiresAt = now + durationMs
303
- } else {
304
383
  lost.push(lock.id)
384
+ } else if (job.cancelRequested) {
385
+ cancelRequested.push(lock.id)
386
+ } else {
387
+ job.lockExpiresAt = now + durationMs
305
388
  }
306
389
  }
307
- return lost
390
+ const result: ExtendLocksResult = { lost, cancelRequested }
391
+ return result
308
392
  }),
309
393
 
310
394
  recoverStalled: (options) =>
@@ -319,6 +403,11 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
319
403
  ) {
320
404
  continue
321
405
  }
406
+ if (job.cancelRequested) {
407
+ // The owning worker died before honouring the cancel: finish it.
408
+ markCancelled(job, now)
409
+ continue
410
+ }
322
411
  clearLock(job)
323
412
  job.stalledCount += 1
324
413
  recordAttempt(job, "stalled", now, undefined)
@@ -414,6 +503,7 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
414
503
  job.state = "waiting"
415
504
  job.attemptsMade = 0
416
505
  job.stalledCount = 0
506
+ job.cancelRequested = false
417
507
  job.exit = undefined
418
508
  job.failedReason = undefined
419
509
  job.finishedAt = undefined
@@ -423,6 +513,101 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
423
513
  signalWake()
424
514
  }),
425
515
 
516
+ cancel: (id) =>
517
+ Effect.gen(function*() {
518
+ const now = yield* Clock.currentTimeMillis
519
+ const job = jobs.get(id)
520
+ if (job === undefined) {
521
+ return yield* new JobNotFoundError({ jobId: id })
522
+ }
523
+ switch (job.state) {
524
+ case "waiting":
525
+ case "delayed": {
526
+ markCancelled(job, now)
527
+ return
528
+ }
529
+ case "active": {
530
+ job.cancelRequested = true
531
+ return
532
+ }
533
+ default: {
534
+ return yield* new JobNotCancellableError({ jobId: id, state: job.state })
535
+ }
536
+ }
537
+ }),
538
+
539
+ promote: (id) =>
540
+ Effect.gen(function*() {
541
+ const now = yield* Clock.currentTimeMillis
542
+ const job = jobs.get(id)
543
+ if (job === undefined) {
544
+ return yield* new JobNotFoundError({ jobId: id })
545
+ }
546
+ if (job.state !== "delayed") {
547
+ return yield* new JobNotPromotableError({ jobId: id, state: job.state })
548
+ }
549
+ job.state = "waiting"
550
+ job.runAt = now
551
+ signalWake()
552
+ }),
553
+
554
+ pause: (queue) =>
555
+ Effect.sync(() => {
556
+ paused.add(queue)
557
+ }),
558
+
559
+ resume: (queue) =>
560
+ Effect.sync(() => {
561
+ if (paused.delete(queue)) {
562
+ signalWake()
563
+ }
564
+ }),
565
+
566
+ pausedQueues: () => Effect.sync(() => Array.from(paused)),
567
+
568
+ upsertSchedule: (schedule) =>
569
+ Effect.sync(() => {
570
+ // An unchanged cadence keeps its next occurrence: deploy-time
571
+ // re-registration must not re-anchor `every` grids or drop a pending
572
+ // catch-up run. A changed cadence takes the caller's fresh nextRunAt.
573
+ const existing = schedules.get(schedule.key)
574
+ const sameCadence = existing !== undefined &&
575
+ existing.cron === schedule.cron &&
576
+ existing.tz === schedule.tz &&
577
+ existing.everyMs === schedule.everyMs
578
+ schedules.set(
579
+ schedule.key,
580
+ sameCadence ? { ...schedule, nextRunAt: existing.nextRunAt } : schedule
581
+ )
582
+ signalWake()
583
+ }),
584
+
585
+ removeSchedule: (key) => Effect.sync(() => schedules.delete(key)),
586
+
587
+ listSchedules: (options) =>
588
+ Effect.sync(() =>
589
+ Array.from(schedules.values()).filter((schedule) =>
590
+ (options?.jobName === undefined || schedule.jobName === options.jobName) &&
591
+ (options?.queue === undefined || schedule.queue === options.queue)
592
+ )
593
+ ),
594
+
595
+ dueSchedules: () =>
596
+ Effect.gen(function*() {
597
+ const now = yield* Clock.currentTimeMillis
598
+ return Array.from(schedules.values())
599
+ .filter((schedule) => schedule.nextRunAt <= now)
600
+ .toSorted((a, b) => a.nextRunAt - b.nextRunAt)
601
+ }),
602
+
603
+ advanceSchedule: (key, expectedRunAt, nextRunAt) =>
604
+ Effect.sync(() => {
605
+ const schedule = schedules.get(key)
606
+ if (schedule !== undefined && schedule.nextRunAt === expectedRunAt) {
607
+ schedules.set(key, { ...schedule, nextRunAt })
608
+ }
609
+ }),
610
+
426
611
  counts: (queue) =>
427
612
  Effect.sync(() => {
428
613
  const counts = {
@@ -430,7 +615,8 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
430
615
  delayed: 0,
431
616
  active: 0,
432
617
  completed: 0,
433
- failed: 0
618
+ failed: 0,
619
+ cancelled: 0
434
620
  } satisfies Record<JobState, number>
435
621
  for (const job of jobs.values()) {
436
622
  if (queue !== undefined && job.queue !== queue) continue
@@ -447,15 +633,75 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
447
633
  return true
448
634
  })
449
635
  })
450
- })
636
+
637
+ return { service, sweepHistory }
638
+ }
451
639
 
452
640
  /**
453
- * A fresh in-memory `JobStore` layer. Pass a named store key to provide a
454
- * `JobStore.named(...)` slot instead of the default.
641
+ * @since 0.2.0
642
+ */
643
+ export interface MemoryJobStoreOptions {
644
+ /**
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.
648
+ */
649
+ readonly historyTtl?: Duration.Input | undefined
650
+ /** Sweep cadence (default 1 minute). */
651
+ readonly historySweepInterval?: Duration.Input | undefined
652
+ /**
653
+ * Generator for store-assigned job ids (e.g. `() => \`job_${ulid()}\``).
654
+ * Default: a `j-<n>` counter. See `JobStore.IdGenerator`.
655
+ */
656
+ readonly idGenerator?: IdGenerator | undefined
657
+ }
658
+
659
+ /**
660
+ * Build a fresh in-memory `JobStore` implementation (no history sweeper —
661
+ * use `makeWith` for `historyTtl` support).
455
662
  *
456
663
  * @since 0.1.0
457
664
  */
458
- export const layer: Layer.Layer<JobStore> = Layer.effect(JobStore, make)
665
+ export const make: Effect.Effect<Service> = Effect.sync(() => makeStoreUnsafe().service)
666
+
667
+ /**
668
+ * Build a fresh in-memory `JobStore` with options; the history sweeper (when
669
+ * configured) lives in the surrounding `Scope`.
670
+ *
671
+ * @since 0.2.0
672
+ */
673
+ export const makeWith = (
674
+ options?: MemoryJobStoreOptions | undefined
675
+ ): Effect.Effect<Service, never, Scope.Scope> =>
676
+ Effect.gen(function*() {
677
+ const { service, sweepHistory } = makeStoreUnsafe(options)
678
+ if (options?.historyTtl !== undefined) {
679
+ const ttlMs = Duration.toMillis(options.historyTtl)
680
+ const intervalMs = Duration.toMillis(options.historySweepInterval ?? "1 minute")
681
+ yield* Effect.gen(function*() {
682
+ yield* Effect.sleep(intervalMs)
683
+ const now = yield* Clock.currentTimeMillis
684
+ sweepHistory(now, ttlMs)
685
+ }).pipe(Effect.forever, Effect.forkScoped)
686
+ }
687
+ return service
688
+ })
689
+
690
+ /**
691
+ * A fresh in-memory `JobStore` layer.
692
+ *
693
+ * @since 0.1.0
694
+ */
695
+ export const layer: Layer.Layer<JobStore> = Layer.effect(JobStore, makeWith())
696
+
697
+ /**
698
+ * An in-memory layer with options (e.g. `historyTtl`).
699
+ *
700
+ * @since 0.2.0
701
+ */
702
+ export const layerWith = (
703
+ options?: MemoryJobStoreOptions | undefined
704
+ ): Layer.Layer<JobStore> => Layer.effect(JobStore, makeWith(options))
459
705
 
460
706
  /**
461
707
  * An in-memory layer for a specific store key.
@@ -463,5 +709,6 @@ export const layer: Layer.Layer<JobStore> = Layer.effect(JobStore, make)
463
709
  * @since 0.1.0
464
710
  */
465
711
  export const layerFor = <Id>(
466
- store: Context.Key<Id, Service>
467
- ): Layer.Layer<Id> => Layer.effect(store, make)
712
+ store: Context.Key<Id, Service>,
713
+ options?: MemoryJobStoreOptions | undefined
714
+ ): Layer.Layer<Id> => Layer.effect(store, makeWith(options))