effect-mq 0.1.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/LICENSE +21 -0
- package/README.md +235 -0
- package/dist/Job.d.ts +222 -0
- package/dist/Job.d.ts.map +1 -0
- package/dist/Job.js +218 -0
- package/dist/Job.js.map +1 -0
- package/dist/JobStore.d.ts +401 -0
- package/dist/JobStore.d.ts.map +1 -0
- package/dist/JobStore.js +89 -0
- package/dist/JobStore.js.map +1 -0
- package/dist/MemoryJobStore.d.ts +34 -0
- package/dist/MemoryJobStore.d.ts.map +1 -0
- package/dist/MemoryJobStore.js +381 -0
- package/dist/MemoryJobStore.js.map +1 -0
- package/dist/Worker.d.ts +127 -0
- package/dist/Worker.d.ts.map +1 -0
- package/dist/Worker.js +274 -0
- package/dist/Worker.js.map +1 -0
- package/dist/drizzle/DrizzleJobStore.d.ts +59 -0
- package/dist/drizzle/DrizzleJobStore.d.ts.map +1 -0
- package/dist/drizzle/DrizzleJobStore.js +426 -0
- package/dist/drizzle/DrizzleJobStore.js.map +1 -0
- package/dist/drizzle/index.d.ts +19 -0
- package/dist/drizzle/index.d.ts.map +1 -0
- package/dist/drizzle/index.js +19 -0
- package/dist/drizzle/index.js.map +1 -0
- package/dist/drizzle/schema.d.ts +464 -0
- package/dist/drizzle/schema.d.ts.map +1 -0
- package/dist/drizzle/schema.js +68 -0
- package/dist/drizzle/schema.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/testing/conformance.d.ts +27 -0
- package/dist/testing/conformance.d.ts.map +1 -0
- package/dist/testing/conformance.js +451 -0
- package/dist/testing/conformance.js.map +1 -0
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +8 -0
- package/dist/testing/index.js.map +1 -0
- package/package.json +71 -0
- package/src/Job.ts +606 -0
- package/src/JobStore.ts +446 -0
- package/src/MemoryJobStore.ts +467 -0
- package/src/Worker.ts +514 -0
- package/src/drizzle/DrizzleJobStore.ts +599 -0
- package/src/drizzle/index.ts +20 -0
- package/src/drizzle/schema.ts +116 -0
- package/src/index.ts +33 -0
- package/src/testing/conformance.ts +654 -0
- package/src/testing/index.ts +7 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conformance suite for `JobStore` implementations.
|
|
3
|
+
*
|
|
4
|
+
* Every storage driver must pass this suite. Run it from a vitest file:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { jobStoreConformance } from "effect-mq/testing"
|
|
8
|
+
* import { MemoryJobStore } from "effect-mq"
|
|
9
|
+
*
|
|
10
|
+
* jobStoreConformance("MemoryJobStore", () => MemoryJobStore.layer)
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* The suite runs under `TestClock`. Drivers must derive ALL time from the
|
|
14
|
+
* Effect `Clock` (e.g. pass `now` into queries as a bind parameter) — never
|
|
15
|
+
* from the database server's clock — so this works against real storage too.
|
|
16
|
+
*
|
|
17
|
+
* @since 0.1.0
|
|
18
|
+
*/
|
|
19
|
+
import * as JobStore from "../JobStore.ts"
|
|
20
|
+
import { assert, describe, expect, it } from "@effect/vitest"
|
|
21
|
+
import { Effect, Exit, Fiber, type Layer, Option } from "effect"
|
|
22
|
+
import { TestClock } from "effect/testing"
|
|
23
|
+
|
|
24
|
+
const { JobId, QueueName } = JobStore
|
|
25
|
+
|
|
26
|
+
const baseRequest = (
|
|
27
|
+
overrides?: Partial<JobStore.EnqueueRequest>
|
|
28
|
+
): JobStore.EnqueueRequest => ({
|
|
29
|
+
id: undefined,
|
|
30
|
+
name: "TestJob",
|
|
31
|
+
queue: QueueName("default"),
|
|
32
|
+
payload: { n: 1 },
|
|
33
|
+
metadata: {},
|
|
34
|
+
priority: 0,
|
|
35
|
+
attemptsMax: 1,
|
|
36
|
+
backoff: undefined,
|
|
37
|
+
keep: undefined,
|
|
38
|
+
delayMs: 0,
|
|
39
|
+
...overrides
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const claimOptions = (
|
|
43
|
+
overrides?: Partial<JobStore.ClaimOptions>
|
|
44
|
+
): JobStore.ClaimOptions => ({
|
|
45
|
+
queue: QueueName("default"),
|
|
46
|
+
names: ["TestJob"],
|
|
47
|
+
token: "t-1",
|
|
48
|
+
lockDurationMs: 30_000,
|
|
49
|
+
...overrides
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Assert a `JobStore` implementation behaves according to the contract.
|
|
54
|
+
*
|
|
55
|
+
* @since 0.1.0
|
|
56
|
+
*/
|
|
57
|
+
export const jobStoreConformance = (
|
|
58
|
+
name: string,
|
|
59
|
+
storeLayer: () => Layer.Layer<JobStore.JobStore>
|
|
60
|
+
): void => {
|
|
61
|
+
describe(`JobStore conformance: ${name}`, () => {
|
|
62
|
+
const withStore = <A, E>(
|
|
63
|
+
body: (store: JobStore.Service) => Effect.Effect<A, E>
|
|
64
|
+
) =>
|
|
65
|
+
Effect.flatMap(JobStore.JobStore, body).pipe(
|
|
66
|
+
Effect.provide(storeLayer())
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
it.effect("enqueue lands in waiting and is claimable", () =>
|
|
70
|
+
withStore((store) =>
|
|
71
|
+
Effect.gen(function*() {
|
|
72
|
+
const result = yield* store.enqueue(baseRequest())
|
|
73
|
+
expect(result.duplicate).toBe(false)
|
|
74
|
+
const counts = yield* store.counts()
|
|
75
|
+
expect(counts.waiting).toBe(1)
|
|
76
|
+
|
|
77
|
+
const claim = yield* store.claim(claimOptions())
|
|
78
|
+
assert(claim._tag === "Claimed")
|
|
79
|
+
expect(claim.job.id).toBe(result.id)
|
|
80
|
+
expect(claim.job.state).toBe("active")
|
|
81
|
+
expect(claim.job.payload).toEqual({ n: 1 })
|
|
82
|
+
})
|
|
83
|
+
))
|
|
84
|
+
|
|
85
|
+
it.effect("enqueue with delay lands in delayed and promotes when due", () =>
|
|
86
|
+
withStore((store) =>
|
|
87
|
+
Effect.gen(function*() {
|
|
88
|
+
yield* store.enqueue(baseRequest({ delayMs: 5_000 }))
|
|
89
|
+
expect((yield* store.counts()).delayed).toBe(1)
|
|
90
|
+
|
|
91
|
+
const early = yield* store.claim(claimOptions())
|
|
92
|
+
assert(early._tag === "Empty")
|
|
93
|
+
expect(early.nextRunAt).toBeDefined()
|
|
94
|
+
|
|
95
|
+
yield* TestClock.adjust(5_000)
|
|
96
|
+
const due = yield* store.claim(claimOptions({ token: "t-2" }))
|
|
97
|
+
assert(due._tag === "Claimed")
|
|
98
|
+
})
|
|
99
|
+
))
|
|
100
|
+
|
|
101
|
+
it.effect("duplicate ids are a no-op", () =>
|
|
102
|
+
withStore((store) =>
|
|
103
|
+
Effect.gen(function*() {
|
|
104
|
+
const first = yield* store.enqueue(
|
|
105
|
+
baseRequest({ id: JobId("custom-1"), payload: { n: 1 } })
|
|
106
|
+
)
|
|
107
|
+
const second = yield* store.enqueue(
|
|
108
|
+
baseRequest({ id: JobId("custom-1"), payload: { n: 999 }, priority: 9 })
|
|
109
|
+
)
|
|
110
|
+
expect(first).toEqual({ id: "custom-1", duplicate: false })
|
|
111
|
+
expect(second).toEqual({ id: "custom-1", duplicate: true })
|
|
112
|
+
|
|
113
|
+
const job = yield* store.getJob(JobId("custom-1"))
|
|
114
|
+
assert(Option.isSome(job))
|
|
115
|
+
expect(job.value.payload).toEqual({ n: 1 })
|
|
116
|
+
expect(job.value.priority).toBe(0)
|
|
117
|
+
expect((yield* store.counts()).waiting).toBe(1)
|
|
118
|
+
})
|
|
119
|
+
))
|
|
120
|
+
|
|
121
|
+
it.effect("store-assigned ids never collide with user-supplied ids", () =>
|
|
122
|
+
withStore((store) =>
|
|
123
|
+
Effect.gen(function*() {
|
|
124
|
+
// Deliberately occupy an id shaped like a store-generated one.
|
|
125
|
+
const custom = yield* store.enqueue(baseRequest({ id: JobId("j-1") }))
|
|
126
|
+
expect(custom).toEqual({ id: "j-1", duplicate: false })
|
|
127
|
+
|
|
128
|
+
const auto = yield* store.enqueue(baseRequest({ payload: { n: 2 } }))
|
|
129
|
+
expect(auto.duplicate).toBe(false)
|
|
130
|
+
expect(auto.id).not.toBe(custom.id)
|
|
131
|
+
expect((yield* store.counts()).waiting).toBe(2)
|
|
132
|
+
})
|
|
133
|
+
))
|
|
134
|
+
|
|
135
|
+
it.effect("metadata round-trips on the record", () =>
|
|
136
|
+
withStore((store) =>
|
|
137
|
+
Effect.gen(function*() {
|
|
138
|
+
const { id } = yield* store.enqueue(
|
|
139
|
+
baseRequest({ metadata: { employerId: "emp-1", region: "us" } })
|
|
140
|
+
)
|
|
141
|
+
const job = yield* store.getJob(id)
|
|
142
|
+
assert(Option.isSome(job))
|
|
143
|
+
expect(job.value.metadata).toEqual({ employerId: "emp-1", region: "us" })
|
|
144
|
+
})
|
|
145
|
+
))
|
|
146
|
+
|
|
147
|
+
it.effect("claims are FIFO within a priority, higher priority first", () =>
|
|
148
|
+
withStore((store) =>
|
|
149
|
+
Effect.gen(function*() {
|
|
150
|
+
const a = yield* store.enqueue(baseRequest({ payload: { n: 1 } }))
|
|
151
|
+
const b = yield* store.enqueue(baseRequest({ payload: { n: 2 } }))
|
|
152
|
+
const c = yield* store.enqueue(
|
|
153
|
+
baseRequest({ payload: { n: 3 }, priority: 5 })
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const claimed: Array<string> = []
|
|
157
|
+
for (const token of ["t-1", "t-2", "t-3"]) {
|
|
158
|
+
const claim = yield* store.claim(claimOptions({ token }))
|
|
159
|
+
assert(claim._tag === "Claimed")
|
|
160
|
+
claimed.push(claim.job.id)
|
|
161
|
+
}
|
|
162
|
+
expect(claimed).toEqual([c.id, a.id, b.id])
|
|
163
|
+
})
|
|
164
|
+
))
|
|
165
|
+
|
|
166
|
+
it.effect("claim filters by queue and by name", () =>
|
|
167
|
+
withStore((store) =>
|
|
168
|
+
Effect.gen(function*() {
|
|
169
|
+
yield* store.enqueue(baseRequest({ queue: QueueName("other") }))
|
|
170
|
+
yield* store.enqueue(baseRequest({ name: "OtherJob" }))
|
|
171
|
+
|
|
172
|
+
const wrongBoth = yield* store.claim(claimOptions())
|
|
173
|
+
assert(wrongBoth._tag === "Empty")
|
|
174
|
+
|
|
175
|
+
const byQueue = yield* store.claim(
|
|
176
|
+
claimOptions({ queue: QueueName("other"), token: "t-2" })
|
|
177
|
+
)
|
|
178
|
+
assert(byQueue._tag === "Claimed")
|
|
179
|
+
expect(byQueue.job.queue).toBe("other")
|
|
180
|
+
|
|
181
|
+
const byName = yield* store.claim(
|
|
182
|
+
claimOptions({ names: ["OtherJob"], token: "t-3" })
|
|
183
|
+
)
|
|
184
|
+
assert(byName._tag === "Claimed")
|
|
185
|
+
expect(byName.job.name).toBe("OtherJob")
|
|
186
|
+
})
|
|
187
|
+
))
|
|
188
|
+
|
|
189
|
+
it.effect("ack Complete stores the exit, finishes the job, and records the run", () =>
|
|
190
|
+
withStore((store) =>
|
|
191
|
+
Effect.gen(function*() {
|
|
192
|
+
const { id } = yield* store.enqueue(baseRequest())
|
|
193
|
+
const claim = yield* store.claim(claimOptions())
|
|
194
|
+
assert(claim._tag === "Claimed")
|
|
195
|
+
|
|
196
|
+
yield* store.ack(id, "t-1", { _tag: "Complete", exit: { ok: true } })
|
|
197
|
+
const job = yield* store.getJob(id)
|
|
198
|
+
assert(Option.isSome(job))
|
|
199
|
+
expect(job.value.state).toBe("completed")
|
|
200
|
+
expect(job.value.exit).toEqual({ ok: true })
|
|
201
|
+
expect(job.value.attemptsMade).toBe(1)
|
|
202
|
+
expect(job.value.finishedAt).toBeDefined()
|
|
203
|
+
|
|
204
|
+
const attempts = yield* store.getAttempts(id)
|
|
205
|
+
expect(attempts).toHaveLength(1)
|
|
206
|
+
expect(attempts[0]?.attempt).toBe(1)
|
|
207
|
+
expect(attempts[0]?.outcome).toBe("completed")
|
|
208
|
+
expect(attempts[0]?.exit).toEqual({ ok: true })
|
|
209
|
+
expect(attempts[0]?.startedAt).toBeDefined()
|
|
210
|
+
expect(attempts[0]?.finishedAt).toBeDefined()
|
|
211
|
+
})
|
|
212
|
+
))
|
|
213
|
+
|
|
214
|
+
it.effect("ack Retry re-queues with delay and records the failed run", () =>
|
|
215
|
+
withStore((store) =>
|
|
216
|
+
Effect.gen(function*() {
|
|
217
|
+
const { id } = yield* store.enqueue(baseRequest({ attemptsMax: 3 }))
|
|
218
|
+
const claim = yield* store.claim(claimOptions())
|
|
219
|
+
assert(claim._tag === "Claimed")
|
|
220
|
+
|
|
221
|
+
yield* store.ack(id, "t-1", {
|
|
222
|
+
_tag: "Retry",
|
|
223
|
+
delayMs: 1_000,
|
|
224
|
+
exit: { boom: 1 }
|
|
225
|
+
})
|
|
226
|
+
const afterRetry = yield* store.getJob(id)
|
|
227
|
+
assert(Option.isSome(afterRetry))
|
|
228
|
+
expect(afterRetry.value.state).toBe("delayed")
|
|
229
|
+
expect(afterRetry.value.attemptsMade).toBe(1)
|
|
230
|
+
|
|
231
|
+
// The failed run is persisted (durable tapError before rerun).
|
|
232
|
+
const attempts = yield* store.getAttempts(id)
|
|
233
|
+
expect(attempts).toHaveLength(1)
|
|
234
|
+
expect(attempts[0]?.attempt).toBe(1)
|
|
235
|
+
expect(attempts[0]?.outcome).toBe("retried")
|
|
236
|
+
expect(attempts[0]?.exit).toEqual({ boom: 1 })
|
|
237
|
+
|
|
238
|
+
const early = yield* store.claim(claimOptions({ token: "t-2" }))
|
|
239
|
+
assert(early._tag === "Empty")
|
|
240
|
+
yield* TestClock.adjust(1_000)
|
|
241
|
+
const due = yield* store.claim(claimOptions({ token: "t-3" }))
|
|
242
|
+
assert(due._tag === "Claimed")
|
|
243
|
+
expect(due.job.attemptsMade).toBe(1)
|
|
244
|
+
})
|
|
245
|
+
))
|
|
246
|
+
|
|
247
|
+
it.effect("ack Fail is terminal and records the run", () =>
|
|
248
|
+
withStore((store) =>
|
|
249
|
+
Effect.gen(function*() {
|
|
250
|
+
const { id } = yield* store.enqueue(baseRequest())
|
|
251
|
+
const claim = yield* store.claim(claimOptions())
|
|
252
|
+
assert(claim._tag === "Claimed")
|
|
253
|
+
|
|
254
|
+
yield* store.ack(id, "t-1", { _tag: "Fail", exit: { failed: true } })
|
|
255
|
+
const job = yield* store.getJob(id)
|
|
256
|
+
assert(Option.isSome(job))
|
|
257
|
+
expect(job.value.state).toBe("failed")
|
|
258
|
+
expect(job.value.exit).toEqual({ failed: true })
|
|
259
|
+
|
|
260
|
+
const attempts = yield* store.getAttempts(id)
|
|
261
|
+
expect(attempts).toHaveLength(1)
|
|
262
|
+
expect(attempts[0]?.outcome).toBe("failed")
|
|
263
|
+
expect(attempts[0]?.exit).toEqual({ failed: true })
|
|
264
|
+
|
|
265
|
+
const after = yield* store.claim(claimOptions({ token: "t-2" }))
|
|
266
|
+
assert(after._tag === "Empty")
|
|
267
|
+
})
|
|
268
|
+
))
|
|
269
|
+
|
|
270
|
+
it.effect("ack with a wrong token fails with LockLostError", () =>
|
|
271
|
+
withStore((store) =>
|
|
272
|
+
Effect.gen(function*() {
|
|
273
|
+
const { id } = yield* store.enqueue(baseRequest())
|
|
274
|
+
const claim = yield* store.claim(claimOptions())
|
|
275
|
+
assert(claim._tag === "Claimed")
|
|
276
|
+
|
|
277
|
+
const result = yield* Effect.exit(
|
|
278
|
+
store.ack(id, "wrong-token", { _tag: "Complete", exit: null })
|
|
279
|
+
)
|
|
280
|
+
assert(Exit.isFailure(result))
|
|
281
|
+
|
|
282
|
+
const state = yield* store.getJob(id)
|
|
283
|
+
assert(Option.isSome(state))
|
|
284
|
+
expect(state.value.state).toBe("active")
|
|
285
|
+
})
|
|
286
|
+
))
|
|
287
|
+
|
|
288
|
+
it.effect("ack of an unknown id fails with JobNotFoundError", () =>
|
|
289
|
+
withStore((store) =>
|
|
290
|
+
Effect.gen(function*() {
|
|
291
|
+
const result = yield* Effect.exit(
|
|
292
|
+
store.ack(JobId("nope"), "t-1", { _tag: "Complete", exit: null })
|
|
293
|
+
)
|
|
294
|
+
assert(Exit.isFailure(result))
|
|
295
|
+
})
|
|
296
|
+
))
|
|
297
|
+
|
|
298
|
+
it.effect("release returns the job to waiting without consuming an attempt or recording a run", () =>
|
|
299
|
+
withStore((store) =>
|
|
300
|
+
Effect.gen(function*() {
|
|
301
|
+
const { id } = yield* store.enqueue(baseRequest())
|
|
302
|
+
const claim = yield* store.claim(claimOptions())
|
|
303
|
+
assert(claim._tag === "Claimed")
|
|
304
|
+
|
|
305
|
+
yield* store.release(id, "t-1")
|
|
306
|
+
const job = yield* store.getJob(id)
|
|
307
|
+
assert(Option.isSome(job))
|
|
308
|
+
expect(job.value.state).toBe("waiting")
|
|
309
|
+
expect(job.value.attemptsMade).toBe(0)
|
|
310
|
+
expect(yield* store.getAttempts(id)).toHaveLength(0)
|
|
311
|
+
})
|
|
312
|
+
))
|
|
313
|
+
|
|
314
|
+
it.effect("extendLocks extends live locks and reports lost ones", () =>
|
|
315
|
+
withStore((store) =>
|
|
316
|
+
Effect.gen(function*() {
|
|
317
|
+
const { id } = yield* store.enqueue(baseRequest())
|
|
318
|
+
const claim = yield* store.claim(
|
|
319
|
+
claimOptions({ lockDurationMs: 10_000 })
|
|
320
|
+
)
|
|
321
|
+
assert(claim._tag === "Claimed")
|
|
322
|
+
|
|
323
|
+
const lost = yield* store.extendLocks(
|
|
324
|
+
[{ id, token: "t-1" }, { id: JobId("ghost"), token: "t-9" }],
|
|
325
|
+
20_000
|
|
326
|
+
)
|
|
327
|
+
expect(lost).toEqual(["ghost"])
|
|
328
|
+
|
|
329
|
+
// The extension outlives the original lock duration.
|
|
330
|
+
yield* TestClock.adjust(15_000)
|
|
331
|
+
const recovered = yield* store.recoverStalled({ maxStalledCount: 1 })
|
|
332
|
+
expect(recovered).toEqual([])
|
|
333
|
+
})
|
|
334
|
+
))
|
|
335
|
+
|
|
336
|
+
it.effect("recoverStalled requeues expired locks, records runs, and fails repeat offenders", () =>
|
|
337
|
+
withStore((store) =>
|
|
338
|
+
Effect.gen(function*() {
|
|
339
|
+
const { id } = yield* store.enqueue(baseRequest())
|
|
340
|
+
|
|
341
|
+
// First stall: back to waiting.
|
|
342
|
+
const first = yield* store.claim(
|
|
343
|
+
claimOptions({ lockDurationMs: 1_000 })
|
|
344
|
+
)
|
|
345
|
+
assert(first._tag === "Claimed")
|
|
346
|
+
yield* TestClock.adjust(1_000)
|
|
347
|
+
const recovered = yield* store.recoverStalled({ maxStalledCount: 1 })
|
|
348
|
+
expect(recovered).toEqual([{ id, failed: false }])
|
|
349
|
+
const afterFirst = yield* store.getJob(id)
|
|
350
|
+
assert(Option.isSome(afterFirst))
|
|
351
|
+
expect(afterFirst.value.state).toBe("waiting")
|
|
352
|
+
|
|
353
|
+
// Second stall exceeds maxStalledCount: failed.
|
|
354
|
+
const second = yield* store.claim(
|
|
355
|
+
claimOptions({ token: "t-2", lockDurationMs: 1_000 })
|
|
356
|
+
)
|
|
357
|
+
assert(second._tag === "Claimed")
|
|
358
|
+
yield* TestClock.adjust(1_000)
|
|
359
|
+
const failed = yield* store.recoverStalled({ maxStalledCount: 1 })
|
|
360
|
+
expect(failed).toEqual([{ id, failed: true }])
|
|
361
|
+
const afterSecond = yield* store.getJob(id)
|
|
362
|
+
assert(Option.isSome(afterSecond))
|
|
363
|
+
expect(afterSecond.value.state).toBe("failed")
|
|
364
|
+
expect(afterSecond.value.failedReason).toBeDefined()
|
|
365
|
+
|
|
366
|
+
const attempts = yield* store.getAttempts(id)
|
|
367
|
+
expect(attempts.map((attempt) => attempt.outcome)).toEqual(["stalled", "stalled"])
|
|
368
|
+
expect(attempts.map((attempt) => attempt.attempt)).toEqual([1, 2])
|
|
369
|
+
|
|
370
|
+
// The old token no longer acks.
|
|
371
|
+
const ack = yield* Effect.exit(
|
|
372
|
+
store.ack(id, "t-2", { _tag: "Complete", exit: null })
|
|
373
|
+
)
|
|
374
|
+
assert(Exit.isFailure(ack))
|
|
375
|
+
})
|
|
376
|
+
))
|
|
377
|
+
|
|
378
|
+
it.effect("retry re-runs a failed job with a fresh budget and a preserved ledger", () =>
|
|
379
|
+
withStore((store) =>
|
|
380
|
+
Effect.gen(function*() {
|
|
381
|
+
const { id } = yield* store.enqueue(baseRequest({ attemptsMax: 1 }))
|
|
382
|
+
const claim = yield* store.claim(claimOptions())
|
|
383
|
+
assert(claim._tag === "Claimed")
|
|
384
|
+
yield* store.ack(id, "t-1", { _tag: "Fail", exit: { failed: 1 } })
|
|
385
|
+
|
|
386
|
+
yield* store.retry(id)
|
|
387
|
+
const job = yield* store.getJob(id)
|
|
388
|
+
assert(Option.isSome(job))
|
|
389
|
+
expect(job.value.state).toBe("waiting")
|
|
390
|
+
expect(job.value.attemptsMade).toBe(0)
|
|
391
|
+
expect(job.value.exit).toBeUndefined()
|
|
392
|
+
expect(job.value.failedReason).toBeUndefined()
|
|
393
|
+
|
|
394
|
+
// The job is claimable again; the ledger keeps counting monotonically.
|
|
395
|
+
const again = yield* store.claim(claimOptions({ token: "t-2" }))
|
|
396
|
+
assert(again._tag === "Claimed")
|
|
397
|
+
yield* store.ack(id, "t-2", { _tag: "Complete", exit: { ok: 1 } })
|
|
398
|
+
const attempts = yield* store.getAttempts(id)
|
|
399
|
+
expect(attempts.map((attempt) => [attempt.attempt, attempt.outcome])).toEqual([
|
|
400
|
+
[1, "failed"],
|
|
401
|
+
[2, "completed"]
|
|
402
|
+
])
|
|
403
|
+
})
|
|
404
|
+
))
|
|
405
|
+
|
|
406
|
+
it.effect("retry rejects non-failed jobs and unknown ids", () =>
|
|
407
|
+
withStore((store) =>
|
|
408
|
+
Effect.gen(function*() {
|
|
409
|
+
const { id } = yield* store.enqueue(baseRequest())
|
|
410
|
+
const wrongState = yield* Effect.flip(store.retry(id))
|
|
411
|
+
expect(wrongState._tag).toBe("JobNotRetryableError")
|
|
412
|
+
|
|
413
|
+
const missing = yield* Effect.flip(store.retry(JobId("nope")))
|
|
414
|
+
expect(missing._tag).toBe("JobNotFoundError")
|
|
415
|
+
})
|
|
416
|
+
))
|
|
417
|
+
|
|
418
|
+
it.effect("list filters by name/queue/state/metadata and paginates newest-first", () =>
|
|
419
|
+
withStore((store) =>
|
|
420
|
+
Effect.gen(function*() {
|
|
421
|
+
for (let i = 0; i < 5; i++) {
|
|
422
|
+
yield* store.enqueue(baseRequest({
|
|
423
|
+
payload: { n: i },
|
|
424
|
+
metadata: { employerId: i % 2 === 0 ? "even" : "odd" }
|
|
425
|
+
}))
|
|
426
|
+
yield* TestClock.adjust(1) // distinct enqueuedAt for stable order
|
|
427
|
+
}
|
|
428
|
+
yield* store.enqueue(baseRequest({ name: "OtherJob" }))
|
|
429
|
+
yield* TestClock.adjust(1)
|
|
430
|
+
yield* store.enqueue(baseRequest({ queue: QueueName("other") }))
|
|
431
|
+
|
|
432
|
+
const byName = yield* store.list({ name: "TestJob" })
|
|
433
|
+
expect(byName.items).toHaveLength(6)
|
|
434
|
+
// Newest first.
|
|
435
|
+
expect(byName.items[0]?.queue).toBe("other")
|
|
436
|
+
|
|
437
|
+
const byQueue = yield* store.list({ name: "TestJob", queue: QueueName("default") })
|
|
438
|
+
expect(byQueue.items).toHaveLength(5)
|
|
439
|
+
expect(byQueue.items.map((job) => job.payload)).toEqual([
|
|
440
|
+
{ n: 4 },
|
|
441
|
+
{ n: 3 },
|
|
442
|
+
{ n: 2 },
|
|
443
|
+
{ n: 1 },
|
|
444
|
+
{ n: 0 }
|
|
445
|
+
])
|
|
446
|
+
|
|
447
|
+
const byMetadata = yield* store.list({ metadata: { employerId: "even" } })
|
|
448
|
+
expect(byMetadata.items.map((job) => job.payload)).toEqual([
|
|
449
|
+
{ n: 4 },
|
|
450
|
+
{ n: 2 },
|
|
451
|
+
{ n: 0 }
|
|
452
|
+
])
|
|
453
|
+
|
|
454
|
+
const byState = yield* store.list({ states: ["waiting"] })
|
|
455
|
+
expect(byState.items).toHaveLength(7)
|
|
456
|
+
|
|
457
|
+
// Pagination walks the full set without overlap.
|
|
458
|
+
const page1 = yield* store.list({ name: "TestJob", limit: 4 })
|
|
459
|
+
expect(page1.items).toHaveLength(4)
|
|
460
|
+
expect(page1.cursor).toBeDefined()
|
|
461
|
+
const page2 = yield* store.list({ name: "TestJob", limit: 4, cursor: page1.cursor })
|
|
462
|
+
expect(page2.items).toHaveLength(2)
|
|
463
|
+
const ids = [...page1.items, ...page2.items].map((job) => job.id)
|
|
464
|
+
expect(new Set(ids).size).toBe(6)
|
|
465
|
+
})
|
|
466
|
+
))
|
|
467
|
+
|
|
468
|
+
it.effect("keep count prunes older terminal records of the same name and state", () =>
|
|
469
|
+
withStore((store) =>
|
|
470
|
+
Effect.gen(function*() {
|
|
471
|
+
for (let i = 0; i < 4; i++) {
|
|
472
|
+
const { id } = yield* store.enqueue(
|
|
473
|
+
baseRequest({ payload: { n: i }, keep: { count: 2, ageMs: undefined } })
|
|
474
|
+
)
|
|
475
|
+
const claim = yield* store.claim(claimOptions({ token: `t-${i}` }))
|
|
476
|
+
assert(claim._tag === "Claimed")
|
|
477
|
+
yield* store.ack(id, `t-${i}`, { _tag: "Complete", exit: null })
|
|
478
|
+
yield* TestClock.adjust(1)
|
|
479
|
+
}
|
|
480
|
+
const listed = yield* store.list({ name: "TestJob", states: ["completed"] })
|
|
481
|
+
expect(listed.items).toHaveLength(2)
|
|
482
|
+
expect(listed.items.map((job) => job.payload)).toEqual([{ n: 3 }, { n: 2 }])
|
|
483
|
+
})
|
|
484
|
+
))
|
|
485
|
+
|
|
486
|
+
it.effect("keep age prunes terminal records older than the window", () =>
|
|
487
|
+
withStore((store) =>
|
|
488
|
+
Effect.gen(function*() {
|
|
489
|
+
const keep = { count: undefined, ageMs: 10_000 }
|
|
490
|
+
const first = yield* store.enqueue(baseRequest({ payload: { n: 1 }, keep }))
|
|
491
|
+
const claim1 = yield* store.claim(claimOptions())
|
|
492
|
+
assert(claim1._tag === "Claimed")
|
|
493
|
+
yield* store.ack(first.id, "t-1", { _tag: "Complete", exit: null })
|
|
494
|
+
|
|
495
|
+
yield* TestClock.adjust(20_000)
|
|
496
|
+
const second = yield* store.enqueue(baseRequest({ payload: { n: 2 }, keep }))
|
|
497
|
+
const claim2 = yield* store.claim(claimOptions({ token: "t-2" }))
|
|
498
|
+
assert(claim2._tag === "Claimed")
|
|
499
|
+
yield* store.ack(second.id, "t-2", { _tag: "Complete", exit: null })
|
|
500
|
+
|
|
501
|
+
expect(Option.isNone(yield* store.getJob(first.id))).toBe(true)
|
|
502
|
+
expect(Option.isSome(yield* store.getJob(second.id))).toBe(true)
|
|
503
|
+
})
|
|
504
|
+
))
|
|
505
|
+
|
|
506
|
+
it.effect("list with an empty states filter matches nothing", () =>
|
|
507
|
+
withStore((store) =>
|
|
508
|
+
Effect.gen(function*() {
|
|
509
|
+
yield* store.enqueue(baseRequest())
|
|
510
|
+
const listed = yield* store.list({ states: [] })
|
|
511
|
+
expect(listed.items).toHaveLength(0)
|
|
512
|
+
expect(listed.cursor).toBeUndefined()
|
|
513
|
+
})
|
|
514
|
+
))
|
|
515
|
+
|
|
516
|
+
it.effect("list pagination is lossless when enqueue timestamps tie", () =>
|
|
517
|
+
withStore((store) =>
|
|
518
|
+
Effect.gen(function*() {
|
|
519
|
+
// No clock adjustment: every record shares one enqueuedAt, so
|
|
520
|
+
// ordering and the cursor fall back entirely to the id tie-break.
|
|
521
|
+
for (let i = 0; i < 7; i++) {
|
|
522
|
+
yield* store.enqueue(baseRequest({ payload: { n: i } }))
|
|
523
|
+
}
|
|
524
|
+
const seen = new Set<string>()
|
|
525
|
+
let cursor: string | undefined
|
|
526
|
+
do {
|
|
527
|
+
const page: JobStore.ListResult = yield* store.list({ limit: 3, cursor })
|
|
528
|
+
for (const item of page.items) {
|
|
529
|
+
expect(seen.has(item.id)).toBe(false)
|
|
530
|
+
seen.add(item.id)
|
|
531
|
+
}
|
|
532
|
+
cursor = page.cursor
|
|
533
|
+
} while (cursor !== undefined)
|
|
534
|
+
expect(seen.size).toBe(7)
|
|
535
|
+
})
|
|
536
|
+
))
|
|
537
|
+
|
|
538
|
+
it.effect("keep count ties on finishedAt keep the most recently acked records", () =>
|
|
539
|
+
withStore((store) =>
|
|
540
|
+
Effect.gen(function*() {
|
|
541
|
+
// Two jobs acked at the SAME TestClock instant: the tie must break
|
|
542
|
+
// on enqueue/seq order identically in every driver.
|
|
543
|
+
const first = yield* store.enqueue(
|
|
544
|
+
baseRequest({ payload: { n: 1 }, keep: { count: 1, ageMs: undefined } })
|
|
545
|
+
)
|
|
546
|
+
const second = yield* store.enqueue(
|
|
547
|
+
baseRequest({ payload: { n: 2 }, keep: { count: 1, ageMs: undefined } })
|
|
548
|
+
)
|
|
549
|
+
const claimA = yield* store.claim(claimOptions({ token: "t-a" }))
|
|
550
|
+
const claimB = yield* store.claim(claimOptions({ token: "t-b" }))
|
|
551
|
+
assert(claimA._tag === "Claimed" && claimB._tag === "Claimed")
|
|
552
|
+
yield* store.ack(first.id, "t-a", { _tag: "Complete", exit: null })
|
|
553
|
+
yield* store.ack(second.id, "t-b", { _tag: "Complete", exit: null })
|
|
554
|
+
|
|
555
|
+
expect(Option.isNone(yield* store.getJob(first.id))).toBe(true)
|
|
556
|
+
expect(Option.isSome(yield* store.getJob(second.id))).toBe(true)
|
|
557
|
+
})
|
|
558
|
+
))
|
|
559
|
+
|
|
560
|
+
it.effect("keep applies count and age together", () =>
|
|
561
|
+
withStore((store) =>
|
|
562
|
+
Effect.gen(function*() {
|
|
563
|
+
const keep = { count: 2, ageMs: 10_000 }
|
|
564
|
+
const ids: Array<JobStore.JobId> = []
|
|
565
|
+
for (let i = 0; i < 3; i++) {
|
|
566
|
+
const { id } = yield* store.enqueue(baseRequest({ payload: { n: i }, keep }))
|
|
567
|
+
const claim = yield* store.claim(claimOptions({ token: `t-${i}` }))
|
|
568
|
+
assert(claim._tag === "Claimed")
|
|
569
|
+
yield* store.ack(id, `t-${i}`, { _tag: "Complete", exit: null })
|
|
570
|
+
ids.push(id)
|
|
571
|
+
yield* TestClock.adjust(6_000)
|
|
572
|
+
}
|
|
573
|
+
const [oldest, middle, newest] = ids
|
|
574
|
+
assert(oldest !== undefined && middle !== undefined && newest !== undefined)
|
|
575
|
+
// At the final ack (t=12s): the age clause prunes #0 (finished 12s
|
|
576
|
+
// ago > 10s) and the count clause independently keeps the newest 2,
|
|
577
|
+
// so #1 (6s old) and #2 survive under both clauses.
|
|
578
|
+
expect(Option.isNone(yield* store.getJob(oldest))).toBe(true)
|
|
579
|
+
expect(Option.isSome(yield* store.getJob(middle))).toBe(true)
|
|
580
|
+
expect(Option.isSome(yield* store.getJob(newest))).toBe(true)
|
|
581
|
+
})
|
|
582
|
+
))
|
|
583
|
+
|
|
584
|
+
it.effect("awaitWake resolves on new work and honours the wake token", () =>
|
|
585
|
+
withStore((store) =>
|
|
586
|
+
Effect.gen(function*() {
|
|
587
|
+
const empty = yield* store.claim(claimOptions())
|
|
588
|
+
assert(empty._tag === "Empty")
|
|
589
|
+
|
|
590
|
+
// Wake-up arriving *before* awaitWake is not lost thanks to the token.
|
|
591
|
+
yield* store.enqueue(baseRequest())
|
|
592
|
+
yield* store.awaitWake([QueueName("default")], empty.wakeToken)
|
|
593
|
+
|
|
594
|
+
// And a waiter blocked on a fresh token is woken by a later enqueue.
|
|
595
|
+
const claimed = yield* store.claim(claimOptions({ token: "t-2" }))
|
|
596
|
+
assert(claimed._tag === "Claimed")
|
|
597
|
+
const emptyAgain = yield* store.claim(claimOptions({ token: "t-3" }))
|
|
598
|
+
assert(emptyAgain._tag === "Empty")
|
|
599
|
+
const waiter = yield* Effect.forkChild(
|
|
600
|
+
store.awaitWake([QueueName("default")], emptyAgain.wakeToken)
|
|
601
|
+
)
|
|
602
|
+
yield* Effect.yieldNow
|
|
603
|
+
yield* store.enqueue(baseRequest({ payload: { n: 2 } }))
|
|
604
|
+
yield* Fiber.join(waiter)
|
|
605
|
+
})
|
|
606
|
+
))
|
|
607
|
+
|
|
608
|
+
it.effect("counts groups by state and can filter by queue", () =>
|
|
609
|
+
withStore((store) =>
|
|
610
|
+
Effect.gen(function*() {
|
|
611
|
+
yield* store.enqueue(baseRequest())
|
|
612
|
+
yield* store.enqueue(baseRequest({ delayMs: 1_000 }))
|
|
613
|
+
yield* store.enqueue(baseRequest({ queue: QueueName("other") }))
|
|
614
|
+
const claim = yield* store.claim(claimOptions())
|
|
615
|
+
assert(claim._tag === "Claimed")
|
|
616
|
+
|
|
617
|
+
const all = yield* store.counts()
|
|
618
|
+
expect(all).toEqual({
|
|
619
|
+
waiting: 1,
|
|
620
|
+
delayed: 1,
|
|
621
|
+
active: 1,
|
|
622
|
+
completed: 0,
|
|
623
|
+
failed: 0
|
|
624
|
+
})
|
|
625
|
+
const other = yield* store.counts(QueueName("other"))
|
|
626
|
+
expect(other.waiting).toBe(1)
|
|
627
|
+
expect(other.active).toBe(0)
|
|
628
|
+
})
|
|
629
|
+
))
|
|
630
|
+
|
|
631
|
+
it.effect("remove deletes non-active jobs but refuses active ones", () =>
|
|
632
|
+
withStore((store) =>
|
|
633
|
+
Effect.gen(function*() {
|
|
634
|
+
const first = yield* store.enqueue(baseRequest())
|
|
635
|
+
const second = yield* store.enqueue(baseRequest({ payload: { n: 2 } }))
|
|
636
|
+
const claim = yield* store.claim(claimOptions())
|
|
637
|
+
assert(claim._tag === "Claimed")
|
|
638
|
+
expect(claim.job.id).toBe(first.id)
|
|
639
|
+
|
|
640
|
+
expect(yield* store.remove(second.id)).toBe(true)
|
|
641
|
+
expect(yield* store.remove(first.id)).toBe(false)
|
|
642
|
+
expect(yield* store.remove(JobId("ghost"))).toBe(false)
|
|
643
|
+
expect((yield* store.counts()).waiting).toBe(0)
|
|
644
|
+
})
|
|
645
|
+
))
|
|
646
|
+
|
|
647
|
+
it.effect("getAttempts returns an empty ledger for unknown ids", () =>
|
|
648
|
+
withStore((store) =>
|
|
649
|
+
Effect.gen(function*() {
|
|
650
|
+
expect(yield* store.getAttempts(JobId("nope"))).toEqual([])
|
|
651
|
+
})
|
|
652
|
+
))
|
|
653
|
+
})
|
|
654
|
+
}
|