effect-mq 0.5.0 → 0.6.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 (59) hide show
  1. package/README.md +85 -17
  2. package/dist/Flow.d.ts +381 -0
  3. package/dist/Flow.d.ts.map +1 -0
  4. package/dist/Flow.js +340 -0
  5. package/dist/Flow.js.map +1 -0
  6. package/dist/Job.d.ts +31 -6
  7. package/dist/Job.d.ts.map +1 -1
  8. package/dist/Job.js +16 -2
  9. package/dist/Job.js.map +1 -1
  10. package/dist/JobStore.d.ts +312 -10
  11. package/dist/JobStore.d.ts.map +1 -1
  12. package/dist/JobStore.js.map +1 -1
  13. package/dist/MemoryJobStore.d.ts.map +1 -1
  14. package/dist/MemoryJobStore.js +334 -7
  15. package/dist/MemoryJobStore.js.map +1 -1
  16. package/dist/Metrics.d.ts +31 -0
  17. package/dist/Metrics.d.ts.map +1 -1
  18. package/dist/Metrics.js +39 -0
  19. package/dist/Metrics.js.map +1 -1
  20. package/dist/Worker.d.ts +120 -11
  21. package/dist/Worker.d.ts.map +1 -1
  22. package/dist/Worker.js +452 -26
  23. package/dist/Worker.js.map +1 -1
  24. package/dist/drizzle-postgres/DrizzleJobStore.d.ts +19 -1
  25. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  26. package/dist/drizzle-postgres/DrizzleJobStore.js +653 -77
  27. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  28. package/dist/drizzle-postgres/schema.d.ts +293 -3
  29. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  30. package/dist/drizzle-postgres/schema.js +66 -1
  31. package/dist/drizzle-postgres/schema.js.map +1 -1
  32. package/dist/index.d.ts +7 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +7 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  37. package/dist/redis/RedisJobStore.js +219 -18
  38. package/dist/redis/RedisJobStore.js.map +1 -1
  39. package/dist/redis/scripts.d.ts +117 -10
  40. package/dist/redis/scripts.d.ts.map +1 -1
  41. package/dist/redis/scripts.js +492 -25
  42. package/dist/redis/scripts.js.map +1 -1
  43. package/dist/testing/conformance.d.ts +6 -0
  44. package/dist/testing/conformance.d.ts.map +1 -1
  45. package/dist/testing/conformance.js +728 -1
  46. package/dist/testing/conformance.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/Flow.ts +778 -0
  49. package/src/Job.ts +35 -11
  50. package/src/JobStore.ts +339 -9
  51. package/src/MemoryJobStore.ts +370 -7
  52. package/src/Metrics.ts +43 -0
  53. package/src/Worker.ts +726 -37
  54. package/src/drizzle-postgres/DrizzleJobStore.ts +817 -78
  55. package/src/drizzle-postgres/schema.ts +92 -0
  56. package/src/index.ts +8 -0
  57. package/src/redis/RedisJobStore.ts +289 -8
  58. package/src/redis/scripts.ts +524 -24
  59. package/src/testing/conformance.ts +945 -1
package/dist/Worker.js CHANGED
@@ -8,20 +8,58 @@
8
8
  *
9
9
  * claim -> decode payload -> run handler -> ack (complete | retry | fail)
10
10
  *
11
- * plus two maintenance fibers: lock renewal for in-flight jobs, and stalled
12
- * job recovery. On scope close, in-flight handlers are interrupted and their
13
- * jobs released back to `waiting` without consuming an attempt.
11
+ * plus maintenance fibers: lock renewal for in-flight jobs, stalled job
12
+ * recovery, schedule sweeping — and, once flows are registered, the outbox
13
+ * relay and flow sweeper. On scope close, in-flight handlers are interrupted
14
+ * and their jobs released back to `waiting` without consuming an attempt.
14
15
  *
15
16
  * @since 0.1.0
16
17
  */
17
18
  import { Cause, Clock, Context, Deferred, Duration, Effect, Exit, Fiber, FiberSet, Layer, Metric, Option, Result, Schedule, Schema, Scope as Scope_, Tracer } from "effect";
18
19
  import { isJobStoreError, isMarkedUnrecoverable, JobId, JobStore, nextOccurrence, QueueName } from "./JobStore.js";
19
20
  import * as Metrics from "./Metrics.js";
