@cosmicdrift/kumiko-framework 0.305.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__/server-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +71 -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 +29 -4
- package/src/api/routes.ts +26 -1
- package/src/api/sse-broker.ts +29 -11
- package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
- package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
- package/src/bun-db/query.ts +42 -18
- package/src/changes.json +92 -0
- package/src/db/__tests__/pg-error.test.ts +14 -0
- package/src/db/__tests__/system-db-view-export.test.ts +107 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
- package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
- package/src/db/index.ts +1 -1
- package/src/db/pg-error.ts +13 -0
- package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
- package/src/db/queries/event-consumer.ts +57 -3
- package/src/db/queries/event-store.ts +69 -0
- package/src/db/tenant-db.ts +133 -18
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +1 -1
- package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
- package/src/engine/extension-names.ts +55 -25
- package/src/engine/extensions/storage-provider.ts +14 -41
- package/src/engine/extensions/tenant-data.ts +4 -0
- package/src/engine/extensions/tenant-resource.ts +40 -0
- package/src/engine/extensions/user-data.ts +8 -7
- package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
- package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
- package/src/engine/feature-ast/entity-field-types.ts +41 -0
- package/src/engine/feature-ast/extractors/handlers.ts +217 -84
- package/src/engine/feature-ast/extractors/hooks.ts +72 -15
- package/src/engine/feature-ast/extractors/round2.ts +21 -0
- package/src/engine/feature-ast/extractors/shared.ts +9 -0
- package/src/engine/feature-ast/index.ts +11 -1
- package/src/engine/feature-ast/patch.ts +338 -5
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
- package/src/engine/feature-ast/patterns.ts +22 -15
- package/src/engine/feature-ast/render.ts +1 -0
- package/src/engine/feature-ui-extensions.ts +8 -7
- package/src/engine/index.ts +21 -5
- package/src/engine/types/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +6 -0
- 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/__tests__/job-retention.integration.test.ts +316 -0
- package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
- package/src/jobs/job-runner.ts +211 -19
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +5 -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__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/dispatch-batch.ts +102 -29
- 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/idempotency.ts +16 -0
- package/src/pipeline/pending-gap-ranges.ts +72 -0
- package/src/pipeline/system-hooks.ts +8 -1
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/pipeline/write-origin.ts +31 -10
- package/src/stack/test-stack.ts +1 -1
- package/src/testing/closed-connection-error.ts +62 -0
- package/src/testing/index.ts +1 -0
- package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
|
@@ -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
|
};
|
|
@@ -19,6 +19,7 @@ export type IdempotencyGuard = {
|
|
|
19
19
|
token: string,
|
|
20
20
|
result: unknown,
|
|
21
21
|
): Promise<void>;
|
|
22
|
+
release(tenantId: string, userId: string, requestId: string, token: string): Promise<void>;
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
// Sentinel prefix stored under the key while the handler is running. Each
|
|
@@ -64,6 +65,16 @@ export function createIdempotencyGuard(
|
|
|
64
65
|
end
|
|
65
66
|
`;
|
|
66
67
|
|
|
68
|
+
// Same CAS guard as storeScript: only clear the lock if we still own it.
|
|
69
|
+
// A stale token (lock already reclaimed by a new owner) is a no-op.
|
|
70
|
+
const releaseScript = `
|
|
71
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
72
|
+
return redis.call("del", KEYS[1])
|
|
73
|
+
else
|
|
74
|
+
return 0
|
|
75
|
+
end
|
|
76
|
+
`;
|
|
77
|
+
|
|
67
78
|
async function tryAcquire(key: string): Promise<string | null> {
|
|
68
79
|
const token = `${PENDING_PREFIX}${generateId()}`;
|
|
69
80
|
const acquired = await redis.set(key, token, "EX", pendingTtl, "NX");
|
|
@@ -138,5 +149,10 @@ export function createIdempotencyGuard(
|
|
|
138
149
|
// SET and could stomp that fresher result with our stale one.
|
|
139
150
|
void written;
|
|
140
151
|
},
|
|
152
|
+
|
|
153
|
+
async release(tenantId, userId, requestId, token) {
|
|
154
|
+
const key = `${prefix}${tenantId}:${userId}:${requestId}`;
|
|
155
|
+
await redis.eval(releaseScript, 1, key, token);
|
|
156
|
+
},
|
|
141
157
|
};
|
|
142
158
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
}
|
|
@@ -490,6 +490,13 @@ function readUserIdFromPreviousSnapshot(payload: Record<string, unknown>): strin
|
|
|
490
490
|
return typeof userId === "string" && userId.length > 0 ? userId : undefined;
|
|
491
491
|
}
|
|
492
492
|
|
|
493
|
+
// A "revoke all others" write must not cut the caller's own live stream, so
|
|
494
|
+
// session-revoked carries the spared sid. Missing or malformed → userwide.
|
|
495
|
+
function readSessionRevokedKeptSessionId(payload: Record<string, unknown>): string | undefined {
|
|
496
|
+
const keptSessionId = payload["keptSessionId"];
|
|
497
|
+
return typeof keptSessionId === "string" && keptSessionId.length > 0 ? keptSessionId : undefined;
|
|
498
|
+
}
|
|
499
|
+
|
|
493
500
|
export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): EventConsumer {
|
|
494
501
|
return {
|
|
495
502
|
name: ACCESS_INVALIDATION_CONSUMER_NAME,
|
|
@@ -511,7 +518,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
|
|
|
511
518
|
// poison would otherwise permanently stop access-invalidation for
|
|
512
519
|
// every user behind one bad row).
|
|
513
520
|
if (typeof userId !== "string" || userId.length === 0) return;
|
|
514
|
-
sseBroker.publishAccessInvalidation(userId);
|
|
521
|
+
sseBroker.publishAccessInvalidation(userId, readSessionRevokedKeptSessionId(event.payload));
|
|
515
522
|
}
|
|
516
523
|
|
|
517
524
|
if (
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type TenantDb,
|
|
3
|
+
type UncheckedSystemDb,
|
|
4
|
+
withSystemDbUnsafeRawGrant,
|
|
5
|
+
withUnsafeRawGrant,
|
|
6
|
+
} from "../db/tenant-db";
|
|
2
7
|
import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user";
|
|
3
8
|
import type {
|
|
4
9
|
ActiveMembershipResult,
|
|
@@ -214,7 +219,10 @@ function readIdentitySwitchFn<
|
|
|
214
219
|
}
|
|
215
220
|
|
|
216
221
|
// context's own keys only — never touch a property of the resolved value, which may be a Proxy that throws on any get.
|
|
217
|
-
function readDbLikeValue(
|
|
222
|
+
function readDbLikeValue(
|
|
223
|
+
context: object,
|
|
224
|
+
key: "db" | "dbOutsideTransaction" | "systemDb",
|
|
225
|
+
): object | undefined {
|
|
218
226
|
if (!(key in context)) return undefined;
|
|
219
227
|
const value = (context as Record<string, unknown>)[key];
|
|
220
228
|
return typeof value === "object" && value !== null ? value : undefined;
|
|
@@ -340,6 +348,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
340
348
|
const ctxQueryProjection = readIdentitySwitchFn<ProjectionReader>(context, "queryProjection");
|
|
341
349
|
const ctxDb = readDbLikeValue(context, "db");
|
|
342
350
|
const ctxDbOutsideTransaction = readDbLikeValue(context, "dbOutsideTransaction");
|
|
351
|
+
const ctxSystemDb = readDbLikeValue(context, "systemDb");
|
|
343
352
|
if (
|
|
344
353
|
!ctxQueryAs &&
|
|
345
354
|
!ctxWriteAs &&
|
|
@@ -347,7 +356,8 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
347
356
|
!ctxQueryAsMember &&
|
|
348
357
|
!ctxQueryProjection &&
|
|
349
358
|
!ctxDb &&
|
|
350
|
-
!ctxDbOutsideTransaction
|
|
359
|
+
!ctxDbOutsideTransaction &&
|
|
360
|
+
!ctxSystemDb
|
|
351
361
|
) {
|
|
352
362
|
return context;
|
|
353
363
|
}
|
|
@@ -366,10 +376,18 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
366
376
|
...(ctxDbOutsideTransaction && {
|
|
367
377
|
dbOutsideTransaction: withUnsafeRawGrant(ctxDbOutsideTransaction as TenantDb, escapeHatch),
|
|
368
378
|
}),
|
|
379
|
+
// @cast-boundary engine-bridge — withSystemDbUnsafeRawGrant passes non-UncheckedSystemDb values through unchanged.
|
|
380
|
+
...(ctxSystemDb && {
|
|
381
|
+
systemDb: withSystemDbUnsafeRawGrant(
|
|
382
|
+
ctxSystemDb as UncheckedSystemDb,
|
|
383
|
+
escapeHatch,
|
|
384
|
+
callerLabel,
|
|
385
|
+
),
|
|
386
|
+
}),
|
|
369
387
|
};
|
|
370
388
|
}
|
|
371
389
|
|
|
372
|
-
// Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.queryProjection/ctx.db/ctx.dbOutsideTransaction instead of inheriting the handler's grant.
|
|
390
|
+
// Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.queryProjection/ctx.db/ctx.dbOutsideTransaction/ctx.systemDb instead of inheriting the handler's grant.
|
|
373
391
|
export function bindHookEscapeHatchGrant(
|
|
374
392
|
fn: LifecycleHookFn,
|
|
375
393
|
label: string,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// The static check in boot-validator/access-declarations.ts only sees a handler's own
|
|
2
2
|
// input schema; writes reached via ctx.write/writeAs/queryAs, hooks or foreign-feature
|
|
3
3
|
// tables are only visible at the actual write, so the gate runs there at runtime.
|
|
4
|
+
import { isPersonalDataGated, type WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
5
|
+
import { z } from "zod";
|
|
4
6
|
import { buildEntityTable } from "../db/table-builder";
|
|
5
7
|
import { type PersonalDataGate, tableNameOf } from "../db/tenant-db";
|
|
6
8
|
import {
|
|
@@ -15,12 +17,29 @@ import { AccessDeniedError } from "../errors";
|
|
|
15
17
|
import { FrameworkReasons } from "../errors/reasons";
|
|
16
18
|
import { toSnakeCase } from "../utils/case";
|
|
17
19
|
|
|
18
|
-
export type WriteOrigin
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
export { isPersonalDataGated, type WriteOrigin };
|
|
21
|
+
|
|
22
|
+
// Only narrows: an inherited origin can add a gate, never lift the root's.
|
|
23
|
+
export function effectiveWriteOrigin(root: WriteOrigin, inherited?: WriteOrigin): WriteOrigin {
|
|
24
|
+
if (!inherited) return root;
|
|
25
|
+
if (isPersonalDataGated(root)) return root;
|
|
26
|
+
if (isPersonalDataGated(inherited)) return inherited;
|
|
27
|
+
return root;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const writeOriginSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
rootHandler: z.string(),
|
|
33
|
+
anonymousRoot: z.boolean(),
|
|
34
|
+
publicIntake: z.boolean(),
|
|
35
|
+
viaJob: z.string().optional(),
|
|
36
|
+
})
|
|
37
|
+
.strict();
|
|
38
|
+
|
|
39
|
+
export function parseWriteOrigin(value: unknown): WriteOrigin | undefined {
|
|
40
|
+
const parsed = writeOriginSchema.safeParse(value);
|
|
41
|
+
return parsed.success ? parsed.data : undefined;
|
|
42
|
+
}
|
|
24
43
|
|
|
25
44
|
function declaresPublicIntake(access: AccessRule): boolean {
|
|
26
45
|
return accessAllowsAnonymous(access) && declaredPersonalData(access) === "public-intake";
|
|
@@ -67,7 +86,7 @@ function personalDataTableMap(registry: Registry): ReadonlyMap<string, ReadonlyS
|
|
|
67
86
|
}
|
|
68
87
|
|
|
69
88
|
// Names fields only, never values: the error reaches the anonymous HTTP caller.
|
|
70
|
-
function publicIntakeRequiredError(
|
|
89
|
+
export function publicIntakeRequiredError(
|
|
71
90
|
origin: WriteOrigin,
|
|
72
91
|
target: string,
|
|
73
92
|
fields: readonly string[],
|
|
@@ -75,14 +94,16 @@ function publicIntakeRequiredError(
|
|
|
75
94
|
return new AccessDeniedError({
|
|
76
95
|
message:
|
|
77
96
|
`Anonymous root handler "${origin.rootHandler}" wrote personal-data field(s) ` +
|
|
78
|
-
`${fields.map((f) => `"${f}"`).join(", ")} on "${target}"
|
|
79
|
-
|
|
97
|
+
`${fields.map((f) => `"${f}"`).join(", ")} on "${target}"` +
|
|
98
|
+
(origin.viaJob ? ` via job "${origin.viaJob}"` : "") +
|
|
99
|
+
'. Declare access: { roles: [..., "anonymous"], personalData: "public-intake" } on ' +
|
|
80
100
|
`"${origin.rootHandler}" to allow anonymous callers to write personal data.`,
|
|
81
101
|
details: {
|
|
82
102
|
reason: FrameworkReasons.publicIntakeRequired,
|
|
83
103
|
rootHandler: origin.rootHandler,
|
|
84
104
|
target,
|
|
85
105
|
fields,
|
|
106
|
+
...(origin.viaJob !== undefined && { job: origin.viaJob }),
|
|
86
107
|
},
|
|
87
108
|
});
|
|
88
109
|
}
|
|
@@ -93,7 +114,7 @@ export function buildPersonalDataGate(
|
|
|
93
114
|
registry: Registry,
|
|
94
115
|
origin: WriteOrigin,
|
|
95
116
|
): PersonalDataGate | undefined {
|
|
96
|
-
if (!origin
|
|
117
|
+
if (!isPersonalDataGated(origin)) return undefined;
|
|
97
118
|
const map = personalDataTableMap(registry);
|
|
98
119
|
return (tableName, keys, entity) => {
|
|
99
120
|
const personalFields = entity ? personalColumnNames(entity) : map.get(tableName);
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -470,7 +470,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
470
470
|
|
|
471
471
|
// Pre-register consumer state rows so tests can call runOnce() directly
|
|
472
472
|
// without a preceding explicit start(). Timer fires at pollIntervalMs=50
|
|
473
|
-
// but
|
|
473
|
+
// but each consumer runs at most one turn at a time — tests that drain via
|
|
474
474
|
// runOnce() remain deterministic. Tests that specifically exercise the
|
|
475
475
|
// timer loop call start() again (idempotent) after setup.
|
|
476
476
|
if (eventDispatcher) await eventDispatcher.ensureRegistered();
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Captures a real closed-connection error instead of a hand-built fake —
|
|
2
|
+
// the production matcher checks driver-specific codes a fake can't reproduce.
|
|
3
|
+
|
|
4
|
+
import postgres from "postgres";
|
|
5
|
+
import { isClosedConnectionError } from "../bun-db/query";
|
|
6
|
+
|
|
7
|
+
export function testDatabaseUrl(): string {
|
|
8
|
+
return (
|
|
9
|
+
process.env["TEST_DATABASE_URL"] ??
|
|
10
|
+
process.env["DATABASE_URL"] ??
|
|
11
|
+
"postgresql://kumiko:kumiko@localhost:15432/kumiko_test"
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MAX_WARMUP_ROUNDS = 10;
|
|
16
|
+
const READS_PER_ROUND = 5;
|
|
17
|
+
|
|
18
|
+
type AdminClient = { unsafe(sql: string, params?: readonly unknown[]): Promise<unknown> };
|
|
19
|
+
|
|
20
|
+
// A per-round admin client hits a postgres-js reconnect timing bug (the first
|
|
21
|
+
// retry after terminate hangs); a single long-lived admin client avoids it.
|
|
22
|
+
export async function terminateBackendsByApplicationName(
|
|
23
|
+
admin: AdminClient,
|
|
24
|
+
applicationName: string,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
await admin.unsafe(
|
|
27
|
+
"select pg_terminate_backend(pid) from pg_stat_activity where application_name = $1",
|
|
28
|
+
[applicationName],
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Terminates a throwaway pool's backends and races reads right after — the
|
|
33
|
+
// dead-connection window is only a few ms wide, so this retries rounds until one lands.
|
|
34
|
+
export async function captureClosedConnectionError(
|
|
35
|
+
url: string = testDatabaseUrl(),
|
|
36
|
+
): Promise<unknown> {
|
|
37
|
+
const admin = postgres(url, { max: 1 });
|
|
38
|
+
try {
|
|
39
|
+
for (let round = 0; round < MAX_WARMUP_ROUNDS; round++) {
|
|
40
|
+
const applicationName = `kumiko-closed-conn-test-${crypto.randomUUID()}`;
|
|
41
|
+
const pool = postgres(url, { max: 1, connection: { application_name: applicationName } });
|
|
42
|
+
try {
|
|
43
|
+
await pool.unsafe("select 1");
|
|
44
|
+
await terminateBackendsByApplicationName(admin, applicationName);
|
|
45
|
+
for (let read = 0; read < READS_PER_ROUND; read++) {
|
|
46
|
+
try {
|
|
47
|
+
await pool.unsafe("select 1");
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (isClosedConnectionError(err)) return err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
await pool.end({ timeout: 0 });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
throw new Error(
|
|
57
|
+
`captureClosedConnectionError: no closed-connection error observed after ${MAX_WARMUP_ROUNDS} rounds.`,
|
|
58
|
+
);
|
|
59
|
+
} finally {
|
|
60
|
+
await admin.end({ timeout: 0 });
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/testing/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export { resetEntityFieldEncryptionCacheForTests } from "../db/entity-field-encr
|
|
|
15
15
|
export { rolesOf } from "./access-assertions";
|
|
16
16
|
export { expectError, expectSuccess } from "./assertions";
|
|
17
17
|
export { withBootValidatorFixture } from "./boot-validator-fixture";
|
|
18
|
+
export { captureClosedConnectionError } from "./closed-connection-error";
|
|
18
19
|
export { type ClearableTable, clearTables, resetTestTables } from "./db-cleanup";
|
|
19
20
|
export {
|
|
20
21
|
type E2EGeneratorOptions,
|