@cosmicdrift/kumiko-framework 0.334.0 → 0.336.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/dist/api/anonymous-live-entities.d.ts +2 -0
- package/dist/api/anonymous-live-entities.js +17 -0
- package/dist/api/auth-routes.js +19 -4
- package/dist/api/client-ip.js +3 -6
- package/dist/api/extra-route.js +3 -2
- package/dist/api/index.d.ts +2 -1
- package/dist/api/index.js +1 -0
- package/dist/api/pii-leak-guard.js +7 -4
- package/dist/api/redis-sse-broker.d.ts +2 -0
- package/dist/api/redis-sse-broker.js +6 -7
- package/dist/api/routes.js +16 -17
- package/dist/api/server.js +20 -10
- package/dist/api/sse-broker.d.ts +3 -0
- package/dist/api/sse-broker.js +14 -0
- package/dist/api/sse-route.d.ts +5 -2
- package/dist/api/sse-route.js +71 -11
- package/dist/bun-db/connection.js +0 -2
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +1 -0
- package/dist/bun-db/query.js +15 -4
- package/dist/crypto/kek-source.js +40 -36
- package/dist/crypto/kms-adapter.js +2 -2
- package/dist/db/apply-entity-event.js +8 -2
- package/dist/db/blind-index-cleanup.js +32 -11
- package/dist/db/entity-table-meta.js +5 -3
- package/dist/db/event-store-executor-context.js +13 -19
- package/dist/db/event-store-executor-read.js +45 -38
- package/dist/db/event-store-executor-write.js +15 -17
- package/dist/db/migrate-generator.js +27 -28
- package/dist/db/pg-error.js +9 -5
- package/dist/db/queries/backfill-pii.js +15 -5
- package/dist/db/queries/shadow-swap.js +5 -4
- package/dist/db/table-builder.d.ts +1 -0
- package/dist/db/table-builder.js +7 -15
- package/dist/db/tenant-db.js +18 -2
- package/dist/derivatives/variant-key.js +3 -1
- package/dist/engine/boot-validator/access-declarations.js +12 -3
- package/dist/engine/boot-validator/entity-handler.js +13 -1
- package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/index.js +6 -6
- package/dist/engine/boot-validator/nav.d.ts +2 -1
- package/dist/engine/boot-validator/nav.js +10 -20
- package/dist/engine/boot-validator/ownership.d.ts +1 -1
- package/dist/engine/boot-validator/ownership.js +2 -1
- package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
- package/dist/engine/boot-validator/pii-retention.js +5 -2
- package/dist/engine/boot-validator/projection-list-screens.js +8 -0
- package/dist/engine/boot-validator/query-output-columns.js +53 -5
- package/dist/engine/boot-validator/screens.d.ts +2 -2
- package/dist/engine/boot-validator/screens.js +125 -46
- package/dist/engine/boot-validator/transfer-graph.js +9 -6
- package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
- package/dist/engine/boot-validator/zod-shape.js +17 -4
- package/dist/engine/build-app-schema.js +65 -20
- package/dist/engine/build-config-feature-schema.js +8 -1
- package/dist/engine/changeset-changes.js +24 -10
- package/dist/engine/create-app.js +10 -1
- package/dist/engine/extension-names.d.ts +4 -0
- package/dist/engine/extension-names.js +4 -0
- package/dist/engine/feature-ast/extractors/events.js +11 -8
- package/dist/engine/feature-ast/extractors/handlers.js +39 -16
- package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
- package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
- package/dist/engine/feature-ast/parse.d.ts +1 -0
- package/dist/engine/feature-ast/parse.js +18 -3
- package/dist/engine/feature-ast/patch.js +80 -61
- package/dist/engine/feature-ast/patcher.d.ts +1 -0
- package/dist/engine/feature-ast/patcher.js +2 -1
- package/dist/engine/feature-ast/render.js +1 -1
- package/dist/engine/feature-entity-handlers.d.ts +1 -0
- package/dist/engine/feature-entity-handlers.js +2 -0
- package/dist/engine/index.d.ts +4 -3
- package/dist/engine/index.js +3 -2
- package/dist/engine/ownership.d.ts +1 -0
- package/dist/engine/ownership.js +15 -2
- package/dist/engine/pattern-library/mixed-schemas.js +15 -29
- package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
- package/dist/engine/pattern-library/shared-fields.js +17 -0
- package/dist/engine/registry-facade.js +3 -0
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +14 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/role-assignment.d.ts +7 -1
- package/dist/engine/role-assignment.js +27 -7
- package/dist/engine/schema-builder.d.ts +1 -0
- package/dist/engine/schema-builder.js +9 -2
- package/dist/engine/screen-helpers.js +15 -4
- package/dist/engine/steps/_event-match.js +34 -7
- package/dist/engine/steps/_read-source.js +8 -0
- package/dist/engine/system-user.d.ts +1 -0
- package/dist/engine/system-user.js +3 -0
- package/dist/engine/where-rule-lint.js +22 -4
- package/dist/env/dry-run.js +10 -3
- package/dist/env/index.js +21 -1
- package/dist/errors/reasons.d.ts +0 -1
- package/dist/errors/reasons.js +2 -5
- package/dist/event-store/event-store.js +10 -2
- package/dist/event-store/provenance-append.js +4 -1
- package/dist/files/file-routes.d.ts +0 -1
- package/dist/files/file-routes.js +22 -7
- package/dist/files/local-provider.js +13 -10
- package/dist/files/provider-resolver.js +20 -1
- package/dist/files/types.js +16 -1
- package/dist/i18n/request-locale.d.ts +3 -2
- package/dist/i18n/request-locale.js +16 -6
- package/dist/i18n/required-surface-keys.js +23 -35
- package/dist/i18n/settings-hub-keys.js +3 -0
- package/dist/jobs/index.d.ts +2 -2
- package/dist/jobs/index.js +1 -1
- package/dist/jobs/job-runner.d.ts +2 -0
- package/dist/jobs/job-runner.js +172 -59
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.js +1 -1
- package/dist/migrations/pending-rebuilds.d.ts +4 -0
- package/dist/migrations/pending-rebuilds.js +23 -1
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +3 -0
- package/dist/observability/index.d.ts +2 -1
- package/dist/observability/index.js +2 -1
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +16 -0
- package/dist/pipeline/dispatch-query.d.ts +1 -1
- package/dist/pipeline/dispatch-query.js +2 -2
- package/dist/pipeline/dispatch-shared.d.ts +2 -3
- package/dist/pipeline/dispatch-shared.js +14 -9
- package/dist/pipeline/dispatch-stream.js +2 -5
- package/dist/pipeline/dispatch-write.js +22 -5
- package/dist/pipeline/dispatcher.js +1 -2
- package/dist/pipeline/event-dispatcher-admin.js +12 -2
- package/dist/pipeline/event-dispatcher-delivery.js +5 -0
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +30 -12
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/pipeline/member-read-only-transaction.js +1 -14
- package/dist/pipeline/msp-rebuild.d.ts +1 -0
- package/dist/pipeline/msp-rebuild.js +1 -0
- package/dist/pipeline/system-hooks.d.ts +1 -1
- package/dist/pipeline/system-hooks.js +19 -20
- package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
- package/dist/pipeline/tenant-timezone-cache.js +20 -6
- package/dist/pipeline/write-origin.js +13 -6
- package/dist/redis/pubsub-signal.d.ts +2 -0
- package/dist/redis/pubsub-signal.js +2 -2
- package/dist/schema-cli.js +4 -14
- package/dist/search/meilisearch-adapter.js +17 -6
- package/dist/search/purge-subject.js +8 -3
- package/dist/stack/drain-event-consumers.js +3 -0
- package/dist/stack/drain-jobs.d.ts +3 -1
- package/dist/stack/drain-jobs.js +55 -13
- package/dist/stack/table-helpers.js +10 -8
- package/dist/testing/closed-connection-error.js +2 -3
- package/dist/testing/wait-for.js +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.d.ts +1 -0
- package/dist/upgrade-cli.js +36 -15
- package/package.json +4 -4
- package/src/changes.json +264 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
- package/src/scripts/codemod/migrate-db-raw.ts +16 -8
- package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
- package/src/scripts/codemod/pii-personal-migration.ts +111 -44
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +136 -42
- package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
- package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
- package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
- package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
- package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
- package/dist/scripts/codemod/migrate-db-raw.js +0 -345
- package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
- package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
- package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
- package/dist/scripts/codemod/pii-personal-migration.js +0 -615
- package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
- package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
package/dist/jobs/job-runner.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Queue, Worker } from "bullmq";
|
|
1
|
+
import { Queue, UnrecoverableError, Worker } from "bullmq";
|
|
2
2
|
import { Redis } from "ioredis";
|
|
3
3
|
import { requestContext } from "../api/request-context.js";
|
|
4
4
|
import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db.js";
|
|
@@ -10,8 +10,7 @@ import { InternalError } from "../errors/index.js";
|
|
|
10
10
|
import { isKumikoError } from "../errors/kumiko-error.js";
|
|
11
11
|
import { createFileContext } from "../files/file-handle.js";
|
|
12
12
|
import { createFallbackLogger } from "../logging/index.js";
|
|
13
|
-
import { createEscapeHatchReporter } from "../observability/
|
|
14
|
-
import { emitJobLastSuccess, emitJobQueueDepth, getFallbackTracer, } from "../observability/index.js";
|
|
13
|
+
import { createEscapeHatchReporter, emitJobLastSuccess, emitJobQueueDepth, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
|
|
15
14
|
import { createDistributedLock } from "../pipeline/distributed-lock.js";
|
|
16
15
|
import { RedisKeys } from "../pipeline/redis-keys.js";
|
|
17
16
|
import { buildPersonalDataGate, isPersonalDataGated, parseWriteOrigin, } from "../pipeline/write-origin.js";
|
|
@@ -122,6 +121,14 @@ function createJobLogger(logs, baseContext = {}) {
|
|
|
122
121
|
};
|
|
123
122
|
return logger;
|
|
124
123
|
}
|
|
124
|
+
// Single source of the stored subject string: the writer (jobSubjectKey) and
|
|
125
|
+
// the tenant failures query both go through it. Matching is exact — field names
|
|
126
|
+
// and JSON types must equal what the writer stored (`"1"` is not `1`).
|
|
127
|
+
export function serializeJobSubject(subject) {
|
|
128
|
+
return JSON.stringify(Object.keys(subject)
|
|
129
|
+
.sort()
|
|
130
|
+
.map((field) => [field, subject[field] ?? null]));
|
|
131
|
+
}
|
|
125
132
|
// Stable identity for the declared payload fields: sorted keys, so two runs
|
|
126
133
|
// with the same subject values produce the same string and the later one
|
|
127
134
|
// replaces the earlier record. Non-primitives would serialize into something
|
|
@@ -129,15 +136,31 @@ function createJobLogger(logs, baseContext = {}) {
|
|
|
129
136
|
function jobSubjectKey(jobName, payload, fields) {
|
|
130
137
|
if (fields === undefined || fields.length === 0)
|
|
131
138
|
return null;
|
|
132
|
-
const
|
|
133
|
-
for (const field of
|
|
139
|
+
const subject = {};
|
|
140
|
+
for (const field of fields) {
|
|
134
141
|
const value = payload[field] ?? null;
|
|
135
142
|
if (value !== null && typeof value === "object") {
|
|
136
143
|
throw new Error(`Job "${jobName}": tenantVisibleFailure.subjectFields["${field}"] must be a primitive, got ${typeof value}`);
|
|
137
144
|
}
|
|
138
|
-
|
|
145
|
+
subject[field] = value;
|
|
139
146
|
}
|
|
140
|
-
return
|
|
147
|
+
return serializeJobSubject(subject);
|
|
148
|
+
}
|
|
149
|
+
// Jobs that never touch ctx.db run fine on a runner built without one; a job
|
|
150
|
+
// that does gets a named error instead of a bare TypeError on undefined.
|
|
151
|
+
function missingDbProxy(jobName) {
|
|
152
|
+
// @cast-boundary db-operator — every access throws, so no TenantDb member is ever returned
|
|
153
|
+
return new Proxy({}, {
|
|
154
|
+
get() {
|
|
155
|
+
throw new InternalError({
|
|
156
|
+
message: `[jobs] job runner constructed without a db connection — ctx.db is unavailable for "${jobName}"`,
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
function priorAttemptsOf(data) {
|
|
162
|
+
const value = data["_priorAttempts"];
|
|
163
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 0;
|
|
141
164
|
}
|
|
142
165
|
// Only a translation key ever travels to the tenant: the thrown error's own
|
|
143
166
|
// i18nKey when it carries one, otherwise the key declared at the job. The
|
|
@@ -202,6 +225,12 @@ function parseRedisOpts(url) {
|
|
|
202
225
|
// redisOpts carries no connectTimeout/retry cap, so an unreachable Redis
|
|
203
226
|
// would otherwise hang start() forever with no health endpoint to notice.
|
|
204
227
|
const BOOT_REDIS_TIMEOUT_MS = 10_000;
|
|
228
|
+
// Shutdown must fit a k8s grace period even when Redis is down, so the
|
|
229
|
+
// readiness wait in stop() is capped well below the boot timeout.
|
|
230
|
+
const STOP_QUEUE_READY_TIMEOUT_MS = 1_000;
|
|
231
|
+
// A gate without an explicit `timeout` may legitimately run long (migration-style
|
|
232
|
+
// checks), so it is never rejected; a hang just must not stay silent.
|
|
233
|
+
const BOOT_GATE_SLOW_WARN_MS = 60_000;
|
|
205
234
|
function timeoutReject(ms, message) {
|
|
206
235
|
let timer;
|
|
207
236
|
const promise = new Promise((_, reject) => {
|
|
@@ -224,10 +253,10 @@ const DEFAULT_JOB_BACKOFF_DELAY_MS = 1_000;
|
|
|
224
253
|
// perTenant wrapper/children, sequential re-enqueue) — a job with `retries`
|
|
225
254
|
// set must retry the same way regardless of how it got enqueued, or it
|
|
226
255
|
// fails for good on the very first error on whichever path skips this.
|
|
227
|
-
function buildRetryBullOpts(jobDef) {
|
|
256
|
+
function buildRetryBullOpts(jobDef, priorAttempts = 0) {
|
|
228
257
|
const opts = {};
|
|
229
258
|
if (jobDef.retries !== undefined)
|
|
230
|
-
opts.attempts = jobDef.retries + 1;
|
|
259
|
+
opts.attempts = Math.max(1, jobDef.retries + 1 - priorAttempts);
|
|
231
260
|
if (jobDef.backoff) {
|
|
232
261
|
opts.backoff =
|
|
233
262
|
typeof jobDef.backoff === "string"
|
|
@@ -282,6 +311,10 @@ export function createJobRunner(options) {
|
|
|
282
311
|
// boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
|
|
283
312
|
const tracer = context.tracer ?? getFallbackTracer();
|
|
284
313
|
const errorLogger = createFallbackLogger("job-runner", context.log);
|
|
314
|
+
// The last-success gauge throws if its metric was never registered; the runner
|
|
315
|
+
// must not depend on buildServer having done it first. Idempotent.
|
|
316
|
+
if (context.meter)
|
|
317
|
+
registerStandardMetrics(context.meter);
|
|
285
318
|
// Set at the top of stop() — a graceful shutdown closes the redis/BullMQ
|
|
286
319
|
// clients itself, which fires the exact same 'error' listeners below with
|
|
287
320
|
// an expected "Connection is closed." Downgrading to debug once stopping
|
|
@@ -318,7 +351,12 @@ export function createJobRunner(options) {
|
|
|
318
351
|
registry,
|
|
319
352
|
...bridgeStub(),
|
|
320
353
|
});
|
|
321
|
-
|
|
354
|
+
if (!Array.isArray(result) || result.some((id) => typeof id !== "string")) {
|
|
355
|
+
throw new InternalError({
|
|
356
|
+
message: `${ACTIVE_TENANT_IDS_QUERY_NAME} must return string[] of tenant ids`,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
// @cast-boundary engine-payload — checked to be string[] above; TenantId is a brand
|
|
322
360
|
return result;
|
|
323
361
|
};
|
|
324
362
|
}
|
|
@@ -455,6 +493,12 @@ export function createJobRunner(options) {
|
|
|
455
493
|
}
|
|
456
494
|
return false;
|
|
457
495
|
}
|
|
496
|
+
async function reportPreRunFailure(jobName, bullJob, err) {
|
|
497
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
498
|
+
await options.onJobFailed?.(jobName, bullJob.id ?? "unknown", message, [
|
|
499
|
+
{ level: "error", message, timestamp: Temporal.Now.instant() },
|
|
500
|
+
]);
|
|
501
|
+
}
|
|
458
502
|
async function handleJob(bullJob) {
|
|
459
503
|
const rawName = bullJob.name;
|
|
460
504
|
// Handle perTenant dispatch jobs — fan out to one job per tenant. The
|
|
@@ -463,34 +507,51 @@ export function createJobRunner(options) {
|
|
|
463
507
|
// (it's picked up by this runner's own worker).
|
|
464
508
|
if (rawName.startsWith("_perTenant:")) {
|
|
465
509
|
const actualName = rawName.slice("_perTenant:".length);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
510
|
+
// These throws happen before runInSpan, so its onJobFailed never sees them
|
|
511
|
+
// and a drained test stack would stay green over a fan-out that never ran.
|
|
512
|
+
try {
|
|
513
|
+
if (!getActiveTenantIds) {
|
|
514
|
+
if (registry.getQueryHandler(ACTIVE_TENANT_IDS_QUERY_NAME)) {
|
|
515
|
+
throw new Error(`perTenant job "${actualName}": the tenant feature is mounted but the job runner has no ` +
|
|
516
|
+
"`context.db`: pass a DbConnection in the runner context or supply options.getActiveTenantIds");
|
|
517
|
+
}
|
|
518
|
+
throw new Error(`perTenant job "${actualName}" requires either options.getActiveTenantIds or the ` +
|
|
519
|
+
`tenant feature mounted (it registers "${ACTIVE_TENANT_IDS_QUERY_NAME}", which the ` +
|
|
520
|
+
"framework uses to resolve active tenants on its own)");
|
|
521
|
+
}
|
|
522
|
+
const actualDef = allJobs.get(actualName);
|
|
523
|
+
if (!actualDef) {
|
|
524
|
+
throw new Error(`Unknown job: ${actualName}`);
|
|
525
|
+
}
|
|
526
|
+
const tenantIds = await getActiveTenantIds();
|
|
527
|
+
const targetQueue = queues[laneForJob(actualDef)];
|
|
528
|
+
// wrapperJobId is only absent for a Job BullMQ somehow never assigned
|
|
529
|
+
// an id to (not observed in practice) — falling back to a constant
|
|
530
|
+
// there would collide every such run's children onto the same ids
|
|
531
|
+
// and silently swallow later runs as "duplicates", which is worse
|
|
532
|
+
// than the duplicate this is meant to prevent. Skip id-based dedup
|
|
533
|
+
// for that edge case instead: enqueue plainly, same as before.
|
|
534
|
+
const wrapperJobId = bullJob.id;
|
|
535
|
+
for (const tenantId of tenantIds) {
|
|
536
|
+
await targetQueue.add(actualName, { ...bullJob.data, _tenantId: tenantId }, {
|
|
537
|
+
...buildRetryBullOpts(actualDef),
|
|
538
|
+
// Dedup over wrapper retries only holds as long as the children
|
|
539
|
+
// stay in Redis for the wrapper's whole retry window — see the
|
|
540
|
+
// COMPLETED_JOB_RETENTION_AGE_SEC invariant check above.
|
|
541
|
+
...(wrapperJobId !== undefined
|
|
542
|
+
? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
|
|
543
|
+
: {}),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
474
546
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const wrapperJobId = bullJob.id;
|
|
484
|
-
for (const tenantId of tenantIds) {
|
|
485
|
-
await targetQueue.add(actualName, { ...bullJob.data, _tenantId: tenantId }, {
|
|
486
|
-
...buildRetryBullOpts(actualDef),
|
|
487
|
-
// Dedup over wrapper retries only holds as long as the children
|
|
488
|
-
// stay in Redis for the wrapper's whole retry window — see the
|
|
489
|
-
// COMPLETED_JOB_RETENTION_AGE_SEC invariant check above.
|
|
490
|
-
...(wrapperJobId !== undefined
|
|
491
|
-
? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
|
|
492
|
-
: {}),
|
|
493
|
-
});
|
|
547
|
+
catch (err) {
|
|
548
|
+
// A retry may still succeed, and a succeeding wrapper reports no completion
|
|
549
|
+
// that could clear an earlier failure, so only the final attempt is reported.
|
|
550
|
+
const retries = allJobs.get(actualName)?.retries ?? 0;
|
|
551
|
+
if (bullJob.attemptsMade + 1 >= retries + 1) {
|
|
552
|
+
await reportPreRunFailure(rawName, bullJob, err);
|
|
553
|
+
}
|
|
554
|
+
throw err;
|
|
494
555
|
}
|
|
495
556
|
// skip: fan-out dispatcher job, per-tenant children enqueued
|
|
496
557
|
return;
|
|
@@ -498,7 +559,9 @@ export function createJobRunner(options) {
|
|
|
498
559
|
const jobName = rawName;
|
|
499
560
|
const jobDef = allJobs.get(jobName);
|
|
500
561
|
if (!jobDef) {
|
|
501
|
-
|
|
562
|
+
const err = new Error(`Unknown job: ${jobName}`);
|
|
563
|
+
await reportPreRunFailure(jobName, bullJob, err);
|
|
564
|
+
throw err;
|
|
502
565
|
}
|
|
503
566
|
// Sequential gate: try to claim the per-name lock. If another worker
|
|
504
567
|
// (or this worker on a different bullJob) holds it, re-enqueue with a
|
|
@@ -516,13 +579,12 @@ export function createJobRunner(options) {
|
|
|
516
579
|
// same queue the worker just picked from (since only the consuming
|
|
517
580
|
// lane runs handleJob at all), but route explicitly — no implicit
|
|
518
581
|
// coupling to "whichever queue the caller happened to be on".
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
await queues[laneForJob(jobDef)].add(jobName, bullJob.data, {
|
|
582
|
+
// Attempts already spent travel in _priorAttempts so a lock conflict
|
|
583
|
+
// on a retry cannot hand out a fresh budget and exceed `retries`.
|
|
584
|
+
const priorAttempts = priorAttemptsOf(bullJob.data) + bullJob.attemptsMade;
|
|
585
|
+
await queues[laneForJob(jobDef)].add(jobName, { ...bullJob.data, _priorAttempts: priorAttempts }, {
|
|
524
586
|
delay: SEQUENTIAL_RETRY_DELAY_MS,
|
|
525
|
-
...buildRetryBullOpts(jobDef),
|
|
587
|
+
...buildRetryBullOpts(jobDef, priorAttempts),
|
|
526
588
|
});
|
|
527
589
|
// skip: lock taken, work re-enqueued with delay, current invocation done
|
|
528
590
|
return;
|
|
@@ -537,7 +599,7 @@ export function createJobRunner(options) {
|
|
|
537
599
|
// retry number, so audit queries distinguish fresh from retry runs
|
|
538
600
|
// without peeking at BullMQ internals.
|
|
539
601
|
const rawData = bullJob.data;
|
|
540
|
-
const attempt = bullJob.attemptsMade + 1;
|
|
602
|
+
const attempt = bullJob.attemptsMade + 1 + priorAttemptsOf(rawData);
|
|
541
603
|
const meta = {
|
|
542
604
|
triggeredById: rawData["_triggeredById"], // @cast-boundary dynamic-key
|
|
543
605
|
payload: rawData["_payload"], // @cast-boundary dynamic-key
|
|
@@ -558,16 +620,26 @@ export function createJobRunner(options) {
|
|
|
558
620
|
// subject is read from the handler payload, so it is resolved here where
|
|
559
621
|
// the payload is built, not in the callbacks.
|
|
560
622
|
const tenantVisibleDecl = jobDef.tenantVisibleFailure;
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
623
|
+
// A bad subject config must surface as a failed run, so it is thrown from
|
|
624
|
+
// inside runInSpan below instead of here, before any run record exists.
|
|
625
|
+
let tenantVisibleSubject = null;
|
|
626
|
+
let subjectConfigError;
|
|
627
|
+
if (tenantVisibleDecl) {
|
|
628
|
+
try {
|
|
629
|
+
tenantVisibleSubject = jobSubjectKey(jobName, payload, tenantVisibleDecl.subjectFields);
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
subjectConfigError = err instanceof Error ? err : new Error(String(err));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
564
635
|
// BullMQ stops retrying once attemptsMade reaches the configured attempts
|
|
565
636
|
// (`retries + 1`), so this is the attempt whose failure is final.
|
|
566
|
-
const finalAttempt = attempt >= (jobDef.retries ?? 0) + 1;
|
|
637
|
+
const finalAttempt = bullJob.retryable === false || attempt >= (jobDef.retries ?? 0) + 1;
|
|
567
638
|
const outcomeMeta = (messageKey) => ({
|
|
568
639
|
tenantId,
|
|
569
640
|
finalAttempt,
|
|
570
|
-
...(tenantVisibleDecl &&
|
|
641
|
+
...(tenantVisibleDecl &&
|
|
642
|
+
!subjectConfigError && {
|
|
571
643
|
tenantVisible: { subject: tenantVisibleSubject, messageKey },
|
|
572
644
|
}),
|
|
573
645
|
});
|
|
@@ -658,8 +730,7 @@ export function createJobRunner(options) {
|
|
|
658
730
|
let memberReader;
|
|
659
731
|
const jobContext = {
|
|
660
732
|
...context,
|
|
661
|
-
|
|
662
|
-
db: jobDb, // @cast-boundary db-operator
|
|
733
|
+
db: jobDb ?? missingDbProxy(jobName),
|
|
663
734
|
files,
|
|
664
735
|
derivatives,
|
|
665
736
|
...(notify !== undefined && { notify }),
|
|
@@ -721,6 +792,10 @@ export function createJobRunner(options) {
|
|
|
721
792
|
const jobCorrelationId = inheritedCorrelationId ?? jobRequestId;
|
|
722
793
|
const runInSpan = async () => {
|
|
723
794
|
try {
|
|
795
|
+
if (subjectConfigError) {
|
|
796
|
+
// Retrying cannot fix a declaration error.
|
|
797
|
+
throw new UnrecoverableError(subjectConfigError.message);
|
|
798
|
+
}
|
|
724
799
|
if (writeOriginInvalid) {
|
|
725
800
|
throw new InternalError({
|
|
726
801
|
message: `Job "${jobName}" received an unparseable _writeOrigin — refusing to run without ` +
|
|
@@ -732,13 +807,22 @@ export function createJobRunner(options) {
|
|
|
732
807
|
correlationId: jobCorrelationId,
|
|
733
808
|
// #3043 — events a job writes carry the job as their origin.
|
|
734
809
|
handler: jobName,
|
|
735
|
-
feature: qnScope(jobName),
|
|
810
|
+
feature: registry.getJobFeature(jobName) ?? qnScope(jobName),
|
|
736
811
|
writeOrigin: jobOrigin,
|
|
737
812
|
}, () => jobDef.handler(payload, jobContext));
|
|
738
813
|
// Stamped before the observer hook: a throwing onJobComplete must not
|
|
739
814
|
// make a run that actually succeeded look dead to the liveness alert.
|
|
740
|
-
if (context.meter)
|
|
741
|
-
|
|
815
|
+
if (context.meter) {
|
|
816
|
+
try {
|
|
817
|
+
emitJobLastSuccess(context.meter, jobName);
|
|
818
|
+
}
|
|
819
|
+
catch (err) {
|
|
820
|
+
errorLogger.warn("job last-success metric failed", {
|
|
821
|
+
job: jobName,
|
|
822
|
+
error: err instanceof Error ? err.message : String(err),
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
}
|
|
742
826
|
const duration = Date.now() - startTime;
|
|
743
827
|
await options.onJobComplete?.(jobName, jobId, duration, logs, outcomeMeta(null));
|
|
744
828
|
}
|
|
@@ -794,8 +878,28 @@ export function createJobRunner(options) {
|
|
|
794
878
|
continue;
|
|
795
879
|
if (!jobDef.bootGate)
|
|
796
880
|
continue;
|
|
881
|
+
const gateTimeout = jobDef.timeout !== undefined
|
|
882
|
+
? timeoutReject(jobDef.timeout, `job-runner: boot gate "${name}" did not finish within ${jobDef.timeout}ms`)
|
|
883
|
+
: undefined;
|
|
884
|
+
const slowWarnTimer = gateTimeout === undefined
|
|
885
|
+
? setTimeout(() => {
|
|
886
|
+
errorLogger.warn("boot gate still running, no timeout configured", {
|
|
887
|
+
gate: name,
|
|
888
|
+
afterMs: BOOT_GATE_SLOW_WARN_MS,
|
|
889
|
+
});
|
|
890
|
+
}, BOOT_GATE_SLOW_WARN_MS)
|
|
891
|
+
: undefined;
|
|
797
892
|
try {
|
|
798
|
-
await
|
|
893
|
+
await Promise.race([
|
|
894
|
+
handleJob({
|
|
895
|
+
id: bootJobIdForJobName(name),
|
|
896
|
+
name,
|
|
897
|
+
data: {},
|
|
898
|
+
attemptsMade: 0,
|
|
899
|
+
retryable: false,
|
|
900
|
+
}),
|
|
901
|
+
...(gateTimeout ? [gateTimeout.promise] : []),
|
|
902
|
+
]);
|
|
799
903
|
}
|
|
800
904
|
catch (err) {
|
|
801
905
|
// The worker is already consuming here; leaving its Redis connections
|
|
@@ -805,6 +909,11 @@ export function createJobRunner(options) {
|
|
|
805
909
|
worker = null;
|
|
806
910
|
throw err;
|
|
807
911
|
}
|
|
912
|
+
finally {
|
|
913
|
+
gateTimeout?.cancel();
|
|
914
|
+
if (slowWarnTimer !== undefined)
|
|
915
|
+
clearTimeout(slowWarnTimer);
|
|
916
|
+
}
|
|
808
917
|
}
|
|
809
918
|
}
|
|
810
919
|
const runnerApi = {
|
|
@@ -925,15 +1034,19 @@ export function createJobRunner(options) {
|
|
|
925
1034
|
// rejects the in-flight INFO with "Connection is closed." on an emitter
|
|
926
1035
|
// without listeners -> unhandled rejection (fw#1805). Wait for readiness
|
|
927
1036
|
// first; a failed/timed-out wait must not block the close itself.
|
|
928
|
-
const
|
|
1037
|
+
const stopReadyTimeoutMs = Math.min(bootRedisTimeoutMs, STOP_QUEUE_READY_TIMEOUT_MS);
|
|
1038
|
+
const readinessTimeout = timeoutReject(stopReadyTimeoutMs, `job-runner: Queue not ready within ${stopReadyTimeoutMs}ms during stop()`);
|
|
929
1039
|
try {
|
|
930
1040
|
await Promise.race([
|
|
931
1041
|
Promise.all([queues.api.waitUntilReady(), queues.worker.waitUntilReady()]),
|
|
932
1042
|
readinessTimeout.promise,
|
|
933
1043
|
]);
|
|
934
1044
|
}
|
|
935
|
-
catch {
|
|
936
|
-
// close() below still has to run
|
|
1045
|
+
catch (err) {
|
|
1046
|
+
// close() below still has to run; stopping is already true, so debug matches the other shutdown logs
|
|
1047
|
+
errorLogger.debug("queue readiness wait failed during stop()", {
|
|
1048
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1049
|
+
});
|
|
937
1050
|
}
|
|
938
1051
|
finally {
|
|
939
1052
|
readinessTimeout.cancel();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { assertKumikoSchemaCurrent, type ChecksumMismatch, detectKumikoDrift, formatKumikoDriftReport, type KumikoDriftReport, SchemaDriftError, } from "./kumiko-drift.js";
|
|
2
|
-
export { createPendingRebuildsTable, type EnqueueProjectionRebuildDeps, type EnqueueProjectionRebuildResult, enqueueProjectionRebuild, listPendingRebuilds, type PendingRebuildRun, PROJECTION_REBUILD_JOB, pendingRebuildsTable, queueRebuildsFromMarkers, type RunPendingRebuildsOptions, rebuildProjectionOrMultiStream, runPendingRebuilds, } from "./pending-rebuilds.js";
|
|
2
|
+
export { createPendingRebuildsTable, type EnqueueProjectionRebuildDeps, type EnqueueProjectionRebuildResult, enqueueProjectionRebuild, listPendingRebuilds, type PendingRebuildRun, PROJECTION_REBUILD_JOB, pendingRebuildsTable, queueRebuildsFromMarkers, type RunPendingRebuildsOptions, rebuildProjectionOrMultiStream, reportPendingRebuildRun, runPendingRebuilds, } from "./pending-rebuilds.js";
|
|
3
3
|
export { createPiiBackfillStateTable, type PiiEventBackfillOptions, type PiiEventBackfillResult, type PiiRebuildRun, piiAnnotationFingerprint, piiBackfillStateTable, runPiiEventBackfill, } from "./pii-event-backfill.js";
|
|
4
4
|
export { buildProjectionTableIndex } from "./projection-table-index.js";
|
package/dist/migrations/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Drizzle-free gate (kumiko/migrations system) — the canonical boot-gate.
|
|
2
2
|
export { assertKumikoSchemaCurrent, detectKumikoDrift, formatKumikoDriftReport, SchemaDriftError, } from "./kumiko-drift.js";
|
|
3
3
|
// Persistente Pending-Rebuild-Queue (survives Rebuild-Failures + Crashes).
|
|
4
|
-
export { createPendingRebuildsTable, enqueueProjectionRebuild, listPendingRebuilds, PROJECTION_REBUILD_JOB, pendingRebuildsTable, queueRebuildsFromMarkers, rebuildProjectionOrMultiStream, runPendingRebuilds, } from "./pending-rebuilds.js";
|
|
4
|
+
export { createPendingRebuildsTable, enqueueProjectionRebuild, listPendingRebuilds, PROJECTION_REBUILD_JOB, pendingRebuildsTable, queueRebuildsFromMarkers, rebuildProjectionOrMultiStream, reportPendingRebuildRun, runPendingRebuilds, } from "./pending-rebuilds.js";
|
|
5
5
|
// Boot-time backfill: re-encrypts plaintext events after a PII annotation was added.
|
|
6
6
|
export { createPiiBackfillStateTable, piiAnnotationFingerprint, piiBackfillStateTable, runPiiEventBackfill, } from "./pii-event-backfill.js";
|
|
7
7
|
// tableName → projection-name, für den app-seitigen Projection-Rebuild.
|
|
@@ -57,6 +57,10 @@ export declare function rebuildProjectionOrMultiStream(name: string, deps: {
|
|
|
57
57
|
readonly skipApplyErrors?: boolean;
|
|
58
58
|
};
|
|
59
59
|
}): Promise<RebuildResult>;
|
|
60
|
+
/** Logs a finished queue drain and throws when any projection failed; the
|
|
61
|
+
* failed tables stay queued. Shared by every `schema apply` entry point so the
|
|
62
|
+
* report and the error text cannot drift apart. */
|
|
63
|
+
export declare function reportPendingRebuildRun(run: PendingRebuildRun, log: (line: string) => void): void;
|
|
60
64
|
/** Arbeitet die persistierte Queue ab: mappt Tabellen auf Projektionen,
|
|
61
65
|
* rebuildet jede betroffene Projektion und räumt ihre Tabellen erst nach
|
|
62
66
|
* ERFOLG aus der Queue. Fehlgeschlagene bleiben pending — der nächste
|
|
@@ -71,7 +71,12 @@ export async function clearPendingRebuilds(db, rows) {
|
|
|
71
71
|
export async function rebuildProjectionOrMultiStream(name, deps) {
|
|
72
72
|
const { db, registry, signal, errorPolicy } = deps;
|
|
73
73
|
if (registry.getAllMultiStreamProjections().has(name)) {
|
|
74
|
-
return rebuildMultiStreamProjection(name, {
|
|
74
|
+
return rebuildMultiStreamProjection(name, {
|
|
75
|
+
db,
|
|
76
|
+
registry,
|
|
77
|
+
markDeadOnFailure: false,
|
|
78
|
+
...(signal && { signal }),
|
|
79
|
+
});
|
|
75
80
|
}
|
|
76
81
|
return rebuildProjection(name, {
|
|
77
82
|
db,
|
|
@@ -80,6 +85,23 @@ export async function rebuildProjectionOrMultiStream(name, deps) {
|
|
|
80
85
|
...(errorPolicy && { errorPolicy }),
|
|
81
86
|
});
|
|
82
87
|
}
|
|
88
|
+
/** Logs a finished queue drain and throws when any projection failed; the
|
|
89
|
+
* failed tables stay queued. Shared by every `schema apply` entry point so the
|
|
90
|
+
* report and the error text cannot drift apart. */
|
|
91
|
+
export function reportPendingRebuildRun(run, log) {
|
|
92
|
+
if (run.rebuilt.length > 0) {
|
|
93
|
+
log(` Rebuild ${run.rebuilt.length} Projection(s)…`);
|
|
94
|
+
for (const r of run.rebuilt) {
|
|
95
|
+
log(` ↻ ${r.projection} (${r.eventsProcessed} events)`);
|
|
96
|
+
}
|
|
97
|
+
log("");
|
|
98
|
+
}
|
|
99
|
+
if (run.failed.length > 0) {
|
|
100
|
+
throw new Error(`Projection rebuild failed for: ${run.failed
|
|
101
|
+
.map((f) => `${f.projection} (${f.error})`)
|
|
102
|
+
.join("; ")}. Table(s) stay queued in kumiko_pending_rebuilds — retried on the next apply.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
83
105
|
/** Arbeitet die persistierte Queue ab: mappt Tabellen auf Projektionen,
|
|
84
106
|
* rebuildet jede betroffene Projektion und räumt ihre Tabellen erst nach
|
|
85
107
|
* ERFOLG aus der Queue. Fehlgeschlagene bleiben pending — der nächste
|
|
@@ -20,4 +20,5 @@ export declare function createEscapeHatchReporter(opts: {
|
|
|
20
20
|
readonly log?: Logger;
|
|
21
21
|
readonly window?: EscapeHatchReportWindow;
|
|
22
22
|
}): EscapeHatchReporter;
|
|
23
|
+
export declare const UNATTRIBUTED_ACTOR = "system";
|
|
23
24
|
export declare function fallbackEscapeHatchReporter(tenantId: TenantId): EscapeHatchReporter;
|
|
@@ -83,6 +83,9 @@ export function createEscapeHatchReporter(opts) {
|
|
|
83
83
|
reportEscapeHatchUse({ handler: opts.handler, kind, reason, tenantId: opts.tenantId, actor: opts.actor, target }, { sink: opts.sink, log: opts.log });
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
|
+
// Actor for escape-hatch uses whose caller did not thread one through. Stays
|
|
87
|
+
// "system" so audit events persisted before the constant existed remain valid.
|
|
88
|
+
export const UNATTRIBUTED_ACTOR = "system";
|
|
86
89
|
export function fallbackEscapeHatchReporter(tenantId) {
|
|
87
90
|
return createEscapeHatchReporter({
|
|
88
91
|
handler: "<unattributed>",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { type ConsoleProviderOptions, createConsoleProvider } from "./console-provider.js";
|
|
2
2
|
export { observabilityContext } from "./context.js";
|
|
3
|
+
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, type EscapeHatchReportWindow, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
|
|
3
4
|
export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
|
|
4
5
|
export { generateSpanId, generateTraceId } from "./ids.js";
|
|
5
6
|
export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
|
|
@@ -11,5 +12,5 @@ export { type MetricEvent, type MetricEventHandler, RecordingMeter, } from "./re
|
|
|
11
12
|
export { type RecordedSpan, RecordingTracer, type RecordingTracerOptions, serializeSpanContext, } from "./recording-tracer.js";
|
|
12
13
|
export { wrapRedisClient } from "./redis-wrapper.js";
|
|
13
14
|
export { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, REDACTED, redactAttributes, redactHeaders, redactQueryString, redactValue, shouldRedactAttribute, } from "./sensitive-filter.js";
|
|
14
|
-
export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
|
|
15
|
+
export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
|
|
15
16
|
export type { Counter, Gauge, Histogram, Meter, MetricDefinition, MetricLabels, MetricsHandle, MetricType, ObservabilityOptions, ObservabilityProvider, SamplingConfig, SensitiveFilterConfig, SerializedTraceContext, Span, SpanAttributes, SpanAttributeValue, SpanKind, SpanStatus, StartSpanOptions, Tracer, } from "./types/index.js";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Public surface of the observability module.
|
|
2
2
|
export { createConsoleProvider } from "./console-provider.js";
|
|
3
3
|
export { observabilityContext } from "./context.js";
|
|
4
|
+
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
|
|
4
5
|
export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
|
|
5
6
|
export { generateSpanId, generateTraceId } from "./ids.js";
|
|
6
7
|
export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
|
|
@@ -12,4 +13,4 @@ export { RecordingMeter, } from "./recording-meter.js";
|
|
|
12
13
|
export { RecordingTracer, serializeSpanContext, } from "./recording-tracer.js";
|
|
13
14
|
export { wrapRedisClient } from "./redis-wrapper.js";
|
|
14
15
|
export { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, REDACTED, redactAttributes, redactHeaders, redactQueryString, redactValue, shouldRedactAttribute, } from "./sensitive-filter.js";
|
|
15
|
-
export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
|
|
16
|
+
export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
|
|
@@ -32,6 +32,10 @@ export declare function emitEventConsumerRearmExhausted(meter: Meter, labels: {
|
|
|
32
32
|
readonly consumer: string;
|
|
33
33
|
readonly instanceId: string;
|
|
34
34
|
}): void;
|
|
35
|
+
export declare function emitEventConsumerPassSkipped(meter: Meter, labels: {
|
|
36
|
+
readonly consumer: string;
|
|
37
|
+
readonly instanceId: string;
|
|
38
|
+
}, reason: "idle"): void;
|
|
35
39
|
export declare function emitEventDispatcherListenConnected(meter: Meter, connected: boolean): void;
|
|
36
40
|
export declare function emitEventConsumerPassOutcome(meter: Meter, labels: {
|
|
37
41
|
readonly consumer: string;
|
|
@@ -93,6 +93,15 @@ export const STANDARD_METRIC_DEFS = [
|
|
|
93
93
|
description: "Consumer transitioned to dead with its auto-rearm budget exhausted.",
|
|
94
94
|
labels: ["consumer", "instance_id"],
|
|
95
95
|
},
|
|
96
|
+
// An idle-gated consumer turn opens no events.consumer.pass span (the lock-free
|
|
97
|
+
// pre-check proves there is nothing to do), so this counter is the only trace of
|
|
98
|
+
// per-tick pass frequency for consumers that are idle or disabled.
|
|
99
|
+
{
|
|
100
|
+
name: "kumiko_event_consumer_pass_skipped_total",
|
|
101
|
+
type: "counter",
|
|
102
|
+
description: "Consumer turns skipped before opening a pass span because the idle pre-check proved nothing to deliver.",
|
|
103
|
+
labels: ["consumer", "instance_id", "reason"],
|
|
104
|
+
},
|
|
96
105
|
// LISTEN-subscription health. 1 = active, 0 = dropped. Drops to 0 while
|
|
97
106
|
// the dispatcher is running signal that delivery latency has regressed
|
|
98
107
|
// from sub-millisecond (LISTEN) to pollIntervalMs (timer fallback); ops
|
|
@@ -203,6 +212,13 @@ export function emitEventConsumerRearmExhausted(meter, labels) {
|
|
|
203
212
|
instance_id: labels.instanceId,
|
|
204
213
|
});
|
|
205
214
|
}
|
|
215
|
+
export function emitEventConsumerPassSkipped(meter, labels, reason) {
|
|
216
|
+
meter.counter("kumiko_event_consumer_pass_skipped_total").inc(1, {
|
|
217
|
+
consumer: labels.consumer,
|
|
218
|
+
instance_id: labels.instanceId,
|
|
219
|
+
reason,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
206
222
|
export function emitEventDispatcherListenConnected(meter, connected) {
|
|
207
223
|
meter.gauge("kumiko_event_dispatcher_listen_connected").set(connected ? 1 : 0);
|
|
208
224
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { DbTx } from "../db/connection.js";
|
|
2
2
|
import type { SessionUser } from "../engine/types/index.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type DispatchContext, type WriteOrigin } from "./dispatch-shared.js";
|
|
4
4
|
export declare function executeQuery(ctx: DispatchContext, type: string, payload: unknown, user: SessionUser, origin: WriteOrigin, tx?: DbTx): Promise<unknown>;
|
|
@@ -2,7 +2,7 @@ import { hasAccess } from "../engine/access.js";
|
|
|
2
2
|
import { filterReadFields } from "../engine/field-access.js";
|
|
3
3
|
import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors/index.js";
|
|
4
4
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
5
|
-
import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, runHandlerInstrumented, } from "./dispatch-shared.js";
|
|
5
|
+
import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, isMemberResolutionPrincipal, runHandlerInstrumented, } from "./dispatch-shared.js";
|
|
6
6
|
import { runInMemberReadOnlyTransaction } from "./member-read-only-transaction.js";
|
|
7
7
|
// Standalone query execution — used by the public dispatcher.query() and
|
|
8
8
|
// by ctx.query/ctx.queryAs inside handlers. Runs the handler, applies
|
|
@@ -51,7 +51,7 @@ async function executeQueryInner(ctx, type, payload, user, origin, tx) {
|
|
|
51
51
|
parsed.data !== null &&
|
|
52
52
|
parsed.data["includeDeleted"] === true; // @cast-boundary validated-payload
|
|
53
53
|
// A resolved member (ctx.queryAsMember) runs in a Postgres READ ONLY transaction, not just the ctx surface below.
|
|
54
|
-
return user
|
|
54
|
+
return isMemberResolutionPrincipal(user)
|
|
55
55
|
? runInMemberReadOnlyTransaction(ctx, tx, (readOnlyTx) => runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, readOnlyTx))
|
|
56
56
|
: runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, tx);
|
|
57
57
|
}
|
|
@@ -6,8 +6,7 @@ import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extensio
|
|
|
6
6
|
import type { TenantId } from "../engine/types/identifiers.js";
|
|
7
7
|
import type { AppContext, HandlerContext, JobRunnerRef, RateLimitDeclaration, Registry, SessionUser, WriteResult } from "../engine/types/index.js";
|
|
8
8
|
import { type WriteErrorInfo } from "../errors/index.js";
|
|
9
|
-
import { type EscapeHatchReportWindow } from "../observability/
|
|
10
|
-
import { type getFallbackMeter, getFallbackTracer } from "../observability/index.js";
|
|
9
|
+
import { type EscapeHatchReportWindow, type getFallbackMeter, getFallbackTracer } from "../observability/index.js";
|
|
11
10
|
import { type AfterCommitHook } from "./dispatcher-utils.js";
|
|
12
11
|
import type { IdempotencyGuard } from "./idempotency.js";
|
|
13
12
|
import type { LifecycleHooks } from "./lifecycle-pipeline.js";
|
|
@@ -49,7 +48,7 @@ export type DispatchContext = {
|
|
|
49
48
|
export declare function resolveDbSource(ctx: DispatchContext, tx: DbTx | undefined): DbConnection | DbTx | undefined;
|
|
50
49
|
export declare function isMemberResolutionPrincipal(user: SessionUser): boolean;
|
|
51
50
|
export declare function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext;
|
|
52
|
-
export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean): Promise<HandlerContext>;
|
|
51
|
+
export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean, isPostCommit?: boolean): Promise<HandlerContext>;
|
|
53
52
|
export declare function runHandlerInstrumented<T>(ctx: DispatchContext, type: string, operation: "query" | "write" | "stream", user: SessionUser, inner: () => Promise<T>): Promise<T>;
|
|
54
53
|
export declare function runStreamInstrumented<T>(ctx: DispatchContext, type: string, user: SessionUser, inner: () => AsyncGenerator<T>): AsyncGenerator<T>;
|
|
55
54
|
export declare function checkFeatureEnabled(ctx: DispatchContext, qualifiedHandler: string, tenantId: TenantId): Promise<import("../errors/index.js").FeatureDisabledError | undefined>;
|