@cosmicdrift/kumiko-framework 0.306.0 → 0.308.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 (178) hide show
  1. package/package.json +4 -4
  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 +133 -0
  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 +144 -1
  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 +57 -3
  28. package/src/api/request-context.ts +24 -0
  29. package/src/api/server.ts +11 -0
  30. package/src/api/sse-broker.ts +69 -12
  31. package/src/changes.json +63 -0
  32. package/src/compliance/override-schema.ts +1 -1
  33. package/src/crypto/__tests__/event-pii.test.ts +1 -1
  34. package/src/crypto/kms-adapter.ts +1 -1
  35. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
  36. package/src/db/queries/event-consumer.ts +155 -9
  37. package/src/db/queries/event-store.ts +69 -0
  38. package/src/db/tenant-db.ts +43 -5
  39. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
  40. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +1 -1
  41. package/src/engine/__tests__/boot-validator-dashboard.test.ts +1 -1
  42. package/src/engine/__tests__/boot-validator-nav-area.test.ts +1 -1
  43. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
  44. package/src/engine/__tests__/boot-validator-projection-list.test.ts +1 -1
  45. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +1 -1
  46. package/src/engine/__tests__/boot-validator-query-refs.test.ts +1 -1
  47. package/src/engine/__tests__/boot-validator-ref-entity.test.ts +1 -1
  48. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +1 -1
  49. package/src/engine/__tests__/boot-validator.test.ts +35 -1
  50. package/src/engine/__tests__/build-app-schema.test.ts +1 -1
  51. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +1 -1
  52. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +1 -1
  53. package/src/engine/__tests__/engine.test.ts +1 -1
  54. package/src/engine/__tests__/event-helpers.test.ts +1 -1
  55. package/src/engine/__tests__/event-migration-declarative.test.ts +1 -1
  56. package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
  57. package/src/engine/__tests__/extends-registrar.test.ts +1 -1
  58. package/src/engine/__tests__/hook-phases.test.ts +1 -1
  59. package/src/engine/__tests__/lifecycle-hooks.test.ts +1 -1
  60. package/src/engine/__tests__/pipeline-engine.test.ts +1 -1
  61. package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
  62. package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
  63. package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
  64. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +1 -1
  65. package/src/engine/__tests__/post-query-hook.test.ts +1 -1
  66. package/src/engine/__tests__/projection-detail-actions.test.ts +1 -1
  67. package/src/engine/__tests__/projection-detail-singleton.test.ts +1 -1
  68. package/src/engine/__tests__/projection-detail-tabs.test.ts +1 -1
  69. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +1 -1
  70. package/src/engine/__tests__/registry.test.ts +1 -1
  71. package/src/engine/__tests__/screen.test.ts +1 -1
  72. package/src/engine/__tests__/validate-projection-allowlist.test.ts +1 -1
  73. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +1 -1
  74. package/src/engine/boot-validator/__tests__/action-icon.test.ts +81 -0
  75. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +1 -1
  76. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +1 -1
  77. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +1 -1
  78. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +1 -1
  79. package/src/engine/boot-validator/screens.ts +83 -0
  80. package/src/engine/define-handler.ts +2 -1
  81. package/src/engine/entity-handlers.ts +2 -1
  82. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +1 -1
  83. package/src/engine/feature-ast/pattern-change-schema.ts +1 -1
  84. package/src/engine/feature-builder-state.ts +2 -1
  85. package/src/engine/feature-config-events-jobs.ts +2 -1
  86. package/src/engine/feature-entity-handlers.ts +2 -1
  87. package/src/engine/schema-builder.ts +1 -1
  88. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
  89. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -1
  90. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
  91. package/src/env/__tests__/compose-env-schema.test.ts +1 -1
  92. package/src/env/__tests__/dry-run.test.ts +1 -1
  93. package/src/env/__tests__/parse-env-dry-run.test.ts +1 -1
  94. package/src/env/_zod-introspect.ts +1 -1
  95. package/src/env/dry-run.ts +1 -1
  96. package/src/env/index.ts +1 -1
  97. package/src/errors/__tests__/classes.test.ts +1 -1
  98. package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
  99. package/src/event-store/__tests__/backfill-pii.integration.test.ts +1 -1
  100. package/src/event-store/__tests__/event-attribution.integration.test.ts +54 -4
  101. package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
  102. package/src/event-store/admin-api.ts +5 -0
  103. package/src/event-store/event-store.ts +16 -7
  104. package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -1
  105. package/src/jobs/__tests__/job-event-trigger.integration.test.ts +1 -1
  106. package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +1 -1
  107. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
  108. package/src/jobs/__tests__/job-retention.integration.test.ts +1 -1
  109. package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -1
  110. package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
  111. package/src/jobs/job-runner.ts +61 -6
  112. package/src/observability/__tests__/metrics-wiring.test.ts +1 -1
  113. package/src/observability/__tests__/observability.integration.test.ts +1 -1
  114. package/src/observability/metrics-wiring.ts +1 -1
  115. package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
  116. package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
  117. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +1 -1
  118. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +1 -1
  119. package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
  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-commit-order.integration.test.ts +278 -0
  129. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
  130. package/src/pipeline/__tests__/event-dispatcher-idle.integration.test.ts +94 -0
  131. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
  132. package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
  133. package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +103 -3
  134. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
  135. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +1 -1
  136. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +1 -1
  137. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  138. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
  139. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
  140. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +116 -1
  141. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +1 -1
  142. package/src/pipeline/__tests__/pending-gap-ranges.test.ts +94 -0
  143. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +1 -1
  144. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +262 -3
  145. package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
  146. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +1 -1
  147. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -1
  148. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +1 -1
  149. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +1 -1
  150. package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
  151. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +1 -1
  152. package/src/pipeline/dispatch-batch.ts +49 -19
  153. package/src/pipeline/dispatch-stream.ts +12 -3
  154. package/src/pipeline/dispatcher-utils.ts +21 -2
  155. package/src/pipeline/dispatcher.ts +71 -6
  156. package/src/pipeline/event-consumer-state.ts +26 -0
  157. package/src/pipeline/event-dispatcher-admin.ts +32 -5
  158. package/src/pipeline/event-dispatcher-delivery.ts +212 -65
  159. package/src/pipeline/event-dispatcher.ts +253 -49
  160. package/src/pipeline/index.ts +1 -0
  161. package/src/pipeline/msp-rebuild.ts +39 -1
  162. package/src/pipeline/pending-gap-ranges.ts +120 -0
  163. package/src/pipeline/system-hooks.ts +226 -64
  164. package/src/pipeline/write-origin.ts +31 -10
  165. package/src/rate-limit/__tests__/dispatcher-l3.integration.test.ts +1 -1
  166. package/src/search/__tests__/derive-search-adapter-config.test.ts +65 -0
  167. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +91 -0
  168. package/src/search/__tests__/search-consumer-batch.integration.test.ts +306 -0
  169. package/src/search/derive-search-adapter-config.ts +27 -0
  170. package/src/search/index.ts +1 -0
  171. package/src/search/meilisearch-adapter.ts +96 -14
  172. package/src/secrets/__tests__/contains-secret.test.ts +1 -1
  173. package/src/stack/__tests__/request-helper.integration.test.ts +1 -1
  174. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
  175. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +1 -1
  176. package/src/stack/test-stack.ts +1 -1
  177. package/src/testing/__tests__/e2e-generator.test.ts +1 -1
  178. package/src/testing/e2e-generator.ts +1 -1
