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/src/Flow.ts
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-store parent-child flows.
|
|
3
|
+
*
|
|
4
|
+
* A flow's PARENT job fans out N child jobs, parks in `waiting-children`
|
|
5
|
+
* until every child settles, then resumes with their typed results. The
|
|
6
|
+
* children may live on a **different store** than the parent — a
|
|
7
|
+
* cron-scheduled parent in Postgres can fan out thousands of idempotent
|
|
8
|
+
* sends into Redis and collect the outcomes back in Postgres.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* const DigestFlow = Flow.make("daily-digest", {
|
|
12
|
+
* parent: SendDigest, // a Job bound to the Postgres store
|
|
13
|
+
* children: [SendEmail], // Jobs, each bound to their own store
|
|
14
|
+
* onChildFailure: "continue" // default; "fail" settles on first failure
|
|
15
|
+
* })
|
|
16
|
+
*
|
|
17
|
+
* // the parent worker runs both phases (requires the parent AND child stores)
|
|
18
|
+
* const DigestWorker = DigestFlow.toLayer({
|
|
19
|
+
* fanOut: (payload) =>
|
|
20
|
+
* Effect.gen(function*() {
|
|
21
|
+
* const users = yield* Users.active
|
|
22
|
+
* return Flow.children(SendEmail, users.map((user) => ({
|
|
23
|
+
* key: user.id,
|
|
24
|
+
* payload: { userId: user.id }
|
|
25
|
+
* })))
|
|
26
|
+
* }),
|
|
27
|
+
* collect: (payload, results) =>
|
|
28
|
+
* Effect.succeed({ sent: results.counts.completed, failed: results.counts.failed })
|
|
29
|
+
* // or: yield* results.all (materialized buckets), results.stream (paged)
|
|
30
|
+
* })
|
|
31
|
+
*
|
|
32
|
+
* // workers that run the CHILDREN list the flow so results push instantly:
|
|
33
|
+
* // Worker.layer({ store: EmailStore, flows: [DigestFlow] })
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* Architecture (see `designs/parent-child-flows.md`): the parent's store
|
|
37
|
+
* owns the flow — child manifest, per-child results, outcome counters — so
|
|
38
|
+
* "the flow settles exactly once" is single-store atomic. Cross-store needs
|
|
39
|
+
* only two at-least-once idempotent mechanisms: every terminal child
|
|
40
|
+
* transition atomically appends its report to the CHILD store's outbox,
|
|
41
|
+
* which worker relays *push* into the parent store in batches (children
|
|
42
|
+
* keep completing even while the parent store is down); and the parent
|
|
43
|
+
* worker's flow sweeper *reconciles* from child-store state (repairs
|
|
44
|
+
* everything the push path can miss: crashes mid-enqueue, dropped outbox
|
|
45
|
+
* entries, children terminal on stores no relay reaches).
|
|
46
|
+
*
|
|
47
|
+
* Flows nest: a child may itself be another flow's parent, reporting upward
|
|
48
|
+
* through the same machinery when it settles. Depth is capped (8) so a
|
|
49
|
+
* cyclic definition surfaces as an unrecoverable failure instead of an
|
|
50
|
+
* unbounded chain.
|
|
51
|
+
*
|
|
52
|
+
* Flow children bypass the child definition's `idempotencyKey`/`dedupe` —
|
|
53
|
+
* the child `key` (unique within the flow) IS the idempotency mechanism,
|
|
54
|
+
* carried in the deterministic job id. Handlers should be idempotent, as
|
|
55
|
+
* everywhere under at-least-once.
|
|
56
|
+
*
|
|
57
|
+
* @since 0.6.0
|
|
58
|
+
*/
|
|
59
|
+
import { Cause, Context, Duration, Effect, Exit, Layer, Option, Schema, Scope as Scope_, Stream, Tracer } from "effect"
|
|
60
|
+
import {
|
|
61
|
+
type AnyStructSchema,
|
|
62
|
+
type JobOptions,
|
|
63
|
+
normalizeBackoff,
|
|
64
|
+
normalizeKeep,
|
|
65
|
+
type ResolvedDefaults
|
|
66
|
+
} from "./Job.ts"
|
|
67
|
+
import {
|
|
68
|
+
type EnqueueRequest,
|
|
69
|
+
type FlowChildRecord,
|
|
70
|
+
type FlowState,
|
|
71
|
+
JobId,
|
|
72
|
+
type QueueName,
|
|
73
|
+
type Service as StoreService,
|
|
74
|
+
unrecoverable
|
|
75
|
+
} from "./JobStore.ts"
|
|
76
|
+
import { type CurrentJob, type FlowDescriptor, type JobContext, type RegisterOptions, Worker } from "./Worker.ts"
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The structural view of a `Job.make` class a flow needs from its members.
|
|
80
|
+
* Contravariant callback members are typed with `never` parameters so every
|
|
81
|
+
* concrete job satisfies the constraint; the runtime only ever passes a
|
|
82
|
+
* job's own payload back into them.
|
|
83
|
+
*
|
|
84
|
+
* @since 0.6.0
|
|
85
|
+
*/
|
|
86
|
+
export interface MemberJob {
|
|
87
|
+
readonly _tag: string
|
|
88
|
+
readonly queue: QueueName
|
|
89
|
+
readonly store: Context.Key<any, StoreService>
|
|
90
|
+
readonly payloadSchema: AnyStructSchema
|
|
91
|
+
readonly payloadJsonSchema: Schema.Top
|
|
92
|
+
readonly successSchema: Schema.Top
|
|
93
|
+
readonly errorSchema: Schema.Top
|
|
94
|
+
readonly exitSchema: Schema.Top
|
|
95
|
+
readonly defaults: ResolvedDefaults
|
|
96
|
+
readonly metadata: ((payload: never) => Readonly<Record<string, string>>) | undefined
|
|
97
|
+
readonly retryable: ((error: never) => boolean) | undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The structural view of the parent job: a `MemberJob` plus the producer
|
|
102
|
+
* surface the flow delegates (`Flow.enqueue` IS the parent's `enqueue`).
|
|
103
|
+
*
|
|
104
|
+
* @since 0.6.0
|
|
105
|
+
*/
|
|
106
|
+
export interface ParentJob extends MemberJob {
|
|
107
|
+
readonly enqueue: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
|
|
108
|
+
readonly enqueueMany: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
|
|
109
|
+
readonly execute: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
|
|
110
|
+
readonly poll: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
|
|
111
|
+
readonly attempts: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
|
|
112
|
+
readonly awaitResult: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
|
|
113
|
+
readonly retry: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
|
|
114
|
+
readonly cancel: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
|
|
115
|
+
readonly promote: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
|
|
116
|
+
readonly schedule: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
|
|
117
|
+
readonly unschedule: (...args: ReadonlyArray<never>) => Effect.Effect<any, any, any>
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Naked-parameter conditionals so unions of member jobs distribute (and
|
|
121
|
+
// empty tuples land on `never`, not `unknown`).
|
|
122
|
+
type NameOf<J> = J extends { readonly _tag: infer N extends string } ? N : never
|
|
123
|
+
type PayloadMakeIn<J> = J extends { readonly payloadSchema: infer P extends AnyStructSchema } ? P["~type.make.in"]
|
|
124
|
+
: never
|
|
125
|
+
type PayloadType<J> = J extends { readonly payloadSchema: infer P extends AnyStructSchema } ? P["Type"] : never
|
|
126
|
+
type PayloadEncodingServices<J> = J extends { readonly payloadSchema: infer P extends AnyStructSchema }
|
|
127
|
+
? P["EncodingServices"]
|
|
128
|
+
: never
|
|
129
|
+
type SuccessValue<J> = J extends { readonly successSchema: infer S extends Schema.Top } ? S["Type"] : never
|
|
130
|
+
type SuccessDecodingServices<J> = J extends { readonly successSchema: infer S extends Schema.Top }
|
|
131
|
+
? S["DecodingServices"]
|
|
132
|
+
: never
|
|
133
|
+
type SuccessEncodingServices<J> = J extends { readonly successSchema: infer S extends Schema.Top }
|
|
134
|
+
? S["EncodingServices"]
|
|
135
|
+
: never
|
|
136
|
+
type ErrorValue<J> = J extends { readonly errorSchema: infer E extends Schema.Top } ? E["Type"] : never
|
|
137
|
+
type ErrorDecodingServices<J> = J extends { readonly errorSchema: infer E extends Schema.Top }
|
|
138
|
+
? E["DecodingServices"]
|
|
139
|
+
: never
|
|
140
|
+
type ErrorEncodingServices<J> = J extends { readonly errorSchema: infer E extends Schema.Top }
|
|
141
|
+
? E["EncodingServices"]
|
|
142
|
+
: never
|
|
143
|
+
type PayloadDecodingServices<J> = J extends { readonly payloadSchema: infer P extends AnyStructSchema }
|
|
144
|
+
? P["DecodingServices"]
|
|
145
|
+
: never
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The StoreIds of a flow's members — what `toLayer` requires so the parent
|
|
149
|
+
* worker is guaranteed able to reconcile and cascade into every child store.
|
|
150
|
+
*
|
|
151
|
+
* @since 0.6.0
|
|
152
|
+
*/
|
|
153
|
+
export type MemberStores<J> = J extends { readonly store: Context.Key<infer Id, StoreService> } ? Id : never
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Per-child options accepted by `Flow.children` items — the shared
|
|
157
|
+
* `JobOptions` minus `delay` (flow children run immediately), plus
|
|
158
|
+
* `metadata` merged over the child definition's callback.
|
|
159
|
+
*
|
|
160
|
+
* @since 0.6.0
|
|
161
|
+
*/
|
|
162
|
+
export type ChildOptions = Omit<JobOptions, "delay"> & {
|
|
163
|
+
readonly metadata?: Readonly<Record<string, string>> | undefined
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* One child to fan out: its flow-unique `key` (the idempotency mechanism —
|
|
168
|
+
* re-runs of `fanOut` re-produce the same child, never a second one), the
|
|
169
|
+
* payload, and optional per-child options.
|
|
170
|
+
*
|
|
171
|
+
* @since 0.6.0
|
|
172
|
+
*/
|
|
173
|
+
export interface ChildItem<PayloadInput> {
|
|
174
|
+
readonly key: string
|
|
175
|
+
readonly payload: PayloadInput
|
|
176
|
+
readonly options?: ChildOptions | undefined
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A group of children of one member type, built by `Flow.children`. A
|
|
181
|
+
* `fanOut` handler returns one group or an array of them.
|
|
182
|
+
*
|
|
183
|
+
* @since 0.6.0
|
|
184
|
+
*/
|
|
185
|
+
export interface ChildGroup<out J extends MemberJob = MemberJob> {
|
|
186
|
+
readonly job: J
|
|
187
|
+
readonly items: ReadonlyArray<ChildItem<PayloadMakeIn<J>>>
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A naked-parameter distribution of `ChildGroup` over a union of members,
|
|
192
|
+
* so a group literal must pair a member's `job` with THAT member's payloads
|
|
193
|
+
* (the undistributed `ChildGroup<A | B>` would accept `A`'s job with `B`'s
|
|
194
|
+
* items).
|
|
195
|
+
*
|
|
196
|
+
* @since 0.6.0
|
|
197
|
+
*/
|
|
198
|
+
export type GroupsOf<J> = J extends MemberJob ? ChildGroup<J> : never
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* What `fanOut` returns: children of any of the flow's member types.
|
|
202
|
+
*
|
|
203
|
+
* @since 0.6.0
|
|
204
|
+
*/
|
|
205
|
+
export type ChildrenInput<J extends MemberJob> = GroupsOf<J> | ReadonlyArray<GroupsOf<J>>
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The deterministic id of a flow child. Every component is an arbitrary
|
|
209
|
+
* string (store keys, ids, and child keys may all contain "/"), so the two
|
|
210
|
+
* variable-length boundaries are pinned by a length prefix — distinct
|
|
211
|
+
* (storeKey, flowId, childKey) triples can never alias.
|
|
212
|
+
*
|
|
213
|
+
* @internal
|
|
214
|
+
*/
|
|
215
|
+
export const childJobId = (parentStoreKey: string, flowId: string, childKey: string): string =>
|
|
216
|
+
`flow/${parentStoreKey.length}.${flowId.length}/${parentStoreKey}/${flowId}/${childKey}`
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Declare children of one member type. Payloads are validated through the
|
|
220
|
+
* child's schema when the specs are built; duplicate keys (across ALL
|
|
221
|
+
* groups) fail the fan-out unrecoverably.
|
|
222
|
+
*
|
|
223
|
+
* @since 0.6.0
|
|
224
|
+
*/
|
|
225
|
+
export const children = <J extends MemberJob>(
|
|
226
|
+
job: J,
|
|
227
|
+
items: ReadonlyArray<ChildItem<PayloadMakeIn<J>>>
|
|
228
|
+
): ChildGroup<J> => ({ job, items })
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A child that completed, with its decoded success value.
|
|
232
|
+
*
|
|
233
|
+
* @since 0.6.0
|
|
234
|
+
*/
|
|
235
|
+
export interface CompletedChild<Name extends string, A> {
|
|
236
|
+
readonly key: string
|
|
237
|
+
readonly name: Name
|
|
238
|
+
readonly value: A
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A child that failed terminally. `cause` carries the decoded typed failure
|
|
243
|
+
* — or a die for store-side failures that never produced an exit (stall
|
|
244
|
+
* exhaustion, a nested parent's fail-fast settle).
|
|
245
|
+
*
|
|
246
|
+
* @since 0.6.0
|
|
247
|
+
*/
|
|
248
|
+
export interface FailedChild<Name extends string, E> {
|
|
249
|
+
readonly key: string
|
|
250
|
+
readonly name: Name
|
|
251
|
+
readonly cause: Cause.Cause<E>
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A child that was cancelled — directly in its store, or by a flow settle
|
|
256
|
+
* (fail-fast, or a cancel of the waiting parent).
|
|
257
|
+
*
|
|
258
|
+
* @since 0.6.0
|
|
259
|
+
*/
|
|
260
|
+
export interface CancelledChild<Name extends string> {
|
|
261
|
+
readonly key: string
|
|
262
|
+
readonly name: Name
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
type CompletedOf<J> = J extends MemberJob ? CompletedChild<NameOf<J>, SuccessValue<J>> : never
|
|
266
|
+
type FailedOf<J> = J extends MemberJob ? FailedChild<NameOf<J>, ErrorValue<J>> : never
|
|
267
|
+
type CancelledOf<J> = J extends MemberJob ? CancelledChild<NameOf<J>> : never
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Per-outcome tallies, read straight off the parent's persisted `FlowState`
|
|
271
|
+
* — no dependency-row reads. When `collect` runs, `pending` is 0.
|
|
272
|
+
*
|
|
273
|
+
* @since 0.6.0
|
|
274
|
+
*/
|
|
275
|
+
export interface ChildCounts {
|
|
276
|
+
readonly pending: number
|
|
277
|
+
readonly completed: number
|
|
278
|
+
readonly failed: number
|
|
279
|
+
readonly cancelled: number
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* One settled child, discriminated by `outcome` (and by `name` across
|
|
284
|
+
* member types), decoded through its member's schemas.
|
|
285
|
+
*
|
|
286
|
+
* @since 0.6.0
|
|
287
|
+
*/
|
|
288
|
+
export type SettledChild<J extends MemberJob> =
|
|
289
|
+
| ({ readonly outcome: "completed" } & CompletedOf<J>)
|
|
290
|
+
| ({ readonly outcome: "failed" } & FailedOf<J>)
|
|
291
|
+
| ({ readonly outcome: "cancelled" } & CancelledOf<J>)
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Every settled child, materialized into outcome buckets.
|
|
295
|
+
*
|
|
296
|
+
* @since 0.6.0
|
|
297
|
+
*/
|
|
298
|
+
export interface SettledChildren<J extends MemberJob> {
|
|
299
|
+
readonly completed: ReadonlyArray<Extract<SettledChild<J>, { readonly outcome: "completed" }>>
|
|
300
|
+
readonly failed: ReadonlyArray<Extract<SettledChild<J>, { readonly outcome: "failed" }>>
|
|
301
|
+
readonly cancelled: ReadonlyArray<Extract<SettledChild<J>, { readonly outcome: "cancelled" }>>
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* What `collect` (and `Flow.childResults`) receives: `counts` for free,
|
|
306
|
+
* `all` to materialize the outcome buckets (one array — fine into the
|
|
307
|
+
* tens of thousands), and `stream` to fold huge flows one page at a time.
|
|
308
|
+
*
|
|
309
|
+
* @since 0.6.0
|
|
310
|
+
*/
|
|
311
|
+
export interface ChildResults<J extends MemberJob> {
|
|
312
|
+
readonly counts: ChildCounts
|
|
313
|
+
readonly all: Effect.Effect<SettledChildren<J>>
|
|
314
|
+
readonly stream: Stream.Stream<SettledChild<J>>
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The two-phase flow handler. `fanOut` runs once per flow (persisted phase
|
|
319
|
+
* dispatch — a resumed parent can never fan out twice) and returns the
|
|
320
|
+
* children; `collect` runs after every child settled, with their results.
|
|
321
|
+
* Both draw on the PARENT's attempt budget: a failing `fanOut` retries
|
|
322
|
+
* against it until the manifest lands, a failing `collect` retries with
|
|
323
|
+
* what remains.
|
|
324
|
+
*
|
|
325
|
+
* @since 0.6.0
|
|
326
|
+
*/
|
|
327
|
+
export interface FlowHandlers<
|
|
328
|
+
Parent extends ParentJob,
|
|
329
|
+
Children extends ReadonlyArray<MemberJob>,
|
|
330
|
+
R1,
|
|
331
|
+
R2
|
|
332
|
+
> {
|
|
333
|
+
readonly fanOut: (
|
|
334
|
+
payload: PayloadType<Parent>
|
|
335
|
+
) => Effect.Effect<ChildrenInput<Children[number]>, ErrorValue<Parent>, R1>
|
|
336
|
+
readonly collect: (
|
|
337
|
+
payload: PayloadType<Parent>,
|
|
338
|
+
results: ChildResults<Children[number]>
|
|
339
|
+
) => Effect.Effect<SuccessValue<Parent>, ErrorValue<Parent>, R2>
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* A flow definition. The producer surface (`enqueue`, `execute`, `poll`,
|
|
344
|
+
* `awaitResult`, `schedule`, ...) IS the parent job's — a flow parent is a
|
|
345
|
+
* real job row in the parent store, and a scheduled parent needs no flow
|
|
346
|
+
* awareness in its schedule row.
|
|
347
|
+
*
|
|
348
|
+
* Note on fail-fast (`onChildFailure: "fail"`): the first failed child
|
|
349
|
+
* settles the parent terminally `failed` store-side (`failedReason` names
|
|
350
|
+
* the child; there is no parent exit, so `awaitResult` dies — like stall
|
|
351
|
+
* exhaustion) and the remaining children are cancelled. The failed child's
|
|
352
|
+
* own exit stays inspectable via `childResults`. An admin `retry` of the
|
|
353
|
+
* parent re-enters `collect` with the mixed results.
|
|
354
|
+
*
|
|
355
|
+
* @since 0.6.0
|
|
356
|
+
*/
|
|
357
|
+
export interface Flow<
|
|
358
|
+
Name extends string,
|
|
359
|
+
Parent extends ParentJob,
|
|
360
|
+
Children extends ReadonlyArray<MemberJob>
|
|
361
|
+
> {
|
|
362
|
+
readonly name: Name
|
|
363
|
+
readonly parent: Parent
|
|
364
|
+
readonly children: Children
|
|
365
|
+
/** True when `onChildFailure` is `"fail"`. */
|
|
366
|
+
readonly failFast: boolean
|
|
367
|
+
|
|
368
|
+
readonly enqueue: Parent["enqueue"]
|
|
369
|
+
readonly enqueueMany: Parent["enqueueMany"]
|
|
370
|
+
readonly execute: Parent["execute"]
|
|
371
|
+
readonly poll: Parent["poll"]
|
|
372
|
+
readonly attempts: Parent["attempts"]
|
|
373
|
+
readonly awaitResult: Parent["awaitResult"]
|
|
374
|
+
readonly retry: Parent["retry"]
|
|
375
|
+
readonly cancel: Parent["cancel"]
|
|
376
|
+
readonly promote: Parent["promote"]
|
|
377
|
+
readonly schedule: Parent["schedule"]
|
|
378
|
+
readonly unschedule: Parent["unschedule"]
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The flow's recorded child results, in any parent state: live `counts`
|
|
382
|
+
* plus the `all`/`stream` accessors — children still pending are absent
|
|
383
|
+
* from both.
|
|
384
|
+
*/
|
|
385
|
+
readonly childResults: (
|
|
386
|
+
flowId: JobId
|
|
387
|
+
) => Effect.Effect<
|
|
388
|
+
ChildResults<Children[number]>,
|
|
389
|
+
never,
|
|
390
|
+
| MemberStores<Parent>
|
|
391
|
+
| SuccessDecodingServices<Children[number]>
|
|
392
|
+
| ErrorDecodingServices<Children[number]>
|
|
393
|
+
>
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Register the parent's two phases on a worker (bound to the parent's
|
|
397
|
+
* store). Requires every member store — this is what makes the parent
|
|
398
|
+
* worker the one process guaranteed capable of reconciling and cascading
|
|
399
|
+
* across the whole flow.
|
|
400
|
+
*/
|
|
401
|
+
readonly toLayer: <R1, R2>(
|
|
402
|
+
handlers: FlowHandlers<Parent, Children, R1, R2>,
|
|
403
|
+
options?: RegisterOptions | undefined
|
|
404
|
+
) => Layer.Layer<
|
|
405
|
+
never,
|
|
406
|
+
never,
|
|
407
|
+
| Worker
|
|
408
|
+
// The worker provides CurrentJob around both phases.
|
|
409
|
+
| Exclude<R1, CurrentJob>
|
|
410
|
+
| Exclude<R2, CurrentJob>
|
|
411
|
+
| MemberStores<Parent>
|
|
412
|
+
| MemberStores<Children[number]>
|
|
413
|
+
| PayloadDecodingServices<Parent>
|
|
414
|
+
| SuccessEncodingServices<Parent>
|
|
415
|
+
| ErrorEncodingServices<Parent>
|
|
416
|
+
| PayloadEncodingServices<Children[number]>
|
|
417
|
+
| SuccessDecodingServices<Children[number]>
|
|
418
|
+
| ErrorDecodingServices<Children[number]>
|
|
419
|
+
>
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Define a flow over existing job definitions.
|
|
424
|
+
*
|
|
425
|
+
* Throws (synchronously, at definition time) on duplicate child names or a
|
|
426
|
+
* parent listed among its own children (direct self-recursion). Nesting
|
|
427
|
+
* across DIFFERENT flows is supported — see the module docs.
|
|
428
|
+
*
|
|
429
|
+
* @since 0.6.0
|
|
430
|
+
*/
|
|
431
|
+
export const make = <
|
|
432
|
+
const Name extends string,
|
|
433
|
+
Parent extends ParentJob,
|
|
434
|
+
const Children extends ReadonlyArray<MemberJob>
|
|
435
|
+
>(
|
|
436
|
+
name: Name,
|
|
437
|
+
options: {
|
|
438
|
+
/** The parent job: its store owns the flow. */
|
|
439
|
+
readonly parent: Parent
|
|
440
|
+
/** The closed set of member definitions `fanOut` may produce. */
|
|
441
|
+
readonly children: Children
|
|
442
|
+
/**
|
|
443
|
+
* What a failed child does to the flow:
|
|
444
|
+
* - `"continue"` (default): every child settles; `collect` sees the
|
|
445
|
+
* failures in `results.failed`.
|
|
446
|
+
* - `"fail"`: the first failed child settles the parent as `failed`
|
|
447
|
+
* and cancels the remaining children (see the `Flow` docs).
|
|
448
|
+
*/
|
|
449
|
+
readonly onChildFailure?: "continue" | "fail" | undefined
|
|
450
|
+
}
|
|
451
|
+
): Flow<Name, Parent, Children> => {
|
|
452
|
+
const parent = options.parent
|
|
453
|
+
const byName = new Map<string, MemberJob>()
|
|
454
|
+
for (const child of options.children) {
|
|
455
|
+
if (byName.has(child._tag)) {
|
|
456
|
+
throw new Error(`effect-mq: flow "${name}" declares child "${child._tag}" twice`)
|
|
457
|
+
}
|
|
458
|
+
byName.set(child._tag, child)
|
|
459
|
+
}
|
|
460
|
+
if (byName.has(parent._tag)) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
`effect-mq: flow "${name}" uses "${parent._tag}" as both its parent and one of its own children`
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
const failFast = options.onChildFailure === "fail"
|
|
466
|
+
|
|
467
|
+
// A dependency row decoded into a settled, tagged entry. The outcome
|
|
468
|
+
// follows the DECODED exit where one exists (a defensive net for
|
|
469
|
+
// outcome/exit drift); rows without an exit follow their recorded status.
|
|
470
|
+
// Pending rows decode to undefined (only reachable via `childResults` on
|
|
471
|
+
// an unsettled flow).
|
|
472
|
+
type SettledEntry =
|
|
473
|
+
| { readonly outcome: "completed"; readonly key: string; readonly name: string; readonly value: unknown }
|
|
474
|
+
| { readonly outcome: "failed"; readonly key: string; readonly name: string; readonly cause: Cause.Cause<unknown> }
|
|
475
|
+
| { readonly outcome: "cancelled"; readonly key: string; readonly name: string }
|
|
476
|
+
const decodeRow = (row: FlowChildRecord) =>
|
|
477
|
+
Effect.gen(function*() {
|
|
478
|
+
if (row.status === "pending") return undefined
|
|
479
|
+
if (row.status === "cancelled") {
|
|
480
|
+
const entry: SettledEntry = { outcome: "cancelled", key: row.childKey, name: row.name }
|
|
481
|
+
return entry
|
|
482
|
+
}
|
|
483
|
+
if (row.exit === undefined) {
|
|
484
|
+
// Store-side failure: no exit was ever produced.
|
|
485
|
+
const entry: SettledEntry = {
|
|
486
|
+
outcome: "failed",
|
|
487
|
+
key: row.childKey,
|
|
488
|
+
name: row.name,
|
|
489
|
+
cause: Cause.die(
|
|
490
|
+
new Error(row.failedReason ?? `effect-mq: flow child "${row.childKey}" failed without a result`)
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
return entry
|
|
494
|
+
}
|
|
495
|
+
const member = byName.get(row.name)
|
|
496
|
+
if (member === undefined) {
|
|
497
|
+
const entry: SettledEntry = {
|
|
498
|
+
outcome: "failed",
|
|
499
|
+
key: row.childKey,
|
|
500
|
+
name: row.name,
|
|
501
|
+
cause: Cause.die(
|
|
502
|
+
new Error(`effect-mq: flow "${name}" has no member definition for child "${row.name}"`)
|
|
503
|
+
)
|
|
504
|
+
}
|
|
505
|
+
return entry
|
|
506
|
+
}
|
|
507
|
+
const decoded = yield* Schema.decodeUnknownEffect(member.exitSchema)(row.exit).pipe(Effect.orDie)
|
|
508
|
+
// SAFETY: a member's exitSchema Type is Exit<Success, Error> by
|
|
509
|
+
// construction in Job.make.
|
|
510
|
+
const exit = decoded as Exit.Exit<unknown, unknown>
|
|
511
|
+
const entry: SettledEntry = Exit.isSuccess(exit)
|
|
512
|
+
? { outcome: "completed", key: row.childKey, name: row.name, value: exit.value }
|
|
513
|
+
: { outcome: "failed", key: row.childKey, name: row.name, cause: exit.cause }
|
|
514
|
+
return entry
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
// Build the collect-facing accessors over a flow's dependency rows.
|
|
518
|
+
// Accessors are lazy and may run in the caller's fiber later, so they
|
|
519
|
+
// carry the context captured at construction (the registration context
|
|
520
|
+
// under a worker; the caller's own context via `childResults`) — that is
|
|
521
|
+
// what lets their public types declare no requirements.
|
|
522
|
+
const makeResults = (
|
|
523
|
+
store: StoreService,
|
|
524
|
+
flow: FlowState,
|
|
525
|
+
flowId: JobId,
|
|
526
|
+
services: Context.Context<never>
|
|
527
|
+
) => {
|
|
528
|
+
// SAFETY: the captured context carries the members' decoding services,
|
|
529
|
+
// declared on toLayer's / childResults' public signatures.
|
|
530
|
+
const withCaptured = <A, E>(effect: Effect.Effect<A, E, unknown>): Effect.Effect<A, E> =>
|
|
531
|
+
effect.pipe(
|
|
532
|
+
Effect.updateContext((input) => Context.merge(input, services) as Context.Context<unknown>)
|
|
533
|
+
) as Effect.Effect<A, E>
|
|
534
|
+
const decodePage = (cursor: string | undefined) =>
|
|
535
|
+
store.listChildResults(flowId, { cursor }).pipe(
|
|
536
|
+
Effect.orDie,
|
|
537
|
+
Effect.flatMap((page) =>
|
|
538
|
+
Effect.forEach(page.items, decodeRow).pipe(
|
|
539
|
+
Effect.map((entries) =>
|
|
540
|
+
[
|
|
541
|
+
entries.filter((entry) => entry !== undefined),
|
|
542
|
+
Option.fromNullishOr(page.cursor)
|
|
543
|
+
] as const
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
)
|
|
547
|
+
)
|
|
548
|
+
const stream = Stream.paginate<string | undefined, SettledEntry>(
|
|
549
|
+
undefined,
|
|
550
|
+
(cursor) => withCaptured(decodePage(cursor))
|
|
551
|
+
)
|
|
552
|
+
const all = withCaptured(Effect.gen(function*() {
|
|
553
|
+
const completed: Array<SettledEntry> = []
|
|
554
|
+
const failed: Array<SettledEntry> = []
|
|
555
|
+
const cancelled: Array<SettledEntry> = []
|
|
556
|
+
let cursor: string | undefined
|
|
557
|
+
do {
|
|
558
|
+
const [entries, next] = yield* decodePage(cursor)
|
|
559
|
+
for (const entry of entries) {
|
|
560
|
+
if (entry.outcome === "completed") completed.push(entry)
|
|
561
|
+
else if (entry.outcome === "failed") failed.push(entry)
|
|
562
|
+
else cancelled.push(entry)
|
|
563
|
+
}
|
|
564
|
+
cursor = Option.getOrUndefined(next)
|
|
565
|
+
} while (cursor !== undefined)
|
|
566
|
+
return { completed, failed, cancelled }
|
|
567
|
+
}))
|
|
568
|
+
const counts = {
|
|
569
|
+
pending: flow.pending,
|
|
570
|
+
completed: flow.completed,
|
|
571
|
+
failed: flow.failed,
|
|
572
|
+
cancelled: flow.cancelled
|
|
573
|
+
}
|
|
574
|
+
return { counts, all, stream }
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Turn the fanOut handler's groups into complete FlowChildSpecs:
|
|
578
|
+
// deterministic ids, `parent` envelopes, encoded payloads, and the
|
|
579
|
+
// fan-out run's span context for cross-store trace linking. Definition
|
|
580
|
+
// bugs (unknown members, duplicate keys) die UNRECOVERABLY — retrying a
|
|
581
|
+
// deterministic bug burns the budget for nothing.
|
|
582
|
+
const buildSpecs = (
|
|
583
|
+
input: ChildrenInput<MemberJob>,
|
|
584
|
+
context: JobContext,
|
|
585
|
+
parentDepth: number
|
|
586
|
+
) =>
|
|
587
|
+
Effect.gen(function*() {
|
|
588
|
+
// Depth rides the parent envelope explicitly (never parsed out of ids
|
|
589
|
+
// — keys and ids are arbitrary user strings). Definitions cannot be
|
|
590
|
+
// cycle-checked statically (a job does not know which flows parent
|
|
591
|
+
// it), so a runaway A→B→A recursion surfaces here instead of growing
|
|
592
|
+
// forever.
|
|
593
|
+
const depth = parentDepth + 1
|
|
594
|
+
if (depth > 8) {
|
|
595
|
+
return yield* Effect.die(unrecoverable(
|
|
596
|
+
new Error(
|
|
597
|
+
`effect-mq: flow "${name}" exceeds the nesting depth limit (8) — flow definitions must not form cycles`
|
|
598
|
+
)
|
|
599
|
+
))
|
|
600
|
+
}
|
|
601
|
+
const groups = Array.isArray(input) ? input : [input]
|
|
602
|
+
const span = yield* Effect.currentSpan.pipe(
|
|
603
|
+
Effect.map((current) => ({
|
|
604
|
+
traceId: current.traceId,
|
|
605
|
+
spanId: current.spanId,
|
|
606
|
+
sampled: current.sampled,
|
|
607
|
+
delayed: false
|
|
608
|
+
})),
|
|
609
|
+
Effect.catchTag("NoSuchElementError", () => Effect.succeed(undefined))
|
|
610
|
+
)
|
|
611
|
+
const seen = new Set<string>()
|
|
612
|
+
const specs: Array<{ childKey: string; storeKey: string; request: EnqueueRequest }> = []
|
|
613
|
+
for (const group of groups) {
|
|
614
|
+
const member = byName.get(group.job._tag)
|
|
615
|
+
if (member === undefined) {
|
|
616
|
+
return yield* Effect.die(unrecoverable(
|
|
617
|
+
new Error(`effect-mq: flow "${name}" fanned out to "${group.job._tag}", which is not a declared child`)
|
|
618
|
+
))
|
|
619
|
+
}
|
|
620
|
+
for (const item of group.items) {
|
|
621
|
+
if (item.key === "") {
|
|
622
|
+
return yield* Effect.die(unrecoverable(
|
|
623
|
+
new Error(`effect-mq: flow "${name}" produced a child with an empty key`)
|
|
624
|
+
))
|
|
625
|
+
}
|
|
626
|
+
if (seen.has(item.key)) {
|
|
627
|
+
return yield* Effect.die(unrecoverable(
|
|
628
|
+
new Error(`effect-mq: flow "${name}" produced duplicate child key "${item.key}"`)
|
|
629
|
+
))
|
|
630
|
+
}
|
|
631
|
+
seen.add(item.key)
|
|
632
|
+
// Payload validation/encode failures are deterministic definition
|
|
633
|
+
// bugs like duplicate keys: retrying the fan-out would burn the
|
|
634
|
+
// whole attempt budget on the same throw, so die unrecoverably.
|
|
635
|
+
let payload: unknown
|
|
636
|
+
try {
|
|
637
|
+
payload = member.payloadSchema.make(item.payload)
|
|
638
|
+
} catch (error) {
|
|
639
|
+
return yield* Effect.die(unrecoverable(new Error(
|
|
640
|
+
`effect-mq: flow "${name}" child "${item.key}" payload failed validation: ${String(error)}`
|
|
641
|
+
)))
|
|
642
|
+
}
|
|
643
|
+
const encoded = yield* Schema.encodeEffect(member.payloadJsonSchema)(payload).pipe(
|
|
644
|
+
Effect.catch((error) =>
|
|
645
|
+
Effect.die(unrecoverable(new Error(
|
|
646
|
+
`effect-mq: flow "${name}" child "${item.key}" payload failed to encode: ${String(error)}`
|
|
647
|
+
)))
|
|
648
|
+
)
|
|
649
|
+
)
|
|
650
|
+
// SAFETY: `metadata` is declared with a `never` parameter only to
|
|
651
|
+
// make the structural constraint universal; at runtime it accepts
|
|
652
|
+
// its own definition's payload, which is what `payload` is.
|
|
653
|
+
const memberMetadata = member.metadata?.(payload as never)
|
|
654
|
+
const itemOptions = item.options
|
|
655
|
+
specs.push({
|
|
656
|
+
childKey: item.key,
|
|
657
|
+
storeKey: member.store.key,
|
|
658
|
+
request: {
|
|
659
|
+
// Namespaced by the parent store key: two parent stores
|
|
660
|
+
// sharing one child store can never collide on parent ids.
|
|
661
|
+
id: JobId(childJobId(parent.store.key, context.jobId, item.key)),
|
|
662
|
+
name: member._tag,
|
|
663
|
+
queue: member.queue,
|
|
664
|
+
payload: encoded,
|
|
665
|
+
metadata: { ...memberMetadata, ...itemOptions?.metadata },
|
|
666
|
+
priority: itemOptions?.priority ?? member.defaults.priority,
|
|
667
|
+
attemptsMax: Math.max(1, itemOptions?.attempts ?? member.defaults.attempts),
|
|
668
|
+
backoff: itemOptions?.backoff !== undefined
|
|
669
|
+
? normalizeBackoff(itemOptions.backoff)
|
|
670
|
+
: member.defaults.backoff,
|
|
671
|
+
keep: itemOptions?.keep !== undefined
|
|
672
|
+
? normalizeKeep(itemOptions.keep)
|
|
673
|
+
: member.defaults.keep,
|
|
674
|
+
timeoutMs: itemOptions?.timeout !== undefined
|
|
675
|
+
? Duration.toMillis(itemOptions.timeout)
|
|
676
|
+
: member.defaults.timeoutMs,
|
|
677
|
+
// The child key is the idempotency mechanism; the member's
|
|
678
|
+
// idempotencyKey/dedupe callbacks do NOT apply to flow
|
|
679
|
+
// children (see the module docs).
|
|
680
|
+
dedupe: undefined,
|
|
681
|
+
trace: span,
|
|
682
|
+
parent: {
|
|
683
|
+
flowName: name,
|
|
684
|
+
flowId: context.jobId,
|
|
685
|
+
childKey: item.key,
|
|
686
|
+
parentStoreKey: parent.store.key,
|
|
687
|
+
depth
|
|
688
|
+
},
|
|
689
|
+
delayMs: 0
|
|
690
|
+
}
|
|
691
|
+
})
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return specs
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
// The lazy accessors and phase handlers run under a captured context;
|
|
698
|
+
// never capture the ambient Scope or span (they must come from the
|
|
699
|
+
// executing fiber), mirroring the worker's registration capture.
|
|
700
|
+
const captureServices = Effect.map(
|
|
701
|
+
Effect.context<never>(),
|
|
702
|
+
(context) => context.pipe(Context.omit(Scope_.Scope, Tracer.ParentSpan))
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
const toLayer = (
|
|
706
|
+
handlers: FlowHandlers<ParentJob, ReadonlyArray<MemberJob>, unknown, unknown>,
|
|
707
|
+
registerOptions?: RegisterOptions | undefined
|
|
708
|
+
) => {
|
|
709
|
+
const descriptor: FlowDescriptor = {
|
|
710
|
+
name,
|
|
711
|
+
parent: {
|
|
712
|
+
_tag: parent._tag,
|
|
713
|
+
queue: parent.queue,
|
|
714
|
+
store: parent.store,
|
|
715
|
+
payloadJsonSchema: parent.payloadJsonSchema,
|
|
716
|
+
exitSchema: parent.exitSchema,
|
|
717
|
+
retryable: parent.retryable
|
|
718
|
+
},
|
|
719
|
+
failFast,
|
|
720
|
+
childStores: options.children.map((child) => child.store),
|
|
721
|
+
fanOut: (payload, context, parentDepth) =>
|
|
722
|
+
// SAFETY: the worker decodes through the parent's payload schema
|
|
723
|
+
// before calling this, so `payload` is the parent's payload type.
|
|
724
|
+
handlers.fanOut(payload as never).pipe(
|
|
725
|
+
Effect.flatMap((produced) => buildSpecs(produced, context, parentDepth))
|
|
726
|
+
),
|
|
727
|
+
collect: (payload, flowState, context) =>
|
|
728
|
+
Effect.gen(function*() {
|
|
729
|
+
const store = yield* parent.store
|
|
730
|
+
const services = yield* captureServices
|
|
731
|
+
const results = makeResults(store, flowState, context.jobId, services)
|
|
732
|
+
// SAFETY: same as fanOut for `payload`; the accessors decode
|
|
733
|
+
// through each member's schemas, matching ChildResults.
|
|
734
|
+
return yield* handlers.collect(payload as never, results as never)
|
|
735
|
+
})
|
|
736
|
+
}
|
|
737
|
+
return Layer.effectDiscard(
|
|
738
|
+
Effect.flatMap(Worker, (worker) => worker.registerFlow(descriptor, registerOptions))
|
|
739
|
+
)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const emptyFlow: FlowState = { failFast, pending: 0, completed: 0, failed: 0, cancelled: 0 }
|
|
743
|
+
const childResults = (flowId: JobId) =>
|
|
744
|
+
Effect.gen(function*() {
|
|
745
|
+
const store = yield* parent.store
|
|
746
|
+
const services = yield* captureServices
|
|
747
|
+
const record = yield* store.getJob(flowId).pipe(Effect.orDie)
|
|
748
|
+
const flowState = Option.isSome(record) && record.value.flow !== undefined
|
|
749
|
+
? record.value.flow
|
|
750
|
+
: emptyFlow
|
|
751
|
+
return makeResults(store, flowState, flowId, services)
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
// SAFETY: the `Flow` interface re-declares the precise member signatures
|
|
755
|
+
// (delegated producer methods carry the parent's own types; toLayer's
|
|
756
|
+
// requirements are declared there); the implementation is assembled
|
|
757
|
+
// dynamically, mirroring Job.make's prototype pattern.
|
|
758
|
+
const flow: any = {
|
|
759
|
+
name,
|
|
760
|
+
parent,
|
|
761
|
+
children: options.children,
|
|
762
|
+
failFast,
|
|
763
|
+
enqueue: parent.enqueue,
|
|
764
|
+
enqueueMany: parent.enqueueMany,
|
|
765
|
+
execute: parent.execute,
|
|
766
|
+
poll: parent.poll,
|
|
767
|
+
attempts: parent.attempts,
|
|
768
|
+
awaitResult: parent.awaitResult,
|
|
769
|
+
retry: parent.retry,
|
|
770
|
+
cancel: parent.cancel,
|
|
771
|
+
promote: parent.promote,
|
|
772
|
+
schedule: parent.schedule,
|
|
773
|
+
unschedule: parent.unschedule,
|
|
774
|
+
childResults,
|
|
775
|
+
toLayer
|
|
776
|
+
}
|
|
777
|
+
return flow
|
|
778
|
+
}
|