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