@@ -1,4 +1,5 @@
1
1
  import type { DbTx, PgClient } from "../db/connection";
2
+ import { selectSnapshotXmax, selectSnapshotXmin } from "../db/queries/event-consumer";
2
3
  import type { AppContext } from "../engine/types";
3
4
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
4
5
  import { EVENTS_PUBSUB_CHANNEL, type StoredEvent } from "../event-store";
@@ -11,18 +12,22 @@ import {
11
12
  type Meter,
12
13
  type Tracer,
13
14
  } from "../observability";
14
- import { SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
15
+ import { type PendingGapEntry, SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
15
16
  import {
16
17
  acquireConsumerState,
17
18
  consumerInstanceId,
19
+ type DeliveryOutcome,
18
20
  deliverEvents,
19
21
  emitLagFromTx,
20
22
  fetchPendingEvents,
21
23
  markProcessing,
24
+ type PersistedConsumerOutcome,
22
25
  persistConsumerOutcome,
23
26
  persistConsumerPassFailure,
24
27
  preRegisterConsumers,
28
+ selectIdleConsumerKeys,
25
29
  } from "./event-dispatcher-delivery";
30
+ import { partitionBurntGaps, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges";
26
31
 
27
32
  // Async event-dispatcher — the "AsyncDaemon"-pendant for Kumiko.
28
33
  //
@@ -37,7 +42,9 @@ import {
37
42
  // 2. SELECT state row FOR UPDATE SKIP LOCKED
38
43
  // — multi-instance-safe: if another poller holds the lock, this pass
39
44
  // skips this consumer and tries the next. No duplicate delivery.
40
- // 3. SELECT events WHERE id > lastProcessedEventId ORDER BY id ASC LIMIT batchSize
45
+ // 3. SELECT events WHERE id > lastProcessedEventId, PLUS any ranges still
46
+ // tracked in pending_gaps (ids invisible on an earlier turn that may
47
+ // have committed since) — ORDER BY id ASC LIMIT batchSize
41
48
  // 4. For each event: call the consumer's handler
42
49
  // - handler throws → increment attempts, mark status="dead" at
43
50
  // maxAttempts, surface lastError, STOP this consumer's pass
@@ -61,6 +68,21 @@ import {
61
68
 
62
69
  export type EventConsumerHandler = (event: StoredEvent, ctx: AppContext) => Promise<void>;
63
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
+
64
86
  // Per-consumer error policy. When skipApplyErrors is true and handler throws,
65
87
  // the dispatcher logs the error, advances the cursor past the offending event,
66
88
  // and keeps delivering — instead of the default retry + dead-letter flow.
@@ -78,6 +100,9 @@ export type EventConsumerErrorPolicy = {
78
100
  export type EventConsumer = {
79
101
  readonly name: string;
80
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;
81
106
  readonly errorPolicy?: EventConsumerErrorPolicy;
82
107
  // Owning feature — when present, the dispatcher skips this consumer's
83
108
  // pass while the feature is globally disabled. Events remain in the store
@@ -240,6 +265,15 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
240
265
  // entry — the next failure (if any) starts a fresh backoff from scratch.
241
266
  const consumerBackoff = new Map<string, { consecutiveFailures: number; retryAtMs: number }>();
242
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
+
243
277
  let running = false;
244
278
  // Separate from `running` on purpose: pre-registration of consumer state
245
279
  // rows is a one-time boot action, while running/timer/LISTEN is a
@@ -255,17 +289,100 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
255
289
  // NOTIFYs (subscription drop, crash mid-commit).
256
290
  let pgUnlisten: (() => Promise<void>) | null = null;
257
291
 
258
- // Serialises concurrent runOnce() calls from both wake-up sources (timer
259
- // + any future explicit nudge). Mirrors outbox-poller's passInFlight
260
- // pattern so behaviour under races stays predictable.
261
- let passInFlight: Promise<DispatcherPassResult> | null = null;
292
+ // Bounds how many consumer turns hold a DB transaction at once. Each turn's
293
+ // db.begin() checks out one pool connection, plus any writes the handler
294
+ // itself makes via context.db — the app db pool defaults to postgres-js's
295
+ // max=10 (db/connection.ts, no DATABASE_POOL_MAX override) and is shared
296
+ // with HTTP request handlers, so turns can't be allowed to claim it all.
297
+ // A pool no larger than this limit can deadlock: every connection held by
298
+ // an open turn TX while each handler waits for one more.
299
+ const MAX_CONCURRENT_CONSUMER_TURNS = 4;
300
+ let activeConsumerTurns = 0;
301
+ const consumerTurnWaiters: Array<() => void> = [];
302
+
303
+ async function acquireConsumerTurnSlot(): Promise<() => void> {
304
+ if (activeConsumerTurns >= MAX_CONCURRENT_CONSUMER_TURNS) {
305
+ // The releasing turn hands its slot over directly (count unchanged),
306
+ // so a synchronous acquire in between cannot overshoot the limit.
307
+ await new Promise<void>((resolve) => consumerTurnWaiters.push(resolve));
308
+ } else {
309
+ activeConsumerTurns++;
310
+ }
311
+ let released = false;
312
+ return () => {
313
+ // skip: already released — finally-blocks calling this twice must not
314
+ // free the same slot twice
315
+ if (released) return;
316
+ released = true;
317
+ const nextWaiter = consumerTurnWaiters.shift();
318
+ if (nextWaiter) nextWaiter();
319
+ else activeConsumerTurns--;
320
+ };
321
+ }
262
322
 
263
- async function drainPassInFlight(): Promise<void> {
264
- if (passInFlight) {
265
- await passInFlight.catch(() => {
266
- // skip: errors already recorded per-consumer inside the pass
267
- });
323
+ // Per-(consumer, instanceId) turn guard, so one slow consumer never holds
324
+ // back the others. Keyed like consumerBackoff: a consumer already running
325
+ // its turn hands back that same promise instead of starting a second one.
326
+ const inFlightTurns = new Map<string, Promise<{ processed: number; failed: number }>>();
327
+
328
+ async function runConsumerTurn(
329
+ consumer: EventConsumer,
330
+ effective: ReadonlySet<string> | undefined,
331
+ idleKeys: ReadonlySet<string>,
332
+ ): Promise<{ processed: number; failed: number }> {
333
+ const key = `${consumer.name}:${consumerInstanceId(consumer, options.instanceId)}`;
334
+ const existing = inFlightTurns.get(key);
335
+ if (existing) return existing;
336
+
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.
341
+ if (effective && consumer.featureName && !effective.has(consumer.featureName)) {
342
+ return { processed: 0, failed: 0 };
343
+ }
344
+ const backoff = consumerBackoff.get(key);
345
+ if (backoff && backoff.retryAtMs > Date.now()) {
346
+ return { processed: 0, failed: 0 };
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 };
268
358
  }
359
+
360
+ // Registered before awaiting the slot — a turn queued behind the
361
+ // concurrency limit still counts as in-flight, so it isn't started twice.
362
+ const turn = (async () => {
363
+ const releaseSlot = await acquireConsumerTurnSlot();
364
+ try {
365
+ return await processConsumer(consumer);
366
+ } finally {
367
+ releaseSlot();
368
+ }
369
+ })();
370
+ inFlightTurns.set(key, turn);
371
+ try {
372
+ return await turn;
373
+ } finally {
374
+ if (inFlightTurns.get(key) === turn) inFlightTurns.delete(key);
375
+ }
376
+ }
377
+
378
+ async function drainInFlightTurns(): Promise<void> {
379
+ await Promise.all(
380
+ [...inFlightTurns.values()].map((turn) =>
381
+ turn.catch(() => {
382
+ // skip: errors already recorded per-consumer inside the pass
383
+ }),
384
+ ),
385
+ );
269
386
  }
270
387
 
271
388
  async function runOnce(): Promise<DispatcherPassResult> {
@@ -274,13 +391,7 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
274
391
  "EventDispatcher.runOnce() called before start() — consumer state rows are not registered. Call start() first (production) or ensureRegistered() (tests after truncating kumiko_event_consumers).",
275
392
  );
276
393
  }
277
- if (passInFlight) return passInFlight;
278
- passInFlight = doPass();
279
- try {
280
- return await passInFlight;
281
- } finally {
282
- passInFlight = null;
283
- }
394
+ return doPass();
284
395
  }
285
396
 
286
397
  async function doPass(): Promise<DispatcherPassResult> {
@@ -301,30 +412,58 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
301
412
  // SYSTEM_TENANT_ID returnt (typisch: union-of-all-tier-features).
302
413
  const effective = context.effectiveFeatures?.(SYSTEM_TENANT_ID);
303
414
 
304
- // Seriell pro consumer. Parallelisierung wäre möglich (je eigene TX), aber
305
- // das einfache Modell reicht für v1 — jeder consumer hat geringe
306
- // per-event-Arbeit (network call at worst). Bei hunderten Events pro
307
- // Batch lohnt sich Parallelisierung — Optimierung für später.
308
- for (const consumer of consumers) {
309
- // Feature-gate: consumers tagged with a featureName get paused while
310
- // that feature is globally disabled. Cursor stays put — events accumulate
311
- // and are re-delivered in order when the feature is re-enabled.
312
- if (effective && consumer.featureName && !effective.has(consumer.featureName)) {
313
- byConsumer[consumer.name] = { processed: 0, failed: 0 };
314
- continue;
315
- }
316
- // Backoff-gate: a consumer whose last pass threw waits out its
317
- // exponential delay before being tried again — see the catch in
318
- // processConsumer. Other consumers are unaffected; only this one's
319
- // turn is skipped this tick.
320
- const backoffKey = `${consumer.name}:${consumerInstanceId(consumer, options.instanceId)}`;
321
- const backoff = consumerBackoff.get(backoffKey);
322
- if (backoff && backoff.retryAtMs > Date.now()) {
323
- byConsumer[consumer.name] = { processed: 0, failed: 0 };
324
- continue;
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
+ idlePreCheckErrorLogged = false;
438
+ } catch (e) {
439
+ idleKeys = new Set();
440
+ // Log once per outage, not once per poll tick — see
441
+ // idlePreCheckErrorLogged above. Every consumer still gets delivered
442
+ // correctly this tick (fallback above), so this is a degradation
443
+ // signal for ops, not a delivery failure.
444
+ if (!idlePreCheckErrorLogged) {
445
+ idlePreCheckErrorLogged = true;
446
+ const msg = e instanceof Error ? e.message : String(e);
447
+ const logMsg = `[event-dispatcher] idle pre-check failed, falling back to per-consumer locking every tick until it recovers: ${msg}`;
448
+ if (context.log) {
449
+ context.log.error(logMsg);
450
+ } else {
451
+ console.error(logMsg);
452
+ }
325
453
  }
326
- const perConsumer = await processConsumer(consumer);
327
- byConsumer[consumer.name] = perConsumer;
454
+ }
455
+
456
+ // Every consumer runs its own turn concurrently (own TX, own cursor,
457
+ // own backoff/feature-gate/idle-gate) — a slow consumer no longer
458
+ // blocks the others' delivery until it commits, see runConsumerTurn.
459
+ const results = await Promise.all(
460
+ consumers.map(async (consumer) => {
461
+ const perConsumer = await runConsumerTurn(consumer, effective, idleKeys);
462
+ return [consumer.name, perConsumer] as const;
463
+ }),
464
+ );
465
+ for (const [name, perConsumer] of results) {
466
+ byConsumer[name] = perConsumer;
328
467
  totalProcessed += perConsumer.processed;
329
468
  totalFailed += perConsumer.failed;
330
469
  }
@@ -376,20 +515,85 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
376
515
  // staying permanently suppressed by this process's Set.
377
516
  reportedDeadConsumers.delete(`${consumer.name}:${instanceId}`);
378
517
 
379
- const events = await fetchPendingEvents(tx, acquired.state.lastProcessedEventId, batchSize);
380
- // skip: nothing to deliver — no markProcessing/persistConsumerOutcome write,
381
- // so an idle consumer doesn't burn a WAL record on every poll tick.
382
- if (events.length === 0) {
518
+ const oldCursor = acquired.state.lastProcessedEventId;
519
+ const pendingGaps = acquired.state.pendingGaps;
520
+ // xmin BEFORE the fetch: a range is provably burnt only once this has
521
+ // passed the xmax recorded with it. Only needed when ranges exist.
522
+ const xminNow = pendingGaps.length > 0 ? await selectSnapshotXmin(tx) : "0";
523
+ const events = await fetchPendingEvents(tx, oldCursor, batchSize, toIdRanges(pendingGaps));
524
+ const fetchedIds = events.map((e) => e.id);
525
+ const truncated = events.length === batchSize;
526
+
527
+ // Burnt: no visible id this turn, the fetch is proven to have
528
+ // covered the range (LIMIT can otherwise cut it off early), and
529
+ // xmin passed its recorded xmax — every xact that could still
530
+ // produce a row has finished without one ever appearing.
531
+ const { burnt: burntGaps, surviving: survivingGaps } = partitionBurntGaps(
532
+ pendingGaps,
533
+ fetchedIds,
534
+ truncated,
535
+ xminNow,
536
+ );
537
+
538
+ // skip: nothing to deliver and no burnt gap to clean up. The
539
+ // doPass idle pre-check (selectIdleConsumerKeys) already keeps the
540
+ // common idle case out of this transaction entirely — reaching
541
+ // here with an empty fetch means the pre-check couldn't prove
542
+ // idleness (pending gaps forced a fetch, or a race window). By this
543
+ // point acquireConsumerState's FOR UPDATE has already taken the
544
+ // lock, so skipping markProcessing/persistConsumerOutcome only
545
+ // avoids extra writes, not that lock's own WAL record.
546
+ if (events.length === 0 && burntGaps.length === 0) {
383
547
  span.setAttribute("consumer.skip_reason", "no_pending_events");
384
548
  return;
385
549
  }
386
550
  await markProcessing(tx, consumer.name, instanceId);
387
551
 
388
- const outcome = await deliverEvents(consumer, events, context, maxAttempts, acquired.state);
552
+ const outcome: DeliveryOutcome =
553
+ events.length > 0
554
+ ? await deliverEvents(consumer, events, context, maxAttempts, acquired.state)
555
+ : {
556
+ cursor: oldCursor,
557
+ attempts: acquired.state.attempts,
558
+ lastError: acquired.state.lastError,
559
+ deadLettered: false,
560
+ processed: 0,
561
+ failed: 0,
562
+ resolvedPendingIds: [],
563
+ };
389
564
  processed = outcome.processed;
390
565
  failed = outcome.failed;
391
566
 
392
- await persistConsumerOutcome(tx, consumer.name, instanceId, outcome);
567
+ // Carve delivered/skip-applied ids out of the surviving ranges —
568
+ // an id still fetched-but-unresolved (halt-on-poison stopped before
569
+ // it) stays put, since it's below the cursor and only pending_gaps
570
+ // will ever retry it.
571
+ const keptGaps = survivingGaps.flatMap((gap) =>
572
+ splitRangeExcludingIds(gap, outcome.resolvedPendingIds),
573
+ );
574
+ // New gaps as ranges between consecutive fetched ids, so a huge id
575
+ // jump (retention prune) costs one entry, not one per missing id.
576
+ // Their xmax is read after the fetch; any later read is only more
577
+ // conservative, so it is fetched lazily.
578
+ const newWindowIds = fetchedIds.filter((id) => id > oldCursor && id <= outcome.cursor);
579
+ const newGapBounds: Array<readonly [bigint, bigint]> = [];
580
+ let prev = oldCursor;
581
+ for (const id of newWindowIds) {
582
+ if (id > prev + 1n) newGapBounds.push([prev + 1n, id - 1n]);
583
+ prev = id;
584
+ }
585
+ const xmaxNow = newGapBounds.length > 0 ? await selectSnapshotXmax(tx) : "";
586
+ const newGaps: PendingGapEntry[] = newGapBounds.map(([from, to]) => ({
587
+ from: from.toString(),
588
+ to: to.toString(),
589
+ xmax: xmaxNow,
590
+ }));
591
+ const persistedOutcome: PersistedConsumerOutcome = {
592
+ ...outcome,
593
+ pendingGaps: [...keptGaps, ...newGaps],
594
+ };
595
+
596
+ await persistConsumerOutcome(tx, consumer.name, instanceId, persistedOutcome);
393
597
  await emitLagFromTx(tx, consumer.name, instanceId, outcome.cursor, meter);
394
598
  });
395
599
 
@@ -544,7 +748,7 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
544
748
  }
545
749
 
546
750
  // Drain any in-flight pass so shutdown observes consistent state.
547
- await drainPassInFlight();
751
+ await drainInFlightTurns();
548
752
  // preRegistered stays true — the rows survive stop(). runOnce()
549
753
  // after a stop() still works (tests stop the timer and then drain
550
754
  // deterministically).
@@ -555,7 +759,7 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
555
759
  preRegistered = true;
556
760
  },
557
761
 
558
- drain: drainPassInFlight,
762
+ drain: drainInFlightTurns,
559
763
 
560
764
  runOnce,
561
765
  };
@@ -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
  });
@@ -0,0 +1,120 @@
1
+ import type { PendingIdRange } from "../db/queries/event-store";
2
+ import type { PendingGapEntry } from "./event-consumer-state";
3
+
4
+ export function toIdRanges(gaps: readonly PendingGapEntry[]): PendingIdRange[] {
5
+ return gaps.map((g) => ({ from: BigInt(g.from), to: BigInt(g.to) }));
6
+ }
7
+
8
+ export function rangeContainsId(range: PendingGapEntry, id: bigint): boolean {
9
+ return id >= BigInt(range.from) && id <= BigInt(range.to);
10
+ }
11
+
12
+ // A range is only provably burnt (rolled back, not just slow) once it's
13
+ // certain the fetch would have surfaced any of its ids had they been
14
+ // visible. LIMIT can cut a fetch off before reaching the range's ids —
15
+ // `to < maxFetchedId` proves the scan passed the range regardless of the
16
+ // cutoff, since ORDER BY id ASC LIMIT N always includes every matching id
17
+ // below the largest one it did return.
18
+ export function isRangeFullyCoveredByFetch(
19
+ range: PendingGapEntry,
20
+ truncated: boolean,
21
+ maxFetchedId: bigint | null,
22
+ ): boolean {
23
+ if (!truncated) return true;
24
+ if (maxFetchedId === null) return false;
25
+ return BigInt(range.to) < maxFetchedId;
26
+ }
27
+
28
+ // A gap is burnt when none of its ids showed up in this turn's fetch, the
29
+ // fetch is proven to have covered it, and its recorded xmax is behind this
30
+ // turn's xmin (every xact that could still produce a row has finished).
31
+ export function partitionBurntGaps(
32
+ gaps: readonly PendingGapEntry[],
33
+ fetchedIds: readonly bigint[],
34
+ truncated: boolean,
35
+ xminNow: string,
36
+ ): { readonly burnt: PendingGapEntry[]; readonly surviving: PendingGapEntry[] } {
37
+ const maxFetchedId = fetchedIds.at(-1) ?? null;
38
+ const burnt: PendingGapEntry[] = [];
39
+ const surviving: PendingGapEntry[] = [];
40
+ for (const gap of gaps) {
41
+ const hasVisibleId = fetchedIds.some((id) => rangeContainsId(gap, id));
42
+ const isBurnt =
43
+ !hasVisibleId &&
44
+ isRangeFullyCoveredByFetch(gap, truncated, maxFetchedId) &&
45
+ BigInt(gap.xmax) <= BigInt(xminNow);
46
+ (isBurnt ? burnt : surviving).push(gap);
47
+ }
48
+ return { burnt, surviving };
49
+ }
50
+
51
+ // Carves `excludeIds` (delivered or skip-applied this turn) out of `range`,
52
+ // yielding 0-2 sub-ranges. xmax is preserved on every surviving piece.
53
+ export function splitRangeExcludingIds(
54
+ range: PendingGapEntry,
55
+ excludeIds: readonly bigint[],
56
+ ): PendingGapEntry[] {
57
+ const from = BigInt(range.from);
58
+ const to = BigInt(range.to);
59
+ const inRange = excludeIds
60
+ .filter((id) => id >= from && id <= to)
61
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
62
+ if (inRange.length === 0) return [range];
63
+ const parts: PendingGapEntry[] = [];
64
+ let cursor = from;
65
+ for (const id of inRange) {
66
+ if (id > cursor)
67
+ parts.push({ from: cursor.toString(), to: (id - 1n).toString(), xmax: range.xmax });
68
+ cursor = id + 1n;
69
+ }
70
+ if (cursor <= to) parts.push({ from: cursor.toString(), to: to.toString(), xmax: range.xmax });
71
+ return parts;
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
+ }