effect-mq 0.5.0 → 0.7.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 +353 -13
- package/dist/JobStore.d.ts.map +1 -1
- package/dist/JobStore.js +10 -0
- package/dist/JobStore.js.map +1 -1
- package/dist/MemoryJobStore.d.ts.map +1 -1
- package/dist/MemoryJobStore.js +361 -21
- 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 +678 -80
- 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 +53 -0
- package/dist/redis/RedisJobStore.d.ts.map +1 -1
- package/dist/redis/RedisJobStore.js +402 -50
- package/dist/redis/RedisJobStore.js.map +1 -1
- package/dist/redis/scripts.d.ts +213 -35
- package/dist/redis/scripts.d.ts.map +1 -1
- package/dist/redis/scripts.js +689 -73
- 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 +855 -12
- 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 +377 -12
- package/src/MemoryJobStore.ts +396 -25
- package/src/Metrics.ts +43 -0
- package/src/Worker.ts +726 -37
- package/src/drizzle-postgres/DrizzleJobStore.ts +844 -81
- package/src/drizzle-postgres/schema.ts +92 -0
- package/src/index.ts +8 -0
- package/src/redis/RedisJobStore.ts +540 -39
- package/src/redis/scripts.ts +751 -78
- package/src/testing/conformance.ts +1088 -12
|
@@ -12,7 +12,20 @@
|
|
|
12
12
|
*
|
|
13
13
|
* @since 0.2.0
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
Clock,
|
|
17
|
+
type Context,
|
|
18
|
+
Data,
|
|
19
|
+
Deferred,
|
|
20
|
+
Duration,
|
|
21
|
+
Effect,
|
|
22
|
+
Exit,
|
|
23
|
+
Layer,
|
|
24
|
+
Option,
|
|
25
|
+
Queue,
|
|
26
|
+
Schedule,
|
|
27
|
+
type Scope
|
|
28
|
+
} from "effect"
|
|
16
29
|
import { Redis } from "effect/unstable/persistence"
|
|
17
30
|
import * as JobStore from "../JobStore.ts"
|
|
18
31
|
import * as scripts from "./scripts.ts"
|
|
@@ -37,6 +50,65 @@ export interface RedisJobStoreOptions {
|
|
|
37
50
|
* Default: `j-<n>` from the store's counter. See `JobStore.IdGenerator`.
|
|
38
51
|
*/
|
|
39
52
|
readonly idGenerator?: JobStore.IdGenerator | undefined
|
|
53
|
+
/**
|
|
54
|
+
* The optional list indexes: `p:byname:<name>` and `p:byqueue:<queue>`,
|
|
55
|
+
* both scored by `enqueuedAt`, serving `list({ name })` / `list({ queue })`
|
|
56
|
+
* without a full scan. Default: all on. `false` disables both; a partial
|
|
57
|
+
* object disables one (`{ name: false }`). Disable an index only when you
|
|
58
|
+
* never list that way — a `list` that ROUTES to a disabled index dies with
|
|
59
|
+
* `ListIndexDisabledError` (a query servable from another structure, e.g.
|
|
60
|
+
* `name` + terminal `states` ordered by `finishedAt`, never touches it).
|
|
61
|
+
*
|
|
62
|
+
* This setting is a PER-PREFIX invariant: every store instance sharing a
|
|
63
|
+
* key prefix must agree on it. Index writes happen at insert time in the
|
|
64
|
+
* writing process, so a store with an index off inserts rows that index
|
|
65
|
+
* never sees — enabled readers on the same prefix silently miss those rows
|
|
66
|
+
* (the `p:index:<kind>:ready` marker churn between mixed stores is a
|
|
67
|
+
* symptom of the misconfiguration, not a safety net against it).
|
|
68
|
+
*
|
|
69
|
+
* Init reconciles each index one-shot. Enabled with no `ready` marker: a
|
|
70
|
+
* full driver-paged ZSCAN of `p:all` rebuilds it, then stamps the marker
|
|
71
|
+
* with the rebuild's start time. Enabled with a marker: rows enqueued since
|
|
72
|
+
* the marker minus a 60s margin are (re-)indexed — so a rolling deploy
|
|
73
|
+
* whose old, index-less writers kept inserting after the marker landed is
|
|
74
|
+
* healed by the last new-code boot — and the marker is re-stamped.
|
|
75
|
+
* Disabled: only the marker is deleted (a future re-enable then does a full
|
|
76
|
+
* rebuild instead of trusting stale zsets). The zsets themselves are NEVER
|
|
77
|
+
* deleted at init — this store cannot know whether an enabled sibling is
|
|
78
|
+
* serving reads from them. After disabling everywhere, reclaim the memory
|
|
79
|
+
* manually, e.g.:
|
|
80
|
+
* `redis-cli --scan --pattern '<prefix>:byname:*' | xargs redis-cli del`.
|
|
81
|
+
*
|
|
82
|
+
* @since 0.7.0
|
|
83
|
+
*/
|
|
84
|
+
readonly indexes?: { readonly name?: boolean | undefined; readonly queue?: boolean | undefined } | false | undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A `list` query routed to a list index this store is configured not to
|
|
89
|
+
* maintain (`RedisJobStoreOptions.indexes`). Delivered as a defect, not a
|
|
90
|
+
* typed failure: the configuration said "we never list this way", so the
|
|
91
|
+
* query contradicting it is a programming mistake, matching the library's
|
|
92
|
+
* die-on-config-mistake idiom.
|
|
93
|
+
*
|
|
94
|
+
* @since 0.7.0
|
|
95
|
+
*/
|
|
96
|
+
export class ListIndexDisabledError extends Data.TaggedError("ListIndexDisabledError")<{
|
|
97
|
+
readonly index: "name" | "queue"
|
|
98
|
+
readonly message: string
|
|
99
|
+
}> {}
|
|
100
|
+
|
|
101
|
+
const TERMINAL_STATES: ReadonlySet<JobStore.JobState> = new Set(["completed", "failed", "cancelled"])
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The `list` predicates the routed structure does NOT pin, applied per row
|
|
105
|
+
* inside the list script. Field names are part of the script's contract.
|
|
106
|
+
*/
|
|
107
|
+
interface ResidualFilters {
|
|
108
|
+
readonly queue?: JobStore.QueueName | undefined
|
|
109
|
+
readonly name?: string | undefined
|
|
110
|
+
readonly states?: ReadonlyArray<JobStore.JobState> | undefined
|
|
111
|
+
readonly metadata?: Readonly<Record<string, string>> | undefined
|
|
40
112
|
}
|
|
41
113
|
|
|
42
114
|
const storeError = (message: string) => (cause: unknown) => new JobStore.JobStoreError({ message, cause })
|
|
@@ -85,6 +157,18 @@ const toRecord = (hash: ReadonlyMap<string, string>): JobStore.JobRecord => ({
|
|
|
85
157
|
cancelRequested: hash.get("cancelRequested") === "1",
|
|
86
158
|
dedupeKey: optionalString(hash.get("dedupeKey")),
|
|
87
159
|
trace: optionalJson<JobStore.TraceContext>(hash.get("trace")),
|
|
160
|
+
parent: optionalJson<JobStore.ParentEnvelope>(hash.get("parent")),
|
|
161
|
+
// Manifest presence IS the phase marker: hashes written before flows (or
|
|
162
|
+
// parents that never fanned out) simply lack the flow fields.
|
|
163
|
+
flow: optionalString(hash.get("flowPending")) === undefined
|
|
164
|
+
? undefined
|
|
165
|
+
: {
|
|
166
|
+
failFast: hash.get("flowFailFast") === "1",
|
|
167
|
+
pending: Number(hash.get("flowPending")),
|
|
168
|
+
completed: Number(hash.get("flowCompleted") ?? 0),
|
|
169
|
+
failed: Number(hash.get("flowFailed") ?? 0),
|
|
170
|
+
cancelled: Number(hash.get("flowCancelled") ?? 0)
|
|
171
|
+
},
|
|
88
172
|
runAt: Number(hash.get("runAt") ?? 0),
|
|
89
173
|
enqueuedAt: Number(hash.get("enqueuedAt") ?? 0),
|
|
90
174
|
processedAt: optionalNumber(hash.get("processedAt")),
|
|
@@ -115,15 +199,68 @@ const toSchedule = (hash: ReadonlyMap<string, string>): JobStore.ScheduleRecord
|
|
|
115
199
|
const asArray = <A>(value: ReadonlyArray<A> | Record<string, never>): ReadonlyArray<A> =>
|
|
116
200
|
Array.isArray(value) ? value : []
|
|
117
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Decode one outbox zset member: `<seq>\0<json>` where the json carries the
|
|
204
|
+
* verbatim parent envelope plus the terminal outcome. The full member string
|
|
205
|
+
* is the opaque entry id (deleteOutbox is then a plain ZREM).
|
|
206
|
+
*/
|
|
207
|
+
const toOutboxEntry = (member: string): JobStore.OutboxEntry => {
|
|
208
|
+
const sep = member.indexOf("\u0000")
|
|
209
|
+
const body: {
|
|
210
|
+
parent: JobStore.ParentEnvelope
|
|
211
|
+
outcome: JobStore.FlowChildReport["outcome"]
|
|
212
|
+
exit?: unknown
|
|
213
|
+
failedReason?: string
|
|
214
|
+
} = JSON.parse(member.slice(sep + 1))
|
|
215
|
+
return {
|
|
216
|
+
id: member,
|
|
217
|
+
flowName: body.parent.flowName,
|
|
218
|
+
parentStoreKey: body.parent.parentStoreKey,
|
|
219
|
+
report: {
|
|
220
|
+
flowId: body.parent.flowId,
|
|
221
|
+
childKey: body.parent.childKey,
|
|
222
|
+
outcome: body.outcome,
|
|
223
|
+
// The exit key is omitted entirely when absent (ledger convention),
|
|
224
|
+
// so a legitimate encoded null exit survives the round trip.
|
|
225
|
+
exit: Object.hasOwn(body, "exit") ? body.exit : undefined,
|
|
226
|
+
failedReason: body.failedReason
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
118
231
|
const JOB_STATES: ReadonlyArray<JobStore.JobState> = [
|
|
119
232
|
"waiting",
|
|
120
233
|
"delayed",
|
|
121
234
|
"active",
|
|
235
|
+
"waiting-children",
|
|
122
236
|
"completed",
|
|
123
237
|
"failed",
|
|
124
238
|
"cancelled"
|
|
125
239
|
]
|
|
126
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Fold one positional dependency-row tuple into a `FlowChildRecord`. The
|
|
243
|
+
* order must stay in lockstep with the HMGET field list in the
|
|
244
|
+
* `listChildResults` script:
|
|
245
|
+
* childKey, storeKey, childJobId, name, status, exit, failedReason, cascaded.
|
|
246
|
+
*/
|
|
247
|
+
const toChildRecord = (
|
|
248
|
+
flowId: JobStore.JobId,
|
|
249
|
+
row: ReadonlyArray<string>
|
|
250
|
+
): JobStore.FlowChildRecord => ({
|
|
251
|
+
flowId,
|
|
252
|
+
childKey: row[0] ?? "",
|
|
253
|
+
storeKey: row[1] ?? "",
|
|
254
|
+
childJobId: JobStore.JobId(row[2] ?? ""),
|
|
255
|
+
name: row[3] ?? "",
|
|
256
|
+
// SAFETY: the status field is only ever written with FlowChildRecord
|
|
257
|
+
// status members ("pending" at insert, a report/settle outcome after).
|
|
258
|
+
status: (row[4] ?? "pending") as JobStore.FlowChildRecord["status"],
|
|
259
|
+
exit: optionalJson(row[5]),
|
|
260
|
+
failedReason: optionalString(row[6]),
|
|
261
|
+
cascaded: row[7] === "1"
|
|
262
|
+
})
|
|
263
|
+
|
|
127
264
|
/**
|
|
128
265
|
* Build a `RedisJobStore` service. Needs the `Redis` service and a `Scope`
|
|
129
266
|
* (the wake-up subscription and the optional history sweeper live in it).
|
|
@@ -137,29 +274,104 @@ export const make = (
|
|
|
137
274
|
const redis = yield* Redis.Redis
|
|
138
275
|
const prefix = options?.prefix ?? "effect-mq"
|
|
139
276
|
const wakeChannel = `${prefix}:wake`
|
|
277
|
+
const indexOptions = options?.indexes
|
|
278
|
+
const indexes: scripts.IndexConfig = indexOptions === false
|
|
279
|
+
? { name: false, queue: false }
|
|
280
|
+
: { name: indexOptions?.name ?? true, queue: indexOptions?.queue ?? true }
|
|
281
|
+
const HELPERS = scripts.helpers(indexes)
|
|
140
282
|
|
|
141
|
-
const evalEnqueue = redis.eval(scripts.enqueue)
|
|
142
|
-
const evalClaim = redis.eval(scripts.claim)
|
|
143
|
-
const evalAck = redis.eval(scripts.ack)
|
|
144
|
-
const evalRelease = redis.eval(scripts.release)
|
|
145
|
-
const evalExtendLocks = redis.eval(scripts.extendLocks)
|
|
146
|
-
const evalRecoverStalled = redis.eval(scripts.recoverStalled)
|
|
147
|
-
const evalGetJob = redis.eval(scripts.getJob)
|
|
148
|
-
const evalList = redis.eval(scripts.list)
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
const
|
|
283
|
+
const evalEnqueue = redis.eval(scripts.enqueue(HELPERS))
|
|
284
|
+
const evalClaim = redis.eval(scripts.claim(HELPERS))
|
|
285
|
+
const evalAck = redis.eval(scripts.ack(HELPERS))
|
|
286
|
+
const evalRelease = redis.eval(scripts.release(HELPERS))
|
|
287
|
+
const evalExtendLocks = redis.eval(scripts.extendLocks(HELPERS))
|
|
288
|
+
const evalRecoverStalled = redis.eval(scripts.recoverStalled(HELPERS))
|
|
289
|
+
const evalGetJob = redis.eval(scripts.getJob(HELPERS))
|
|
290
|
+
const evalList = redis.eval(scripts.list(HELPERS))
|
|
291
|
+
const evalIndexMembers = redis.eval(scripts.indexMembers(HELPERS))
|
|
292
|
+
const evalIndexTailPage = redis.eval(scripts.indexTailPage(HELPERS))
|
|
293
|
+
const evalCounts = redis.eval(scripts.counts(HELPERS))
|
|
294
|
+
const evalRemove = redis.eval(scripts.remove(HELPERS))
|
|
295
|
+
const evalRetry = redis.eval(scripts.retry(HELPERS))
|
|
296
|
+
const evalCancel = redis.eval(scripts.cancel(HELPERS))
|
|
297
|
+
const evalPromote = redis.eval(scripts.promote(HELPERS))
|
|
298
|
+
const evalUpsertSchedule = redis.eval(scripts.upsertSchedule(HELPERS))
|
|
299
|
+
const evalRemoveSchedule = redis.eval(scripts.removeSchedule(HELPERS))
|
|
300
|
+
const evalListSchedules = redis.eval(scripts.listSchedules(HELPERS))
|
|
301
|
+
const evalDueSchedules = redis.eval(scripts.dueSchedules(HELPERS))
|
|
302
|
+
const evalAdvanceSchedule = redis.eval(scripts.advanceSchedule(HELPERS))
|
|
303
|
+
const evalTickSchedule = redis.eval(scripts.tickSchedule(HELPERS))
|
|
304
|
+
const evalEnqueueMany = redis.eval(scripts.enqueueMany(HELPERS))
|
|
305
|
+
const evalSweepState = redis.eval(scripts.sweepState(HELPERS))
|
|
306
|
+
const evalSweepDedupes = redis.eval(scripts.sweepDedupes(HELPERS))
|
|
307
|
+
const evalFanOut = redis.eval(scripts.fanOut(HELPERS))
|
|
308
|
+
const evalRecordChildResults = redis.eval(scripts.recordChildResults(HELPERS))
|
|
309
|
+
const evalListChildResults = redis.eval(scripts.listChildResults(HELPERS))
|
|
310
|
+
const evalFlowSweepWork = redis.eval(scripts.flowSweepWork(HELPERS))
|
|
311
|
+
const evalMarkChildrenCascaded = redis.eval(scripts.markChildrenCascaded(HELPERS))
|
|
312
|
+
|
|
313
|
+
// List-index reconcile, once per boot per index. All work is driver-paged
|
|
314
|
+
// — never one giant Lua call — so the single-threaded server is never
|
|
315
|
+
// held. There is no build lock: concurrent boots duplicate idempotent
|
|
316
|
+
// ZADDs, a crash before the marker stamp makes the next boot redo the
|
|
317
|
+
// work, and rows inserted meanwhile are indexed live by insertJobRow.
|
|
318
|
+
//
|
|
319
|
+
// - enabled, no marker: full rebuild via ZSCAN over `all` (cursor-based
|
|
320
|
+
// and linear — immune to score ties and rank shifts; every member
|
|
321
|
+
// present for the whole scan is guaranteed returned). Rows deleted
|
|
322
|
+
// mid-scan leave at most stale members the read path self-heals.
|
|
323
|
+
// - enabled, marker present: heal the tail. Index writes are per-process,
|
|
324
|
+
// so writers without them (an older version mid-rolling-deploy, or a
|
|
325
|
+
// misconfigured indexes-off store) may have inserted unindexed rows
|
|
326
|
+
// AFTER the marker landed. Re-indexing everything enqueued since the
|
|
327
|
+
// marker minus a 60s margin closes that window: the last enabled boot
|
|
328
|
+
// after such writers stop covers everything they wrote before it.
|
|
329
|
+
// - disabled: delete ONLY the marker (a later re-enable must not trust
|
|
330
|
+
// stale zsets). The zsets stay — an enabled sibling may be reading
|
|
331
|
+
// them, and this store cannot know.
|
|
332
|
+
//
|
|
333
|
+
// Both paths re-stamp the marker with this boot's start time. Init-time
|
|
334
|
+
// infra failures die — the store never starts half-configured.
|
|
335
|
+
yield* Effect.gen(function*() {
|
|
336
|
+
const bootAt = yield* Clock.currentTimeMillis
|
|
337
|
+
for (const kind of ["name", "queue"] as const) {
|
|
338
|
+
const marker = `${prefix}:index:${kind}:ready`
|
|
339
|
+
if (!indexes[kind]) {
|
|
340
|
+
yield* redis.send("DEL", marker)
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
// SAFETY: GET always replies with a bulk string or null.
|
|
344
|
+
const stamped = (yield* redis.send("GET", marker)) as string | null
|
|
345
|
+
const markerAt = stamped === null || stamped === "" ? Number.NaN : Number(stamped)
|
|
346
|
+
if (Number.isNaN(markerAt)) {
|
|
347
|
+
let cursor = "0"
|
|
348
|
+
do {
|
|
349
|
+
const reply = yield* redis.send("ZSCAN", `${prefix}:all`, cursor, "COUNT", "500")
|
|
350
|
+
// SAFETY: ZSCAN always replies [nextCursor, member/score pairs].
|
|
351
|
+
const [next, flat] = reply as [string, ReadonlyArray<string>]
|
|
352
|
+
cursor = next
|
|
353
|
+
const ids: Array<string> = []
|
|
354
|
+
for (let i = 0; i < flat.length; i += 2) {
|
|
355
|
+
const id = flat[i]
|
|
356
|
+
if (id !== undefined) ids.push(id)
|
|
357
|
+
}
|
|
358
|
+
// COUNT is only a hint — chunk what actually came back.
|
|
359
|
+
for (let start = 0; start < ids.length; start += 500) {
|
|
360
|
+
yield* evalIndexMembers(prefix, kind, JSON.stringify(ids.slice(start, start + 500)))
|
|
361
|
+
}
|
|
362
|
+
} while (cursor !== "0")
|
|
363
|
+
} else {
|
|
364
|
+
const min = String(markerAt - 60_000)
|
|
365
|
+
let offset = 0
|
|
366
|
+
while (true) {
|
|
367
|
+
const scanned = Number(yield* evalIndexTailPage(prefix, kind, min, offset, 500))
|
|
368
|
+
if (scanned < 500) break
|
|
369
|
+
offset += scanned
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
yield* redis.send("SET", marker, String(bootAt))
|
|
373
|
+
}
|
|
374
|
+
}).pipe(Effect.orDie)
|
|
163
375
|
|
|
164
376
|
// Wake protocol: a queue-filtered waiter registry (same-process wake-ups
|
|
165
377
|
// never depend on the pub/sub round trip), with the channel carrying
|
|
@@ -282,9 +494,71 @@ export const make = (
|
|
|
282
494
|
request.dedupe?.ttlMs === undefined ? "" : String(request.dedupe.ttlMs),
|
|
283
495
|
request.dedupe?.extend === true ? "1" : "0",
|
|
284
496
|
request.dedupe?.replace === true ? "1" : "0",
|
|
285
|
-
request.trace === undefined ? "" : JSON.stringify(request.trace)
|
|
497
|
+
request.trace === undefined ? "" : JSON.stringify(request.trace),
|
|
498
|
+
request.parent === undefined ? "" : JSON.stringify(request.parent)
|
|
286
499
|
)
|
|
287
500
|
|
|
501
|
+
// The FanOut ack. Large manifests chunk their dependency rows across
|
|
502
|
+
// several lock-token-guarded script calls (ARGV strides, like
|
|
503
|
+
// enqueueMany); only the FINAL chunk writes the manifest and flips the
|
|
504
|
+
// state, so a crash mid-staging leaves the job active and recoverable —
|
|
505
|
+
// the next attempt's first chunk clears the orphaned staged rows.
|
|
506
|
+
const fanOutAck = (
|
|
507
|
+
id: JobStore.JobId,
|
|
508
|
+
token: string,
|
|
509
|
+
failFast: boolean,
|
|
510
|
+
children: ReadonlyArray<JobStore.FlowChildSpec>
|
|
511
|
+
) =>
|
|
512
|
+
Effect.gen(function*() {
|
|
513
|
+
if (children.some((child) => child.request.id === undefined)) {
|
|
514
|
+
// Validate BEFORE any script call, so a bad spec cannot leave the
|
|
515
|
+
// job half-acked (rows staged, ledger written, still active).
|
|
516
|
+
return yield* new JobStore.JobStoreError({
|
|
517
|
+
message: "FanOut child specs require an explicit request.id"
|
|
518
|
+
})
|
|
519
|
+
}
|
|
520
|
+
const now = yield* Clock.currentTimeMillis
|
|
521
|
+
const chunks: Array<ReadonlyArray<JobStore.FlowChildSpec>> = []
|
|
522
|
+
for (let start = 0; start < children.length; start += 500) {
|
|
523
|
+
chunks.push(children.slice(start, start + 500))
|
|
524
|
+
}
|
|
525
|
+
// An empty manifest still needs the final (state-flipping) call.
|
|
526
|
+
if (chunks.length === 0) chunks.push([])
|
|
527
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
528
|
+
const chunk = chunks[i]
|
|
529
|
+
if (chunk === undefined) continue
|
|
530
|
+
const items: Array<string> = []
|
|
531
|
+
for (const child of chunk) {
|
|
532
|
+
items.push(
|
|
533
|
+
child.childKey,
|
|
534
|
+
child.storeKey,
|
|
535
|
+
child.request.id ?? "",
|
|
536
|
+
child.request.name,
|
|
537
|
+
JSON.stringify(child.request)
|
|
538
|
+
)
|
|
539
|
+
}
|
|
540
|
+
const reply: { error?: string; wake?: boolean; queue?: string } = JSON.parse(
|
|
541
|
+
yield* evalFanOut(
|
|
542
|
+
prefix,
|
|
543
|
+
id,
|
|
544
|
+
token,
|
|
545
|
+
i === chunks.length - 1 ? "1" : "0",
|
|
546
|
+
i === 0 ? "1" : "0",
|
|
547
|
+
failFast ? "1" : "0",
|
|
548
|
+
children.length,
|
|
549
|
+
now,
|
|
550
|
+
chunk.length,
|
|
551
|
+
items
|
|
552
|
+
).pipe(Effect.mapError(storeError("ack failed")))
|
|
553
|
+
)
|
|
554
|
+
if (reply.error === "notfound") return yield* new JobStore.JobNotFoundError({ jobId: id })
|
|
555
|
+
if (reply.error === "locklost") return yield* new JobStore.LockLostError({ jobId: id })
|
|
556
|
+
if (reply.wake === true) {
|
|
557
|
+
yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
})
|
|
561
|
+
|
|
288
562
|
// Shared by cancel and cancelByDedupe.
|
|
289
563
|
const cancelJob = (id: JobStore.JobId) =>
|
|
290
564
|
Effect.gen(function*() {
|
|
@@ -380,6 +654,7 @@ export const make = (
|
|
|
380
654
|
request.keep === undefined ? "" : JSON.stringify(request.keep),
|
|
381
655
|
request.timeoutMs === undefined ? "" : String(request.timeoutMs),
|
|
382
656
|
request.trace === undefined ? "" : JSON.stringify(request.trace),
|
|
657
|
+
request.parent === undefined ? "" : JSON.stringify(request.parent),
|
|
383
658
|
String(Math.max(0, request.delayMs))
|
|
384
659
|
)
|
|
385
660
|
}
|
|
@@ -504,15 +779,20 @@ export const make = (
|
|
|
504
779
|
})
|
|
505
780
|
}).pipe(Effect.mapError(storeError("claim failed"))),
|
|
506
781
|
|
|
507
|
-
ack: (id, token, outcome) =>
|
|
508
|
-
|
|
782
|
+
ack: (id, token, outcome) => {
|
|
783
|
+
if (outcome._tag === "FanOut") {
|
|
784
|
+
return fanOutAck(id, token, outcome.failFast, outcome.children)
|
|
785
|
+
}
|
|
786
|
+
// Narrowed binding: the closure below must see the FanOut-free union.
|
|
787
|
+
const settled = outcome
|
|
788
|
+
return Effect.gen(function*() {
|
|
509
789
|
const now = yield* Clock.currentTimeMillis
|
|
510
|
-
const exitJson =
|
|
790
|
+
const exitJson = settled._tag === "Cancelled" || settled.exit === undefined
|
|
511
791
|
? ""
|
|
512
|
-
: JSON.stringify(
|
|
513
|
-
const delayMs =
|
|
792
|
+
: JSON.stringify(settled.exit)
|
|
793
|
+
const delayMs = settled._tag === "Retry" ? Math.max(0, settled.delayMs) : 0
|
|
514
794
|
const reply: { error?: string; wake?: boolean; queue?: string } = JSON.parse(
|
|
515
|
-
yield* evalAck(prefix, id, token,
|
|
795
|
+
yield* evalAck(prefix, id, token, settled._tag, exitJson, delayMs, now).pipe(
|
|
516
796
|
Effect.mapError(storeError("ack failed"))
|
|
517
797
|
)
|
|
518
798
|
)
|
|
@@ -521,7 +801,8 @@ export const make = (
|
|
|
521
801
|
if (reply.wake === true) {
|
|
522
802
|
yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined)
|
|
523
803
|
}
|
|
524
|
-
})
|
|
804
|
+
})
|
|
805
|
+
},
|
|
525
806
|
|
|
526
807
|
release: (id, token) =>
|
|
527
808
|
Effect.gen(function*() {
|
|
@@ -617,24 +898,102 @@ export const make = (
|
|
|
617
898
|
list: (listOptions) =>
|
|
618
899
|
Effect.gen(function*() {
|
|
619
900
|
const limit = Math.max(1, listOptions.limit ?? 50)
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
901
|
+
const { metadata, name, queue, states } = listOptions
|
|
902
|
+
const orderBy = listOptions.orderBy ?? "enqueuedAt"
|
|
903
|
+
const order = listOptions.order ?? "desc"
|
|
904
|
+
// Routing: the narrowest structure whose zset score IS the
|
|
905
|
+
// requested order value; everything it does not pin stays a
|
|
906
|
+
// residual predicate applied in-script. Routing runs BEFORE the
|
|
907
|
+
// disabled-index check — a query another structure serves (e.g.
|
|
908
|
+
// name + terminal states ordered by finishedAt) must never die.
|
|
909
|
+
let sources: ReadonlyArray<string>
|
|
910
|
+
let residual: ResidualFilters
|
|
911
|
+
if (orderBy === "finishedAt") {
|
|
912
|
+
if (states === undefined || states.some((state) => !TERMINAL_STATES.has(state))) {
|
|
913
|
+
return yield* Effect.die(
|
|
914
|
+
new JobStore.ListOrderUnsupportedError({
|
|
915
|
+
orderBy,
|
|
916
|
+
message: "effect-mq: the Redis store serves orderBy \"finishedAt\" only with " +
|
|
917
|
+
"states ⊆ {completed, failed, cancelled} (the finished/terminal zsets carry that order)"
|
|
918
|
+
})
|
|
919
|
+
)
|
|
920
|
+
}
|
|
921
|
+
// ≤3 per-state sources, merged by (finishedAt, id) in-script.
|
|
922
|
+
const uniqueStates = [...new Set(states)]
|
|
923
|
+
sources = name === undefined
|
|
924
|
+
? uniqueStates.map((state) => `${prefix}:finished:${state}`)
|
|
925
|
+
: uniqueStates.map((state) => `${prefix}:terminal:${name}:${state}`)
|
|
926
|
+
residual = { queue, metadata }
|
|
927
|
+
} else if (orderBy === "runAt") {
|
|
928
|
+
const onlyDelayed = states !== undefined && states.length > 0 &&
|
|
929
|
+
states.every((state) => state === "delayed")
|
|
930
|
+
if (queue === undefined || !onlyDelayed) {
|
|
931
|
+
return yield* Effect.die(
|
|
932
|
+
new JobStore.ListOrderUnsupportedError({
|
|
933
|
+
orderBy,
|
|
934
|
+
message: "effect-mq: the Redis store serves orderBy \"runAt\" only with " +
|
|
935
|
+
"states: [\"delayed\"] and a queue (the delayed:<queue> zset carries that order)"
|
|
936
|
+
})
|
|
937
|
+
)
|
|
938
|
+
}
|
|
939
|
+
sources = [`${prefix}:delayed:${queue}`]
|
|
940
|
+
residual = { name, metadata }
|
|
941
|
+
} else if (name !== undefined) {
|
|
942
|
+
if (!indexes.name) {
|
|
943
|
+
return yield* Effect.die(
|
|
944
|
+
new ListIndexDisabledError({
|
|
945
|
+
index: "name",
|
|
946
|
+
message: "effect-mq: list({ name }) routes to the byname index, " +
|
|
947
|
+
"but RedisJobStoreOptions.indexes.name is disabled for this store"
|
|
948
|
+
})
|
|
949
|
+
)
|
|
950
|
+
}
|
|
951
|
+
sources = [`${prefix}:byname:${name}`]
|
|
952
|
+
residual = { queue, states, metadata }
|
|
953
|
+
} else if (queue !== undefined) {
|
|
954
|
+
if (!indexes.queue) {
|
|
955
|
+
return yield* Effect.die(
|
|
956
|
+
new ListIndexDisabledError({
|
|
957
|
+
index: "queue",
|
|
958
|
+
message: "effect-mq: list({ queue }) routes to the byqueue index, " +
|
|
959
|
+
"but RedisJobStoreOptions.indexes.queue is disabled for this store"
|
|
960
|
+
})
|
|
961
|
+
)
|
|
962
|
+
}
|
|
963
|
+
sources = [`${prefix}:byqueue:${queue}`]
|
|
964
|
+
residual = { states, metadata }
|
|
965
|
+
} else {
|
|
966
|
+
sources = [`${prefix}:all`]
|
|
967
|
+
residual = { states, metadata }
|
|
625
968
|
}
|
|
626
969
|
const reply: { items: ReadonlyArray<ReadonlyArray<string>> | Record<string, never>; more: boolean } = JSON
|
|
627
970
|
.parse(
|
|
628
|
-
yield* evalList(
|
|
971
|
+
yield* evalList(
|
|
972
|
+
prefix,
|
|
973
|
+
JSON.stringify(sources),
|
|
974
|
+
order,
|
|
975
|
+
JSON.stringify(residual),
|
|
976
|
+
listOptions.cursor ?? "",
|
|
977
|
+
limit
|
|
978
|
+
).pipe(Effect.mapError(storeError("list failed")))
|
|
629
979
|
)
|
|
630
980
|
const items = asArray(reply.items).map((flat) => toRecord(foldPairs(flat)))
|
|
631
981
|
const last = items[items.length - 1]
|
|
982
|
+
// The cursor value is the order field, which equals the routed
|
|
983
|
+
// zset's score for every row the script returned.
|
|
984
|
+
const orderValue = last === undefined
|
|
985
|
+
? 0
|
|
986
|
+
: orderBy === "enqueuedAt"
|
|
987
|
+
? last.enqueuedAt
|
|
988
|
+
: orderBy === "runAt"
|
|
989
|
+
? last.runAt
|
|
990
|
+
: last.finishedAt ?? 0
|
|
632
991
|
const result: JobStore.ListResult = {
|
|
633
992
|
items,
|
|
634
|
-
cursor: reply.more && last !== undefined ? `${
|
|
993
|
+
cursor: reply.more && last !== undefined ? `${orderValue}:${last.id}` : undefined
|
|
635
994
|
}
|
|
636
995
|
return result
|
|
637
|
-
})
|
|
996
|
+
}),
|
|
638
997
|
|
|
639
998
|
retry: (id) =>
|
|
640
999
|
Effect.gen(function*() {
|
|
@@ -800,6 +1159,7 @@ export const make = (
|
|
|
800
1159
|
request.keep === undefined ? "" : JSON.stringify(request.keep),
|
|
801
1160
|
request.timeoutMs === undefined ? "" : String(request.timeoutMs),
|
|
802
1161
|
request.trace === undefined ? "" : JSON.stringify(request.trace),
|
|
1162
|
+
request.parent === undefined ? "" : JSON.stringify(request.parent),
|
|
803
1163
|
Math.max(0, request.delayMs),
|
|
804
1164
|
now
|
|
805
1165
|
)) === "1"
|
|
@@ -817,6 +1177,147 @@ export const make = (
|
|
|
817
1177
|
evalAdvanceSchedule(prefix, key, expectedRunAt, nextRunAt).pipe(
|
|
818
1178
|
Effect.mapError(storeError("advanceSchedule failed")),
|
|
819
1179
|
Effect.asVoid
|
|
1180
|
+
),
|
|
1181
|
+
|
|
1182
|
+
recordChildResults: (reports) =>
|
|
1183
|
+
Effect.gen(function*() {
|
|
1184
|
+
if (reports.length === 0) {
|
|
1185
|
+
const none: ReadonlyArray<{ applied: boolean; parentSettled: boolean }> = []
|
|
1186
|
+
return none
|
|
1187
|
+
}
|
|
1188
|
+
const now = yield* Clock.currentTimeMillis
|
|
1189
|
+
const all: Array<{ applied: boolean; parentSettled: boolean }> = []
|
|
1190
|
+
// One atomic batch per chunk of 500 (ARGV headroom); the
|
|
1191
|
+
// contract's per-batch settle semantics then apply per chunk.
|
|
1192
|
+
for (let start = 0; start < reports.length; start += 500) {
|
|
1193
|
+
const chunk = reports.slice(start, start + 500)
|
|
1194
|
+
const items: Array<string> = []
|
|
1195
|
+
for (const report of chunk) {
|
|
1196
|
+
items.push(
|
|
1197
|
+
report.flowId,
|
|
1198
|
+
report.childKey,
|
|
1199
|
+
report.outcome,
|
|
1200
|
+
report.exit === undefined ? "" : JSON.stringify(report.exit),
|
|
1201
|
+
report.failedReason ?? ""
|
|
1202
|
+
)
|
|
1203
|
+
}
|
|
1204
|
+
const reply: {
|
|
1205
|
+
results: ReadonlyArray<{ applied: boolean; parentSettled: boolean }>
|
|
1206
|
+
wakes: ReadonlyArray<string> | Record<string, never>
|
|
1207
|
+
} = JSON.parse(yield* evalRecordChildResults(prefix, now, chunk.length, items))
|
|
1208
|
+
for (const queue of asArray(reply.wakes)) {
|
|
1209
|
+
// A parent settled to runnable collect: wake its queue.
|
|
1210
|
+
yield* wakeUp(JobStore.QueueName(queue))
|
|
1211
|
+
}
|
|
1212
|
+
for (const result of reply.results) {
|
|
1213
|
+
all.push(result)
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
return all
|
|
1217
|
+
}).pipe(Effect.mapError(storeError("recordChildResults failed"))),
|
|
1218
|
+
|
|
1219
|
+
peekOutbox: (peekOptions) =>
|
|
1220
|
+
Effect.gen(function*() {
|
|
1221
|
+
const limit = Math.floor(peekOptions.limit)
|
|
1222
|
+
if (limit <= 0) {
|
|
1223
|
+
const none: ReadonlyArray<JobStore.OutboxEntry> = []
|
|
1224
|
+
return none
|
|
1225
|
+
}
|
|
1226
|
+
// The `after` cursor compares by the id's embedded score (the seq
|
|
1227
|
+
// prefix before the NUL), so the walk moves past the named entry
|
|
1228
|
+
// whether or not it still exists. Unparseable input reads as unset.
|
|
1229
|
+
let afterSeq: number | undefined = undefined
|
|
1230
|
+
if (peekOptions.after !== undefined) {
|
|
1231
|
+
const nul = peekOptions.after.indexOf("\u0000")
|
|
1232
|
+
const seq = nul > 0 ? Number(peekOptions.after.slice(0, nul)) : Number.NaN
|
|
1233
|
+
if (Number.isFinite(seq)) afterSeq = seq
|
|
1234
|
+
}
|
|
1235
|
+
const raw = afterSeq === undefined
|
|
1236
|
+
? yield* redis.send("ZRANGE", `${prefix}:flowoutbox`, "0", String(limit - 1))
|
|
1237
|
+
: yield* redis.send(
|
|
1238
|
+
"ZRANGEBYSCORE",
|
|
1239
|
+
`${prefix}:flowoutbox`,
|
|
1240
|
+
`(${afterSeq}`,
|
|
1241
|
+
"+inf",
|
|
1242
|
+
"LIMIT",
|
|
1243
|
+
"0",
|
|
1244
|
+
String(limit)
|
|
1245
|
+
)
|
|
1246
|
+
// SAFETY: ZRANGE/ZRANGEBYSCORE always reply with arrays of bulk
|
|
1247
|
+
// strings.
|
|
1248
|
+
const members = raw as ReadonlyArray<string>
|
|
1249
|
+
return members.map(toOutboxEntry)
|
|
1250
|
+
}).pipe(Effect.mapError(storeError("peekOutbox failed"))),
|
|
1251
|
+
|
|
1252
|
+
deleteOutbox: (ids) =>
|
|
1253
|
+
Effect.gen(function*() {
|
|
1254
|
+
// Chunked ZREMs keep the variadic argument count bounded; each
|
|
1255
|
+
// chunk is idempotent, so a partial failure just redelivers.
|
|
1256
|
+
for (let start = 0; start < ids.length; start += 500) {
|
|
1257
|
+
yield* redis.send("ZREM", `${prefix}:flowoutbox`, ...ids.slice(start, start + 500))
|
|
1258
|
+
}
|
|
1259
|
+
}).pipe(Effect.mapError(storeError("deleteOutbox failed"))),
|
|
1260
|
+
|
|
1261
|
+
listChildResults: (flowId, listOptions) =>
|
|
1262
|
+
Effect.gen(function*() {
|
|
1263
|
+
const limit = Math.max(1, listOptions?.limit ?? 1000)
|
|
1264
|
+
const reply: { items: ReadonlyArray<ReadonlyArray<string>> | Record<string, never>; more: boolean } = JSON
|
|
1265
|
+
.parse(
|
|
1266
|
+
yield* evalListChildResults(prefix, flowId, listOptions?.cursor ?? "", limit)
|
|
1267
|
+
)
|
|
1268
|
+
const items = asArray(reply.items).map((row) => toChildRecord(flowId, row))
|
|
1269
|
+
const last = items[items.length - 1]
|
|
1270
|
+
return {
|
|
1271
|
+
items,
|
|
1272
|
+
cursor: reply.more && last !== undefined ? last.childKey : undefined
|
|
1273
|
+
}
|
|
1274
|
+
}).pipe(Effect.mapError(storeError("listChildResults failed"))),
|
|
1275
|
+
|
|
1276
|
+
flowSweepWork: (sweepOptions) =>
|
|
1277
|
+
Effect.gen(function*() {
|
|
1278
|
+
const now = yield* Clock.currentTimeMillis
|
|
1279
|
+
const limit = Math.max(1, sweepOptions.limit ?? 1000)
|
|
1280
|
+
const reply: {
|
|
1281
|
+
reconcile:
|
|
1282
|
+
| ReadonlyArray<{
|
|
1283
|
+
flowId: string
|
|
1284
|
+
children: ReadonlyArray<{ childKey: string; storeKey: string; spec: string }>
|
|
1285
|
+
}>
|
|
1286
|
+
| Record<string, never>
|
|
1287
|
+
cascade:
|
|
1288
|
+
| ReadonlyArray<{
|
|
1289
|
+
flowId: string
|
|
1290
|
+
children: ReadonlyArray<{ childKey: string; storeKey: string; childJobId: string }>
|
|
1291
|
+
}>
|
|
1292
|
+
| Record<string, never>
|
|
1293
|
+
} = JSON.parse(yield* evalFlowSweepWork(prefix, sweepOptions.pendingAgeMs, limit, now))
|
|
1294
|
+
const work: JobStore.FlowSweepWork = {
|
|
1295
|
+
reconcile: asArray(reply.reconcile).map((group) => ({
|
|
1296
|
+
flowId: JobStore.JobId(group.flowId),
|
|
1297
|
+
children: group.children.map((child) => {
|
|
1298
|
+
// The stored spec is the verbatim JSON this driver wrote at
|
|
1299
|
+
// fan-out time (never routed through cjson), so it re-parses
|
|
1300
|
+
// to the original EnqueueRequest.
|
|
1301
|
+
const request: JobStore.EnqueueRequest = JSON.parse(child.spec)
|
|
1302
|
+
return { childKey: child.childKey, storeKey: child.storeKey, request }
|
|
1303
|
+
})
|
|
1304
|
+
})),
|
|
1305
|
+
cascade: asArray(reply.cascade).map((group) => ({
|
|
1306
|
+
flowId: JobStore.JobId(group.flowId),
|
|
1307
|
+
children: group.children.map((child) => ({
|
|
1308
|
+
childKey: child.childKey,
|
|
1309
|
+
storeKey: child.storeKey,
|
|
1310
|
+
childJobId: JobStore.JobId(child.childJobId)
|
|
1311
|
+
}))
|
|
1312
|
+
}))
|
|
1313
|
+
}
|
|
1314
|
+
return work
|
|
1315
|
+
}).pipe(Effect.mapError(storeError("flowSweepWork failed"))),
|
|
1316
|
+
|
|
1317
|
+
markChildrenCascaded: (flowId, childKeys) =>
|
|
1318
|
+
evalMarkChildrenCascaded(prefix, flowId, JSON.stringify(childKeys)).pipe(
|
|
1319
|
+
Effect.mapError(storeError("markChildrenCascaded failed")),
|
|
1320
|
+
Effect.asVoid
|
|
820
1321
|
)
|
|
821
1322
|
}
|
|
822
1323
|
|