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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +235 -0
  3. package/dist/Job.d.ts +222 -0
  4. package/dist/Job.d.ts.map +1 -0
  5. package/dist/Job.js +218 -0
  6. package/dist/Job.js.map +1 -0
  7. package/dist/JobStore.d.ts +401 -0
  8. package/dist/JobStore.d.ts.map +1 -0
  9. package/dist/JobStore.js +89 -0
  10. package/dist/JobStore.js.map +1 -0
  11. package/dist/MemoryJobStore.d.ts +34 -0
  12. package/dist/MemoryJobStore.d.ts.map +1 -0
  13. package/dist/MemoryJobStore.js +381 -0
  14. package/dist/MemoryJobStore.js.map +1 -0
  15. package/dist/Worker.d.ts +127 -0
  16. package/dist/Worker.d.ts.map +1 -0
  17. package/dist/Worker.js +274 -0
  18. package/dist/Worker.js.map +1 -0
  19. package/dist/drizzle/DrizzleJobStore.d.ts +59 -0
  20. package/dist/drizzle/DrizzleJobStore.d.ts.map +1 -0
  21. package/dist/drizzle/DrizzleJobStore.js +426 -0
  22. package/dist/drizzle/DrizzleJobStore.js.map +1 -0
  23. package/dist/drizzle/index.d.ts +19 -0
  24. package/dist/drizzle/index.d.ts.map +1 -0
  25. package/dist/drizzle/index.js +19 -0
  26. package/dist/drizzle/index.js.map +1 -0
  27. package/dist/drizzle/schema.d.ts +464 -0
  28. package/dist/drizzle/schema.d.ts.map +1 -0
  29. package/dist/drizzle/schema.js +68 -0
  30. package/dist/drizzle/schema.js.map +1 -0
  31. package/dist/index.d.ts +30 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +30 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/testing/conformance.d.ts +27 -0
  36. package/dist/testing/conformance.d.ts.map +1 -0
  37. package/dist/testing/conformance.js +451 -0
  38. package/dist/testing/conformance.js.map +1 -0
  39. package/dist/testing/index.d.ts +8 -0
  40. package/dist/testing/index.d.ts.map +1 -0
  41. package/dist/testing/index.js +8 -0
  42. package/dist/testing/index.js.map +1 -0
  43. package/package.json +71 -0
  44. package/src/Job.ts +606 -0
  45. package/src/JobStore.ts +446 -0
  46. package/src/MemoryJobStore.ts +467 -0
  47. package/src/Worker.ts +514 -0
  48. package/src/drizzle/DrizzleJobStore.ts +599 -0
  49. package/src/drizzle/index.ts +20 -0
  50. package/src/drizzle/schema.ts +116 -0
  51. package/src/index.ts +33 -0
  52. package/src/testing/conformance.ts +654 -0
  53. package/src/testing/index.ts +7 -0
