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.
- package/README.md +153 -18
- package/dist/Job.d.ts +51 -1
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +44 -2
- package/dist/Job.js.map +1 -1
- package/dist/JobStore.d.ts +86 -6
- package/dist/JobStore.d.ts.map +1 -1
- package/dist/JobStore.js +27 -1
- package/dist/JobStore.js.map +1 -1
- package/dist/MemoryJobStore.d.ts +6 -5
- package/dist/MemoryJobStore.d.ts.map +1 -1
- package/dist/MemoryJobStore.js +160 -32
- package/dist/MemoryJobStore.js.map +1 -1
- package/dist/Worker.d.ts.map +1 -1
- package/dist/Worker.js +1 -0
- package/dist/Worker.js.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts +20 -3
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.js +352 -84
- package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
- package/dist/drizzle-postgres/schema.d.ts +255 -351
- package/dist/drizzle-postgres/schema.d.ts.map +1 -1
- package/dist/drizzle-postgres/schema.js +75 -27
- package/dist/drizzle-postgres/schema.js.map +1 -1
- package/dist/redis/RedisJobStore.d.ts +4 -2
- package/dist/redis/RedisJobStore.d.ts.map +1 -1
- package/dist/redis/RedisJobStore.js +67 -28
- package/dist/redis/RedisJobStore.js.map +1 -1
- package/dist/redis/scripts.d.ts +19 -6
- package/dist/redis/scripts.d.ts.map +1 -1
- package/dist/redis/scripts.js +208 -23
- package/dist/redis/scripts.js.map +1 -1
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +172 -7
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +1 -1
- package/src/Job.ts +113 -6
- package/src/JobStore.ts +122 -6
- package/src/MemoryJobStore.ts +184 -44
- package/src/Worker.ts +1 -0
- package/src/drizzle-postgres/DrizzleJobStore.ts +431 -91
- package/src/drizzle-postgres/schema.ts +177 -63
- package/src/redis/RedisJobStore.ts +90 -35
- package/src/redis/scripts.ts +217 -24
- 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*() {
|