effect-mq 0.3.2 → 0.4.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 (42) hide show
  1. package/README.md +102 -14
  2. package/dist/Job.d.ts +131 -9
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +81 -5
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +63 -2
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js.map +1 -1
  9. package/dist/MemoryJobStore.d.ts.map +1 -1
  10. package/dist/MemoryJobStore.js +159 -119
  11. package/dist/MemoryJobStore.js.map +1 -1
  12. package/dist/Worker.d.ts +18 -0
  13. package/dist/Worker.d.ts.map +1 -1
  14. package/dist/Worker.js +37 -9
  15. package/dist/Worker.js.map +1 -1
  16. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  17. package/dist/drizzle-postgres/DrizzleJobStore.js +239 -49
  18. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  19. package/dist/drizzle-postgres/schema.d.ts +2 -0
  20. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  21. package/dist/drizzle-postgres/schema.js +1 -0
  22. package/dist/drizzle-postgres/schema.js.map +1 -1
  23. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  24. package/dist/redis/RedisJobStore.js +173 -36
  25. package/dist/redis/RedisJobStore.js.map +1 -1
  26. package/dist/redis/scripts.d.ts +24 -1
  27. package/dist/redis/scripts.d.ts.map +1 -1
  28. package/dist/redis/scripts.js +126 -21
  29. package/dist/redis/scripts.js.map +1 -1
  30. package/dist/testing/conformance.d.ts.map +1 -1
  31. package/dist/testing/conformance.js +266 -0
  32. package/dist/testing/conformance.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/Job.ts +268 -13
  35. package/src/JobStore.ts +77 -2
  36. package/src/MemoryJobStore.ts +102 -53
  37. package/src/Worker.ts +61 -9
  38. package/src/drizzle-postgres/DrizzleJobStore.ts +273 -66
  39. package/src/drizzle-postgres/schema.ts +2 -0
  40. package/src/redis/RedisJobStore.ts +236 -47
  41. package/src/redis/scripts.ts +153 -21
  42. package/src/testing/conformance.ts +350 -0
@@ -37,6 +37,7 @@ const baseRequest = (
37
37
  keep: undefined,
38
38
  timeoutMs: undefined,
39
39
  dedupe: undefined,
40
+ trace: undefined,
40
41
  delayMs: 0,
41
42
  ...overrides
42
43
  })
@@ -832,6 +833,310 @@ export const jobStoreConformance = (
832
833
  })
833
834
  ))
834
835
 
