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.
Files changed (59) hide show
  1. package/README.md +85 -17
  2. package/dist/Flow.d.ts +381 -0
  3. package/dist/Flow.d.ts.map +1 -0
  4. package/dist/Flow.js +340 -0
  5. package/dist/Flow.js.map +1 -0
  6. package/dist/Job.d.ts +31 -6
  7. package/dist/Job.d.ts.map +1 -1
  8. package/dist/Job.js +16 -2
  9. package/dist/Job.js.map +1 -1
  10. package/dist/JobStore.d.ts +312 -10
  11. package/dist/JobStore.d.ts.map +1 -1
  12. package/dist/JobStore.js.map +1 -1
  13. package/dist/MemoryJobStore.d.ts.map +1 -1
  14. package/dist/MemoryJobStore.js +334 -7
  15. package/dist/MemoryJobStore.js.map +1 -1
  16. package/dist/Metrics.d.ts +31 -0
  17. package/dist/Metrics.d.ts.map +1 -1
  18. package/dist/Metrics.js +39 -0
  19. package/dist/Metrics.js.map +1 -1
  20. package/dist/Worker.d.ts +120 -11
  21. package/dist/Worker.d.ts.map +1 -1
  22. package/dist/Worker.js +452 -26
  23. package/dist/Worker.js.map +1 -1
  24. package/dist/drizzle-postgres/DrizzleJobStore.d.ts +19 -1
  25. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  26. package/dist/drizzle-postgres/DrizzleJobStore.js +653 -77
  27. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  28. package/dist/drizzle-postgres/schema.d.ts +293 -3
  29. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  30. package/dist/drizzle-postgres/schema.js +66 -1
  31. package/dist/drizzle-postgres/schema.js.map +1 -1
  32. package/dist/index.d.ts +7 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +7 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  37. package/dist/redis/RedisJobStore.js +219 -18
  38. package/dist/redis/RedisJobStore.js.map +1 -1
  39. package/dist/redis/scripts.d.ts +117 -10
  40. package/dist/redis/scripts.d.ts.map +1 -1
  41. package/dist/redis/scripts.js +492 -25
  42. package/dist/redis/scripts.js.map +1 -1
  43. package/dist/testing/conformance.d.ts +6 -0
  44. package/dist/testing/conformance.d.ts.map +1 -1
  45. package/dist/testing/conformance.js +728 -1
  46. package/dist/testing/conformance.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/Flow.ts +778 -0
  49. package/src/Job.ts +35 -11
  50. package/src/JobStore.ts +339 -9
  51. package/src/MemoryJobStore.ts +370 -7
  52. package/src/Metrics.ts +43 -0
  53. package/src/Worker.ts +726 -37
  54. package/src/drizzle-postgres/DrizzleJobStore.ts +817 -78
  55. package/src/drizzle-postgres/schema.ts +92 -0
  56. package/src/index.ts +8 -0
  57. package/src/redis/RedisJobStore.ts +289 -8
  58. package/src/redis/scripts.ts +524 -24
  59. package/src/testing/conformance.ts +945 -1
@@ -20,9 +20,49 @@ import * as JobStore from "../JobStore.js";
20
20
  import { asc, eq, getTableColumns, sql } from "drizzle-orm";
21
21
  import * as PgDrizzle from "drizzle-orm/effect-postgres";
22
22
  import { getTableConfig } from "drizzle-orm/pg-core";
23
- import { Clock, Deferred, Duration, Effect, Layer, Option, Stream } from "effect";
23
+ import { Cause, Clock, Deferred, Duration, Effect, Layer, Option, Predicate, Stream } from "effect";
24
24
  const { JobId } = JobStore;
25
25
  const storeError = (message) => (cause) => new JobStore.JobStoreError({ message, cause });
26
+ // The exact spellings a bigserial ever produces: no leading zeros, no signs,
27
+ // no whitespace — what outbox ids look like and nothing Postgres would
28
+ // silently normalize into one.
29
+ const CANONICAL_BIGSERIAL = /^[1-9]\d*$/;
30
+ /**
31
+ * Whether an error surfaced by drizzle's Effect driver is a Postgres
32
+ * deadlock (40P01), i.e. safe to retry.
33
+ *
34
+ * The deadlock travels wrapped: drizzle's session fails with an
35
+ * `EffectDrizzleQueryError` whose `cause` field is `Cause.fail(SqlError)`
36
+ * (drizzle-orm `pg-core/effect/session.ts`), and `@effect/sql-pg` classifies
37
+ * pg code 40P01 into the `SqlError`'s `reason: DeadlockError` (PgClient.ts
38
+ * `classifyError`). Neither layer's `toString` renders the pg code, so this
39
+ * unwraps structurally instead of string-matching the rendered error.
40
+ *
41
+ * @internal
42
+ */
43
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- a retry-predicate classifier over whatever the driver threw IS the boundary parser
44
+ export const isDeadlockError = (error) => {
45
+ // Unwrap drizzle's envelope: its `cause` is an Effect Cause holding the
46
+ // original failure. A bare SqlError (non-drizzle path) passes through.
47
+ const unwrapped = Predicate.hasProperty(error, "cause") && Cause.isCause(error.cause)
48
+ ? Option.getOrUndefined(Cause.findErrorOption(error.cause))
49
+ : error;
50
+ if (!Predicate.hasProperty(unwrapped, "_tag") || unwrapped._tag !== "SqlError" ||
51
+ !Predicate.hasProperty(unwrapped, "reason")) {
52
+ return false;
53
+ }
54
+ const reason = unwrapped.reason;
55
+ if (Predicate.hasProperty(reason, "_tag") && reason._tag === "DeadlockError") {
56
+ return true;
57
+ }
58
+ // Fallback for classifiers that missed the code: the reason keeps the raw
59
+ // pg error, whose message for 40P01 is "deadlock detected".
60
+ if (Predicate.hasProperty(reason, "cause")) {
61
+ const raw = String(reason.cause);
62
+ return raw.includes("40P01") || raw.includes("deadlock detected");
63
+ }
64
+ return false;
65
+ };
26
66
  const rowsOf = (result) => "rows" in result ? result.rows : result;
