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
|
@@ -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" }),
|
|
@@ -245,6 +258,75 @@ export const mqDedupe = <JobName extends string = string>(
|
|
|
245
258
|
...options?.extraConfig?.(table) ?? []
|
|
246
259
|
])
|
|
247
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
|
+
|
|
248
330
|
const queueControlColumns = <Queue extends string>() => ({
|
|
249
331
|
queue: text("queue").primaryKey().$type<Queue>(),
|
|
250
332
|
paused: boolean("paused").notNull().default(false)
|
|
@@ -287,3 +369,13 @@ export type MqQueueControlTable = ReturnType<typeof mqQueueControl<any>>
|
|
|
287
369
|
* @since 0.3.0
|
|
288
370
|
*/
|
|
289
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
|
*
|
|
@@ -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")),
|
|
@@ -115,15 +127,68 @@ const toSchedule = (hash: ReadonlyMap<string, string>): JobStore.ScheduleRecord
|
|
|
115
127
|
const asArray = <A>(value: ReadonlyArray<A> | Record<string, never>): ReadonlyArray<A> =>
|
|
116
128
|
Array.isArray(value) ? value : []
|
|
117
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
|
+
|
|
118
159
|
const JOB_STATES: ReadonlyArray<JobStore.JobState> = [
|
|
119
160
|
"waiting",
|
|
120
161
|
"delayed",
|
|
121
162
|
"active",
|
|
163
|
+
"waiting-children",
|
|
122
164
|
"completed",
|
|
123
165
|
"failed",
|
|
124
166
|
"cancelled"
|
|
125
167
|
]
|
|
126
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
|
+
|
|
127
192
|
/**
|
|
128
193
|
* Build a `RedisJobStore` service. Needs the `Redis` service and a `Scope`
|
|
129
194
|
* (the wake-up subscription and the optional history sweeper live in it).
|
|
@@ -160,6 +225,11 @@ export const make = (
|
|
|
160
225
|
const evalEnqueueMany = redis.eval(scripts.enqueueMany)
|
|
161
226
|
const evalSweepState = redis.eval(scripts.sweepState)
|
|
162
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)
|
|
163
233
|
|
|
164
234
|
// Wake protocol: a queue-filtered waiter registry (same-process wake-ups
|
|
165
235
|
// never depend on the pub/sub round trip), with the channel carrying
|
|
@@ -282,9 +352,71 @@ export const make = (
|
|
|
282
352
|
request.dedupe?.ttlMs === undefined ? "" : String(request.dedupe.ttlMs),
|
|
283
353
|
request.dedupe?.extend === true ? "1" : "0",
|
|
284
354
|
request.dedupe?.replace === true ? "1" : "0",
|
|
285
|
-
request.trace === undefined ? "" : JSON.stringify(request.trace)
|
|
355
|
+
request.trace === undefined ? "" : JSON.stringify(request.trace),
|
|
356
|
+
request.parent === undefined ? "" : JSON.stringify(request.parent)
|
|
286
357
|
)
|
|
287
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
|
+
|
|
288
420
|
// Shared by cancel and cancelByDedupe.
|
|
289
421
|
const cancelJob = (id: JobStore.JobId) =>
|
|
290
422
|
Effect.gen(function*() {
|
|
@@ -380,6 +512,7 @@ export const make = (
|
|
|
380
512
|
request.keep === undefined ? "" : JSON.stringify(request.keep),
|
|
381
513
|
request.timeoutMs === undefined ? "" : String(request.timeoutMs),
|
|
382
514
|
request.trace === undefined ? "" : JSON.stringify(request.trace),
|
|
515
|
+
request.parent === undefined ? "" : JSON.stringify(request.parent),
|
|
383
516
|
String(Math.max(0, request.delayMs))
|
|
384
517
|
)
|
|
385
518
|
}
|
|
@@ -504,15 +637,20 @@ export const make = (
|
|
|
504
637
|
})
|
|
505
638
|
}).pipe(Effect.mapError(storeError("claim failed"))),
|
|
506
639
|
|
|
507
|
-
ack: (id, token, outcome) =>
|
|
508
|
-
|
|
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*() {
|
|
509
647
|
const now = yield* Clock.currentTimeMillis
|
|
510
|
-
const exitJson =
|
|
648
|
+
const exitJson = settled._tag === "Cancelled" || settled.exit === undefined
|
|
511
649
|
? ""
|
|
512
|
-
: JSON.stringify(
|
|
513
|
-
const delayMs =
|
|
650
|
+
: JSON.stringify(settled.exit)
|
|
651
|
+
const delayMs = settled._tag === "Retry" ? Math.max(0, settled.delayMs) : 0
|
|
514
652
|
const reply: { error?: string; wake?: boolean; queue?: string } = JSON.parse(
|
|
515
|
-
yield* evalAck(prefix, id, token,
|
|
653
|
+
yield* evalAck(prefix, id, token, settled._tag, exitJson, delayMs, now).pipe(
|
|
516
654
|
Effect.mapError(storeError("ack failed"))
|
|
517
655
|
)
|
|
518
656
|
)
|
|
@@ -521,7 +659,8 @@ export const make = (
|
|
|
521
659
|
if (reply.wake === true) {
|
|
522
660
|
yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined)
|
|
523
661
|
}
|
|
524
|
-
})
|
|
662
|
+
})
|
|
663
|
+
},
|
|
525
664
|
|
|
526
665
|
release: (id, token) =>
|
|
527
666
|
Effect.gen(function*() {
|
|
@@ -800,6 +939,7 @@ export const make = (
|
|
|
800
939
|
request.keep === undefined ? "" : JSON.stringify(request.keep),
|
|
801
940
|
request.timeoutMs === undefined ? "" : String(request.timeoutMs),
|
|
802
941
|
request.trace === undefined ? "" : JSON.stringify(request.trace),
|
|
942
|
+
request.parent === undefined ? "" : JSON.stringify(request.parent),
|
|
803
943
|
Math.max(0, request.delayMs),
|
|
804
944
|
now
|
|
805
945
|
)) === "1"
|
|
@@ -817,6 +957,147 @@ export const make = (
|
|
|
817
957
|
evalAdvanceSchedule(prefix, key, expectedRunAt, nextRunAt).pipe(
|
|
818
958
|
Effect.mapError(storeError("advanceSchedule failed")),
|
|
819
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
|
|
820
1101
|
)
|
|
821
1102
|
}
|
|
822
1103
|
|