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
@@ -35,6 +35,8 @@ const baseRequest = (
35
35
  attemptsMax: 1,
36
36
  backoff: undefined,
37
37
  keep: undefined,
38
+ timeoutMs: undefined,
39
+ dedupe: undefined,
38
40
  delayMs: 0,
39
41
  ...overrides
40
42
  })
@@ -320,11 +322,12 @@ export const jobStoreConformance = (
320
322
  )
321
323
  assert(claim._tag === "Claimed")
322
324
 
323
- const lost = yield* store.extendLocks(
325
+ const result = yield* store.extendLocks(
324
326
  [{ id, token: "t-1" }, { id: JobId("ghost"), token: "t-9" }],
325
327
  20_000
326
328
  )
327
- expect(lost).toEqual(["ghost"])
329
+ expect(result.lost).toEqual(["ghost"])
330
+ expect(result.cancelRequested).toEqual([])
328
331
 
329
332
  // The extension outlives the original lock duration.
330
333
  yield* TestClock.adjust(15_000)
@@ -470,7 +473,7 @@ export const jobStoreConformance = (
470
473
  Effect.gen(function*() {
471
474
  for (let i = 0; i < 4; i++) {
472
475
  const { id } = yield* store.enqueue(
473
- baseRequest({ payload: { n: i }, keep: { count: 2, ageMs: undefined } })
476
+ baseRequest({ payload: { n: i }, keep: { completed: { count: 2, ageMs: undefined } } })
474
477
  )
475
478
  const claim = yield* store.claim(claimOptions({ token: `t-${i}` }))
476
479
  assert(claim._tag === "Claimed")
@@ -486,7 +489,7 @@ export const jobStoreConformance = (
486
489
  it.effect("keep age prunes terminal records older than the window", () =>
487
490
  withStore((store) =>
488
491
  Effect.gen(function*() {
489
- const keep = { count: undefined, ageMs: 10_000 }
492
+ const keep = { completed: { count: undefined, ageMs: 10_000 } }
490
493
  const first = yield* store.enqueue(baseRequest({ payload: { n: 1 }, keep }))
491
494
  const claim1 = yield* store.claim(claimOptions())
492
495
  assert(claim1._tag === "Claimed")
@@ -541,10 +544,10 @@ export const jobStoreConformance = (
541
544
  // Two jobs acked at the SAME TestClock instant: the tie must break
542
545
  // on enqueue/seq order identically in every driver.
543
546
  const first = yield* store.enqueue(
544
- baseRequest({ payload: { n: 1 }, keep: { count: 1, ageMs: undefined } })
547
+ baseRequest({ payload: { n: 1 }, keep: { completed: { count: 1, ageMs: undefined } } })
545
548
  )
546
549
  const second = yield* store.enqueue(
547
- baseRequest({ payload: { n: 2 }, keep: { count: 1, ageMs: undefined } })
550
+ baseRequest({ payload: { n: 2 }, keep: { completed: { count: 1, ageMs: undefined } } })
548
551
  )
549
552
  const claimA = yield* store.claim(claimOptions({ token: "t-a" }))
550
553
  const claimB = yield* store.claim(claimOptions({ token: "t-b" }))
@@ -560,7 +563,7 @@ export const jobStoreConformance = (
560
563
  it.effect("keep applies count and age together", () =>
561
564
  withStore((store) =>
562
565
  Effect.gen(function*() {
563
- const keep = { count: 2, ageMs: 10_000 }
566
+ const keep = { completed: { count: 2, ageMs: 10_000 } }
564
567
  const ids: Array<JobStore.JobId> = []
565
568
  for (let i = 0; i < 3; i++) {
566
569
  const { id } = yield* store.enqueue(baseRequest({ payload: { n: i }, keep }))
@@ -620,7 +623,8 @@ export const jobStoreConformance = (
620
623
  delayed: 1,
621
624
  active: 1,
622
625
  completed: 0,
623
- failed: 0
626
+ failed: 0,
627
+ cancelled: 0
624
628
  })
625
629
  const other = yield* store.counts(QueueName("other"))
626
630
  expect(other.waiting).toBe(1)
@@ -650,5 +654,658 @@ export const jobStoreConformance = (
650
654
  expect(yield* store.getAttempts(JobId("nope"))).toEqual([])
651
655
  })
652
656
  ))
657
+
658
+ it.effect("cancel makes waiting and delayed jobs terminal with a ledger entry", () =>
659
+ withStore((store) =>
660
+ Effect.gen(function*() {
661
+ const waiting = yield* store.enqueue(baseRequest())
662
+ const delayed = yield* store.enqueue(baseRequest({ delayMs: 60_000 }))
663
+ yield* store.cancel(waiting.id)
664
+ yield* store.cancel(delayed.id)
665
+
666
+ for (const id of [waiting.id, delayed.id]) {
667
+ const job = yield* store.getJob(id)
668
+ assert(Option.isSome(job))
669
+ expect(job.value.state).toBe("cancelled")
670
+ expect(job.value.finishedAt).toBeDefined()
671
+ const attempts = yield* store.getAttempts(id)
672
+ expect(attempts.map((attempt) => attempt.outcome)).toEqual(["cancelled"])
673
+ }
674
+ const claim = yield* store.claim(claimOptions())
675
+ assert(claim._tag === "Empty")
676
+
677
+ // Terminal jobs cannot be cancelled again; unknown ids are reported.
678
+ const again = yield* Effect.flip(store.cancel(waiting.id))
679
+ expect(again._tag).toBe("JobNotCancellableError")
680
+ const missing = yield* Effect.flip(store.cancel(JobId("nope")))
681
+ expect(missing._tag).toBe("JobNotFoundError")
682
+ })
683
+ ))
684
+
685
+ it.effect("cancel on an active job flags it; the heartbeat reports it; ack Cancelled finishes it", () =>
686
+ withStore((store) =>
687
+ Effect.gen(function*() {
688
+ const { id } = yield* store.enqueue(baseRequest())
689
+ const claim = yield* store.claim(claimOptions())
690
+ assert(claim._tag === "Claimed")
691
+
692
+ yield* store.cancel(id)
693
+ const flagged = yield* store.getJob(id)
694
+ assert(Option.isSome(flagged))
695
+ expect(flagged.value.state).toBe("active")
696
+ expect(flagged.value.cancelRequested).toBe(true)
697
+
698
+ // The heartbeat surfaces the request instead of extending the lock.
699
+ const heartbeat = yield* store.extendLocks([{ id, token: "t-1" }], 20_000)
700
+ expect(heartbeat.cancelRequested).toEqual([id])
701
+ expect(heartbeat.lost).toEqual([])
702
+
703
+ yield* store.ack(id, "t-1", { _tag: "Cancelled" })
704
+ const done = yield* store.getJob(id)
705
+ assert(Option.isSome(done))
706
+ expect(done.value.state).toBe("cancelled")
707
+ expect(done.value.cancelRequested).toBe(false)
708
+ const attempts = yield* store.getAttempts(id)
709
+ expect(attempts.map((attempt) => attempt.outcome)).toEqual(["cancelled"])
710
+ })
711
+ ))
712
+
713
+ it.effect("release and stall recovery honour a pending cancel request", () =>
714
+ withStore((store) =>
715
+ Effect.gen(function*() {
716
+ // release path
717
+ const first = yield* store.enqueue(baseRequest())
718
+ const claimA = yield* store.claim(claimOptions())
719
+ assert(claimA._tag === "Claimed")
720
+ yield* store.cancel(first.id)
721
+ yield* store.release(first.id, "t-1")
722
+ const released = yield* store.getJob(first.id)
723
+ assert(Option.isSome(released))
724
+ expect(released.value.state).toBe("cancelled")
725
+
726
+ // stall path
727
+ const second = yield* store.enqueue(baseRequest({ payload: { n: 2 } }))
728
+ const claimB = yield* store.claim(claimOptions({ token: "t-2", lockDurationMs: 1_000 }))
729
+ assert(claimB._tag === "Claimed")
730
+ yield* store.cancel(second.id)
731
+ yield* TestClock.adjust(1_000)
732
+ const recovered = yield* store.recoverStalled({ maxStalledCount: 5 })
733
+ // Cancelled-by-recovery jobs are not reported as stalled recoveries.
734
+ expect(recovered).toEqual([])
735
+ const swept = yield* store.getJob(second.id)
736
+ assert(Option.isSome(swept))
737
+ expect(swept.value.state).toBe("cancelled")
738
+ })
739
+ ))
740
+
741
+ it.effect("promote runs a delayed job now and rejects other states", () =>
742
+ withStore((store) =>
743
+ Effect.gen(function*() {
744
+ const { id } = yield* store.enqueue(baseRequest({ delayMs: 60_000 }))
745
+ const early = yield* store.claim(claimOptions())
746
+ assert(early._tag === "Empty")
747
+
748
+ yield* store.promote(id)
749
+ const claim = yield* store.claim(claimOptions({ token: "t-2" }))
750
+ assert(claim._tag === "Claimed")
751
+ expect(claim.job.id).toBe(id)
752
+
753
+ const wrongState = yield* Effect.flip(store.promote(id))
754
+ expect(wrongState._tag).toBe("JobNotPromotableError")
755
+ const missing = yield* Effect.flip(store.promote(JobId("nope")))
756
+ expect(missing._tag).toBe("JobNotFoundError")
757
+ })
758
+ ))
759
+
760
+ it.effect("pause stops claims for a queue until resume", () =>
761
+ withStore((store) =>
762
+ Effect.gen(function*() {
763
+ yield* store.enqueue(baseRequest())
764
+ yield* store.pause(QueueName("default"))
765
+ expect(yield* store.pausedQueues()).toEqual(["default"])
766
+
767
+ const paused = yield* store.claim(claimOptions())
768
+ assert(paused._tag === "Empty")
769
+
770
+ // Other queues are unaffected.
771
+ yield* store.enqueue(baseRequest({ queue: QueueName("other") }))
772
+ const other = yield* store.claim(claimOptions({ queue: QueueName("other"), token: "t-2" }))
773
+ assert(other._tag === "Claimed")
774
+
775
+ // Resume wakes idle workers and claims flow again.
776
+ const empty = yield* store.claim(claimOptions({ token: "t-3" }))
777
+ assert(empty._tag === "Empty")
778
+ const waiter = yield* Effect.forkChild(
779
+ store.awaitWake([QueueName("default")], empty.wakeToken)
780
+ )
781
+ yield* Effect.yieldNow
782
+ yield* store.resume(QueueName("default"))
783
+ yield* Fiber.join(waiter)
784
+ expect(yield* store.pausedQueues()).toEqual([])
785
+ const resumed = yield* store.claim(claimOptions({ token: "t-4" }))
786
+ assert(resumed._tag === "Claimed")
787
+ })
788
+ ))
789
+
790
+ it.effect("schedules: upsert, list, due, conditional advance, remove", () =>
791
+ withStore((store) =>
792
+ Effect.gen(function*() {
793
+ const schedule: JobStore.ScheduleRecord = {
794
+ key: JobStore.ScheduleKey("TestJob/hourly"),
795
+ jobName: "TestJob",
796
+ queue: QueueName("default"),
797
+ cron: "0 * * * *",
798
+ tz: undefined,
799
+ everyMs: undefined,
800
+ payload: { n: 1 },
801
+ metadata: { source: "conformance" },
802
+ priority: 2,
803
+ attemptsMax: 3,
804
+ backoff: { _tag: "fixed", delayMs: 1_000 },
805
+ keep: undefined,
806
+ timeoutMs: 5_000,
807
+ nextRunAt: 60_000
808
+ }
809
+ yield* store.upsertSchedule(schedule)
810
+ expect(yield* store.listSchedules()).toEqual([schedule])
811
+ expect(yield* store.listSchedules({ jobName: "OtherJob" })).toEqual([])
812
+
813
+ // Not due yet (TestClock starts at 0).
814
+ expect(yield* store.dueSchedules()).toEqual([])
815
+ yield* TestClock.adjust(60_000)
816
+ expect(yield* store.dueSchedules()).toEqual([schedule])
817
+
818
+ // Upsert replaces in place.
819
+ yield* store.upsertSchedule({ ...schedule, priority: 9 })
820
+ expect((yield* store.listSchedules())[0]?.priority).toBe(9)
821
+
822
+ // Conditional advance: a stale expectation is a no-op.
823
+ yield* store.advanceSchedule(schedule.key, 999, 120_000)
824
+ expect((yield* store.listSchedules())[0]?.nextRunAt).toBe(60_000)
825
+ yield* store.advanceSchedule(schedule.key, 60_000, 120_000)
826
+ expect((yield* store.listSchedules())[0]?.nextRunAt).toBe(120_000)
827
+ expect(yield* store.dueSchedules()).toEqual([])
828
+
829
+ expect(yield* store.removeSchedule(schedule.key)).toBe(true)
830
+ expect(yield* store.removeSchedule(schedule.key)).toBe(false)
831
+ expect(yield* store.listSchedules()).toEqual([])
832
+ })
833
+ ))
834
+
835
+ it.effect("completion wins over a pending cancel request", () =>
836
+ withStore((store) =>
837
+ Effect.gen(function*() {
838
+ const { id } = yield* store.enqueue(baseRequest())
839
+ const claim = yield* store.claim(claimOptions())
840
+ assert(claim._tag === "Claimed")
841
+ yield* store.cancel(id)
842
+
843
+ yield* store.ack(id, "t-1", { _tag: "Complete", exit: { ok: true } })
844
+ const job = yield* store.getJob(id)
845
+ assert(Option.isSome(job))
846
+ expect(job.value.state).toBe("completed")
847
+ expect(job.value.cancelRequested).toBe(false)
848
+ })
849
+ ))
850
+
851
+ it.effect("a natural failure racing a cancel is cancelled, not revived", () =>
852
+ withStore((store) =>
853
+ Effect.gen(function*() {
854
+ const { id } = yield* store.enqueue(baseRequest({ attemptsMax: 5 }))
855
+ const claim = yield* store.claim(claimOptions())
856
+ assert(claim._tag === "Claimed")
857
+ yield* store.cancel(id)
858
+
859
+ // The handler failed on its own before the heartbeat could
860
+ // interrupt it; the retry ack must honour the pending cancel.
861
+ yield* store.ack(id, "t-1", { _tag: "Retry", exit: { boom: 1 }, delayMs: 1_000 })
862
+ const job = yield* store.getJob(id)
863
+ assert(Option.isSome(job))
864
+ expect(job.value.state).toBe("cancelled")
865
+ expect(job.value.cancelRequested).toBe(false)
866
+ const attempts = yield* store.getAttempts(id)
867
+ expect(attempts.map((attempt) => attempt.outcome)).toEqual(["cancelled"])
868
+ const nothing = yield* store.claim(claimOptions({ token: "t-2" }))
869
+ assert(nothing._tag === "Empty")
870
+ })
871
+ ))
872
+
873
+ it.effect("ack Cancelled with a wrong token fails with LockLostError", () =>
874
+ withStore((store) =>
875
+ Effect.gen(function*() {
876
+ const { id } = yield* store.enqueue(baseRequest())
877
+ const claim = yield* store.claim(claimOptions())
878
+ assert(claim._tag === "Claimed")
879
+ yield* store.cancel(id)
880
+
881
+ const wrong = yield* Effect.flip(store.ack(id, "not-mine", { _tag: "Cancelled" }))
882
+ expect(wrong._tag).toBe("LockLostError")
883
+ const job = yield* store.getJob(id)
884
+ assert(Option.isSome(job))
885
+ expect(job.value.state).toBe("active")
886
+ expect(job.value.cancelRequested).toBe(true)
887
+ })
888
+ ))
889
+
890
+ it.effect("cancel applies the keep retention policy", () =>
891
+ withStore((store) =>
892
+ Effect.gen(function*() {
893
+ const keep = { cancelled: { count: 1 } }
894
+ const ids: Array<JobStore.JobId> = []
895
+ for (let i = 0; i < 3; i++) {
896
+ const { id } = yield* store.enqueue(baseRequest({ payload: { n: i }, keep }))
897
+ ids.push(id)
898
+ yield* store.cancel(id)
899
+ yield* TestClock.adjust(10)
900
+ }
901
+ const listed = yield* store.list({ states: ["cancelled"] })
902
+ expect(listed.items).toHaveLength(1)
903
+ expect(listed.items[0]?.id).toBe(ids[2])
904
+ })
905
+ ))
906
+
907
+ it.effect("upserting an unchanged cadence preserves the next occurrence; a changed one resets it", () =>
908
+ withStore((store) =>
909
+ Effect.gen(function*() {
910
+ const base: JobStore.ScheduleRecord = {
911
+ key: JobStore.ScheduleKey("TestJob/pulse"),
912
+ jobName: "TestJob",
913
+ queue: QueueName("default"),
914
+ cron: undefined,
915
+ tz: undefined,
916
+ everyMs: 60_000,
917
+ payload: {},
918
+ metadata: {},
919
+ priority: 0,
920
+ attemptsMax: 1,
921
+ backoff: undefined,
922
+ keep: undefined,
923
+ timeoutMs: undefined,
924
+ nextRunAt: 60_000
925
+ }
926
+ yield* store.upsertSchedule(base)
927
+
928
+ // A deploy-time re-registration recomputes nextRunAt from "now" —
929
+ // the store must keep the original grid point.
930
+ yield* TestClock.adjust(30_000)
931
+ yield* store.upsertSchedule({ ...base, priority: 5, nextRunAt: 90_000 })
932
+ const preserved = (yield* store.listSchedules())[0]
933
+ expect(preserved?.nextRunAt).toBe(60_000)
934
+ expect(preserved?.priority).toBe(5)
935
+
936
+ // Changing the cadence takes the caller's fresh nextRunAt.
937
+ yield* store.upsertSchedule({ ...base, everyMs: 120_000, nextRunAt: 150_000 })
938
+ expect((yield* store.listSchedules())[0]?.nextRunAt).toBe(150_000)
939
+ })
940
+ ))
941
+
942
+ it.effect("schedule records round-trip every/tz fields; due order is by nextRunAt; list filters by queue", () =>
943
+ withStore((store) =>
944
+ Effect.gen(function*() {
945
+ const everySchedule: JobStore.ScheduleRecord = {
946
+ key: JobStore.ScheduleKey("TestJob/every"),
947
+ jobName: "TestJob",
948
+ queue: QueueName("default"),
949
+ cron: undefined,
950
+ tz: undefined,
951
+ everyMs: 90_000,
952
+ payload: { n: 1 },
953
+ metadata: {},
954
+ priority: 0,
955
+ attemptsMax: 1,
956
+ backoff: undefined,
957
+ keep: undefined,
958
+ timeoutMs: undefined,
959
+ nextRunAt: 90_000
960
+ }
961
+ const cronSchedule: JobStore.ScheduleRecord = {
962
+ ...everySchedule,
963
+ key: JobStore.ScheduleKey("TestJob/tz"),
964
+ queue: QueueName("other"),
965
+ cron: "0 9 * * *",
966
+ tz: "America/New_York",
967
+ everyMs: undefined,
968
+ nextRunAt: 30_000
969
+ }
970
+ yield* store.upsertSchedule(everySchedule)
971
+ yield* store.upsertSchedule(cronSchedule)
972
+
973
+ const byQueue = yield* store.listSchedules({ queue: QueueName("other") })
974
+ expect(byQueue).toEqual([cronSchedule])
975
+ expect(byQueue[0]?.tz).toBe("America/New_York")
976
+ const roundTripped = (yield* store.listSchedules({ queue: QueueName("default") }))[0]
977
+ expect(roundTripped?.everyMs).toBe(90_000)
978
+
979
+ // Both due: ordered by nextRunAt ascending.
980
+ yield* TestClock.adjust(90_000)
981
+ const due = yield* store.dueSchedules()
982
+ expect(due.map((schedule) => schedule.key)).toEqual([
983
+ "TestJob/tz",
984
+ "TestJob/every"
985
+ ])
986
+ })
987
+ ))
988
+
989
+ it.effect("counts stay consistent across cancel, retry, keep pruning, and remove", () =>
990
+ withStore((store) =>
991
+ Effect.gen(function*() {
992
+ // Three completions with keep {count: 1}: two get pruned.
993
+ for (let i = 0; i < 3; i++) {
994
+ const { id } = yield* store.enqueue(baseRequest({ payload: { n: i }, keep: { completed: { count: 1 } } }))
995
+ const claim = yield* store.claim(claimOptions({ token: `t-${i}` }))
996
+ assert(claim._tag === "Claimed")
997
+ yield* store.ack(id, `t-${i}`, { _tag: "Complete", exit: undefined })
998
+ yield* TestClock.adjust(10)
999
+ }
1000
+ expect((yield* store.counts()).completed).toBe(1)
1001
+
1002
+ // Cancel a waiting job.
1003
+ const doomed = yield* store.enqueue(baseRequest({ name: "Doomed" }))
1004
+ yield* store.cancel(doomed.id)
1005
+ expect((yield* store.counts()).cancelled).toBe(1)
1006
+
1007
+ // Fail a job, then admin-retry it: failed -> waiting.
1008
+ const flaky = yield* store.enqueue(baseRequest())
1009
+ const claim = yield* store.claim(claimOptions({ token: "t-f" }))
1010
+ assert(claim._tag === "Claimed")
1011
+ yield* store.ack(flaky.id, "t-f", { _tag: "Fail", exit: undefined })
1012
+ expect((yield* store.counts()).failed).toBe(1)
1013
+ yield* store.retry(flaky.id)
1014
+
1015
+ // Remove the surviving completed record.
1016
+ const completed = yield* store.list({ states: ["completed"] })
1017
+ const survivor = completed.items[0]
1018
+ assert(survivor !== undefined)
1019
+ expect(yield* store.remove(survivor.id)).toBe(true)
1020
+
1021
+ expect(yield* store.counts()).toEqual({
1022
+ waiting: 1,
1023
+ delayed: 0,
1024
+ active: 0,
1025
+ completed: 0,
1026
+ failed: 0,
1027
+ cancelled: 1
1028
+ })
1029
+ })
1030
+ ))
1031
+
1032
+ it.effect("schedule payloads round-trip high-precision numbers and empty arrays exactly", () =>
1033
+ withStore((store) =>
1034
+ Effect.gen(function*() {
1035
+ const payload = { big: 1234567890123456, third: 1 / 3, empty: [] }
1036
+ const schedule: JobStore.ScheduleRecord = {
1037
+ key: JobStore.ScheduleKey("TestJob/precise"),
1038
+ jobName: "TestJob",
1039
+ queue: QueueName("default"),
1040
+ cron: undefined,
1041
+ tz: undefined,
1042
+ everyMs: 60_000,
1043
+ payload,
1044
+ metadata: {},
1045
+ priority: 0,
1046
+ attemptsMax: 1,
1047
+ backoff: undefined,
1048
+ keep: undefined,
1049
+ timeoutMs: undefined,
1050
+ nextRunAt: 60_000
1051
+ }
1052
+ yield* store.upsertSchedule(schedule)
1053
+ const stored = (yield* store.listSchedules())[0]
1054
+ expect(stored?.payload).toEqual(payload)
1055
+ })
1056
+ ))
1057
+
1058
+ it.effect("dedupe never changes the job id, and keys are scoped per name", () =>
1059
+ withStore((store) =>
1060
+ Effect.gen(function*() {
1061
+ const dedupe = { key: "emp-1", ttlMs: undefined, extend: false, replace: false }
1062
+ const first = yield* store.enqueue(baseRequest({ id: JobId("my-ulid-1"), dedupe }))
1063
+ expect(first).toEqual({ id: "my-ulid-1", duplicate: false })
1064
+ const job = yield* store.getJob(first.id)
1065
+ assert(Option.isSome(job))
1066
+ expect(job.value.dedupeKey).toBe("emp-1")
1067
+
1068
+ // Same key, same name: deduplicated to the FIRST job's id.
1069
+ const second = yield* store.enqueue(baseRequest({ id: JobId("my-ulid-2"), dedupe }))
1070
+ expect(second).toEqual({ id: "my-ulid-1", duplicate: true })
1071
+ expect(Option.isNone(yield* store.getJob(JobId("my-ulid-2")))).toBe(true)
1072
+
1073
+ // Same key, different name: no interference.
1074
+ const other = yield* store.enqueue(baseRequest({ name: "OtherJob", dedupe }))
1075
+ expect(other.duplicate).toBe(false)
1076
+ })
1077
+ ))
1078
+
1079
+ it.effect("pending dedupe holds while the keyed job is unfinished and frees on completion", () =>
1080
+ withStore((store) =>
1081
+ Effect.gen(function*() {
1082
+ const dedupe = { key: "k", ttlMs: undefined, extend: false, replace: false }
1083
+ const first = yield* store.enqueue(baseRequest({ dedupe }))
1084
+ expect((yield* store.enqueue(baseRequest({ dedupe }))).duplicate).toBe(true)
1085
+
1086
+ // Still deduped while active.
1087
+ const claim = yield* store.claim(claimOptions())
1088
+ assert(claim._tag === "Claimed")
1089
+ expect((yield* store.enqueue(baseRequest({ dedupe }))).duplicate).toBe(true)
1090
+
1091
+ // A terminal ack frees the key immediately.
1092
+ yield* store.ack(first.id, "t-1", { _tag: "Complete", exit: undefined })
1093
+ const fresh = yield* store.enqueue(baseRequest({ dedupe }))
1094
+ expect(fresh.duplicate).toBe(false)
1095
+ expect(fresh.id).not.toBe(first.id)
1096
+ })
1097
+ ))
1098
+
1099
+ it.effect("cancellation also frees a pending dedupe key", () =>
1100
+ withStore((store) =>
1101
+ Effect.gen(function*() {
1102
+ const dedupe = { key: "k", ttlMs: undefined, extend: false, replace: false }
1103
+ const first = yield* store.enqueue(baseRequest({ dedupe }))
1104
+ yield* store.cancel(first.id)
1105
+ expect((yield* store.enqueue(baseRequest({ dedupe }))).duplicate).toBe(false)
1106
+ })
1107
+ ))
1108
+
1109
+ it.effect("a ttl dedupe window throttles even past completion, then expires", () =>
1110
+ withStore((store) =>
1111
+ Effect.gen(function*() {
1112
+ const dedupe = { key: "k", ttlMs: 60_000, extend: false, replace: false }
1113
+ const first = yield* store.enqueue(baseRequest({ dedupe }))
1114
+ const claim = yield* store.claim(claimOptions())
1115
+ assert(claim._tag === "Claimed")
1116
+ yield* store.ack(first.id, "t-1", { _tag: "Complete", exit: undefined })
1117
+
1118
+ // Completed, but the window still throttles...
1119
+ yield* TestClock.adjust(30_000)
1120
+ expect((yield* store.enqueue(baseRequest({ dedupe }))).duplicate).toBe(true)
1121
+
1122
+ // ...and a plain (non-extend) window is NOT pushed out by drops.
1123
+ yield* TestClock.adjust(30_000)
1124
+ const fresh = yield* store.enqueue(baseRequest({ dedupe }))
1125
+ expect(fresh.duplicate).toBe(false)
1126
+ expect(fresh.id).not.toBe(first.id)
1127
+ })
1128
+ ))
1129
+
1130
+ it.effect("an extend dedupe window is pushed out by each deduplicated enqueue", () =>
1131
+ withStore((store) =>
1132
+ Effect.gen(function*() {
1133
+ const dedupe = { key: "k", ttlMs: 60_000, extend: true, replace: false }
1134
+ yield* store.enqueue(baseRequest({ dedupe }))
1135
+
1136
+ yield* TestClock.adjust(45_000)
1137
+ expect((yield* store.enqueue(baseRequest({ dedupe }))).duplicate).toBe(true)
1138
+
1139
+ // 75s after the FIRST enqueue — past the original window, inside
1140
+ // the extended one.
1141
+ yield* TestClock.adjust(30_000)
1142
+ expect((yield* store.enqueue(baseRequest({ dedupe }))).duplicate).toBe(true)
1143
+
1144
+ // Past the latest extension: free again.
1145
+ yield* TestClock.adjust(60_001)
1146
+ expect((yield* store.enqueue(baseRequest({ dedupe }))).duplicate).toBe(false)
1147
+ })
1148
+ ))
1149
+
1150
+ it.effect("replace dedupe rewrites a still-delayed job in place, latest content wins", () =>
1151
+ withStore((store) =>
1152
+ Effect.gen(function*() {
1153
+ const dedupe = { key: "k", ttlMs: undefined, extend: false, replace: true }
1154
+ const first = yield* store.enqueue(
1155
+ baseRequest({ dedupe, payload: { n: 1 }, priority: 1, delayMs: 60_000 })
1156
+ )
1157
+
1158
+ const replaced = yield* store.enqueue(
1159
+ baseRequest({ dedupe, payload: { n: 2 }, priority: 7, delayMs: 5_000 })
1160
+ )
1161
+ expect(replaced).toEqual({ id: first.id, duplicate: true })
1162
+ const job = yield* store.getJob(first.id)
1163
+ assert(Option.isSome(job))
1164
+ expect(job.value.payload).toEqual({ n: 2 })
1165
+ expect(job.value.priority).toBe(7)
1166
+ expect(job.value.state).toBe("delayed")
1167
+
1168
+ // The rewritten delay is live: due after 5s, not the original 60s.
1169
+ yield* TestClock.adjust(5_000)
1170
+ const claim = yield* store.claim(claimOptions())
1171
+ assert(claim._tag === "Claimed")
1172
+ expect(claim.job.id).toBe(first.id)
1173
+ expect(claim.job.payload).toEqual({ n: 2 })
1174
+
1175
+ // Once claimed (active), replace degrades to plain dedup.
1176
+ const during = yield* store.enqueue(baseRequest({ dedupe, payload: { n: 3 } }))
1177
+ expect(during).toEqual({ id: first.id, duplicate: true })
1178
+ const active = yield* store.getJob(first.id)
1179
+ assert(Option.isSome(active))
1180
+ expect(active.value.payload).toEqual({ n: 2 })
1181
+ })
1182
+ ))
1183
+
1184
+ it.effect("an existing explicit id wins over the dedup tree in every driver", () =>
1185
+ withStore((store) =>
1186
+ Effect.gen(function*() {
1187
+ const key = { key: "k", ttlMs: undefined, extend: false, replace: false }
1188
+ // X runs under key k and completes, freeing the key but staying in
1189
+ // history.
1190
+ const x = yield* store.enqueue(baseRequest({ id: JobId("X"), dedupe: key }))
1191
+ const claim = yield* store.claim(claimOptions())
1192
+ assert(claim._tag === "Claimed")
1193
+ yield* store.ack(x.id, "t-1", { _tag: "Complete", exit: undefined })
1194
+
1195
+ // J takes over the key, delayed.
1196
+ const j = yield* store.enqueue(
1197
+ baseRequest({ dedupe: key, payload: { n: 9 }, delayMs: 60_000 })
1198
+ )
1199
+
1200
+ // A retried enqueue of X (id exists) must return X untouched — the
1201
+ // id check precedes the dedup tree, so J is neither returned nor
1202
+ // replaced.
1203
+ const retried = yield* store.enqueue(baseRequest({
1204
+ id: JobId("X"),
1205
+ payload: { n: 1 },
1206
+ dedupe: { ...key, replace: true }
1207
+ }))
1208
+ expect(retried).toEqual({ id: "X", duplicate: true })
1209
+ const job = yield* store.getJob(j.id)
1210
+ assert(Option.isSome(job))
1211
+ expect(job.value.payload).toEqual({ n: 9 })
1212
+ expect(job.value.state).toBe("delayed")
1213
+ })
1214
+ ))
1215
+
1216
+ it.effect("a landed replace re-arms the ttl window", () =>
1217
+ withStore((store) =>
1218
+ Effect.gen(function*() {
1219
+ const dedupe = { key: "k", ttlMs: 60_000, extend: false, replace: true }
1220
+ const first = yield* store.enqueue(baseRequest({ dedupe, delayMs: 300_000 }))
1221
+
1222
+ // 50s in: replace lands; the window restarts from here.
1223
+ yield* TestClock.adjust(50_000)
1224
+ const replaced = yield* store.enqueue(
1225
+ baseRequest({ dedupe, payload: { n: 2 }, delayMs: 300_000 })
1226
+ )
1227
+ expect(replaced).toEqual({ id: first.id, duplicate: true })
1228
+
1229
+ // 100s after the FIRST enqueue — past the original window, inside
1230
+ // the re-armed one: still the same job.
1231
+ yield* TestClock.adjust(50_000)
1232
+ const again = yield* store.enqueue(
1233
+ baseRequest({ dedupe, payload: { n: 3 }, delayMs: 300_000 })
1234
+ )
1235
+ expect(again).toEqual({ id: first.id, duplicate: true })
1236
+ })
1237
+ ))
1238
+
1239
+ it.effect("keep policies are independent per terminal state", () =>
1240
+ withStore((store) =>
1241
+ Effect.gen(function*() {
1242
+ // Completed records keep only the newest 1; failed keep everything.
1243
+ const keep = { completed: { count: 1 } }
1244
+ const completed: Array<JobStore.JobId> = []
1245
+ const failed: Array<JobStore.JobId> = []
1246
+ for (let i = 0; i < 2; i++) {
1247
+ const done = yield* store.enqueue(baseRequest({ payload: { n: i }, keep }))
1248
+ const claimA = yield* store.claim(claimOptions({ token: `tc-${i}` }))
1249
+ assert(claimA._tag === "Claimed")
1250
+ yield* store.ack(done.id, `tc-${i}`, { _tag: "Complete", exit: undefined })
1251
+ completed.push(done.id)
1252
+
1253
+ const bad = yield* store.enqueue(baseRequest({ payload: { n: 10 + i }, keep }))
1254
+ const claimB = yield* store.claim(claimOptions({ token: `tf-${i}` }))
1255
+ assert(claimB._tag === "Claimed")
1256
+ yield* store.ack(bad.id, `tf-${i}`, { _tag: "Fail", exit: undefined })
1257
+ failed.push(bad.id)
1258
+ yield* TestClock.adjust(10)
1259
+ }
1260
+ const counts = yield* store.counts()
1261
+ expect(counts.completed).toBe(1)
1262
+ expect(counts.failed).toBe(2)
1263
+ expect(Option.isNone(yield* store.getJob(completed[0] ?? JobId("?")))).toBe(true)
1264
+ expect(Option.isSome(yield* store.getJob(failed[0] ?? JobId("?")))).toBe(true)
1265
+ })
1266
+ ))
1267
+
1268
+ it.effect("an enqueue wakes only waiters watching its queue", () =>
1269
+ withStore((store) =>
1270
+ Effect.gen(function*() {
1271
+ const empty = yield* store.claim(claimOptions())
1272
+ assert(empty._tag === "Empty")
1273
+
1274
+ let woke = false
1275
+ const waiter = yield* Effect.forkChild(
1276
+ store.awaitWake([QueueName("default")], empty.wakeToken).pipe(
1277
+ Effect.tap(() => Effect.sync(() => void (woke = true)))
1278
+ )
1279
+ )
1280
+ yield* Effect.yieldNow
1281
+
1282
+ // Work on ANOTHER queue must not wake the default-queue waiter.
1283
+ yield* store.enqueue(baseRequest({ queue: QueueName("other") }))
1284
+ for (let i = 0; i < 10; i++) {
1285
+ yield* Effect.yieldNow
1286
+ }
1287
+ expect(woke).toBe(false)
1288
+
1289
+ // Matching-queue work does.
1290
+ yield* store.enqueue(baseRequest())
1291
+ yield* Fiber.join(waiter)
1292
+ expect(woke).toBe(true)
1293
+ })
1294
+ ))
1295
+
1296
+ it.effect("delayed jobs still promote to waiting while their queue is paused", () =>
1297
+ withStore((store) =>
1298
+ Effect.gen(function*() {
1299
+ const { id } = yield* store.enqueue(baseRequest({ delayMs: 1_000 }))
1300
+ yield* store.pause(QueueName("default"))
1301
+ yield* TestClock.adjust(1_000)
1302
+
1303
+ const claim = yield* store.claim(claimOptions())
1304
+ assert(claim._tag === "Empty")
1305
+ const job = yield* store.getJob(id)
1306
+ assert(Option.isSome(job))
1307
+ expect(job.value.state).toBe("waiting")
1308
+ })
1309
+ ))
653
1310
  })
654
1311
  }