@@ -0,0 +1,467 @@
1
+ /**
2
+ * In-memory `JobStore` built purely on Effect primitives.
3
+ *
4
+ * The reference driver: used for tests, local development and as the template
5
+ * for real storage adapters. Time comes from `Clock`, so everything is fully
6
+ * `TestClock`-compatible.
7
+ *
8
+ * Atomicity note: every operation reads the clock once and then mutates state
9
+ * in a single synchronous block, which is atomic on the JavaScript runtime.
10
+ *
11
+ * @since 0.1.0
12
+ */
13
+ import { Clock, type Context, Deferred, Effect, Exit, Layer, Option } from "effect"
14
+ import {
15
+ type AttemptRecord,
16
+ type ClaimResult,
17
+ type EnqueueRequest,
18
+ JobId,
19
+ JobNotFoundError,
20
+ JobNotRetryableError,
21
+ type JobRecord,
22
+ type JobState,
23
+ JobStore,
24
+ type ListResult,
25
+ LockLostError,
26
+ type QueueName,
27
+ type Service
28
+ } from "./JobStore.ts"
29
+
30
+ interface MemJob {
31
+ readonly id: JobId
32
+ readonly name: string
33
+ readonly queue: QueueName
34
+ readonly payload: unknown
35
+ readonly metadata: Readonly<Record<string, string>>
36
+ state: JobState
37
+ readonly priority: number
38
+ readonly attemptsMax: number
39
+ attemptsMade: number
40
+ stalledCount: number
41
+ readonly backoff: JobRecord["backoff"]
42
+ readonly keep: JobRecord["keep"]
43
+ runAt: number
44
+ readonly enqueuedAt: number
45
+ processedAt: number | undefined
46
+ finishedAt: number | undefined
47
+ exit: unknown
48
+ failedReason: string | undefined
49
+ attempts: Array<AttemptRecord>
50
+ seq: number
51
+ lockToken: string | undefined
52
+ lockExpiresAt: number | undefined
53
+ }
54
+
55
+ const snapshot = (job: MemJob): JobRecord => ({
56
+ id: job.id,
57
+ name: job.name,
58
+ queue: job.queue,
59
+ payload: job.payload,
60
+ metadata: job.metadata,
61
+ state: job.state,
62
+ priority: job.priority,
63
+ attemptsMax: job.attemptsMax,
64
+ attemptsMade: job.attemptsMade,
65
+ stalledCount: job.stalledCount,
66
+ backoff: job.backoff,
67
+ keep: job.keep,
68
+ runAt: job.runAt,
69
+ enqueuedAt: job.enqueuedAt,
70
+ processedAt: job.processedAt,
71
+ finishedAt: job.finishedAt,
72
+ exit: job.exit,
73
+ failedReason: job.failedReason
74
+ })
75
+
76
+ const metadataMatches = (
77
+ record: Readonly<Record<string, string>>,
78
+ filter: Readonly<Record<string, string>>
79
+ ): boolean => {
80
+ for (const [key, value] of Object.entries(filter)) {
81
+ if (record[key] !== value) return false
82
+ }
83
+ return true
84
+ }
85
+
86
+ /**
87
+ * Build a fresh in-memory `JobStore` implementation.
88
+ *
89
+ * @since 0.1.0
90
+ */
91
+ export const make: Effect.Effect<Service> = Effect.sync(() => {
92
+ const jobs = new Map<string, MemJob>()
93
+ let seq = 0
94
+ let idCounter = 0
95
+ let wakeVersion = 0
96
+ let wake = Deferred.makeUnsafe<void>()
97
+
98
+ // Synchronous on purpose: it is called inside the same synchronous block as
99
+ // the state mutation, so no effect-op boundary (where an interrupt could
100
+ // land) can separate a mutation from its wake-up signal.
101
+ const signalWake = () => {
102
+ wakeVersion += 1
103
+ const current = wake
104
+ wake = Deferred.makeUnsafe<void>()
105
+ Deferred.doneUnsafe(current, Exit.succeed<void>(void 0))
106
+ }
107
+
108
+ const promoteDue = (now: number) => {
109
+ for (const job of jobs.values()) {
110
+ if (job.state === "delayed" && job.runAt <= now) {
111
+ job.state = "waiting"
112
+ }
113
+ }
114
+ }
115
+
116
+ const clearLock = (job: MemJob) => {
117
+ job.lockToken = undefined
118
+ job.lockExpiresAt = undefined
119
+ }
120
+
121
+ const recordAttempt = (
122
+ job: MemJob,
123
+ outcome: AttemptRecord["outcome"],
124
+ finishedAt: number,
125
+ exit: AttemptRecord["exit"]
126
+ ) => {
127
+ job.attempts.push({
128
+ attempt: job.attempts.length + 1,
129
+ startedAt: job.processedAt,
130
+ finishedAt,
131
+ outcome,
132
+ exit
133
+ })
134
+ }
135
+
136
+ // Terminal retention: keep at most `count` and drop older than `ageMs`
137
+ // among terminal jobs sharing this job's name + state.
138
+ const applyKeep = (job: MemJob, now: number) => {
139
+ if (job.keep === undefined) return
140
+ const peers = Array.from(jobs.values())
141
+ .filter((peer) => peer.name === job.name && peer.state === job.state)
142
+ .toSorted((a, b) => ((b.finishedAt ?? 0) - (a.finishedAt ?? 0)) || (b.seq - a.seq))
143
+ const remove = new Set<string>()
144
+ if (job.keep.ageMs !== undefined) {
145
+ for (const peer of peers) {
146
+ if (peer.finishedAt !== undefined && peer.finishedAt <= now - job.keep.ageMs) {
147
+ remove.add(peer.id)
148
+ }
149
+ }
150
+ }
151
+ if (job.keep.count !== undefined) {
152
+ for (const peer of peers.slice(Math.max(0, job.keep.count))) {
153
+ remove.add(peer.id)
154
+ }
155
+ }
156
+ for (const id of remove) {
157
+ jobs.delete(id)
158
+ }
159
+ }
160
+
161
+ return JobStore.of({
162
+ enqueue: (request: EnqueueRequest) =>
163
+ Effect.gen(function*() {
164
+ const now = yield* Clock.currentTimeMillis
165
+ let id = request.id
166
+ if (id === undefined) {
167
+ // Store-assigned ids must never collide with user-supplied ones.
168
+ do {
169
+ id = JobId(`j-${++idCounter}`)
170
+ } while (jobs.has(id))
171
+ } else if (jobs.has(id)) {
172
+ return { id, duplicate: true }
173
+ }
174
+ jobs.set(id, {
175
+ id,
176
+ name: request.name,
177
+ queue: request.queue,
178
+ payload: request.payload,
179
+ metadata: request.metadata,
180
+ state: request.delayMs > 0 ? "delayed" : "waiting",
181
+ priority: request.priority,
182
+ attemptsMax: request.attemptsMax,
183
+ attemptsMade: 0,
184
+ stalledCount: 0,
185
+ backoff: request.backoff,
186
+ keep: request.keep,
187
+ runAt: now + Math.max(0, request.delayMs),
188
+ enqueuedAt: now,
189
+ processedAt: undefined,
190
+ finishedAt: undefined,
191
+ exit: undefined,
192
+ failedReason: undefined,
193
+ attempts: [],
194
+ seq: ++seq,
195
+ lockToken: undefined,
196
+ lockExpiresAt: undefined
197
+ })
198
+ signalWake()
199
+ return { id, duplicate: false }
200
+ }),
201
+
202
+ claim: (options) =>
203
+ Effect.gen(function*() {
204
+ const now = yield* Clock.currentTimeMillis
205
+ promoteDue(now)
206
+ const names = new Set(options.names)
207
+ let best: MemJob | undefined
208
+ let nextRunAt: number | undefined
209
+ for (const job of jobs.values()) {
210
+ if (job.queue !== options.queue || !names.has(job.name)) continue
211
+ if (job.state === "waiting") {
212
+ if (
213
+ best === undefined ||
214
+ job.priority > best.priority ||
215
+ (job.priority === best.priority && job.seq < best.seq)
216
+ ) {
217
+ best = job
218
+ }
219
+ } else if (job.state === "delayed") {
220
+ if (nextRunAt === undefined || job.runAt < nextRunAt) {
221
+ nextRunAt = job.runAt
222
+ }
223
+ }
224
+ }
225
+ if (best === undefined) {
226
+ const empty: ClaimResult = { _tag: "Empty", nextRunAt, wakeToken: wakeVersion }
227
+ return empty
228
+ }
229
+ best.state = "active"
230
+ best.lockToken = options.token
231
+ best.lockExpiresAt = now + options.lockDurationMs
232
+ best.processedAt = now
233
+ const claimed: ClaimResult = { _tag: "Claimed", job: snapshot(best) }
234
+ return claimed
235
+ }),
236
+
237
+ ack: (id, token, outcome) =>
238
+ Effect.gen(function*() {
239
+ const now = yield* Clock.currentTimeMillis
240
+ const job = jobs.get(id)
241
+ if (job === undefined) {
242
+ return yield* new JobNotFoundError({ jobId: id })
243
+ }
244
+ if (job.state !== "active" || job.lockToken !== token) {
245
+ return yield* new LockLostError({ jobId: id })
246
+ }
247
+ clearLock(job)
248
+ job.attemptsMade += 1
249
+ switch (outcome._tag) {
250
+ case "Complete": {
251
+ job.state = "completed"
252
+ job.exit = outcome.exit
253
+ job.finishedAt = now
254
+ recordAttempt(job, "completed", now, outcome.exit)
255
+ applyKeep(job, now)
256
+ break
257
+ }
258
+ case "Fail": {
259
+ job.state = "failed"
260
+ job.exit = outcome.exit
261
+ job.finishedAt = now
262
+ recordAttempt(job, "failed", now, outcome.exit)
263
+ applyKeep(job, now)
264
+ break
265
+ }
266
+ case "Retry": {
267
+ recordAttempt(job, "retried", now, outcome.exit)
268
+ job.runAt = now + Math.max(0, outcome.delayMs)
269
+ job.state = outcome.delayMs > 0 ? "delayed" : "waiting"
270
+ job.seq = ++seq
271
+ signalWake()
272
+ break
273
+ }
274
+ }
275
+ }),
276
+
277
+ release: (id, token) =>
278
+ Effect.gen(function*() {
279
+ const job = jobs.get(id)
280
+ if (job === undefined) {
281
+ return yield* new JobNotFoundError({ jobId: id })
282
+ }
283
+ if (job.state !== "active" || job.lockToken !== token) {
284
+ return yield* new LockLostError({ jobId: id })
285
+ }
286
+ clearLock(job)
287
+ job.state = "waiting"
288
+ signalWake()
289
+ }),
290
+
291
+ extendLocks: (locks, durationMs) =>
292
+ Effect.gen(function*() {
293
+ const now = yield* Clock.currentTimeMillis
294
+ const lost: Array<JobId> = []
295
+ for (const lock of locks) {
296
+ const job = jobs.get(lock.id)
297
+ if (
298
+ job !== undefined &&
299
+ job.state === "active" &&
300
+ job.lockToken === lock.token
301
+ ) {
302
+ job.lockExpiresAt = now + durationMs
303
+ } else {
304
+ lost.push(lock.id)
305
+ }
306
+ }
307
+ return lost
308
+ }),
309
+
310
+ recoverStalled: (options) =>
311
+ Effect.gen(function*() {
312
+ const now = yield* Clock.currentTimeMillis
313
+ const recovered: Array<{ id: JobId; failed: boolean }> = []
314
+ for (const job of jobs.values()) {
315
+ if (
316
+ job.state !== "active" ||
317
+ job.lockExpiresAt === undefined ||
318
+ job.lockExpiresAt > now
319
+ ) {
320
+ continue
321
+ }
322
+ clearLock(job)
323
+ job.stalledCount += 1
324
+ recordAttempt(job, "stalled", now, undefined)
325
+ if (job.stalledCount > options.maxStalledCount) {
326
+ job.state = "failed"
327
+ job.finishedAt = now
328
+ job.failedReason = "job stalled more than allowable limit"
329
+ recovered.push({ id: job.id, failed: true })
330
+ } else {
331
+ job.state = "waiting"
332
+ recovered.push({ id: job.id, failed: false })
333
+ }
334
+ }
335
+ if (recovered.some((entry) => !entry.failed)) {
336
+ signalWake()
337
+ }
338
+ return recovered
339
+ }),
340
+
341
+ awaitWake: (_queues, wakeToken) =>
342
+ Effect.suspend(() => {
343
+ if (wakeVersion > wakeToken) return Effect.void
344
+ return Deferred.await(wake)
345
+ }),
346
+
347
+ getJob: (id) =>
348
+ Effect.sync(() => {
349
+ const job = jobs.get(id)
350
+ return job === undefined ? Option.none() : Option.some(snapshot(job))
351
+ }),
352
+
353
+ getAttempts: (id) =>
354
+ Effect.sync(() => {
355
+ const job = jobs.get(id)
356
+ return job === undefined ? [] : [...job.attempts]
357
+ }),
358
+
359
+ list: (options) =>
360
+ Effect.sync(() => {
361
+ const limit = Math.max(1, options.limit ?? 50)
362
+ const states = options.states === undefined ? undefined : new Set(options.states)
363
+ // Newest first, stable across retries: (enqueuedAt desc, id desc).
364
+ let cursor: { readonly at: number; readonly id: string } | undefined
365
+ if (options.cursor !== undefined) {
366
+ const split = options.cursor.indexOf(":")
367
+ cursor = {
368
+ at: Number(options.cursor.slice(0, split)),
369
+ id: options.cursor.slice(split + 1)
370
+ }
371
+ }
372
+ const matches = Array.from(jobs.values())
373
+ .filter((job) =>
374
+ (options.queue === undefined || job.queue === options.queue) &&
375
+ (options.name === undefined || job.name === options.name) &&
376
+ (states === undefined || states.has(job.state)) &&
377
+ (options.metadata === undefined || metadataMatches(job.metadata, options.metadata))
378
+ )
379
+ .toSorted((a, b) =>
380
+ b.enqueuedAt !== a.enqueuedAt
381
+ ? b.enqueuedAt - a.enqueuedAt
382
+ : b.id < a.id
383
+ ? -1
384
+ : b.id > a.id
385
+ ? 1
386
+ : 0
387
+ )
388
+ .filter((job) =>
389
+ cursor === undefined ||
390
+ job.enqueuedAt < cursor.at ||
391
+ (job.enqueuedAt === cursor.at && job.id < cursor.id)
392
+ )
393
+ const items = matches.slice(0, limit).map(snapshot)
394
+ const last = items[items.length - 1]
395
+ const result: ListResult = {
396
+ items,
397
+ cursor: matches.length > limit && last !== undefined
398
+ ? `${last.enqueuedAt}:${last.id}`
399
+ : undefined
400
+ }
401
+ return result
402
+ }),
403
+
404
+ retry: (id) =>
405
+ Effect.gen(function*() {
406
+ const job = jobs.get(id)
407
+ if (job === undefined) {
408
+ return yield* new JobNotFoundError({ jobId: id })
409
+ }
410
+ if (job.state !== "failed") {
411
+ return yield* new JobNotRetryableError({ jobId: id, state: job.state })
412
+ }
413
+ const now = yield* Clock.currentTimeMillis
414
+ job.state = "waiting"
415
+ job.attemptsMade = 0
416
+ job.stalledCount = 0
417
+ job.exit = undefined
418
+ job.failedReason = undefined
419
+ job.finishedAt = undefined
420
+ job.processedAt = undefined
421
+ job.runAt = now
422
+ job.seq = ++seq
423
+ signalWake()
424
+ }),
425
+
426
+ counts: (queue) =>
427
+ Effect.sync(() => {
428
+ const counts = {
429
+ waiting: 0,
430
+ delayed: 0,
431
+ active: 0,
432
+ completed: 0,
433
+ failed: 0
434
+ } satisfies Record<JobState, number>
435
+ for (const job of jobs.values()) {
436
+ if (queue !== undefined && job.queue !== queue) continue
437
+ counts[job.state] += 1
438
+ }
439
+ return counts
440
+ }),
441
+
442
+ remove: (id) =>
443
+ Effect.sync(() => {
444
+ const job = jobs.get(id)
445
+ if (job === undefined || job.state === "active") return false
446
+ jobs.delete(id)
447
+ return true
448
+ })
449
+ })
450
+ })
451
+
452
+ /**
453
+ * A fresh in-memory `JobStore` layer. Pass a named store key to provide a
454
+ * `JobStore.named(...)` slot instead of the default.
455
+ *
456
+ * @since 0.1.0
457
+ */
458
+ export const layer: Layer.Layer<JobStore> = Layer.effect(JobStore, make)
459
+
460
+ /**
461
+ * An in-memory layer for a specific store key.
462
+ *
463
+ * @since 0.1.0
464
+ */
465
+ export const layerFor = <Id>(
466
+ store: Context.Key<Id, Service>
467
+ ): Layer.Layer<Id> => Layer.effect(store, make)