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.
- 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 +31 -6
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +16 -2
- package/dist/Job.js.map +1 -1
- package/dist/JobStore.d.ts +312 -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 +334 -7
- 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 +653 -77
- package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
- package/dist/drizzle-postgres/schema.d.ts +293 -3
- package/dist/drizzle-postgres/schema.d.ts.map +1 -1
- package/dist/drizzle-postgres/schema.js +66 -1
- package/dist/drizzle-postgres/schema.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/redis/RedisJobStore.d.ts.map +1 -1
- package/dist/redis/RedisJobStore.js +219 -18
- package/dist/redis/RedisJobStore.js.map +1 -1
- package/dist/redis/scripts.d.ts +117 -10
- package/dist/redis/scripts.d.ts.map +1 -1
- package/dist/redis/scripts.js +492 -25
- 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 +728 -1
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +1 -1
- package/src/Flow.ts +778 -0
- package/src/Job.ts +35 -11
- package/src/JobStore.ts +339 -9
- package/src/MemoryJobStore.ts +370 -7
- package/src/Metrics.ts +43 -0
- package/src/Worker.ts +726 -37
- package/src/drizzle-postgres/DrizzleJobStore.ts +817 -78
- package/src/drizzle-postgres/schema.ts +92 -0
- package/src/index.ts +8 -0
- package/src/redis/RedisJobStore.ts +289 -8
- package/src/redis/scripts.ts +524 -24
- package/src/testing/conformance.ts +945 -1
package/src/Worker.ts
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
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
|
*/
|
|
@@ -18,6 +19,9 @@ import { Cause, Clock, Context, Deferred, Duration, Effect, Exit, Fiber, FiberSe
|
|
|
18
19
|
import {
|
|
19
20
|
type AckOutcome,
|
|
20
21
|
type BackoffPolicy,
|
|
22
|
+
type EnqueueRequest,
|
|
23
|
+
type FlowChildSpec,
|
|
24
|
+
type FlowState,
|
|
21
25
|
isJobStoreError,
|
|
22
26
|
isMarkedUnrecoverable,
|
|
23
27
|
JobId,
|
|
@@ -34,8 +38,8 @@ import {
|
|
|
34
38
|
import * as Metrics from "./Metrics.ts"
|
|
35
39
|
|
|
36
40
|
/**
|
|
37
|
-
* Information about the currently running attempt
|
|
38
|
-
*
|
|
41
|
+
* Information about the currently running attempt. Handlers (and anything
|
|
42
|
+
* they call, however deeply nested) read it from the `CurrentJob` service.
|
|
39
43
|
*
|
|
40
44
|
* @since 0.1.0
|
|
41
45
|
*/
|
|
@@ -49,6 +53,28 @@ export interface JobContext {
|
|
|
49
53
|
readonly attemptsMax: number
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The currently running attempt, provided by the worker around every
|
|
58
|
+
* handler run (plain jobs and both flow phases). Declaring it as a
|
|
59
|
+
* requirement makes "this code only runs inside a job" a compile-time
|
|
60
|
+
* fact — `toLayer` subtracts it, since the worker supplies it per run:
|
|
61
|
+
*
|
|
62
|
+
* ```ts
|
|
63
|
+
* const notify = Effect.gen(function*() {
|
|
64
|
+
* const { jobId, attempt } = yield* Worker.CurrentJob
|
|
65
|
+
* // ... use them however deep in the call graph this runs
|
|
66
|
+
* })
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* Outside a worker (unit tests calling such code directly), provide a
|
|
70
|
+
* value with `Effect.provideService(Worker.CurrentJob, {...})`.
|
|
71
|
+
*
|
|
72
|
+
* @since 0.6.0
|
|
73
|
+
*/
|
|
74
|
+
export class CurrentJob extends Context.Service<CurrentJob, JobContext>()(
|
|
75
|
+
"effect-mq/Worker/CurrentJob"
|
|
76
|
+
) {}
|
|
77
|
+
|
|
52
78
|
/**
|
|
53
79
|
* The structural shape of a job definition the worker needs for
|
|
54
80
|
* registration. `Job.Job` satisfies this — the indirection avoids a module
|
|
@@ -78,6 +104,56 @@ export interface JobDescriptor<
|
|
|
78
104
|
readonly retryable: ((error: Error["Type"]) => boolean) | undefined
|
|
79
105
|
}
|
|
80
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The structural shape of a flow definition the worker needs — for running
|
|
109
|
+
* flow CHILDREN (the outbox relay routes results by parent store) and, via
|
|
110
|
+
* `FlowDescriptor`, for running the parent's two phases. `Flow.Flow`
|
|
111
|
+
* satisfies this; the indirection avoids a module cycle.
|
|
112
|
+
*
|
|
113
|
+
* @since 0.6.0
|
|
114
|
+
*/
|
|
115
|
+
export interface FlowAny {
|
|
116
|
+
/** The flow's unique name (what child `parent` envelopes reference). */
|
|
117
|
+
readonly name: string
|
|
118
|
+
readonly parent: {
|
|
119
|
+
readonly store: Context.Key<any, StoreService>
|
|
120
|
+
}
|
|
121
|
+
/** True when the flow's `onChildFailure` policy is `"fail"`. */
|
|
122
|
+
readonly failFast: boolean
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What `Flow.toLayer` hands to `registerFlow`: the parent job's codecs plus
|
|
127
|
+
* the two phase runners, pre-wired by the flow module (fan-out builds
|
|
128
|
+
* complete `FlowChildSpec`s — deterministic ids, `parent` envelopes, trace
|
|
129
|
+
* stamps — and collect consumes the recorded dependency rows).
|
|
130
|
+
*
|
|
131
|
+
* @internal
|
|
132
|
+
*/
|
|
133
|
+
export interface FlowDescriptor extends FlowAny {
|
|
134
|
+
readonly parent: {
|
|
135
|
+
readonly _tag: string
|
|
136
|
+
readonly queue: QueueName
|
|
137
|
+
readonly store: Context.Key<any, StoreService>
|
|
138
|
+
readonly payloadJsonSchema: Schema.Top
|
|
139
|
+
readonly exitSchema: Schema.Top
|
|
140
|
+
readonly retryable: ((error: never) => boolean) | undefined
|
|
141
|
+
}
|
|
142
|
+
/** Every child store the sweeper/enqueuer must reach, for context lookup. */
|
|
143
|
+
readonly childStores: ReadonlyArray<Context.Key<any, StoreService>>
|
|
144
|
+
readonly fanOut: (
|
|
145
|
+
payload: JobRecord["payload"],
|
|
146
|
+
context: JobContext,
|
|
147
|
+
/** The parent's own nesting depth (0 for top-level flows). */
|
|
148
|
+
parentDepth: number
|
|
149
|
+
) => Effect.Effect<ReadonlyArray<FlowChildSpec>, unknown, unknown>
|
|
150
|
+
readonly collect: (
|
|
151
|
+
payload: JobRecord["payload"],
|
|
152
|
+
flowState: FlowState,
|
|
153
|
+
context: JobContext
|
|
154
|
+
) => Effect.Effect<unknown, unknown, unknown>
|
|
155
|
+
}
|
|
156
|
+
|
|
81
157
|
/**
|
|
82
158
|
* @since 0.1.0
|
|
83
159
|
*/
|
|
@@ -152,6 +228,29 @@ export interface WorkerOptions<StoreId = JobStore> {
|
|
|
152
228
|
* trackers, paging), not a logging prerequisite.
|
|
153
229
|
*/
|
|
154
230
|
readonly onJobFailure?: ((failure: JobFailure) => Effect.Effect<void>) | undefined
|
|
231
|
+
/**
|
|
232
|
+
* Flows whose CHILD jobs this worker runs. Child results are written to
|
|
233
|
+
* the child store's outbox atomically with each terminal transition; this
|
|
234
|
+
* list gives the worker the flows' parent stores (each parent StoreId
|
|
235
|
+
* lands in the layer's requirements), so its relay can push those results
|
|
236
|
+
* immediately. Without it the results wait for a relay elsewhere or for
|
|
237
|
+
* the parent-side sweeper's reconciliation — correct either way, just
|
|
238
|
+
* slower. Workers running a flow's PARENT phases get their registration
|
|
239
|
+
* implicitly from `Flow.toLayer`.
|
|
240
|
+
*/
|
|
241
|
+
readonly flows?: ReadonlyArray<FlowAny> | undefined
|
|
242
|
+
/**
|
|
243
|
+
* Cadence of the flow sweeper (default 30s), which repairs whatever the
|
|
244
|
+
* push path missed: (re-)enqueues fanned-out children that never landed
|
|
245
|
+
* in their store, synthesizes reports for children that reached a
|
|
246
|
+
* terminal state without a delivered report, and cascades cancels after
|
|
247
|
+
* a flow settles. Also the age a dependency row must reach before it is
|
|
248
|
+
* reconciled, and the outbox relay's fallback drain cadence (the relay
|
|
249
|
+
* normally drains the moment a child result is acked). The sweeper runs
|
|
250
|
+
* only on workers that registered a flow via `Flow.toLayer`; the relay
|
|
251
|
+
* runs on any worker with flow registrations of either kind.
|
|
252
|
+
*/
|
|
253
|
+
readonly flowSweepInterval?: Duration.Input | undefined
|
|
155
254
|
/** Identifier used in lock tokens (default: random). */
|
|
156
255
|
readonly id?: string | undefined
|
|
157
256
|
}
|
|
@@ -187,25 +286,38 @@ export class Worker extends Context.Service<Worker, {
|
|
|
187
286
|
>(
|
|
188
287
|
job: JobDescriptor<Payload, Success, Error>,
|
|
189
288
|
handler: (
|
|
190
|
-
payload: Payload["Type"]
|
|
191
|
-
context: JobContext
|
|
289
|
+
payload: Payload["Type"]
|
|
192
290
|
) => Effect.Effect<Success["Type"], Error["Type"], R>,
|
|
193
291
|
options?: RegisterOptions | undefined
|
|
194
292
|
) => Effect.Effect<
|
|
195
293
|
void,
|
|
196
294
|
never,
|
|
197
295
|
| Scope.Scope
|
|
198
|
-
|
|
296
|
+
// The worker provides CurrentJob around every run.
|
|
297
|
+
| Exclude<R, CurrentJob>
|
|
199
298
|
| Payload["DecodingServices"]
|
|
200
299
|
| Success["EncodingServices"]
|
|
201
300
|
| Error["EncodingServices"]
|
|
202
301
|
>
|
|
302
|
+
/**
|
|
303
|
+
* Register a flow's parent phases (`fanOut`/`collect`). Called by
|
|
304
|
+
* `Flow.toLayer`, which declares the real requirements (parent + child
|
|
305
|
+
* stores, handler R, codec services) on its own signature.
|
|
306
|
+
*
|
|
307
|
+
* @internal
|
|
308
|
+
*/
|
|
309
|
+
readonly registerFlow: (
|
|
310
|
+
flow: FlowDescriptor,
|
|
311
|
+
options?: RegisterOptions | undefined
|
|
312
|
+
) => Effect.Effect<void, never, Scope.Scope>
|
|
203
313
|
}>()("effect-mq/Worker") {}
|
|
204
314
|
|
|
205
315
|
interface HandlerEntry {
|
|
316
|
+
/** For flow parents this is the `collect` phase (`flowState` carries its tallies). */
|
|
206
317
|
readonly run: (
|
|
207
318
|
payload: JobRecord["payload"],
|
|
208
|
-
context: JobContext
|
|
319
|
+
context: JobContext,
|
|
320
|
+
flowState: FlowState | undefined
|
|
209
321
|
) => Effect.Effect<unknown, unknown>
|
|
210
322
|
readonly encodeExit: (
|
|
211
323
|
exit: Exit.Exit<unknown, unknown>
|
|
@@ -213,8 +325,40 @@ interface HandlerEntry {
|
|
|
213
325
|
readonly unrecoverableFailure:
|
|
214
326
|
| ((cause: Cause.Cause<unknown>) => boolean)
|
|
215
327
|
| undefined
|
|
328
|
+
/** Present for flow parents: the fan-out phase and its plumbing. */
|
|
329
|
+
readonly flow: {
|
|
330
|
+
readonly flowName: string
|
|
331
|
+
readonly failFast: boolean
|
|
332
|
+
readonly fanOut: (
|
|
333
|
+
payload: JobRecord["payload"],
|
|
334
|
+
context: JobContext,
|
|
335
|
+
parentDepth: number
|
|
336
|
+
) => Effect.Effect<ReadonlyArray<FlowChildSpec>, unknown>
|
|
337
|
+
readonly enqueueChildren: (
|
|
338
|
+
children: ReadonlyArray<FlowChildSpec>
|
|
339
|
+
) => Effect.Effect<void>
|
|
340
|
+
} | undefined
|
|
216
341
|
}
|
|
217
342
|
|
|
343
|
+
// A `retryable` predicate lifted to a cause classifier (false ⇒ skip the
|
|
344
|
+
// remaining retry budget).
|
|
345
|
+
const toUnrecoverableFailure = (
|
|
346
|
+
retryable: ((error: never) => boolean) | undefined
|
|
347
|
+
): ((cause: Cause.Cause<unknown>) => boolean) | undefined =>
|
|
348
|
+
retryable === undefined ? undefined : (cause) => {
|
|
349
|
+
const failure = Cause.findErrorOption(cause)
|
|
350
|
+
if (Option.isNone(failure)) return false
|
|
351
|
+
try {
|
|
352
|
+
// SAFETY: the only typed failures a handler can produce are its
|
|
353
|
+
// declared error type, which is what `retryable` accepts.
|
|
354
|
+
return !retryable(failure.value as never)
|
|
355
|
+
} catch {
|
|
356
|
+
// A throwing predicate must never leave the job un-acked: treat the
|
|
357
|
+
// failure as retryable and let the budget decide.
|
|
358
|
+
return false
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
218
362
|
// A cause is unrecoverable when its error or defect was marked via
|
|
219
363
|
// `Job.unrecoverable` (identity-based, so typed channels stay untouched).
|
|
220
364
|
const causeIsMarkedUnrecoverable = (cause: Cause.Cause<unknown>): boolean => {
|
|
@@ -252,14 +396,29 @@ const storeRetryPolicy = Schedule.min([
|
|
|
252
396
|
type Restore = <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
|
253
397
|
|
|
254
398
|
/**
|
|
255
|
-
*
|
|
256
|
-
*
|
|
399
|
+
* The parent StoreIds of the flows passed via `Worker.layer({ flows })` —
|
|
400
|
+
* a naked type parameter so the union of flows distributes.
|
|
401
|
+
*
|
|
402
|
+
* @since 0.6.0
|
|
403
|
+
*/
|
|
404
|
+
export type FlowParentStores<F> = F extends {
|
|
405
|
+
readonly parent: { readonly store: Context.Key<infer Id, StoreService> }
|
|
406
|
+
} ? Id
|
|
407
|
+
: never
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Build the worker service. Requires a `Scope` (fibers live in it), the
|
|
411
|
+
* `JobStore`, and the parent store of every flow in `flows`.
|
|
257
412
|
*
|
|
258
413
|
* @since 0.1.0
|
|
259
414
|
*/
|
|
260
|
-
export const make = <StoreId = JobStore>(
|
|
261
|
-
options?: WorkerOptions<StoreId> | undefined
|
|
262
|
-
): Effect.Effect<
|
|
415
|
+
export const make = <StoreId = JobStore, const Flows extends ReadonlyArray<FlowAny> = ReadonlyArray<never>>(
|
|
416
|
+
options?: (WorkerOptions<StoreId> & { readonly flows?: Flows | undefined }) | undefined
|
|
417
|
+
): Effect.Effect<
|
|
418
|
+
Worker["Service"],
|
|
419
|
+
never,
|
|
420
|
+
Scope.Scope | StoreId | FlowParentStores<Flows[number]>
|
|
421
|
+
> =>
|
|
263
422
|
Effect.gen(function*() {
|
|
264
423
|
// Taker fibers are forked from whichever registration arrives first; pin
|
|
265
424
|
// them to the worker's own context so they never inherit one job's
|
|
@@ -278,6 +437,7 @@ export const make = <StoreId = JobStore>(
|
|
|
278
437
|
: Math.max(1, Math.floor(lockDurationMs / 2))
|
|
279
438
|
const stalledMs = Duration.toMillis(options?.stalledInterval ?? 30_000)
|
|
280
439
|
const scheduleSweepMs = Duration.toMillis(options?.scheduleSweepInterval ?? 15_000)
|
|
440
|
+
const flowSweepMs = Duration.toMillis(options?.flowSweepInterval ?? 30_000)
|
|
281
441
|
const maxStalledCount = options?.maxStalledCount ?? 1
|
|
282
442
|
const pollMs = Duration.toMillis(options?.pollInterval ?? 5_000)
|
|
283
443
|
const workerId = options?.id ?? `worker-${Math.random().toString(36).slice(2, 10)}`
|
|
@@ -293,6 +453,24 @@ export const make = <StoreId = JobStore>(
|
|
|
293
453
|
// interrupt-only exits ack as Cancelled instead of shutdown-release.
|
|
294
454
|
const cancelling = new Set<JobId>()
|
|
295
455
|
|
|
456
|
+
// Flow plumbing. `storesByKey` maps store-key strings to resolved
|
|
457
|
+
// services — this worker's own store, every `flows` entry's parent
|
|
458
|
+
// store, and each registered flow's child stores (a registered flow's
|
|
459
|
+
// parent store IS the worker's own store). The relay
|
|
460
|
+
// routes outbox entries by `parentStoreKey` through it, and the sweeper
|
|
461
|
+
// and post-fan-out enqueue resolve child stores through it.
|
|
462
|
+
// `flowPolicies` carries each known flow's fail-fast bit for settle
|
|
463
|
+
// logging.
|
|
464
|
+
const storesByKey = new Map<string, StoreService>()
|
|
465
|
+
const flowPolicies = new Map<string, { readonly failFast: boolean }>()
|
|
466
|
+
let flowSweeperStarted = false
|
|
467
|
+
let relayStarted = false
|
|
468
|
+
storesByKey.set(storeKey.key, store)
|
|
469
|
+
for (const flow of options?.flows ?? []) {
|
|
470
|
+
storesByKey.set(flow.parent.store.key, yield* flow.parent.store)
|
|
471
|
+
flowPolicies.set(flow.name, { failFast: flow.failFast })
|
|
472
|
+
}
|
|
473
|
+
|
|
296
474
|
let tokenCounter = 0
|
|
297
475
|
const nextToken = () => `${workerId}:${++tokenCounter}`
|
|
298
476
|
|
|
@@ -341,6 +519,29 @@ export const make = <StoreId = JobStore>(
|
|
|
341
519
|
)
|
|
342
520
|
)
|
|
343
521
|
|
|
522
|
+
// Like ackSafely, but says whether the ack definitely landed — flow
|
|
523
|
+
// reports and post-fan-out child enqueues must only follow an ack this
|
|
524
|
+
// worker actually won.
|
|
525
|
+
const ackLanded = (
|
|
526
|
+
effect: Effect.Effect<
|
|
527
|
+
void,
|
|
528
|
+
JobStoreError | JobNotFoundError | LockLostError
|
|
529
|
+
>,
|
|
530
|
+
what: string
|
|
531
|
+
) =>
|
|
532
|
+
effect.pipe(
|
|
533
|
+
Effect.retry({
|
|
534
|
+
while: (error) => isJobStoreError(error),
|
|
535
|
+
schedule: storeRetryPolicy
|
|
536
|
+
}),
|
|
537
|
+
Effect.as(true),
|
|
538
|
+
Effect.catch((error) =>
|
|
539
|
+
Effect.logWarning(`effect-mq: ${what} dropped (${error._tag})`, error).pipe(
|
|
540
|
+
Effect.as(false)
|
|
541
|
+
)
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
|
|
344
545
|
// Every failed run is logged (warning while retries remain, error once
|
|
345
546
|
// terminal) and handed to the onJobFailure hook. The hook runs isolated:
|
|
346
547
|
// whatever it does, job processing proceeds.
|
|
@@ -369,6 +570,121 @@ export const make = <StoreId = JobStore>(
|
|
|
369
570
|
}
|
|
370
571
|
})
|
|
371
572
|
|
|
573
|
+
// The outbox relay. Child results land in this store's outbox
|
|
574
|
+
// atomically with each terminal transition; the relay drains them into
|
|
575
|
+
// their parent stores in batches and deletes what it delivered.
|
|
576
|
+
// Redelivery after a crash is safe (dependency rows dedup), so there
|
|
577
|
+
// are no leases — peek, deliver, delete. A terminal ack nudges the
|
|
578
|
+
// relay so results push immediately; the periodic pass is the fallback.
|
|
579
|
+
let relayPulseVersion = 0
|
|
580
|
+
let relayPulse = Deferred.makeUnsafe<void>()
|
|
581
|
+
const fireRelayPulse = Effect.suspend(() => {
|
|
582
|
+
relayPulseVersion += 1
|
|
583
|
+
const current = relayPulse
|
|
584
|
+
relayPulse = Deferred.makeUnsafe<void>()
|
|
585
|
+
return Deferred.succeed(current, void 0)
|
|
586
|
+
})
|
|
587
|
+
const awaitRelayPulse = (observed: number) =>
|
|
588
|
+
Effect.suspend(() =>
|
|
589
|
+
relayPulseVersion > observed ? Effect.void : Deferred.await(relayPulse)
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
const relayDrain = Effect.gen(function*() {
|
|
593
|
+
const pageSize = 500
|
|
594
|
+
// Cursor-walk the whole outbox: `after` pages past everything already
|
|
595
|
+
// seen this drain, so entries this worker cannot route — or could not
|
|
596
|
+
// deliver just now — never blockade the ones behind them. Each drain
|
|
597
|
+
// starts from the head again, which is what retries them.
|
|
598
|
+
let after: string | undefined
|
|
599
|
+
while (true) {
|
|
600
|
+
const entries = yield* retryStore(store.peekOutbox({ limit: pageSize, after }))
|
|
601
|
+
if (entries.length === 0) return
|
|
602
|
+
after = entries[entries.length - 1]?.id
|
|
603
|
+
const byStore = new Map<string, Array<(typeof entries)[number]>>()
|
|
604
|
+
let skipped = 0
|
|
605
|
+
for (const entry of entries) {
|
|
606
|
+
if (!storesByKey.has(entry.parentStoreKey)) {
|
|
607
|
+
// No route from this worker; a worker with the right `flows`
|
|
608
|
+
// registration relays it, and reconciliation keeps the flow
|
|
609
|
+
// correct regardless.
|
|
610
|
+
skipped += 1
|
|
611
|
+
continue
|
|
612
|
+
}
|
|
613
|
+
let group = byStore.get(entry.parentStoreKey)
|
|
614
|
+
if (group === undefined) {
|
|
615
|
+
group = []
|
|
616
|
+
byStore.set(entry.parentStoreKey, group)
|
|
617
|
+
}
|
|
618
|
+
group.push(entry)
|
|
619
|
+
}
|
|
620
|
+
if (skipped > 0) {
|
|
621
|
+
yield* Metric.update(Metrics.flowOutboxSkipped, skipped)
|
|
622
|
+
}
|
|
623
|
+
for (const [key, group] of byStore) {
|
|
624
|
+
const target = storesByKey.get(key)
|
|
625
|
+
if (target === undefined) continue
|
|
626
|
+
// One unreachable parent store must not block deliveries to the
|
|
627
|
+
// others: log, leave the group's entries for the next pass, move
|
|
628
|
+
// on.
|
|
629
|
+
yield* Effect.gen(function*() {
|
|
630
|
+
const results = yield* retryStore(
|
|
631
|
+
target.recordChildResults(group.map((entry) => entry.report))
|
|
632
|
+
)
|
|
633
|
+
for (const [index, result] of results.entries()) {
|
|
634
|
+
const entry = group[index]
|
|
635
|
+
if (entry === undefined) continue
|
|
636
|
+
if (result.applied) {
|
|
637
|
+
yield* Metric.update(
|
|
638
|
+
Metrics.flowChildReports.pipe(
|
|
639
|
+
Metric.withAttributes({
|
|
640
|
+
flow: entry.flowName,
|
|
641
|
+
outcome: entry.report.outcome,
|
|
642
|
+
source: "report"
|
|
643
|
+
})
|
|
644
|
+
),
|
|
645
|
+
1
|
|
646
|
+
)
|
|
647
|
+
}
|
|
648
|
+
if (
|
|
649
|
+
result.parentSettled && entry.report.outcome === "failed" &&
|
|
650
|
+
flowPolicies.get(entry.flowName)?.failFast === true
|
|
651
|
+
) {
|
|
652
|
+
yield* Effect.logError(
|
|
653
|
+
`effect-mq: flow "${entry.flowName}" (${entry.report.flowId}) settled failed-fast on child "${entry.report.childKey}"`
|
|
654
|
+
)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
yield* retryStore(store.deleteOutbox(group.map((entry) => entry.id)))
|
|
658
|
+
}).pipe(
|
|
659
|
+
Effect.catchCause((cause) =>
|
|
660
|
+
Effect.logWarning(
|
|
661
|
+
`effect-mq: outbox relay could not deliver to store "${key}"; retrying next pass`,
|
|
662
|
+
cause
|
|
663
|
+
)
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
}
|
|
667
|
+
if (entries.length < pageSize) return
|
|
668
|
+
}
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
const relayLoop = Effect.gen(function*() {
|
|
672
|
+
const observed = relayPulseVersion
|
|
673
|
+
// Failures are contained per drain so the race below always paces the
|
|
674
|
+
// loop (no hot retry); a pulse fired DURING the drain re-runs it
|
|
675
|
+
// immediately via the version check.
|
|
676
|
+
yield* relayDrain.pipe(
|
|
677
|
+
Effect.catchCause((cause) => Effect.logError("effect-mq: outbox relay drain failed", cause))
|
|
678
|
+
)
|
|
679
|
+
yield* Effect.race(awaitRelayPulse(observed), Effect.sleep(flowSweepMs))
|
|
680
|
+
}).pipe(Effect.forever)
|
|
681
|
+
|
|
682
|
+
const ensureRelay = Effect.suspend(() => {
|
|
683
|
+
if (relayStarted) return Effect.void
|
|
684
|
+
relayStarted = true
|
|
685
|
+
return FiberSet.run(fibers, relayLoop.pipe(Effect.updateContext(() => workerContext)))
|
|
686
|
+
})
|
|
687
|
+
|
|
372
688
|
const routeFailure = (record: JobRecord, exit: JobRecord["exit"]): AckOutcome => {
|
|
373
689
|
const attempt = record.attemptsMade + 1
|
|
374
690
|
if (attempt >= record.attemptsMax) {
|
|
@@ -405,6 +721,38 @@ export const make = <StoreId = JobStore>(
|
|
|
405
721
|
Math.max(0, finished - (record.processedAt ?? finished))
|
|
406
722
|
)
|
|
407
723
|
})
|
|
724
|
+
if (record.flow !== undefined && entry.flow === undefined) {
|
|
725
|
+
// A fanned-out flow parent claimed by a PLAIN handler registration
|
|
726
|
+
// (the flow was re-registered as an ordinary job in a deploy):
|
|
727
|
+
// running the plain handler would silently ack its result as the
|
|
728
|
+
// parent's terminal exit and discard the recorded child results.
|
|
729
|
+
// Fail visibly instead; once the deploy is fixed, an admin retry
|
|
730
|
+
// re-enters collect with the manifest intact.
|
|
731
|
+
return Effect.gen(function*() {
|
|
732
|
+
const cause = Cause.die(new Error(
|
|
733
|
+
`effect-mq: job "${record.name}" carries flow state (collect phase) but is registered as a plain handler; ` +
|
|
734
|
+
`register its flow via Flow.toLayer instead of Job.toLayer`
|
|
735
|
+
))
|
|
736
|
+
const encoded = yield* Effect.exit(entry.encodeExit(Exit.failCause(cause)))
|
|
737
|
+
yield* ackSafely(
|
|
738
|
+
store.ack(record.id, token, {
|
|
739
|
+
_tag: "Fail",
|
|
740
|
+
exit: Exit.isSuccess(encoded) ? encoded.value : undefined
|
|
741
|
+
}),
|
|
742
|
+
"ack"
|
|
743
|
+
)
|
|
744
|
+
yield* reportFailure({
|
|
745
|
+
jobId: record.id,
|
|
746
|
+
name: record.name,
|
|
747
|
+
queue: record.queue,
|
|
748
|
+
attempt: context.attempt,
|
|
749
|
+
attemptsMax: record.attemptsMax,
|
|
750
|
+
willRetry: false,
|
|
751
|
+
cause
|
|
752
|
+
}, undefined)
|
|
753
|
+
yield* recordRun("failed")
|
|
754
|
+
})
|
|
755
|
+
}
|
|
408
756
|
return Effect.gen(function*() {
|
|
409
757
|
// The per-run time limit interrupts the handler internally; surface
|
|
410
758
|
// it as a defect so it flows through normal retry accounting.
|
|
@@ -425,7 +773,25 @@ export const make = <StoreId = JobStore>(
|
|
|
425
773
|
spanId: record.trace.spanId,
|
|
426
774
|
sampled: record.trace.sampled
|
|
427
775
|
})
|
|
428
|
-
|
|
776
|
+
// Flow-parent phase dispatch is persisted, not inferred: no `flow`
|
|
777
|
+
// bookkeeping on the record means the manifest never landed (run
|
|
778
|
+
// `fanOut`); its presence means a resumed parent (run `collect`,
|
|
779
|
+
// stored in `entry.run`) — a re-claimed parent can never fan out
|
|
780
|
+
// twice.
|
|
781
|
+
const flow = entry.flow
|
|
782
|
+
const isFanOut = flow !== undefined && record.flow === undefined
|
|
783
|
+
const runEffect = (isFanOut
|
|
784
|
+
? flow.fanOut(record.payload, context, record.parent?.depth ?? 0)
|
|
785
|
+
: entry.run(record.payload, context, record.flow)).pipe(
|
|
786
|
+
// Every log line a handler writes carries the job identity —
|
|
787
|
+
// log-based alerting needs no per-handler setup.
|
|
788
|
+
Effect.annotateLogs({
|
|
789
|
+
effectMqJobId: record.id,
|
|
790
|
+
effectMqQueue: record.queue,
|
|
791
|
+
effectMqAttempt: context.attempt
|
|
792
|
+
})
|
|
793
|
+
)
|
|
794
|
+
const withRunSpan = runEffect.pipe(
|
|
429
795
|
Effect.withSpan(
|
|
430
796
|
options?.handlerSpanName?.(context) ?? `${record.name}.run`,
|
|
431
797
|
{
|
|
@@ -472,7 +838,12 @@ export const make = <StoreId = JobStore>(
|
|
|
472
838
|
// A cancel-request interrupt is terminal: ack Cancelled (even if a
|
|
473
839
|
// shutdown races it — cancellation wins, the job must not revive).
|
|
474
840
|
if (wasCancelled && Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) {
|
|
475
|
-
yield*
|
|
841
|
+
const landed = yield* ackLanded(store.ack(record.id, token, { _tag: "Cancelled" }), "ack")
|
|
842
|
+
if (landed && record.parent !== undefined) {
|
|
843
|
+
// The ack appended this child's report to the outbox; drain now
|
|
844
|
+
// instead of waiting for the periodic pass.
|
|
845
|
+
yield* fireRelayPulse
|
|
846
|
+
}
|
|
476
847
|
return yield* recordRun("cancelled")
|
|
477
848
|
}
|
|
478
849
|
|
|
@@ -499,6 +870,53 @@ export const make = <StoreId = JobStore>(
|
|
|
499
870
|
)
|
|
500
871
|
: exit
|
|
501
872
|
|
|
873
|
+
if (isFanOut && Exit.isSuccess(effective)) {
|
|
874
|
+
// SAFETY: the fan-out runner's success type is the built specs.
|
|
875
|
+
const children = effective.value as ReadonlyArray<FlowChildSpec>
|
|
876
|
+
const acked = yield* Effect.exit(
|
|
877
|
+
store.ack(record.id, token, {
|
|
878
|
+
_tag: "FanOut",
|
|
879
|
+
failFast: flow.failFast,
|
|
880
|
+
children
|
|
881
|
+
}).pipe(
|
|
882
|
+
Effect.retry({
|
|
883
|
+
while: (error) => isJobStoreError(error),
|
|
884
|
+
schedule: storeRetryPolicy
|
|
885
|
+
})
|
|
886
|
+
)
|
|
887
|
+
)
|
|
888
|
+
if (Exit.isSuccess(acked)) {
|
|
889
|
+
yield* Metric.update(
|
|
890
|
+
Metrics.flowFanOuts.pipe(Metric.withAttributes({ flow: flow.flowName })),
|
|
891
|
+
1
|
|
892
|
+
)
|
|
893
|
+
// The ack may have settled the parent instead of parking it (a
|
|
894
|
+
// cancel raced the fan-out: cancellation wins and the rows are
|
|
895
|
+
// already marked for cascade) — children of a settled flow must
|
|
896
|
+
// not start. A settle that lands AFTER this check still
|
|
897
|
+
// converges: the marked rows make the sweeper cancel whatever
|
|
898
|
+
// we enqueue below.
|
|
899
|
+
const parked = children.length === 0 ? Option.none() : yield* retryStore(store.getJob(record.id))
|
|
900
|
+
if (Option.isSome(parked) && parked.value.state === "waiting-children") {
|
|
901
|
+
// Fast path only: the flow sweeper re-drives whatever a
|
|
902
|
+
// crash here misses, straight from the persisted specs.
|
|
903
|
+
yield* flow.enqueueChildren(children)
|
|
904
|
+
} else if (children.length > 0) {
|
|
905
|
+
yield* Effect.logInfo(
|
|
906
|
+
`effect-mq: flow "${flow.flowName}" (${record.id}) settled during fan-out; children not enqueued`
|
|
907
|
+
)
|
|
908
|
+
}
|
|
909
|
+
} else {
|
|
910
|
+
// Lock lost or job vanished: the manifest did NOT land, so the
|
|
911
|
+
// children must not run — another worker re-runs fanOut.
|
|
912
|
+
yield* Effect.logWarning(
|
|
913
|
+
`effect-mq: FanOut ack dropped for flow "${flow.flowName}" (${record.id})`,
|
|
914
|
+
acked.cause
|
|
915
|
+
)
|
|
916
|
+
}
|
|
917
|
+
return yield* recordRun("fanned-out")
|
|
918
|
+
}
|
|
919
|
+
|
|
502
920
|
// Never let an encode defect escape: the job would be stuck active.
|
|
503
921
|
let encoded = yield* Effect.exit(entry.encodeExit(effective))
|
|
504
922
|
let encodable = true
|
|
@@ -527,7 +945,15 @@ export const make = <StoreId = JobStore>(
|
|
|
527
945
|
: unrecoverable
|
|
528
946
|
? { _tag: "Fail", exit: exitValue }
|
|
529
947
|
: routeFailure(record, exitValue)
|
|
530
|
-
yield*
|
|
948
|
+
const landed = yield* ackLanded(store.ack(record.id, token, outcome), "ack")
|
|
949
|
+
if (
|
|
950
|
+
landed && record.parent !== undefined &&
|
|
951
|
+
(outcome._tag === "Complete" || outcome._tag === "Fail")
|
|
952
|
+
) {
|
|
953
|
+
// The ack appended this child's report to the outbox; drain now
|
|
954
|
+
// instead of waiting for the periodic pass.
|
|
955
|
+
yield* fireRelayPulse
|
|
956
|
+
}
|
|
531
957
|
if (outcome._tag === "Fail" || outcome._tag === "Retry") {
|
|
532
958
|
const cause = Exit.isFailure(effective)
|
|
533
959
|
? effective.cause
|
|
@@ -752,6 +1178,7 @@ export const make = <StoreId = JobStore>(
|
|
|
752
1178
|
timeoutMs: schedule.timeoutMs,
|
|
753
1179
|
dedupe: undefined,
|
|
754
1180
|
trace: undefined,
|
|
1181
|
+
parent: undefined,
|
|
755
1182
|
delayMs: 0
|
|
756
1183
|
}))
|
|
757
1184
|
if (fired) {
|
|
@@ -779,9 +1206,149 @@ export const make = <StoreId = JobStore>(
|
|
|
779
1206
|
Effect.forever
|
|
780
1207
|
)
|
|
781
1208
|
|
|
1209
|
+
// The flow reconciliation engine (see `FlowSweepWork`). Every action is
|
|
1210
|
+
// idempotent by construction: enqueues dedup on the deterministic child
|
|
1211
|
+
// id, reports dedup on the dependency row's state, cancels dedup on the
|
|
1212
|
+
// child's state, and the cascaded flag dedups its own write — so crashes
|
|
1213
|
+
// anywhere in the sweep are safe.
|
|
1214
|
+
const reconcileChild = (flowId: JobId, child: FlowChildSpec) =>
|
|
1215
|
+
Effect.gen(function*() {
|
|
1216
|
+
const childStore = storesByKey.get(child.storeKey)
|
|
1217
|
+
if (childStore === undefined) {
|
|
1218
|
+
// A flow registered on another worker shares this parent store;
|
|
1219
|
+
// that worker's sweeper holds the child store layer.
|
|
1220
|
+
return
|
|
1221
|
+
}
|
|
1222
|
+
const id = child.request.id
|
|
1223
|
+
if (id === undefined) return
|
|
1224
|
+
const existing = yield* retryStore(childStore.getJob(id))
|
|
1225
|
+
if (Option.isNone(existing)) {
|
|
1226
|
+
// Never landed (crash between the FanOut ack and the enqueue), or
|
|
1227
|
+
// pruned before reporting (see the child-retention guidance) —
|
|
1228
|
+
// (re-)drive it from the persisted spec. The flow may have settled
|
|
1229
|
+
// since this work item was snapshotted (another sweeper's cascade
|
|
1230
|
+
// would then have found this child missing, marked its row
|
|
1231
|
+
// cascaded, and moved on), so re-check the parent around the
|
|
1232
|
+
// enqueue: before, to skip settled flows, and after, so a settle
|
|
1233
|
+
// that lands inside the window cannot leave an orphan running.
|
|
1234
|
+
const before = yield* retryStore(store.getJob(flowId))
|
|
1235
|
+
if (Option.isNone(before) || before.value.state !== "waiting-children") return
|
|
1236
|
+
yield* retryStore(childStore.enqueue(child.request))
|
|
1237
|
+
const after = yield* retryStore(store.getJob(flowId))
|
|
1238
|
+
if (Option.isNone(after) || after.value.state !== "waiting-children") {
|
|
1239
|
+
yield* retryStore(
|
|
1240
|
+
childStore.cancel(id).pipe(
|
|
1241
|
+
Effect.catchTag(["JobNotFoundError", "JobNotCancellableError"], () => Effect.void)
|
|
1242
|
+
)
|
|
1243
|
+
)
|
|
1244
|
+
}
|
|
1245
|
+
return
|
|
1246
|
+
}
|
|
1247
|
+
const state = existing.value.state
|
|
1248
|
+
if (state !== "completed" && state !== "failed" && state !== "cancelled") {
|
|
1249
|
+
// Still in flight: never blindly re-drive a live child.
|
|
1250
|
+
return
|
|
1251
|
+
}
|
|
1252
|
+
// Terminal without a delivered report (a dropped outbox entry, or a
|
|
1253
|
+
// child whose store has no relaying worker): synthesize the report
|
|
1254
|
+
// from the child store's own record.
|
|
1255
|
+
const results = yield* retryStore(store.recordChildResults([{
|
|
1256
|
+
flowId,
|
|
1257
|
+
childKey: child.childKey,
|
|
1258
|
+
outcome: state,
|
|
1259
|
+
exit: existing.value.exit,
|
|
1260
|
+
failedReason: existing.value.failedReason
|
|
1261
|
+
}]))
|
|
1262
|
+
const result = results[0]
|
|
1263
|
+
if (result?.applied === true) {
|
|
1264
|
+
yield* Metric.update(
|
|
1265
|
+
Metrics.flowChildReports.pipe(
|
|
1266
|
+
Metric.withAttributes({
|
|
1267
|
+
flow: child.request.parent?.flowName ?? "unknown",
|
|
1268
|
+
outcome: state,
|
|
1269
|
+
source: "reconcile"
|
|
1270
|
+
})
|
|
1271
|
+
),
|
|
1272
|
+
1
|
|
1273
|
+
)
|
|
1274
|
+
if (result?.parentSettled === true && state === "failed") {
|
|
1275
|
+
yield* Effect.logError(
|
|
1276
|
+
`effect-mq: flow ${flowId} settled on reconciled child failure "${child.childKey}"`
|
|
1277
|
+
)
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
})
|
|
1281
|
+
|
|
1282
|
+
const cascadeChildren = (
|
|
1283
|
+
flowId: JobId,
|
|
1284
|
+
children: ReadonlyArray<{
|
|
1285
|
+
readonly childKey: string
|
|
1286
|
+
readonly storeKey: string
|
|
1287
|
+
readonly childJobId: JobId
|
|
1288
|
+
}>
|
|
1289
|
+
) =>
|
|
1290
|
+
Effect.gen(function*() {
|
|
1291
|
+
const done: Array<string> = []
|
|
1292
|
+
for (const child of children) {
|
|
1293
|
+
const childStore = storesByKey.get(child.storeKey)
|
|
1294
|
+
if (childStore === undefined) continue
|
|
1295
|
+
// Idempotent: a vanished or already-terminal child is "cancelled
|
|
1296
|
+
// enough".
|
|
1297
|
+
yield* retryStore(
|
|
1298
|
+
childStore.cancel(child.childJobId).pipe(
|
|
1299
|
+
Effect.catchTag(["JobNotFoundError", "JobNotCancellableError"], () => Effect.void)
|
|
1300
|
+
)
|
|
1301
|
+
)
|
|
1302
|
+
done.push(child.childKey)
|
|
1303
|
+
}
|
|
1304
|
+
if (done.length > 0) {
|
|
1305
|
+
yield* retryStore(store.markChildrenCascaded(flowId, done))
|
|
1306
|
+
yield* Metric.update(Metrics.flowCascades, done.length)
|
|
1307
|
+
}
|
|
1308
|
+
})
|
|
1309
|
+
|
|
1310
|
+
const flowSweepLoop = Effect.gen(function*() {
|
|
1311
|
+
yield* Effect.sleep(flowSweepMs)
|
|
1312
|
+
const work = yield* retryStore(store.flowSweepWork({ pendingAgeMs: flowSweepMs, limit: 512 }))
|
|
1313
|
+
for (const group of work.reconcile) {
|
|
1314
|
+
for (const child of group.children) {
|
|
1315
|
+
// Each child in isolation: one poison row cannot starve the sweep.
|
|
1316
|
+
yield* reconcileChild(group.flowId, child).pipe(
|
|
1317
|
+
Effect.catchCause((cause) =>
|
|
1318
|
+
Effect.logError(
|
|
1319
|
+
`effect-mq: flow reconcile failed for child "${child.childKey}" of ${group.flowId}`,
|
|
1320
|
+
cause
|
|
1321
|
+
)
|
|
1322
|
+
)
|
|
1323
|
+
)
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
for (const group of work.cascade) {
|
|
1327
|
+
yield* cascadeChildren(group.flowId, group.children).pipe(
|
|
1328
|
+
Effect.catchCause((cause) =>
|
|
1329
|
+
Effect.logError(`effect-mq: flow cascade failed for ${group.flowId}`, cause)
|
|
1330
|
+
)
|
|
1331
|
+
)
|
|
1332
|
+
}
|
|
1333
|
+
}).pipe(
|
|
1334
|
+
Effect.catchCause((cause) => Effect.logError("effect-mq: flow sweep failed", cause)),
|
|
1335
|
+
Effect.forever
|
|
1336
|
+
)
|
|
1337
|
+
|
|
1338
|
+
// Started lazily by the first flow registration — plain workers never
|
|
1339
|
+
// pay for a sweep query.
|
|
1340
|
+
const ensureFlowSweeper = Effect.suspend(() => {
|
|
1341
|
+
if (flowSweeperStarted) return Effect.void
|
|
1342
|
+
flowSweeperStarted = true
|
|
1343
|
+
return FiberSet.run(fibers, flowSweepLoop.pipe(Effect.updateContext(() => workerContext)))
|
|
1344
|
+
})
|
|
1345
|
+
|
|
782
1346
|
yield* FiberSet.run(fibers, renewalLoop)
|
|
783
1347
|
yield* FiberSet.run(fibers, stalledLoop)
|
|
784
1348
|
yield* FiberSet.run(fibers, scheduleLoop)
|
|
1349
|
+
if (flowPolicies.size > 0) {
|
|
1350
|
+
yield* ensureRelay
|
|
1351
|
+
}
|
|
785
1352
|
|
|
786
1353
|
if (options?.queueMetricsInterval !== undefined) {
|
|
787
1354
|
const sampleMs = Duration.toMillis(options.queueMetricsInterval)
|
|
@@ -828,7 +1395,7 @@ export const make = <StoreId = JobStore>(
|
|
|
828
1395
|
// registration layer; capture it, minus runtime-ambient keys that
|
|
829
1396
|
// must always come from the executing fiber.
|
|
830
1397
|
const services = (yield* Effect.context<never>()).pipe(
|
|
831
|
-
Context.omit(Scope_.Scope, Tracer.ParentSpan)
|
|
1398
|
+
Context.omit(Scope_.Scope, Tracer.ParentSpan, CurrentJob)
|
|
832
1399
|
)
|
|
833
1400
|
const decodePayload = Schema.decodeUnknownEffect(job.payloadJsonSchema)
|
|
834
1401
|
const encodeExit = Schema.encodeEffect(job.exitSchema)
|
|
@@ -842,29 +1409,20 @@ export const make = <StoreId = JobStore>(
|
|
|
842
1409
|
Context.merge(input, services) as Context.Context<unknown>
|
|
843
1410
|
)
|
|
844
1411
|
) as Effect.Effect<A, E>
|
|
845
|
-
const retryable = job.retryable
|
|
846
1412
|
const entry: HandlerEntry = {
|
|
847
1413
|
run: (payload, context) =>
|
|
848
1414
|
provideCaptured(
|
|
849
1415
|
decodePayload(payload).pipe(
|
|
850
1416
|
Effect.orDie,
|
|
851
|
-
Effect.flatMap((decoded) => handler(decoded
|
|
1417
|
+
Effect.flatMap((decoded) => handler(decoded)),
|
|
1418
|
+
// Innermost, so neither the captured registration context
|
|
1419
|
+
// nor the worker's own can shadow the running attempt.
|
|
1420
|
+
Effect.provideService(CurrentJob, context)
|
|
852
1421
|
)
|
|
853
1422
|
),
|
|
854
1423
|
encodeExit: (exit) => provideCaptured(encodeExit(exit)),
|
|
855
|
-
unrecoverableFailure: retryable
|
|
856
|
-
|
|
857
|
-
if (Option.isNone(failure)) return false
|
|
858
|
-
try {
|
|
859
|
-
// SAFETY: the only typed failures a handler can produce are
|
|
860
|
-
// its declared error type, which is what `retryable` accepts.
|
|
861
|
-
return !retryable(failure.value as Parameters<typeof retryable>[0])
|
|
862
|
-
} catch {
|
|
863
|
-
// A throwing predicate must never leave the job un-acked:
|
|
864
|
-
// treat the failure as retryable and let the budget decide.
|
|
865
|
-
return false
|
|
866
|
-
}
|
|
867
|
-
}
|
|
1424
|
+
unrecoverableFailure: toUnrecoverableFailure(job.retryable),
|
|
1425
|
+
flow: undefined
|
|
868
1426
|
}
|
|
869
1427
|
handlers.set(name, entry)
|
|
870
1428
|
const queue = registerOptions?.queue !== undefined
|
|
@@ -884,7 +1442,137 @@ export const make = <StoreId = JobStore>(
|
|
|
884
1442
|
)
|
|
885
1443
|
yield* ensureQueueLoop(queue, registerOptions?.concurrency)
|
|
886
1444
|
yield* firePulse
|
|
887
|
-
}) as Effect.Effect<void, never, never
|
|
1445
|
+
}) as Effect.Effect<void, never, never>,
|
|
1446
|
+
|
|
1447
|
+
registerFlow: (flow, registerOptions) =>
|
|
1448
|
+
Effect.gen(function*() {
|
|
1449
|
+
const name = flow.parent._tag
|
|
1450
|
+
if (handlers.has(name)) {
|
|
1451
|
+
return yield* Effect.die(
|
|
1452
|
+
new Error(`effect-mq: duplicate handler registered for job "${name}"`)
|
|
1453
|
+
)
|
|
1454
|
+
}
|
|
1455
|
+
if (flow.parent.store.key !== storeKey.key) {
|
|
1456
|
+
return yield* Effect.die(
|
|
1457
|
+
new Error(
|
|
1458
|
+
`effect-mq: flow "${flow.name}" parent "${name}" is bound to store "${flow.parent.store.key}" but this worker claims from "${storeKey.key}". ` +
|
|
1459
|
+
`Provide a Worker.layer({ store }) for the parent's store.`
|
|
1460
|
+
)
|
|
1461
|
+
)
|
|
1462
|
+
}
|
|
1463
|
+
const services = (yield* Effect.context<never>()).pipe(
|
|
1464
|
+
Context.omit(Scope_.Scope, Tracer.ParentSpan, CurrentJob)
|
|
1465
|
+
)
|
|
1466
|
+
// Resolve every child store from the registration context (declared
|
|
1467
|
+
// on Flow.toLayer's signature) — this worker is the one process
|
|
1468
|
+
// guaranteed able to reconcile and cascade across all of them.
|
|
1469
|
+
for (const key of flow.childStores) {
|
|
1470
|
+
const service = Context.getOption(services, key)
|
|
1471
|
+
if (Option.isNone(service)) {
|
|
1472
|
+
return yield* Effect.die(
|
|
1473
|
+
new Error(
|
|
1474
|
+
`effect-mq: flow "${flow.name}" requires child store "${key.key}" — provide it to the flow's layer`
|
|
1475
|
+
)
|
|
1476
|
+
)
|
|
1477
|
+
}
|
|
1478
|
+
storesByKey.set(key.key, service.value)
|
|
1479
|
+
}
|
|
1480
|
+
flowPolicies.set(flow.name, { failFast: flow.failFast })
|
|
1481
|
+
const decodePayload = Schema.decodeUnknownEffect(flow.parent.payloadJsonSchema)
|
|
1482
|
+
const encodeExit = Schema.encodeEffect(flow.parent.exitSchema)
|
|
1483
|
+
// SAFETY: same contract as `register` — the captured context holds
|
|
1484
|
+
// everything Flow.toLayer's signature required.
|
|
1485
|
+
const provideCaptured = <A, E>(effect: Effect.Effect<A, E, unknown>): Effect.Effect<A, E> =>
|
|
1486
|
+
effect.pipe(
|
|
1487
|
+
Effect.updateContext((input) =>
|
|
1488
|
+
Context.merge(input, services) as Context.Context<unknown>
|
|
1489
|
+
)
|
|
1490
|
+
) as Effect.Effect<A, E>
|
|
1491
|
+
const emptyFlow: FlowState = {
|
|
1492
|
+
failFast: flow.failFast,
|
|
1493
|
+
pending: 0,
|
|
1494
|
+
completed: 0,
|
|
1495
|
+
failed: 0,
|
|
1496
|
+
cancelled: 0
|
|
1497
|
+
}
|
|
1498
|
+
const entry: HandlerEntry = {
|
|
1499
|
+
run: (payload, context, flowState) =>
|
|
1500
|
+
provideCaptured(
|
|
1501
|
+
decodePayload(payload).pipe(
|
|
1502
|
+
Effect.orDie,
|
|
1503
|
+
Effect.flatMap((decoded) =>
|
|
1504
|
+
// collect dispatch requires a persisted manifest, so the
|
|
1505
|
+
// fallback is defensive only.
|
|
1506
|
+
flow.collect(decoded, flowState ?? emptyFlow, context)
|
|
1507
|
+
),
|
|
1508
|
+
Effect.provideService(CurrentJob, context)
|
|
1509
|
+
)
|
|
1510
|
+
),
|
|
1511
|
+
encodeExit: (exit) => provideCaptured(encodeExit(exit)),
|
|
1512
|
+
unrecoverableFailure: toUnrecoverableFailure(flow.parent.retryable),
|
|
1513
|
+
flow: {
|
|
1514
|
+
flowName: flow.name,
|
|
1515
|
+
failFast: flow.failFast,
|
|
1516
|
+
fanOut: (payload, context, parentDepth) =>
|
|
1517
|
+
provideCaptured(
|
|
1518
|
+
decodePayload(payload).pipe(
|
|
1519
|
+
Effect.orDie,
|
|
1520
|
+
Effect.flatMap((decoded) => flow.fanOut(decoded, context, parentDepth)),
|
|
1521
|
+
Effect.provideService(CurrentJob, context)
|
|
1522
|
+
)
|
|
1523
|
+
),
|
|
1524
|
+
enqueueChildren: (children) =>
|
|
1525
|
+
Effect.gen(function*() {
|
|
1526
|
+
// Group per child store; enqueueMany chunks internally and
|
|
1527
|
+
// dedups on the deterministic ids.
|
|
1528
|
+
const byStore = new Map<string, Array<EnqueueRequest>>()
|
|
1529
|
+
for (const child of children) {
|
|
1530
|
+
let group = byStore.get(child.storeKey)
|
|
1531
|
+
if (group === undefined) {
|
|
1532
|
+
group = []
|
|
1533
|
+
byStore.set(child.storeKey, group)
|
|
1534
|
+
}
|
|
1535
|
+
group.push(child.request)
|
|
1536
|
+
}
|
|
1537
|
+
for (const [key, requests] of byStore) {
|
|
1538
|
+
const childStore = storesByKey.get(key)
|
|
1539
|
+
if (childStore === undefined) continue
|
|
1540
|
+
yield* retryStore(childStore.enqueueMany(requests)).pipe(
|
|
1541
|
+
Effect.catchCause((cause) =>
|
|
1542
|
+
Effect.logWarning(
|
|
1543
|
+
`effect-mq: flow "${flow.name}" child enqueue incomplete; the flow sweeper will reconcile`,
|
|
1544
|
+
cause
|
|
1545
|
+
)
|
|
1546
|
+
)
|
|
1547
|
+
)
|
|
1548
|
+
}
|
|
1549
|
+
})
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
handlers.set(name, entry)
|
|
1553
|
+
const queue = registerOptions?.queue !== undefined
|
|
1554
|
+
? QueueName(registerOptions.queue)
|
|
1555
|
+
: flow.parent.queue
|
|
1556
|
+
let names = queueNames.get(queue)
|
|
1557
|
+
if (names === undefined) {
|
|
1558
|
+
names = new Set()
|
|
1559
|
+
queueNames.set(queue, names)
|
|
1560
|
+
}
|
|
1561
|
+
names.add(name)
|
|
1562
|
+
yield* Effect.addFinalizer(() =>
|
|
1563
|
+
Effect.sync(() => {
|
|
1564
|
+
handlers.delete(name)
|
|
1565
|
+
queueNames.get(queue)?.delete(name)
|
|
1566
|
+
})
|
|
1567
|
+
)
|
|
1568
|
+
yield* ensureQueueLoop(queue, registerOptions?.concurrency)
|
|
1569
|
+
yield* ensureFlowSweeper
|
|
1570
|
+
yield* ensureRelay
|
|
1571
|
+
yield* firePulse
|
|
1572
|
+
// SAFETY: like `register`, the implementation erases requirements
|
|
1573
|
+
// that Flow.toLayer's public signature declares and the captured
|
|
1574
|
+
// context (via provideCaptured) restores; only Scope remains.
|
|
1575
|
+
}) as Effect.Effect<void, never, Scope.Scope>
|
|
888
1576
|
})
|
|
889
1577
|
})
|
|
890
1578
|
|
|
@@ -894,6 +1582,7 @@ export const make = <StoreId = JobStore>(
|
|
|
894
1582
|
*
|
|
895
1583
|
* @since 0.1.0
|
|
896
1584
|
*/
|
|
897
|
-
export const layer = <StoreId = JobStore>(
|
|
898
|
-
options?: WorkerOptions<StoreId> | undefined
|
|
899
|
-
): Layer.Layer<Worker, never, StoreId
|
|
1585
|
+
export const layer = <StoreId = JobStore, const Flows extends ReadonlyArray<FlowAny> = ReadonlyArray<never>>(
|
|
1586
|
+
options?: (WorkerOptions<StoreId> & { readonly flows?: Flows | undefined }) | undefined
|
|
1587
|
+
): Layer.Layer<Worker, never, StoreId | FlowParentStores<Flows[number]>> =>
|
|
1588
|
+
Layer.effect(Worker, make(options))
|