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
|
@@ -36,6 +36,7 @@ import { sql } from "drizzle-orm"
|
|
|
36
36
|
import {
|
|
37
37
|
type AnyPgColumnBuilder,
|
|
38
38
|
bigint,
|
|
39
|
+
bigserial,
|
|
39
40
|
boolean,
|
|
40
41
|
index,
|
|
41
42
|
integer,
|
|
@@ -56,6 +57,10 @@ type BackoffPolicy = JobStore.BackoffPolicy
|
|
|
56
57
|
type KeepPolicy = JobStore.KeepPolicy
|
|
57
58
|
type AttemptOutcome = JobStore.AttemptRecord["outcome"]
|
|
58
59
|
type TraceContext = JobStore.TraceContext
|
|
60
|
+
type ParentEnvelope = JobStore.ParentEnvelope
|
|
61
|
+
type EnqueueRequest = JobStore.EnqueueRequest
|
|
62
|
+
type FlowChildStatus = JobStore.FlowChildRecord["status"]
|
|
63
|
+
type FlowChildReport = JobStore.FlowChildReport
|
|
59
64
|
|
|
60
65
|
/**
|
|
61
66
|
* Table-factory options: `extraConfig` receives the table's columns (exactly
|
|
@@ -89,6 +94,14 @@ const jobsColumns = <JobName extends string, Queue extends string>() => ({
|
|
|
89
94
|
cancelRequested: boolean("cancel_requested").notNull().default(false),
|
|
90
95
|
dedupeKey: text("dedupe_key"),
|
|
91
96
|
trace: jsonb("trace").$type<TraceContext>(),
|
|
97
|
+
/** Flow-parent link on child jobs (opaque envelope, like `trace`). */
|
|
98
|
+
parent: jsonb("parent").$type<ParentEnvelope>(),
|
|
99
|
+
/** Flow bookkeeping: NULL together until a FanOut ack lands the manifest. */
|
|
100
|
+
flowFailFast: boolean("flow_fail_fast"),
|
|
101
|
+
flowPending: integer("flow_pending"),
|
|
102
|
+
flowCompleted: integer("flow_completed"),
|
|
103
|
+
flowFailed: integer("flow_failed"),
|
|
104
|
+
flowCancelled: integer("flow_cancelled"),
|
|
92
105
|
runAt: timestamp("run_at", { withTimezone: true, mode: "date" }).notNull(),
|
|
93
106
|
enqueuedAt: timestamp("enqueued_at", { withTimezone: true, mode: "date" }).notNull(),
|
|
94
107
|
processedAt: timestamp("processed_at", { withTimezone: true, mode: "date" }),
|
|
@@ -201,6 +214,8 @@ const schedulesColumns = <JobName extends string, Queue extends string>() => ({
|
|
|
201
214
|
backoff: jsonb("backoff").$type<BackoffPolicy>(),
|
|
202
215
|
keep: jsonb("keep").$type<KeepPolicy>(),
|
|
203
216
|
timeoutMs: bigint("timeout_ms", { mode: "number" }),
|
|
217
|
+
/** Ownership label for declarative reconciliation; NULL = never pruned. */
|
|
218
|
+
group: text("group_name"),
|
|
204
219
|
nextRunAt: timestamp("next_run_at", { withTimezone: true, mode: "date" }).notNull()
|
|
205
220
|
})
|
|
206
221
|
|
|
@@ -243,6 +258,75 @@ export const mqDedupe = <JobName extends string = string>(
|
|
|
243
258
|
...options?.extraConfig?.(table) ?? []
|
|
244
259
|
])
|
|
245
260
|
|
|
261
|
+
const flowChildrenColumns = () => ({
|
|
262
|
+
/** The parent (flow) job's id in this store. */
|
|
263
|
+
flowId: text("flow_id").notNull().$type<JobId>(),
|
|
264
|
+
/** Unique within the flow (the idempotency mechanism). */
|
|
265
|
+
childKey: text("child_key").notNull(),
|
|
266
|
+
/** The child job's name (projection of `spec.name` for dashboards). */
|
|
267
|
+
name: text("name").notNull(),
|
|
268
|
+
/** The CHILD store's context-key string (children may live elsewhere). */
|
|
269
|
+
storeKey: text("store_key").notNull(),
|
|
270
|
+
/** The FULL `EnqueueRequest`, so the sweeper can re-enqueue from storage. */
|
|
271
|
+
spec: jsonb("spec").notNull().$type<EnqueueRequest>(),
|
|
272
|
+
status: text("status").notNull().$type<FlowChildStatus>(),
|
|
273
|
+
/** The child's schema-encoded exit; NULL for store-side failures. */
|
|
274
|
+
exit: jsonb("exit"),
|
|
275
|
+
failedReason: text("failed_reason"),
|
|
276
|
+
/** True once no cancel needs delivering into the child's store. */
|
|
277
|
+
cascaded: boolean("cascaded").notNull(),
|
|
278
|
+
pendingSince: timestamp("pending_since", { withTimezone: true, mode: "date" }).notNull()
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Flow dependency rows (one per fan-out child; see `JobStore.FlowChildRecord`).
|
|
283
|
+
* Lives next to the PARENT store's jobs table.
|
|
284
|
+
*
|
|
285
|
+
* @since 0.6.0
|
|
286
|
+
*/
|
|
287
|
+
export const mqFlowChildren = (
|
|
288
|
+
tableName = "effect_mq_flow_children",
|
|
289
|
+
options?: MqTableOptions<ReturnType<typeof flowChildrenColumns>>
|
|
290
|
+
) =>
|
|
291
|
+
pgTable(tableName, flowChildrenColumns(), (table) => [
|
|
292
|
+
primaryKey({ columns: [table.flowId, table.childKey] }),
|
|
293
|
+
// flowSweepWork reconcile: pending rows older than the threshold.
|
|
294
|
+
index(`${tableName}_pending_idx`)
|
|
295
|
+
.on(table.pendingSince)
|
|
296
|
+
.where(sql`${table.status} = 'pending'`),
|
|
297
|
+
// flowSweepWork cascade: cancelled rows not yet delivered.
|
|
298
|
+
index(`${tableName}_cascade_idx`)
|
|
299
|
+
.on(table.flowId)
|
|
300
|
+
.where(sql`${table.status} = 'cancelled' AND NOT ${table.cascaded}`),
|
|
301
|
+
...options?.extraConfig?.(table) ?? []
|
|
302
|
+
])
|
|
303
|
+
|
|
304
|
+
const flowOutboxColumns = () => ({
|
|
305
|
+
/** Store-assigned, oldest-first; exposed to callers as an opaque string. */
|
|
306
|
+
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
307
|
+
/** The flow definition's name, for relay routing. */
|
|
308
|
+
flowName: text("flow_name").notNull(),
|
|
309
|
+
/** The PARENT store's context-key string, for relay routing. */
|
|
310
|
+
parentStoreKey: text("parent_store_key").notNull(),
|
|
311
|
+
/** The full `FlowChildReport` to deliver into the parent store. */
|
|
312
|
+
report: jsonb("report").notNull().$type<FlowChildReport>()
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Undelivered child-result reports (one per terminal transition of an
|
|
317
|
+
* envelope-carrying job; see `JobStore.OutboxEntry`). Lives next to the
|
|
318
|
+
* CHILD store's jobs table.
|
|
319
|
+
*
|
|
320
|
+
* @since 0.6.0
|
|
321
|
+
*/
|
|
322
|
+
export const mqFlowOutbox = (
|
|
323
|
+
tableName = "effect_mq_flow_outbox",
|
|
324
|
+
options?: MqTableOptions<ReturnType<typeof flowOutboxColumns>>
|
|
325
|
+
) =>
|
|
326
|
+
pgTable(tableName, flowOutboxColumns(), (table) => [
|
|
327
|
+
...options?.extraConfig?.(table) ?? []
|
|
328
|
+
])
|
|
329
|
+
|
|
246
330
|
const queueControlColumns = <Queue extends string>() => ({
|
|
247
331
|
queue: text("queue").primaryKey().$type<Queue>(),
|
|
248
332
|
paused: boolean("paused").notNull().default(false)
|
|
@@ -285,3 +369,13 @@ export type MqQueueControlTable = ReturnType<typeof mqQueueControl<any>>
|
|
|
285
369
|
* @since 0.3.0
|
|
286
370
|
*/
|
|
287
371
|
export type MqDedupeTable = ReturnType<typeof mqDedupe<any>>
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* @since 0.6.0
|
|
375
|
+
*/
|
|
376
|
+
export type MqFlowChildrenTable = ReturnType<typeof mqFlowChildren>
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* @since 0.6.0
|
|
380
|
+
*/
|
|
381
|
+
export type MqFlowOutboxTable = ReturnType<typeof mqFlowOutbox>
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
* @since 0.1.0
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Cross-store parent-child flows: `Flow.make`, `Flow.children`, the
|
|
9
|
+
* two-phase `fanOut`/`collect` handler.
|
|
10
|
+
*
|
|
11
|
+
* @since 0.6.0
|
|
12
|
+
*/
|
|
13
|
+
export * as Flow from "./Flow.ts"
|
|
14
|
+
|
|
7
15
|
/**
|
|
8
16
|
* Schema-first job definitions: `Job.make`, `enqueue`, `toLayer`.
|
|
9
17
|
*
|
|
@@ -11,6 +19,14 @@
|
|
|
11
19
|
*/
|
|
12
20
|
export * as Job from "./Job.ts"
|
|
13
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Declarative schedule reconciliation: declare a service's full schedule
|
|
24
|
+
* set as a layer; startup upserts it and detects deletion drift.
|
|
25
|
+
*
|
|
26
|
+
* @since 0.5.0
|
|
27
|
+
*/
|
|
28
|
+
export * as JobSchedules from "./JobSchedules.ts"
|
|
29
|
+
|
|
14
30
|
/**
|
|
15
31
|
* The storage seam: the `JobStore` service, job records and typed errors.
|
|
16
32
|
*
|
|
@@ -85,6 +85,18 @@ const toRecord = (hash: ReadonlyMap<string, string>): JobStore.JobRecord => ({
|
|
|
85
85
|
cancelRequested: hash.get("cancelRequested") === "1",
|
|
86
86
|
dedupeKey: optionalString(hash.get("dedupeKey")),
|
|
87
87
|
trace: optionalJson<JobStore.TraceContext>(hash.get("trace")),
|
|
88
|
+
parent: optionalJson<JobStore.ParentEnvelope>(hash.get("parent")),
|
|
89
|
+
// Manifest presence IS the phase marker: hashes written before flows (or
|
|
90
|
+
// parents that never fanned out) simply lack the flow fields.
|
|
91
|
+
flow: optionalString(hash.get("flowPending")) === undefined
|
|
92
|
+
? undefined
|
|
93
|
+
: {
|
|
94
|
+
failFast: hash.get("flowFailFast") === "1",
|
|
95
|
+
pending: Number(hash.get("flowPending")),
|
|
96
|
+
completed: Number(hash.get("flowCompleted") ?? 0),
|
|
97
|
+
failed: Number(hash.get("flowFailed") ?? 0),
|
|
98
|
+
cancelled: Number(hash.get("flowCancelled") ?? 0)
|
|
99
|
+
},
|
|
88
100
|
runAt: Number(hash.get("runAt") ?? 0),
|
|
89
101
|
enqueuedAt: Number(hash.get("enqueuedAt") ?? 0),
|
|
90
102
|
processedAt: optionalNumber(hash.get("processedAt")),
|
|
@@ -107,6 +119,7 @@ const toSchedule = (hash: ReadonlyMap<string, string>): JobStore.ScheduleRecord
|
|
|
107
119
|
backoff: optionalJson<JobStore.BackoffPolicy>(hash.get("backoff")),
|
|
108
120
|
keep: optionalJson<JobStore.KeepPolicy>(hash.get("keep")),
|
|
109
121
|
timeoutMs: optionalNumber(hash.get("timeoutMs")),
|
|
122
|
+
group: optionalString(hash.get("group")),
|
|
110
123
|
nextRunAt: Number(hash.get("nextRunAt") ?? 0)
|
|
111
124
|
})
|
|
112
125
|
|
|
@@ -114,15 +127,68 @@ const toSchedule = (hash: ReadonlyMap<string, string>): JobStore.ScheduleRecord
|
|
|
114
127
|
const asArray = <A>(value: ReadonlyArray<A> | Record<string, never>): ReadonlyArray<A> =>
|
|
115
128
|
Array.isArray(value) ? value : []
|
|
116
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Decode one outbox zset member: `<seq>\0<json>` where the json carries the
|
|
132
|
+
* verbatim parent envelope plus the terminal outcome. The full member string
|
|
133
|
+
* is the opaque entry id (deleteOutbox is then a plain ZREM).
|
|
134
|
+
*/
|
|
135
|
+
const toOutboxEntry = (member: string): JobStore.OutboxEntry => {
|
|
136
|
+
const sep = member.indexOf("\u0000")
|
|
137
|
+
const body: {
|
|
138
|
+
parent: JobStore.ParentEnvelope
|
|
139
|
+
outcome: JobStore.FlowChildReport["outcome"]
|
|
140
|
+
exit?: unknown
|
|
141
|
+
failedReason?: string
|
|
142
|
+
} = JSON.parse(member.slice(sep + 1))
|
|
143
|
+
return {
|
|
144
|
+
id: member,
|
|
145
|
+
flowName: body.parent.flowName,
|
|
146
|
+
parentStoreKey: body.parent.parentStoreKey,
|
|
147
|
+
report: {
|
|
148
|
+
flowId: body.parent.flowId,
|
|
149
|
+
childKey: body.parent.childKey,
|
|
150
|
+
outcome: body.outcome,
|
|
151
|
+
// The exit key is omitted entirely when absent (ledger convention),
|
|
152
|
+
// so a legitimate encoded null exit survives the round trip.
|
|
153
|
+
exit: Object.hasOwn(body, "exit") ? body.exit : undefined,
|
|
154
|
+
failedReason: body.failedReason
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
117
159
|
const JOB_STATES: ReadonlyArray<JobStore.JobState> = [
|
|
118
160
|
"waiting",
|
|
119
161
|
"delayed",
|
|
120
162
|
"active",
|
|
163
|
+
"waiting-children",
|
|
121
164
|
"completed",
|
|
122
165
|
"failed",
|
|
123
166
|
"cancelled"
|
|
124
167
|
]
|
|
125
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Fold one positional dependency-row tuple into a `FlowChildRecord`. The
|
|
171
|
+
* order must stay in lockstep with the HMGET field list in the
|
|
172
|
+
* `listChildResults` script:
|
|
173
|
+
* childKey, storeKey, childJobId, name, status, exit, failedReason, cascaded.
|
|
174
|
+
*/
|
|
175
|
+
const toChildRecord = (
|
|
176
|
+
flowId: JobStore.JobId,
|
|
177
|
+
row: ReadonlyArray<string>
|
|
178
|
+
): JobStore.FlowChildRecord => ({
|
|
179
|
+
flowId,
|
|
180
|
+
childKey: row[0] ?? "",
|
|
181
|
+
storeKey: row[1] ?? "",
|
|
182
|
+
childJobId: JobStore.JobId(row[2] ?? ""),
|
|
183
|
+
name: row[3] ?? "",
|
|
184
|
+
// SAFETY: the status field is only ever written with FlowChildRecord
|
|
185
|
+
// status members ("pending" at insert, a report/settle outcome after).
|
|
186
|
+
status: (row[4] ?? "pending") as JobStore.FlowChildRecord["status"],
|
|
187
|
+
exit: optionalJson(row[5]),
|
|
188
|
+
failedReason: optionalString(row[6]),
|
|
189
|
+
cascaded: row[7] === "1"
|
|
190
|
+
})
|
|
191
|
+
|
|
126
192
|
/**
|
|
127
193
|
* Build a `RedisJobStore` service. Needs the `Redis` service and a `Scope`
|
|
128
194
|
* (the wake-up subscription and the optional history sweeper live in it).
|
|
@@ -159,6 +225,11 @@ export const make = (
|
|
|
159
225
|
const evalEnqueueMany = redis.eval(scripts.enqueueMany)
|
|
160
226
|
const evalSweepState = redis.eval(scripts.sweepState)
|
|
161
227
|
const evalSweepDedupes = redis.eval(scripts.sweepDedupes)
|
|
228
|
+
const evalFanOut = redis.eval(scripts.fanOut)
|
|
229
|
+
const evalRecordChildResults = redis.eval(scripts.recordChildResults)
|
|
230
|
+
const evalListChildResults = redis.eval(scripts.listChildResults)
|
|
231
|
+
const evalFlowSweepWork = redis.eval(scripts.flowSweepWork)
|
|
232
|
+
const evalMarkChildrenCascaded = redis.eval(scripts.markChildrenCascaded)
|
|
162
233
|
|
|
163
234
|
// Wake protocol: a queue-filtered waiter registry (same-process wake-ups
|
|
164
235
|
// never depend on the pub/sub round trip), with the channel carrying
|
|
@@ -281,9 +352,71 @@ export const make = (
|
|
|
281
352
|
request.dedupe?.ttlMs === undefined ? "" : String(request.dedupe.ttlMs),
|
|
282
353
|
request.dedupe?.extend === true ? "1" : "0",
|
|
283
354
|
request.dedupe?.replace === true ? "1" : "0",
|
|
284
|
-
request.trace === undefined ? "" : JSON.stringify(request.trace)
|
|
355
|
+
request.trace === undefined ? "" : JSON.stringify(request.trace),
|
|
356
|
+
request.parent === undefined ? "" : JSON.stringify(request.parent)
|
|
285
357
|
)
|
|
286
358
|
|
|
359
|
+
// The FanOut ack. Large manifests chunk their dependency rows across
|
|
360
|
+
// several lock-token-guarded script calls (ARGV strides, like
|
|
361
|
+
// enqueueMany); only the FINAL chunk writes the manifest and flips the
|
|
362
|
+
// state, so a crash mid-staging leaves the job active and recoverable —
|
|
363
|
+
// the next attempt's first chunk clears the orphaned staged rows.
|
|
364
|
+
const fanOutAck = (
|
|
365
|
+
id: JobStore.JobId,
|
|
366
|
+
token: string,
|
|
367
|
+
failFast: boolean,
|
|
368
|
+
children: ReadonlyArray<JobStore.FlowChildSpec>
|
|
369
|
+
) =>
|
|
370
|
+
Effect.gen(function*() {
|
|
371
|
+
if (children.some((child) => child.request.id === undefined)) {
|
|
372
|
+
// Validate BEFORE any script call, so a bad spec cannot leave the
|
|
373
|
+
// job half-acked (rows staged, ledger written, still active).
|
|
374
|
+
return yield* new JobStore.JobStoreError({
|
|
375
|
+
message: "FanOut child specs require an explicit request.id"
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
const now = yield* Clock.currentTimeMillis
|
|
379
|
+
const chunks: Array<ReadonlyArray<JobStore.FlowChildSpec>> = []
|
|
380
|
+
for (let start = 0; start < children.length; start += 500) {
|
|
381
|
+
chunks.push(children.slice(start, start + 500))
|
|
382
|
+
}
|
|
383
|
+
// An empty manifest still needs the final (state-flipping) call.
|
|
384
|
+
if (chunks.length === 0) chunks.push([])
|
|
385
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
386
|
+
const chunk = chunks[i]
|
|
387
|
+
if (chunk === undefined) continue
|
|
388
|
+
const items: Array<string> = []
|
|
389
|
+
for (const child of chunk) {
|
|
390
|
+
items.push(
|
|
391
|
+
child.childKey,
|
|
392
|
+
child.storeKey,
|
|
393
|
+
child.request.id ?? "",
|
|
394
|
+
child.request.name,
|
|
395
|
+
JSON.stringify(child.request)
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
const reply: { error?: string; wake?: boolean; queue?: string } = JSON.parse(
|
|
399
|
+
yield* evalFanOut(
|
|
400
|
+
prefix,
|
|
401
|
+
id,
|
|
402
|
+
token,
|
|
403
|
+
i === chunks.length - 1 ? "1" : "0",
|
|
404
|
+
i === 0 ? "1" : "0",
|
|
405
|
+
failFast ? "1" : "0",
|
|
406
|
+
children.length,
|
|
407
|
+
now,
|
|
408
|
+
chunk.length,
|
|
409
|
+
items
|
|
410
|
+
).pipe(Effect.mapError(storeError("ack failed")))
|
|
411
|
+
)
|
|
412
|
+
if (reply.error === "notfound") return yield* new JobStore.JobNotFoundError({ jobId: id })
|
|
413
|
+
if (reply.error === "locklost") return yield* new JobStore.LockLostError({ jobId: id })
|
|
414
|
+
if (reply.wake === true) {
|
|
415
|
+
yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
})
|
|
419
|
+
|
|
287
420
|
// Shared by cancel and cancelByDedupe.
|
|
288
421
|
const cancelJob = (id: JobStore.JobId) =>
|
|
289
422
|
Effect.gen(function*() {
|
|
@@ -379,6 +512,7 @@ export const make = (
|
|
|
379
512
|
request.keep === undefined ? "" : JSON.stringify(request.keep),
|
|
380
513
|
request.timeoutMs === undefined ? "" : String(request.timeoutMs),
|
|
381
514
|
request.trace === undefined ? "" : JSON.stringify(request.trace),
|
|
515
|
+
request.parent === undefined ? "" : JSON.stringify(request.parent),
|
|
382
516
|
String(Math.max(0, request.delayMs))
|
|
383
517
|
)
|
|
384
518
|
}
|
|
@@ -503,15 +637,20 @@ export const make = (
|
|
|
503
637
|
})
|
|
504
638
|
}).pipe(Effect.mapError(storeError("claim failed"))),
|
|
505
639
|
|
|
506
|
-
ack: (id, token, outcome) =>
|
|
507
|
-
|
|
640
|
+
ack: (id, token, outcome) => {
|
|
641
|
+
if (outcome._tag === "FanOut") {
|
|
642
|
+
return fanOutAck(id, token, outcome.failFast, outcome.children)
|
|
643
|
+
}
|
|
644
|
+
// Narrowed binding: the closure below must see the FanOut-free union.
|
|
645
|
+
const settled = outcome
|
|
646
|
+
return Effect.gen(function*() {
|
|
508
647
|
const now = yield* Clock.currentTimeMillis
|
|
509
|
-
const exitJson =
|
|
648
|
+
const exitJson = settled._tag === "Cancelled" || settled.exit === undefined
|
|
510
649
|
? ""
|
|
511
|
-
: JSON.stringify(
|
|
512
|
-
const delayMs =
|
|
650
|
+
: JSON.stringify(settled.exit)
|
|
651
|
+
const delayMs = settled._tag === "Retry" ? Math.max(0, settled.delayMs) : 0
|
|
513
652
|
const reply: { error?: string; wake?: boolean; queue?: string } = JSON.parse(
|
|
514
|
-
yield* evalAck(prefix, id, token,
|
|
653
|
+
yield* evalAck(prefix, id, token, settled._tag, exitJson, delayMs, now).pipe(
|
|
515
654
|
Effect.mapError(storeError("ack failed"))
|
|
516
655
|
)
|
|
517
656
|
)
|
|
@@ -520,7 +659,8 @@ export const make = (
|
|
|
520
659
|
if (reply.wake === true) {
|
|
521
660
|
yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined)
|
|
522
661
|
}
|
|
523
|
-
})
|
|
662
|
+
})
|
|
663
|
+
},
|
|
524
664
|
|
|
525
665
|
release: (id, token) =>
|
|
526
666
|
Effect.gen(function*() {
|
|
@@ -746,6 +886,7 @@ export const make = (
|
|
|
746
886
|
schedule.backoff === undefined ? "" : JSON.stringify(schedule.backoff),
|
|
747
887
|
schedule.keep === undefined ? "" : JSON.stringify(schedule.keep),
|
|
748
888
|
schedule.timeoutMs === undefined ? "" : String(schedule.timeoutMs),
|
|
889
|
+
schedule.group ?? "",
|
|
749
890
|
schedule.nextRunAt
|
|
750
891
|
).pipe(
|
|
751
892
|
Effect.mapError(storeError("upsertSchedule failed")),
|
|
@@ -798,6 +939,7 @@ export const make = (
|
|
|
798
939
|
request.keep === undefined ? "" : JSON.stringify(request.keep),
|
|
799
940
|
request.timeoutMs === undefined ? "" : String(request.timeoutMs),
|
|
800
941
|
request.trace === undefined ? "" : JSON.stringify(request.trace),
|
|
942
|
+
request.parent === undefined ? "" : JSON.stringify(request.parent),
|
|
801
943
|
Math.max(0, request.delayMs),
|
|
802
944
|
now
|
|
803
945
|
)) === "1"
|
|
@@ -815,6 +957,147 @@ export const make = (
|
|
|
815
957
|
evalAdvanceSchedule(prefix, key, expectedRunAt, nextRunAt).pipe(
|
|
816
958
|
Effect.mapError(storeError("advanceSchedule failed")),
|
|
817
959
|
Effect.asVoid
|
|
960
|
+
),
|
|
961
|
+
|
|
962
|
+
recordChildResults: (reports) =>
|
|
963
|
+
Effect.gen(function*() {
|
|
964
|
+
if (reports.length === 0) {
|
|
965
|
+
const none: ReadonlyArray<{ applied: boolean; parentSettled: boolean }> = []
|
|
966
|
+
return none
|
|
967
|
+
}
|
|
968
|
+
const now = yield* Clock.currentTimeMillis
|
|
969
|
+
const all: Array<{ applied: boolean; parentSettled: boolean }> = []
|
|
970
|
+
// One atomic batch per chunk of 500 (ARGV headroom); the
|
|
971
|
+
// contract's per-batch settle semantics then apply per chunk.
|
|
972
|
+
for (let start = 0; start < reports.length; start += 500) {
|
|
973
|
+
const chunk = reports.slice(start, start + 500)
|
|
974
|
+
const items: Array<string> = []
|
|
975
|
+
for (const report of chunk) {
|
|
976
|
+
items.push(
|
|
977
|
+
report.flowId,
|
|
978
|
+
report.childKey,
|
|
979
|
+
report.outcome,
|
|
980
|
+
report.exit === undefined ? "" : JSON.stringify(report.exit),
|
|
981
|
+
report.failedReason ?? ""
|
|
982
|
+
)
|
|
983
|
+
}
|
|
984
|
+
const reply: {
|
|
985
|
+
results: ReadonlyArray<{ applied: boolean; parentSettled: boolean }>
|
|
986
|
+
wakes: ReadonlyArray<string> | Record<string, never>
|
|
987
|
+
} = JSON.parse(yield* evalRecordChildResults(prefix, now, chunk.length, items))
|
|
988
|
+
for (const queue of asArray(reply.wakes)) {
|
|
989
|
+
// A parent settled to runnable collect: wake its queue.
|
|
990
|
+
yield* wakeUp(JobStore.QueueName(queue))
|
|
991
|
+
}
|
|
992
|
+
for (const result of reply.results) {
|
|
993
|
+
all.push(result)
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return all
|
|
997
|
+
}).pipe(Effect.mapError(storeError("recordChildResults failed"))),
|
|
998
|
+
|
|
999
|
+
peekOutbox: (peekOptions) =>
|
|
1000
|
+
Effect.gen(function*() {
|
|
1001
|
+
const limit = Math.floor(peekOptions.limit)
|
|
1002
|
+
if (limit <= 0) {
|
|
1003
|
+
const none: ReadonlyArray<JobStore.OutboxEntry> = []
|
|
1004
|
+
return none
|
|
1005
|
+
}
|
|
1006
|
+
// The `after` cursor compares by the id's embedded score (the seq
|
|
1007
|
+
// prefix before the NUL), so the walk moves past the named entry
|
|
1008
|
+
// whether or not it still exists. Unparseable input reads as unset.
|
|
1009
|
+
let afterSeq: number | undefined = undefined
|
|
1010
|
+
if (peekOptions.after !== undefined) {
|
|
1011
|
+
const nul = peekOptions.after.indexOf("\u0000")
|
|
1012
|
+
const seq = nul > 0 ? Number(peekOptions.after.slice(0, nul)) : Number.NaN
|
|
1013
|
+
if (Number.isFinite(seq)) afterSeq = seq
|
|
1014
|
+
}
|
|
1015
|
+
const raw = afterSeq === undefined
|
|
1016
|
+
? yield* redis.send("ZRANGE", `${prefix}:flowoutbox`, "0", String(limit - 1))
|
|
1017
|
+
: yield* redis.send(
|
|
1018
|
+
"ZRANGEBYSCORE",
|
|
1019
|
+
`${prefix}:flowoutbox`,
|
|
1020
|
+
`(${afterSeq}`,
|
|
1021
|
+
"+inf",
|
|
1022
|
+
"LIMIT",
|
|
1023
|
+
"0",
|
|
1024
|
+
String(limit)
|
|
1025
|
+
)
|
|
1026
|
+
// SAFETY: ZRANGE/ZRANGEBYSCORE always reply with arrays of bulk
|
|
1027
|
+
// strings.
|
|
1028
|
+
const members = raw as ReadonlyArray<string>
|
|
1029
|
+
return members.map(toOutboxEntry)
|
|
1030
|
+
}).pipe(Effect.mapError(storeError("peekOutbox failed"))),
|
|
1031
|
+
|
|
1032
|
+
deleteOutbox: (ids) =>
|
|
1033
|
+
Effect.gen(function*() {
|
|
1034
|
+
// Chunked ZREMs keep the variadic argument count bounded; each
|
|
1035
|
+
// chunk is idempotent, so a partial failure just redelivers.
|
|
1036
|
+
for (let start = 0; start < ids.length; start += 500) {
|
|
1037
|
+
yield* redis.send("ZREM", `${prefix}:flowoutbox`, ...ids.slice(start, start + 500))
|
|
1038
|
+
}
|
|
1039
|
+
}).pipe(Effect.mapError(storeError("deleteOutbox failed"))),
|
|
1040
|
+
|
|
1041
|
+
listChildResults: (flowId, listOptions) =>
|
|
1042
|
+
Effect.gen(function*() {
|
|
1043
|
+
const limit = Math.max(1, listOptions?.limit ?? 1000)
|
|
1044
|
+
const reply: { items: ReadonlyArray<ReadonlyArray<string>> | Record<string, never>; more: boolean } = JSON
|
|
1045
|
+
.parse(
|
|
1046
|
+
yield* evalListChildResults(prefix, flowId, listOptions?.cursor ?? "", limit)
|
|
1047
|
+
)
|
|
1048
|
+
const items = asArray(reply.items).map((row) => toChildRecord(flowId, row))
|
|
1049
|
+
const last = items[items.length - 1]
|
|
1050
|
+
return {
|
|
1051
|
+
items,
|
|
1052
|
+
cursor: reply.more && last !== undefined ? last.childKey : undefined
|
|
1053
|
+
}
|
|
1054
|
+
}).pipe(Effect.mapError(storeError("listChildResults failed"))),
|
|
1055
|
+
|
|
1056
|
+
flowSweepWork: (sweepOptions) =>
|
|
1057
|
+
Effect.gen(function*() {
|
|
1058
|
+
const now = yield* Clock.currentTimeMillis
|
|
1059
|
+
const limit = Math.max(1, sweepOptions.limit ?? 1000)
|
|
1060
|
+
const reply: {
|
|
1061
|
+
reconcile:
|
|
1062
|
+
| ReadonlyArray<{
|
|
1063
|
+
flowId: string
|
|
1064
|
+
children: ReadonlyArray<{ childKey: string; storeKey: string; spec: string }>
|
|
1065
|
+
}>
|
|
1066
|
+
| Record<string, never>
|
|
1067
|
+
cascade:
|
|
1068
|
+
| ReadonlyArray<{
|
|
1069
|
+
flowId: string
|
|
1070
|
+
children: ReadonlyArray<{ childKey: string; storeKey: string; childJobId: string }>
|
|
1071
|
+
}>
|
|
1072
|
+
| Record<string, never>
|
|
1073
|
+
} = JSON.parse(yield* evalFlowSweepWork(prefix, sweepOptions.pendingAgeMs, limit, now))
|
|
1074
|
+
const work: JobStore.FlowSweepWork = {
|
|
1075
|
+
reconcile: asArray(reply.reconcile).map((group) => ({
|
|
1076
|
+
flowId: JobStore.JobId(group.flowId),
|
|
1077
|
+
children: group.children.map((child) => {
|
|
1078
|
+
// The stored spec is the verbatim JSON this driver wrote at
|
|
1079
|
+
// fan-out time (never routed through cjson), so it re-parses
|
|
1080
|
+
// to the original EnqueueRequest.
|
|
1081
|
+
const request: JobStore.EnqueueRequest = JSON.parse(child.spec)
|
|
1082
|
+
return { childKey: child.childKey, storeKey: child.storeKey, request }
|
|
1083
|
+
})
|
|
1084
|
+
})),
|
|
1085
|
+
cascade: asArray(reply.cascade).map((group) => ({
|
|
1086
|
+
flowId: JobStore.JobId(group.flowId),
|
|
1087
|
+
children: group.children.map((child) => ({
|
|
1088
|
+
childKey: child.childKey,
|
|
1089
|
+
storeKey: child.storeKey,
|
|
1090
|
+
childJobId: JobStore.JobId(child.childJobId)
|
|
1091
|
+
}))
|
|
1092
|
+
}))
|
|
1093
|
+
}
|
|
1094
|
+
return work
|
|
1095
|
+
}).pipe(Effect.mapError(storeError("flowSweepWork failed"))),
|
|
1096
|
+
|
|
1097
|
+
markChildrenCascaded: (flowId, childKeys) =>
|
|
1098
|
+
evalMarkChildrenCascaded(prefix, flowId, JSON.stringify(childKeys)).pipe(
|
|
1099
|
+
Effect.mapError(storeError("markChildrenCascaded failed")),
|
|
1100
|
+
Effect.asVoid
|
|
818
1101
|
)
|
|
819
1102
|
}
|
|
820
1103
|
|