@cosmicdrift/kumiko-framework 0.306.0 → 0.307.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 (33) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/redis-sse-broker.integration.test.ts +66 -0
  3. package/src/api/__tests__/sse-broker.test.ts +49 -0
  4. package/src/api/redis-sse-broker.ts +17 -3
  5. package/src/api/request-context.ts +24 -0
  6. package/src/api/sse-broker.ts +29 -11
  7. package/src/changes.json +26 -0
  8. package/src/db/queries/event-consumer.ts +57 -3
  9. package/src/db/queries/event-store.ts +69 -0
  10. package/src/db/tenant-db.ts +43 -5
  11. package/src/event-store/__tests__/event-attribution.integration.test.ts +53 -3
  12. package/src/event-store/admin-api.ts +5 -0
  13. package/src/event-store/event-store.ts +16 -7
  14. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
  15. package/src/jobs/job-runner.ts +61 -6
  16. package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
  17. package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
  18. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
  19. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
  20. package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
  21. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
  22. package/src/pipeline/dispatch-batch.ts +49 -19
  23. package/src/pipeline/dispatch-stream.ts +7 -3
  24. package/src/pipeline/dispatcher-utils.ts +21 -2
  25. package/src/pipeline/dispatcher.ts +71 -6
  26. package/src/pipeline/event-consumer-state.ts +26 -0
  27. package/src/pipeline/event-dispatcher-admin.ts +32 -5
  28. package/src/pipeline/event-dispatcher-delivery.ts +109 -57
  29. package/src/pipeline/event-dispatcher.ts +167 -50
  30. package/src/pipeline/pending-gap-ranges.ts +72 -0
  31. package/src/pipeline/system-hooks.ts +8 -1
  32. package/src/pipeline/write-origin.ts +31 -10
  33. package/src/stack/test-stack.ts +1 -1
@@ -1,13 +1,15 @@
1
1
  import type { DbConnection, DbTx } from "../db/connection";
2
2
  import {
3
3
  advanceConsumerPastEventReturning,
4
+ removePendingGapReturning,
4
5
  updateConsumerStatusReturning,
5
6
  } from "../db/queries/event-consumer";
6
- import { selectNextEventIdAfter } from "../db/queries/event-store";
7
+ import { selectNextEventIdAfter, selectSmallestVisibleIdInRanges } from "../db/queries/event-store";
7
8
  import { coerceRow, extractTableInfo, selectMany } from "../db/query";
8
9
  import { getEventsHighWaterMark } from "../event-store";
9
10
  import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
10
11
  import type { ConsumerStateRow, ConsumerStateRowShape } from "./event-dispatcher-delivery";
