@cosmicdrift/kumiko-framework 0.353.0 → 0.354.1
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/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.d.ts +1 -0
- package/dist/api/redis-sse-broker.js +16 -10
- package/dist/api/request-context.d.ts +1 -0
- package/dist/api/request-context.js +5 -0
- package/dist/api/routes.js +4 -2
- package/dist/api/server.js +28 -3
- package/dist/api/sse-broker.d.ts +7 -3
- package/dist/api/sse-route.js +2 -6
- package/dist/api/token-expiry-timer.d.ts +1 -0
- package/dist/api/token-expiry-timer.js +10 -0
- package/dist/bun-db/query.js +29 -2
- package/dist/crypto/event-pii.js +4 -2
- package/dist/crypto/kms-wiring.js +31 -5
- package/dist/db/eagerload.d.ts +8 -3
- package/dist/db/eagerload.js +85 -81
- package/dist/db/event-store-executor-context.d.ts +12 -0
- package/dist/db/event-store-executor-context.js +59 -18
- package/dist/db/event-store-executor-read.js +81 -31
- package/dist/db/event-store-executor-write.js +54 -3
- package/dist/db/index.d.ts +1 -1
- package/dist/db/migrate-generator.js +1 -1
- package/dist/db/queries/backfill-pii.d.ts +1 -0
- package/dist/db/queries/backfill-pii.js +11 -6
- package/dist/db/queries/event-consumer.d.ts +2 -3
- package/dist/db/queries/event-consumer.js +54 -43
- package/dist/db/queries/event-store.d.ts +5 -0
- package/dist/db/queries/event-store.js +51 -3
- package/dist/db/queries/pii-backfill-state.d.ts +8 -1
- package/dist/db/queries/pii-backfill-state.js +16 -5
- package/dist/db/queries/shadow-swap.js +32 -3
- package/dist/db/render-ddl.js +6 -6
- package/dist/db/table-builder.js +6 -7
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +47 -7
- package/dist/derivatives/variant-key.d.ts +1 -0
- package/dist/derivatives/variant-key.js +3 -0
- package/dist/engine/boot-validator/access-declarations.js +5 -0
- package/dist/engine/boot-validator/config-deps.js +3 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +40 -0
- package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
- package/dist/engine/boot-validator/handler-field-references.js +25 -0
- package/dist/engine/boot-validator/index.js +6 -1
- package/dist/engine/boot-validator/nav.js +3 -1
- package/dist/engine/boot-validator/ownership.d.ts +2 -1
- package/dist/engine/boot-validator/ownership.js +32 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -1
- package/dist/engine/boot-validator/query-output-columns.js +9 -9
- package/dist/engine/boot-validator/screens.js +98 -7
- package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
- package/dist/engine/boot-validator/transfer-graph.js +8 -63
- package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
- package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
- package/dist/engine/build-app-schema.js +3 -0
- package/dist/engine/changeset-changes.d.ts +1 -0
- package/dist/engine/changeset-changes.js +49 -33
- package/dist/engine/define-feature.js +11 -0
- package/dist/engine/entity-handlers.d.ts +3 -3
- package/dist/engine/entity-handlers.js +55 -34
- package/dist/engine/escape-hatch-reason.d.ts +2 -0
- package/dist/engine/escape-hatch-reason.js +8 -0
- package/dist/engine/extension-names.d.ts +8 -9
- package/dist/engine/extension-names.js +8 -9
- package/dist/engine/extensions/tenant-data.d.ts +3 -1
- package/dist/engine/extensions/tenant-resource.d.ts +9 -1
- package/dist/engine/factories.d.ts +1 -1
- package/dist/engine/feature-changelog.d.ts +2 -0
- package/dist/engine/feature-changelog.js +6 -0
- package/dist/engine/feature-config-events-jobs.js +41 -4
- package/dist/engine/handler-field-references.d.ts +3 -0
- package/dist/engine/handler-field-references.js +31 -0
- package/dist/engine/i18n-key.js +2 -3
- package/dist/engine/index.d.ts +8 -2
- package/dist/engine/index.js +6 -1
- package/dist/engine/ownership.js +3 -4
- package/dist/engine/project-app-schema-for-roles.js +3 -1
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +8 -3
- package/dist/engine/run-pipeline.js +12 -1
- package/dist/engine/schema-builder.js +35 -2
- package/dist/engine/screen-helpers.d.ts +13 -0
- package/dist/engine/screen-helpers.js +31 -0
- package/dist/engine/soft-delete-cleanup.js +3 -2
- package/dist/engine/steps/_read-source.d.ts +4 -0
- package/dist/engine/steps/_read-source.js +22 -0
- package/dist/engine/steps/describe-workflow-step-error.js +3 -1
- package/dist/engine/steps/read-find-many.js +2 -1
- package/dist/engine/steps/read-find-one.js +2 -1
- package/dist/engine/steps/workflow-step-error.d.ts +5 -0
- package/dist/engine/steps/workflow-step-error.js +13 -0
- package/dist/engine/transfer-adjacency.d.ts +24 -0
- package/dist/engine/transfer-adjacency.js +107 -0
- package/dist/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +2 -0
- package/dist/env/index.js +45 -23
- package/dist/errors/classes.d.ts +8 -0
- package/dist/errors/classes.js +13 -0
- package/dist/errors/index.d.ts +2 -1
- package/dist/errors/index.js +2 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/errors/subject-erased.d.ts +2 -0
- package/dist/errors/subject-erased.js +9 -0
- package/dist/event-store/admin-api.js +14 -10
- package/dist/event-store/event-store.d.ts +1 -0
- package/dist/event-store/event-store.js +14 -9
- package/dist/event-store/events-schema.js +2 -1
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +30 -6
- package/dist/files/file-routes.js +10 -4
- package/dist/files/resolve-content-type.d.ts +2 -0
- package/dist/files/resolve-content-type.js +8 -0
- package/dist/i18n/required-surface-keys.d.ts +2 -0
- package/dist/i18n/required-surface-keys.js +8 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +13 -0
- package/dist/jobs/job-runner.js +128 -22
- package/dist/migrations/pii-event-backfill.js +46 -25
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +12 -0
- package/dist/pipeline/dispatch-shared.d.ts +2 -0
- package/dist/pipeline/dispatch-shared.js +74 -9
- package/dist/pipeline/dispatch-stream.d.ts +5 -1
- package/dist/pipeline/dispatch-stream.js +19 -12
- package/dist/pipeline/dispatcher.d.ts +3 -1
- package/dist/pipeline/dispatcher.js +10 -15
- package/dist/pipeline/event-consumer-state.js +0 -26
- package/dist/pipeline/event-dispatcher-admin.js +17 -11
- package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
- package/dist/pipeline/event-dispatcher-delivery.js +3 -2
- package/dist/pipeline/event-dispatcher.js +48 -7
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/member-read-only-transaction.js +25 -5
- package/dist/pipeline/msp-rebuild.js +3 -3
- package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
- package/dist/pipeline/pending-gap-ranges.js +22 -0
- package/dist/pipeline/system-hooks.js +4 -4
- package/dist/pipeline/system-identity-switch.d.ts +1 -1
- package/dist/pipeline/system-identity-switch.js +5 -4
- package/dist/pipeline/tenant-timezone-cache.js +5 -3
- package/dist/redis/cache-sync-topics.d.ts +1 -0
- package/dist/redis/cache-sync-topics.js +1 -0
- package/dist/redis/client.d.ts +1 -0
- package/dist/redis/client.js +7 -0
- package/dist/redis/index.d.ts +1 -1
- package/dist/redis/index.js +1 -1
- package/dist/redis/pubsub-signal.d.ts +1 -0
- package/dist/redis/pubsub-signal.js +3 -0
- package/dist/search/meilisearch-adapter.js +34 -4
- package/dist/stack/test-stack.d.ts +4 -1
- package/dist/stack/test-stack.js +9 -2
- package/dist/testing/e2e-generator.js +7 -2
- package/dist/ui-types/index.d.ts +3 -3
- package/dist/ui-types/index.js +2 -2
- package/dist/upgrade-cli.js +7 -2
- package/package.json +12 -4
- package/src/changes.json +209 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
2
|
import { requestContext, runWithOrigin } from "../api/request-context.js";
|
|
3
3
|
import { runInSavepoint, selectMany } from "../db/query.js";
|
|
4
|
-
import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, } from "../db/tenant-db.js";
|
|
4
|
+
import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, runInOwnTransaction, } from "../db/tenant-db.js";
|
|
5
5
|
import { createDerivativesContext } from "../derivatives/derivatives-context.js";
|
|
6
6
|
import { createSystemUser } from "../engine/system-user.js";
|
|
7
7
|
import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
|
|
@@ -40,6 +40,7 @@ export const TENANT_TIMEZONE_CONFIG_KEY = "tenant:config:timezone";
|
|
|
40
40
|
// exercises both against the real config feature.
|
|
41
41
|
export const CONFIG_WRITE_SET_TYPE = "config:write:set";
|
|
42
42
|
export const CONFIG_WRITE_RESET_TYPE = "config:write:reset";
|
|
43
|
+
export const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10_000;
|
|
43
44
|
// Narrowing-helper: AppContext.db ist DbConnection|TenantDb|undefined. Die
|
|
44
45
|
// dispatch-Pfade brauchen DbConnection (oder DbTx aus Caller-Scope) für
|
|
45
46
|
// appendEvent/projection-writes; TenantDb-Branch wird hier ausgeschlossen.
|
|
@@ -113,14 +114,57 @@ function denyingJobRunnerProxy() {
|
|
|
113
114
|
},
|
|
114
115
|
});
|
|
115
116
|
}
|
|
117
|
+
// Allowlist (not denylist): a method added to the target later stays denied
|
|
118
|
+
// until someone decides it is read-only.
|
|
119
|
+
function allowlistedReadOnlyProxy(target, allowedMethods) {
|
|
120
|
+
return new Proxy(target, {
|
|
121
|
+
get(inner, prop) {
|
|
122
|
+
if (typeof prop === "string" && allowedMethods.has(prop)) {
|
|
123
|
+
const method = Reflect.get(inner, prop, inner);
|
|
124
|
+
return typeof method === "function" ? method.bind(inner) : method;
|
|
125
|
+
}
|
|
126
|
+
// Symbol reads and "then" come from await/Promise.resolve/inspect, not from handler code.
|
|
127
|
+
if (typeof prop === "symbol" || prop === "then")
|
|
128
|
+
return undefined;
|
|
129
|
+
throw memberResolutionReadOnlyDenied();
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const MEMBER_READ_SEARCH_METHODS = new Set(["search"]);
|
|
134
|
+
const MEMBER_READ_ENTITY_CACHE_METHODS = new Set(["get", "mget"]);
|
|
135
|
+
const MEMBER_READ_REDIS_METHODS = new Set([
|
|
136
|
+
"get",
|
|
137
|
+
"mget",
|
|
138
|
+
"exists",
|
|
139
|
+
"ttl",
|
|
140
|
+
"pttl",
|
|
141
|
+
"strlen",
|
|
142
|
+
"type",
|
|
143
|
+
"hget",
|
|
144
|
+
"hmget",
|
|
145
|
+
"hgetall",
|
|
146
|
+
"hexists",
|
|
147
|
+
"hlen",
|
|
148
|
+
"sismember",
|
|
149
|
+
"smembers",
|
|
150
|
+
"scard",
|
|
151
|
+
"llen",
|
|
152
|
+
"lrange",
|
|
153
|
+
"zscore",
|
|
154
|
+
"zcard",
|
|
155
|
+
"zrange",
|
|
156
|
+
]);
|
|
116
157
|
// Every write/side-effect surface throws, so "no writeAsMember" holds even
|
|
117
158
|
// if a handler tries ctx.write directly on a resolved member principal.
|
|
159
|
+
// Deliberately open: `db` (guarded by the READ ONLY transaction + memberReadOnly grant),
|
|
160
|
+
// and read-only methods of searchAdapter / redis / entityCache (allowlisted).
|
|
118
161
|
export function applyMemberResolutionReadOnly(handlerContext) {
|
|
119
162
|
return {
|
|
120
163
|
...handlerContext,
|
|
121
164
|
// `db` stays open — executeQuery runs the whole handler in a Postgres READ ONLY
|
|
122
165
|
// transaction, and its `memberReadOnly` grant denies every raw-runner handout.
|
|
123
166
|
dbOutsideTransaction: undefined,
|
|
167
|
+
outsideTransaction: undefined,
|
|
124
168
|
write: denyMemberResolutionWrite,
|
|
125
169
|
writeAs: denyMemberResolutionWrite,
|
|
126
170
|
// A resolved member cannot switch identity either — the target would get a normal, writable context.
|
|
@@ -144,6 +188,15 @@ export function applyMemberResolutionReadOnly(handlerContext) {
|
|
|
144
188
|
derivatives: undefined,
|
|
145
189
|
...(handlerContext.jobRunner && { jobRunner: denyingJobRunnerProxy() }),
|
|
146
190
|
...(handlerContext.notify && { notify: denyMemberResolutionWrite }),
|
|
191
|
+
...(handlerContext.searchAdapter && {
|
|
192
|
+
searchAdapter: allowlistedReadOnlyProxy(handlerContext.searchAdapter, MEMBER_READ_SEARCH_METHODS),
|
|
193
|
+
}),
|
|
194
|
+
...(handlerContext.redis && {
|
|
195
|
+
redis: allowlistedReadOnlyProxy(handlerContext.redis, MEMBER_READ_REDIS_METHODS),
|
|
196
|
+
}),
|
|
197
|
+
...(handlerContext.entityCache && {
|
|
198
|
+
entityCache: allowlistedReadOnlyProxy(handlerContext.entityCache, MEMBER_READ_ENTITY_CACHE_METHODS),
|
|
199
|
+
}),
|
|
147
200
|
};
|
|
148
201
|
}
|
|
149
202
|
export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, isPostCommit) {
|
|
@@ -210,6 +263,11 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
210
263
|
? createSystemScopedDbGuard(type, "dbOutsideTransaction", "ctx.systemDb.outsideTransaction.assertTenantMatch(...) or " +
|
|
211
264
|
"ctx.systemDb.outsideTransaction.acknowledgeCrossTenant(...)")
|
|
212
265
|
: rawDbOutsideTransaction;
|
|
266
|
+
// Not offered to r.systemScope() handlers: the raw outside-tx db is unfiltered in
|
|
267
|
+
// system mode, so fn would hand them the same cross-tenant door the guards above close.
|
|
268
|
+
const outsideTransaction = !isSystem && rawDbOutsideTransaction
|
|
269
|
+
? (fn) => runInOwnTransaction(rawDbOutsideTransaction, fn)
|
|
270
|
+
: undefined;
|
|
213
271
|
const log = context.log?.child({
|
|
214
272
|
handler: type,
|
|
215
273
|
tenantId: user.tenantId,
|
|
@@ -255,7 +313,9 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
255
313
|
// _fileProviderResolver when a file-provider plugin is mounted; falls back
|
|
256
314
|
// to a statically-injected context.files (tests).
|
|
257
315
|
const fileResolver = context._fileProviderResolver;
|
|
258
|
-
const files = fileResolver
|
|
316
|
+
const files = fileResolver
|
|
317
|
+
? createFileContext(() => fileResolver(user.tenantId), user.tenantId)
|
|
318
|
+
: context.files;
|
|
259
319
|
// ctx.derivatives builds on ctx.files (needs a FileContext to read the
|
|
260
320
|
// original + write the variant) plus db (to look up the FileRef row) — so
|
|
261
321
|
// it can only be constructed exactly when files+db both resolved; falls
|
|
@@ -302,13 +362,13 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
302
362
|
ungatedMemberReader ??= createMemberReaderFn(ctx, user.tenantId, tx);
|
|
303
363
|
return ungatedMemberReader(userId, qn, payload);
|
|
304
364
|
}, { ...identitySwitchAudit, tenantId: user.tenantId });
|
|
365
|
+
// queryProjection works against both single-stream and multi-stream
|
|
366
|
+
// projections. MSPs without a table cannot be queried — those are
|
|
367
|
+
// side-effect-only consumers (no state to read back).
|
|
368
|
+
const findProjectionTable = (qualifiedName) => registry.getAllProjections().get(qualifiedName)?.table ??
|
|
369
|
+
registry.getAllMultiStreamProjections().get(qualifiedName)?.table;
|
|
305
370
|
const ungatedQueryProjection = async (qualifiedName, queryOptions) => {
|
|
306
|
-
|
|
307
|
-
// projections. MSPs without a table cannot be queried — those are
|
|
308
|
-
// side-effect-only consumers (no state to read back).
|
|
309
|
-
const singleProj = registry.getAllProjections().get(qualifiedName);
|
|
310
|
-
const mspProj = registry.getAllMultiStreamProjections().get(qualifiedName);
|
|
311
|
-
const projTable = singleProj?.table ?? mspProj?.table;
|
|
371
|
+
const projTable = findProjectionTable(qualifiedName);
|
|
312
372
|
if (!projTable) {
|
|
313
373
|
const singleNames = [...registry.getAllProjections().keys()];
|
|
314
374
|
const mspNames = [...registry.getAllMultiStreamProjections().keys()].filter((n) => registry.getAllMultiStreamProjections().get(n)?.table);
|
|
@@ -330,7 +390,11 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
330
390
|
const rows = await selectMany(dbSource, projTable, where);
|
|
331
391
|
return rows; // @cast-boundary engine-payload
|
|
332
392
|
};
|
|
333
|
-
const queryProjection = createGatedProjectionReader(`handler "${type}"`, hasIdentitySwitchGrant, ungatedQueryProjection, identitySwitchAudit)
|
|
393
|
+
const queryProjection = createGatedProjectionReader(`handler "${type}"`, hasIdentitySwitchGrant, ungatedQueryProjection, identitySwitchAudit, (qualifiedName) => {
|
|
394
|
+
const projTable = findProjectionTable(qualifiedName);
|
|
395
|
+
// Unknown projection: keep the grant check first, the ungated reader reports the real error.
|
|
396
|
+
return projTable === undefined || hasTenantColumn(projTable);
|
|
397
|
+
});
|
|
334
398
|
const bridge = {
|
|
335
399
|
query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx), // @wrapper-known semantic-alias
|
|
336
400
|
queryAs: identitySwitch.queryAs,
|
|
@@ -624,6 +688,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
624
688
|
registry,
|
|
625
689
|
db: exposedDb,
|
|
626
690
|
dbOutsideTransaction,
|
|
691
|
+
...(outsideTransaction && { outsideTransaction }),
|
|
627
692
|
...(systemDb && { systemDb }),
|
|
628
693
|
log,
|
|
629
694
|
notify,
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
import type { SessionUser } from "../engine/types/index.js";
|
|
2
2
|
import { type DispatchContext, type WriteOrigin } from "./dispatch-shared.js";
|
|
3
|
-
export
|
|
3
|
+
export type StreamOptions = {
|
|
4
|
+
/** JWT exp (epoch seconds) the stream was opened with; the stream ends when it passes. */
|
|
5
|
+
readonly tokenExpiresAtSec?: number | undefined;
|
|
6
|
+
};
|
|
7
|
+
export declare function executeStream(ctx: DispatchContext, type: string, payload: unknown, user: SessionUser, origin: WriteOrigin, options?: StreamOptions): AsyncGenerator<unknown>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { accessInvalidationCredentialFor } from "../api/sse-broker.js";
|
|
2
|
+
import { scheduleTokenExpiry } from "../api/token-expiry-timer.js";
|
|
2
3
|
import { hasAccess } from "../engine/access.js";
|
|
3
4
|
import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, validationErrorFromZod, } from "../errors/index.js";
|
|
4
5
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
@@ -10,10 +11,10 @@ import { handlerAccessError } from "./handler-access-error.js";
|
|
|
10
11
|
// of returning a single response. streamHandler never entity-maps (unlike
|
|
11
12
|
// write/queryHandler — see feature-entity-handlers.ts), so there's no
|
|
12
13
|
// field-access filter or postQuery-hook stage to run here.
|
|
13
|
-
export async function* executeStream(ctx, type, payload, user, origin) {
|
|
14
|
-
yield* runStreamInstrumented(ctx, type, user, () => executeStreamInner(ctx, type, payload, user, origin));
|
|
14
|
+
export async function* executeStream(ctx, type, payload, user, origin, options) {
|
|
15
|
+
yield* runStreamInstrumented(ctx, type, user, () => executeStreamInner(ctx, type, payload, user, origin, options));
|
|
15
16
|
}
|
|
16
|
-
async function* executeStreamInner(ctx, type, payload, user, origin) {
|
|
17
|
+
async function* executeStreamInner(ctx, type, payload, user, origin, options) {
|
|
17
18
|
const { registry } = ctx;
|
|
18
19
|
const handler = registry.getStreamHandler(type);
|
|
19
20
|
if (!handler)
|
|
@@ -32,15 +33,18 @@ async function* executeStreamInner(ctx, type, payload, user, origin) {
|
|
|
32
33
|
if (!parsed.success) {
|
|
33
34
|
throw validationErrorFromZod(parsed.error);
|
|
34
35
|
}
|
|
35
|
-
// Idle (heartbeat-only) streams must also cut on access revoke — race each
|
|
36
|
-
// pull against an
|
|
37
|
-
let
|
|
38
|
-
const
|
|
39
|
-
|
|
36
|
+
// Idle (heartbeat-only) streams must also cut on access revoke or token expiry — race each
|
|
37
|
+
// pull against an ended Deferred instead of a post-chunk boolean.
|
|
38
|
+
let resolveEnded;
|
|
39
|
+
const ended = new Promise((resolve) => {
|
|
40
|
+
resolveEnded = resolve;
|
|
40
41
|
});
|
|
41
42
|
const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
|
|
42
|
-
|
|
43
|
+
resolveEnded?.("revoked");
|
|
43
44
|
}, accessInvalidationCredentialFor(user));
|
|
45
|
+
const expiryTimer = scheduleTokenExpiry(options?.tokenExpiresAtSec, () => {
|
|
46
|
+
resolveEnded?.("expired");
|
|
47
|
+
});
|
|
44
48
|
let iterator;
|
|
45
49
|
// When access is revoked mid-pull, `iterator.next()` is still in flight.
|
|
46
50
|
// Awaiting `iterator.return()` in that state deadlocks async generators in
|
|
@@ -55,14 +59,16 @@ async function* executeStreamInner(ctx, type, payload, user, origin) {
|
|
|
55
59
|
const nextPull = iterator.next();
|
|
56
60
|
const outcome = await Promise.race([
|
|
57
61
|
nextPull.then((result) => ({ kind: "chunk", result })),
|
|
58
|
-
|
|
62
|
+
ended.then((reason) => ({ kind: "ended", reason })),
|
|
59
63
|
]);
|
|
60
|
-
if (outcome.kind === "
|
|
64
|
+
if (outcome.kind === "ended") {
|
|
61
65
|
abandonedForInvalidation = true;
|
|
62
66
|
void nextPull.catch(() => { });
|
|
63
67
|
void iterator.return?.(undefined)?.then(undefined, () => { });
|
|
64
68
|
throw new AccessDeniedError({
|
|
65
|
-
message:
|
|
69
|
+
message: outcome.reason === "expired"
|
|
70
|
+
? `token expired mid-stream for ${type}`
|
|
71
|
+
: `access revoked mid-stream for ${type}`,
|
|
66
72
|
details: { handler: type },
|
|
67
73
|
});
|
|
68
74
|
}
|
|
@@ -75,6 +81,7 @@ async function* executeStreamInner(ctx, type, payload, user, origin) {
|
|
|
75
81
|
}
|
|
76
82
|
finally {
|
|
77
83
|
unsubscribeAccessInvalidation?.();
|
|
84
|
+
clearTimeout(expiryTimer);
|
|
78
85
|
// Close the generator so cleanup runs; skip awaiting return() after
|
|
79
86
|
// access-revoke abandonment — overlapping next()+return() can deadlock.
|
|
80
87
|
if (iterator !== undefined && !abandonedForInvalidation) {
|
|
@@ -4,6 +4,7 @@ import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extensio
|
|
|
4
4
|
import type { TenantId } from "../engine/types/identifiers.js";
|
|
5
5
|
import type { ActiveMembershipResult, AppContext, DispatchWriteRef, JobRunnerRef, MemberReader, Registry, SessionUser, WriteResult } from "../engine/types/index.js";
|
|
6
6
|
import type { BatchCommand, BatchResult } from "./dispatch-shared.js";
|
|
7
|
+
import { type StreamOptions } from "./dispatch-stream.js";
|
|
7
8
|
import { type HandlerType } from "./dispatcher-utils.js";
|
|
8
9
|
import type { IdempotencyGuard } from "./idempotency.js";
|
|
9
10
|
import type { LifecycleHooks } from "./lifecycle-pipeline.js";
|
|
@@ -14,6 +15,7 @@ export type DispatcherOptions = {
|
|
|
14
15
|
lifecycle?: LifecycleHooks;
|
|
15
16
|
jobRunner?: JobRunnerRef;
|
|
16
17
|
membershipQuery?: string;
|
|
18
|
+
memberReadTimeoutMs?: number;
|
|
17
19
|
effectiveFeatures?: EffectiveFeaturesResolver;
|
|
18
20
|
sseBroker?: SseBroker;
|
|
19
21
|
cacheSync?: CacheSyncBus | null;
|
|
@@ -21,7 +23,7 @@ export type DispatcherOptions = {
|
|
|
21
23
|
export type Dispatcher = {
|
|
22
24
|
write(type: HandlerType, payload: unknown, user: SessionUser, requestId?: string): Promise<WriteResult>;
|
|
23
25
|
query(type: HandlerType, payload: unknown, user: SessionUser): Promise<unknown>;
|
|
24
|
-
stream(type: HandlerType, payload: unknown, user: SessionUser): AsyncGenerator<unknown>;
|
|
26
|
+
stream(type: HandlerType, payload: unknown, user: SessionUser, options?: StreamOptions): AsyncGenerator<unknown>;
|
|
25
27
|
command(type: HandlerType, payload: unknown, user: SessionUser): Promise<void>;
|
|
26
28
|
batch(commands: readonly BatchCommand[], user: SessionUser, requestId?: string): Promise<BatchResult>;
|
|
27
29
|
resolveAuthClaims(user: SessionUser): Promise<Record<string, unknown>>;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { runWithWriteOrigin } from "../api/request-context.js";
|
|
2
|
-
import { DEPRECATED_CROSS_TENANT_SIGNAL, isDeprecatedCrossTenantHandler, } from "../engine/entity-handlers.js";
|
|
3
2
|
import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names.js";
|
|
4
3
|
import { InternalError, reraiseAsKumikoError } from "../errors/index.js";
|
|
5
4
|
import { createEscapeHatchReportWindow, getFallbackMeter, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
|
|
@@ -7,7 +6,7 @@ import { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage } from "../redis/cache-syn
|
|
|
7
6
|
import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
|
|
8
7
|
import { runBatch, unwrapSingle } from "./dispatch-batch.js";
|
|
9
8
|
import { executeQuery } from "./dispatch-query.js";
|
|
10
|
-
import { resolveAuthClaimsFn, TENANT_TIMEZONE_CONFIG_KEY } from "./dispatch-shared.js";
|
|
9
|
+
import { DEFAULT_MEMBER_READ_TIMEOUT_MS, resolveAuthClaimsFn, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
|
|
11
10
|
import { executeStream } from "./dispatch-stream.js";
|
|
12
11
|
import { resolveType } from "./dispatcher-utils.js";
|
|
13
12
|
import { createMemberReaderFn } from "./member-reader.js";
|
|
@@ -49,6 +48,12 @@ export function dispatcherToWriteRef(dispatcher) {
|
|
|
49
48
|
export function createDispatcher(registry, context, options = {}) {
|
|
50
49
|
const { idempotency, lifecycle, jobRunner, effectiveFeatures, sseBroker } = options;
|
|
51
50
|
const membershipQuery = options.membershipQuery ?? TENANT_MEMBERSHIPS_QUERY;
|
|
51
|
+
const memberReadTimeoutMs = options.memberReadTimeoutMs ?? DEFAULT_MEMBER_READ_TIMEOUT_MS;
|
|
52
|
+
if (!Number.isSafeInteger(memberReadTimeoutMs) || memberReadTimeoutMs <= 0) {
|
|
53
|
+
throw new InternalError({
|
|
54
|
+
message: `DispatcherOptions.memberReadTimeoutMs must be a positive integer, got ${memberReadTimeoutMs}`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
52
57
|
// Pre-build tables and transition maps for auto-guard (avoid per-request allocation)
|
|
53
58
|
const tableCache = new Map();
|
|
54
59
|
const transitionCache = new Map();
|
|
@@ -75,17 +80,6 @@ export function createDispatcher(registry, context, options = {}) {
|
|
|
75
80
|
// Ensure standard metrics exist on whatever meter we ended up with.
|
|
76
81
|
// Idempotent: buildServer may have registered them already.
|
|
77
82
|
registerStandardMetrics(dispatcherMeter);
|
|
78
|
-
for (const def of [
|
|
79
|
-
...registry.getAllWriteHandlers().values(),
|
|
80
|
-
...registry.getAllQueryHandlers().values(),
|
|
81
|
-
]) {
|
|
82
|
-
if (isDeprecatedCrossTenantHandler(def.handler)) {
|
|
83
|
-
context.log?.warn(DEPRECATED_CROSS_TENANT_SIGNAL, {
|
|
84
|
-
handler: def.name,
|
|
85
|
-
migration: 'replace crossTenant: true with escapeHatch: { reason: "<why this operator handler reads/writes every tenant>" } (bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/migrate-cross-tenant.ts)',
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
83
|
const ctx = {
|
|
90
84
|
registry,
|
|
91
85
|
appContext: context,
|
|
@@ -102,6 +96,7 @@ export function createDispatcher(registry, context, options = {}) {
|
|
|
102
96
|
tracer: dispatcherTracer,
|
|
103
97
|
meter: dispatcherMeter,
|
|
104
98
|
membershipQuery,
|
|
99
|
+
memberReadTimeoutMs,
|
|
105
100
|
};
|
|
106
101
|
const dispatcher = {
|
|
107
102
|
async write(typeOrRef, payload, user, requestId) {
|
|
@@ -116,9 +111,9 @@ export function createDispatcher(registry, context, options = {}) {
|
|
|
116
111
|
const origin = rootWriteOrigin(registry, type, user);
|
|
117
112
|
return runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin));
|
|
118
113
|
},
|
|
119
|
-
stream: (typeOrRef, payload, user) => {
|
|
114
|
+
stream: (typeOrRef, payload, user, options) => {
|
|
120
115
|
const type = resolveType(typeOrRef);
|
|
121
|
-
return executeStream(ctx, type, payload, user, rootWriteOrigin(registry, type, user));
|
|
116
|
+
return executeStream(ctx, type, payload, user, rootWriteOrigin(registry, type, user), options);
|
|
122
117
|
},
|
|
123
118
|
async command(typeOrRef, payload, user) {
|
|
124
119
|
const type = resolveType(typeOrRef);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { bigint, index, instant, integer, jsonb, table as pgTable, primaryKey, sql, text, } from "../db/dialect.js";
|
|
2
2
|
import { alterTableAddColumn } from "../db/queries/ddl.js";
|
|
3
|
-
import { deleteOrphanedPerInstanceConsumerRows } from "../db/queries/event-consumer.js";
|
|
4
3
|
import { tableExists } from "../db/schema-inspection.js";
|
|
5
4
|
import { unsafePushTables } from "../stack/index.js";
|
|
6
5
|
// Reserved sentinel used in the instance_id column for consumers whose
|
|
@@ -39,20 +38,6 @@ export const eventConsumerStateTable = pgTable("kumiko_event_consumers", {
|
|
|
39
38
|
pk: primaryKey({ columns: [t.name, t.instanceId] }),
|
|
40
39
|
statusIdx: index("kumiko_event_consumers_status_idx").on(t.status),
|
|
41
40
|
}));
|
|
42
|
-
// fw#2625: these three consumers moved from delivery: "per-instance" to
|
|
43
|
-
// "shared" (see system-hooks.ts and bundled-features/feature-toggles/
|
|
44
|
-
// feature.ts). Their old per-instance rows are now orphaned — no dispatcher
|
|
45
|
-
// will ever advance them again — and pruneEvents stays pinned to whichever
|
|
46
|
-
// one has the lowest cursor forever. Named here (not imported from
|
|
47
|
-
// system-hooks.ts / feature.ts) to avoid a pipeline → system-hooks /
|
|
48
|
-
// bundled-features dependency for three string literals; those modules'
|
|
49
|
-
// own exported constants (or, for feature-toggles, the qualified consumer
|
|
50
|
-
// name qualify() produces) are the source of truth these must match.
|
|
51
|
-
const ORPHANED_PER_INSTANCE_CONSUMER_NAMES = [
|
|
52
|
-
"system:consumer:sse-broadcast",
|
|
53
|
-
"system:consumer:access-invalidation",
|
|
54
|
-
"feature-toggles:projection:toggle-cache-sync",
|
|
55
|
-
];
|
|
56
41
|
// Object-const form lets call sites write `ConsumerStatuses.disabled` instead
|
|
57
42
|
// of the raw string, which keeps status checks refactor-safe. The runtime
|
|
58
43
|
// value on each field is the same string the DB stores — no mapping needed.
|
|
@@ -84,19 +69,8 @@ export async function createEventConsumerStateTable(db) {
|
|
|
84
69
|
/* ifNotExists */ true);
|
|
85
70
|
await alterTableAddColumn(db, "kumiko_event_consumers", "last_failed_event_id", "bigint", "", "",
|
|
86
71
|
/* ifNotExists */ true);
|
|
87
|
-
// Runs on every boot, including mid-rolling-deploy: a new pod can delete
|
|
88
|
-
// an old pod's still-in-use per-instance row out from under it. That old
|
|
89
|
-
// pod's next acquireConsumer then finds no row, delivers no more SSE/
|
|
90
|
-
// access-invalidation events for the rest of its lifetime — a one-time,
|
|
91
|
-
// few-second gap that resolves itself as clients reconnect onto new pods
|
|
92
|
-
// during the same rollout. Not a replay risk: preRegisterConsumers only
|
|
93
|
-
// (re-)inserts a row at dispatcher startup, so the deleted pod won't
|
|
94
|
-
// silently get a fresh cursor-0 row and replay history mid-flight.
|
|
95
|
-
await deleteOrphanedPerInstanceConsumerRows(db, ORPHANED_PER_INSTANCE_CONSUMER_NAMES, SHARED_INSTANCE_SENTINEL);
|
|
96
72
|
// skip: table (+ any missing column) is already up to date
|
|
97
73
|
return;
|
|
98
74
|
}
|
|
99
|
-
// A freshly created table has no rows to clean up — the DELETE only
|
|
100
|
-
// matters for a DB that already ran the old per-instance consumers.
|
|
101
75
|
await unsafePushTables(db, { kumikoEventConsumers: eventConsumerStateTable });
|
|
102
76
|
}
|
|
@@ -3,7 +3,7 @@ import { selectNextEventIdAfter, selectSmallestVisibleIdInRanges, } from "../db/
|
|
|
3
3
|
import { coerceRow, extractTableInfo, selectMany } from "../db/query.js";
|
|
4
4
|
import { getEventsHighWaterMark } from "../event-store/index.js";
|
|
5
5
|
import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.js";
|
|
6
|
-
import { rangeContainsId, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges.js";
|
|
6
|
+
import { mergeContiguousGapRanges, rangeContainsId, splitRangeExcludingIds, toIdRanges, } from "./pending-gap-ranges.js";
|
|
7
7
|
// --- Ops recovery surface ---
|
|
8
8
|
//
|
|
9
9
|
// These are intentionally verb-distinct; each maps to a CLI sub-command.
|
|
@@ -84,6 +84,19 @@ export async function enableConsumer(db, name, instanceId = SHARED_INSTANCE_SENT
|
|
|
84
84
|
}
|
|
85
85
|
return applyConsumerStatusTransition(db, name, instanceId, "idle");
|
|
86
86
|
}
|
|
87
|
+
// Delivery stops at the poison event, so ids between the cursor and it were
|
|
88
|
+
// invisible during that pass (in-flight commits); keeping them as a pending gap
|
|
89
|
+
// avoids losing them when the cursor jumps over.
|
|
90
|
+
async function invisibleWindowGap(tx, cursor, poisonId) {
|
|
91
|
+
const windowStart = cursor + 1n;
|
|
92
|
+
if (poisonId <= windowStart)
|
|
93
|
+
return null;
|
|
94
|
+
return {
|
|
95
|
+
from: windowStart.toString(),
|
|
96
|
+
to: (poisonId - 1n).toString(),
|
|
97
|
+
xmax: await selectSnapshotXmax(tx),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
87
100
|
// skipPoisonEvent skips the event whose handler failed last
|
|
88
101
|
// (last_failed_event_id, set by the delivery's halt-on-poison branch): inside
|
|
89
102
|
// pending_gaps it is split out of its range, otherwise the cursor moves onto
|
|
@@ -109,7 +122,7 @@ export async function skipPoisonEvent(db, name, instanceId = SHARED_INSTANCE_SEN
|
|
|
109
122
|
const newPendingGaps = pendingGaps.flatMap((gap) => rangeContainsId(gap, pendingPoisonId)
|
|
110
123
|
? splitRangeExcludingIds(gap, [pendingPoisonId])
|
|
111
124
|
: [gap]);
|
|
112
|
-
const raw = await removePendingGapReturning(tx, name, instanceId, newPendingGaps);
|
|
125
|
+
const raw = await removePendingGapReturning(tx, name, instanceId, mergeContiguousGapRanges(newPendingGaps));
|
|
113
126
|
const updated = raw && coerceRow(raw, extractTableInfo(eventConsumerStateTable));
|
|
114
127
|
if (!updated)
|
|
115
128
|
throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`);
|
|
@@ -130,15 +143,8 @@ export async function skipPoisonEvent(db, name, instanceId = SHARED_INSTANCE_SEN
|
|
|
130
143
|
// Delivery stops at the poison event, so ids between the cursor and it were
|
|
131
144
|
// invisible during that pass (in-flight commits). Jumping the cursor over them
|
|
132
145
|
// would lose them; keep them as a pending gap like the dispatcher's own gaps.
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
? {
|
|
136
|
-
from: skippedWindowStart.toString(),
|
|
137
|
-
to: (poisonId - 1n).toString(),
|
|
138
|
-
xmax: await selectSnapshotXmax(tx),
|
|
139
|
-
}
|
|
140
|
-
: null;
|
|
141
|
-
const raw = await advanceConsumerPastEventReturning(tx, name, instanceId, poisonId, gapToAdd);
|
|
146
|
+
const gapToAdd = await invisibleWindowGap(tx, before.lastProcessedEventId, poisonId);
|
|
147
|
+
const raw = await advanceConsumerPastEventReturning(tx, name, instanceId, poisonId, mergeContiguousGapRanges(gapToAdd ? [...pendingGaps, gapToAdd] : pendingGaps));
|
|
142
148
|
const updated = raw && coerceRow(raw, extractTableInfo(eventConsumerStateTable));
|
|
143
149
|
if (!updated)
|
|
144
150
|
throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`);
|
|
@@ -68,4 +68,4 @@ export type PersistedConsumerOutcome = DeliveryOutcome & {
|
|
|
68
68
|
readonly pendingGaps: readonly PendingGapEntry[];
|
|
69
69
|
};
|
|
70
70
|
export declare function persistConsumerOutcome(tx: DbTx, name: string, instanceId: string, outcome: PersistedConsumerOutcome): Promise<void>;
|
|
71
|
-
export declare function emitLagFromTx(tx: DbTx, consumerName: string, instanceId: string, cursor: bigint, meter: Meter): Promise<void>;
|
|
71
|
+
export declare function emitLagFromTx(tx: DbTx, consumerName: string, instanceId: string, cursor: bigint, meter: Meter, pendingGapRanges: number): Promise<void>;
|
|
@@ -6,7 +6,7 @@ import { selectEventsHeadId, selectPendingAndNewEventRows, } from "../db/queries
|
|
|
6
6
|
import { coerceRow, extractTableInfo } from "../db/query.js";
|
|
7
7
|
import { qnScope } from "../engine/qualified-name.js";
|
|
8
8
|
import { eventsTable, toStoredEvent as rowToStoredEvent, } from "../event-store/index.js";
|
|
9
|
-
import { emitDispatcherError, emitEventConsumerLag, getFallbackMeter, } from "../observability/index.js";
|
|
9
|
+
import { emitDispatcherError, emitEventConsumerLag, emitEventConsumerPendingGaps, getFallbackMeter, } from "../observability/index.js";
|
|
10
10
|
import { ConsumerStatuses, eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.js";
|
|
11
11
|
import { parseWriteOrigin } from "./write-origin.js";
|
|
12
12
|
// Fails closed without throwing: a throw would poison the event for every consumer.
|
|
@@ -315,8 +315,9 @@ export async function persistConsumerOutcome(tx, name, instanceId, outcome) {
|
|
|
315
315
|
// Emit the lag gauge inside the consumer pass's tx so ops sees a snapshot
|
|
316
316
|
// consistent with the cursor we just advanced to. `MAX(id)` on the events
|
|
317
317
|
// table is an O(1) reverse-index scan — cheap even under load.
|
|
318
|
-
export async function emitLagFromTx(tx, consumerName, instanceId, cursor, meter) {
|
|
318
|
+
export async function emitLagFromTx(tx, consumerName, instanceId, cursor, meter, pendingGapRanges) {
|
|
319
319
|
const head = await selectEventsHeadId(tx);
|
|
320
320
|
const lag = head > cursor ? Number(head - cursor) : 0;
|
|
321
321
|
emitEventConsumerLag(meter, { consumer: consumerName, instanceId }, lag);
|
|
322
|
+
emitEventConsumerPendingGaps(meter, { consumer: consumerName, instanceId }, pendingGapRanges);
|
|
322
323
|
}
|
|
@@ -1,15 +1,33 @@
|
|
|
1
|
+
import { extractPgError } from "../db/pg-error.js";
|
|
1
2
|
import { recordConsumerPassFailure, selectSnapshotXmax, selectSnapshotXmin, } from "../db/queries/event-consumer.js";
|
|
2
3
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
3
4
|
import { EVENTS_PUBSUB_CHANNEL } from "../event-store/index.js";
|
|
4
5
|
import { emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, getFallbackMeter, getFallbackTracer, } from "../observability/index.js";
|
|
5
6
|
import { SHARED_INSTANCE_SENTINEL } from "./event-consumer-state.js";
|
|
6
7
|
import { acquireConsumerState, consumerInstanceId, deliverEvents, emitLagFromTx, fetchPendingEvents, markProcessing, persistConsumerOutcome, preRegisterConsumers, selectIdleConsumerKeys, } from "./event-dispatcher-delivery.js";
|
|
7
|
-
import { partitionBurntGaps, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges.js";
|
|
8
|
+
import { mergeContiguousGapRanges, partitionBurntGaps, splitRangeExcludingIds, toIdRanges, } from "./pending-gap-ranges.js";
|
|
8
9
|
const DEFAULT_BATCH_SIZE = 200;
|
|
9
10
|
const DEFAULT_POLL_MS = 100;
|
|
10
11
|
const DEFAULT_MAX_ATTEMPTS = 10;
|
|
11
12
|
const DEFAULT_REARM_COOLDOWN_MS = 5 * 60_000;
|
|
12
13
|
const DEFAULT_MAX_REARM_COUNT = 3;
|
|
14
|
+
const PENDING_GAPS_WARN_THRESHOLD = 1000;
|
|
15
|
+
// Only these say the database socket itself is gone; timeouts, schema or pool errors do not
|
|
16
|
+
// imply the dedicated LISTEN connection dropped.
|
|
17
|
+
const CONNECTION_LOSS_CODES = new Set([
|
|
18
|
+
"CONNECTION_CLOSED",
|
|
19
|
+
"CONNECTION_DESTROYED",
|
|
20
|
+
"CONNECTION_ENDED",
|
|
21
|
+
"CONNECT_TIMEOUT",
|
|
22
|
+
"ECONNREFUSED",
|
|
23
|
+
"ECONNRESET",
|
|
24
|
+
"ERR_POSTGRES_CONNECTION_CLOSED",
|
|
25
|
+
"57P01",
|
|
26
|
+
]);
|
|
27
|
+
function isConnectionLossError(e) {
|
|
28
|
+
const code = extractPgError(e)?.code;
|
|
29
|
+
return code !== undefined && CONNECTION_LOSS_CODES.has(code);
|
|
30
|
+
}
|
|
13
31
|
// Backoff for a consumer whose pass THREW (the processConsumer catch below)
|
|
14
32
|
// — an infra-level failure (db.begin's callback rejected outside deliverEvents'
|
|
15
33
|
// own per-event try/catch), not a poisoned event. Doubles per consecutive
|
|
@@ -52,6 +70,19 @@ export function createEventDispatcher(options) {
|
|
|
52
70
|
// which is fine: a fresh process re-observing a still-dead consumer is
|
|
53
71
|
// exactly the "still needs a human" signal ops wants.
|
|
54
72
|
const reportedDeadConsumers = new Set();
|
|
73
|
+
// Same once-per-process dedup for the pending_gaps size warning.
|
|
74
|
+
const reportedLargeGapSets = new Set();
|
|
75
|
+
// A long-running writer transaction pins the cluster-wide xmin, so rolled-back appends
|
|
76
|
+
// (each burns a sequence id) accumulate as gaps and every turn ships them all to the fetch.
|
|
77
|
+
const warnOnceOnLargeGapSet = (consumerName, instance, count) => {
|
|
78
|
+
const key = `${consumerName}:${instance}`;
|
|
79
|
+
// skip: below the warn threshold, or already reported once for this consumer instance
|
|
80
|
+
if (count <= PENDING_GAPS_WARN_THRESHOLD || reportedLargeGapSets.has(key))
|
|
81
|
+
return;
|
|
82
|
+
reportedLargeGapSets.add(key);
|
|
83
|
+
logDispatcherError(`[event-dispatcher] ${consumerName} tracks ${count} pending gap ranges (> ${PENDING_GAPS_WARN_THRESHOLD}); ` +
|
|
84
|
+
`a long-running write transaction is probably holding back the snapshot xmin`);
|
|
85
|
+
};
|
|
55
86
|
// Per-(consumer, instanceId) backoff after a thrown pass (see the catch in
|
|
56
87
|
// processConsumer). Keyed the same way as reportedDeadConsumers.
|
|
57
88
|
// consecutiveFailures drives the exponential delay; retryAtMs is the
|
|
@@ -67,6 +98,9 @@ export function createEventDispatcher(options) {
|
|
|
67
98
|
// unrelated outage gets its own fresh log line instead of staying
|
|
68
99
|
// permanently suppressed by this process's flag.
|
|
69
100
|
let idlePreCheckErrorLogged = false;
|
|
101
|
+
// Mirrors the last gauge value derived from the LISTEN connection (set by onlisten), so the
|
|
102
|
+
// pre-check recovery can restore it after a connection-loss error flipped it to 0.
|
|
103
|
+
let listenSubscribed = false;
|
|
70
104
|
let running = false;
|
|
71
105
|
// Separate from `running` on purpose: pre-registration of consumer state
|
|
72
106
|
// rows is a one-time boot action, while running/timer/LISTEN is a
|
|
@@ -241,6 +275,7 @@ export function createEventDispatcher(options) {
|
|
|
241
275
|
})));
|
|
242
276
|
if (idlePreCheckErrorLogged) {
|
|
243
277
|
idlePreCheckErrorLogged = false;
|
|
278
|
+
emitEventDispatcherListenConnected(meter, listenSubscribed);
|
|
244
279
|
const recoveredMsg = "[event-dispatcher] idle pre-check recovered, database reachable again";
|
|
245
280
|
if (context.log) {
|
|
246
281
|
context.log.info(recoveredMsg);
|
|
@@ -258,9 +293,10 @@ export function createEventDispatcher(options) {
|
|
|
258
293
|
// signal for ops, not a delivery failure.
|
|
259
294
|
if (!idlePreCheckErrorLogged) {
|
|
260
295
|
idlePreCheckErrorLogged = true;
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
|
|
296
|
+
// A lost DB socket takes the LISTEN peer down with it; other pre-check failures
|
|
297
|
+
// (statement_timeout, pool exhaustion, schema errors) say nothing about LISTEN.
|
|
298
|
+
if (isConnectionLossError(e))
|
|
299
|
+
emitEventDispatcherListenConnected(meter, false);
|
|
264
300
|
const msg = e instanceof Error ? e.message : String(e);
|
|
265
301
|
const logMsg = `[event-dispatcher] idle pre-check failed, falling back to per-consumer locking every tick until it recovers: ${msg}`;
|
|
266
302
|
logDispatcherError(logMsg);
|
|
@@ -290,6 +326,7 @@ export function createEventDispatcher(options) {
|
|
|
290
326
|
"consumer.instance_id": instanceId,
|
|
291
327
|
},
|
|
292
328
|
});
|
|
329
|
+
let cursorBeforePass;
|
|
293
330
|
try {
|
|
294
331
|
await db.begin(async (tx) => {
|
|
295
332
|
const acquired = await acquireConsumerState(tx, consumer.name, instanceId, rearmCooldownMs, maxRearmCount);
|
|
@@ -313,6 +350,7 @@ export function createEventDispatcher(options) {
|
|
|
313
350
|
// staying permanently suppressed by this process's Set.
|
|
314
351
|
reportedDeadConsumers.delete(`${consumer.name}:${instanceId}`);
|
|
315
352
|
const oldCursor = acquired.state.lastProcessedEventId;
|
|
353
|
+
cursorBeforePass = oldCursor;
|
|
316
354
|
const pendingGaps = acquired.state.pendingGaps;
|
|
317
355
|
// xmin BEFORE the fetch: a range is provably burnt only once this has
|
|
318
356
|
// passed the xmax recorded with it. Only needed when ranges exist.
|
|
@@ -377,10 +415,11 @@ export function createEventDispatcher(options) {
|
|
|
377
415
|
}));
|
|
378
416
|
const persistedOutcome = {
|
|
379
417
|
...outcome,
|
|
380
|
-
pendingGaps: [...keptGaps, ...newGaps],
|
|
418
|
+
pendingGaps: mergeContiguousGapRanges([...keptGaps, ...newGaps]),
|
|
381
419
|
};
|
|
420
|
+
warnOnceOnLargeGapSet(consumer.name, instanceId, persistedOutcome.pendingGaps.length);
|
|
382
421
|
await persistConsumerOutcome(tx, consumer.name, instanceId, persistedOutcome);
|
|
383
|
-
await emitLagFromTx(tx, consumer.name, instanceId, outcome.cursor, meter);
|
|
422
|
+
await emitLagFromTx(tx, consumer.name, instanceId, outcome.cursor, meter, persistedOutcome.pendingGaps.length);
|
|
384
423
|
});
|
|
385
424
|
// Pass completed without throwing — clear any backoff from a prior
|
|
386
425
|
// failure so the next unrelated outage gets its own fresh delay
|
|
@@ -409,7 +448,7 @@ export function createEventDispatcher(options) {
|
|
|
409
448
|
// so any failure here is swallowed after logging.
|
|
410
449
|
try {
|
|
411
450
|
await db.begin(async (tx) => {
|
|
412
|
-
await recordConsumerPassFailure(tx, consumer.name, instanceId, msg);
|
|
451
|
+
await recordConsumerPassFailure(tx, consumer.name, instanceId, msg, cursorBeforePass);
|
|
413
452
|
});
|
|
414
453
|
}
|
|
415
454
|
catch (persistErr) {
|
|
@@ -472,6 +511,7 @@ export function createEventDispatcher(options) {
|
|
|
472
511
|
// ponytail: no own LISTEN reconnect, the backoff gap is polled;
|
|
473
512
|
// add a dedicated LISTEN connection if prod wires a pgClient
|
|
474
513
|
// and that latency matters.
|
|
514
|
+
listenSubscribed = true;
|
|
475
515
|
emitEventDispatcherListenConnected(meter, true);
|
|
476
516
|
});
|
|
477
517
|
pgUnlisten = sub.unlisten;
|
|
@@ -498,6 +538,7 @@ export function createEventDispatcher(options) {
|
|
|
498
538
|
// subscription is being torn down anyway.
|
|
499
539
|
});
|
|
500
540
|
pgUnlisten = null;
|
|
541
|
+
listenSubscribed = false;
|
|
501
542
|
emitEventDispatcherListenConnected(meter, false);
|
|
502
543
|
}
|
|
503
544
|
// Drain any in-flight pass so shutdown observes consistent state —
|
package/dist/pipeline/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export type { EscapeHatchReportWindow } from "../observability/escape-hatch-report.js";
|
|
2
2
|
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "../observability/escape-hatch-report.js";
|
|
3
|
+
export type { AppendDomainEventCoreDeps } from "./append-event-core.js";
|
|
4
|
+
export { appendDomainEventCore } from "./append-event-core.js";
|
|
3
5
|
export type { ResolveAuthClaimsArgs } from "./auth-claims-resolver.js";
|
|
4
6
|
export { resolveAuthClaims } from "./auth-claims-resolver.js";
|
|
5
7
|
export { createCascadeDeleteHook } from "./cascade-handler.js";
|
package/dist/pipeline/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "../observability/escape-hatch-report.js";
|
|
2
|
+
export { appendDomainEventCore } from "./append-event-core.js";
|
|
2
3
|
export { resolveAuthClaims } from "./auth-claims-resolver.js";
|
|
3
4
|
export { createCascadeDeleteHook } from "./cascade-handler.js";
|
|
4
5
|
export { createDispatcher, dispatcherToWriteRef } from "./dispatcher.js";
|