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/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))
@@ -5,3 +5,10 @@
5
5
  * @since 0.1.0
6
6
  */
7
7
  export * from "./conformance.ts"
8
+
9
+ /**
10
+ * Assert what services enqueue in unit tests, with typed payloads.
11
+ *
12
+ * @since 0.3.2
13
+ */
14
+ export * as TestJobStore from "./TestJobStore.ts"