12
+ import { rangeContainsId, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges";
11
13
 
12
14
  // --- Ops recovery surface ---
13
15
  //
@@ -129,10 +131,13 @@ export async function enableConsumer(
129
131
  return applyConsumerStatusTransition(db, name, instanceId, "idle");
130
132
  }
131
133
 
132
- // skipPoisonEvent advances the cursor past the first event after the
133
- // current cursor. Single TX so concurrent dispatcher passes can't double-
134
- // advance. If no event exists past the cursor, there is nothing to skip —
135
- // treat as idempotent no-op (cursor already at head).
134
+ // skipPoisonEvent advances past whatever is currently blocking the consumer.
135
+ // If the smallest visible id inside pending_gaps exists, THAT'S the poison —
136
+ // halt-on-poison walks gap ids before any id past the cursor — so it's split
137
+ // out of its range directly; the cursor stays put (it's already above this
138
+ // id). Otherwise the poison is the usual next event after the cursor. Single
139
+ // TX so concurrent dispatcher passes can't double-advance. Neither exists →
140
+ // idempotent no-op.
136
141
  export async function skipPoisonEvent(
137
142
  db: DbConnection,
138
143
  name: string,
@@ -140,6 +145,28 @@ export async function skipPoisonEvent(
140
145
  ): Promise<ConsumerRecoveryState & { readonly skippedEventId: bigint | null }> {
141
146
  const before = await requireConsumerRow(db, name, instanceId);
142
147
  return db.begin(async (tx: DbTx) => {
148
+ const pendingGaps = before.pendingGaps;
149
+ const smallestVisiblePending = await selectSmallestVisibleIdInRanges(
150
+ tx,
151
+ toIdRanges(pendingGaps),
152
+ );
153
+
154
+ if (smallestVisiblePending !== null) {
155
+ const newPendingGaps = pendingGaps.flatMap((gap) =>
156
+ rangeContainsId(gap, smallestVisiblePending)
157
+ ? splitRangeExcludingIds(gap, [smallestVisiblePending])
158
+ : [gap],
159
+ );
160
+ const raw = await removePendingGapReturning(tx, name, instanceId, newPendingGaps);
161
+ const updated =
162
+ raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
163
+ if (!updated)
164
+ throw new Error(
165
+ `Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`,
166
+ );
167
+ return { ...normalizeConsumerState(updated), skippedEventId: smallestVisiblePending };
168
+ }
169
+
143
170
  const poisonId = await selectNextEventIdAfter(tx, before.lastProcessedEventId);
144
171
  if (poisonId === null) {
145
172
  const [unchanged] = await selectMany<ConsumerStateRow>(tx, eventConsumerStateTable, {
@@ -12,8 +12,12 @@ import {
12
12
  selectConsumerForUpdateSkipLocked,
13
13
  updateConsumerDeliveryOutcome,
14
14
  } from "../db/queries/event-consumer";
15
- import { selectEventsHeadId } from "../db/queries/event-store";
16
- import { coerceRow, extractTableInfo, selectMany } from "../db/query";
15
+ import {
16
+ type PendingIdRange,
17
+ selectEventsHeadId,
18
+ selectPendingAndNewEventRows,
19
+ } from "../db/queries/event-store";
20
+ import { coerceRow, extractTableInfo } from "../db/query";
17
21
  import { qnScope } from "../engine/qualified-name";
18
22
  import type { AppContext } from "../engine/types";
19
23
  import { eventsTable, toStoredEvent as rowToStoredEvent } from "../event-store";
@@ -26,9 +30,18 @@ import {
26
30
  import {
27
31
  ConsumerStatuses,
28
32
  eventConsumerStateTable,
33
+ type PendingGapEntry,
29
34
  SHARED_INSTANCE_SENTINEL,
30
35
  } from "./event-consumer-state";
31
36
  import type { EventConsumer } from "./event-dispatcher";
37
+ import { parseWriteOrigin } from "./write-origin";
38
+
39
+ // Fails closed without throwing: a throw would poison the event for every consumer.
40
+ const UNPARSEABLE_STORED_WRITE_ORIGIN = {
41
+ rootHandler: "<unknown>",
42
+ anonymousRoot: true,
43
+ publicIntake: false,
44
+ } as const;
32
45
 
33
46
  // Per-consumer pass mechanics: acquire the state row, fetch pending events,
34
47
  // hand them to the consumer's handler in order, persist the outcome. Split
@@ -46,6 +59,7 @@ export type ConsumerStateRowShape = {
46
59
  readonly status: string;
47
60
  readonly attempts: number;
48
61
  readonly rearmCount: number;
62
+ readonly pendingGaps: readonly PendingGapEntry[];
49
63
  readonly lastError: string | null;
50
64
  readonly updatedAt: Temporal.Instant;
51
65
  };
@@ -186,17 +200,20 @@ export async function markProcessing(tx: DbTx, name: string, instanceId: string)
186
200
  await markConsumerProcessing(tx, name, instanceId);
187
201
  }
188
202
 
203
+ // `pendingRanges` are id ranges below `cursor` the consumer is still
204
+ // watching as gaps (invisible on an earlier turn — see event-dispatcher.ts's
205
+ // processConsumer). Fetching them alongside the plain `id > cursor` window
206
+ // means a row that committed late becomes visible and deliverable the next
207
+ // time this consumer's turn runs, instead of being permanently skipped.
189
208
  export async function fetchPendingEvents(
190
209
  tx: DbTx,
191
210
  cursor: bigint,
192
211
  batchSize: number,
212
+ pendingRanges: readonly PendingIdRange[] = [],
193
213
  ): Promise<ReadonlyArray<StoredEventRow>> {
194
- return (await selectMany(
195
- tx,
196
- eventsTable,
197
- { id: { gt: cursor } },
198
- { orderBy: { col: "id", direction: "asc" }, limit: batchSize },
199
- )) as ReadonlyArray<StoredEventRow>; // @cast-boundary db-row
214
+ const rawRows = await selectPendingAndNewEventRows(tx, cursor, pendingRanges, batchSize);
215
+ const info = extractTableInfo(eventsTable);
216
+ return rawRows.map((row) => coerceRow(row, info) as StoredEventRow); // @cast-boundary db-row
200
217
  }
201
218
 
202
219
  export type DeliveryOutcome = {
@@ -206,6 +223,11 @@ export type DeliveryOutcome = {
206
223
  readonly deadLettered: boolean;
207
224
  readonly processed: number;
208
225
  readonly failed: number;
226
+ // Which of the *pending* ids in `events` (id <= the cursor this delivery
227
+ // started from) got resolved this pass — delivered or skip-applied. The
228
+ // caller (event-dispatcher.ts) splits exactly these out of pending_gaps;
229
+ // everything else in the input batch was a "new" row past the old cursor.
230
+ readonly resolvedPendingIds: readonly bigint[];
209
231
  };
210
232
 
211
233
  // Deliver events to the consumer's handler in events.id order. Halt-on-
@@ -221,83 +243,113 @@ export async function deliverEvents(
221
243
  maxAttempts: number,
222
244
  state: ConsumerStateRow,
223
245
  ): Promise<DeliveryOutcome> {
224
- let cursor = state.lastProcessedEventId;
246
+ const startCursor = state.lastProcessedEventId;
247
+ let cursor = startCursor;
225
248
  let attempts = state.attempts;
226
249
  let lastError: string | null = state.lastError ?? null;
227
250
  let deadLettered = false;
228
251
  const effectiveMaxAttempts = consumer.errorPolicy?.maxAttempts ?? maxAttempts;
229
252
  let processed = 0;
230
253
  let failed = 0;
254
+ const resolvedPendingIds: bigint[] = [];
255
+
256
+ // A pending row sits below startCursor: it resolves its gap but never moves
257
+ // the cursor backward. ORDER BY id walks all pending rows first.
258
+ const resolve = (id: bigint): void => {
259
+ if (id > cursor) cursor = id;
260
+ if (id <= startCursor) resolvedPendingIds.push(id);
261
+ attempts = 0;
262
+ lastError = null;
263
+ };
231
264
 
232
265
  for (const row of events) {
233
266
  try {
234
- // Propagate causation: if the handler calls ctx.appendEvent, the new
235
- // event should record THIS event as its cause. correlationId is
236
- // inherited unchanged — it survives the hop across streams by design.
237
- // requestId falls back to a fresh id because the dispatcher runs
238
- // outside any HTTP request (background poll), and a stable log-
239
- // correlation handle is still useful for debugging.
240
- const stored = rowToStoredEvent(row);
241
- const correlationId = stored.metadata.correlationId ?? requestContext.generateId();
242
- const causationId = String(stored.id);
243
- const requestId = requestContext.generateId();
244
- // #3043 — an event this apply writes is attributed to the consumer, not
245
- // to whatever wrote the triggering event; causationId already links back.
246
- await requestContext.run(
247
- {
248
- requestId,
249
- correlationId,
250
- causationId,
251
- handler: consumer.name,
252
- feature: consumer.featureName ?? qnScope(consumer.name),
253
- },
254
- async () => {
255
- await consumer.handler(stored, context);
256
- },
257
- );
258
- cursor = row.id;
259
- attempts = 0;
260
- lastError = null;
267
+ await applyEvent(consumer, row, context);
268
+ resolve(row.id);
261
269
  processed += 1;
262
270
  } catch (e) {
263
271
  const errMessage = e instanceof Error ? e.message : String(e);
272
+ failed += 1;
264
273
  if (consumer.errorPolicy?.skipApplyErrors) {
265
- // Best-effort mode: record the error on the skip counter so ops
266
- // can alert on a spike of skipped events, advance the cursor past
267
- // the bad event, keep going. The consumer stays "idle", not "dead".
268
- // Also emit a warn-level log line — the metric tells ops THAT events
269
- // are being dropped, the log tells them WHICH events. Without this
270
- // a poisoned-then-skipped event is invisible to forensic search.
271
- const errorClass = e instanceof Error ? e.constructor.name : "UnknownError";
272
- emitDispatcherError(context.meter ?? getFallbackMeter(), {
273
- handler: consumer.name,
274
- errorClass,
275
- });
276
- context.log?.warn(
277
- `event-dispatcher: ${consumer.name} skipped event ${row.id} (${errorClass}): ${errMessage}`,
278
- );
279
- cursor = row.id;
280
- attempts = 0;
281
- lastError = null;
282
- failed += 1;
274
+ reportSkippedEvent(consumer, row.id, e, errMessage, context);
275
+ resolve(row.id);
283
276
  continue;
284
277
  }
285
278
  attempts += 1;
286
279
  lastError = errMessage;
287
- failed += 1;
288
280
  if (attempts >= effectiveMaxAttempts) deadLettered = true;
289
281
  break;
290
282
  }
291
283
  }
292
284
 
293
- return { cursor, attempts, lastError, deadLettered, processed, failed };
285
+ return { cursor, attempts, lastError, deadLettered, processed, failed, resolvedPendingIds };
286
+ }
287
+
288
+ async function applyEvent(
289
+ consumer: EventConsumer,
290
+ row: StoredEventRow,
291
+ context: AppContext,
292
+ ): Promise<void> {
293
+ // Propagate causation: if the handler calls ctx.appendEvent, the new
294
+ // event should record THIS event as its cause. correlationId is
295
+ // inherited unchanged — it survives the hop across streams by design.
296
+ // requestId falls back to a fresh id because the dispatcher runs
297
+ // outside any HTTP request (background poll), and a stable log-
298
+ // correlation handle is still useful for debugging.
299
+ const stored = rowToStoredEvent(row);
300
+ const correlationId = stored.metadata.correlationId ?? requestContext.generateId();
301
+ const causationId = String(stored.id);
302
+ const requestId = requestContext.generateId();
303
+ // The job-trigger consumer's handleEvent stamps event-triggered jobs from this.
304
+ const rawStoredWriteOrigin = stored.metadata.writeOrigin;
305
+ const writeOrigin =
306
+ rawStoredWriteOrigin === undefined
307
+ ? undefined
308
+ : (parseWriteOrigin(rawStoredWriteOrigin) ?? UNPARSEABLE_STORED_WRITE_ORIGIN);
309
+ // #3043 — an event this apply writes is attributed to the consumer, not
310
+ // to whatever wrote the triggering event; causationId already links back.
311
+ await requestContext.run(
312
+ {
313
+ requestId,
314
+ correlationId,
315
+ causationId,
316
+ handler: consumer.name,
317
+ feature: consumer.featureName ?? qnScope(consumer.name),
318
+ writeOrigin,
319
+ },
320
+ async () => {
321
+ await consumer.handler(stored, context);
322
+ },
323
+ );
324
+ }
325
+
326
+ // Best-effort mode: record the error on the skip counter so ops can alert on
327
+ // a spike of skipped events; the consumer stays "idle", not "dead". The
328
+ // warn-level log line tells them WHICH events — without it a
329
+ // poisoned-then-skipped event is invisible to forensic search.
330
+ function reportSkippedEvent(
331
+ consumer: EventConsumer,
332
+ eventId: bigint,
333
+ e: unknown,
334
+ errMessage: string,
335
+ context: AppContext,
336
+ ): void {
337
+ const errorClass = e instanceof Error ? e.constructor.name : "UnknownError";
338
+ emitDispatcherError(context.meter ?? getFallbackMeter(), { handler: consumer.name, errorClass });
339
+ context.log?.warn(
340
+ `event-dispatcher: ${consumer.name} skipped event ${eventId} (${errorClass}): ${errMessage}`,
341
+ );
294
342
  }
295
343
 
344
+ export type PersistedConsumerOutcome = DeliveryOutcome & {
345
+ readonly pendingGaps: readonly PendingGapEntry[];
346
+ };
347
+
296
348
  export async function persistConsumerOutcome(
297
349
  tx: DbTx,
298
350
  name: string,
299
351
  instanceId: string,
300
- outcome: DeliveryOutcome,
352
+ outcome: PersistedConsumerOutcome,
301
353
  ): Promise<void> {
302
354
  await updateConsumerDeliveryOutcome(tx, name, instanceId, outcome);
303
355
  }
@@ -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,21 @@ 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,
25
28
  } from "./event-dispatcher-delivery";
29
+ import { partitionBurntGaps, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges";
26
30
 
27
31
  // Async event-dispatcher — the "AsyncDaemon"-pendant for Kumiko.
28
32
  //
@@ -37,7 +41,9 @@ import {
37
41
  // 2. SELECT state row FOR UPDATE SKIP LOCKED
38
42
  // — multi-instance-safe: if another poller holds the lock, this pass
39
43
  // skips this consumer and tries the next. No duplicate delivery.
40
- // 3. SELECT events WHERE id > lastProcessedEventId ORDER BY id ASC LIMIT batchSize
44
+ // 3. SELECT events WHERE id > lastProcessedEventId, PLUS any ranges still
45
+ // tracked in pending_gaps (ids invisible on an earlier turn that may
46
+ // have committed since) — ORDER BY id ASC LIMIT batchSize
41
47
  // 4. For each event: call the consumer's handler
42
48
  // - handler throws → increment attempts, mark status="dead" at
43
49
  // maxAttempts, surface lastError, STOP this consumer's pass
@@ -255,32 +261,96 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
255
261
  // NOTIFYs (subscription drop, crash mid-commit).
256
262
  let pgUnlisten: (() => Promise<void>) | null = null;
257
263
 
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;
264
+ // Bounds how many consumer turns hold a DB transaction at once. Each turn's
265
+ // db.begin() checks out one pool connection, plus any writes the handler
266
+ // itself makes via context.db — the app db pool defaults to postgres-js's
267
+ // max=10 (db/connection.ts, no DATABASE_POOL_MAX override) and is shared
268
+ // with HTTP request handlers, so turns can't be allowed to claim it all.
269
+ // A pool no larger than this limit can deadlock: every connection held by
270
+ // an open turn TX while each handler waits for one more.
271
+ const MAX_CONCURRENT_CONSUMER_TURNS = 4;
272
+ let activeConsumerTurns = 0;
273
+ const consumerTurnWaiters: Array<() => void> = [];
274
+
275
+ async function acquireConsumerTurnSlot(): Promise<() => void> {
276
+ if (activeConsumerTurns >= MAX_CONCURRENT_CONSUMER_TURNS) {
277
+ // The releasing turn hands its slot over directly (count unchanged),
278
+ // so a synchronous acquire in between cannot overshoot the limit.
279
+ await new Promise<void>((resolve) => consumerTurnWaiters.push(resolve));
280
+ } else {
281
+ activeConsumerTurns++;
282
+ }
283
+ let released = false;
284
+ return () => {
285
+ // skip: already released — finally-blocks calling this twice must not
286
+ // free the same slot twice
287
+ if (released) return;
288
+ released = true;
289
+ const nextWaiter = consumerTurnWaiters.shift();
290
+ if (nextWaiter) nextWaiter();
291
+ else activeConsumerTurns--;
292
+ };
293
+ }
262
294
 
263
- async function drainPassInFlight(): Promise<void> {
264
- if (passInFlight) {
265
- await passInFlight.catch(() => {
266
- // skip: errors already recorded per-consumer inside the pass
267
- });
295
+ // Per-(consumer, instanceId) turn guard, so one slow consumer never holds
296
+ // back the others. Keyed like consumerBackoff: a consumer already running
297
+ // its turn hands back that same promise instead of starting a second one.
298
+ const inFlightTurns = new Map<string, Promise<{ processed: number; failed: number }>>();
299
+
300
+ async function runConsumerTurn(
301
+ consumer: EventConsumer,
302
+ effective: ReadonlySet<string> | undefined,
303
+ ): Promise<{ processed: number; failed: number }> {
304
+ const key = `${consumer.name}:${consumerInstanceId(consumer, options.instanceId)}`;
305
+ const existing = inFlightTurns.get(key);
306
+ if (existing) return existing;
307
+
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.
311
+ if (effective && consumer.featureName && !effective.has(consumer.featureName)) {
312
+ return { processed: 0, failed: 0 };
313
+ }
314
+ const backoff = consumerBackoff.get(key);
315
+ if (backoff && backoff.retryAtMs > Date.now()) {
316
+ return { processed: 0, failed: 0 };
317
+ }
318
+
319
+ // Registered before awaiting the slot — a turn queued behind the
320
+ // concurrency limit still counts as in-flight, so it isn't started twice.
321
+ const turn = (async () => {
322
+ const releaseSlot = await acquireConsumerTurnSlot();
323
+ try {
324
+ return await processConsumer(consumer);
325
+ } finally {
326
+ releaseSlot();
327
+ }
328
+ })();
329
+ inFlightTurns.set(key, turn);
330
+ try {
331
+ return await turn;
332
+ } finally {
333
+ if (inFlightTurns.get(key) === turn) inFlightTurns.delete(key);
268
334
  }
269
335
  }
270
336
 
337
+ async function drainInFlightTurns(): Promise<void> {
338
+ await Promise.all(
339
+ [...inFlightTurns.values()].map((turn) =>
340
+ turn.catch(() => {
341
+ // skip: errors already recorded per-consumer inside the pass
342
+ }),
343
+ ),
344
+ );
345
+ }
346
+
271
347
  async function runOnce(): Promise<DispatcherPassResult> {
272
348
  if (!preRegistered) {
273
349
  throw new Error(
274
350
  "EventDispatcher.runOnce() called before start() — consumer state rows are not registered. Call start() first (production) or ensureRegistered() (tests after truncating kumiko_event_consumers).",
275
351
  );
276
352
  }
277
- if (passInFlight) return passInFlight;
278
- passInFlight = doPass();
279
- try {
280
- return await passInFlight;
281
- } finally {
282
- passInFlight = null;
283
- }
353
+ return doPass();
284
354
  }
285
355
 
286
356
  async function doPass(): Promise<DispatcherPassResult> {
@@ -301,30 +371,17 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
301
371
  // SYSTEM_TENANT_ID returnt (typisch: union-of-all-tier-features).
302
372
  const effective = context.effectiveFeatures?.(SYSTEM_TENANT_ID);
303
373
 
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;
325
- }
326
- const perConsumer = await processConsumer(consumer);
327
- byConsumer[consumer.name] = perConsumer;
374
+ // 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.
377
+ const results = await Promise.all(
378
+ consumers.map(async (consumer) => {
379
+ const perConsumer = await runConsumerTurn(consumer, effective);
380
+ return [consumer.name, perConsumer] as const;
381
+ }),
382
+ );
383
+ for (const [name, perConsumer] of results) {
384
+ byConsumer[name] = perConsumer;
328
385
  totalProcessed += perConsumer.processed;
329
386
  totalFailed += perConsumer.failed;
330
387
  }
@@ -376,20 +433,80 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
376
433
  // staying permanently suppressed by this process's Set.
377
434
  reportedDeadConsumers.delete(`${consumer.name}:${instanceId}`);
378
435
 
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) {
436
+ const oldCursor = acquired.state.lastProcessedEventId;
437
+ const pendingGaps = acquired.state.pendingGaps;
438
+ // xmin BEFORE the fetch: a range is provably burnt only once this has
439
+ // passed the xmax recorded with it. Only needed when ranges exist.
440
+ const xminNow = pendingGaps.length > 0 ? await selectSnapshotXmin(tx) : "0";
441
+ const events = await fetchPendingEvents(tx, oldCursor, batchSize, toIdRanges(pendingGaps));
442
+ const fetchedIds = events.map((e) => e.id);
443
+ const truncated = events.length === batchSize;
444
+
445
+ // Burnt: no visible id this turn, the fetch is proven to have
446
+ // covered the range (LIMIT can otherwise cut it off early), and
447
+ // xmin passed its recorded xmax — every xact that could still
448
+ // produce a row has finished without one ever appearing.
449
+ const { burnt: burntGaps, surviving: survivingGaps } = partitionBurntGaps(
450
+ pendingGaps,
451
+ fetchedIds,
452
+ truncated,
453
+ xminNow,
454
+ );
455
+
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.
459
+ if (events.length === 0 && burntGaps.length === 0) {
383
460
  span.setAttribute("consumer.skip_reason", "no_pending_events");
384
461
  return;
385
462
  }
386
463
  await markProcessing(tx, consumer.name, instanceId);
387
464
 
388
- const outcome = await deliverEvents(consumer, events, context, maxAttempts, acquired.state);
465
+ const outcome: DeliveryOutcome =
466
+ events.length > 0
467
+ ? await deliverEvents(consumer, events, context, maxAttempts, acquired.state)
468
+ : {
469
+ cursor: oldCursor,
470
+ attempts: acquired.state.attempts,
471
+ lastError: acquired.state.lastError,
472
+ deadLettered: false,
473
+ processed: 0,
474
+ failed: 0,
475
+ resolvedPendingIds: [],
476
+ };
389
477
  processed = outcome.processed;
390
478
  failed = outcome.failed;
391
479
 
392
- await persistConsumerOutcome(tx, consumer.name, instanceId, outcome);
480
+ // Carve delivered/skip-applied ids out of the surviving ranges —
481
+ // an id still fetched-but-unresolved (halt-on-poison stopped before
482
+ // it) stays put, since it's below the cursor and only pending_gaps
483
+ // will ever retry it.
484
+ const keptGaps = survivingGaps.flatMap((gap) =>
485
+ splitRangeExcludingIds(gap, outcome.resolvedPendingIds),
486
+ );
487
+ // New gaps as ranges between consecutive fetched ids, so a huge id
488
+ // jump (retention prune) costs one entry, not one per missing id.
489
+ // Their xmax is read after the fetch; any later read is only more
490
+ // conservative, so it is fetched lazily.
491
+ const newWindowIds = fetchedIds.filter((id) => id > oldCursor && id <= outcome.cursor);
492
+ const newGapBounds: Array<readonly [bigint, bigint]> = [];
493
+ let prev = oldCursor;
494
+ for (const id of newWindowIds) {
495
+ if (id > prev + 1n) newGapBounds.push([prev + 1n, id - 1n]);
496
+ prev = id;
497
+ }
498
+ const xmaxNow = newGapBounds.length > 0 ? await selectSnapshotXmax(tx) : "";
499
+ const newGaps: PendingGapEntry[] = newGapBounds.map(([from, to]) => ({
500
+ from: from.toString(),
501
+ to: to.toString(),
502
+ xmax: xmaxNow,
503
+ }));
504
+ const persistedOutcome: PersistedConsumerOutcome = {
505
+ ...outcome,
506
+ pendingGaps: [...keptGaps, ...newGaps],
507
+ };
508
+
509
+ await persistConsumerOutcome(tx, consumer.name, instanceId, persistedOutcome);
393
510
  await emitLagFromTx(tx, consumer.name, instanceId, outcome.cursor, meter);
394
511
  });
395
512
 
@@ -544,7 +661,7 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
544
661
  }
545
662
 
546
663
  // Drain any in-flight pass so shutdown observes consistent state.
547
- await drainPassInFlight();
664
+ await drainInFlightTurns();
548
665
  // preRegistered stays true — the rows survive stop(). runOnce()
549
666
  // after a stop() still works (tests stop the timer and then drain
550
667
  // deterministically).
@@ -555,7 +672,7 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
555
672
  preRegistered = true;
556
673
  },
557
674
 
558
- drain: drainPassInFlight,
675
+ drain: drainInFlightTurns,
559
676
 
560
677
  runOnce,
561
678
  };