effect-mq 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +153 -18
  2. package/dist/Job.d.ts +51 -1
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +44 -2
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +86 -6
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js +27 -1
  9. package/dist/JobStore.js.map +1 -1
  10. package/dist/MemoryJobStore.d.ts +6 -5
  11. package/dist/MemoryJobStore.d.ts.map +1 -1
  12. package/dist/MemoryJobStore.js +160 -32
  13. package/dist/MemoryJobStore.js.map +1 -1
  14. package/dist/Worker.d.ts.map +1 -1
  15. package/dist/Worker.js +1 -0
  16. package/dist/Worker.js.map +1 -1
  17. package/dist/drizzle-postgres/DrizzleJobStore.d.ts +20 -3
  18. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  19. package/dist/drizzle-postgres/DrizzleJobStore.js +352 -84
  20. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  21. package/dist/drizzle-postgres/schema.d.ts +255 -351
  22. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  23. package/dist/drizzle-postgres/schema.js +75 -27
  24. package/dist/drizzle-postgres/schema.js.map +1 -1
  25. package/dist/redis/RedisJobStore.d.ts +4 -2
  26. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  27. package/dist/redis/RedisJobStore.js +67 -28
  28. package/dist/redis/RedisJobStore.js.map +1 -1
  29. package/dist/redis/scripts.d.ts +19 -6
  30. package/dist/redis/scripts.d.ts.map +1 -1
  31. package/dist/redis/scripts.js +208 -23
  32. package/dist/redis/scripts.js.map +1 -1
  33. package/dist/testing/conformance.d.ts.map +1 -1
  34. package/dist/testing/conformance.js +172 -7
  35. package/dist/testing/conformance.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/Job.ts +113 -6
  38. package/src/JobStore.ts +122 -6
  39. package/src/MemoryJobStore.ts +184 -44
  40. package/src/Worker.ts +1 -0
  41. package/src/drizzle-postgres/DrizzleJobStore.ts +431 -91
  42. package/src/drizzle-postgres/schema.ts +177 -63
  43. package/src/redis/RedisJobStore.ts +90 -35
  44. package/src/redis/scripts.ts +217 -24
  45. package/src/testing/conformance.ts +246 -7
@@ -36,6 +36,7 @@ const baseRequest = (
36
36
  backoff: undefined,
37
37
  keep: undefined,
38
38
  timeoutMs: undefined,
39
+ dedupe: undefined,
39
40
  delayMs: 0,
40
41
  ...overrides
41
42
  })
@@ -472,7 +473,7 @@ export const jobStoreConformance = (
472
473
  Effect.gen(function*() {
473
474
  for (let i = 0; i < 4; i++) {
474
475
  const { id } = yield* store.enqueue(
475
- baseRequest({ payload: { n: i }, keep: { count: 2, ageMs: undefined } })
476
+ baseRequest({ payload: { n: i }, keep: { completed: { count: 2, ageMs: undefined } } })
476
477
  )
477
478
  const claim = yield* store.claim(claimOptions({ token: `t-${i}` }))
478
479
  assert(claim._tag === "Claimed")
@@ -488,7 +489,7 @@ export const jobStoreConformance = (
488
489
  it.effect("keep age prunes terminal records older than the window", () =>
489
490
  withStore((store) =>
490
491
  Effect.gen(function*() {
491
- const keep = { count: undefined, ageMs: 10_000 }
492
+ const keep = { completed: { count: undefined, ageMs: 10_000 } }
492
493
  const first = yield* store.enqueue(baseRequest({ payload: { n: 1 }, keep }))
493
494
  const claim1 = yield* store.claim(claimOptions())
494
495
  assert(claim1._tag === "Claimed")
@@ -543,10 +544,10 @@ export const jobStoreConformance = (
543
544
  // Two jobs acked at the SAME TestClock instant: the tie must break
544
545
  // on enqueue/seq order identically in every driver.
545
546
  const first = yield* store.enqueue(
546
- baseRequest({ payload: { n: 1 }, keep: { count: 1, ageMs: undefined } })
547
+ baseRequest({ payload: { n: 1 }, keep: { completed: { count: 1, ageMs: undefined } } })
547
548
  )
548
549
  const second = yield* store.enqueue(
549
- baseRequest({ payload: { n: 2 }, keep: { count: 1, ageMs: undefined } })
550
+ baseRequest({ payload: { n: 2 }, keep: { completed: { count: 1, ageMs: undefined } } })
550
551
  )
551
552
  const claimA = yield* store.claim(claimOptions({ token: "t-a" }))
552
553
  const claimB = yield* store.claim(claimOptions({ token: "t-b" }))
@@ -562,7 +563,7 @@ export const jobStoreConformance = (
562
563
  it.effect("keep applies count and age together", () =>
563
564
  withStore((store) =>
564
565
  Effect.gen(function*() {
565
- const keep = { count: 2, ageMs: 10_000 }
566
+ const keep = { completed: { count: 2, ageMs: 10_000 } }
566
567
  const ids: Array<JobStore.JobId> = []
567
568
  for (let i = 0; i < 3; i++) {
568
569
  const { id } = yield* store.enqueue(baseRequest({ payload: { n: i }, keep }))
@@ -889,7 +890,7 @@ export const jobStoreConformance = (
889
890
  it.effect("cancel applies the keep retention policy", () =>
890
891
  withStore((store) =>
891
892
  Effect.gen(function*() {
892
- const keep = { count: 1 }
893
+ const keep = { cancelled: { count: 1 } }
893
894
  const ids: Array<JobStore.JobId> = []
894
895
  for (let i = 0; i < 3; i++) {
895
896
  const { id } = yield* store.enqueue(baseRequest({ payload: { n: i }, keep }))
@@ -990,7 +991,7 @@ export const jobStoreConformance = (
990
991
  Effect.gen(function*() {
991
992
  // Three completions with keep {count: 1}: two get pruned.
992
993
  for (let i = 0; i < 3; i++) {
993
- const { id } = yield* store.enqueue(baseRequest({ payload: { n: i }, keep: { count: 1 } }))
994
+ const { id } = yield* store.enqueue(baseRequest({ payload: { n: i }, keep: { completed: { count: 1 } } }))
994
995
  const claim = yield* store.claim(claimOptions({ token: `t-${i}` }))
995
996
  assert(claim._tag === "Claimed")
996
997
  yield* store.ack(id, `t-${i}`, { _tag: "Complete", exit: undefined })
@@ -1054,6 +1055,244 @@ export const jobStoreConformance = (
1054
1055
  })
1055
1056
  ))
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
+
1057
1296
  it.effect("delayed jobs still promote to waiting while their queue is paused", () =>
1058
1297
  withStore((store) =>
1059
1298
  Effect.gen(function*() {