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
package/dist/MemoryJobStore.js
CHANGED
|
@@ -30,6 +30,8 @@ const snapshot = (job) => ({
|
|
|
30
30
|
cancelRequested: job.cancelRequested,
|
|
31
31
|
dedupeKey: job.dedupeKey,
|
|
32
32
|
trace: job.trace,
|
|
33
|
+
parent: job.parent,
|
|
34
|
+
flow: job.flow,
|
|
33
35
|
runAt: job.runAt,
|
|
34
36
|
enqueuedAt: job.enqueuedAt,
|
|
35
37
|
processedAt: job.processedAt,
|
|
@@ -48,6 +50,29 @@ const makeStoreUnsafe = (options) => {
|
|
|
48
50
|
const jobs = new Map();
|
|
49
51
|
const schedules = new Map();
|
|
50
52
|
const paused = new Set();
|
|
53
|
+
// Flow dependency rows, keyed by parent job id then child key. Insertion
|
|
54
|
+
// order is FanOut spec order; listChildResults sorts by child key.
|
|
55
|
+
const flowChildren = new Map();
|
|
56
|
+
// Undelivered child-result reports, appended atomically with every
|
|
57
|
+
// terminal transition of an envelope-carrying job (see OutboxEntry).
|
|
58
|
+
const outbox = [];
|
|
59
|
+
let outboxSeq = 0;
|
|
60
|
+
const appendOutbox = (job, outcome) => {
|
|
61
|
+
if (job.parent === undefined)
|
|
62
|
+
return;
|
|
63
|
+
outbox.push({
|
|
64
|
+
id: `ob-${++outboxSeq}`,
|
|
65
|
+
flowName: job.parent.flowName,
|
|
66
|
+
parentStoreKey: job.parent.parentStoreKey,
|
|
67
|
+
report: {
|
|
68
|
+
flowId: job.parent.flowId,
|
|
69
|
+
childKey: job.parent.childKey,
|
|
70
|
+
outcome,
|
|
71
|
+
exit: job.exit,
|
|
72
|
+
failedReason: job.failedReason
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
};
|
|
51
76
|
// Dedup registry: one entry per (name, key). `expiresAt` is set for
|
|
52
77
|
// ttl/throttle windows; pending-mode entries live as long as their job.
|
|
53
78
|
const dedupes = new Map();
|
|
@@ -117,12 +142,67 @@ const makeStoreUnsafe = (options) => {
|
|
|
117
142
|
dedupes.delete(key);
|
|
118
143
|
}
|
|
119
144
|
};
|
|
145
|
+
// A pruned/removed flow parent takes its dependency rows with it.
|
|
146
|
+
const deleteJob = (id) => {
|
|
147
|
+
jobs.delete(id);
|
|
148
|
+
// SAFETY: flowChildren keys are JobIds; a plain string that is not one
|
|
149
|
+
// simply misses.
|
|
150
|
+
flowChildren.delete(id);
|
|
151
|
+
};
|
|
152
|
+
// A settled flow parent whose rows still owe cascade cancels is exempt
|
|
153
|
+
// from automatic retention (keep policies, the history sweep): deleting
|
|
154
|
+
// it would delete the only record that the child stores are still owed
|
|
155
|
+
// real cancels, leaving marked children running. Once the sweeper marks
|
|
156
|
+
// the rows cascaded, retention applies normally. The explicit `remove`
|
|
157
|
+
// verb is NOT exempted — it is an operator override.
|
|
158
|
+
const owesCascades = (id) => {
|
|
159
|
+
// SAFETY: flowChildren keys are JobIds; a plain string that is not one
|
|
160
|
+
// simply misses.
|
|
161
|
+
const rows = flowChildren.get(id);
|
|
162
|
+
if (rows === undefined)
|
|
163
|
+
return false;
|
|
164
|
+
for (const row of rows.values()) {
|
|
165
|
+
if (row.status === "cancelled" && !row.cascaded)
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
};
|
|
170
|
+
// Settle-time marking: remaining pending rows flip to cancelled (NOT
|
|
171
|
+
// cascaded — the sweeper still owes the child stores real cancels), so
|
|
172
|
+
// late reports find their row terminal and drop, and `listChildResults`
|
|
173
|
+
// stays truthful. Returns how many rows flipped, for the flow counters.
|
|
174
|
+
// Lock order note: in this driver everything is one synchronous block,
|
|
175
|
+
// but the row-then-parent order is still observed.
|
|
176
|
+
const markPendingRowsCancelled = (flowId) => {
|
|
177
|
+
const rows = flowChildren.get(flowId);
|
|
178
|
+
if (rows === undefined)
|
|
179
|
+
return 0;
|
|
180
|
+
let marked = 0;
|
|
181
|
+
for (const row of rows.values()) {
|
|
182
|
+
if (row.status === "pending") {
|
|
183
|
+
row.status = "cancelled";
|
|
184
|
+
row.cascaded = false;
|
|
185
|
+
marked += 1;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return marked;
|
|
189
|
+
};
|
|
190
|
+
const settleMarkRows = (job) => {
|
|
191
|
+
const marked = markPendingRowsCancelled(job.id);
|
|
192
|
+
if (job.flow !== undefined) {
|
|
193
|
+
job.flow = { ...job.flow, pending: 0, cancelled: job.flow.cancelled + marked };
|
|
194
|
+
}
|
|
195
|
+
};
|
|
120
196
|
const markCancelled = (job, now) => {
|
|
121
197
|
clearLock(job);
|
|
122
198
|
job.cancelRequested = false;
|
|
199
|
+
if (job.state === "waiting-children") {
|
|
200
|
+
settleMarkRows(job);
|
|
201
|
+
}
|
|
123
202
|
job.state = "cancelled";
|
|
124
203
|
job.finishedAt = now;
|
|
125
204
|
recordAttempt(job, "cancelled", now, undefined);
|
|
205
|
+
appendOutbox(job, "cancelled");
|
|
126
206
|
releaseDedupe(job, now);
|
|
127
207
|
applyKeep(job, now);
|
|
128
208
|
};
|
|
@@ -171,7 +251,9 @@ const makeStoreUnsafe = (options) => {
|
|
|
171
251
|
}
|
|
172
252
|
}
|
|
173
253
|
for (const id of remove) {
|
|
174
|
-
|
|
254
|
+
if (owesCascades(id))
|
|
255
|
+
continue;
|
|
256
|
+
deleteJob(id);
|
|
175
257
|
}
|
|
176
258
|
};
|
|
177
259
|
const sweepHistory = (now, ttlByState) => {
|
|
@@ -185,8 +267,8 @@ const makeStoreUnsafe = (options) => {
|
|
|
185
267
|
// The sweep honours min(per-row keep age, store ceiling) — a quiet job
|
|
186
268
|
// name is pruned on the timer, not only when its group is acked.
|
|
187
269
|
const effective = keepAge !== undefined && (ttl === undefined || keepAge < ttl) ? keepAge : ttl;
|
|
188
|
-
if (effective !== undefined && job.finishedAt <= now - effective) {
|
|
189
|
-
|
|
270
|
+
if (effective !== undefined && job.finishedAt <= now - effective && !owesCascades(job.id)) {
|
|
271
|
+
deleteJob(job.id);
|
|
190
272
|
}
|
|
191
273
|
}
|
|
192
274
|
// Dead dedup entries: expired window, or a pointer at a vanished job.
|
|
@@ -206,7 +288,8 @@ const makeStoreUnsafe = (options) => {
|
|
|
206
288
|
}
|
|
207
289
|
switch (job.state) {
|
|
208
290
|
case "waiting":
|
|
209
|
-
case "delayed":
|
|
291
|
+
case "delayed":
|
|
292
|
+
case "waiting-children": {
|
|
210
293
|
markCancelled(job, now);
|
|
211
294
|
return;
|
|
212
295
|
}
|
|
@@ -237,6 +320,8 @@ const makeStoreUnsafe = (options) => {
|
|
|
237
320
|
cancelRequested: false,
|
|
238
321
|
dedupeKey: request.dedupe?.key,
|
|
239
322
|
trace: request.trace,
|
|
323
|
+
parent: request.parent,
|
|
324
|
+
flow: undefined,
|
|
240
325
|
runAt: now + Math.max(0, request.delayMs),
|
|
241
326
|
enqueuedAt: now,
|
|
242
327
|
processedAt: undefined,
|
|
@@ -375,15 +460,78 @@ const makeStoreUnsafe = (options) => {
|
|
|
375
460
|
if (job.state !== "active" || job.lockToken !== token) {
|
|
376
461
|
return yield* new LockLostError({ jobId: id });
|
|
377
462
|
}
|
|
463
|
+
if (outcome._tag === "FanOut" &&
|
|
464
|
+
outcome.children.some((child) => child.request.id === undefined)) {
|
|
465
|
+
// Validate BEFORE any mutation, so a bad spec cannot leave the job
|
|
466
|
+
// half-acked (lock cleared, ledger written, still active).
|
|
467
|
+
return yield* new JobStoreError({
|
|
468
|
+
message: "FanOut child specs require an explicit request.id"
|
|
469
|
+
});
|
|
470
|
+
}
|
|
378
471
|
clearLock(job);
|
|
379
|
-
|
|
472
|
+
// A fan-out is a phase transition, not a completed run — the attempt
|
|
473
|
+
// budget spans both phases.
|
|
474
|
+
if (outcome._tag !== "FanOut") {
|
|
475
|
+
job.attemptsMade += 1;
|
|
476
|
+
}
|
|
380
477
|
switch (outcome._tag) {
|
|
478
|
+
case "FanOut": {
|
|
479
|
+
recordAttempt(job, "fanned-out", now, undefined);
|
|
480
|
+
if (job.flow === undefined) {
|
|
481
|
+
job.flow = {
|
|
482
|
+
failFast: outcome.failFast,
|
|
483
|
+
pending: outcome.children.length,
|
|
484
|
+
completed: 0,
|
|
485
|
+
failed: 0,
|
|
486
|
+
cancelled: 0
|
|
487
|
+
};
|
|
488
|
+
const rows = new Map();
|
|
489
|
+
for (const child of outcome.children) {
|
|
490
|
+
rows.set(child.childKey, {
|
|
491
|
+
flowId: job.id,
|
|
492
|
+
childKey: child.childKey,
|
|
493
|
+
storeKey: child.storeKey,
|
|
494
|
+
spec: child.request,
|
|
495
|
+
status: "pending",
|
|
496
|
+
exit: undefined,
|
|
497
|
+
failedReason: undefined,
|
|
498
|
+
cascaded: false,
|
|
499
|
+
pendingSince: now
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
flowChildren.set(job.id, rows);
|
|
503
|
+
}
|
|
504
|
+
// A manifest that was already present is kept untouched (double
|
|
505
|
+
// fan-out converges on the persisted children); the state
|
|
506
|
+
// transition follows the persisted pending count either way.
|
|
507
|
+
if (job.cancelRequested) {
|
|
508
|
+
// A cancel raced the fan-out: cancellation wins. Mark the rows
|
|
509
|
+
// here — the job is still `active`, so markCancelled's own
|
|
510
|
+
// waiting-children marking does not apply — and the sweeper
|
|
511
|
+
// cascades (mostly no-op cancels for never-enqueued children).
|
|
512
|
+
settleMarkRows(job);
|
|
513
|
+
markCancelled(job, now);
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
if (job.flow.pending > 0) {
|
|
517
|
+
job.state = "waiting-children";
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
// Empty spec: settle straight to runnable `collect`.
|
|
521
|
+
job.state = "waiting";
|
|
522
|
+
job.runAt = now;
|
|
523
|
+
job.seq = ++seq;
|
|
524
|
+
signalWake(job.queue);
|
|
525
|
+
}
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
381
528
|
case "Complete": {
|
|
382
529
|
job.cancelRequested = false;
|
|
383
530
|
job.state = "completed";
|
|
384
531
|
job.exit = outcome.exit;
|
|
385
532
|
job.finishedAt = now;
|
|
386
533
|
recordAttempt(job, "completed", now, outcome.exit);
|
|
534
|
+
appendOutbox(job, "completed");
|
|
387
535
|
releaseDedupe(job, now);
|
|
388
536
|
applyKeep(job, now);
|
|
389
537
|
break;
|
|
@@ -394,6 +542,7 @@ const makeStoreUnsafe = (options) => {
|
|
|
394
542
|
job.exit = outcome.exit;
|
|
395
543
|
job.finishedAt = now;
|
|
396
544
|
recordAttempt(job, "failed", now, outcome.exit);
|
|
545
|
+
appendOutbox(job, "failed");
|
|
397
546
|
releaseDedupe(job, now);
|
|
398
547
|
applyKeep(job, now);
|
|
399
548
|
break;
|
|
@@ -403,6 +552,7 @@ const makeStoreUnsafe = (options) => {
|
|
|
403
552
|
job.state = "cancelled";
|
|
404
553
|
job.finishedAt = now;
|
|
405
554
|
recordAttempt(job, "cancelled", now, undefined);
|
|
555
|
+
appendOutbox(job, "cancelled");
|
|
406
556
|
releaseDedupe(job, now);
|
|
407
557
|
applyKeep(job, now);
|
|
408
558
|
break;
|
|
@@ -485,6 +635,7 @@ const makeStoreUnsafe = (options) => {
|
|
|
485
635
|
job.state = "failed";
|
|
486
636
|
job.finishedAt = now;
|
|
487
637
|
job.failedReason = "job stalled more than allowable limit";
|
|
638
|
+
appendOutbox(job, "failed");
|
|
488
639
|
releaseDedupe(job, now);
|
|
489
640
|
recovered.push({ id: job.id, failed: true });
|
|
490
641
|
}
|
|
@@ -516,7 +667,13 @@ const makeStoreUnsafe = (options) => {
|
|
|
516
667
|
list: (options) => Effect.sync(() => {
|
|
517
668
|
const limit = Math.max(1, options.limit ?? 50);
|
|
518
669
|
const states = options.states === undefined ? undefined : new Set(options.states);
|
|
519
|
-
|
|
670
|
+
const orderBy = options.orderBy ?? "enqueuedAt";
|
|
671
|
+
const descending = (options.order ?? "desc") === "desc";
|
|
672
|
+
// Jobs missing the field (finishedAt on non-terminal rows) sort as 0.
|
|
673
|
+
const orderValue = (job) => orderBy === "enqueuedAt" ? job.enqueuedAt : orderBy === "runAt" ? job.runAt : job.finishedAt ?? 0;
|
|
674
|
+
const compareIds = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
675
|
+
// Stable across retries: the id tiebreak follows the direction, and
|
|
676
|
+
// the cursor excludes everything at or before its (value, id).
|
|
520
677
|
let cursor;
|
|
521
678
|
if (options.cursor !== undefined) {
|
|
522
679
|
const split = options.cursor.indexOf(":");
|
|
@@ -530,22 +687,29 @@ const makeStoreUnsafe = (options) => {
|
|
|
530
687
|
(options.name === undefined || job.name === options.name) &&
|
|
531
688
|
(states === undefined || states.has(job.state)) &&
|
|
532
689
|
(options.metadata === undefined || metadataMatches(job.metadata, options.metadata)))
|
|
533
|
-
.toSorted((a, b) =>
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
job
|
|
542
|
-
(
|
|
690
|
+
.toSorted((a, b) => {
|
|
691
|
+
const byValue = descending
|
|
692
|
+
? orderValue(b) - orderValue(a)
|
|
693
|
+
: orderValue(a) - orderValue(b);
|
|
694
|
+
if (byValue !== 0)
|
|
695
|
+
return byValue;
|
|
696
|
+
return descending ? compareIds(b.id, a.id) : compareIds(a.id, b.id);
|
|
697
|
+
})
|
|
698
|
+
.filter((job) => {
|
|
699
|
+
if (cursor === undefined)
|
|
700
|
+
return true;
|
|
701
|
+
const value = orderValue(job);
|
|
702
|
+
if (descending) {
|
|
703
|
+
return value < cursor.at || (value === cursor.at && job.id < cursor.id);
|
|
704
|
+
}
|
|
705
|
+
return value > cursor.at || (value === cursor.at && job.id > cursor.id);
|
|
706
|
+
});
|
|
543
707
|
const items = matches.slice(0, limit).map(snapshot);
|
|
544
|
-
const
|
|
708
|
+
const lastJob = matches[Math.min(limit, matches.length) - 1];
|
|
545
709
|
const result = {
|
|
546
710
|
items,
|
|
547
|
-
cursor: matches.length > limit &&
|
|
548
|
-
? `${
|
|
711
|
+
cursor: matches.length > limit && lastJob !== undefined
|
|
712
|
+
? `${orderValue(lastJob)}:${lastJob.id}`
|
|
549
713
|
: undefined
|
|
550
714
|
};
|
|
551
715
|
return result;
|
|
@@ -653,11 +817,186 @@ const makeStoreUnsafe = (options) => {
|
|
|
653
817
|
insertJobRecord(id, request, now);
|
|
654
818
|
return true;
|
|
655
819
|
}),
|
|
820
|
+
recordChildResults: (reports) => Effect.gen(function* () {
|
|
821
|
+
const now = yield* Clock.currentTimeMillis;
|
|
822
|
+
const results = reports.map(() => ({ applied: false, parentSettled: false }));
|
|
823
|
+
// Phase 1 — apply every row update (lock order: rows before
|
|
824
|
+
// parents), tracking per flow which report would decide a settle.
|
|
825
|
+
const touched = new Map();
|
|
826
|
+
for (const [index, report] of reports.entries()) {
|
|
827
|
+
const row = flowChildren.get(report.flowId)?.get(report.childKey);
|
|
828
|
+
if (row === undefined || row.status !== "pending")
|
|
829
|
+
continue;
|
|
830
|
+
row.status = report.outcome;
|
|
831
|
+
row.exit = report.exit;
|
|
832
|
+
row.failedReason = report.failedReason;
|
|
833
|
+
// The outcome came from the child's store: no cancel to deliver.
|
|
834
|
+
row.cascaded = true;
|
|
835
|
+
results[index] = { applied: true, parentSettled: false };
|
|
836
|
+
const parent = jobs.get(report.flowId);
|
|
837
|
+
if (parent?.flow !== undefined) {
|
|
838
|
+
const flow = parent.flow;
|
|
839
|
+
parent.flow = {
|
|
840
|
+
...flow,
|
|
841
|
+
pending: Math.max(0, flow.pending - 1),
|
|
842
|
+
completed: flow.completed + (report.outcome === "completed" ? 1 : 0),
|
|
843
|
+
failed: flow.failed + (report.outcome === "failed" ? 1 : 0),
|
|
844
|
+
cancelled: flow.cancelled + (report.outcome === "cancelled" ? 1 : 0)
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
const touch = touched.get(report.flowId) ?? { lastApplied: index, firstAppliedFailed: undefined };
|
|
848
|
+
touch.lastApplied = index;
|
|
849
|
+
if (report.outcome === "failed" && touch.firstAppliedFailed === undefined) {
|
|
850
|
+
touch.firstAppliedFailed = index;
|
|
851
|
+
}
|
|
852
|
+
touched.set(report.flowId, touch);
|
|
853
|
+
}
|
|
854
|
+
// Phase 2 — at most one settle decision per touched flow. Fail-fast
|
|
855
|
+
// wins ties (a batch whose failure also empties `pending` settles
|
|
856
|
+
// terminally, never into collect).
|
|
857
|
+
for (const [flowId, touch] of touched) {
|
|
858
|
+
const parent = jobs.get(flowId);
|
|
859
|
+
if (parent === undefined || parent.flow === undefined)
|
|
860
|
+
continue;
|
|
861
|
+
if (parent.state !== "waiting-children")
|
|
862
|
+
continue;
|
|
863
|
+
const failedIndex = parent.flow.failFast ? touch.firstAppliedFailed : undefined;
|
|
864
|
+
const failedReport = failedIndex !== undefined ? reports[failedIndex] : undefined;
|
|
865
|
+
if (failedIndex !== undefined && failedReport !== undefined) {
|
|
866
|
+
// First applied failure settles the parent terminally
|
|
867
|
+
// (store-side, like stall exhaustion) and marks the remaining
|
|
868
|
+
// rows in the same op. A nested parent's own report goes to the
|
|
869
|
+
// outbox here — this settle IS its terminal transition.
|
|
870
|
+
settleMarkRows(parent);
|
|
871
|
+
parent.cancelRequested = false;
|
|
872
|
+
parent.state = "failed";
|
|
873
|
+
parent.finishedAt = now;
|
|
874
|
+
parent.failedReason = `effect-mq: flow child "${failedReport.childKey}" failed`;
|
|
875
|
+
recordAttempt(parent, "failed", now, undefined);
|
|
876
|
+
appendOutbox(parent, "failed");
|
|
877
|
+
releaseDedupe(parent, now);
|
|
878
|
+
applyKeep(parent, now);
|
|
879
|
+
results[failedIndex] = { applied: true, parentSettled: true };
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
if (parent.flow.pending === 0) {
|
|
883
|
+
// All children settled: the parent resumes runnable, phase
|
|
884
|
+
// collect.
|
|
885
|
+
parent.state = "waiting";
|
|
886
|
+
parent.runAt = now;
|
|
887
|
+
parent.seq = ++seq;
|
|
888
|
+
signalWake(parent.queue);
|
|
889
|
+
results[touch.lastApplied] = { applied: true, parentSettled: true };
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return results;
|
|
893
|
+
}),
|
|
894
|
+
peekOutbox: (options) => Effect.sync(() => {
|
|
895
|
+
// `after` compares by the id's embedded sequence so the cursor
|
|
896
|
+
// works whether or not the named entry still exists.
|
|
897
|
+
const afterSeq = options.after !== undefined && options.after.startsWith("ob-")
|
|
898
|
+
? Number(options.after.slice(3))
|
|
899
|
+
: undefined;
|
|
900
|
+
const eligible = afterSeq === undefined || Number.isNaN(afterSeq)
|
|
901
|
+
? outbox
|
|
902
|
+
: outbox.filter((entry) => Number(entry.id.slice(3)) > afterSeq);
|
|
903
|
+
return eligible.slice(0, Math.max(0, options.limit));
|
|
904
|
+
}),
|
|
905
|
+
deleteOutbox: (ids) => Effect.sync(() => {
|
|
906
|
+
const drop = new Set(ids);
|
|
907
|
+
let write = 0;
|
|
908
|
+
for (const entry of outbox) {
|
|
909
|
+
if (!drop.has(entry.id)) {
|
|
910
|
+
outbox[write] = entry;
|
|
911
|
+
write += 1;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
outbox.length = write;
|
|
915
|
+
}),
|
|
916
|
+
listChildResults: (flowId, options) => Effect.sync(() => {
|
|
917
|
+
const limit = Math.max(1, options?.limit ?? 1000);
|
|
918
|
+
const rows = Array.from(flowChildren.get(flowId)?.values() ?? [])
|
|
919
|
+
.toSorted((a, b) => (a.childKey < b.childKey ? -1 : a.childKey > b.childKey ? 1 : 0))
|
|
920
|
+
.filter((row) => options?.cursor === undefined || row.childKey > options.cursor);
|
|
921
|
+
const page = rows.slice(0, limit);
|
|
922
|
+
const items = page.map((row) => ({
|
|
923
|
+
flowId: row.flowId,
|
|
924
|
+
childKey: row.childKey,
|
|
925
|
+
name: row.spec.name,
|
|
926
|
+
storeKey: row.storeKey,
|
|
927
|
+
// SAFETY: FanOut validated every spec id at ack time.
|
|
928
|
+
childJobId: row.spec.id,
|
|
929
|
+
status: row.status,
|
|
930
|
+
exit: row.exit,
|
|
931
|
+
failedReason: row.failedReason,
|
|
932
|
+
cascaded: row.cascaded
|
|
933
|
+
}));
|
|
934
|
+
const last = page[page.length - 1];
|
|
935
|
+
return {
|
|
936
|
+
items,
|
|
937
|
+
cursor: rows.length > limit && last !== undefined ? last.childKey : undefined
|
|
938
|
+
};
|
|
939
|
+
}),
|
|
940
|
+
flowSweepWork: (options) => Effect.gen(function* () {
|
|
941
|
+
const now = yield* Clock.currentTimeMillis;
|
|
942
|
+
const limit = Math.max(1, options.limit ?? 1000);
|
|
943
|
+
const threshold = now - options.pendingAgeMs;
|
|
944
|
+
const reconcile = [];
|
|
945
|
+
const cascade = [];
|
|
946
|
+
let reconcileCount = 0;
|
|
947
|
+
let cascadeCount = 0;
|
|
948
|
+
for (const [flowId, rows] of flowChildren) {
|
|
949
|
+
const parent = jobs.get(flowId);
|
|
950
|
+
const reconciling = parent !== undefined && parent.state === "waiting-children";
|
|
951
|
+
let reconcileGroup;
|
|
952
|
+
let cascadeGroup;
|
|
953
|
+
for (const row of rows.values()) {
|
|
954
|
+
if (reconciling && row.status === "pending" &&
|
|
955
|
+
row.pendingSince <= threshold && reconcileCount < limit) {
|
|
956
|
+
reconcileGroup ??= [];
|
|
957
|
+
reconcileGroup.push({ childKey: row.childKey, storeKey: row.storeKey, request: row.spec });
|
|
958
|
+
reconcileCount += 1;
|
|
959
|
+
// Re-arm eligibility: returned work waits another pendingAgeMs
|
|
960
|
+
// before it can come back, so a full page rotates (see the
|
|
961
|
+
// interface's rotation note).
|
|
962
|
+
row.pendingSince = now;
|
|
963
|
+
}
|
|
964
|
+
if (row.status === "cancelled" && !row.cascaded && cascadeCount < limit) {
|
|
965
|
+
cascadeGroup ??= [];
|
|
966
|
+
cascadeGroup.push({
|
|
967
|
+
childKey: row.childKey,
|
|
968
|
+
storeKey: row.storeKey,
|
|
969
|
+
// SAFETY: FanOut validated every spec id at ack time.
|
|
970
|
+
childJobId: row.spec.id
|
|
971
|
+
});
|
|
972
|
+
cascadeCount += 1;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
if (reconcileGroup !== undefined)
|
|
976
|
+
reconcile.push({ flowId, children: reconcileGroup });
|
|
977
|
+
if (cascadeGroup !== undefined)
|
|
978
|
+
cascade.push({ flowId, children: cascadeGroup });
|
|
979
|
+
}
|
|
980
|
+
const work = { reconcile, cascade };
|
|
981
|
+
return work;
|
|
982
|
+
}),
|
|
983
|
+
markChildrenCascaded: (flowId, childKeys) => Effect.sync(() => {
|
|
984
|
+
const rows = flowChildren.get(flowId);
|
|
985
|
+
if (rows === undefined)
|
|
986
|
+
return;
|
|
987
|
+
for (const key of childKeys) {
|
|
988
|
+
const row = rows.get(key);
|
|
989
|
+
if (row !== undefined) {
|
|
990
|
+
row.cascaded = true;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}),
|
|
656
994
|
counts: (queue) => Effect.sync(() => {
|
|
657
995
|
const counts = {
|
|
658
996
|
waiting: 0,
|
|
659
997
|
delayed: 0,
|
|
660
998
|
active: 0,
|
|
999
|
+
"waiting-children": 0,
|
|
661
1000
|
completed: 0,
|
|
662
1001
|
failed: 0,
|
|
663
1002
|
cancelled: 0
|
|
@@ -671,9 +1010,10 @@ const makeStoreUnsafe = (options) => {
|
|
|
671
1010
|
}),
|
|
672
1011
|
remove: (id) => Effect.sync(() => {
|
|
673
1012
|
const job = jobs.get(id);
|
|
674
|
-
if (job === undefined || job.state === "active")
|
|
1013
|
+
if (job === undefined || job.state === "active" || job.state === "waiting-children") {
|
|
675
1014
|
return false;
|
|
676
|
-
|
|
1015
|
+
}
|
|
1016
|
+
deleteJob(id);
|
|
677
1017
|
return true;
|
|
678
1018
|
})
|
|
679
1019
|
});
|