@cosmicdrift/kumiko-framework 0.307.0 → 0.309.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 (174) hide show
  1. package/package.json +5 -5
  2. package/src/__tests__/anonymous-access.integration.test.ts +1 -1
  3. package/src/__tests__/error-contract.integration.test.ts +1 -1
  4. package/src/__tests__/field-access.integration.test.ts +1 -1
  5. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +1 -1
  6. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +1 -1
  7. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +1 -1
  8. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +1 -1
  9. package/src/__tests__/full-stack.integration.test.ts +1 -1
  10. package/src/__tests__/transition-guard.integration.test.ts +1 -1
  11. package/src/api/__tests__/api.test.ts +1 -1
  12. package/src/api/__tests__/batch.integration.test.ts +1 -1
  13. package/src/api/__tests__/body-limit.test.ts +1 -1
  14. package/src/api/__tests__/dispatcher-live.integration.test.ts +1 -1
  15. package/src/api/__tests__/extra-routes.integration.test.ts +1 -1
  16. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +1 -1
  17. package/src/api/__tests__/nested-write-ownership.integration.test.ts +1 -1
  18. package/src/api/__tests__/nested-write.integration.test.ts +1 -1
  19. package/src/api/__tests__/pii-leak-guard.integration.test.ts +1 -1
  20. package/src/api/__tests__/redis-sse-broker.integration.test.ts +73 -6
  21. package/src/api/__tests__/request-locale.integration.test.ts +1 -1
  22. package/src/api/__tests__/server-boot-guards.test.ts +1 -1
  23. package/src/api/__tests__/server-error-logging.test.ts +1 -1
  24. package/src/api/__tests__/sse-broker.test.ts +107 -13
  25. package/src/api/__tests__/stream-escape-hatch.integration.test.ts +1 -1
  26. package/src/api/auth-routes.ts +1 -1
  27. package/src/api/redis-sse-broker.ts +51 -11
  28. package/src/api/server.ts +11 -0
  29. package/src/api/sse-broker.ts +61 -22
  30. package/src/bun-db/__tests__/postgres-closed-socket-write.integration.test.ts +87 -0
  31. package/src/bun-db/connection.ts +2 -0
  32. package/src/changes.json +61 -0
  33. package/src/compliance/override-schema.ts +1 -1
  34. package/src/crypto/__tests__/event-pii.test.ts +1 -1
  35. package/src/crypto/kms-adapter.ts +1 -1
  36. package/src/db/__tests__/feature-table-sources.test.ts +2 -2
  37. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
  38. package/src/db/collect-table-metas.ts +3 -6
  39. package/src/db/feature-table-sources.ts +3 -4
  40. package/src/db/queries/event-consumer.ts +98 -6
  41. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
  42. package/src/derivatives/delete-stored-file.ts +86 -0
  43. package/src/derivatives/index.ts +2 -0
  44. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +1 -1
  45. package/src/engine/__tests__/boot-validator-dashboard.test.ts +1 -1
  46. package/src/engine/__tests__/boot-validator-nav-area.test.ts +1 -1
  47. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
  48. package/src/engine/__tests__/boot-validator-projection-list.test.ts +1 -1
  49. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +1 -1
  50. package/src/engine/__tests__/boot-validator-query-refs.test.ts +1 -1
  51. package/src/engine/__tests__/boot-validator-ref-entity.test.ts +1 -1
  52. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +1 -1
  53. package/src/engine/__tests__/boot-validator.test.ts +35 -1
  54. package/src/engine/__tests__/build-app-schema.test.ts +1 -1
  55. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +1 -1
  56. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +1 -1
  57. package/src/engine/__tests__/engine.test.ts +1 -1
  58. package/src/engine/__tests__/event-helpers.test.ts +1 -1
  59. package/src/engine/__tests__/event-migration-declarative.test.ts +1 -1
  60. package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
  61. package/src/engine/__tests__/extends-registrar.test.ts +1 -1
  62. package/src/engine/__tests__/hook-phases.test.ts +1 -1
  63. package/src/engine/__tests__/lifecycle-hooks.test.ts +1 -1
  64. package/src/engine/__tests__/pipeline-engine.test.ts +1 -1
  65. package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
  66. package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
  67. package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
  68. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +1 -1
  69. package/src/engine/__tests__/post-query-hook.test.ts +1 -1
  70. package/src/engine/__tests__/projection-detail-actions.test.ts +1 -1
  71. package/src/engine/__tests__/projection-detail-singleton.test.ts +1 -1
  72. package/src/engine/__tests__/projection-detail-tabs.test.ts +1 -1
  73. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +1 -1
  74. package/src/engine/__tests__/registry.test.ts +1 -1
  75. package/src/engine/__tests__/screen.test.ts +1 -1
  76. package/src/engine/__tests__/validate-projection-allowlist.test.ts +1 -1
  77. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +1 -1
  78. package/src/engine/boot-validator/__tests__/action-icon.test.ts +81 -0
  79. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +1 -1
  80. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +1 -1
  81. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +1 -1
  82. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +1 -1
  83. package/src/engine/boot-validator/screens.ts +83 -0
  84. package/src/engine/define-handler.ts +2 -1
  85. package/src/engine/entity-handlers.ts +2 -1
  86. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +1 -1
  87. package/src/engine/feature-ast/pattern-change-schema.ts +1 -1
  88. package/src/engine/feature-builder-state.ts +2 -1
  89. package/src/engine/feature-config-events-jobs.ts +2 -1
  90. package/src/engine/feature-entity-handlers.ts +2 -1
  91. package/src/engine/schema-builder.ts +1 -1
  92. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
  93. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -1
  94. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
  95. package/src/env/__tests__/compose-env-schema.test.ts +1 -1
  96. package/src/env/__tests__/dry-run.test.ts +1 -1
  97. package/src/env/__tests__/parse-env-dry-run.test.ts +1 -1
  98. package/src/env/_zod-introspect.ts +1 -1
  99. package/src/env/dry-run.ts +1 -1
  100. package/src/env/index.ts +1 -1
  101. package/src/errors/__tests__/classes.test.ts +1 -1
  102. package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
  103. package/src/event-store/__tests__/backfill-pii.integration.test.ts +1 -1
  104. package/src/event-store/__tests__/event-attribution.integration.test.ts +1 -1
  105. package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
  106. package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -1
  107. package/src/jobs/__tests__/job-event-trigger.integration.test.ts +1 -1
  108. package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +1 -1
  109. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +1 -1
  110. package/src/jobs/__tests__/job-retention.integration.test.ts +1 -1
  111. package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -1
  112. package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
  113. package/src/observability/__tests__/metrics-wiring.test.ts +1 -1
  114. package/src/observability/__tests__/observability.integration.test.ts +1 -1
  115. package/src/observability/metrics-wiring.ts +1 -1
  116. package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
  117. package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
  118. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +1 -1
  119. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +1 -1
  120. package/src/pipeline/__tests__/dispatcher.test.ts +1 -1
  121. package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +1 -1
  122. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +1 -1
  123. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +1 -1
  124. package/src/pipeline/__tests__/event-dedup.integration.test.ts +1 -1
  125. package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +1 -1
  126. package/src/pipeline/__tests__/event-dispatcher-batch-delivery.test.ts +178 -0
  127. package/src/pipeline/__tests__/event-dispatcher-batch-handler.integration.test.ts +143 -0
  128. package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +192 -0
  129. package/src/pipeline/__tests__/event-dispatcher-idle.integration.test.ts +94 -0
  130. package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +103 -3
  131. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
  132. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +1 -1
  133. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +1 -1
  134. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  135. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
  136. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
  137. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +116 -1
  138. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +1 -1
  139. package/src/pipeline/__tests__/pending-gap-ranges.test.ts +94 -0
  140. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +1 -1
  141. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +1 -1
  142. package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
  143. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +1 -1
  144. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -1
  145. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +1 -1
  146. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +1 -1
  147. package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
  148. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +1 -1
  149. package/src/pipeline/dispatch-stream.ts +6 -1
  150. package/src/pipeline/event-dispatcher-delivery.ts +132 -37
  151. package/src/pipeline/event-dispatcher.ts +121 -17
  152. package/src/pipeline/index.ts +1 -0
  153. package/src/pipeline/msp-rebuild.ts +39 -1
  154. package/src/pipeline/pending-gap-ranges.ts +48 -0
  155. package/src/pipeline/system-hooks.ts +223 -68
  156. package/src/pipeline/write-origin.ts +1 -1
  157. package/src/rate-limit/__tests__/dispatcher-l3.integration.test.ts +1 -1
  158. package/src/search/__tests__/derive-search-adapter-config.test.ts +65 -0
  159. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +91 -0
  160. package/src/search/__tests__/search-consumer-batch.integration.test.ts +306 -0
  161. package/src/search/derive-search-adapter-config.ts +27 -0
  162. package/src/search/index.ts +1 -0
  163. package/src/search/meilisearch-adapter.ts +96 -14
  164. package/src/secrets/__tests__/contains-secret.test.ts +1 -1
  165. package/src/stack/__tests__/request-helper.integration.test.ts +1 -1
  166. package/src/stack/__tests__/setup-test-stack-entity-tables.integration.test.ts +65 -0
  167. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
  168. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +1 -1
  169. package/src/stack/test-stack.ts +61 -54
  170. package/src/testing/__tests__/e2e-generator.test.ts +1 -1
  171. package/src/testing/__tests__/real-providers.test.ts +9 -1
  172. package/src/testing/e2e-generator.ts +1 -1
  173. package/src/testing/index.ts +1 -1
  174. package/src/testing/real-providers.ts +12 -4
