effect-mq 0.4.2 → 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.
- package/README.md +85 -17
- package/dist/Flow.d.ts +381 -0
- package/dist/Flow.d.ts.map +1 -0
- package/dist/Flow.js +340 -0
- package/dist/Flow.js.map +1 -0
- package/dist/Job.d.ts +37 -6
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +17 -2
- package/dist/Job.js.map +1 -1
- package/dist/JobSchedules.d.ts +112 -0
- package/dist/JobSchedules.d.ts.map +1 -0
- package/dist/JobSchedules.js +106 -0
- package/dist/JobSchedules.js.map +1 -0
- package/dist/JobStore.d.ts +320 -10
- package/dist/JobStore.d.ts.map +1 -1
- package/dist/JobStore.js.map +1 -1
- package/dist/MemoryJobStore.d.ts.map +1 -1
- package/dist/MemoryJobStore.js +336 -8
- package/dist/MemoryJobStore.js.map +1 -1
- package/dist/Metrics.d.ts +31 -0
- package/dist/Metrics.d.ts.map +1 -1
- package/dist/Metrics.js +39 -0
- package/dist/Metrics.js.map +1 -1
- package/dist/Worker.d.ts +120 -11
- package/dist/Worker.d.ts.map +1 -1
- package/dist/Worker.js +452 -26
- package/dist/Worker.js.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts +19 -1
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.js +662 -81
- package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
- package/dist/drizzle-postgres/schema.d.ts +310 -3
- package/dist/drizzle-postgres/schema.d.ts.map +1 -1
- package/dist/drizzle-postgres/schema.js +68 -1
- package/dist/drizzle-postgres/schema.js.map +1 -1
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -1
- package/dist/redis/RedisJobStore.d.ts.map +1 -1
- package/dist/redis/RedisJobStore.js +221 -19
- package/dist/redis/RedisJobStore.js.map +1 -1
- package/dist/redis/scripts.d.ts +118 -11
- package/dist/redis/scripts.d.ts.map +1 -1
- package/dist/redis/scripts.js +497 -28
- package/dist/redis/scripts.js.map +1 -1
- package/dist/testing/conformance.d.ts +6 -0
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +765 -1
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +1 -1
- package/src/Flow.ts +778 -0
- package/src/Job.ts +42 -11
- package/src/JobSchedules.ts +223 -0
- package/src/JobStore.ts +347 -9
- package/src/MemoryJobStore.ts +372 -8
- package/src/Metrics.ts +43 -0
- package/src/Worker.ts +726 -37
- package/src/drizzle-postgres/DrizzleJobStore.ts +827 -82
- package/src/drizzle-postgres/schema.ts +94 -0
- package/src/index.ts +16 -0
- package/src/redis/RedisJobStore.ts +291 -8
- package/src/redis/scripts.ts +529 -26
- package/src/testing/conformance.ts +989 -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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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)
|
|
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
|
-
|
|
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*
|
|
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*
|
|
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
|
|
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
|
|
478
|
-
|
|
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
|
});
|