21
+ /**
22
+ * The currently running attempt, provided by the worker around every
23
+ * handler run (plain jobs and both flow phases). Declaring it as a
24
+ * requirement makes "this code only runs inside a job" a compile-time
25
+ * fact — `toLayer` subtracts it, since the worker supplies it per run:
26
+ *
27
+ * ```ts
28
+ * const notify = Effect.gen(function*() {
29
+ * const { jobId, attempt } = yield* Worker.CurrentJob
30
+ * // ... use them however deep in the call graph this runs
31
+ * })
32
+ * ```
33
+ *
34
+ * Outside a worker (unit tests calling such code directly), provide a
35
+ * value with `Effect.provideService(Worker.CurrentJob, {...})`.
36
+ *
37
+ * @since 0.6.0
38
+ */
39
+ export class CurrentJob extends Context.Service()("effect-mq/Worker/CurrentJob") {
40
+ }
20
41
  /**
21
42
  * @since 0.1.0
22
43
  */
23
44
  export class Worker extends Context.Service()("effect-mq/Worker") {
24
45
  }
46
+ // A `retryable` predicate lifted to a cause classifier (false ⇒ skip the
47
+ // remaining retry budget).
48
+ const toUnrecoverableFailure = (retryable) => retryable === undefined ? undefined : (cause) => {
49
+ const failure = Cause.findErrorOption(cause);
50
+ if (Option.isNone(failure))
51
+ return false;
52
+ try {
53
+ // SAFETY: the only typed failures a handler can produce are its
54
+ // declared error type, which is what `retryable` accepts.
55
+ return !retryable(failure.value);
56
+ }
57
+ catch {
58
+ // A throwing predicate must never leave the job un-acked: treat the
59
+ // failure as retryable and let the budget decide.
60
+ return false;
61
+ }
62
+ };
25
63
  // A cause is unrecoverable when its error or defect was marked via
26
64
  // `Job.unrecoverable` (identity-based, so typed channels stay untouched).
