@cosmicdrift/kumiko-framework 0.306.0 → 0.308.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- 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 +133 -0
- 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 +144 -1
- 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 +57 -3
- package/src/api/request-context.ts +24 -0
- package/src/api/server.ts +11 -0
- package/src/api/sse-broker.ts +69 -12
- package/src/changes.json +63 -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__/tenant-db-where-merge.integration.test.ts +1 -1
- package/src/db/queries/event-consumer.ts +155 -9
- package/src/db/queries/event-store.ts +69 -0
- package/src/db/tenant-db.ts +43 -5
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
- 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 +54 -4
- package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
- package/src/event-store/admin-api.ts +5 -0
- package/src/event-store/event-store.ts +16 -7
- 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 +536 -0
- 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/jobs/job-runner.ts +61 -6
- 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-utils.test.ts +8 -0
- 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-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-idle.integration.test.ts +94 -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__/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 +262 -3
- 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-batch.ts +49 -19
- package/src/pipeline/dispatch-stream.ts +12 -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 +212 -65
- package/src/pipeline/event-dispatcher.ts +253 -49
- package/src/pipeline/index.ts +1 -0
- package/src/pipeline/msp-rebuild.ts +39 -1
- package/src/pipeline/pending-gap-ranges.ts +120 -0
- package/src/pipeline/system-hooks.ts +226 -64
- package/src/pipeline/write-origin.ts +31 -10
- 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-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 +1 -1
- package/src/testing/__tests__/e2e-generator.test.ts +1 -1
- package/src/testing/e2e-generator.ts +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DbTx, PgClient } from "../db/connection";
|
|
2
|
+
import { selectSnapshotXmax, selectSnapshotXmin } from "../db/queries/event-consumer";
|
|
2
3
|
import type { AppContext } from "../engine/types";
|
|
3
4
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
4
5
|
import { EVENTS_PUBSUB_CHANNEL, type StoredEvent } from "../event-store";
|
|
@@ -11,18 +12,22 @@ import {
|
|
|
11
12
|
type Meter,
|
|
12
13
|
type Tracer,
|
|
13
14
|
} from "../observability";
|
|
14
|
-
import { SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
|
|
15
|
+
import { type PendingGapEntry, SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
|
|
15
16
|
import {
|
|
16
17
|
acquireConsumerState,
|
|
17
18
|
consumerInstanceId,
|
|
19
|
+
type DeliveryOutcome,
|
|
18
20
|
deliverEvents,
|
|
19
21
|
emitLagFromTx,
|
|
20
22
|
fetchPendingEvents,
|
|
21
23
|
markProcessing,
|
|
24
|
+
type PersistedConsumerOutcome,
|
|
22
25
|
persistConsumerOutcome,
|
|
23
26
|
persistConsumerPassFailure,
|
|
24
27
|
preRegisterConsumers,
|
|
28
|
+
selectIdleConsumerKeys,
|
|
25
29
|
} from "./event-dispatcher-delivery";
|
|
30
|
+
import { partitionBurntGaps, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges";
|
|
26
31
|
|
|
27
32
|
// Async event-dispatcher — the "AsyncDaemon"-pendant for Kumiko.
|
|
28
33
|
//
|
|
@@ -37,7 +42,9 @@ import {
|
|
|
37
42
|
// 2. SELECT state row FOR UPDATE SKIP LOCKED
|
|
38
43
|
// — multi-instance-safe: if another poller holds the lock, this pass
|
|
39
44
|
// skips this consumer and tries the next. No duplicate delivery.
|
|
40
|
-
// 3. SELECT events WHERE id > lastProcessedEventId
|
|
45
|
+
// 3. SELECT events WHERE id > lastProcessedEventId, PLUS any ranges still
|
|
46
|
+
// tracked in pending_gaps (ids invisible on an earlier turn that may
|
|
47
|
+
// have committed since) — ORDER BY id ASC LIMIT batchSize
|
|
41
48
|
// 4. For each event: call the consumer's handler
|
|
42
49
|
// - handler throws → increment attempts, mark status="dead" at
|
|
43
50
|
// maxAttempts, surface lastError, STOP this consumer's pass
|
|
@@ -61,6 +68,21 @@ import {
|
|
|
61
68
|
|
|
62
69
|
export type EventConsumerHandler = (event: StoredEvent, ctx: AppContext) => Promise<void>;
|
|
63
70
|
|
|
71
|
+
// Batch variant of EventConsumerHandler — receives all events of one
|
|
72
|
+
// delivery turn in id order, meant for side-effect sinks (search index,
|
|
73
|
+
// bulk fan-out) that can collapse a turn into one round-trip. `handler`
|
|
74
|
+
// stays mandatory and is the fallback: if the batch throws, the dispatcher
|
|
75
|
+
// re-delivers the same events one by one through `handler`, so errorPolicy
|
|
76
|
+
// (maxAttempts, skipApplyErrors, dead-lettering) applies per event exactly
|
|
77
|
+
// as before. The batch handler must therefore be replay-safe — a partially
|
|
78
|
+
// applied batch gets re-applied event by event under the same idempotency
|
|
79
|
+
// rule as `handler`. Events appended from inside a batch handler carry the
|
|
80
|
+
// LAST event's causation id only.
|
|
81
|
+
export type EventConsumerBatchHandler = (
|
|
82
|
+
events: readonly StoredEvent[],
|
|
83
|
+
ctx: AppContext,
|
|
84
|
+
) => Promise<void>;
|
|
85
|
+
|
|
64
86
|
// Per-consumer error policy. When skipApplyErrors is true and handler throws,
|
|
65
87
|
// the dispatcher logs the error, advances the cursor past the offending event,
|
|
66
88
|
// and keeps delivering — instead of the default retry + dead-letter flow.
|
|
@@ -78,6 +100,9 @@ export type EventConsumerErrorPolicy = {
|
|
|
78
100
|
export type EventConsumer = {
|
|
79
101
|
readonly name: string;
|
|
80
102
|
readonly handler: EventConsumerHandler;
|
|
103
|
+
// Optional batch fast-path for this turn's events — see
|
|
104
|
+
// EventConsumerBatchHandler for the fallback and replay-safety contract.
|
|
105
|
+
readonly batchHandler?: EventConsumerBatchHandler;
|
|
81
106
|
readonly errorPolicy?: EventConsumerErrorPolicy;
|
|
82
107
|
// Owning feature — when present, the dispatcher skips this consumer's
|
|
83
108
|
// pass while the feature is globally disabled. Events remain in the store
|
|
@@ -240,6 +265,15 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
240
265
|
// entry — the next failure (if any) starts a fresh backoff from scratch.
|
|
241
266
|
const consumerBackoff = new Map<string, { consecutiveFailures: number; retryAtMs: number }>();
|
|
242
267
|
|
|
268
|
+
// Dedup flag for the idle pre-check's error log (doPass) — a broken
|
|
269
|
+
// pre-check falls back to the expensive-but-correct path silently on
|
|
270
|
+
// every tick (see doPass), which must not mean silently in the logs too:
|
|
271
|
+
// ops needs to see it degrade once, not have every poll tick spam the
|
|
272
|
+
// same line. Cleared the moment the pre-check succeeds again, so a later,
|
|
273
|
+
// unrelated outage gets its own fresh log line instead of staying
|
|
274
|
+
// permanently suppressed by this process's flag.
|
|
275
|
+
let idlePreCheckErrorLogged = false;
|
|
276
|
+
|
|
243
277
|
let running = false;
|
|
244
278
|
// Separate from `running` on purpose: pre-registration of consumer state
|
|
245
279
|
// rows is a one-time boot action, while running/timer/LISTEN is a
|
|
@@ -255,17 +289,100 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
255
289
|
// NOTIFYs (subscription drop, crash mid-commit).
|
|
256
290
|
let pgUnlisten: (() => Promise<void>) | null = null;
|
|
257
291
|
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
292
|
+
// Bounds how many consumer turns hold a DB transaction at once. Each turn's
|
|
293
|
+
// db.begin() checks out one pool connection, plus any writes the handler
|
|
294
|
+
// itself makes via context.db — the app db pool defaults to postgres-js's
|
|
295
|
+
// max=10 (db/connection.ts, no DATABASE_POOL_MAX override) and is shared
|
|
296
|
+
// with HTTP request handlers, so turns can't be allowed to claim it all.
|
|
297
|
+
// A pool no larger than this limit can deadlock: every connection held by
|
|
298
|
+
// an open turn TX while each handler waits for one more.
|
|
299
|
+
const MAX_CONCURRENT_CONSUMER_TURNS = 4;
|
|
300
|
+
let activeConsumerTurns = 0;
|
|
301
|
+
const consumerTurnWaiters: Array<() => void> = [];
|
|
302
|
+
|
|
303
|
+
async function acquireConsumerTurnSlot(): Promise<() => void> {
|
|
304
|
+
if (activeConsumerTurns >= MAX_CONCURRENT_CONSUMER_TURNS) {
|
|
305
|
+
// The releasing turn hands its slot over directly (count unchanged),
|
|
306
|
+
// so a synchronous acquire in between cannot overshoot the limit.
|
|
307
|
+
await new Promise<void>((resolve) => consumerTurnWaiters.push(resolve));
|
|
308
|
+
} else {
|
|
309
|
+
activeConsumerTurns++;
|
|
310
|
+
}
|
|
311
|
+
let released = false;
|
|
312
|
+
return () => {
|
|
313
|
+
// skip: already released — finally-blocks calling this twice must not
|
|
314
|
+
// free the same slot twice
|
|
315
|
+
if (released) return;
|
|
316
|
+
released = true;
|
|
317
|
+
const nextWaiter = consumerTurnWaiters.shift();
|
|
318
|
+
if (nextWaiter) nextWaiter();
|
|
319
|
+
else activeConsumerTurns--;
|
|
320
|
+
};
|
|
321
|
+
}
|
|
262
322
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
323
|
+
// Per-(consumer, instanceId) turn guard, so one slow consumer never holds
|
|
324
|
+
// back the others. Keyed like consumerBackoff: a consumer already running
|
|
325
|
+
// its turn hands back that same promise instead of starting a second one.
|
|
326
|
+
const inFlightTurns = new Map<string, Promise<{ processed: number; failed: number }>>();
|
|
327
|
+
|
|
328
|
+
async function runConsumerTurn(
|
|
329
|
+
consumer: EventConsumer,
|
|
330
|
+
effective: ReadonlySet<string> | undefined,
|
|
331
|
+
idleKeys: ReadonlySet<string>,
|
|
332
|
+
): Promise<{ processed: number; failed: number }> {
|
|
333
|
+
const key = `${consumer.name}:${consumerInstanceId(consumer, options.instanceId)}`;
|
|
334
|
+
const existing = inFlightTurns.get(key);
|
|
335
|
+
if (existing) return existing;
|
|
336
|
+
|
|
337
|
+
// Feature-gate, backoff-gate and idle-gate are resolved synchronously,
|
|
338
|
+
// before any promise is registered — a gated consumer must resolve
|
|
339
|
+
// without ever occupying inFlightTurns, or it would look "still
|
|
340
|
+
// running" forever.
|
|
341
|
+
if (effective && consumer.featureName && !effective.has(consumer.featureName)) {
|
|
342
|
+
return { processed: 0, failed: 0 };
|
|
343
|
+
}
|
|
344
|
+
const backoff = consumerBackoff.get(key);
|
|
345
|
+
if (backoff && backoff.retryAtMs > Date.now()) {
|
|
346
|
+
return { processed: 0, failed: 0 };
|
|
347
|
+
}
|
|
348
|
+
// Idle-gate: doPass already proved this consumer has nothing to do
|
|
349
|
+
// (see selectIdleConsumerKeys) — skip the turn without ever opening a
|
|
350
|
+
// db.begin/FOR UPDATE SKIP LOCKED (see doPass for why that matters).
|
|
351
|
+
// Clear any prior backoff same as a successful pass would: the
|
|
352
|
+
// backoff-gate above already confirmed retryAtMs has elapsed, and
|
|
353
|
+
// "provably nothing to deliver" is itself proof the consumer isn't
|
|
354
|
+
// presently failing.
|
|
355
|
+
if (idleKeys.has(key)) {
|
|
356
|
+
consumerBackoff.delete(key);
|
|
357
|
+
return { processed: 0, failed: 0 };
|
|
268
358
|
}
|
|
359
|
+
|
|
360
|
+
// Registered before awaiting the slot — a turn queued behind the
|
|
361
|
+
// concurrency limit still counts as in-flight, so it isn't started twice.
|
|
362
|
+
const turn = (async () => {
|
|
363
|
+
const releaseSlot = await acquireConsumerTurnSlot();
|
|
364
|
+
try {
|
|
365
|
+
return await processConsumer(consumer);
|
|
366
|
+
} finally {
|
|
367
|
+
releaseSlot();
|
|
368
|
+
}
|
|
369
|
+
})();
|
|
370
|
+
inFlightTurns.set(key, turn);
|
|
371
|
+
try {
|
|
372
|
+
return await turn;
|
|
373
|
+
} finally {
|
|
374
|
+
if (inFlightTurns.get(key) === turn) inFlightTurns.delete(key);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function drainInFlightTurns(): Promise<void> {
|
|
379
|
+
await Promise.all(
|
|
380
|
+
[...inFlightTurns.values()].map((turn) =>
|
|
381
|
+
turn.catch(() => {
|
|
382
|
+
// skip: errors already recorded per-consumer inside the pass
|
|
383
|
+
}),
|
|
384
|
+
),
|
|
385
|
+
);
|
|
269
386
|
}
|
|
270
387
|
|
|
271
388
|
async function runOnce(): Promise<DispatcherPassResult> {
|
|
@@ -274,13 +391,7 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
274
391
|
"EventDispatcher.runOnce() called before start() — consumer state rows are not registered. Call start() first (production) or ensureRegistered() (tests after truncating kumiko_event_consumers).",
|
|
275
392
|
);
|
|
276
393
|
}
|
|
277
|
-
|
|
278
|
-
passInFlight = doPass();
|
|
279
|
-
try {
|
|
280
|
-
return await passInFlight;
|
|
281
|
-
} finally {
|
|
282
|
-
passInFlight = null;
|
|
283
|
-
}
|
|
394
|
+
return doPass();
|
|
284
395
|
}
|
|
285
396
|
|
|
286
397
|
async function doPass(): Promise<DispatcherPassResult> {
|
|
@@ -301,30 +412,58 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
301
412
|
// SYSTEM_TENANT_ID returnt (typisch: union-of-all-tier-features).
|
|
302
413
|
const effective = context.effectiveFeatures?.(SYSTEM_TENANT_ID);
|
|
303
414
|
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
415
|
+
// Lock-free pre-check, once per pass: which consumers are provably idle
|
|
416
|
+
// (no locking, no xid, no WAL — see selectIdleConsumerKeys). Read
|
|
417
|
+
// outside any transaction, before a single db.begin/FOR UPDATE SKIP
|
|
418
|
+
// LOCKED opens — an idle deployment (most consumers, most ticks)
|
|
419
|
+
// otherwise pays a row lock + WAL record + commit fsync per consumer
|
|
420
|
+
// per tick for nothing.
|
|
421
|
+
//
|
|
422
|
+
// A failure here (e.g. a transient DB blip) must not abort the whole
|
|
423
|
+
// pass — timer/LISTEN callers swallow doPass()'s rejection silently
|
|
424
|
+
// (see start()), which would mean no log, no persistConsumerPassFailure,
|
|
425
|
+
// and no backoff for every consumer this tick. Falling back to "nothing
|
|
426
|
+
// proven idle" instead routes every consumer through its normal
|
|
427
|
+
// acquireConsumerState path, whose own try/catch already covers this.
|
|
428
|
+
let idleKeys: ReadonlySet<string>;
|
|
429
|
+
try {
|
|
430
|
+
idleKeys = await selectIdleConsumerKeys(
|
|
431
|
+
db,
|
|
432
|
+
consumers.map((consumer) => ({
|
|
433
|
+
name: consumer.name,
|
|
434
|
+
instanceId: consumerInstanceId(consumer, options.instanceId),
|
|
435
|
+
})),
|
|
436
|
+
);
|
|
437
|
+
idlePreCheckErrorLogged = false;
|
|
438
|
+
} catch (e) {
|
|
439
|
+
idleKeys = new Set();
|
|
440
|
+
// Log once per outage, not once per poll tick — see
|
|
441
|
+
// idlePreCheckErrorLogged above. Every consumer still gets delivered
|
|
442
|
+
// correctly this tick (fallback above), so this is a degradation
|
|
443
|
+
// signal for ops, not a delivery failure.
|
|
444
|
+
if (!idlePreCheckErrorLogged) {
|
|
445
|
+
idlePreCheckErrorLogged = true;
|
|
446
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
447
|
+
const logMsg = `[event-dispatcher] idle pre-check failed, falling back to per-consumer locking every tick until it recovers: ${msg}`;
|
|
448
|
+
if (context.log) {
|
|
449
|
+
context.log.error(logMsg);
|
|
450
|
+
} else {
|
|
451
|
+
console.error(logMsg);
|
|
452
|
+
}
|
|
325
453
|
}
|
|
326
|
-
|
|
327
|
-
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Every consumer runs its own turn concurrently (own TX, own cursor,
|
|
457
|
+
// own backoff/feature-gate/idle-gate) — a slow consumer no longer
|
|
458
|
+
// blocks the others' delivery until it commits, see runConsumerTurn.
|
|
459
|
+
const results = await Promise.all(
|
|
460
|
+
consumers.map(async (consumer) => {
|
|
461
|
+
const perConsumer = await runConsumerTurn(consumer, effective, idleKeys);
|
|
462
|
+
return [consumer.name, perConsumer] as const;
|
|
463
|
+
}),
|
|
464
|
+
);
|
|
465
|
+
for (const [name, perConsumer] of results) {
|
|
466
|
+
byConsumer[name] = perConsumer;
|
|
328
467
|
totalProcessed += perConsumer.processed;
|
|
329
468
|
totalFailed += perConsumer.failed;
|
|
330
469
|
}
|
|
@@ -376,20 +515,85 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
376
515
|
// staying permanently suppressed by this process's Set.
|
|
377
516
|
reportedDeadConsumers.delete(`${consumer.name}:${instanceId}`);
|
|
378
517
|
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
//
|
|
382
|
-
|
|
518
|
+
const oldCursor = acquired.state.lastProcessedEventId;
|
|
519
|
+
const pendingGaps = acquired.state.pendingGaps;
|
|
520
|
+
// xmin BEFORE the fetch: a range is provably burnt only once this has
|
|
521
|
+
// passed the xmax recorded with it. Only needed when ranges exist.
|
|
522
|
+
const xminNow = pendingGaps.length > 0 ? await selectSnapshotXmin(tx) : "0";
|
|
523
|
+
const events = await fetchPendingEvents(tx, oldCursor, batchSize, toIdRanges(pendingGaps));
|
|
524
|
+
const fetchedIds = events.map((e) => e.id);
|
|
525
|
+
const truncated = events.length === batchSize;
|
|
526
|
+
|
|
527
|
+
// Burnt: no visible id this turn, the fetch is proven to have
|
|
528
|
+
// covered the range (LIMIT can otherwise cut it off early), and
|
|
529
|
+
// xmin passed its recorded xmax — every xact that could still
|
|
530
|
+
// produce a row has finished without one ever appearing.
|
|
531
|
+
const { burnt: burntGaps, surviving: survivingGaps } = partitionBurntGaps(
|
|
532
|
+
pendingGaps,
|
|
533
|
+
fetchedIds,
|
|
534
|
+
truncated,
|
|
535
|
+
xminNow,
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
// skip: nothing to deliver and no burnt gap to clean up. The
|
|
539
|
+
// doPass idle pre-check (selectIdleConsumerKeys) already keeps the
|
|
540
|
+
// common idle case out of this transaction entirely — reaching
|
|
541
|
+
// here with an empty fetch means the pre-check couldn't prove
|
|
542
|
+
// idleness (pending gaps forced a fetch, or a race window). By this
|
|
543
|
+
// point acquireConsumerState's FOR UPDATE has already taken the
|
|
544
|
+
// lock, so skipping markProcessing/persistConsumerOutcome only
|
|
545
|
+
// avoids extra writes, not that lock's own WAL record.
|
|
546
|
+
if (events.length === 0 && burntGaps.length === 0) {
|
|
383
547
|
span.setAttribute("consumer.skip_reason", "no_pending_events");
|
|
384
548
|
return;
|
|
385
549
|
}
|
|
386
550
|
await markProcessing(tx, consumer.name, instanceId);
|
|
387
551
|
|
|
388
|
-
const outcome =
|
|
552
|
+
const outcome: DeliveryOutcome =
|
|
553
|
+
events.length > 0
|
|
554
|
+
? await deliverEvents(consumer, events, context, maxAttempts, acquired.state)
|
|
555
|
+
: {
|
|
556
|
+
cursor: oldCursor,
|
|
557
|
+
attempts: acquired.state.attempts,
|
|
558
|
+
lastError: acquired.state.lastError,
|
|
559
|
+
deadLettered: false,
|
|
560
|
+
processed: 0,
|
|
561
|
+
failed: 0,
|
|
562
|
+
resolvedPendingIds: [],
|
|
563
|
+
};
|
|
389
564
|
processed = outcome.processed;
|
|
390
565
|
failed = outcome.failed;
|
|
391
566
|
|
|
392
|
-
|
|
567
|
+
// Carve delivered/skip-applied ids out of the surviving ranges —
|
|
568
|
+
// an id still fetched-but-unresolved (halt-on-poison stopped before
|
|
569
|
+
// it) stays put, since it's below the cursor and only pending_gaps
|
|
570
|
+
// will ever retry it.
|
|
571
|
+
const keptGaps = survivingGaps.flatMap((gap) =>
|
|
572
|
+
splitRangeExcludingIds(gap, outcome.resolvedPendingIds),
|
|
573
|
+
);
|
|
574
|
+
// New gaps as ranges between consecutive fetched ids, so a huge id
|
|
575
|
+
// jump (retention prune) costs one entry, not one per missing id.
|
|
576
|
+
// Their xmax is read after the fetch; any later read is only more
|
|
577
|
+
// conservative, so it is fetched lazily.
|
|
578
|
+
const newWindowIds = fetchedIds.filter((id) => id > oldCursor && id <= outcome.cursor);
|
|
579
|
+
const newGapBounds: Array<readonly [bigint, bigint]> = [];
|
|
580
|
+
let prev = oldCursor;
|
|
581
|
+
for (const id of newWindowIds) {
|
|
582
|
+
if (id > prev + 1n) newGapBounds.push([prev + 1n, id - 1n]);
|
|
583
|
+
prev = id;
|
|
584
|
+
}
|
|
585
|
+
const xmaxNow = newGapBounds.length > 0 ? await selectSnapshotXmax(tx) : "";
|
|
586
|
+
const newGaps: PendingGapEntry[] = newGapBounds.map(([from, to]) => ({
|
|
587
|
+
from: from.toString(),
|
|
588
|
+
to: to.toString(),
|
|
589
|
+
xmax: xmaxNow,
|
|
590
|
+
}));
|
|
591
|
+
const persistedOutcome: PersistedConsumerOutcome = {
|
|
592
|
+
...outcome,
|
|
593
|
+
pendingGaps: [...keptGaps, ...newGaps],
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
await persistConsumerOutcome(tx, consumer.name, instanceId, persistedOutcome);
|
|
393
597
|
await emitLagFromTx(tx, consumer.name, instanceId, outcome.cursor, meter);
|
|
394
598
|
});
|
|
395
599
|
|
|
@@ -544,7 +748,7 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
544
748
|
}
|
|
545
749
|
|
|
546
750
|
// Drain any in-flight pass so shutdown observes consistent state.
|
|
547
|
-
await
|
|
751
|
+
await drainInFlightTurns();
|
|
548
752
|
// preRegistered stays true — the rows survive stop(). runOnce()
|
|
549
753
|
// after a stop() still works (tests stop the timer and then drain
|
|
550
754
|
// deterministically).
|
|
@@ -555,7 +759,7 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
555
759
|
preRegistered = true;
|
|
556
760
|
},
|
|
557
761
|
|
|
558
|
-
drain:
|
|
762
|
+
drain: drainInFlightTurns,
|
|
559
763
|
|
|
560
764
|
runOnce,
|
|
561
765
|
};
|
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
|
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { PendingIdRange } from "../db/queries/event-store";
|
|
2
|
+
import type { PendingGapEntry } from "./event-consumer-state";
|
|
3
|
+
|
|
4
|
+
export function toIdRanges(gaps: readonly PendingGapEntry[]): PendingIdRange[] {
|
|
5
|
+
return gaps.map((g) => ({ from: BigInt(g.from), to: BigInt(g.to) }));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function rangeContainsId(range: PendingGapEntry, id: bigint): boolean {
|
|
9
|
+
return id >= BigInt(range.from) && id <= BigInt(range.to);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// A range is only provably burnt (rolled back, not just slow) once it's
|
|
13
|
+
// certain the fetch would have surfaced any of its ids had they been
|
|
14
|
+
// visible. LIMIT can cut a fetch off before reaching the range's ids —
|
|
15
|
+
// `to < maxFetchedId` proves the scan passed the range regardless of the
|
|
16
|
+
// cutoff, since ORDER BY id ASC LIMIT N always includes every matching id
|
|
17
|
+
// below the largest one it did return.
|
|
18
|
+
export function isRangeFullyCoveredByFetch(
|
|
19
|
+
range: PendingGapEntry,
|
|
20
|
+
truncated: boolean,
|
|
21
|
+
maxFetchedId: bigint | null,
|
|
22
|
+
): boolean {
|
|
23
|
+
if (!truncated) return true;
|
|
24
|
+
if (maxFetchedId === null) return false;
|
|
25
|
+
return BigInt(range.to) < maxFetchedId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A gap is burnt when none of its ids showed up in this turn's fetch, the
|
|
29
|
+
// fetch is proven to have covered it, and its recorded xmax is behind this
|
|
30
|
+
// turn's xmin (every xact that could still produce a row has finished).
|
|
31
|
+
export function partitionBurntGaps(
|
|
32
|
+
gaps: readonly PendingGapEntry[],
|
|
33
|
+
fetchedIds: readonly bigint[],
|
|
34
|
+
truncated: boolean,
|
|
35
|
+
xminNow: string,
|
|
36
|
+
): { readonly burnt: PendingGapEntry[]; readonly surviving: PendingGapEntry[] } {
|
|
37
|
+
const maxFetchedId = fetchedIds.at(-1) ?? null;
|
|
38
|
+
const burnt: PendingGapEntry[] = [];
|
|
39
|
+
const surviving: PendingGapEntry[] = [];
|
|
40
|
+
for (const gap of gaps) {
|
|
41
|
+
const hasVisibleId = fetchedIds.some((id) => rangeContainsId(gap, id));
|
|
42
|
+
const isBurnt =
|
|
43
|
+
!hasVisibleId &&
|
|
44
|
+
isRangeFullyCoveredByFetch(gap, truncated, maxFetchedId) &&
|
|
45
|
+
BigInt(gap.xmax) <= BigInt(xminNow);
|
|
46
|
+
(isBurnt ? burnt : surviving).push(gap);
|
|
47
|
+
}
|
|
48
|
+
return { burnt, surviving };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Carves `excludeIds` (delivered or skip-applied this turn) out of `range`,
|
|
52
|
+
// yielding 0-2 sub-ranges. xmax is preserved on every surviving piece.
|
|
53
|
+
export function splitRangeExcludingIds(
|
|
54
|
+
range: PendingGapEntry,
|
|
55
|
+
excludeIds: readonly bigint[],
|
|
56
|
+
): PendingGapEntry[] {
|
|
57
|
+
const from = BigInt(range.from);
|
|
58
|
+
const to = BigInt(range.to);
|
|
59
|
+
const inRange = excludeIds
|
|
60
|
+
.filter((id) => id >= from && id <= to)
|
|
61
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
62
|
+
if (inRange.length === 0) return [range];
|
|
63
|
+
const parts: PendingGapEntry[] = [];
|
|
64
|
+
let cursor = from;
|
|
65
|
+
for (const id of inRange) {
|
|
66
|
+
if (id > cursor)
|
|
67
|
+
parts.push({ from: cursor.toString(), to: (id - 1n).toString(), xmax: range.xmax });
|
|
68
|
+
cursor = id + 1n;
|
|
69
|
+
}
|
|
70
|
+
if (cursor <= to) parts.push({ from: cursor.toString(), to: to.toString(), xmax: range.xmax });
|
|
71
|
+
return parts;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Same carve-out as splitRangeExcludingIds, but for many ranges against many
|
|
75
|
+
// ids at once — an MSP rebuild's replay reads events in ascending id order,
|
|
76
|
+
// so a single forward pointer over `sortedIds` (both inputs already sorted,
|
|
77
|
+
// ranges non-overlapping) avoids scanning the full id list per range.
|
|
78
|
+
export function subtractSortedIdsFromRanges(
|
|
79
|
+
ranges: readonly PendingGapEntry[],
|
|
80
|
+
sortedIds: readonly bigint[],
|
|
81
|
+
): PendingGapEntry[] {
|
|
82
|
+
if (sortedIds.length === 0) return [...ranges];
|
|
83
|
+
const result: PendingGapEntry[] = [];
|
|
84
|
+
let cursor = 0;
|
|
85
|
+
for (const range of ranges) {
|
|
86
|
+
const from = BigInt(range.from);
|
|
87
|
+
const to = BigInt(range.to);
|
|
88
|
+
let id = sortedIds[cursor];
|
|
89
|
+
while (id !== undefined && id < from) {
|
|
90
|
+
cursor++;
|
|
91
|
+
id = sortedIds[cursor];
|
|
92
|
+
}
|
|
93
|
+
const idsInRange: bigint[] = [];
|
|
94
|
+
while (id !== undefined && id <= to) {
|
|
95
|
+
idsInRange.push(id);
|
|
96
|
+
cursor++;
|
|
97
|
+
id = sortedIds[cursor];
|
|
98
|
+
}
|
|
99
|
+
result.push(...splitRangeExcludingIds(range, idsInRange));
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Ranges above the written cursor belong to the live dispatcher's own
|
|
105
|
+
// `id > cursor` territory; a range straddling it is truncated at cursor - 1.
|
|
106
|
+
export function capPendingGapsBelowCursor(
|
|
107
|
+
gaps: readonly PendingGapEntry[],
|
|
108
|
+
cursor: bigint,
|
|
109
|
+
): PendingGapEntry[] {
|
|
110
|
+
if (cursor <= 0n) return [];
|
|
111
|
+
const capped: PendingGapEntry[] = [];
|
|
112
|
+
for (const gap of gaps) {
|
|
113
|
+
const from = BigInt(gap.from);
|
|
114
|
+
if (from >= cursor) continue;
|
|
115
|
+
const to = BigInt(gap.to);
|
|
116
|
+
const cappedTo = to >= cursor ? cursor - 1n : to;
|
|
117
|
+
capped.push({ ...gap, to: cappedTo.toString() });
|
|
118
|
+
}
|
|
119
|
+
return capped;
|
|
120
|
+
}
|