@@ -25,6 +25,7 @@ import {
25
25
  persistConsumerOutcome,
26
26
  persistConsumerPassFailure,
27
27
  preRegisterConsumers,
28
+ selectIdleConsumerKeys,
28
29
  } from "./event-dispatcher-delivery";
29
30
  import { partitionBurntGaps, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges";
30
31
 
@@ -67,6 +68,21 @@ import { partitionBurntGaps, splitRangeExcludingIds, toIdRanges } from "./pendin
67
68
 
68
69
  export type EventConsumerHandler = (event: StoredEvent, ctx: AppContext) => Promise<void>;
69
70
 
71
+ // Batch variant of EventConsumerHandler — receives all events of one
72
+ // delivery turn in id order, meant for side-effect sinks (search index,
73
+ // bulk fan-out) that can collapse a turn into one round-trip. `handler`
74
+ // stays mandatory and is the fallback: if the batch throws, the dispatcher
75
+ // re-delivers the same events one by one through `handler`, so errorPolicy
76
+ // (maxAttempts, skipApplyErrors, dead-lettering) applies per event exactly
77
+ // as before. The batch handler must therefore be replay-safe — a partially
78
+ // applied batch gets re-applied event by event under the same idempotency
79
+ // rule as `handler`. Events appended from inside a batch handler carry the
80
+ // LAST event's causation id only.
81
+ export type EventConsumerBatchHandler = (
82
+ events: readonly StoredEvent[],
83
+ ctx: AppContext,
84
+ ) => Promise<void>;
85
+
70
86
  // Per-consumer error policy. When skipApplyErrors is true and handler throws,
71
87
  // the dispatcher logs the error, advances the cursor past the offending event,
72
88
  // and keeps delivering — instead of the default retry + dead-letter flow.
@@ -84,6 +100,9 @@ export type EventConsumerErrorPolicy = {
84
100
  export type EventConsumer = {
85
101
  readonly name: string;
86
102
  readonly handler: EventConsumerHandler;
103
+ // Optional batch fast-path for this turn's events — see
104
+ // EventConsumerBatchHandler for the fallback and replay-safety contract.
105
+ readonly batchHandler?: EventConsumerBatchHandler;
87
106
  readonly errorPolicy?: EventConsumerErrorPolicy;
88
107
  // Owning feature — when present, the dispatcher skips this consumer's
89
108
  // pass while the feature is globally disabled. Events remain in the store
@@ -246,6 +265,15 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
246
265
  // entry — the next failure (if any) starts a fresh backoff from scratch.
247
266
  const consumerBackoff = new Map<string, { consecutiveFailures: number; retryAtMs: number }>();
248
267
 
268
+ // Dedup flag for the idle pre-check's error log (doPass) — a broken
269
+ // pre-check falls back to the expensive-but-correct path silently on
270
+ // every tick (see doPass), which must not mean silently in the logs too:
271
+ // ops needs to see it degrade once, not have every poll tick spam the
272
+ // same line. Cleared the moment the pre-check succeeds again, so a later,
273
+ // unrelated outage gets its own fresh log line instead of staying
274
+ // permanently suppressed by this process's flag.
275
+ let idlePreCheckErrorLogged = false;
276
+
249
277
  let running = false;
250
278
  // Separate from `running` on purpose: pre-registration of consumer state
251
279
  // rows is a one-time boot action, while running/timer/LISTEN is a
@@ -300,14 +328,16 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
300
328
  async function runConsumerTurn(
301
329
  consumer: EventConsumer,
302
330
  effective: ReadonlySet<string> | undefined,
331
+ idleKeys: ReadonlySet<string>,
303
332
  ): Promise<{ processed: number; failed: number }> {
304
333
  const key = `${consumer.name}:${consumerInstanceId(consumer, options.instanceId)}`;
305
334
  const existing = inFlightTurns.get(key);
306
335
  if (existing) return existing;
307
336
 
308
- // Feature-gate and backoff-gate are resolved synchronously, before any
309
- // promise is registered — a gated consumer must resolve without ever
310
- // occupying inFlightTurns, or it would look "still running" forever.
337
+ // Feature-gate, backoff-gate and idle-gate are resolved synchronously,
338
+ // before any promise is registered — a gated consumer must resolve
339
+ // without ever occupying inFlightTurns, or it would look "still
340
+ // running" forever.
311
341
  if (effective && consumer.featureName && !effective.has(consumer.featureName)) {
312
342
  return { processed: 0, failed: 0 };
313
343
  }
@@ -315,6 +345,17 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
315
345
  if (backoff && backoff.retryAtMs > Date.now()) {
316
346
  return { processed: 0, failed: 0 };
317
347
  }
348
+ // Idle-gate: doPass already proved this consumer has nothing to do
349
+ // (see selectIdleConsumerKeys) — skip the turn without ever opening a
350
+ // db.begin/FOR UPDATE SKIP LOCKED (see doPass for why that matters).
351
+ // Clear any prior backoff same as a successful pass would: the
352
+ // backoff-gate above already confirmed retryAtMs has elapsed, and
353
+ // "provably nothing to deliver" is itself proof the consumer isn't
354
+ // presently failing.
355
+ if (idleKeys.has(key)) {
356
+ consumerBackoff.delete(key);
357
+ return { processed: 0, failed: 0 };
358
+ }
318
359
 
319
360
  // Registered before awaiting the slot — a turn queued behind the
320
361
  // concurrency limit still counts as in-flight, so it isn't started twice.
@@ -371,12 +412,65 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
371
412
  // SYSTEM_TENANT_ID returnt (typisch: union-of-all-tier-features).
372
413
  const effective = context.effectiveFeatures?.(SYSTEM_TENANT_ID);
373
414
 
415
+ // Lock-free pre-check, once per pass: which consumers are provably idle
416
+ // (no locking, no xid, no WAL — see selectIdleConsumerKeys). Read
417
+ // outside any transaction, before a single db.begin/FOR UPDATE SKIP
418
+ // LOCKED opens — an idle deployment (most consumers, most ticks)
419
+ // otherwise pays a row lock + WAL record + commit fsync per consumer
420
+ // per tick for nothing.
421
+ //
422
+ // A failure here (e.g. a transient DB blip) must not abort the whole
423
+ // pass — timer/LISTEN callers swallow doPass()'s rejection silently
424
+ // (see start()), which would mean no log, no persistConsumerPassFailure,
425
+ // and no backoff for every consumer this tick. Falling back to "nothing
426
+ // proven idle" instead routes every consumer through its normal
427
+ // acquireConsumerState path, whose own try/catch already covers this.
428
+ let idleKeys: ReadonlySet<string>;
429
+ try {
430
+ idleKeys = await selectIdleConsumerKeys(
431
+ db,
432
+ consumers.map((consumer) => ({
433
+ name: consumer.name,
434
+ instanceId: consumerInstanceId(consumer, options.instanceId),
435
+ })),
436
+ );
437
+ if (idlePreCheckErrorLogged) {
438
+ idlePreCheckErrorLogged = false;
439
+ const recoveredMsg =
440
+ "[event-dispatcher] idle pre-check recovered, database reachable again";
441
+ if (context.log) {
442
+ context.log.info(recoveredMsg);
443
+ } else {
444
+ console.log(recoveredMsg);
445
+ }
446
+ }
447
+ } catch (e) {
448
+ idleKeys = new Set();
449
+ // Log once per outage, not once per poll tick — see
450
+ // idlePreCheckErrorLogged above. Every consumer still gets delivered
451
+ // correctly this tick (fallback above), so this is a degradation
452
+ // signal for ops, not a delivery failure.
453
+ if (!idlePreCheckErrorLogged) {
454
+ idlePreCheckErrorLogged = true;
455
+ // The LISTEN peer talks to the same DB, so it is gone too; only a
456
+ // later onlisten may flip the gauge back to 1.
457
+ emitEventDispatcherListenConnected(meter, false);
458
+ const msg = e instanceof Error ? e.message : String(e);
459
+ const logMsg = `[event-dispatcher] idle pre-check failed, falling back to per-consumer locking every tick until it recovers: ${msg}`;
460
+ if (context.log) {
461
+ context.log.error(logMsg);
462
+ } else {
463
+ console.error(logMsg);
464
+ }
465
+ }
466
+ }
467
+
374
468
  // Every consumer runs its own turn concurrently (own TX, own cursor,
375
- // own backoff/feature-gate) — a slow consumer no longer blocks the
376
- // others' delivery until it commits, see runConsumerTurn.
469
+ // own backoff/feature-gate/idle-gate) — a slow consumer no longer
470
+ // blocks the others' delivery until it commits, see runConsumerTurn.
377
471
  const results = await Promise.all(
378
472
  consumers.map(async (consumer) => {
379
- const perConsumer = await runConsumerTurn(consumer, effective);
473
+ const perConsumer = await runConsumerTurn(consumer, effective, idleKeys);
380
474
  return [consumer.name, perConsumer] as const;
381
475
  }),
382
476
  );
@@ -453,9 +547,14 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
453
547
  xminNow,
454
548
  );
455
549
 
456
- // skip: nothing to deliver and no burnt gap to clean up — no
457
- // markProcessing/persistConsumerOutcome write, so an idle consumer
458
- // doesn't burn a WAL record on every poll tick.
550
+ // skip: nothing to deliver and no burnt gap to clean up. The
551
+ // doPass idle pre-check (selectIdleConsumerKeys) already keeps the
552
+ // common idle case out of this transaction entirely — reaching
553
+ // here with an empty fetch means the pre-check couldn't prove
554
+ // idleness (pending gaps forced a fetch, or a race window). By this
555
+ // point acquireConsumerState's FOR UPDATE has already taken the
556
+ // lock, so skipping markProcessing/persistConsumerOutcome only
557
+ // avoids extra writes, not that lock's own WAL record.
459
558
  if (events.length === 0 && burntGaps.length === 0) {
460
559
  span.setAttribute("consumer.skip_reason", "no_pending_events");
461
560
  return;
@@ -610,10 +709,11 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
610
709
  // fallback (dropped subscriptions, missed commits under load).
611
710
  //
612
711
  // Observability: the gauge kumiko_event_dispatcher_listen_connected
613
- // flips to 1 on initial subscribe AND on every postgres.js silent
614
- // reconnect (via the onlisten callback). A drop to 0 while running
615
- // means delivery latency regressed from TCP-round-trip to
616
- // pollIntervalMs — ops-visible.
712
+ // flips to 1 on initial subscribe and again on every reconnect that
713
+ // succeeds (onlisten callback). It drops to 0 on the initial connect
714
+ // failure, on stop() and when the idle pre-check detects a DB outage.
715
+ // An outage shorter than one poll tick is not detected; the gauge then
716
+ // stays at 1 until postgres.js's delayed re-LISTEN lands.
617
717
  emitEventDispatcherListenConnected(meter, false);
618
718
  if (options.pgClient) {
619
719
  try {
@@ -625,10 +725,14 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
625
725
  });
626
726
  },
627
727
  () => {
628
- // Fires on initial connect AND on each reconnect. postgres.js
629
- // reconnects transparently if the TCP connection drops, so the
630
- // only way to see the recovery window is to flip the gauge
631
- // every time this callback lands.
728
+ // Fires on initial connect and again when postgres.js's own
729
+ // re-LISTEN lands after a drop; during an outage that re-LISTEN
730
+ // waits in its connection backoff (measured 10-30s after the DB
731
+ // returns), polling covers delivery meanwhile. Never unlisten()
732
+ // + listen() by hand: that leaves duplicate listeners.
733
+ // ponytail: no own LISTEN reconnect, the backoff gap is polled;
734
+ // add a dedicated LISTEN connection if prod wires a pgClient
735
+ // and that latency matters.
632
736
  emitEventDispatcherListenConnected(meter, true);
633
737
  },
634
738
  );
@@ -28,6 +28,7 @@ export type {
28
28
  ConsumerRecoveryState,
29
29
  DispatcherPassResult,
30
30
  EventConsumer,
31
+ EventConsumerBatchHandler,
31
32
  EventConsumerHandler,
32
33
  EventDispatcher,
33
34
  EventDispatcherOptions,
@@ -3,6 +3,8 @@ import {
3
3
  markConsumerRebuildFailed,
4
4
  resetConsumerForMspRebuild,
5
5
  selectConsumerForUpdate,
6
+ selectEventIdHorizonWithMissingRanges,
7
+ selectSnapshotXmax,
6
8
  updateConsumerRebuildCursor,
7
9
  } from "../db/queries/event-consumer";
8
10
  import {
@@ -28,6 +30,7 @@ import { emitProjectionRebuild } from "../observability/standard-metrics";
28
30
  import type { Meter } from "../observability/types/metric";
29
31
  import { SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
30
32
  import type { MultiStreamApplyContext } from "./multi-stream-apply-context";
33
+ import { capPendingGapsBelowCursor, subtractSortedIdsFromRanges } from "./pending-gap-ranges";
31
34
  import type { RebuildResult } from "./projection-rebuild";
32
35
 
33
36
  // Rebuild a multi-stream projection (MSP) from the event log. Symmetric to
@@ -61,6 +64,20 @@ import type { RebuildResult } from "./projection-rebuild";
61
64
  // - Advance cursor to last processed event id, status=idle.
62
65
  // - Swap the shadow into public (brief ACCESS EXCLUSIVE, not the replay).
63
66
  //
67
+ // Handoff to the live dispatcher (#3231): the replay horizon (MAX(id) at the
68
+ // time selectEventIdHorizonWithMissingRanges ran) plus the id gaps open below
69
+ // it in that same snapshot are read up front and the replay is bounded to
70
+ // `id <= horizon`. A transaction holding a lower id can still be in-flight
71
+ // during replay — its row is invisible here and, once committed, would sit
72
+ // below the cursor with no live-dispatcher gap tracking to ever revisit it.
73
+ // Seeding pending_gaps closes that: ids actually read during replay are
74
+ // carved out (delivered here, so a live pass must never redeliver them), and
75
+ // whatever survives is capped below the written cursor — the live dispatcher
76
+ // owns everything from `id > cursor` onward on its own. Those surviving
77
+ // ranges are the shape of the snapshot taken before the replay, but their
78
+ // xmax is read after the replay — a later xmax can only delay when the live
79
+ // dispatcher burns the gap, never make it burn a still-in-flight range early.
80
+ //
64
81
  // Failure: outer catch writes status="dead" + lastError so ops sees the
65
82
  // failure after the TX rolled back. Use restartConsumer to clear dead.
66
83
  //
@@ -145,10 +162,13 @@ export async function rebuildMultiStreamProjection(
145
162
  // Outside the rebuild tx, like the schema: idempotent DDL colliding
146
163
  // inside the tx would roll the whole replay back.
147
164
  if (skipApplyErrors) await createRebuildDeadLetterTable(db);
165
+ const replayedIds: bigint[] = [];
148
166
  await db.begin(async (tx: DbTx) => {
149
167
  await resetConsumerForMspRebuild(tx, mspName, SHARED_INSTANCE_SENTINEL);
150
168
  await selectConsumerForUpdate(tx, mspName, SHARED_INSTANCE_SENTINEL);
151
169
 
170
+ const { horizon, gaps: seededGaps } = await selectEventIdHorizonWithMissingRanges(tx);
171
+
152
172
  await assertLiveColumnsMatchMeta(tx, meta, mspName);
153
173
  await assertLiveTableHasNoRowLevelSecurity(tx, meta.tableName, mspName);
154
174
  await buildShadowTable(tx, meta);
@@ -168,10 +188,13 @@ export async function rebuildMultiStreamProjection(
168
188
  createdAt: Temporal.Instant;
169
189
  createdBy: string;
170
190
  };
191
+ // Events past `horizon` are the live dispatcher's to deliver via
192
+ // `id > cursor` — bounding the replay here is what keeps the two
193
+ // paths from ever double-applying the same event.
171
194
  const events = await selectMany<EventRow>(
172
195
  tx,
173
196
  eventsTable,
174
- { type: [...subscribedTypes] },
197
+ { type: [...subscribedTypes], id: { lte: horizon } },
175
198
  { orderBy: { col: "id", direction: "asc" } },
176
199
  );
177
200
 
@@ -194,6 +217,7 @@ export async function rebuildMultiStreamProjection(
194
217
  db: tx,
195
218
  tenantId: row.tenantId,
196
219
  });
220
+ replayedIds.push(row.id);
197
221
  const applyFn = msp.apply[row.type];
198
222
  if (!applyFn) continue;
199
223
  if (skipApplyErrors) {
@@ -221,11 +245,25 @@ export async function rebuildMultiStreamProjection(
221
245
  if (skipped.length > 0) {
222
246
  await recordRebuildDeadLetters(tx, mspName, skipped);
223
247
  }
248
+ // Every id actually read this replay (applied or skip-applied) is
249
+ // resolved — a live pass must never redeliver it. Anything surviving
250
+ // that subtraction is capped below the written cursor: a gap at or
251
+ // above it is the live dispatcher's own `id > cursor` territory, not
252
+ // pending_gaps'. No events replayed (cursor stays 0) means no gap can
253
+ // sit below it either.
254
+ const survivingGaps = subtractSortedIdsFromRanges(seededGaps, replayedIds);
255
+ const cappedGaps = capPendingGapsBelowCursor(survivingGaps, lastProcessedEventId);
256
+ const postReplayXmax = cappedGaps.length > 0 ? await selectSnapshotXmax(tx) : null;
257
+ const pendingGaps =
258
+ postReplayXmax === null
259
+ ? cappedGaps
260
+ : cappedGaps.map((gap) => ({ ...gap, xmax: postReplayXmax }));
224
261
  await updateConsumerRebuildCursor(
225
262
  tx,
226
263
  mspName,
227
264
  SHARED_INSTANCE_SENTINEL,
228
265
  lastProcessedEventId,
266
+ pendingGaps,
229
267
  );
230
268
  await swapShadowIntoLive(tx, meta.tableName);
231
269
  });
@@ -70,3 +70,51 @@ export function splitRangeExcludingIds(
70
70
  if (cursor <= to) parts.push({ from: cursor.toString(), to: to.toString(), xmax: range.xmax });
71
71
  return parts;
72
72
  }
73
+
74
+ // Same carve-out as splitRangeExcludingIds, but for many ranges against many
75
+ // ids at once — an MSP rebuild's replay reads events in ascending id order,
76
+ // so a single forward pointer over `sortedIds` (both inputs already sorted,
77
+ // ranges non-overlapping) avoids scanning the full id list per range.
78
+ export function subtractSortedIdsFromRanges(
79
+ ranges: readonly PendingGapEntry[],
80
+ sortedIds: readonly bigint[],
81
+ ): PendingGapEntry[] {
82
+ if (sortedIds.length === 0) return [...ranges];
83
+ const result: PendingGapEntry[] = [];
84
+ let cursor = 0;
85
+ for (const range of ranges) {
86
+ const from = BigInt(range.from);
87
+ const to = BigInt(range.to);
88
+ let id = sortedIds[cursor];
89
+ while (id !== undefined && id < from) {
90
+ cursor++;
91
+ id = sortedIds[cursor];
92
+ }
93
+ const idsInRange: bigint[] = [];
94
+ while (id !== undefined && id <= to) {
95
+ idsInRange.push(id);
96
+ cursor++;
97
+ id = sortedIds[cursor];
98
+ }
99
+ result.push(...splitRangeExcludingIds(range, idsInRange));
100
+ }
101
+ return result;
102
+ }
103
+
104
+ // Ranges above the written cursor belong to the live dispatcher's own
105
+ // `id > cursor` territory; a range straddling it is truncated at cursor - 1.
106
+ export function capPendingGapsBelowCursor(
107
+ gaps: readonly PendingGapEntry[],
108
+ cursor: bigint,
109
+ ): PendingGapEntry[] {
110
+ if (cursor <= 0n) return [];
111
+ const capped: PendingGapEntry[] = [];
112
+ for (const gap of gaps) {
113
+ const from = BigInt(gap.from);
114
+ if (from >= cursor) continue;
115
+ const to = BigInt(gap.to);
116
+ const cappedTo = to >= cursor ? cursor - 1n : to;
117
+ capped.push({ ...gap, to: cappedTo.toString() });
118
+ }
119
+ return capped;
120
+ }