836
+ const minutelySchedule = (): JobStore.ScheduleRecord => ({
837
+ key: JobStore.ScheduleKey("TestJob/minutely"),
838
+ jobName: "TestJob",
839
+ queue: QueueName("default"),
840
+ cron: undefined,
841
+ tz: undefined,
842
+ everyMs: 60_000,
843
+ payload: { n: 7 },
844
+ metadata: {},
845
+ priority: 0,
846
+ attemptsMax: 1,
847
+ backoff: undefined,
848
+ keep: undefined,
849
+ timeoutMs: undefined,
850
+ nextRunAt: 60_000
851
+ })
852
+
853
+ it.effect("tickSchedule fires a slot exactly once and advances atomically", () =>
854
+ withStore((store) =>
855
+ Effect.gen(function*() {
856
+ const schedule = minutelySchedule()
857
+ yield* store.upsertSchedule(schedule)
858
+ yield* TestClock.adjust(60_000)
859
+ const request = baseRequest({
860
+ id: JobId("sched/TestJob/minutely/60000"),
861
+ payload: { n: 7 }
862
+ })
863
+
864
+ expect(yield* store.tickSchedule(schedule.key, 60_000, 120_000, request)).toBe(true)
865
+ expect((yield* store.listSchedules())[0]?.nextRunAt).toBe(120_000)
866
+ expect((yield* store.counts()).waiting).toBe(1)
867
+
868
+ // A concurrent sweeper holding the same stale expectation loses the
869
+ // CAS: nothing fires, nothing advances further.
870
+ expect(yield* store.tickSchedule(schedule.key, 60_000, 180_000, request)).toBe(false)
871
+ expect((yield* store.listSchedules())[0]?.nextRunAt).toBe(120_000)
872
+ expect((yield* store.counts()).waiting).toBe(1)
873
+
874
+ const claim = yield* store.claim(claimOptions())
875
+ assert(claim._tag === "Claimed")
876
+ expect(claim.job.id).toBe("sched/TestJob/minutely/60000")
877
+ expect(claim.job.payload).toEqual({ n: 7 })
878
+ })
879
+ ))
880
+
881
+ it.effect("a stale tick cannot re-fire a slot whose job was pruned", () =>
882
+ withStore((store) =>
883
+ Effect.gen(function*() {
884
+ const schedule = minutelySchedule()
885
+ yield* store.upsertSchedule(schedule)
886
+ yield* TestClock.adjust(60_000)
887
+ const slotId = JobId("sched/TestJob/minutely/60000")
888
+ const request = baseRequest({ id: slotId, payload: { n: 7 } })
889
+ expect(yield* store.tickSchedule(schedule.key, 60_000, 120_000, request)).toBe(true)
890
+
891
+ // Retention prunes the slot job — the old non-atomic design would
892
+ // now let a stale sweeper re-insert it. The CAS must still refuse.
893
+ expect(yield* store.remove(slotId)).toBe(true)
894
+ expect(yield* store.tickSchedule(schedule.key, 60_000, 180_000, request)).toBe(false)
895
+ expect((yield* store.counts()).waiting).toBe(0)
896
+ expect((yield* store.listSchedules())[0]?.nextRunAt).toBe(120_000)
897
+ })
898
+ ))
899
+
900
+ it.effect("tickSchedule advances without firing when the slot job already exists", () =>
901
+ withStore((store) =>
902
+ Effect.gen(function*() {
903
+ const schedule = minutelySchedule()
904
+ yield* store.upsertSchedule(schedule)
905
+ yield* TestClock.adjust(60_000)
906
+ const slotId = JobId("sched/TestJob/minutely/60000")
907
+ // A pre-0.4 worker crashed between its enqueue and advance: the
908
+ // slot row exists but nextRunAt is stale. The tick must advance the
909
+ // schedule (or it stays due forever) yet report nothing new fired.
910
+ yield* store.enqueue(baseRequest({ id: slotId, payload: { n: 7 } }))
911
+ const request = baseRequest({ id: slotId, payload: { n: 7 } })
912
+ expect(yield* store.tickSchedule(schedule.key, 60_000, 120_000, request)).toBe(false)
913
+ expect((yield* store.listSchedules())[0]?.nextRunAt).toBe(120_000)
914
+ expect((yield* store.counts()).waiting).toBe(1)
915
+ })
916
+ ))
917
+
918
+ it.effect("tickSchedule rejects requests without an id and unknown keys", () =>
919
+ withStore((store) =>
920
+ Effect.gen(function*() {
921
+ const missing = yield* Effect.flip(
922
+ store.tickSchedule(JobStore.ScheduleKey("TestJob/minutely"), 60_000, 120_000, baseRequest())
923
+ )
924
+ expect(missing._tag).toBe("JobStoreError")
925
+ expect(
926
+ yield* store.tickSchedule(
927
+ JobStore.ScheduleKey("ghost"),
928
+ 0,
929
+ 60_000,
930
+ baseRequest({ id: JobId("sched/ghost/0") })
931
+ )
932
+ ).toBe(false)
933
+ expect((yield* store.counts()).waiting).toBe(0)
934
+ })
935
+ ))
936
+
937
+ it.effect("tickSchedule wakes parked takers", () =>
938
+ withStore((store) =>
939
+ Effect.gen(function*() {
940
+ const schedule = minutelySchedule()
941
+ yield* store.upsertSchedule(schedule)
942
+ const empty = yield* store.claim(claimOptions())
943
+ assert(empty._tag === "Empty")
944
+ const waiter = yield* Effect.forkChild(
945
+ store.awaitWake([QueueName("default")], empty.wakeToken)
946
+ )
947
+ yield* Effect.yieldNow
948
+ yield* TestClock.adjust(60_000)
949
+ const request = baseRequest({
950
+ id: JobId("sched/TestJob/minutely/60000"),
951
+ payload: { n: 7 }
952
+ })
953
+ expect(yield* store.tickSchedule(schedule.key, 60_000, 120_000, request)).toBe(true)
954
+ yield* Fiber.join(waiter)
955
+ const claim = yield* store.claim(claimOptions({ token: "t-2" }))
956
+ assert(claim._tag === "Claimed")
957
+ })
958
+ ))
959
+
960
+ it.effect("enqueueMany inserts a batch with positional results", () =>
961
+ withStore((store) =>
962
+ Effect.gen(function*() {
963
+ const results = yield* store.enqueueMany([
964
+ baseRequest({ payload: { n: 1 } }),
965
+ baseRequest({ payload: { n: 2 }, delayMs: 60_000 }),
966
+ baseRequest({ id: JobId("batch-3"), payload: { n: 3 }, priority: 5 })
967
+ ])
968
+ expect(results).toHaveLength(3)
969
+ expect(results.map((result) => result.duplicate)).toEqual([false, false, false])
970
+ expect(new Set(results.map((result) => result.id)).size).toBe(3)
971
+ expect(results[2]?.id).toBe("batch-3")
972
+
973
+ const counts = yield* store.counts()
974
+ expect(counts.waiting).toBe(2)
975
+ expect(counts.delayed).toBe(1)
976
+
977
+ const explicit = yield* store.getJob(JobId("batch-3"))
978
+ assert(Option.isSome(explicit))
979
+ expect(explicit.value.payload).toEqual({ n: 3 })
980
+ expect(explicit.value.priority).toBe(5)
981
+
982
+ const delayedId = results[1]?.id
983
+ assert(delayedId !== undefined)
984
+ const delayed = yield* store.getJob(delayedId)
985
+ assert(Option.isSome(delayed))
986
+ expect(delayed.value.state).toBe("delayed")
987
+ expect(delayed.value.runAt).toBe(60_000)
988
+
989
+ // Priority order survives the batch path.
990
+ const claim = yield* store.claim(claimOptions())
991
+ assert(claim._tag === "Claimed")
992
+ expect(claim.job.id).toBe("batch-3")
993
+ })
994
+ ))
995
+
996
+ it.effect("enqueueMany reports duplicates positionally without clobbering", () =>
997
+ withStore((store) =>
998
+ Effect.gen(function*() {
999
+ yield* store.enqueue(baseRequest({ id: JobId("dup-1"), payload: { n: 0 } }))
1000
+ const results = yield* store.enqueueMany([
1001
+ baseRequest({ payload: { n: 1 } }),
1002
+ baseRequest({ id: JobId("dup-1"), payload: { n: 99 } }),
1003
+ baseRequest({ payload: { n: 2 } })
1004
+ ])
1005
+ expect(results.map((result) => result.duplicate)).toEqual([false, true, false])
1006
+ expect(results[1]?.id).toBe("dup-1")
1007
+ const kept = yield* store.getJob(JobId("dup-1"))
1008
+ assert(Option.isSome(kept))
1009
+ expect(kept.value.payload).toEqual({ n: 0 })
1010
+ expect((yield* store.counts()).waiting).toBe(3)
1011
+ })
1012
+ ))
1013
+
1014
+ it.effect("enqueueMany resolves intra-batch repeats of one id like separate enqueues", () =>
1015
+ withStore((store) =>
1016
+ Effect.gen(function*() {
1017
+ const results = yield* store.enqueueMany([
1018
+ baseRequest({ id: JobId("same"), payload: { n: 1 } }),
1019
+ baseRequest({ id: JobId("same"), payload: { n: 2 } })
1020
+ ])
1021
+ expect(results.map((result) => result.duplicate)).toEqual([false, true])
1022
+ expect(results[1]?.id).toBe("same")
1023
+ expect((yield* store.counts()).waiting).toBe(1)
1024
+ const job = yield* store.getJob(JobId("same"))
1025
+ assert(Option.isSome(job))
1026
+ expect(job.value.payload).toEqual({ n: 1 })
1027
+ })
1028
+ ))
1029
+
1030
+ it.effect("enqueueMany applies per-item dedup policies in order", () =>
1031
+ withStore((store) =>
1032
+ Effect.gen(function*() {
1033
+ const dedupe = { key: "k", ttlMs: undefined, extend: false, replace: false }
1034
+ const results = yield* store.enqueueMany([
1035
+ baseRequest({ payload: { n: 1 }, dedupe }),
1036
+ baseRequest({ payload: { n: 2 } }),
1037
+ baseRequest({ payload: { n: 3 }, dedupe })
1038
+ ])
1039
+ expect(results.map((result) => result.duplicate)).toEqual([false, false, true])
1040
+ expect(results[2]?.id).toBe(results[0]?.id)
1041
+ expect((yield* store.counts()).waiting).toBe(2)
1042
+ })
1043
+ ))
1044
+
1045
+ it.effect("enqueueMany with an empty batch returns an empty array", () =>
1046
+ withStore((store) =>
1047
+ Effect.gen(function*() {
1048
+ expect(yield* store.enqueueMany([])).toEqual([])
1049
+ })
1050
+ ))
1051
+
1052
+ // Pins the hand-duplicated field plumbing in batch/tick insert paths
1053
+ // (positional Lua ARGV strides, multi-row VALUES lists): every persisted
1054
+ // field must come out identical to the single-enqueue path. A swapped
1055
+ // pair of ARGVs or a dropped column fails this even though the simpler
1056
+ // tests (payload/priority/state only) stay green.
1057
+ it.effect("batch and tick inserts persist every field exactly like enqueue", () =>
1058
+ withStore((store) =>
1059
+ Effect.gen(function*() {
1060
+ const richRequest = (id: string): JobStore.EnqueueRequest =>
1061
+ baseRequest({
1062
+ id: JobId(id),
1063
+ payload: { big: 1234567890123456, nested: { arr: [1, 2, 3] } },
1064
+ metadata: { tenant: "acme", region: "us" },
1065
+ priority: 3,
1066
+ attemptsMax: 4,
1067
+ backoff: { _tag: "fixed", delayMs: 2_000 },
1068
+ keep: { completed: { count: 2, ageMs: undefined } },
1069
+ timeoutMs: 9_000,
1070
+ trace: { traceId: "trace-1", spanId: "span-1", sampled: true, delayed: false }
1071
+ })
1072
+ yield* store.enqueue(richRequest("rich-single"))
1073
+ expect(yield* store.enqueueMany([richRequest("rich-batch")]))
1074
+ .toEqual([{ id: "rich-batch", duplicate: false }])
1075
+ const schedule = {
1076
+ ...minutelySchedule(),
1077
+ key: JobStore.ScheduleKey("TestJob/parity"),
1078
+ nextRunAt: 0
1079
+ }
1080
+ yield* store.upsertSchedule(schedule)
1081
+ expect(yield* store.tickSchedule(schedule.key, 0, 60_000, richRequest("rich-tick")))
1082
+ .toBe(true)
1083
+
1084
+ const project = (job: JobStore.JobRecord) => ({
1085
+ name: job.name,
1086
+ queue: job.queue,
1087
+ state: job.state,
1088
+ payload: job.payload,
1089
+ metadata: job.metadata,
1090
+ priority: job.priority,
1091
+ attemptsMax: job.attemptsMax,
1092
+ attemptsMade: job.attemptsMade,
1093
+ backoff: job.backoff,
1094
+ keep: job.keep,
1095
+ timeoutMs: job.timeoutMs,
1096
+ cancelRequested: job.cancelRequested,
1097
+ trace: job.trace,
1098
+ runAt: job.runAt,
1099
+ enqueuedAt: job.enqueuedAt
1100
+ })
1101
+ const single = yield* store.getJob(JobId("rich-single"))
1102
+ assert(Option.isSome(single))
1103
+ const expected = project(single.value)
1104
+ // The reference row itself must carry the rich fields — otherwise
1105
+ // three empty rows would compare equal and prove nothing.
1106
+ expect(expected.backoff).toEqual({ _tag: "fixed", delayMs: 2_000 })
1107
+ expect(expected.keep).toEqual({ completed: { count: 2 } })
1108
+ expect(expected.timeoutMs).toBe(9_000)
1109
+ expect(expected.trace).toEqual({
1110
+ traceId: "trace-1",
1111
+ spanId: "span-1",
1112
+ sampled: true,
1113
+ delayed: false
1114
+ })
1115
+ expect(expected.payload).toEqual({ big: 1234567890123456, nested: { arr: [1, 2, 3] } })
1116
+ for (const id of [JobId("rich-batch"), JobId("rich-tick")]) {
1117
+ const job = yield* store.getJob(id)
1118
+ assert(Option.isSome(job))
1119
+ expect(project(job.value)).toEqual(expected)
1120
+ }
1121
+ })
1122
+ ))
1123
+
1124
+ it.effect("enqueueMany wakes parked takers", () =>
1125
+ withStore((store) =>
1126
+ Effect.gen(function*() {
1127
+ const empty = yield* store.claim(claimOptions())
1128
+ assert(empty._tag === "Empty")
1129
+ const waiter = yield* Effect.forkChild(
1130
+ store.awaitWake([QueueName("default")], empty.wakeToken)
1131
+ )
1132
+ yield* Effect.yieldNow
1133
+ yield* store.enqueueMany([baseRequest()])
1134
+ yield* Fiber.join(waiter)
1135
+ const claim = yield* store.claim(claimOptions({ token: "t-2" }))
1136
+ assert(claim._tag === "Claimed")
1137
+ })
1138
+ ))
1139
+
835
1140
  it.effect("completion wins over a pending cancel request", () =>
836
1141
  withStore((store) =>
837
1142
  Effect.gen(function*() {
@@ -1293,6 +1598,51 @@ export const jobStoreConformance = (
1293
1598
  })
1294
1599
  ))
1295
1600
 
1601
+ it.effect("cancelByDedupe cancels pending keyed jobs and is idempotent", () =>
1602
+ withStore((store) =>
1603
+ Effect.gen(function*() {
1604
+ const dedupe = { key: "emp-1", ttlMs: undefined, extend: false, replace: false }
1605
+ // Unknown key: nothing pending, no error.
1606
+ expect(yield* store.cancelByDedupe("TestJob", "emp-1")).toBe(false)
1607
+
1608
+ // Delayed keyed job: cancelled terminally.
1609
+ const delayed = yield* store.enqueue(baseRequest({ dedupe, delayMs: 60_000 }))
1610
+ expect(yield* store.cancelByDedupe("TestJob", "emp-1")).toBe(true)
1611
+ const job = yield* store.getJob(delayed.id)
1612
+ assert(Option.isSome(job))
1613
+ expect(job.value.state).toBe("cancelled")
1614
+ expect(yield* store.cancelByDedupe("TestJob", "emp-1")).toBe(false)
1615
+
1616
+ // Active keyed job: flagged for the heartbeat.
1617
+ const active = yield* store.enqueue(baseRequest({ dedupe, payload: { n: 2 } }))
1618
+ const claim = yield* store.claim(claimOptions())
1619
+ assert(claim._tag === "Claimed")
1620
+ expect(yield* store.cancelByDedupe("TestJob", "emp-1")).toBe(true)
1621
+ const flagged = yield* store.getJob(active.id)
1622
+ assert(Option.isSome(flagged))
1623
+ expect(flagged.value.cancelRequested).toBe(true)
1624
+
1625
+ // Name scoping: same key under another name is untouched.
1626
+ expect(yield* store.cancelByDedupe("OtherJob", "emp-1")).toBe(false)
1627
+ })
1628
+ ))
1629
+
1630
+ it.effect("the producer's trace context round-trips on the record", () =>
1631
+ withStore((store) =>
1632
+ Effect.gen(function*() {
1633
+ const trace = { traceId: "trace-abc", spanId: "span-def", sampled: true, delayed: false }
1634
+ const { id } = yield* store.enqueue(baseRequest({ trace }))
1635
+ const job = yield* store.getJob(id)
1636
+ assert(Option.isSome(job))
1637
+ expect(job.value.trace).toEqual(trace)
1638
+
1639
+ const bare = yield* store.enqueue(baseRequest({ payload: { n: 2 } }))
1640
+ const none = yield* store.getJob(bare.id)
1641
+ assert(Option.isSome(none))
1642
+ expect(none.value.trace).toBeUndefined()
1643
+ })
1644
+ ))
1645
+
1296
1646
  it.effect("delayed jobs still promote to waiting while their queue is paused", () =>
1297
1647
  withStore((store) =>
1298
1648
  Effect.gen(function*() {