27
65
  const causeIsMarkedUnrecoverable = (cause) => {
@@ -53,8 +91,8 @@ const storeRetryPolicy = Schedule.min([
53
91
  Schedule.spaced("30 seconds")
54
92
  ]).pipe(Schedule.upTo({ times: 8 }));
55
93
  /**
56
- * Build the worker service. Requires a `Scope` (fibers live in it) and the
57
- * `JobStore`.
94
+ * Build the worker service. Requires a `Scope` (fibers live in it), the
95
+ * `JobStore`, and the parent store of every flow in `flows`.
58
96
  *
59
97
  * @since 0.1.0
60
98
  */
@@ -75,6 +113,7 @@ export const make = (options) => Effect.gen(function* () {
75
113
  : Math.max(1, Math.floor(lockDurationMs / 2));
76
114
  const stalledMs = Duration.toMillis(options?.stalledInterval ?? 30_000);
77
115
  const scheduleSweepMs = Duration.toMillis(options?.scheduleSweepInterval ?? 15_000);
116
+ const flowSweepMs = Duration.toMillis(options?.flowSweepInterval ?? 30_000);
78
117
  const maxStalledCount = options?.maxStalledCount ?? 1;
79
118
  const pollMs = Duration.toMillis(options?.pollInterval ?? 5_000);
80
119
  const workerId = options?.id ?? `worker-${Math.random().toString(36).slice(2, 10)}`;
@@ -85,6 +124,23 @@ export const make = (options) => Effect.gen(function* () {
85
124
  // Jobs this worker is interrupting because of a cancel request; their
86
125
  // interrupt-only exits ack as Cancelled instead of shutdown-release.
87
126
  const cancelling = new Set();
127
+ // Flow plumbing. `storesByKey` maps store-key strings to resolved
128
+ // services — this worker's own store, every `flows` entry's parent
129
+ // store, and each registered flow's child stores (a registered flow's
130
+ // parent store IS the worker's own store). The relay
131
+ // routes outbox entries by `parentStoreKey` through it, and the sweeper
132
+ // and post-fan-out enqueue resolve child stores through it.
133
+ // `flowPolicies` carries each known flow's fail-fast bit for settle
134
+ // logging.
135
+ const storesByKey = new Map();
136
+ const flowPolicies = new Map();
137
+ let flowSweeperStarted = false;
138
+ let relayStarted = false;
139
+ storesByKey.set(storeKey.key, store);
140
+ for (const flow of options?.flows ?? []) {
141
+ storesByKey.set(flow.parent.store.key, yield* flow.parent.store);
142
+ flowPolicies.set(flow.name, { failFast: flow.failFast });
143
+ }
88
144
  let tokenCounter = 0;
89
145
  const nextToken = () => `${workerId}:${++tokenCounter}`;
90
146
  // Local wake-up for registration changes. Versioned like the store's
@@ -110,6 +166,13 @@ export const make = (options) => Effect.gen(function* () {
110
166
  while: (error) => isJobStoreError(error),
111
167
  schedule: storeRetryPolicy
112
168
  }), Effect.catch((error) => Effect.logWarning(`effect-mq: ${what} dropped (${error._tag})`, error)));
169
+ // Like ackSafely, but says whether the ack definitely landed — flow
170
+ // reports and post-fan-out child enqueues must only follow an ack this
171
+ // worker actually won.
172
+ const ackLanded = (effect, what) => effect.pipe(Effect.retry({
173
+ while: (error) => isJobStoreError(error),
174
+ schedule: storeRetryPolicy
175
+ }), Effect.as(true), Effect.catch((error) => Effect.logWarning(`effect-mq: ${what} dropped (${error._tag})`, error).pipe(Effect.as(false))));
113
176
  // Every failed run is logged (warning while retries remain, error once
114
177
  // terminal) and handed to the onJobFailure hook. The hook runs isolated:
115
178
  // whatever it does, job processing proceeds.
@@ -128,6 +191,99 @@ export const make = (options) => Effect.gen(function* () {
128
191
  yield* hook(failure).pipe(Effect.catchCause((cause) => Effect.logError("effect-mq: onJobFailure hook failed", cause)));
129
192
  }
130
193
  });
194
+ // The outbox relay. Child results land in this store's outbox
195
+ // atomically with each terminal transition; the relay drains them into
196
+ // their parent stores in batches and deletes what it delivered.
197
+ // Redelivery after a crash is safe (dependency rows dedup), so there
198
+ // are no leases — peek, deliver, delete. A terminal ack nudges the
199
+ // relay so results push immediately; the periodic pass is the fallback.
200
+ let relayPulseVersion = 0;
201
+ let relayPulse = Deferred.makeUnsafe();
202
+ const fireRelayPulse = Effect.suspend(() => {
203
+ relayPulseVersion += 1;
204
+ const current = relayPulse;
205
+ relayPulse = Deferred.makeUnsafe();
206
+ return Deferred.succeed(current, void 0);
207
+ });
208
+ const awaitRelayPulse = (observed) => Effect.suspend(() => relayPulseVersion > observed ? Effect.void : Deferred.await(relayPulse));
209
+ const relayDrain = Effect.gen(function* () {
210
+ const pageSize = 500;
211
+ // Cursor-walk the whole outbox: `after` pages past everything already
212
+ // seen this drain, so entries this worker cannot route — or could not
213
+ // deliver just now — never blockade the ones behind them. Each drain
214
+ // starts from the head again, which is what retries them.
215
+ let after;
216
+ while (true) {
217
+ const entries = yield* retryStore(store.peekOutbox({ limit: pageSize, after }));
218
+ if (entries.length === 0)
219
+ return;
220
+ after = entries[entries.length - 1]?.id;
221
+ const byStore = new Map();
222
+ let skipped = 0;
223
+ for (const entry of entries) {
224
+ if (!storesByKey.has(entry.parentStoreKey)) {
225
+ // No route from this worker; a worker with the right `flows`
226
+ // registration relays it, and reconciliation keeps the flow
227
+ // correct regardless.
228
+ skipped += 1;
229
+ continue;
230
+ }
231
+ let group = byStore.get(entry.parentStoreKey);
232
+ if (group === undefined) {
233
+ group = [];
234
+ byStore.set(entry.parentStoreKey, group);
235
+ }
236
+ group.push(entry);
237
+ }
238
+ if (skipped > 0) {
239
+ yield* Metric.update(Metrics.flowOutboxSkipped, skipped);
240
+ }
241
+ for (const [key, group] of byStore) {
242
+ const target = storesByKey.get(key);
243
+ if (target === undefined)
244
+ continue;
245
+ // One unreachable parent store must not block deliveries to the
246
+ // others: log, leave the group's entries for the next pass, move
247
+ // on.
248
+ yield* Effect.gen(function* () {
249
+ const results = yield* retryStore(target.recordChildResults(group.map((entry) => entry.report)));
250
+ for (const [index, result] of results.entries()) {
251
+ const entry = group[index];
252
+ if (entry === undefined)
253
+ continue;
254
+ if (result.applied) {
255
+ yield* Metric.update(Metrics.flowChildReports.pipe(Metric.withAttributes({
256
+ flow: entry.flowName,
257
+ outcome: entry.report.outcome,
258
+ source: "report"
259
+ })), 1);
260
+ }
261
+ if (result.parentSettled && entry.report.outcome === "failed" &&
262
+ flowPolicies.get(entry.flowName)?.failFast === true) {
263
+ yield* Effect.logError(`effect-mq: flow "${entry.flowName}" (${entry.report.flowId}) settled failed-fast on child "${entry.report.childKey}"`);
264
+ }
265
+ }
266
+ yield* retryStore(store.deleteOutbox(group.map((entry) => entry.id)));
267
+ }).pipe(Effect.catchCause((cause) => Effect.logWarning(`effect-mq: outbox relay could not deliver to store "${key}"; retrying next pass`, cause)));
268
+ }
269
+ if (entries.length < pageSize)
270
+ return;
271
+ }
272
+ });
273
+ const relayLoop = Effect.gen(function* () {
274
+ const observed = relayPulseVersion;
275
+ // Failures are contained per drain so the race below always paces the
276
+ // loop (no hot retry); a pulse fired DURING the drain re-runs it
277
+ // immediately via the version check.
278
+ yield* relayDrain.pipe(Effect.catchCause((cause) => Effect.logError("effect-mq: outbox relay drain failed", cause)));
279
+ yield* Effect.race(awaitRelayPulse(observed), Effect.sleep(flowSweepMs));
280
+ }).pipe(Effect.forever);
281
+ const ensureRelay = Effect.suspend(() => {
282
+ if (relayStarted)
283
+ return Effect.void;
284
+ relayStarted = true;
285
+ return FiberSet.run(fibers, relayLoop.pipe(Effect.updateContext(() => workerContext)));
286
+ });
131
287
  const routeFailure = (record, exit) => {
132
288
  const attempt = record.attemptsMade + 1;
133
289
  if (attempt >= record.attemptsMax) {
@@ -158,6 +314,33 @@ export const make = (options) => Effect.gen(function* () {
158
314
  yield* Metric.update(Metrics.jobRuns.pipe(Metric.withAttributes(attributes)), 1);
159
315
  yield* Metric.update(Metrics.jobRunDuration.pipe(Metric.withAttributes(attributes)), Math.max(0, finished - (record.processedAt ?? finished)));
160
316
  });
317
+ if (record.flow !== undefined && entry.flow === undefined) {
318
+ // A fanned-out flow parent claimed by a PLAIN handler registration
319
+ // (the flow was re-registered as an ordinary job in a deploy):
320
+ // running the plain handler would silently ack its result as the
321
+ // parent's terminal exit and discard the recorded child results.
322
+ // Fail visibly instead; once the deploy is fixed, an admin retry
323
+ // re-enters collect with the manifest intact.
324
+ return Effect.gen(function* () {
325
+ const cause = Cause.die(new Error(`effect-mq: job "${record.name}" carries flow state (collect phase) but is registered as a plain handler; ` +
326
+ `register its flow via Flow.toLayer instead of Job.toLayer`));
327
+ const encoded = yield* Effect.exit(entry.encodeExit(Exit.failCause(cause)));
328
+ yield* ackSafely(store.ack(record.id, token, {
329
+ _tag: "Fail",
330
+ exit: Exit.isSuccess(encoded) ? encoded.value : undefined
331
+ }), "ack");
332
+ yield* reportFailure({
333
+ jobId: record.id,
334
+ name: record.name,
335
+ queue: record.queue,
336
+ attempt: context.attempt,
337
+ attemptsMax: record.attemptsMax,
338
+ willRetry: false,
339
+ cause
340
+ }, undefined);
341
+ yield* recordRun("failed");
342
+ });
343
+ }
161
344
  return Effect.gen(function* () {
162
345
  // The per-run time limit interrupts the handler internally; surface
163
346
  // it as a defect so it flows through normal retry accounting.
@@ -178,7 +361,24 @@ export const make = (options) => Effect.gen(function* () {
178
361
  spanId: record.trace.spanId,
179
362
  sampled: record.trace.sampled
180
363
  });
181
- const withRunSpan = entry.run(record.payload, context).pipe(Effect.withSpan(options?.handlerSpanName?.(context) ?? `${record.name}.run`, {
364
+ // Flow-parent phase dispatch is persisted, not inferred: no `flow`
365
+ // bookkeeping on the record means the manifest never landed (run
366
+ // `fanOut`); its presence means a resumed parent (run `collect`,
367
+ // stored in `entry.run`) — a re-claimed parent can never fan out
368
+ // twice.
369
+ const flow = entry.flow;
370
+ const isFanOut = flow !== undefined && record.flow === undefined;
371
+ const runEffect = (isFanOut
372
+ ? flow.fanOut(record.payload, context, record.parent?.depth ?? 0)
373
+ : entry.run(record.payload, context, record.flow)).pipe(
374
+ // Every log line a handler writes carries the job identity —
375
+ // log-based alerting needs no per-handler setup.
376
+ Effect.annotateLogs({
377
+ effectMqJobId: record.id,
378
+ effectMqQueue: record.queue,
379
+ effectMqAttempt: context.attempt
380
+ }));
381
+ const withRunSpan = runEffect.pipe(Effect.withSpan(options?.handlerSpanName?.(context) ?? `${record.name}.run`, {
182
382
  attributes: {
183
383
  effectMqJobId: record.id,
184
384
  effectMqQueue: record.queue,
@@ -211,7 +411,12 @@ export const make = (options) => Effect.gen(function* () {
211
411
  // A cancel-request interrupt is terminal: ack Cancelled (even if a
212
412
  // shutdown races it — cancellation wins, the job must not revive).
213
413
  if (wasCancelled && Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) {
214
- yield* ackSafely(store.ack(record.id, token, { _tag: "Cancelled" }), "ack");
414
+ const landed = yield* ackLanded(store.ack(record.id, token, { _tag: "Cancelled" }), "ack");
415
+ if (landed && record.parent !== undefined) {
416
+ // The ack appended this child's report to the outbox; drain now
417
+ // instead of waiting for the periodic pass.
418
+ yield* fireRelayPulse;
419
+ }
215
420
  return yield* recordRun("cancelled");
216
421
  }
217
422
  // Distinguish worker shutdown from a handler that interrupted
@@ -232,6 +437,42 @@ export const make = (options) => Effect.gen(function* () {
232
437
  const effective = Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
233
438
  ? Exit.die(new Error(`effect-mq: handler for job "${record.name}" interrupted itself`))
234
439
  : exit;
440
+ if (isFanOut && Exit.isSuccess(effective)) {
441
+ // SAFETY: the fan-out runner's success type is the built specs.
442
+ const children = effective.value;
443
+ const acked = yield* Effect.exit(store.ack(record.id, token, {
444
+ _tag: "FanOut",
445
+ failFast: flow.failFast,
446
+ children
447
+ }).pipe(Effect.retry({
448
+ while: (error) => isJobStoreError(error),
449
+ schedule: storeRetryPolicy
450
+ })));
451
+ if (Exit.isSuccess(acked)) {
452
+ yield* Metric.update(Metrics.flowFanOuts.pipe(Metric.withAttributes({ flow: flow.flowName })), 1);
453
+ // The ack may have settled the parent instead of parking it (a
454
+ // cancel raced the fan-out: cancellation wins and the rows are
455
+ // already marked for cascade) — children of a settled flow must
456
+ // not start. A settle that lands AFTER this check still
457
+ // converges: the marked rows make the sweeper cancel whatever
458
+ // we enqueue below.
459
+ const parked = children.length === 0 ? Option.none() : yield* retryStore(store.getJob(record.id));
460
+ if (Option.isSome(parked) && parked.value.state === "waiting-children") {
461
+ // Fast path only: the flow sweeper re-drives whatever a
462
+ // crash here misses, straight from the persisted specs.
463
+ yield* flow.enqueueChildren(children);
464
+ }
465
+ else if (children.length > 0) {
466
+ yield* Effect.logInfo(`effect-mq: flow "${flow.flowName}" (${record.id}) settled during fan-out; children not enqueued`);
467
+ }
468
+ }
469
+ else {
470
+ // Lock lost or job vanished: the manifest did NOT land, so the
471
+ // children must not run — another worker re-runs fanOut.
472
+ yield* Effect.logWarning(`effect-mq: FanOut ack dropped for flow "${flow.flowName}" (${record.id})`, acked.cause);
473
+ }
474
+ return yield* recordRun("fanned-out");
475
+ }
235
476
  // Never let an encode defect escape: the job would be stuck active.
236
477
  let encoded = yield* Effect.exit(entry.encodeExit(effective));
237
478
  let encodable = true;
@@ -252,7 +493,13 @@ export const make = (options) => Effect.gen(function* () {
252
493
  : unrecoverable
253
494
  ? { _tag: "Fail", exit: exitValue }
254
495
  : routeFailure(record, exitValue);
255
- yield* ackSafely(store.ack(record.id, token, outcome), "ack");
496
+ const landed = yield* ackLanded(store.ack(record.id, token, outcome), "ack");
497
+ if (landed && record.parent !== undefined &&
498
+ (outcome._tag === "Complete" || outcome._tag === "Fail")) {
499
+ // The ack appended this child's report to the outbox; drain now
500
+ // instead of waiting for the periodic pass.
501
+ yield* fireRelayPulse;
502
+ }
256
503
  if (outcome._tag === "Fail" || outcome._tag === "Retry") {
257
504
  const cause = Exit.isFailure(effective)
258
505
  ? effective.cause
@@ -414,6 +661,7 @@ export const make = (options) => Effect.gen(function* () {
414
661
  timeoutMs: schedule.timeoutMs,
415
662
  dedupe: undefined,
416
663
  trace: undefined,
664
+ parent: undefined,
417
665
  delayMs: 0
418
666
  }));
419
667
  if (fired) {
@@ -429,9 +677,111 @@ export const make = (options) => Effect.gen(function* () {
429
677
  yield* sweepSchedule(schedule).pipe(Effect.catchCause((cause) => Effect.logError(`effect-mq: sweep of schedule "${schedule.key}" failed`, cause)));
430
678
  }
431
679
  }).pipe(Effect.catchCause((cause) => Effect.logError("effect-mq: schedule sweep failed", cause)), Effect.forever);
680
+ // The flow reconciliation engine (see `FlowSweepWork`). Every action is
681
+ // idempotent by construction: enqueues dedup on the deterministic child
682
+ // id, reports dedup on the dependency row's state, cancels dedup on the
683
+ // child's state, and the cascaded flag dedups its own write — so crashes
684
+ // anywhere in the sweep are safe.
685
+ const reconcileChild = (flowId, child) => Effect.gen(function* () {
686
+ const childStore = storesByKey.get(child.storeKey);
687
+ if (childStore === undefined) {
688
+ // A flow registered on another worker shares this parent store;
689
+ // that worker's sweeper holds the child store layer.
690
+ return;
691
+ }
692
+ const id = child.request.id;
693
+ if (id === undefined)
694
+ return;
695
+ const existing = yield* retryStore(childStore.getJob(id));
696
+ if (Option.isNone(existing)) {
697
+ // Never landed (crash between the FanOut ack and the enqueue), or
698
+ // pruned before reporting (see the child-retention guidance) —
699
+ // (re-)drive it from the persisted spec. The flow may have settled
700
+ // since this work item was snapshotted (another sweeper's cascade
701
+ // would then have found this child missing, marked its row
702
+ // cascaded, and moved on), so re-check the parent around the
703
+ // enqueue: before, to skip settled flows, and after, so a settle
704
+ // that lands inside the window cannot leave an orphan running.
705
+ const before = yield* retryStore(store.getJob(flowId));
706
+ if (Option.isNone(before) || before.value.state !== "waiting-children")
707
+ return;
708
+ yield* retryStore(childStore.enqueue(child.request));
709
+ const after = yield* retryStore(store.getJob(flowId));
710
+ if (Option.isNone(after) || after.value.state !== "waiting-children") {
711
+ yield* retryStore(childStore.cancel(id).pipe(Effect.catchTag(["JobNotFoundError", "JobNotCancellableError"], () => Effect.void)));
712
+ }
713
+ return;
714
+ }
715
+ const state = existing.value.state;
716
+ if (state !== "completed" && state !== "failed" && state !== "cancelled") {
717
+ // Still in flight: never blindly re-drive a live child.
718
+ return;
719
+ }
720
+ // Terminal without a delivered report (a dropped outbox entry, or a
721
+ // child whose store has no relaying worker): synthesize the report
722
+ // from the child store's own record.
723
+ const results = yield* retryStore(store.recordChildResults([{
724
+ flowId,
725
+ childKey: child.childKey,
726
+ outcome: state,
727
+ exit: existing.value.exit,
728
+ failedReason: existing.value.failedReason
729
+ }]));
730
+ const result = results[0];
731
+ if (result?.applied === true) {
732
+ yield* Metric.update(Metrics.flowChildReports.pipe(Metric.withAttributes({
733
+ flow: child.request.parent?.flowName ?? "unknown",
734
+ outcome: state,
735
+ source: "reconcile"
736
+ })), 1);
737
+ if (result?.parentSettled === true && state === "failed") {
738
+ yield* Effect.logError(`effect-mq: flow ${flowId} settled on reconciled child failure "${child.childKey}"`);
739
+ }
740
+ }
741
+ });
742
+ const cascadeChildren = (flowId, children) => Effect.gen(function* () {
743
+ const done = [];
744
+ for (const child of children) {
745
+ const childStore = storesByKey.get(child.storeKey);
746
+ if (childStore === undefined)
747
+ continue;
748
+ // Idempotent: a vanished or already-terminal child is "cancelled
749
+ // enough".
750
+ yield* retryStore(childStore.cancel(child.childJobId).pipe(Effect.catchTag(["JobNotFoundError", "JobNotCancellableError"], () => Effect.void)));
751
+ done.push(child.childKey);
752
+ }
753
+ if (done.length > 0) {
754
+ yield* retryStore(store.markChildrenCascaded(flowId, done));
755
+ yield* Metric.update(Metrics.flowCascades, done.length);
756
+ }
757
+ });
758
+ const flowSweepLoop = Effect.gen(function* () {
759
+ yield* Effect.sleep(flowSweepMs);
760
+ const work = yield* retryStore(store.flowSweepWork({ pendingAgeMs: flowSweepMs, limit: 512 }));
761
+ for (const group of work.reconcile) {
762
+ for (const child of group.children) {
763
+ // Each child in isolation: one poison row cannot starve the sweep.
764
+ yield* reconcileChild(group.flowId, child).pipe(Effect.catchCause((cause) => Effect.logError(`effect-mq: flow reconcile failed for child "${child.childKey}" of ${group.flowId}`, cause)));
765
+ }
766
+ }
767
+ for (const group of work.cascade) {
768
+ yield* cascadeChildren(group.flowId, group.children).pipe(Effect.catchCause((cause) => Effect.logError(`effect-mq: flow cascade failed for ${group.flowId}`, cause)));
769
+ }
770
+ }).pipe(Effect.catchCause((cause) => Effect.logError("effect-mq: flow sweep failed", cause)), Effect.forever);
771
+ // Started lazily by the first flow registration — plain workers never
772
+ // pay for a sweep query.
773
+ const ensureFlowSweeper = Effect.suspend(() => {
774
+ if (flowSweeperStarted)
775
+ return Effect.void;
776
+ flowSweeperStarted = true;
777
+ return FiberSet.run(fibers, flowSweepLoop.pipe(Effect.updateContext(() => workerContext)));
778
+ });
432
779
  yield* FiberSet.run(fibers, renewalLoop);
433
780
  yield* FiberSet.run(fibers, stalledLoop);
434
781
  yield* FiberSet.run(fibers, scheduleLoop);
782
+ if (flowPolicies.size > 0) {
783
+ yield* ensureRelay;
784
+ }
435
785
  if (options?.queueMetricsInterval !== undefined) {
436
786
  const sampleMs = Duration.toMillis(options.queueMetricsInterval);
437
787
  const depthLoop = Effect.gen(function* () {
@@ -462,7 +812,7 @@ export const make = (options) => Effect.gen(function* () {
462
812
  // Everything the handler and codecs require was provided to the
463
813
  // registration layer; capture it, minus runtime-ambient keys that
464
814
  // must always come from the executing fiber.
465
- const services = (yield* Effect.context()).pipe(Context.omit(Scope_.Scope, Tracer.ParentSpan));
815
+ const services = (yield* Effect.context()).pipe(Context.omit(Scope_.Scope, Tracer.ParentSpan, CurrentJob));
466
816
  const decodePayload = Schema.decodeUnknownEffect(job.payloadJsonSchema);
467
817
  const encodeExit = Schema.encodeEffect(job.exitSchema);
468
818
  // SAFETY: per `register`'s public signature the captured context
@@ -470,25 +820,14 @@ export const make = (options) => Effect.gen(function* () {
470
820
  // context (captured wins on conflicts, so locally provided services
471
821
  // are not shadowed by the worker's) restores those requirements.
472
822
  const provideCaptured = (effect) => effect.pipe(Effect.updateContext((input) => Context.merge(input, services)));
473
- const retryable = job.retryable;
474
823
  const entry = {
475
- run: (payload, context) => provideCaptured(decodePayload(payload).pipe(Effect.orDie, Effect.flatMap((decoded) => handler(decoded, context)))),
824
+ run: (payload, context) => provideCaptured(decodePayload(payload).pipe(Effect.orDie, Effect.flatMap((decoded) => handler(decoded)),
825
+ // Innermost, so neither the captured registration context
826
+ // nor the worker's own can shadow the running attempt.
827
+ Effect.provideService(CurrentJob, context))),
476
828
  encodeExit: (exit) => provideCaptured(encodeExit(exit)),
477
- unrecoverableFailure: retryable === undefined ? undefined : (cause) => {
478
- const failure = Cause.findErrorOption(cause);
479
- if (Option.isNone(failure))
480
- return false;
481
- try {
482
- // SAFETY: the only typed failures a handler can produce are
483
- // its declared error type, which is what `retryable` accepts.
484
- return !retryable(failure.value);
485
- }
486
- catch {
487
- // A throwing predicate must never leave the job un-acked:
488
- // treat the failure as retryable and let the budget decide.
489
- return false;
490
- }
491
- }
829
+ unrecoverableFailure: toUnrecoverableFailure(job.retryable),
830
+ flow: undefined
492
831
  };
493
832
  handlers.set(name, entry);
494
833
  const queue = registerOptions?.queue !== undefined
@@ -506,6 +845,93 @@ export const make = (options) => Effect.gen(function* () {
506
845
  }));
507
846
  yield* ensureQueueLoop(queue, registerOptions?.concurrency);
508
847
  yield* firePulse;
848
+ }),
849
+ registerFlow: (flow, registerOptions) => Effect.gen(function* () {
850
+ const name = flow.parent._tag;
851
+ if (handlers.has(name)) {
852
+ return yield* Effect.die(new Error(`effect-mq: duplicate handler registered for job "${name}"`));
853
+ }
854
+ if (flow.parent.store.key !== storeKey.key) {
855
+ return yield* Effect.die(new Error(`effect-mq: flow "${flow.name}" parent "${name}" is bound to store "${flow.parent.store.key}" but this worker claims from "${storeKey.key}". ` +
856
+ `Provide a Worker.layer({ store }) for the parent's store.`));
857
+ }
858
+ const services = (yield* Effect.context()).pipe(Context.omit(Scope_.Scope, Tracer.ParentSpan, CurrentJob));
859
+ // Resolve every child store from the registration context (declared
860
+ // on Flow.toLayer's signature) — this worker is the one process
861
+ // guaranteed able to reconcile and cascade across all of them.
862
+ for (const key of flow.childStores) {
863
+ const service = Context.getOption(services, key);
864
+ if (Option.isNone(service)) {
865
+ return yield* Effect.die(new Error(`effect-mq: flow "${flow.name}" requires child store "${key.key}" — provide it to the flow's layer`));
866
+ }
867
+ storesByKey.set(key.key, service.value);
868
+ }
869
+ flowPolicies.set(flow.name, { failFast: flow.failFast });
870
+ const decodePayload = Schema.decodeUnknownEffect(flow.parent.payloadJsonSchema);
871
+ const encodeExit = Schema.encodeEffect(flow.parent.exitSchema);
872
+ // SAFETY: same contract as `register` — the captured context holds
873
+ // everything Flow.toLayer's signature required.
874
+ const provideCaptured = (effect) => effect.pipe(Effect.updateContext((input) => Context.merge(input, services)));
875
+ const emptyFlow = {
876
+ failFast: flow.failFast,
877
+ pending: 0,
878
+ completed: 0,
879
+ failed: 0,
880
+ cancelled: 0
881
+ };
882
+ const entry = {
883
+ run: (payload, context, flowState) => provideCaptured(decodePayload(payload).pipe(Effect.orDie, Effect.flatMap((decoded) =>
884
+ // collect dispatch requires a persisted manifest, so the
885
+ // fallback is defensive only.
886
+ flow.collect(decoded, flowState ?? emptyFlow, context)), Effect.provideService(CurrentJob, context))),
887
+ encodeExit: (exit) => provideCaptured(encodeExit(exit)),
888
+ unrecoverableFailure: toUnrecoverableFailure(flow.parent.retryable),
889
+ flow: {
890
+ flowName: flow.name,
891
+ failFast: flow.failFast,
892
+ fanOut: (payload, context, parentDepth) => provideCaptured(decodePayload(payload).pipe(Effect.orDie, Effect.flatMap((decoded) => flow.fanOut(decoded, context, parentDepth)), Effect.provideService(CurrentJob, context))),
893
+ enqueueChildren: (children) => Effect.gen(function* () {
894
+ // Group per child store; enqueueMany chunks internally and
895
+ // dedups on the deterministic ids.
896
+ const byStore = new Map();
897
+ for (const child of children) {
898
+ let group = byStore.get(child.storeKey);
899
+ if (group === undefined) {
900
+ group = [];
901
+ byStore.set(child.storeKey, group);
902
+ }
903
+ group.push(child.request);
904
+ }
905
+ for (const [key, requests] of byStore) {
906
+ const childStore = storesByKey.get(key);
907
+ if (childStore === undefined)
908
+ continue;
909
+ yield* retryStore(childStore.enqueueMany(requests)).pipe(Effect.catchCause((cause) => Effect.logWarning(`effect-mq: flow "${flow.name}" child enqueue incomplete; the flow sweeper will reconcile`, cause)));
910
+ }
911
+ })
912
+ }
913
+ };
914
+ handlers.set(name, entry);
915
+ const queue = registerOptions?.queue !== undefined
916
+ ? QueueName(registerOptions.queue)
917
+ : flow.parent.queue;
918
+ let names = queueNames.get(queue);
919
+ if (names === undefined) {
920
+ names = new Set();
921
+ queueNames.set(queue, names);
922
+ }
923
+ names.add(name);
924
+ yield* Effect.addFinalizer(() => Effect.sync(() => {
925
+ handlers.delete(name);
926
+ queueNames.get(queue)?.delete(name);
927
+ }));
928
+ yield* ensureQueueLoop(queue, registerOptions?.concurrency);
929
+ yield* ensureFlowSweeper;
930
+ yield* ensureRelay;
931
+ yield* firePulse;
932
+ // SAFETY: like `register`, the implementation erases requirements
933
+ // that Flow.toLayer's public signature declares and the captured
934
+ // context (via provideCaptured) restores; only Scope remains.
509
935
  })
510
936
  });
511
937
  });