effect-mq 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -17
- package/dist/Flow.d.ts +381 -0
- package/dist/Flow.d.ts.map +1 -0
- package/dist/Flow.js +340 -0
- package/dist/Flow.js.map +1 -0
- package/dist/Job.d.ts +37 -6
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +17 -2
- package/dist/Job.js.map +1 -1
- package/dist/JobSchedules.d.ts +112 -0
- package/dist/JobSchedules.d.ts.map +1 -0
- package/dist/JobSchedules.js +106 -0
- package/dist/JobSchedules.js.map +1 -0
- package/dist/JobStore.d.ts +320 -10
- package/dist/JobStore.d.ts.map +1 -1
- package/dist/JobStore.js.map +1 -1
- package/dist/MemoryJobStore.d.ts.map +1 -1
- package/dist/MemoryJobStore.js +336 -8
- package/dist/MemoryJobStore.js.map +1 -1
- package/dist/Metrics.d.ts +31 -0
- package/dist/Metrics.d.ts.map +1 -1
- package/dist/Metrics.js +39 -0
- package/dist/Metrics.js.map +1 -1
- package/dist/Worker.d.ts +120 -11
- package/dist/Worker.d.ts.map +1 -1
- package/dist/Worker.js +452 -26
- package/dist/Worker.js.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts +19 -1
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.js +662 -81
- package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
- package/dist/drizzle-postgres/schema.d.ts +310 -3
- package/dist/drizzle-postgres/schema.d.ts.map +1 -1
- package/dist/drizzle-postgres/schema.js +68 -1
- package/dist/drizzle-postgres/schema.js.map +1 -1
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -1
- package/dist/redis/RedisJobStore.d.ts.map +1 -1
- package/dist/redis/RedisJobStore.js +221 -19
- package/dist/redis/RedisJobStore.js.map +1 -1
- package/dist/redis/scripts.d.ts +118 -11
- package/dist/redis/scripts.d.ts.map +1 -1
- package/dist/redis/scripts.js +497 -28
- package/dist/redis/scripts.js.map +1 -1
- package/dist/testing/conformance.d.ts +6 -0
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +765 -1
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +1 -1
- package/src/Flow.ts +778 -0
- package/src/Job.ts +42 -11
- package/src/JobSchedules.ts +223 -0
- package/src/JobStore.ts +347 -9
- package/src/MemoryJobStore.ts +372 -8
- package/src/Metrics.ts +43 -0
- package/src/Worker.ts +726 -37
- package/src/drizzle-postgres/DrizzleJobStore.ts +827 -82
- package/src/drizzle-postgres/schema.ts +94 -0
- package/src/index.ts +16 -0
- package/src/redis/RedisJobStore.ts +291 -8
- package/src/redis/scripts.ts +529 -26
- package/src/testing/conformance.ts +989 -1
package/src/MemoryJobStore.ts
CHANGED
|
@@ -16,9 +16,14 @@ import {
|
|
|
16
16
|
type ClaimResult,
|
|
17
17
|
type EnqueueRequest,
|
|
18
18
|
type ExtendLocksResult,
|
|
19
|
+
type FlowChildRecord,
|
|
20
|
+
type FlowChildReport,
|
|
21
|
+
type FlowChildSpec,
|
|
22
|
+
type FlowSweepWork,
|
|
19
23
|
type HistoryTtlByState,
|
|
20
24
|
type HistoryTtlInput,
|
|
21
25
|
type IdGenerator,
|
|
26
|
+
type OutboxEntry,
|
|
22
27
|
JobId,
|
|
23
28
|
JobNotCancellableError,
|
|
24
29
|
JobNotFoundError,
|
|
@@ -56,6 +61,8 @@ interface MemJob {
|
|
|
56
61
|
cancelRequested: boolean
|
|
57
62
|
readonly dedupeKey: string | undefined
|
|
58
63
|
trace: JobRecord["trace"]
|
|
64
|
+
readonly parent: JobRecord["parent"]
|
|
65
|
+
flow: JobRecord["flow"]
|
|
59
66
|
runAt: number
|
|
60
67
|
readonly enqueuedAt: number
|
|
61
68
|
processedAt: number | undefined
|
|
@@ -87,6 +94,8 @@ const snapshot = (job: MemJob): JobRecord => ({
|
|
|
87
94
|
cancelRequested: job.cancelRequested,
|
|
88
95
|
dedupeKey: job.dedupeKey,
|
|
89
96
|
trace: job.trace,
|
|
97
|
+
parent: job.parent,
|
|
98
|
+
flow: job.flow,
|
|
90
99
|
runAt: job.runAt,
|
|
91
100
|
enqueuedAt: job.enqueuedAt,
|
|
92
101
|
processedAt: job.processedAt,
|
|
@@ -110,10 +119,46 @@ interface MemoryStore {
|
|
|
110
119
|
readonly sweepHistory: (now: number, ttlByState: HistoryTtlByState) => void
|
|
111
120
|
}
|
|
112
121
|
|
|
122
|
+
interface MemFlowChild {
|
|
123
|
+
readonly flowId: JobId
|
|
124
|
+
readonly childKey: string
|
|
125
|
+
readonly storeKey: string
|
|
126
|
+
readonly spec: EnqueueRequest
|
|
127
|
+
status: FlowChildRecord["status"]
|
|
128
|
+
exit: unknown
|
|
129
|
+
failedReason: string | undefined
|
|
130
|
+
cascaded: boolean
|
|
131
|
+
/** Sweep-eligibility timestamp: set at FanOut, re-armed on each return. */
|
|
132
|
+
pendingSince: number
|
|
133
|
+
}
|
|
134
|
+
|
|
113
135
|
const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemoryStore => {
|
|
114
136
|
const jobs = new Map<string, MemJob>()
|
|
115
137
|
const schedules = new Map<ScheduleKey, ScheduleRecord>()
|
|
116
138
|
const paused = new Set<QueueName>()
|
|
139
|
+
// Flow dependency rows, keyed by parent job id then child key. Insertion
|
|
140
|
+
// order is FanOut spec order; listChildResults sorts by child key.
|
|
141
|
+
const flowChildren = new Map<JobId, Map<string, MemFlowChild>>()
|
|
142
|
+
// Undelivered child-result reports, appended atomically with every
|
|
143
|
+
// terminal transition of an envelope-carrying job (see OutboxEntry).
|
|
144
|
+
const outbox: Array<OutboxEntry> = []
|
|
145
|
+
let outboxSeq = 0
|
|
146
|
+
|
|
147
|
+
const appendOutbox = (job: MemJob, outcome: FlowChildReport["outcome"]) => {
|
|
148
|
+
if (job.parent === undefined) return
|
|
149
|
+
outbox.push({
|
|
150
|
+
id: `ob-${++outboxSeq}`,
|
|
151
|
+
flowName: job.parent.flowName,
|
|
152
|
+
parentStoreKey: job.parent.parentStoreKey,
|
|
153
|
+
report: {
|
|
154
|
+
flowId: job.parent.flowId,
|
|
155
|
+
childKey: job.parent.childKey,
|
|
156
|
+
outcome,
|
|
157
|
+
exit: job.exit,
|
|
158
|
+
failedReason: job.failedReason
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
}
|
|
117
162
|
// Dedup registry: one entry per (name, key). `expiresAt` is set for
|
|
118
163
|
// ttl/throttle windows; pending-mode entries live as long as their job.
|
|
119
164
|
const dedupes = new Map<string, { jobId: JobId; expiresAt: number | undefined }>()
|
|
@@ -198,12 +243,68 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
|
|
|
198
243
|
}
|
|
199
244
|
}
|
|
200
245
|
|
|
246
|
+
// A pruned/removed flow parent takes its dependency rows with it.
|
|
247
|
+
const deleteJob = (id: string) => {
|
|
248
|
+
jobs.delete(id)
|
|
249
|
+
// SAFETY: flowChildren keys are JobIds; a plain string that is not one
|
|
250
|
+
// simply misses.
|
|
251
|
+
flowChildren.delete(id as JobId)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// A settled flow parent whose rows still owe cascade cancels is exempt
|
|
255
|
+
// from automatic retention (keep policies, the history sweep): deleting
|
|
256
|
+
// it would delete the only record that the child stores are still owed
|
|
257
|
+
// real cancels, leaving marked children running. Once the sweeper marks
|
|
258
|
+
// the rows cascaded, retention applies normally. The explicit `remove`
|
|
259
|
+
// verb is NOT exempted — it is an operator override.
|
|
260
|
+
const owesCascades = (id: string) => {
|
|
261
|
+
// SAFETY: flowChildren keys are JobIds; a plain string that is not one
|
|
262
|
+
// simply misses.
|
|
263
|
+
const rows = flowChildren.get(id as JobId)
|
|
264
|
+
if (rows === undefined) return false
|
|
265
|
+
for (const row of rows.values()) {
|
|
266
|
+
if (row.status === "cancelled" && !row.cascaded) return true
|
|
267
|
+
}
|
|
268
|
+
return false
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Settle-time marking: remaining pending rows flip to cancelled (NOT
|
|
272
|
+
// cascaded — the sweeper still owes the child stores real cancels), so
|
|
273
|
+
// late reports find their row terminal and drop, and `listChildResults`
|
|
274
|
+
// stays truthful. Returns how many rows flipped, for the flow counters.
|
|
275
|
+
// Lock order note: in this driver everything is one synchronous block,
|
|
276
|
+
// but the row-then-parent order is still observed.
|
|
277
|
+
const markPendingRowsCancelled = (flowId: JobId): number => {
|
|
278
|
+
const rows = flowChildren.get(flowId)
|
|
279
|
+
if (rows === undefined) return 0
|
|
280
|
+
let marked = 0
|
|
281
|
+
for (const row of rows.values()) {
|
|
282
|
+
if (row.status === "pending") {
|
|
283
|
+
row.status = "cancelled"
|
|
284
|
+
row.cascaded = false
|
|
285
|
+
marked += 1
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return marked
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const settleMarkRows = (job: MemJob) => {
|
|
292
|
+
const marked = markPendingRowsCancelled(job.id)
|
|
293
|
+
if (job.flow !== undefined) {
|
|
294
|
+
job.flow = { ...job.flow, pending: 0, cancelled: job.flow.cancelled + marked }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
201
298
|
const markCancelled = (job: MemJob, now: number) => {
|
|
202
299
|
clearLock(job)
|
|
203
300
|
job.cancelRequested = false
|
|
301
|
+
if (job.state === "waiting-children") {
|
|
302
|
+
settleMarkRows(job)
|
|
303
|
+
}
|
|
204
304
|
job.state = "cancelled"
|
|
205
305
|
job.finishedAt = now
|
|
206
306
|
recordAttempt(job, "cancelled", now, undefined)
|
|
307
|
+
appendOutbox(job, "cancelled")
|
|
207
308
|
releaseDedupe(job, now)
|
|
208
309
|
applyKeep(job, now)
|
|
209
310
|
}
|
|
@@ -253,7 +354,8 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
|
|
|
253
354
|
}
|
|
254
355
|
}
|
|
255
356
|
for (const id of remove) {
|
|
256
|
-
|
|
357
|
+
if (owesCascades(id)) continue
|
|
358
|
+
deleteJob(id)
|
|
257
359
|
}
|
|
258
360
|
}
|
|
259
361
|
|
|
@@ -267,8 +369,8 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
|
|
|
267
369
|
// The sweep honours min(per-row keep age, store ceiling) — a quiet job
|
|
268
370
|
// name is pruned on the timer, not only when its group is acked.
|
|
269
371
|
const effective = keepAge !== undefined && (ttl === undefined || keepAge < ttl) ? keepAge : ttl
|
|
270
|
-
if (effective !== undefined && job.finishedAt <= now - effective) {
|
|
271
|
-
|
|
372
|
+
if (effective !== undefined && job.finishedAt <= now - effective && !owesCascades(job.id)) {
|
|
373
|
+
deleteJob(job.id)
|
|
272
374
|
}
|
|
273
375
|
}
|
|
274
376
|
// Dead dedup entries: expired window, or a pointer at a vanished job.
|
|
@@ -289,7 +391,8 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
|
|
|
289
391
|
}
|
|
290
392
|
switch (job.state) {
|
|
291
393
|
case "waiting":
|
|
292
|
-
case "delayed":
|
|
394
|
+
case "delayed":
|
|
395
|
+
case "waiting-children": {
|
|
293
396
|
markCancelled(job, now)
|
|
294
397
|
return
|
|
295
398
|
}
|
|
@@ -321,6 +424,8 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
|
|
|
321
424
|
cancelRequested: false,
|
|
322
425
|
dedupeKey: request.dedupe?.key,
|
|
323
426
|
trace: request.trace,
|
|
427
|
+
parent: request.parent,
|
|
428
|
+
flow: undefined,
|
|
324
429
|
runAt: now + Math.max(0, request.delayMs),
|
|
325
430
|
enqueuedAt: now,
|
|
326
431
|
processedAt: undefined,
|
|
@@ -466,15 +571,79 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
|
|
|
466
571
|
if (job.state !== "active" || job.lockToken !== token) {
|
|
467
572
|
return yield* new LockLostError({ jobId: id })
|
|
468
573
|
}
|
|
574
|
+
if (
|
|
575
|
+
outcome._tag === "FanOut" &&
|
|
576
|
+
outcome.children.some((child) => child.request.id === undefined)
|
|
577
|
+
) {
|
|
578
|
+
// Validate BEFORE any mutation, so a bad spec cannot leave the job
|
|
579
|
+
// half-acked (lock cleared, ledger written, still active).
|
|
580
|
+
return yield* new JobStoreError({
|
|
581
|
+
message: "FanOut child specs require an explicit request.id"
|
|
582
|
+
})
|
|
583
|
+
}
|
|
469
584
|
clearLock(job)
|
|
470
|
-
|
|
585
|
+
// A fan-out is a phase transition, not a completed run — the attempt
|
|
586
|
+
// budget spans both phases.
|
|
587
|
+
if (outcome._tag !== "FanOut") {
|
|
588
|
+
job.attemptsMade += 1
|
|
589
|
+
}
|
|
471
590
|
switch (outcome._tag) {
|
|
591
|
+
case "FanOut": {
|
|
592
|
+
recordAttempt(job, "fanned-out", now, undefined)
|
|
593
|
+
if (job.flow === undefined) {
|
|
594
|
+
job.flow = {
|
|
595
|
+
failFast: outcome.failFast,
|
|
596
|
+
pending: outcome.children.length,
|
|
597
|
+
completed: 0,
|
|
598
|
+
failed: 0,
|
|
599
|
+
cancelled: 0
|
|
600
|
+
}
|
|
601
|
+
const rows = new Map<string, MemFlowChild>()
|
|
602
|
+
for (const child of outcome.children) {
|
|
603
|
+
rows.set(child.childKey, {
|
|
604
|
+
flowId: job.id,
|
|
605
|
+
childKey: child.childKey,
|
|
606
|
+
storeKey: child.storeKey,
|
|
607
|
+
spec: child.request,
|
|
608
|
+
status: "pending",
|
|
609
|
+
exit: undefined,
|
|
610
|
+
failedReason: undefined,
|
|
611
|
+
cascaded: false,
|
|
612
|
+
pendingSince: now
|
|
613
|
+
})
|
|
614
|
+
}
|
|
615
|
+
flowChildren.set(job.id, rows)
|
|
616
|
+
}
|
|
617
|
+
// A manifest that was already present is kept untouched (double
|
|
618
|
+
// fan-out converges on the persisted children); the state
|
|
619
|
+
// transition follows the persisted pending count either way.
|
|
620
|
+
if (job.cancelRequested) {
|
|
621
|
+
// A cancel raced the fan-out: cancellation wins. Mark the rows
|
|
622
|
+
// here — the job is still `active`, so markCancelled's own
|
|
623
|
+
// waiting-children marking does not apply — and the sweeper
|
|
624
|
+
// cascades (mostly no-op cancels for never-enqueued children).
|
|
625
|
+
settleMarkRows(job)
|
|
626
|
+
markCancelled(job, now)
|
|
627
|
+
break
|
|
628
|
+
}
|
|
629
|
+
if (job.flow.pending > 0) {
|
|
630
|
+
job.state = "waiting-children"
|
|
631
|
+
} else {
|
|
632
|
+
// Empty spec: settle straight to runnable `collect`.
|
|
633
|
+
job.state = "waiting"
|
|
634
|
+
job.runAt = now
|
|
635
|
+
job.seq = ++seq
|
|
636
|
+
signalWake(job.queue)
|
|
637
|
+
}
|
|
638
|
+
break
|
|
639
|
+
}
|
|
472
640
|
case "Complete": {
|
|
473
641
|
job.cancelRequested = false
|
|
474
642
|
job.state = "completed"
|
|
475
643
|
job.exit = outcome.exit
|
|
476
644
|
job.finishedAt = now
|
|
477
645
|
recordAttempt(job, "completed", now, outcome.exit)
|
|
646
|
+
appendOutbox(job, "completed")
|
|
478
647
|
releaseDedupe(job, now)
|
|
479
648
|
applyKeep(job, now)
|
|
480
649
|
break
|
|
@@ -485,6 +654,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
|
|
|
485
654
|
job.exit = outcome.exit
|
|
486
655
|
job.finishedAt = now
|
|
487
656
|
recordAttempt(job, "failed", now, outcome.exit)
|
|
657
|
+
appendOutbox(job, "failed")
|
|
488
658
|
releaseDedupe(job, now)
|
|
489
659
|
applyKeep(job, now)
|
|
490
660
|
break
|
|
@@ -494,6 +664,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
|
|
|
494
664
|
job.state = "cancelled"
|
|
495
665
|
job.finishedAt = now
|
|
496
666
|
recordAttempt(job, "cancelled", now, undefined)
|
|
667
|
+
appendOutbox(job, "cancelled")
|
|
497
668
|
releaseDedupe(job, now)
|
|
498
669
|
applyKeep(job, now)
|
|
499
670
|
break
|
|
@@ -584,6 +755,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
|
|
|
584
755
|
job.state = "failed"
|
|
585
756
|
job.finishedAt = now
|
|
586
757
|
job.failedReason = "job stalled more than allowable limit"
|
|
758
|
+
appendOutbox(job, "failed")
|
|
587
759
|
releaseDedupe(job, now)
|
|
588
760
|
recovered.push({ id: job.id, failed: true })
|
|
589
761
|
} else {
|
|
@@ -753,7 +925,8 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
|
|
|
753
925
|
Effect.sync(() =>
|
|
754
926
|
Array.from(schedules.values()).filter((schedule) =>
|
|
755
927
|
(options?.jobName === undefined || schedule.jobName === options.jobName) &&
|
|
756
|
-
(options?.queue === undefined || schedule.queue === options.queue)
|
|
928
|
+
(options?.queue === undefined || schedule.queue === options.queue) &&
|
|
929
|
+
(options?.group === undefined || schedule.group === options.group)
|
|
757
930
|
)
|
|
758
931
|
),
|
|
759
932
|
|
|
@@ -796,12 +969,201 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
|
|
|
796
969
|
return true
|
|
797
970
|
}),
|
|
798
971
|
|
|
972
|
+
recordChildResults: (reports) =>
|
|
973
|
+
Effect.gen(function*() {
|
|
974
|
+
const now = yield* Clock.currentTimeMillis
|
|
975
|
+
const results = reports.map(() => ({ applied: false, parentSettled: false }))
|
|
976
|
+
|
|
977
|
+
// Phase 1 — apply every row update (lock order: rows before
|
|
978
|
+
// parents), tracking per flow which report would decide a settle.
|
|
979
|
+
const touched = new Map<JobId, { lastApplied: number; firstAppliedFailed: number | undefined }>()
|
|
980
|
+
for (const [index, report] of reports.entries()) {
|
|
981
|
+
const row = flowChildren.get(report.flowId)?.get(report.childKey)
|
|
982
|
+
if (row === undefined || row.status !== "pending") continue
|
|
983
|
+
row.status = report.outcome
|
|
984
|
+
row.exit = report.exit
|
|
985
|
+
row.failedReason = report.failedReason
|
|
986
|
+
// The outcome came from the child's store: no cancel to deliver.
|
|
987
|
+
row.cascaded = true
|
|
988
|
+
results[index] = { applied: true, parentSettled: false }
|
|
989
|
+
const parent = jobs.get(report.flowId)
|
|
990
|
+
if (parent?.flow !== undefined) {
|
|
991
|
+
const flow = parent.flow
|
|
992
|
+
parent.flow = {
|
|
993
|
+
...flow,
|
|
994
|
+
pending: Math.max(0, flow.pending - 1),
|
|
995
|
+
completed: flow.completed + (report.outcome === "completed" ? 1 : 0),
|
|
996
|
+
failed: flow.failed + (report.outcome === "failed" ? 1 : 0),
|
|
997
|
+
cancelled: flow.cancelled + (report.outcome === "cancelled" ? 1 : 0)
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
const touch = touched.get(report.flowId) ?? { lastApplied: index, firstAppliedFailed: undefined }
|
|
1001
|
+
touch.lastApplied = index
|
|
1002
|
+
if (report.outcome === "failed" && touch.firstAppliedFailed === undefined) {
|
|
1003
|
+
touch.firstAppliedFailed = index
|
|
1004
|
+
}
|
|
1005
|
+
touched.set(report.flowId, touch)
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Phase 2 — at most one settle decision per touched flow. Fail-fast
|
|
1009
|
+
// wins ties (a batch whose failure also empties `pending` settles
|
|
1010
|
+
// terminally, never into collect).
|
|
1011
|
+
for (const [flowId, touch] of touched) {
|
|
1012
|
+
const parent = jobs.get(flowId)
|
|
1013
|
+
if (parent === undefined || parent.flow === undefined) continue
|
|
1014
|
+
if (parent.state !== "waiting-children") continue
|
|
1015
|
+
const failedIndex = parent.flow.failFast ? touch.firstAppliedFailed : undefined
|
|
1016
|
+
const failedReport = failedIndex !== undefined ? reports[failedIndex] : undefined
|
|
1017
|
+
if (failedIndex !== undefined && failedReport !== undefined) {
|
|
1018
|
+
// First applied failure settles the parent terminally
|
|
1019
|
+
// (store-side, like stall exhaustion) and marks the remaining
|
|
1020
|
+
// rows in the same op. A nested parent's own report goes to the
|
|
1021
|
+
// outbox here — this settle IS its terminal transition.
|
|
1022
|
+
settleMarkRows(parent)
|
|
1023
|
+
parent.cancelRequested = false
|
|
1024
|
+
parent.state = "failed"
|
|
1025
|
+
parent.finishedAt = now
|
|
1026
|
+
parent.failedReason = `effect-mq: flow child "${failedReport.childKey}" failed`
|
|
1027
|
+
recordAttempt(parent, "failed", now, undefined)
|
|
1028
|
+
appendOutbox(parent, "failed")
|
|
1029
|
+
releaseDedupe(parent, now)
|
|
1030
|
+
applyKeep(parent, now)
|
|
1031
|
+
results[failedIndex] = { applied: true, parentSettled: true }
|
|
1032
|
+
continue
|
|
1033
|
+
}
|
|
1034
|
+
if (parent.flow.pending === 0) {
|
|
1035
|
+
// All children settled: the parent resumes runnable, phase
|
|
1036
|
+
// collect.
|
|
1037
|
+
parent.state = "waiting"
|
|
1038
|
+
parent.runAt = now
|
|
1039
|
+
parent.seq = ++seq
|
|
1040
|
+
signalWake(parent.queue)
|
|
1041
|
+
results[touch.lastApplied] = { applied: true, parentSettled: true }
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
return results
|
|
1045
|
+
}),
|
|
1046
|
+
|
|
1047
|
+
peekOutbox: (options) =>
|
|
1048
|
+
Effect.sync(() => {
|
|
1049
|
+
// `after` compares by the id's embedded sequence so the cursor
|
|
1050
|
+
// works whether or not the named entry still exists.
|
|
1051
|
+
const afterSeq = options.after !== undefined && options.after.startsWith("ob-")
|
|
1052
|
+
? Number(options.after.slice(3))
|
|
1053
|
+
: undefined
|
|
1054
|
+
const eligible = afterSeq === undefined || Number.isNaN(afterSeq)
|
|
1055
|
+
? outbox
|
|
1056
|
+
: outbox.filter((entry) => Number(entry.id.slice(3)) > afterSeq)
|
|
1057
|
+
return eligible.slice(0, Math.max(0, options.limit))
|
|
1058
|
+
}),
|
|
1059
|
+
|
|
1060
|
+
deleteOutbox: (ids) =>
|
|
1061
|
+
Effect.sync(() => {
|
|
1062
|
+
const drop = new Set(ids)
|
|
1063
|
+
let write = 0
|
|
1064
|
+
for (const entry of outbox) {
|
|
1065
|
+
if (!drop.has(entry.id)) {
|
|
1066
|
+
outbox[write] = entry
|
|
1067
|
+
write += 1
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
outbox.length = write
|
|
1071
|
+
}),
|
|
1072
|
+
|
|
1073
|
+
listChildResults: (flowId, options) =>
|
|
1074
|
+
Effect.sync(() => {
|
|
1075
|
+
const limit = Math.max(1, options?.limit ?? 1000)
|
|
1076
|
+
const rows = Array.from(flowChildren.get(flowId)?.values() ?? [])
|
|
1077
|
+
.toSorted((a, b) => (a.childKey < b.childKey ? -1 : a.childKey > b.childKey ? 1 : 0))
|
|
1078
|
+
.filter((row) => options?.cursor === undefined || row.childKey > options.cursor)
|
|
1079
|
+
const page = rows.slice(0, limit)
|
|
1080
|
+
const items: Array<FlowChildRecord> = page.map((row) => ({
|
|
1081
|
+
flowId: row.flowId,
|
|
1082
|
+
childKey: row.childKey,
|
|
1083
|
+
name: row.spec.name,
|
|
1084
|
+
storeKey: row.storeKey,
|
|
1085
|
+
// SAFETY: FanOut validated every spec id at ack time.
|
|
1086
|
+
childJobId: row.spec.id as JobId,
|
|
1087
|
+
status: row.status,
|
|
1088
|
+
exit: row.exit,
|
|
1089
|
+
failedReason: row.failedReason,
|
|
1090
|
+
cascaded: row.cascaded
|
|
1091
|
+
}))
|
|
1092
|
+
const last = page[page.length - 1]
|
|
1093
|
+
return {
|
|
1094
|
+
items,
|
|
1095
|
+
cursor: rows.length > limit && last !== undefined ? last.childKey : undefined
|
|
1096
|
+
}
|
|
1097
|
+
}),
|
|
1098
|
+
|
|
1099
|
+
flowSweepWork: (options) =>
|
|
1100
|
+
Effect.gen(function*() {
|
|
1101
|
+
const now = yield* Clock.currentTimeMillis
|
|
1102
|
+
const limit = Math.max(1, options.limit ?? 1000)
|
|
1103
|
+
const threshold = now - options.pendingAgeMs
|
|
1104
|
+
const reconcile: Array<{ flowId: JobId; children: Array<FlowChildSpec> }> = []
|
|
1105
|
+
const cascade: Array<
|
|
1106
|
+
{ flowId: JobId; children: Array<{ childKey: string; storeKey: string; childJobId: JobId }> }
|
|
1107
|
+
> = []
|
|
1108
|
+
let reconcileCount = 0
|
|
1109
|
+
let cascadeCount = 0
|
|
1110
|
+
for (const [flowId, rows] of flowChildren) {
|
|
1111
|
+
const parent = jobs.get(flowId)
|
|
1112
|
+
const reconciling = parent !== undefined && parent.state === "waiting-children"
|
|
1113
|
+
let reconcileGroup: Array<FlowChildSpec> | undefined
|
|
1114
|
+
let cascadeGroup:
|
|
1115
|
+
| Array<{ childKey: string; storeKey: string; childJobId: JobId }>
|
|
1116
|
+
| undefined
|
|
1117
|
+
for (const row of rows.values()) {
|
|
1118
|
+
if (
|
|
1119
|
+
reconciling && row.status === "pending" &&
|
|
1120
|
+
row.pendingSince <= threshold && reconcileCount < limit
|
|
1121
|
+
) {
|
|
1122
|
+
reconcileGroup ??= []
|
|
1123
|
+
reconcileGroup.push({ childKey: row.childKey, storeKey: row.storeKey, request: row.spec })
|
|
1124
|
+
reconcileCount += 1
|
|
1125
|
+
// Re-arm eligibility: returned work waits another pendingAgeMs
|
|
1126
|
+
// before it can come back, so a full page rotates (see the
|
|
1127
|
+
// interface's rotation note).
|
|
1128
|
+
row.pendingSince = now
|
|
1129
|
+
}
|
|
1130
|
+
if (row.status === "cancelled" && !row.cascaded && cascadeCount < limit) {
|
|
1131
|
+
cascadeGroup ??= []
|
|
1132
|
+
cascadeGroup.push({
|
|
1133
|
+
childKey: row.childKey,
|
|
1134
|
+
storeKey: row.storeKey,
|
|
1135
|
+
// SAFETY: FanOut validated every spec id at ack time.
|
|
1136
|
+
childJobId: row.spec.id as JobId
|
|
1137
|
+
})
|
|
1138
|
+
cascadeCount += 1
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
if (reconcileGroup !== undefined) reconcile.push({ flowId, children: reconcileGroup })
|
|
1142
|
+
if (cascadeGroup !== undefined) cascade.push({ flowId, children: cascadeGroup })
|
|
1143
|
+
}
|
|
1144
|
+
const work: FlowSweepWork = { reconcile, cascade }
|
|
1145
|
+
return work
|
|
1146
|
+
}),
|
|
1147
|
+
|
|
1148
|
+
markChildrenCascaded: (flowId, childKeys) =>
|
|
1149
|
+
Effect.sync(() => {
|
|
1150
|
+
const rows = flowChildren.get(flowId)
|
|
1151
|
+
if (rows === undefined) return
|
|
1152
|
+
for (const key of childKeys) {
|
|
1153
|
+
const row = rows.get(key)
|
|
1154
|
+
if (row !== undefined) {
|
|
1155
|
+
row.cascaded = true
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}),
|
|
1159
|
+
|
|
799
1160
|
counts: (queue) =>
|
|
800
1161
|
Effect.sync(() => {
|
|
801
1162
|
const counts = {
|
|
802
1163
|
waiting: 0,
|
|
803
1164
|
delayed: 0,
|
|
804
1165
|
active: 0,
|
|
1166
|
+
"waiting-children": 0,
|
|
805
1167
|
completed: 0,
|
|
806
1168
|
failed: 0,
|
|
807
1169
|
cancelled: 0
|
|
@@ -816,8 +1178,10 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
|
|
|
816
1178
|
remove: (id) =>
|
|
817
1179
|
Effect.sync(() => {
|
|
818
1180
|
const job = jobs.get(id)
|
|
819
|
-
if (job === undefined || job.state === "active")
|
|
820
|
-
|
|
1181
|
+
if (job === undefined || job.state === "active" || job.state === "waiting-children") {
|
|
1182
|
+
return false
|
|
1183
|
+
}
|
|
1184
|
+
deleteJob(id)
|
|
821
1185
|
return true
|
|
822
1186
|
})
|
|
823
1187
|
})
|
package/src/Metrics.ts
CHANGED
|
@@ -133,3 +133,46 @@ export const stalledRecovered = Metric.counter("effect_mq_stalled_recovered", {
|
|
|
133
133
|
export const scheduleTicks = Metric.counter("effect_mq_schedule_ticks", {
|
|
134
134
|
description: "Repeatable-schedule occurrences enqueued by the schedule sweep"
|
|
135
135
|
})
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Flow fan-outs acked (manifests landed). Tags: `flow`.
|
|
139
|
+
*
|
|
140
|
+
* @since 0.6.0
|
|
141
|
+
*/
|
|
142
|
+
export const flowFanOuts = Metric.counter("effect_mq_flow_fanouts", {
|
|
143
|
+
description: "Flow fan-out acks (child manifests persisted)"
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Child results recorded into parent stores. Tags: `flow`, `outcome`
|
|
148
|
+
* (completed | failed | cancelled), `source` (`report` = delivered by a
|
|
149
|
+
* worker's outbox relay; `reconcile` = synthesized by the flow sweeper from
|
|
150
|
+
* child-store state). Only applied reports count — duplicates dropped by
|
|
151
|
+
* the dependency row do not.
|
|
152
|
+
*
|
|
153
|
+
* @since 0.6.0
|
|
154
|
+
*/
|
|
155
|
+
export const flowChildReports = Metric.counter("effect_mq_flow_child_reports", {
|
|
156
|
+
description: "Applied flow child-result reports by outcome and delivery path"
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Cancels delivered into child stores after a flow settle. Tags: none.
|
|
161
|
+
*
|
|
162
|
+
* @since 0.6.0
|
|
163
|
+
*/
|
|
164
|
+
export const flowCascades = Metric.counter("effect_mq_flow_cascades", {
|
|
165
|
+
description: "Child cancels cascaded into child stores after a flow settled"
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Undelivered outbox entries a relay drain had to leave behind (their
|
|
170
|
+
* parent store is not reachable from this worker — no matching `flows`
|
|
171
|
+
* registration). Tags: none. Sustained growth means no process anywhere
|
|
172
|
+
* can relay these flows; reconciliation keeps the flows correct meanwhile.
|
|
173
|
+
*
|
|
174
|
+
* @since 0.6.0
|
|
175
|
+
*/
|
|
176
|
+
export const flowOutboxSkipped = Metric.counter("effect_mq_flow_outbox_skipped", {
|
|
177
|
+
description: "Outbox entries left undelivered by a relay drain (parent store unknown here)"
|
|
178
|
+
})
|