27
67
  const toSchedule = (row) => ({
28
68
  key: JobStore.ScheduleKey(row.key),
@@ -58,6 +98,17 @@ const toRecord = (row) => ({
58
98
  cancelRequested: row.cancelRequested,
59
99
  dedupeKey: row.dedupeKey ?? undefined,
60
100
  trace: row.trace ?? undefined,
101
+ parent: row.parent ?? undefined,
102
+ // The flow columns are NULL together; `flowPending` is the presence marker.
103
+ flow: row.flowPending === null || row.flowPending === undefined
104
+ ? undefined
105
+ : {
106
+ failFast: row.flowFailFast === true,
107
+ pending: Number(row.flowPending),
108
+ completed: Number(row.flowCompleted ?? 0),
109
+ failed: Number(row.flowFailed ?? 0),
110
+ cancelled: Number(row.flowCancelled ?? 0)
111
+ },
61
112
  runAt: row.runAt.getTime(),
62
113
  enqueuedAt: row.enqueuedAt.getTime(),
63
114
  processedAt: row.processedAt?.getTime(),
@@ -65,6 +116,17 @@ const toRecord = (row) => ({
65
116
  exit: row.exit ?? undefined,
66
117
  failedReason: row.failedReason ?? undefined
67
118
  });
119
+ const toFlowChildRecord = (row) => ({
120
+ flowId: JobId(row.flowId),
121
+ childKey: row.childKey,
122
+ name: row.name,
123
+ storeKey: row.storeKey,
124
+ childJobId: JobId(row.childJobId),
125
+ status: row.status,
126
+ exit: row.exit ?? undefined,
127
+ failedReason: row.failedReason ?? undefined,
128
+ cascaded: row.cascaded
129
+ });
68
130
  /**
69
131
  * Build the store implementation. Requires `PgClient` and a `Scope` (for the
70
132
  * LISTEN subscription).
@@ -79,6 +141,8 @@ export const make = (options) => Effect.gen(function* () {
79
141
  const schedules = options.schedules;
80
142
  const queues = options.queues;
81
143
  const dedupe = options.dedupe;
144
+ const flowChildren = options.flowChildren;
145
+ const flowOutbox = options.flowOutbox;
82
146
  const jobsName = getTableConfig(jobs).name;
83
147
  const attemptsName = getTableConfig(attempts).name;
84
148
  const wakeChannel = `effect_mq_wake_${jobsName}`;
@@ -103,6 +167,12 @@ export const make = (options) => Effect.gen(function* () {
103
167
  "cancelRequested",
104
168
  "dedupeKey",
105
169
  "trace",
170
+ "parent",
171
+ "flowFailFast",
172
+ "flowPending",
173
+ "flowCompleted",
174
+ "flowFailed",
175
+ "flowCancelled",
106
176
  "runAt",
107
177
  "enqueuedAt",
108
178
  "processedAt",
@@ -136,7 +206,9 @@ export const make = (options) => Effect.gen(function* () {
136
206
  db.select({ jobId: attempts.jobId }).from(attempts).limit(0),
137
207
  db.select({ key: schedules.key }).from(schedules).limit(0),
138
208
  db.select({ queue: queues.queue }).from(queues).limit(0),
139
- db.select({ key: dedupe.key }).from(dedupe).limit(0)
209
+ db.select({ key: dedupe.key }).from(dedupe).limit(0),
210
+ db.select({ flowId: flowChildren.flowId }).from(flowChildren).limit(0),
211
+ db.select({ id: flowOutbox.id }).from(flowOutbox).limit(0)
140
212
  ]).pipe(Effect.mapError(storeError(`effect-mq: tables "${jobsName}"/"${attemptsName}" are missing or mismatched — ` +
141
213
  `re-export the effect-mq/drizzle schema factories from your drizzle schema and run your migrations (drizzle-kit generate)`)));
142
214
  }
@@ -176,6 +248,26 @@ export const make = (options) => Effect.gen(function* () {
176
248
  // Resubscribe forever: if the LISTEN stream ends or fails, wake-ups
177
249
  // degrade to the worker's pollInterval until the next attempt succeeds.
178
250
  yield* client.listen(wakeChannel).pipe(Stream.runForEach((payload) => Effect.sync(() => signalWake(payload === "*" ? undefined : JobStore.QueueName(payload)))), Effect.catchCause((cause) => Effect.logWarning("effect-mq: LISTEN subscription failed; wake-ups degraded to polling until resubscribe", cause)), Effect.andThen(Effect.sleep("1 second")), Effect.forever, Effect.forkScoped);
251
+ // Automatic retention (the store-level sweep and per-job `keep`) never
252
+ // prunes a flow parent that still owes cascade cancels: its dependency
253
+ // rows marked `cancelled` and not `cascaded` are the only record that
254
+ // real cancels are still due in the child stores. The explicit `remove`
255
+ // verb is not exempted.
256
+ const owesCascades = sql `EXISTS (
257
+ SELECT 1 FROM ${flowChildren}
258
+ WHERE ${flowChildren.flowId} = ${jobs.id}
259
+ AND ${flowChildren.status} = 'cancelled' AND ${flowChildren.cascaded} = FALSE
260
+ )`;
261
+ // A pruned flow parent's dependency rows go with it in the same statement.
262
+ const purgeJobsWhere = (predicate) => sql `
263
+ WITH deleted AS (
264
+ DELETE FROM ${jobs}
265
+ WHERE ${predicate}
266
+ RETURNING ${jobs.id} AS id
267
+ )
268
+ DELETE FROM ${flowChildren}
269
+ WHERE ${flowChildren.flowId} IN (SELECT id FROM deleted)
270
+ `;
179
271
  if (options.historyTtl !== undefined) {
180
272
  const ttlByState = JobStore.normalizeHistoryTtl(options.historyTtl);
181
273
  const sweepMs = Duration.toMillis(options.historySweepInterval ?? "1 minute");
@@ -186,9 +278,8 @@ export const make = (options) => Effect.gen(function* () {
186
278
  // job name is pruned on the timer, not only when its group is acked.
187
279
  for (const state of ["completed", "failed", "cancelled"]) {
188
280
  const ttl = ttlByState[state];
189
- yield* db.execute(sql `
190
- DELETE FROM ${jobs}
191
- WHERE ${jobs.state} = ${state} AND (
281
+ yield* db.execute(purgeJobsWhere(sql `
282
+ ${jobs.state} = ${state} AND NOT ${owesCascades} AND (
192
283
  ${ttl !== undefined ? sql `${jobs.finishedAt} <= ${new Date(now.getTime() - ttl)}` : sql `FALSE`}
193
284
  OR (
194
285
  COALESCE(
@@ -203,7 +294,7 @@ export const make = (options) => Effect.gen(function* () {
203
294
  ELSE ${jobs.keep}->>'ageMs' END
204
295
  )::double precision) / 1000.0))
205
296
  )
206
- `);
297
+ `));
207
298
  }
208
299
  // Dead dedup rows: expired windows, or pointers at vanished jobs.
209
300
  yield* db.execute(sql `
@@ -211,7 +302,7 @@ export const make = (options) => Effect.gen(function* () {
211
302
  WHERE (${dedupe.windowExpiresAt} IS NOT NULL AND ${dedupe.windowExpiresAt} <= ${now})
212
303
  OR (${dedupe.windowExpiresAt} IS NULL AND NOT EXISTS (
213
304
  SELECT 1 FROM ${jobs} WHERE ${jobs.id} = ${dedupe.jobId}
214
- AND ${jobs.state} IN ('waiting', 'delayed', 'active')
305
+ AND ${jobs.state} IN ('waiting', 'delayed', 'active', 'waiting-children')
215
306
  ))
216
307
  `);
217
308
  }).pipe(Effect.catchCause((cause) => Effect.logWarning("effect-mq: history sweep failed", cause)), Effect.forever, Effect.forkScoped);
@@ -259,23 +350,23 @@ export const make = (options) => Effect.gen(function* () {
259
350
  if (keep === undefined)
260
351
  return;
261
352
  if (keep.ageMs !== undefined) {
262
- yield* tx.execute(sql `
263
- DELETE FROM ${jobs}
264
- WHERE ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
353
+ yield* tx.execute(purgeJobsWhere(sql `
354
+ ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
265
355
  AND ${jobs.finishedAt} <= ${new Date(now.getTime() - keep.ageMs)}
266
- `);
356
+ AND NOT ${owesCascades}
357
+ `));
267
358
  }
268
359
  if (keep.count !== undefined) {
269
- yield* tx.execute(sql `
270
- DELETE FROM ${jobs}
271
- WHERE ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
360
+ yield* tx.execute(purgeJobsWhere(sql `
361
+ ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
272
362
  AND ${jobs.id} NOT IN (
273
363
  SELECT ${jobs.id} FROM ${jobs}
274
364
  WHERE ${jobs.name} = ${row.name} AND ${jobs.state} = ${row.state}
275
365
  ORDER BY ${jobs.finishedAt} DESC, ${jobs.seq} DESC
276
366
  LIMIT ${keep.count}
277
367
  )
278
- `);
368
+ AND NOT ${owesCascades}
369
+ `));
279
370
  }
280
371
  });
281
372
  // Distinguish JobNotFound vs LockLost after a guarded UPDATE hit 0 rows.
@@ -304,7 +395,7 @@ export const make = (options) => Effect.gen(function* () {
304
395
  : sql `'j-' || ${seqExpr}::text`;
305
396
  const rows = rowsOf(yield* exec.execute(sql `
306
397
  INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
307
- attempts_max, backoff, keep, timeout_ms, dedupe_key, trace, run_at, enqueued_at${extraColumnNames})
398
+ attempts_max, backoff, keep, timeout_ms, dedupe_key, trace, parent, run_at, enqueued_at${extraColumnNames})
308
399
  VALUES (${idExpr}, ${request.name}, ${request.queue}, ${state}, ${request.priority},
309
400
  ${JSON.stringify(request.payload ?? null)}::jsonb, ${JSON.stringify(request.metadata)}::jsonb,
310
401
  ${request.attemptsMax},
@@ -312,6 +403,7 @@ export const make = (options) => Effect.gen(function* () {
312
403
  ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
313
404
  ${request.timeoutMs ?? null}, ${request.dedupe?.key ?? null},
314
405
  ${request.trace === undefined ? null : JSON.stringify(request.trace)}::jsonb,
406
+ ${request.parent === undefined ? null : JSON.stringify(request.parent)}::jsonb,
315
407
  ${runAt}, ${now}${extraColumnValues(request)})
316
408
  ON CONFLICT (id) DO NOTHING
317
409
  RETURNING ${jobs.id} AS id
@@ -438,7 +530,7 @@ export const make = (options) => Effect.gen(function* () {
438
530
  // killed and safe to retry.
439
531
  Effect.retry({
440
532
  times: 3,
441
- while: (error) => String(error).includes("40P01") || String(error).includes("deadlock detected")
533
+ while: isDeadlockError
442
534
  }), Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("enqueue failed")(error)));
443
535
  // A job leaving the pending states frees its pending-mode dedup row; live
444
536
  // throttle windows deliberately outlast the job.
@@ -450,26 +542,65 @@ export const make = (options) => Effect.gen(function* () {
450
542
  AND ${dedupe.jobId} = ${jobId}
451
543
  AND (${dedupe.windowExpiresAt} IS NULL OR ${dedupe.windowExpiresAt} <= ${now})
452
544
  `).pipe(Effect.asVoid);
545
+ // The outbox invariant: every operation that moves a job carrying a
546
+ // `parent` envelope INTO a terminal state appends its report here in the
547
+ // same transaction (see `JobStore.OutboxEntry`). `exit`/`failedReason`
548
+ // mirror the job row AFTER the transition; JSON.stringify drops
549
+ // undefined fields so absent values read back as undefined.
550
+ const appendOutbox = (exec, parent, outcome, exit, failedReason) => parent === null || parent === undefined
551
+ ? Effect.void
552
+ : exec.execute(sql `
553
+ INSERT INTO ${flowOutbox} (flow_name, parent_store_key, report)
554
+ VALUES (${parent.flowName}, ${parent.parentStoreKey}, ${JSON.stringify({
555
+ flowId: parent.flowId,
556
+ childKey: parent.childKey,
557
+ outcome,
558
+ exit,
559
+ failedReason: failedReason ?? undefined
560
+ })}::jsonb)
561
+ `).pipe(Effect.asVoid);
562
+ // Flip a flow's still-pending dependency rows to `cancelled` and NOT
563
+ // `cascaded` (the sweeper still owes the child stores real cancels).
564
+ // Embedded as a CTE body so the caller can count the marked rows into
565
+ // the parent's `cancelled` counter in the same statement.
566
+ const cancelPendingChildren = (flowId) => sql `
567
+ UPDATE ${flowChildren} SET status = 'cancelled', cascaded = FALSE
568
+ WHERE ${flowChildren.flowId} = ${flowId} AND ${flowChildren.status} = 'pending'
569
+ RETURNING 1
570
+ `;
453
571
  // Shared by cancel and cancelByDedupe.
454
572
  const cancelJob = (id) => db.transaction((tx) => Effect.gen(function* () {
455
573
  const now = yield* nowDate;
456
- // One guarded statement: waiting/delayed become terminal, active
457
- // gets the cancel-request flag; anything else is reported by state.
574
+ // Contract lock order (dependency rows first, parent second): mark
575
+ // a waiting-children parent's remaining pending rows cancelled.
576
+ // Pending rows exist only while the parent is `waiting-children`,
577
+ // so this is a no-op for every other state; a non-cancellable
578
+ // parent rolls the transaction back anyway.
579
+ const firstPass = rowsOf(yield* tx.execute(sql `
580
+ WITH marked AS (${cancelPendingChildren(id)})
581
+ SELECT count(*)::int AS marked FROM marked
582
+ `));
583
+ const preMarked = firstPass[0]?.marked ?? 0;
584
+ // One guarded statement: waiting/delayed/waiting-children become
585
+ // terminal, active gets the cancel-request flag; anything else is
586
+ // reported by state.
458
587
  const rows = rowsOf(yield* tx.execute(sql `
459
- UPDATE ${jobs} SET
460
- state = CASE WHEN ${jobs.state} IN ('waiting', 'delayed') THEN 'cancelled' ELSE ${jobs.state} END,
461
- finished_at = CASE WHEN ${jobs.state} IN ('waiting', 'delayed') THEN ${now}::timestamptz ELSE ${jobs.finishedAt} END,
462
- cancel_requested = CASE WHEN ${jobs.state} = 'active' THEN TRUE ELSE ${jobs.cancelRequested} END
463
- WHERE ${jobs.id} = ${id} AND ${jobs.state} IN ('waiting', 'delayed', 'active')
464
- RETURNING ${jobs.id} AS id, ${jobs.state} AS state,
465
- ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
466
- ${jobs.dedupeKey} AS "dedupeKey"
467
- `));
588
+ UPDATE ${jobs} SET
589
+ state = CASE WHEN ${jobs.state} IN ('waiting', 'delayed', 'waiting-children') THEN 'cancelled' ELSE ${jobs.state} END,
590
+ finished_at = CASE WHEN ${jobs.state} IN ('waiting', 'delayed', 'waiting-children') THEN ${now}::timestamptz ELSE ${jobs.finishedAt} END,
591
+ flow_pending = CASE WHEN ${jobs.state} = 'waiting-children' THEN 0 ELSE ${jobs.flowPending} END,
592
+ cancel_requested = CASE WHEN ${jobs.state} = 'active' THEN TRUE ELSE ${jobs.cancelRequested} END
593
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} IN ('waiting', 'delayed', 'active', 'waiting-children')
594
+ RETURNING ${jobs.id} AS id, ${jobs.state} AS state,
595
+ ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
596
+ ${jobs.dedupeKey} AS "dedupeKey", (${jobs.flowPending} IS NOT NULL) AS "hasFlow",
597
+ ${jobs.parent} AS "parent", ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
598
+ `));
468
599
  const row = rows[0];
469
600
  if (row === undefined) {
470
601
  const existing = rowsOf(yield* tx.execute(sql `
471
- SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${id}
472
- `));
602
+ SELECT ${jobs.state} AS state FROM ${jobs} WHERE ${jobs.id} = ${id}
603
+ `));
473
604
  const found = existing[0];
474
605
  if (found === undefined) {
475
606
  return yield* new JobStore.JobNotFoundError({ jobId: id });
@@ -477,11 +608,36 @@ export const make = (options) => Effect.gen(function* () {
477
608
  return yield* new JobStore.JobNotCancellableError({ jobId: id, state: found.state });
478
609
  }
479
610
  if (row.state === "cancelled") {
611
+ if (row.hasFlow) {
612
+ // Re-run the marking now that the parent lock is held: the
613
+ // first UPDATE above races a concurrent FanOut — its
614
+ // uncommitted dependency-row INSERTs are invisible, while
615
+ // this UPDATE's own EPQ re-check can still see the parent as
616
+ // 'waiting-children' after the FanOut commits. Without this
617
+ // pass those rows would stay 'pending' forever, invisible to
618
+ // both sweep classes. Every row either pass marked lands in
619
+ // the `cancelled` counter.
620
+ yield* tx.execute(sql `
621
+ WITH marked AS (${cancelPendingChildren(id)})
622
+ UPDATE ${jobs} SET flow_cancelled = ${jobs.flowCancelled} + ${preMarked}::int
623
+ + (SELECT count(*)::int FROM marked)
624
+ WHERE ${jobs.id} = ${id}
625
+ `);
626
+ }
480
627
  yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined);
628
+ // A cancelled child reports upward through the outbox.
629
+ yield* appendOutbox(tx, row.parent, "cancelled", row.exit ?? undefined, row.failedReason);
481
630
  yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
482
631
  yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now);
483
632
  }
484
- })).pipe(Effect.mapError((error) => error instanceof JobStore.JobNotFoundError ||
633
+ })).pipe(
634
+ // Rare lock-order inversion (this row-then-parent flip vs a
635
+ // fail-fast settle's parent-then-rows marking) surfaces as a
636
+ // Postgres deadlock (40P01); one side is killed and safe to retry.
637
+ Effect.retry({
638
+ times: 3,
639
+ while: isDeadlockError
640
+ }), Effect.mapError((error) => error instanceof JobStore.JobNotFoundError ||
485
641
  error instanceof JobStore.JobNotCancellableError ||
486
642
  error instanceof JobStore.JobStoreError
487
643
  ? error
@@ -566,10 +722,11 @@ export const make = (options) => Effect.gen(function* () {
566
722
  ${request.keep === undefined ? null : JSON.stringify(request.keep)}::jsonb,
567
723
  ${request.timeoutMs ?? null}, ${request.dedupe?.key ?? null},
568
724
  ${request.trace === undefined ? null : JSON.stringify(request.trace)}::jsonb,
725
+ ${request.parent === undefined ? null : JSON.stringify(request.parent)}::jsonb,
569
726
  ${new Date(now.getTime() + Math.max(0, request.delayMs))}, ${now}${extraColumnValues(request)})`);
570
727
  const rows = rowsOf(yield* db.execute(sql `
571
728
  INSERT INTO ${jobs} (id, name, queue, state, priority, payload, metadata,
572
- attempts_max, backoff, keep, timeout_ms, dedupe_key, trace, run_at, enqueued_at${extraColumnNames})
729
+ attempts_max, backoff, keep, timeout_ms, dedupe_key, trace, parent, run_at, enqueued_at${extraColumnNames})
573
730
  VALUES ${sql.join(values, sql `, `)}
574
731
  ON CONFLICT (id) DO NOTHING
575
732
  RETURNING ${jobs.id} AS id
@@ -611,6 +768,97 @@ export const make = (options) => Effect.gen(function* () {
611
768
  }
612
769
  return resolved;
613
770
  });
771
+ // The FanOut ack: land the manifest (flow columns + dependency rows) and
772
+ // park the parent, all lock-token-guarded in one transaction. A fan-out
773
+ // is a phase transition, not a completed run — `attempts_made` is not
774
+ // incremented; the ledger records `fanned-out` with no exit.
775
+ const ackFanOut = (id, token, outcome) => Effect.gen(function* () {
776
+ // Validate BEFORE any mutation, so a bad spec cannot leave the job
777
+ // half-acked (lock cleared, ledger written, still active).
778
+ if (outcome.children.some((child) => child.request.id === undefined)) {
779
+ return yield* new JobStore.JobStoreError({
780
+ message: "FanOut child specs require an explicit request.id"
781
+ });
782
+ }
783
+ const wakeQueue = yield* db.transaction((tx) => Effect.gen(function* () {
784
+ const now = yield* nowDate;
785
+ const rows = rowsOf(yield* tx.execute(sql `
786
+ UPDATE ${jobs} SET lock_token = NULL, lock_expires_at = NULL
787
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
788
+ RETURNING ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name",
789
+ ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey", ${jobs.queue} AS "queue",
790
+ ${jobs.cancelRequested} AS "cancelRequested", ${jobs.flowPending} AS "flowPending",
791
+ ${jobs.parent} AS "parent", ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
792
+ `));
793
+ const row = rows[0];
794
+ if (row === undefined) {
795
+ return yield* explainMiss(id);
796
+ }
797
+ yield* insertAttempt(tx, id, "fanned-out", row.processedAt, now, undefined);
798
+ let pending;
799
+ if (row.flowPending === null || row.flowPending === undefined) {
800
+ pending = outcome.children.length;
801
+ yield* tx.execute(sql `
802
+ UPDATE ${jobs} SET flow_fail_fast = ${outcome.failFast}, flow_pending = ${pending},
803
+ flow_completed = 0, flow_failed = 0, flow_cancelled = 0
804
+ WHERE ${jobs.id} = ${id}
805
+ `);
806
+ // Chunked multi-row VALUES, like enqueueMany's insertBatch.
807
+ for (let start = 0; start < outcome.children.length; start += 500) {
808
+ const chunk = outcome.children.slice(start, start + 500);
809
+ const values = chunk.map((child) => sql `(${id}, ${child.childKey}, ${child.request.name}, ${child.storeKey},
810
+ ${JSON.stringify(child.request)}::jsonb, 'pending', NULL, NULL, FALSE, ${now})`);
811
+ yield* tx.execute(sql `
812
+ INSERT INTO ${flowChildren} (flow_id, child_key, name, store_key, spec,
813
+ status, exit, failed_reason, cascaded, pending_since)
814
+ VALUES ${sql.join(values, sql `, `)}
815
+ `);
816
+ }
817
+ }
818
+ else {
819
+ // A manifest that was already present is kept untouched (double
820
+ // fan-out converges on the persisted children); the state
821
+ // transition follows the persisted pending count either way.
822
+ pending = Number(row.flowPending);
823
+ }
824
+ if (row.cancelRequested) {
825
+ // A cancel raced the fan-out: cancellation wins. The rows exist
826
+ // and get marked (into the `cancelled` counter), so the sweeper
827
+ // cascades (mostly no-op cancels for never-enqueued children).
828
+ yield* tx.execute(sql `
829
+ WITH marked AS (${cancelPendingChildren(id)})
830
+ UPDATE ${jobs} SET state = 'cancelled', finished_at = ${now},
831
+ cancel_requested = FALSE, flow_pending = 0,
832
+ flow_cancelled = ${jobs.flowCancelled} + (SELECT count(*)::int FROM marked)
833
+ WHERE ${jobs.id} = ${id}
834
+ `);
835
+ yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined);
836
+ // A cancelled NESTED parent reports upward through the outbox.
837
+ yield* appendOutbox(tx, row.parent, "cancelled", row.exit ?? undefined, row.failedReason);
838
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
839
+ yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now);
840
+ return undefined;
841
+ }
842
+ if (pending > 0) {
843
+ yield* tx.execute(sql `
844
+ UPDATE ${jobs} SET state = 'waiting-children' WHERE ${jobs.id} = ${id}
845
+ `);
846
+ return undefined;
847
+ }
848
+ // Empty (or fully recorded) manifest: straight to runnable collect.
849
+ yield* tx.execute(sql `
850
+ UPDATE ${jobs} SET state = 'waiting', run_at = ${now}, seq = ${seqExpr}
851
+ WHERE ${jobs.id} = ${id}
852
+ `);
853
+ return JobStore.QueueName(row.queue);
854
+ })).pipe(Effect.mapError((error) => error instanceof JobStore.JobNotFoundError || error instanceof JobStore.LockLostError ||
855
+ error instanceof JobStore.JobStoreError
856
+ ? error
857
+ : storeError("ack failed")(error)));
858
+ if (wakeQueue !== undefined) {
859
+ yield* wakeUp(wakeQueue);
860
+ }
861
+ });
614
862
  const store = {
615
863
  enqueue: enqueueOne,
616
864
  enqueueMany: (requests) => Effect.gen(function* () {
@@ -681,7 +929,10 @@ export const make = (options) => Effect.gen(function* () {
681
929
  ${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
682
930
  ${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
683
931
  ${jobs.cancelRequested} AS "cancelRequested", ${jobs.dedupeKey} AS "dedupeKey",
684
- ${jobs.trace} AS "trace",
932
+ ${jobs.trace} AS "trace", ${jobs.parent} AS "parent",
933
+ ${jobs.flowFailFast} AS "flowFailFast", ${jobs.flowPending} AS "flowPending",
934
+ ${jobs.flowCompleted} AS "flowCompleted", ${jobs.flowFailed} AS "flowFailed",
935
+ ${jobs.flowCancelled} AS "flowCancelled",
685
936
  ${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
686
937
  ${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
687
938
  ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
@@ -704,55 +955,64 @@ export const make = (options) => Effect.gen(function* () {
704
955
  return empty;
705
956
  }));
706
957
  }).pipe(Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("claim failed")(error))),
707
- ack: (id, token, outcome) => db.transaction((tx) => Effect.gen(function* () {
708
- const now = yield* nowDate;
709
- const update = outcome._tag === "Complete"
710
- ? sql `state = 'completed', cancel_requested = FALSE, exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
711
- : outcome._tag === "Fail"
712
- ? sql `state = 'failed', cancel_requested = FALSE, exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
713
- : outcome._tag === "Cancelled"
714
- ? sql `state = 'cancelled', cancel_requested = FALSE, finished_at = ${now}`
715
- // A cancel that raced this natural failure wins over revival
716
- // (mirrors release/recoverStalled).
717
- : sql `state = CASE WHEN ${jobs.cancelRequested} THEN 'cancelled'
958
+ ack: (id, token, outcome) => {
959
+ if (outcome._tag === "FanOut") {
960
+ return ackFanOut(id, token, outcome);
961
+ }
962
+ return db.transaction((tx) => Effect.gen(function* () {
963
+ const now = yield* nowDate;
964
+ const update = outcome._tag === "Complete"
965
+ ? sql `state = 'completed', cancel_requested = FALSE, exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
966
+ : outcome._tag === "Fail"
967
+ ? sql `state = 'failed', cancel_requested = FALSE, exit = ${JSON.stringify(outcome.exit ?? null)}::jsonb, finished_at = ${now}`
968
+ : outcome._tag === "Cancelled"
969
+ ? sql `state = 'cancelled', cancel_requested = FALSE, finished_at = ${now}`
970
+ // A cancel that raced this natural failure wins over revival
971
+ // (mirrors release/recoverStalled).
972
+ : sql `state = CASE WHEN ${jobs.cancelRequested} THEN 'cancelled'
718
973
  ELSE ${outcome.delayMs > 0 ? "delayed" : "waiting"} END,
719
974
  finished_at = CASE WHEN ${jobs.cancelRequested} THEN ${now}::timestamptz ELSE NULL END,
720
975
  run_at = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.runAt}
721
976
  ELSE ${new Date(now.getTime() + Math.max(0, outcome.delayMs))}::timestamptz END,
722
977
  seq = CASE WHEN ${jobs.cancelRequested} THEN ${jobs.seq} ELSE ${seqExpr} END,
723
978
  cancel_requested = FALSE`;
724
- const rows = rowsOf(yield* tx.execute(sql `
979
+ const rows = rowsOf(yield* tx.execute(sql `
725
980
  UPDATE ${jobs} SET ${update},
726
981
  attempts_made = ${jobs.attemptsMade} + 1, lock_token = NULL, lock_expires_at = NULL
727
982
  WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
728
983
  RETURNING ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name",
729
984
  ${jobs.state} AS "state", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey",
730
- ${jobs.queue} AS "queue"
985
+ ${jobs.queue} AS "queue", ${jobs.parent} AS "parent", ${jobs.exit} AS "exit",
986
+ ${jobs.failedReason} AS "failedReason"
731
987
  `));
732
- const row = rows[0];
733
- if (row === undefined) {
734
- return yield* explainMiss(id);
735
- }
736
- const cancelledRetry = outcome._tag === "Retry" && row.state === "cancelled";
737
- const ledgerOutcome = outcome._tag === "Complete"
738
- ? "completed"
739
- : outcome._tag === "Fail"
740
- ? "failed"
741
- : outcome._tag === "Cancelled" || cancelledRetry
742
- ? "cancelled"
743
- : "retried";
744
- yield* insertAttempt(tx, id, ledgerOutcome, row.processedAt, now, outcome._tag === "Cancelled" || cancelledRetry ? undefined : outcome.exit);
745
- if (outcome._tag !== "Retry" || cancelledRetry) {
746
- yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
747
- yield* applyKeep(tx, row, now);
748
- }
749
- return outcome._tag === "Retry" && !cancelledRetry
750
- ? JobStore.QueueName(row.queue)
751
- : undefined;
752
- })).pipe(Effect.mapError((error) => error instanceof JobStore.JobNotFoundError || error instanceof JobStore.LockLostError ||
753
- error instanceof JobStore.JobStoreError
754
- ? error
755
- : storeError("ack failed")(error)), Effect.tap((queue) => queue !== undefined ? wakeUp(queue) : Effect.void), Effect.asVoid),
988
+ const row = rows[0];
989
+ if (row === undefined) {
990
+ return yield* explainMiss(id);
991
+ }
992
+ const cancelledRetry = outcome._tag === "Retry" && row.state === "cancelled";
993
+ const ledgerOutcome = outcome._tag === "Complete"
994
+ ? "completed"
995
+ : outcome._tag === "Fail"
996
+ ? "failed"
997
+ : outcome._tag === "Cancelled" || cancelledRetry
998
+ ? "cancelled"
999
+ : "retried";
1000
+ yield* insertAttempt(tx, id, ledgerOutcome, row.processedAt, now, outcome._tag === "Cancelled" || cancelledRetry ? undefined : outcome.exit);
1001
+ if (outcome._tag !== "Retry" || cancelledRetry) {
1002
+ // A terminal transition of an envelope-carrying child reports
1003
+ // upward through the outbox (exit/failedReason as persisted).
1004
+ yield* appendOutbox(tx, row.parent, ledgerOutcome === "retried" ? "cancelled" : ledgerOutcome, row.exit ?? undefined, row.failedReason);
1005
+ yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
1006
+ yield* applyKeep(tx, row, now);
1007
+ }
1008
+ return outcome._tag === "Retry" && !cancelledRetry
1009
+ ? JobStore.QueueName(row.queue)
1010
+ : undefined;
1011
+ })).pipe(Effect.mapError((error) => error instanceof JobStore.JobNotFoundError || error instanceof JobStore.LockLostError ||
1012
+ error instanceof JobStore.JobStoreError
1013
+ ? error
1014
+ : storeError("ack failed")(error)), Effect.tap((queue) => queue !== undefined ? wakeUp(queue) : Effect.void), Effect.asVoid);
1015
+ },
756
1016
  release: (id, token) => Effect.gen(function* () {
757
1017
  const released = yield* db.transaction((tx) => Effect.gen(function* () {
758
1018
  const now = yield* nowDate;
@@ -767,13 +1027,16 @@ export const make = (options) => Effect.gen(function* () {
767
1027
  WHERE ${jobs.id} = ${id} AND ${jobs.state} = 'active' AND ${jobs.lockToken} = ${token}
768
1028
  RETURNING ${jobs.id} AS id, (${jobs.state} = 'cancelled') AS cancelled,
769
1029
  ${jobs.processedAt} AS "processedAt", ${jobs.name} AS "name", ${jobs.keep} AS "keep",
770
- ${jobs.dedupeKey} AS "dedupeKey", ${jobs.queue} AS "queue"
1030
+ ${jobs.dedupeKey} AS "dedupeKey", ${jobs.queue} AS "queue",
1031
+ ${jobs.parent} AS "parent", ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
771
1032
  `));
772
1033
  const row = rows[0];
773
1034
  if (row === undefined)
774
1035
  return undefined;
775
1036
  if (row.cancelled) {
776
1037
  yield* insertAttempt(tx, id, "cancelled", row.processedAt, now, undefined);
1038
+ // A cancel honoured at release is a terminal transition too.
1039
+ yield* appendOutbox(tx, row.parent, "cancelled", row.exit ?? undefined, row.failedReason);
777
1040
  yield* releaseDedupe(tx, row.name, row.dedupeKey, id, now);
778
1041
  yield* applyKeep(tx, { name: row.name, state: "cancelled", keep: row.keep }, now);
779
1042
  }
@@ -843,12 +1106,16 @@ export const make = (options) => Effect.gen(function* () {
843
1106
  cancel_requested = FALSE
844
1107
  WHERE ${jobs.state} = 'active' AND ${jobs.lockExpiresAt} <= ${now}::timestamptz
845
1108
  RETURNING ${jobs.id} AS "id", ${jobs.state} AS "state", ${jobs.processedAt} AS "processedAt",
846
- ${jobs.name} AS "name", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey"
1109
+ ${jobs.name} AS "name", ${jobs.keep} AS "keep", ${jobs.dedupeKey} AS "dedupeKey",
1110
+ ${jobs.parent} AS "parent", ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
847
1111
  `));
848
1112
  const recovered = [];
849
1113
  for (const row of rows) {
850
1114
  yield* insertAttempt(tx, row.id, row.state === "cancelled" ? "cancelled" : "stalled", row.processedAt, now, undefined);
851
1115
  if (row.state === "cancelled" || row.state === "failed") {
1116
+ // Honoured cancels and stall exhaustion are terminal
1117
+ // transitions: envelope-carrying children report upward.
1118
+ yield* appendOutbox(tx, row.parent, row.state === "cancelled" ? "cancelled" : "failed", row.exit ?? undefined, row.failedReason);
852
1119
  yield* releaseDedupe(tx, row.name, row.dedupeKey, row.id, now);
853
1120
  }
854
1121
  if (row.state === "cancelled") {
@@ -906,7 +1173,11 @@ export const make = (options) => Effect.gen(function* () {
906
1173
  ${jobs.attemptsMax} AS "attemptsMax", ${jobs.attemptsMade} AS "attemptsMade",
907
1174
  ${jobs.stalledCount} AS "stalledCount", ${jobs.backoff} AS "backoff",
908
1175
  ${jobs.keep} AS "keep", ${jobs.timeoutMs} AS "timeoutMs",
909
- ${jobs.cancelRequested} AS "cancelRequested",
1176
+ ${jobs.cancelRequested} AS "cancelRequested", ${jobs.dedupeKey} AS "dedupeKey",
1177
+ ${jobs.trace} AS "trace", ${jobs.parent} AS "parent",
1178
+ ${jobs.flowFailFast} AS "flowFailFast", ${jobs.flowPending} AS "flowPending",
1179
+ ${jobs.flowCompleted} AS "flowCompleted", ${jobs.flowFailed} AS "flowFailed",
1180
+ ${jobs.flowCancelled} AS "flowCancelled",
910
1181
  ${jobs.runAt} AS "runAt", ${jobs.enqueuedAt} AS "enqueuedAt",
911
1182
  ${jobs.processedAt} AS "processedAt", ${jobs.finishedAt} AS "finishedAt",
912
1183
  ${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
@@ -1090,6 +1361,301 @@ export const make = (options) => Effect.gen(function* () {
1090
1361
  }
1091
1362
  return fired;
1092
1363
  }),
1364
+ recordChildResults: (reports) => Effect.gen(function* () {
1365
+ if (reports.length === 0) {
1366
+ const none = [];
1367
+ return none;
1368
+ }
1369
+ const batch = yield* db.transaction((tx) => Effect.gen(function* () {
1370
+ const now = yield* nowDate;
1371
+ const results = reports.map(() => ({ applied: false, parentSettled: false }));
1372
+ // Phase 1a — apply every row update (contract lock order:
1373
+ // dependency rows FIRST, parents second). Only a (flow, key)'s
1374
+ // first occurrence in the batch can apply — later duplicates
1375
+ // would find the row non-pending anyway, and UPDATE ... FROM
1376
+ // must never see two source rows for one target.
1377
+ const candidates = [];
1378
+ const seen = new Set();
1379
+ for (const [index, report] of reports.entries()) {
1380
+ const key = `${report.flowId}\u0000${report.childKey}`;
1381
+ if (seen.has(key))
1382
+ continue;
1383
+ seen.add(key);
1384
+ candidates.push({ index, report });
1385
+ }
1386
+ for (let start = 0; start < candidates.length; start += 200) {
1387
+ const chunk = candidates.slice(start, start + 200);
1388
+ const values = chunk.map(({ index, report }) => sql `(${index}::int, ${report.flowId}::text, ${report.childKey}::text,
1389
+ ${report.outcome}::text,
1390
+ ${report.exit === undefined ? null : JSON.stringify(report.exit)}::jsonb,
1391
+ ${report.failedReason ?? null}::text)`);
1392
+ const appliedRows = rowsOf(yield* tx.execute(sql `
1393
+ UPDATE ${flowChildren} SET status = v.outcome, exit = v.exit,
1394
+ failed_reason = v.failed_reason, cascaded = TRUE
1395
+ FROM (VALUES ${sql.join(values, sql `, `)})
1396
+ AS v(ord, flow_id, child_key, outcome, exit, failed_reason)
1397
+ WHERE ${flowChildren.flowId} = v.flow_id AND ${flowChildren.childKey} = v.child_key
1398
+ AND ${flowChildren.status} = 'pending'
1399
+ RETURNING v.ord AS "ord"
1400
+ `));
1401
+ for (const row of appliedRows) {
1402
+ const result = results[Number(row.ord)];
1403
+ if (result !== undefined) {
1404
+ result.applied = true;
1405
+ }
1406
+ }
1407
+ }
1408
+ const touched = new Map();
1409
+ for (const [index, report] of reports.entries()) {
1410
+ if (results[index]?.applied !== true)
1411
+ continue;
1412
+ const touch = touched.get(report.flowId) ?? {
1413
+ appliedCount: 0,
1414
+ completed: 0,
1415
+ failed: 0,
1416
+ cancelled: 0,
1417
+ firstAppliedFailed: undefined,
1418
+ lastApplied: index
1419
+ };
1420
+ touch.appliedCount += 1;
1421
+ touch.lastApplied = index;
1422
+ if (report.outcome === "completed")
1423
+ touch.completed += 1;
1424
+ if (report.outcome === "cancelled")
1425
+ touch.cancelled += 1;
1426
+ if (report.outcome === "failed") {
1427
+ touch.failed += 1;
1428
+ touch.firstAppliedFailed ??= index;
1429
+ }
1430
+ touched.set(report.flowId, touch);
1431
+ }
1432
+ // Phase 1b + 2 — per touched flow (sorted, so concurrent
1433
+ // batches take parent locks in one order): move the applied
1434
+ // children from `pending` to their outcome counters, then make
1435
+ // at most one settle decision. Fail-fast wins the tie.
1436
+ const wakeQueues = [];
1437
+ for (const flowId of [...touched.keys()].toSorted()) {
1438
+ const touch = touched.get(flowId);
1439
+ if (touch === undefined)
1440
+ continue;
1441
+ const parents = rowsOf(yield* tx.execute(sql `
1442
+ UPDATE ${jobs} SET
1443
+ flow_pending = GREATEST(${jobs.flowPending} - ${touch.appliedCount}::int, 0),
1444
+ flow_completed = ${jobs.flowCompleted} + ${touch.completed}::int,
1445
+ flow_failed = ${jobs.flowFailed} + ${touch.failed}::int,
1446
+ flow_cancelled = ${jobs.flowCancelled} + ${touch.cancelled}::int
1447
+ WHERE ${jobs.id} = ${flowId} AND ${jobs.flowPending} IS NOT NULL
1448
+ RETURNING ${jobs.state} AS "state", ${jobs.flowPending} AS "flowPending",
1449
+ ${jobs.flowFailFast} AS "flowFailFast", ${jobs.processedAt} AS "processedAt",
1450
+ ${jobs.name} AS "name", ${jobs.keep} AS "keep",
1451
+ ${jobs.dedupeKey} AS "dedupeKey", ${jobs.queue} AS "queue",
1452
+ ${jobs.parent} AS "parent"
1453
+ `));
1454
+ const parent = parents[0];
1455
+ if (parent === undefined || parent.state !== "waiting-children")
1456
+ continue;
1457
+ const failedIndex = parent.flowFailFast === true ? touch.firstAppliedFailed : undefined;
1458
+ const failedReport = failedIndex !== undefined ? reports[failedIndex] : undefined;
1459
+ if (failedIndex !== undefined && failedReport !== undefined) {
1460
+ // The first applied failure settles the parent terminally
1461
+ // (store-side, like stall exhaustion: failedReason, no
1462
+ // exit) and marks the remaining rows in the same
1463
+ // transaction. A nested parent's own report goes to the
1464
+ // outbox here — this settle IS its terminal transition.
1465
+ const reason = `effect-mq: flow child "${failedReport.childKey}" failed`;
1466
+ yield* tx.execute(sql `
1467
+ WITH marked AS (${cancelPendingChildren(flowId)})
1468
+ UPDATE ${jobs} SET state = 'failed', finished_at = ${now},
1469
+ failed_reason = ${reason}, cancel_requested = FALSE, flow_pending = 0,
1470
+ flow_cancelled = ${jobs.flowCancelled} + (SELECT count(*)::int FROM marked)
1471
+ WHERE ${jobs.id} = ${flowId}
1472
+ `);
1473
+ yield* insertAttempt(tx, flowId, "failed", parent.processedAt, now, undefined);
1474
+ yield* appendOutbox(tx, parent.parent, "failed", undefined, reason);
1475
+ yield* releaseDedupe(tx, parent.name, parent.dedupeKey, flowId, now);
1476
+ yield* applyKeep(tx, { name: parent.name, state: "failed", keep: parent.keep }, now);
1477
+ const decided = results[failedIndex];
1478
+ if (decided !== undefined) {
1479
+ decided.parentSettled = true;
1480
+ }
1481
+ continue;
1482
+ }
1483
+ if (Number(parent.flowPending) === 0) {
1484
+ // All children settled: the parent resumes runnable, phase
1485
+ // collect, settled at the flow's last applied report.
1486
+ yield* tx.execute(sql `
1487
+ UPDATE ${jobs} SET state = 'waiting', run_at = ${now}, seq = ${seqExpr}
1488
+ WHERE ${jobs.id} = ${flowId}
1489
+ `);
1490
+ wakeQueues.push(JobStore.QueueName(parent.queue));
1491
+ const decided = results[touch.lastApplied];
1492
+ if (decided !== undefined) {
1493
+ decided.parentSettled = true;
1494
+ }
1495
+ }
1496
+ }
1497
+ return { results, wakeQueues };
1498
+ })).pipe(
1499
+ // Rare lock-order inversion (a concurrent cancel/settle marking
1500
+ // rows) surfaces as a Postgres deadlock (40P01); the killed side
1501
+ // is safe to retry — the row-state guard keeps it idempotent.
1502
+ Effect.retry({
1503
+ times: 3,
1504
+ while: isDeadlockError
1505
+ }), Effect.mapError((error) => error instanceof JobStore.JobStoreError ? error : storeError("recordChildResults failed")(error)));
1506
+ for (const queue of batch.wakeQueues) {
1507
+ yield* wakeUp(queue);
1508
+ }
1509
+ return batch.results;
1510
+ }),
1511
+ peekOutbox: (peekOptions) => Effect.gen(function* () {
1512
+ const limit = Math.max(0, peekOptions.limit);
1513
+ if (limit === 0) {
1514
+ const none = [];
1515
+ return none;
1516
+ }
1517
+ // `after` pages past a previously returned id (exclusive), whether
1518
+ // or not that entry still exists. Anything that is not a canonical
1519
+ // id this store issued is treated as unset.
1520
+ const after = peekOptions.after !== undefined && CANONICAL_BIGSERIAL.test(peekOptions.after)
1521
+ ? peekOptions.after
1522
+ : undefined;
1523
+ const rows = rowsOf(yield* db.execute(sql `
1524
+ SELECT ${flowOutbox.id}::text AS "id", ${flowOutbox.flowName} AS "flowName",
1525
+ ${flowOutbox.parentStoreKey} AS "parentStoreKey", ${flowOutbox.report} AS "report"
1526
+ FROM ${flowOutbox}
1527
+ ${after === undefined ? sql `` : sql `WHERE ${flowOutbox.id} > ${after}::bigint`}
1528
+ ORDER BY ${flowOutbox.id} ASC
1529
+ LIMIT ${limit}
1530
+ `).pipe(Effect.mapError(storeError("peekOutbox failed"))));
1531
+ return rows.map((row) => ({
1532
+ id: row.id,
1533
+ flowName: row.flowName,
1534
+ parentStoreKey: row.parentStoreKey,
1535
+ report: row.report
1536
+ }));
1537
+ }),
1538
+ deleteOutbox: (ids) => Effect.suspend(() => {
1539
+ // Ids are opaque strings to callers; only CANONICAL ids this store
1540
+ // could have issued can match. The strictness matters twice: a
1541
+ // foreign/garbled id must not blow up the ::bigint cast, and a
1542
+ // non-canonical spelling ("007") must stay an unknown-id no-op
1543
+ // rather than cast to 7 and delete a live entry.
1544
+ const numeric = ids.filter((id) => CANONICAL_BIGSERIAL.test(id));
1545
+ if (numeric.length === 0)
1546
+ return Effect.void;
1547
+ return db.execute(sql `
1548
+ DELETE FROM ${flowOutbox}
1549
+ WHERE ${flowOutbox.id} = ANY(${sql.param(numeric)}::bigint[])
1550
+ `).pipe(Effect.mapError(storeError("deleteOutbox failed")), Effect.asVoid);
1551
+ }),
1552
+ listChildResults: (flowId, listOptions) => Effect.gen(function* () {
1553
+ const limit = Math.max(1, listOptions?.limit ?? 1000);
1554
+ const cursor = listOptions?.cursor;
1555
+ // `spec->>'id'` instead of the whole spec: at 10k children the full
1556
+ // payloads would transfer on every collect.
1557
+ const rows = rowsOf(yield* db.execute(sql `
1558
+ SELECT ${flowChildren.flowId} AS "flowId", ${flowChildren.childKey} AS "childKey",
1559
+ ${flowChildren.name} AS "name", ${flowChildren.storeKey} AS "storeKey",
1560
+ ${flowChildren.spec}->>'id' AS "childJobId", ${flowChildren.status} AS "status",
1561
+ ${flowChildren.exit} AS "exit", ${flowChildren.failedReason} AS "failedReason",
1562
+ ${flowChildren.cascaded} AS "cascaded"
1563
+ FROM ${flowChildren}
1564
+ WHERE ${flowChildren.flowId} = ${flowId}
1565
+ ${cursor === undefined ? sql `` : sql `AND ${flowChildren.childKey} > ${cursor}`}
1566
+ ORDER BY ${flowChildren.childKey} ASC
1567
+ LIMIT ${limit + 1}
1568
+ `).pipe(Effect.mapError(storeError("listChildResults failed"))));
1569
+ const page = rows.slice(0, limit);
1570
+ const items = page.map(toFlowChildRecord);
1571
+ const last = items[items.length - 1];
1572
+ return {
1573
+ items,
1574
+ cursor: rows.length > limit && last !== undefined ? last.childKey : undefined
1575
+ };
1576
+ }),
1577
+ flowSweepWork: (sweepOptions) => Effect.gen(function* () {
1578
+ const now = yield* nowDate;
1579
+ const limit = Math.max(1, sweepOptions.limit ?? 1000);
1580
+ const threshold = new Date(now.getTime() - sweepOptions.pendingAgeMs);
1581
+ // Reconcile: pending rows past the eligibility threshold whose
1582
+ // parent is still parked (a settled flow never re-drives work).
1583
+ // Returning a row re-arms `pending_since` in the same statement, so
1584
+ // a full page rotates across sweeps instead of pinning its head.
1585
+ // SKIP LOCKED: rows a concurrent report/settle holds are its
1586
+ // business, and never waiting means this statement cannot deadlock.
1587
+ // The raw column names are safe: only table names vary across
1588
+ // factory instances. RETURNING order is unspecified — sorted below.
1589
+ const reconcileRows = rowsOf(yield* db.execute(sql `
1590
+ WITH due AS (
1591
+ SELECT c.flow_id, c.child_key
1592
+ FROM ${flowChildren} c
1593
+ JOIN ${jobs} j ON j.id = c.flow_id
1594
+ WHERE c.status = 'pending' AND c.pending_since <= ${threshold}
1595
+ AND j.state = 'waiting-children'
1596
+ ORDER BY c.flow_id, c.child_key
1597
+ LIMIT ${limit}
1598
+ FOR UPDATE OF c SKIP LOCKED
1599
+ )
1600
+ UPDATE ${flowChildren} SET pending_since = ${now}
1601
+ FROM due
1602
+ WHERE ${flowChildren.flowId} = due.flow_id AND ${flowChildren.childKey} = due.child_key
1603
+ RETURNING ${flowChildren.flowId} AS "flowId", ${flowChildren.childKey} AS "childKey",
1604
+ ${flowChildren.storeKey} AS "storeKey", ${flowChildren.spec} AS "spec"
1605
+ `).pipe(Effect.mapError(storeError("flowSweepWork failed"))))
1606
+ .toSorted((a, b) => a.flowId !== b.flowId
1607
+ ? (a.flowId < b.flowId ? -1 : 1)
1608
+ : a.childKey < b.childKey
1609
+ ? -1
1610
+ : a.childKey > b.childKey
1611
+ ? 1
1612
+ : 0);
1613
+ // Cascade: cancelled rows whose cancel has not been delivered into
1614
+ // the child's store yet (any parent state).
1615
+ const cascadeRows = rowsOf(yield* db.execute(sql `
1616
+ SELECT ${flowChildren.flowId} AS "flowId", ${flowChildren.childKey} AS "childKey",
1617
+ ${flowChildren.storeKey} AS "storeKey", ${flowChildren.spec} AS "spec"
1618
+ FROM ${flowChildren}
1619
+ WHERE ${flowChildren.status} = 'cancelled' AND ${flowChildren.cascaded} = FALSE
1620
+ ORDER BY ${flowChildren.flowId}, ${flowChildren.childKey}
1621
+ LIMIT ${limit}
1622
+ `).pipe(Effect.mapError(storeError("flowSweepWork failed"))));
1623
+ const reconcile = [];
1624
+ for (const row of reconcileRows) {
1625
+ const flowId = JobId(row.flowId);
1626
+ let group = reconcile[reconcile.length - 1];
1627
+ if (group === undefined || group.flowId !== flowId) {
1628
+ group = { flowId, children: [] };
1629
+ reconcile.push(group);
1630
+ }
1631
+ group.children.push({ childKey: row.childKey, storeKey: row.storeKey, request: row.spec });
1632
+ }
1633
+ const cascade = [];
1634
+ for (const row of cascadeRows) {
1635
+ const flowId = JobId(row.flowId);
1636
+ let group = cascade[cascade.length - 1];
1637
+ if (group === undefined || group.flowId !== flowId) {
1638
+ group = { flowId, children: [] };
1639
+ cascade.push(group);
1640
+ }
1641
+ group.children.push({
1642
+ childKey: row.childKey,
1643
+ storeKey: row.storeKey,
1644
+ // SAFETY: the FanOut ack validated every spec id before
1645
+ // persisting it.
1646
+ childJobId: row.spec.id
1647
+ });
1648
+ }
1649
+ const work = { reconcile, cascade };
1650
+ return work;
1651
+ }),
1652
+ markChildrenCascaded: (flowId, childKeys) => childKeys.length === 0
1653
+ ? Effect.void
1654
+ : db.execute(sql `
1655
+ UPDATE ${flowChildren} SET cascaded = TRUE
1656
+ WHERE ${flowChildren.flowId} = ${flowId}
1657
+ AND ${flowChildren.childKey} = ANY(${sql.param([...childKeys])})
1658
+ `).pipe(Effect.mapError(storeError("markChildrenCascaded failed")), Effect.asVoid),
1093
1659
  counts: (queue) => db.execute(sql `
1094
1660
  SELECT ${jobs.state} AS "state", count(*)::int AS "count" FROM ${jobs}
1095
1661
  ${queue === undefined ? sql `` : sql `WHERE ${jobs.queue} = ${queue}`}
@@ -1100,6 +1666,7 @@ export const make = (options) => Effect.gen(function* () {
1100
1666
  waiting: 0,
1101
1667
  delayed: 0,
1102
1668
  active: 0,
1669
+ "waiting-children": 0,
1103
1670
  completed: 0,
1104
1671
  failed: 0,
1105
1672
  cancelled: 0
@@ -1108,10 +1675,19 @@ export const make = (options) => Effect.gen(function* () {
1108
1675
  counts[row.state] = row.count;
1109
1676
  return counts;
1110
1677
  })),
1111
- remove: (id) => db.execute(sql `
1112
- DELETE FROM ${jobs}
1113
- WHERE ${jobs.id} = ${id} AND ${jobs.state} <> 'active'
1114
- RETURNING ${jobs.id} AS id
1678
+ remove: (id) =>
1679
+ // The purge CTE takes a removed flow parent's dependency rows with it
1680
+ // in the same statement.
1681
+ db.execute(sql `
1682
+ WITH deleted AS (
1683
+ DELETE FROM ${jobs}
1684
+ WHERE ${jobs.id} = ${id} AND ${jobs.state} NOT IN ('active', 'waiting-children')
1685
+ RETURNING ${jobs.id} AS id
1686
+ ), purged AS (
1687
+ DELETE FROM ${flowChildren}
1688
+ WHERE ${flowChildren.flowId} IN (SELECT id FROM deleted)
1689
+ )
1690
+ SELECT id FROM deleted
1115
1691
  `).pipe(Effect.mapError(storeError("remove failed")), Effect.map((result) => rowsOf(result).length > 0))
1116
1692
  };
1117
1693
  return store;