@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.
- package/package.json +4 -4
- package/src/api/__tests__/redis-sse-broker.integration.test.ts +66 -0
- package/src/api/__tests__/sse-broker.test.ts +49 -0
- package/src/api/redis-sse-broker.ts +17 -3
- package/src/api/request-context.ts +24 -0
- package/src/api/sse-broker.ts +29 -11
- package/src/changes.json +26 -0
- package/src/db/queries/event-consumer.ts +57 -3
- package/src/db/queries/event-store.ts +69 -0
- package/src/db/tenant-db.ts +43 -5
- package/src/event-store/__tests__/event-attribution.integration.test.ts +53 -3
- package/src/event-store/admin-api.ts +5 -0
- package/src/event-store/event-store.ts +16 -7
- package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
- package/src/jobs/job-runner.ts +61 -6
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
- package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
- package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
- package/src/pipeline/dispatch-batch.ts +49 -19
- package/src/pipeline/dispatch-stream.ts +7 -3
- package/src/pipeline/dispatcher-utils.ts +21 -2
- package/src/pipeline/dispatcher.ts +71 -6
- package/src/pipeline/event-consumer-state.ts +26 -0
- package/src/pipeline/event-dispatcher-admin.ts +32 -5
- package/src/pipeline/event-dispatcher-delivery.ts +109 -57
- package/src/pipeline/event-dispatcher.ts +167 -50
- package/src/pipeline/pending-gap-ranges.ts +72 -0
- package/src/pipeline/system-hooks.ts +8 -1
- package/src/pipeline/write-origin.ts +31 -10
- 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
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
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 {
|
|
16
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
380
|
-
|
|
381
|
-
//
|
|
382
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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:
|
|
675
|
+
drain: drainInFlightTurns,
|
|
559
676
|
|
|
560
677
|
runOnce,
|
|
561
678
|
};
|