effect-mq 0.3.0 → 0.3.2
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 +58 -1
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +9 -5
- package/dist/Job.js.map +1 -1
- package/dist/Metrics.d.ts +98 -0
- package/dist/Metrics.d.ts.map +1 -0
- package/dist/Metrics.js +124 -0
- package/dist/Metrics.js.map +1 -0
- package/dist/Worker.d.ts +6 -0
- package/dist/Worker.d.ts.map +1 -1
- package/dist/Worker.js +62 -3
- package/dist/Worker.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/testing/TestJobStore.d.ts +95 -0
- package/dist/testing/TestJobStore.d.ts.map +1 -0
- package/dist/testing/TestJobStore.js +88 -0
- package/dist/testing/TestJobStore.js.map +1 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +6 -0
- package/dist/testing/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Job.ts +16 -4
- package/src/Metrics.ts +135 -0
- package/src/Worker.ts +101 -3
- package/src/index.ts +8 -0
- package/src/testing/TestJobStore.ts +143 -0
- package/src/testing/index.ts +7 -0
package/src/Metrics.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The metrics effect-mq emits, as Effect `Metric` instruments.
|
|
3
|
+
*
|
|
4
|
+
* Metrics are **process-local operational signal, not persisted state**:
|
|
5
|
+
* they live in the Effect metric registry of the emitting process and are
|
|
6
|
+
* exported by whatever exporter the application runs (the Otlp modules from
|
|
7
|
+
* `effect/unstable/observability`, `@effect/opentelemetry`, a Prometheus
|
|
8
|
+
* scraper, ...). Retention belongs to that metrics backend. The queue's
|
|
9
|
+
* *durable* analogues remain in the store — `store.counts()` for live depth
|
|
10
|
+
* and the attempt ledger for per-run history — and stay queryable forever.
|
|
11
|
+
*
|
|
12
|
+
* Producers emit `jobsEnqueued`; workers emit the rest from their loops.
|
|
13
|
+
* Everything is tagged with low-cardinality attributes only (job name,
|
|
14
|
+
* queue, outcome/result) — never ids or keys.
|
|
15
|
+
*
|
|
16
|
+
* This module is exported so applications can read the same instruments
|
|
17
|
+
* (e.g. `Metric.value(Metrics.jobRuns.pipe(Metric.withAttributes({...})))`)
|
|
18
|
+
* or reference the names when building dashboards.
|
|
19
|
+
*
|
|
20
|
+
* @since 0.3.1
|
|
21
|
+
*/
|
|
22
|
+
import { Metric } from "effect"
|
|
23
|
+
|
|
24
|
+
/** Milliseconds, 5ms .. ~55 minutes. */
|
|
25
|
+
const durationBoundaries = Metric.exponentialBoundaries({ start: 5, factor: 2, count: 20 })
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Producer-side enqueues. Tags: `name`, `queue`, `duplicate` ("true" when
|
|
29
|
+
* the enqueue deduplicated against an existing id or dedup key).
|
|
30
|
+
*
|
|
31
|
+
* @since 0.3.1
|
|
32
|
+
*/
|
|
33
|
+
export const jobsEnqueued = Metric.counter("effect_mq_jobs_enqueued", {
|
|
34
|
+
description: "Jobs submitted via Job.enqueue/execute, including deduplicated submissions"
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Finished runs, one per handler attempt. Tags: `name`, `queue`, `outcome`
|
|
39
|
+
* (completed | retried | failed | cancelled | released).
|
|
40
|
+
*
|
|
41
|
+
* @since 0.3.1
|
|
42
|
+
*/
|
|
43
|
+
export const jobRuns = Metric.counter("effect_mq_job_runs", {
|
|
44
|
+
description: "Handler runs by outcome (released = handed back on worker shutdown)"
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Handler execution time in milliseconds (claim to ack). Tags: `name`,
|
|
49
|
+
* `queue`, `outcome`.
|
|
50
|
+
*
|
|
51
|
+
* @since 0.3.1
|
|
52
|
+
*/
|
|
53
|
+
export const jobRunDuration = Metric.histogram("effect_mq_job_run_duration_ms", {
|
|
54
|
+
description: "Handler execution time in milliseconds, claim to ack",
|
|
55
|
+
boundaries: durationBoundaries
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Queue latency in milliseconds: how long a job was runnable (past its
|
|
60
|
+
* `runAt`) before a worker claimed it. Tags: `name`, `queue`.
|
|
61
|
+
*
|
|
62
|
+
* @since 0.3.1
|
|
63
|
+
*/
|
|
64
|
+
export const jobWaitDuration = Metric.histogram("effect_mq_job_wait_duration_ms", {
|
|
65
|
+
description: "Time between a job becoming runnable and its claim, in milliseconds",
|
|
66
|
+
boundaries: durationBoundaries
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Claim attempts by result. Tags: `queue`, `result` (claimed | empty).
|
|
71
|
+
* A high empty ratio means takers outnumber work.
|
|
72
|
+
*
|
|
73
|
+
* @since 0.3.1
|
|
74
|
+
*/
|
|
75
|
+
export const claims = Metric.counter("effect_mq_claims", {
|
|
76
|
+
description: "Store claim attempts by result"
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Handlers currently executing. Tags: `queue`.
|
|
81
|
+
*
|
|
82
|
+
* @since 0.3.1
|
|
83
|
+
*/
|
|
84
|
+
export const jobsInFlight = Metric.gauge("effect_mq_jobs_in_flight", {
|
|
85
|
+
description: "Handlers currently executing"
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Queue depth by state, sampled from `store.counts()` when
|
|
90
|
+
* `Worker.layer({ queueMetricsInterval })` is set. Tags: `queue`, `state`.
|
|
91
|
+
*
|
|
92
|
+
* @since 0.3.1
|
|
93
|
+
*/
|
|
94
|
+
export const queueDepth = Metric.gauge("effect_mq_queue_depth", {
|
|
95
|
+
description: "Jobs per state, sampled from store.counts() per registered queue"
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Locks found gone at renewal, counted once per lost lock (the job may run
|
|
100
|
+
* twice). Tags: none.
|
|
101
|
+
*
|
|
102
|
+
* @since 0.3.1
|
|
103
|
+
*/
|
|
104
|
+
export const locksLost = Metric.counter("effect_mq_locks_lost", {
|
|
105
|
+
description: "Locks found lost at heartbeat renewal, once per lock"
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Cross-process cancel requests delivered to running handlers by the
|
|
110
|
+
* heartbeat. Tags: none.
|
|
111
|
+
*
|
|
112
|
+
* @since 0.3.1
|
|
113
|
+
*/
|
|
114
|
+
export const cancelInterrupts = Metric.counter("effect_mq_cancel_interrupts", {
|
|
115
|
+
description: "Running handlers interrupted by a cancel request"
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Jobs recovered by the stalled sweep. Tags: `outcome` (requeued | failed).
|
|
120
|
+
*
|
|
121
|
+
* @since 0.3.1
|
|
122
|
+
*/
|
|
123
|
+
export const stalledRecovered = Metric.counter("effect_mq_stalled_recovered", {
|
|
124
|
+
description: "Stalled jobs recovered (requeued) or failed past the stall limit"
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Repeatable-schedule occurrences enqueued by this worker's sweep. Tags:
|
|
129
|
+
* `name` (the job name).
|
|
130
|
+
*
|
|
131
|
+
* @since 0.3.1
|
|
132
|
+
*/
|
|
133
|
+
export const scheduleTicks = Metric.counter("effect_mq_schedule_ticks", {
|
|
134
|
+
description: "Repeatable-schedule occurrences enqueued by the schedule sweep"
|
|
135
|
+
})
|
package/src/Worker.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* @since 0.1.0
|
|
16
16
|
*/
|
|
17
|
-
import { Cause, Clock, Context, Deferred, Duration, Effect, Exit, Fiber, FiberSet, Layer, Option, Result, Schedule, Schema, type Scope, Scope as Scope_, Tracer } from "effect"
|
|
17
|
+
import { Cause, Clock, Context, Deferred, Duration, Effect, Exit, Fiber, FiberSet, Layer, Metric, Option, Result, Schedule, Schema, type Scope, Scope as Scope_, Tracer } from "effect"
|
|
18
18
|
import {
|
|
19
19
|
type AckOutcome,
|
|
20
20
|
type BackoffPolicy,
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type ScheduleRecord,
|
|
32
32
|
type Service as StoreService
|
|
33
33
|
} from "./JobStore.ts"
|
|
34
|
+
import * as Metrics from "./Metrics.ts"
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* Information about the currently running attempt, passed to handlers as the
|
|
@@ -117,6 +118,12 @@ export interface WorkerOptions<StoreId = JobStore> {
|
|
|
117
118
|
readonly pollInterval?: Duration.Input | undefined
|
|
118
119
|
/** How often to tick due repeatable-job schedules (default 15s). */
|
|
119
120
|
readonly scheduleSweepInterval?: Duration.Input | undefined
|
|
121
|
+
/**
|
|
122
|
+
* When set, sample `store.counts(queue)` for every registered queue into
|
|
123
|
+
* the `effect_mq_queue_depth` gauge at this cadence (default: off — depth
|
|
124
|
+
* sampling costs one store query per queue per tick).
|
|
125
|
+
*/
|
|
126
|
+
readonly queueMetricsInterval?: Duration.Input | undefined
|
|
120
127
|
/** Identifier used in lock tokens (default: random). */
|
|
121
128
|
readonly id?: string | undefined
|
|
122
129
|
}
|
|
@@ -311,6 +318,18 @@ export const make = <StoreId = JobStore>(
|
|
|
311
318
|
attempt: record.attemptsMade + 1,
|
|
312
319
|
attemptsMax: record.attemptsMax
|
|
313
320
|
}
|
|
321
|
+
// One line per finished run: outcome counter + duration histogram
|
|
322
|
+
// (claim time to now, from the Effect Clock).
|
|
323
|
+
const recordRun = (outcome: string) =>
|
|
324
|
+
Effect.gen(function*() {
|
|
325
|
+
const finished = yield* Clock.currentTimeMillis
|
|
326
|
+
const attributes = { name: record.name, queue: record.queue, outcome }
|
|
327
|
+
yield* Metric.update(Metrics.jobRuns.pipe(Metric.withAttributes(attributes)), 1)
|
|
328
|
+
yield* Metric.update(
|
|
329
|
+
Metrics.jobRunDuration.pipe(Metric.withAttributes(attributes)),
|
|
330
|
+
Math.max(0, finished - (record.processedAt ?? finished))
|
|
331
|
+
)
|
|
332
|
+
})
|
|
314
333
|
return Effect.gen(function*() {
|
|
315
334
|
// The per-run time limit interrupts the handler internally; surface
|
|
316
335
|
// it as a defect so it flows through normal retry accounting.
|
|
@@ -334,7 +353,10 @@ export const make = <StoreId = JobStore>(
|
|
|
334
353
|
// keeps the child interruptible while the fork itself is masked.
|
|
335
354
|
const fiber = yield* Effect.forkChild(restore(handlerEffect))
|
|
336
355
|
inflight.set(record.id, { id: record.id, token, fiber })
|
|
356
|
+
const busy = Metrics.jobsInFlight.pipe(Metric.withAttributes({ queue: record.queue }))
|
|
357
|
+
yield* Metric.modify(busy, 1)
|
|
337
358
|
const exit = yield* Effect.exit(restore(Fiber.join(fiber)))
|
|
359
|
+
yield* Metric.modify(busy, -1)
|
|
338
360
|
inflight.delete(record.id)
|
|
339
361
|
const wasCancelled = cancelling.delete(record.id)
|
|
340
362
|
|
|
@@ -342,7 +364,7 @@ export const make = <StoreId = JobStore>(
|
|
|
342
364
|
// shutdown races it — cancellation wins, the job must not revive).
|
|
343
365
|
if (wasCancelled && Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) {
|
|
344
366
|
yield* ackSafely(store.ack(record.id, token, { _tag: "Cancelled" }), "ack")
|
|
345
|
-
return
|
|
367
|
+
return yield* recordRun("cancelled")
|
|
346
368
|
}
|
|
347
369
|
|
|
348
370
|
// Distinguish worker shutdown from a handler that interrupted
|
|
@@ -355,6 +377,7 @@ export const make = <StoreId = JobStore>(
|
|
|
355
377
|
// without consuming an attempt.
|
|
356
378
|
yield* Fiber.interrupt(fiber)
|
|
357
379
|
yield* ackSafely(store.release(record.id, token), "release")
|
|
380
|
+
yield* recordRun("released")
|
|
358
381
|
return yield* Effect.interrupt
|
|
359
382
|
}
|
|
360
383
|
|
|
@@ -396,6 +419,15 @@ export const make = <StoreId = JobStore>(
|
|
|
396
419
|
? { _tag: "Fail", exit: exitValue }
|
|
397
420
|
: routeFailure(record, exitValue)
|
|
398
421
|
yield* ackSafely(store.ack(record.id, token, outcome), "ack")
|
|
422
|
+
yield* recordRun(
|
|
423
|
+
outcome._tag === "Complete"
|
|
424
|
+
? "completed"
|
|
425
|
+
: outcome._tag === "Retry"
|
|
426
|
+
? "retried"
|
|
427
|
+
: outcome._tag === "Cancelled"
|
|
428
|
+
? "cancelled"
|
|
429
|
+
: "failed"
|
|
430
|
+
)
|
|
399
431
|
})
|
|
400
432
|
})
|
|
401
433
|
|
|
@@ -420,6 +452,10 @@ export const make = <StoreId = JobStore>(
|
|
|
420
452
|
lockDurationMs
|
|
421
453
|
}))
|
|
422
454
|
if (result._tag === "Empty") {
|
|
455
|
+
yield* Metric.update(
|
|
456
|
+
Metrics.claims.pipe(Metric.withAttributes({ queue, result: "empty" })),
|
|
457
|
+
1
|
|
458
|
+
)
|
|
423
459
|
const now = yield* Clock.currentTimeMillis
|
|
424
460
|
const timeout = result.nextRunAt !== undefined
|
|
425
461
|
? Math.max(0, Math.min(result.nextRunAt - now, pollMs))
|
|
@@ -430,6 +466,17 @@ export const make = <StoreId = JobStore>(
|
|
|
430
466
|
awaitPulse(observedPulse)
|
|
431
467
|
]))
|
|
432
468
|
}
|
|
469
|
+
yield* Metric.update(
|
|
470
|
+
Metrics.claims.pipe(Metric.withAttributes({ queue, result: "claimed" })),
|
|
471
|
+
1
|
|
472
|
+
)
|
|
473
|
+
const claimedAt = yield* Clock.currentTimeMillis
|
|
474
|
+
yield* Metric.update(
|
|
475
|
+
Metrics.jobWaitDuration.pipe(
|
|
476
|
+
Metric.withAttributes({ name: result.job.name, queue })
|
|
477
|
+
),
|
|
478
|
+
Math.max(0, claimedAt - result.job.runAt)
|
|
479
|
+
)
|
|
433
480
|
yield* processJob(result.job, token, restore)
|
|
434
481
|
})
|
|
435
482
|
)
|
|
@@ -475,16 +522,28 @@ export const make = <StoreId = JobStore>(
|
|
|
475
522
|
lockDurationMs
|
|
476
523
|
))
|
|
477
524
|
if (result.lost.length > 0) {
|
|
525
|
+
yield* Metric.update(Metrics.locksLost, result.lost.length)
|
|
478
526
|
yield* Effect.logWarning(
|
|
479
527
|
"effect-mq: failed to renew locks; jobs may run twice",
|
|
480
528
|
result.lost
|
|
481
529
|
)
|
|
530
|
+
// A lost lock stays lost: drop the flight so we neither renew nor
|
|
531
|
+
// recount it every heartbeat (the run's eventual ack surfaces
|
|
532
|
+
// LockLostError on its own).
|
|
533
|
+
for (const id of result.lost) {
|
|
534
|
+
inflight.delete(id)
|
|
535
|
+
}
|
|
482
536
|
}
|
|
483
537
|
// Honour cross-process cancel requests: interrupt the handler fiber;
|
|
484
538
|
// processJob acks the job as Cancelled.
|
|
485
539
|
for (const id of result.cancelRequested) {
|
|
486
540
|
const flight = inflight.get(id)
|
|
487
541
|
if (flight !== undefined) {
|
|
542
|
+
// The store re-reports the request every heartbeat until the ack
|
|
543
|
+
// lands; count the first delivery only.
|
|
544
|
+
if (!cancelling.has(id)) {
|
|
545
|
+
yield* Metric.update(Metrics.cancelInterrupts, 1)
|
|
546
|
+
}
|
|
488
547
|
cancelling.add(id)
|
|
489
548
|
// Delivery only — awaiting the handler's exit here would park the
|
|
490
549
|
// heartbeat behind arbitrary user finalizers and starve every other
|
|
@@ -502,6 +561,19 @@ export const make = <StoreId = JobStore>(
|
|
|
502
561
|
yield* Effect.sleep(stalledMs)
|
|
503
562
|
const recovered = yield* retryStore(store.recoverStalled({ maxStalledCount }))
|
|
504
563
|
if (recovered.length > 0) {
|
|
564
|
+
const failed = recovered.filter((entry) => entry.failed).length
|
|
565
|
+
if (failed > 0) {
|
|
566
|
+
yield* Metric.update(
|
|
567
|
+
Metrics.stalledRecovered.pipe(Metric.withAttributes({ outcome: "failed" })),
|
|
568
|
+
failed
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
if (recovered.length - failed > 0) {
|
|
572
|
+
yield* Metric.update(
|
|
573
|
+
Metrics.stalledRecovered.pipe(Metric.withAttributes({ outcome: "requeued" })),
|
|
574
|
+
recovered.length - failed
|
|
575
|
+
)
|
|
576
|
+
}
|
|
505
577
|
yield* Effect.logWarning("effect-mq: recovered stalled jobs", recovered)
|
|
506
578
|
}
|
|
507
579
|
}).pipe(
|
|
@@ -523,7 +595,7 @@ export const make = <StoreId = JobStore>(
|
|
|
523
595
|
`effect-mq: schedule "${schedule.key}" has an invalid cron/every configuration; skipping`
|
|
524
596
|
)
|
|
525
597
|
}
|
|
526
|
-
yield* retryStore(store.enqueue({
|
|
598
|
+
const tick = yield* retryStore(store.enqueue({
|
|
527
599
|
id: JobId(`sched/${schedule.key}/${slot}`),
|
|
528
600
|
name: schedule.jobName,
|
|
529
601
|
queue: schedule.queue,
|
|
@@ -537,6 +609,12 @@ export const make = <StoreId = JobStore>(
|
|
|
537
609
|
dedupe: undefined,
|
|
538
610
|
delayMs: 0
|
|
539
611
|
}))
|
|
612
|
+
if (!tick.duplicate) {
|
|
613
|
+
yield* Metric.update(
|
|
614
|
+
Metrics.scheduleTicks.pipe(Metric.withAttributes({ name: schedule.jobName })),
|
|
615
|
+
1
|
|
616
|
+
)
|
|
617
|
+
}
|
|
540
618
|
yield* retryStore(store.advanceSchedule(schedule.key, slot, next))
|
|
541
619
|
})
|
|
542
620
|
|
|
@@ -561,6 +639,26 @@ export const make = <StoreId = JobStore>(
|
|
|
561
639
|
yield* FiberSet.run(fibers, stalledLoop)
|
|
562
640
|
yield* FiberSet.run(fibers, scheduleLoop)
|
|
563
641
|
|
|
642
|
+
if (options?.queueMetricsInterval !== undefined) {
|
|
643
|
+
const sampleMs = Duration.toMillis(options.queueMetricsInterval)
|
|
644
|
+
const depthLoop = Effect.gen(function*() {
|
|
645
|
+
yield* Effect.sleep(sampleMs)
|
|
646
|
+
for (const queue of startedQueues) {
|
|
647
|
+
const counts = yield* retryStore(store.counts(queue))
|
|
648
|
+
for (const [state, depth] of Object.entries(counts)) {
|
|
649
|
+
yield* Metric.update(
|
|
650
|
+
Metrics.queueDepth.pipe(Metric.withAttributes({ queue, state })),
|
|
651
|
+
depth
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}).pipe(
|
|
656
|
+
Effect.catchCause((cause) => Effect.logWarning("effect-mq: queue depth sampling failed", cause)),
|
|
657
|
+
Effect.forever
|
|
658
|
+
)
|
|
659
|
+
yield* FiberSet.run(fibers, depthLoop)
|
|
660
|
+
}
|
|
661
|
+
|
|
564
662
|
// SAFETY: the public `register` signature declares Scope, the handler's R
|
|
565
663
|
// and the codec services as requirements; the implementation erases them
|
|
566
664
|
// (the trailing assertion below) because the handler runs with the
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,14 @@ export * as JobStore from "./JobStore.ts"
|
|
|
25
25
|
*/
|
|
26
26
|
export * as MemoryJobStore from "./MemoryJobStore.ts"
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The metrics effect-mq emits (Effect `Metric` instruments) — process-local,
|
|
30
|
+
* exported by your observability stack.
|
|
31
|
+
*
|
|
32
|
+
* @since 0.3.1
|
|
33
|
+
*/
|
|
34
|
+
export * as Metrics from "./Metrics.ts"
|
|
35
|
+
|
|
28
36
|
/**
|
|
29
37
|
* The worker runtime: `Worker.layer` and handler registration.
|
|
30
38
|
*
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A test harness for asserting what your services enqueue, without running
|
|
3
|
+
* a worker.
|
|
4
|
+
*
|
|
5
|
+
* Provide `TestJobStore.layer` in a unit test and jobs enqueued by the code
|
|
6
|
+
* under test simply accumulate in `waiting`/`delayed` (nothing claims them).
|
|
7
|
+
* `enqueuedOf` returns them with payloads **decoded through the job's own
|
|
8
|
+
* schema** — you assert against the typed values your service produced, not
|
|
9
|
+
* the encoded JSON the store persists:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { TestJobStore } from "effect-mq/testing"
|
|
13
|
+
*
|
|
14
|
+
* it.effect("signup enqueues a welcome email", () =>
|
|
15
|
+
* Effect.gen(function*() {
|
|
16
|
+
* yield* SignupService.register({ email: "ada@example.com" })
|
|
17
|
+
*
|
|
18
|
+
* const emails = yield* TestJobStore.enqueuedOf(SendEmail)
|
|
19
|
+
* expect(emails).toHaveLength(1)
|
|
20
|
+
* expect(emails[0]?.payload.to).toBe("ada@example.com")
|
|
21
|
+
* expect(emails[0]?.state).toBe("waiting")
|
|
22
|
+
* }).pipe(Effect.provide(TestJobStore.layer)))
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Jobs bound to named stores use `TestJobStore.layerFor(Durable)` instead.
|
|
26
|
+
* The raw `JobStore` service is also exposed (as `.store`) for advanced
|
|
27
|
+
* scenarios — simulating claims/acks, reading `counts()`, and so on.
|
|
28
|
+
*
|
|
29
|
+
* @since 0.3.2
|
|
30
|
+
*/
|
|
31
|
+
import * as JobStore from "../JobStore.ts"
|
|
32
|
+
import * as MemoryJobStore from "../MemoryJobStore.ts"
|
|
33
|
+
import { Context, Effect, Layer, Schema } from "effect"
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The minimal structural view of a `Job.make` class that `enqueuedOf`
|
|
37
|
+
* needs: its tag and its JSON payload codec.
|
|
38
|
+
*
|
|
39
|
+
* @since 0.3.2
|
|
40
|
+
*/
|
|
41
|
+
export interface AnyJobDefinition {
|
|
42
|
+
readonly _tag: string
|
|
43
|
+
readonly payloadJsonSchema: Schema.Top & { readonly DecodingServices: never }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A stored job with its payload decoded back to the definition's payload
|
|
48
|
+
* type.
|
|
49
|
+
*
|
|
50
|
+
* @since 0.3.2
|
|
51
|
+
*/
|
|
52
|
+
export interface EnqueuedJob<Payload> extends Omit<JobStore.JobRecord, "payload"> {
|
|
53
|
+
readonly payload: Payload
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const drainList = (store: JobStore.Service, name?: string) =>
|
|
57
|
+
Effect.gen(function*() {
|
|
58
|
+
const all: Array<JobStore.JobRecord> = []
|
|
59
|
+
let cursor: string | undefined = undefined
|
|
60
|
+
while (true) {
|
|
61
|
+
const page: JobStore.ListResult = yield* store.list({ name, cursor, limit: 200 }).pipe(Effect.orDie)
|
|
62
|
+
all.push(...page.items)
|
|
63
|
+
if (page.cursor === undefined) break
|
|
64
|
+
cursor = page.cursor
|
|
65
|
+
}
|
|
66
|
+
// list() is newest-first; flip to oldest-first for natural reading.
|
|
67
|
+
// Ties (same-instant enqueues) order by id, not submission order.
|
|
68
|
+
return all.toReversed()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const makeApi = (store: JobStore.Service) => ({
|
|
72
|
+
store,
|
|
73
|
+
enqueued: (name?: string) => drainList(store, name),
|
|
74
|
+
enqueuedOf: <J extends AnyJobDefinition>(job: J) =>
|
|
75
|
+
drainList(store, job._tag).pipe(
|
|
76
|
+
Effect.flatMap(Effect.forEach((record) =>
|
|
77
|
+
Schema.decodeUnknownEffect(job.payloadJsonSchema)(record.payload).pipe(
|
|
78
|
+
Effect.orDie,
|
|
79
|
+
Effect.map((payload): EnqueuedJob<J["payloadJsonSchema"]["Type"]> => ({ ...record, payload }))
|
|
80
|
+
)
|
|
81
|
+
))
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Inspection API over the test store. `enqueued(name?)` returns raw records
|
|
87
|
+
* oldest-first; `enqueuedOf(JobClass)` additionally decodes payloads through
|
|
88
|
+
* the definition's schema.
|
|
89
|
+
*
|
|
90
|
+
* @since 0.3.2
|
|
91
|
+
*/
|
|
92
|
+
export class TestJobStore extends Context.Service<TestJobStore, ReturnType<typeof makeApi>>()(
|
|
93
|
+
"effect-mq/testing/TestJobStore"
|
|
94
|
+
) {}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A fresh in-memory store provided as BOTH the default `JobStore` (for the
|
|
98
|
+
* code under test) and the `TestJobStore` inspection service (for the
|
|
99
|
+
* assertions).
|
|
100
|
+
*
|
|
101
|
+
* @since 0.3.2
|
|
102
|
+
*/
|
|
103
|
+
export const layer: Layer.Layer<JobStore.JobStore | TestJobStore> = Layer.effectContext(
|
|
104
|
+
Effect.map(MemoryJobStore.makeWith(), (store) =>
|
|
105
|
+
Context.make(JobStore.JobStore, store).pipe(
|
|
106
|
+
Context.add(TestJobStore, TestJobStore.of(makeApi(store)))
|
|
107
|
+
))
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Like `layer`, for jobs bound to a `JobStore.named(...)` key.
|
|
112
|
+
*
|
|
113
|
+
* @since 0.3.2
|
|
114
|
+
*/
|
|
115
|
+
export const layerFor = <Id>(
|
|
116
|
+
store: Context.Key<Id, JobStore.Service>
|
|
117
|
+
): Layer.Layer<Id | TestJobStore> =>
|
|
118
|
+
Layer.effectContext(
|
|
119
|
+
Effect.map(MemoryJobStore.makeWith(), (memory) =>
|
|
120
|
+
Context.make(store, memory).pipe(
|
|
121
|
+
Context.add(TestJobStore, TestJobStore.of(makeApi(memory)))
|
|
122
|
+
))
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Convenience accessors so tests don't have to `yield* TestJobStore` first.
|
|
127
|
+
*
|
|
128
|
+
* @since 0.3.2
|
|
129
|
+
*/
|
|
130
|
+
export const enqueuedOf = <J extends AnyJobDefinition>(
|
|
131
|
+
job: J
|
|
132
|
+
): Effect.Effect<Array<EnqueuedJob<J["payloadJsonSchema"]["Type"]>>, never, TestJobStore> =>
|
|
133
|
+
Effect.flatMap(TestJobStore, (api) => api.enqueuedOf(job))
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Raw records (optionally filtered by job name), oldest-first.
|
|
137
|
+
*
|
|
138
|
+
* @since 0.3.2
|
|
139
|
+
*/
|
|
140
|
+
export const enqueued = (
|
|
141
|
+
name?: string
|
|
142
|
+
): Effect.Effect<Array<JobStore.JobRecord>, never, TestJobStore> =>
|
|
143
|
+
Effect.flatMap(TestJobStore, (api) => api.enqueued(name))
|