@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
package/dist/jobs/job-runner.js
CHANGED
|
@@ -134,14 +134,14 @@ export function serializeJobSubject(subject) {
|
|
|
134
134
|
// with the same subject values produce the same string and the later one
|
|
135
135
|
// replaces the earlier record. Non-primitives would serialize into something
|
|
136
136
|
// no caller can reconstruct for a lookup, so they fail the run loudly.
|
|
137
|
-
function jobSubjectKey(jobName, payload, fields) {
|
|
137
|
+
function jobSubjectKey(jobName, payload, fields, declaration) {
|
|
138
138
|
if (fields === undefined || fields.length === 0)
|
|
139
139
|
return null;
|
|
140
140
|
const subject = {};
|
|
141
141
|
for (const field of fields) {
|
|
142
142
|
const value = payload[field] ?? null;
|
|
143
143
|
if (value !== null && typeof value === "object") {
|
|
144
|
-
throw new Error(`Job "${jobName}":
|
|
144
|
+
throw new Error(`Job "${jobName}": ${declaration}.subjectFields["${field}"] must be a primitive, got ${typeof value}`);
|
|
145
145
|
}
|
|
146
146
|
subject[field] = value;
|
|
147
147
|
}
|
|
@@ -516,12 +516,72 @@ export function createJobRunner(options) {
|
|
|
516
516
|
}
|
|
517
517
|
return false;
|
|
518
518
|
}
|
|
519
|
+
// Only err.message, never payload or the logs array: job payloads can carry PII.
|
|
520
|
+
function logFailedAttempt(entry) {
|
|
521
|
+
errorLogger.error("job failed", {
|
|
522
|
+
job: entry.job,
|
|
523
|
+
jobId: entry.jobId,
|
|
524
|
+
attempt: entry.attempt,
|
|
525
|
+
// BullMQ never retries an UnrecoverableError, whatever attempts remain.
|
|
526
|
+
final: entry.finalAttempt || entry.err instanceof UnrecoverableError,
|
|
527
|
+
error: entry.err instanceof Error ? entry.err.message : String(entry.err),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
519
530
|
async function reportPreRunFailure(jobName, bullJob, err) {
|
|
520
531
|
const message = err instanceof Error ? err.message : String(err);
|
|
521
532
|
await options.onJobFailed?.(jobName, bullJob.id ?? "unknown", message, [
|
|
522
533
|
{ level: "error", message, timestamp: Temporal.Now.instant() },
|
|
523
534
|
]);
|
|
524
535
|
}
|
|
536
|
+
function handlerPayloadOf(data) {
|
|
537
|
+
const payload = {};
|
|
538
|
+
for (const [k, v] of Object.entries(data)) {
|
|
539
|
+
if (!k.startsWith("_"))
|
|
540
|
+
payload[k] = v;
|
|
541
|
+
}
|
|
542
|
+
return payload;
|
|
543
|
+
}
|
|
544
|
+
function tenantIdOfJobData(data) {
|
|
545
|
+
return (data["_tenantId"] ?? // @cast-boundary dynamic-key
|
|
546
|
+
data["tenantId"] ?? // @cast-boundary dynamic-key
|
|
547
|
+
SYSTEM_TENANT_ID);
|
|
548
|
+
}
|
|
549
|
+
// Tenant-visible queued state. A callback or subject problem must never fail
|
|
550
|
+
// the enqueue; a bad subject config surfaces as a failed run instead.
|
|
551
|
+
async function announceQueued(jobName, jobDef, bullJobId, data) {
|
|
552
|
+
const declaration = jobDef.tenantVisibleRun;
|
|
553
|
+
// skip: job did not opt in, no logger wired, or BullMQ returned no id
|
|
554
|
+
if (!declaration || !options.onJobQueued || bullJobId === undefined)
|
|
555
|
+
return;
|
|
556
|
+
const tenantId = tenantIdOfJobData(data);
|
|
557
|
+
// skip: tenant-less run — no tenant-scoped query could ever read it
|
|
558
|
+
if (tenantId === SYSTEM_TENANT_ID)
|
|
559
|
+
return;
|
|
560
|
+
try {
|
|
561
|
+
const subject = jobSubjectKey(jobName, handlerPayloadOf(data), declaration.subjectFields, "tenantVisibleRun");
|
|
562
|
+
await options.onJobQueued(jobName, bullJobId, { tenantId, subject });
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
errorLogger.warn("tenant-visible queued state not recorded", {
|
|
566
|
+
job: jobName,
|
|
567
|
+
error: err instanceof Error ? err.message : String(err),
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
async function announceDropped(jobName, jobDef, bullJobId) {
|
|
572
|
+
// skip: job did not opt in or no logger wired
|
|
573
|
+
if (!jobDef.tenantVisibleRun || !options.onJobDropped)
|
|
574
|
+
return;
|
|
575
|
+
try {
|
|
576
|
+
await options.onJobDropped(jobName, bullJobId);
|
|
577
|
+
}
|
|
578
|
+
catch (err) {
|
|
579
|
+
errorLogger.warn("tenant-visible queued state not cleared", {
|
|
580
|
+
job: jobName,
|
|
581
|
+
error: err instanceof Error ? err.message : String(err),
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
525
585
|
async function handleJob(bullJob) {
|
|
526
586
|
const rawName = bullJob.name;
|
|
527
587
|
// Handle perTenant dispatch jobs — fan out to one job per tenant. The
|
|
@@ -556,7 +616,8 @@ export function createJobRunner(options) {
|
|
|
556
616
|
// for that edge case instead: enqueue plainly, same as before.
|
|
557
617
|
const wrapperJobId = bullJob.id;
|
|
558
618
|
for (const tenantId of tenantIds) {
|
|
559
|
-
|
|
619
|
+
const childData = { ...bullJob.data, _tenantId: tenantId };
|
|
620
|
+
const child = await targetQueue.add(actualName, childData, {
|
|
560
621
|
...buildRetryBullOpts(actualDef),
|
|
561
622
|
// Dedup over wrapper retries only holds as long as the children
|
|
562
623
|
// stay in Redis for the wrapper's whole retry window — see the
|
|
@@ -565,13 +626,22 @@ export function createJobRunner(options) {
|
|
|
565
626
|
? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
|
|
566
627
|
: {}),
|
|
567
628
|
});
|
|
629
|
+
await announceQueued(actualName, actualDef, child.id, childData);
|
|
568
630
|
}
|
|
569
631
|
}
|
|
570
632
|
catch (err) {
|
|
571
633
|
// A retry may still succeed, and a succeeding wrapper reports no completion
|
|
572
634
|
// that could clear an earlier failure, so only the final attempt is reported.
|
|
573
635
|
const retries = allJobs.get(actualName)?.retries ?? 0;
|
|
574
|
-
|
|
636
|
+
const finalAttempt = bullJob.attemptsMade + 1 >= retries + 1;
|
|
637
|
+
logFailedAttempt({
|
|
638
|
+
job: rawName,
|
|
639
|
+
jobId: bullJob.id ?? "unknown",
|
|
640
|
+
attempt: bullJob.attemptsMade + 1,
|
|
641
|
+
finalAttempt,
|
|
642
|
+
err,
|
|
643
|
+
});
|
|
644
|
+
if (finalAttempt) {
|
|
575
645
|
await reportPreRunFailure(rawName, bullJob, err);
|
|
576
646
|
}
|
|
577
647
|
throw err;
|
|
@@ -583,6 +653,14 @@ export function createJobRunner(options) {
|
|
|
583
653
|
const jobDef = allJobs.get(jobName);
|
|
584
654
|
if (!jobDef) {
|
|
585
655
|
const err = new Error(`Unknown job: ${jobName}`);
|
|
656
|
+
// Final regardless of attempts left: a retry cannot make the job exist.
|
|
657
|
+
logFailedAttempt({
|
|
658
|
+
job: jobName,
|
|
659
|
+
jobId: bullJob.id ?? "unknown",
|
|
660
|
+
attempt: bullJob.attemptsMade + 1,
|
|
661
|
+
finalAttempt: true,
|
|
662
|
+
err,
|
|
663
|
+
});
|
|
586
664
|
await reportPreRunFailure(jobName, bullJob, err);
|
|
587
665
|
throw err;
|
|
588
666
|
}
|
|
@@ -605,10 +683,15 @@ export function createJobRunner(options) {
|
|
|
605
683
|
// Attempts already spent travel in _priorAttempts so a lock conflict
|
|
606
684
|
// on a retry cannot hand out a fresh budget and exceed `retries`.
|
|
607
685
|
const priorAttempts = priorAttemptsOf(bullJob.data) + bullJob.attemptsMade;
|
|
608
|
-
|
|
686
|
+
const requeuedData = { ...bullJob.data, _priorAttempts: priorAttempts };
|
|
687
|
+
const requeued = await queues[laneForJob(jobDef)].add(jobName, requeuedData, {
|
|
609
688
|
delay: SEQUENTIAL_RETRY_DELAY_MS,
|
|
610
689
|
...buildRetryBullOpts(jobDef, priorAttempts),
|
|
611
690
|
});
|
|
691
|
+
if (bullJob.id !== undefined && bullJob.id !== requeued.id) {
|
|
692
|
+
await announceDropped(jobName, jobDef, bullJob.id);
|
|
693
|
+
}
|
|
694
|
+
await announceQueued(jobName, jobDef, requeued.id, requeuedData);
|
|
612
695
|
// skip: lock taken, work re-enqueued with delay, current invocation done
|
|
613
696
|
return;
|
|
614
697
|
}
|
|
@@ -629,27 +712,31 @@ export function createJobRunner(options) {
|
|
|
629
712
|
attempt,
|
|
630
713
|
};
|
|
631
714
|
// Build handler payload (without internal meta fields)
|
|
632
|
-
const payload =
|
|
633
|
-
for (const [k, v] of Object.entries(rawData)) {
|
|
634
|
-
if (!k.startsWith("_"))
|
|
635
|
-
payload[k] = v;
|
|
636
|
-
}
|
|
715
|
+
const payload = handlerPayloadOf(rawData);
|
|
637
716
|
// Determine tenantId and triggeredBy from meta
|
|
638
|
-
const tenantId = rawData
|
|
639
|
-
payload["tenantId"] ?? // @cast-boundary dynamic-key
|
|
640
|
-
SYSTEM_TENANT_ID;
|
|
717
|
+
const tenantId = tenantIdOfJobData(rawData);
|
|
641
718
|
const triggeredById = rawData["_triggeredById"] ?? null; // @cast-boundary dynamic-key
|
|
642
719
|
// Tenant-visible failure record (JobDefinition.tenantVisibleFailure). The
|
|
643
720
|
// subject is read from the handler payload, so it is resolved here where
|
|
644
721
|
// the payload is built, not in the callbacks.
|
|
645
722
|
const tenantVisibleDecl = jobDef.tenantVisibleFailure;
|
|
723
|
+
const tenantVisibleRunDecl = jobDef.tenantVisibleRun;
|
|
646
724
|
// A bad subject config must surface as a failed run, so it is thrown from
|
|
647
725
|
// inside runInSpan below instead of here, before any run record exists.
|
|
648
726
|
let tenantVisibleSubject = null;
|
|
727
|
+
let tenantVisibleRunSubject = null;
|
|
649
728
|
let subjectConfigError;
|
|
650
|
-
if (
|
|
729
|
+
if (tenantVisibleRunDecl) {
|
|
651
730
|
try {
|
|
652
|
-
|
|
731
|
+
tenantVisibleRunSubject = jobSubjectKey(jobName, payload, tenantVisibleRunDecl.subjectFields, "tenantVisibleRun");
|
|
732
|
+
}
|
|
733
|
+
catch (err) {
|
|
734
|
+
subjectConfigError = err instanceof Error ? err : new Error(String(err));
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (tenantVisibleDecl && !subjectConfigError) {
|
|
738
|
+
try {
|
|
739
|
+
tenantVisibleSubject = jobSubjectKey(jobName, payload, tenantVisibleDecl.subjectFields, "tenantVisibleFailure");
|
|
653
740
|
}
|
|
654
741
|
catch (err) {
|
|
655
742
|
subjectConfigError = err instanceof Error ? err : new Error(String(err));
|
|
@@ -658,13 +745,16 @@ export function createJobRunner(options) {
|
|
|
658
745
|
// BullMQ stops retrying once attemptsMade reaches the configured attempts
|
|
659
746
|
// (`retries + 1`), so this is the attempt whose failure is final.
|
|
660
747
|
const finalAttempt = bullJob.retryable === false || attempt >= (jobDef.retries ?? 0) + 1;
|
|
661
|
-
const outcomeMeta = (messageKey) => ({
|
|
748
|
+
const outcomeMeta = (messageKey, unrecoverable = false) => ({
|
|
662
749
|
tenantId,
|
|
663
|
-
|
|
750
|
+
// BullMQ never retries an UnrecoverableError, whatever attempts are left.
|
|
751
|
+
finalAttempt: finalAttempt || unrecoverable,
|
|
664
752
|
...(tenantVisibleDecl &&
|
|
665
753
|
!subjectConfigError && {
|
|
666
754
|
tenantVisible: { subject: tenantVisibleSubject, messageKey },
|
|
667
755
|
}),
|
|
756
|
+
...(tenantVisibleRunDecl &&
|
|
757
|
+
!subjectConfigError && { tenantVisibleRun: { subject: tenantVisibleRunSubject } }),
|
|
668
758
|
});
|
|
669
759
|
// Carry `_triggerName` from rawData when set — handleEvent injects it on
|
|
670
760
|
// multi-trigger dispatch; exposed as jobContext.triggerName so handlers
|
|
@@ -693,7 +783,9 @@ export function createJobRunner(options) {
|
|
|
693
783
|
// through the same _fileProviderResolver for jobs as for write-handlers,
|
|
694
784
|
// otherwise event-triggered jobs silently get an unresolved ctx.files.
|
|
695
785
|
const fileResolver = context._fileProviderResolver;
|
|
696
|
-
const files = fileResolver
|
|
786
|
+
const files = fileResolver
|
|
787
|
+
? createFileContext(() => fileResolver(tenantId), tenantId)
|
|
788
|
+
: context.files;
|
|
697
789
|
const jobSystemUser = createSystemUser(tenantId);
|
|
698
790
|
// Same buildHandlerContext parity as ctx.files above: ctx.notify/ctx.config
|
|
699
791
|
// must resolve for jobs the same way they do for write-handlers, or an
|
|
@@ -813,6 +905,9 @@ export function createJobRunner(options) {
|
|
|
813
905
|
return memberReader(userId, qn, payload);
|
|
814
906
|
},
|
|
815
907
|
};
|
|
908
|
+
if (tenantVisibleRunDecl && !subjectConfigError) {
|
|
909
|
+
meta.tenantVisibleRun = { tenantId, subject: tenantVisibleRunSubject };
|
|
910
|
+
}
|
|
816
911
|
await options.onJobStart?.(jobName, jobId, meta);
|
|
817
912
|
// Cross-process trace continuation: if the enqueuing code captured a
|
|
818
913
|
// parent span, start the job.execute span as its child. Works for event
|
|
@@ -865,7 +960,8 @@ export function createJobRunner(options) {
|
|
|
865
960
|
catch (err) {
|
|
866
961
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
867
962
|
logs.push({ level: "error", message: errorMsg, timestamp: Temporal.Now.instant() });
|
|
868
|
-
|
|
963
|
+
logFailedAttempt({ job: jobName, jobId, attempt, finalAttempt, err });
|
|
964
|
+
await options.onJobFailed?.(jobName, jobId, errorMsg, logs, outcomeMeta(tenantFailureMessageKey(err, tenantVisibleDecl), err instanceof UnrecoverableError));
|
|
869
965
|
throw err;
|
|
870
966
|
}
|
|
871
967
|
};
|
|
@@ -938,8 +1034,7 @@ export function createJobRunner(options) {
|
|
|
938
1034
|
]);
|
|
939
1035
|
}
|
|
940
1036
|
catch (err) {
|
|
941
|
-
//
|
|
942
|
-
// open would keep the event loop alive and turn the aborted boot into
|
|
1037
|
+
// Leaving the worker's Redis connections open would keep the event loop alive and turn the aborted boot into
|
|
943
1038
|
// a hanging process instead of a non-zero exit.
|
|
944
1039
|
await worker?.close();
|
|
945
1040
|
worker = null;
|
|
@@ -965,6 +1060,9 @@ export function createJobRunner(options) {
|
|
|
965
1060
|
worker = new Worker(queueNameFor(queueNamePrefix, consumerLane), handleJob, {
|
|
966
1061
|
connection: redisOpts,
|
|
967
1062
|
concurrency: 5,
|
|
1063
|
+
// Not consuming until the boot gates passed: a job already queued from a
|
|
1064
|
+
// previous run must not execute against an unmet boot precondition.
|
|
1065
|
+
autorun: false,
|
|
968
1066
|
});
|
|
969
1067
|
worker.on("error", (err) => {
|
|
970
1068
|
const log = stopping ? errorLogger.debug : errorLogger.error;
|
|
@@ -989,6 +1087,11 @@ export function createJobRunner(options) {
|
|
|
989
1087
|
// Ahead of cron/runOnBoot wiring on purpose: a failed gate must not
|
|
990
1088
|
// leave recurring schedulers behind.
|
|
991
1089
|
await runBootGates(consumerLane);
|
|
1090
|
+
// Floating on purpose: run() resolves only when the worker closes.
|
|
1091
|
+
void worker.run().catch((err) => {
|
|
1092
|
+
const log = stopping ? errorLogger.debug : errorLogger.error;
|
|
1093
|
+
log("worker run loop failed", { error: err instanceof Error ? err.message : String(err) });
|
|
1094
|
+
});
|
|
992
1095
|
// Only schedule cron + boot for jobs that belong to this lane. Jobs
|
|
993
1096
|
// assigned to the other lane get their cron/boot wiring from the
|
|
994
1097
|
// runner running on that lane. Running both here would double-fire.
|
|
@@ -1142,6 +1245,7 @@ export function createJobRunner(options) {
|
|
|
1142
1245
|
for (const j of waiting) {
|
|
1143
1246
|
if (j.name === jobName && j.id) {
|
|
1144
1247
|
await j.remove();
|
|
1248
|
+
await announceDropped(jobName, jobDef, j.id);
|
|
1145
1249
|
}
|
|
1146
1250
|
}
|
|
1147
1251
|
break;
|
|
@@ -1182,6 +1286,7 @@ export function createJobRunner(options) {
|
|
|
1182
1286
|
data["_correlationId"] = reqCtx.correlationId;
|
|
1183
1287
|
stampGatedWriteOrigin(data);
|
|
1184
1288
|
const job = await targetQueue.add(jobName, data, bullOpts);
|
|
1289
|
+
await announceQueued(jobName, jobDef, job.id, data);
|
|
1185
1290
|
return job.id ?? "unknown";
|
|
1186
1291
|
},
|
|
1187
1292
|
async handleEvent(eventName, payload, user, triggerEvent) {
|
|
@@ -1230,7 +1335,8 @@ export function createJobRunner(options) {
|
|
|
1230
1335
|
stampGatedWriteOrigin(data);
|
|
1231
1336
|
// Route to the job's declared lane, not a fixed queue — that's
|
|
1232
1337
|
// the whole reason both queues are held.
|
|
1233
|
-
await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
|
|
1338
|
+
const job = await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
|
|
1339
|
+
await announceQueued(name, jobDef, job.id, data);
|
|
1234
1340
|
}
|
|
1235
1341
|
},
|
|
1236
1342
|
attachDispatcher(ref) {
|
|
@@ -14,6 +14,7 @@ import { bigint, instant, table as pgTable, sql, text } from "../db/dialect.js";
|
|
|
14
14
|
import { extractTableName } from "../db/index.js";
|
|
15
15
|
import { acquireNamespacedAdvisoryLock } from "../db/queries/advisory-lock.js";
|
|
16
16
|
import { backfillEventPiiEncryptionBatch, } from "../db/queries/backfill-pii.js";
|
|
17
|
+
import { alterTableAddColumn } from "../db/queries/ddl.js";
|
|
17
18
|
import { persistPiiBackfillState, readPiiBackfillState, } from "../db/queries/pii-backfill-state.js";
|
|
18
19
|
import { upsertOnConflict } from "../db/query.js";
|
|
19
20
|
import { tableExists } from "../db/schema-inspection.js";
|
|
@@ -37,10 +38,12 @@ export const piiBackfillStateTable = pgTable("kumiko_pii_backfill_state", {
|
|
|
37
38
|
updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql `now()`),
|
|
38
39
|
});
|
|
39
40
|
export async function createPiiBackfillStateTable(db) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
if (!(await tableExists(db, "public.kumiko_pii_backfill_state"))) {
|
|
42
|
+
await unsafePushTables(db, { kumikoPiiBackfillState: piiBackfillStateTable });
|
|
43
|
+
}
|
|
44
|
+
// Added after the table's first deploy; IF NOT EXISTS keeps concurrent replica boots safe.
|
|
45
|
+
await alterTableAddColumn(db, "kumiko_pii_backfill_state", "failed_event_ids", "bigint[]", " DEFAULT '{}'::bigint[]", " NOT NULL",
|
|
46
|
+
/* ifNotExists */ true);
|
|
44
47
|
}
|
|
45
48
|
// Replicas boot concurrently: two of them can both pass the tableExists gate and
|
|
46
49
|
// one CREATE then fails. The table existing afterwards means the other won.
|
|
@@ -107,17 +110,19 @@ function initialScanCursor(state) {
|
|
|
107
110
|
// One transaction per batch: the advisory lock serializes pods, and the payload
|
|
108
111
|
// is re-read inside it so a value is never encrypted twice and two backfills
|
|
109
112
|
// never mint the same DEK. Returns null when another pod already finished a full run.
|
|
110
|
-
async function runOneBatch(ctx, progress) {
|
|
113
|
+
async function runOneBatch(ctx, progress, retryEventIds) {
|
|
111
114
|
return ctx.db.begin(async (tx) => {
|
|
112
115
|
await acquireNamespacedAdvisoryLock(tx, PII_EVENT_BACKFILL_LOCK_NAMESPACE, PII_EVENT_BACKFILL_LOCK_KEY);
|
|
113
116
|
const state = await readPiiBackfillState(tx, ctx.fingerprint);
|
|
114
|
-
if (ctx.mode === "full" && state?.completed === true)
|
|
117
|
+
if (retryEventIds === undefined && ctx.mode === "full" && state?.completed === true) {
|
|
115
118
|
return null;
|
|
119
|
+
}
|
|
116
120
|
const startCursor = ctx.mode === "full" && state
|
|
117
121
|
? maxBigInt(progress.scanCursor, BigInt(state.cursor))
|
|
118
122
|
: progress.scanCursor;
|
|
119
123
|
const batch = await backfillEventPiiEncryptionBatch(tx, ctx.registry, {
|
|
120
124
|
afterEventId: startCursor,
|
|
125
|
+
...(retryEventIds && { eventIds: retryEventIds }),
|
|
121
126
|
batchSize: ctx.batchSize,
|
|
122
127
|
resolveOwnerFromProjection: true,
|
|
123
128
|
scanCache: progress.scanCache,
|
|
@@ -128,10 +133,15 @@ async function runOneBatch(ctx, progress) {
|
|
|
128
133
|
: [];
|
|
129
134
|
if (tables.length > 0)
|
|
130
135
|
await queueRebuildTables(tx, tables, ctx.migrationId);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
136
|
+
// The cursor moves past failed events: they are recorded here and retried on their own,
|
|
137
|
+
// so one permanently failing event no longer forces a rescan from it on every boot.
|
|
138
|
+
const failedIds = batch.failures.map((failure) => failure.eventId);
|
|
139
|
+
await persistPiiBackfillState(tx, ctx.fingerprint, {
|
|
140
|
+
cursor: retryEventIds ? 0n : advancedCursor(batch, startCursor),
|
|
141
|
+
markCompleted: retryEventIds === undefined && batch.scannedAll,
|
|
142
|
+
addFailedEventIds: failedIds,
|
|
143
|
+
removeFailedEventIds: retryEventIds?.filter((id) => !failedIds.includes(id)) ?? [],
|
|
144
|
+
});
|
|
135
145
|
return { batch, tables };
|
|
136
146
|
});
|
|
137
147
|
}
|
|
@@ -141,14 +151,25 @@ function recordBatch(progress, step) {
|
|
|
141
151
|
progress.totals.updatedEvents += batch.updatedEvents;
|
|
142
152
|
progress.totals.encryptedFields += batch.encryptedFields;
|
|
143
153
|
progress.totals.erasedFields += batch.erasedFields;
|
|
144
|
-
|
|
154
|
+
for (const failure of batch.failures) {
|
|
155
|
+
// The catch-up overlap and the per-event retry can hit the same failed event twice in one run.
|
|
156
|
+
if (progress.failedIdsSeen.has(failure.eventId))
|
|
157
|
+
continue;
|
|
158
|
+
progress.failedIdsSeen.add(failure.eventId);
|
|
159
|
+
progress.failures.push(failure);
|
|
160
|
+
}
|
|
145
161
|
for (const table of step.tables)
|
|
146
162
|
progress.queuedTables.add(table);
|
|
147
|
-
if (batch.failures.length > 0)
|
|
148
|
-
progress.failureSeen = true;
|
|
149
163
|
}
|
|
150
164
|
// Returns true when the run was aborted between batches.
|
|
151
|
-
async function scanAllBatches(ctx, progress, signal) {
|
|
165
|
+
async function scanAllBatches(ctx, progress, signal, retryEventIds) {
|
|
166
|
+
for (let from = 0; from < retryEventIds.length; from += ctx.batchSize) {
|
|
167
|
+
if (signal?.aborted)
|
|
168
|
+
return true;
|
|
169
|
+
const step = await runOneBatch(ctx, progress, retryEventIds.slice(from, from + ctx.batchSize));
|
|
170
|
+
if (step !== null)
|
|
171
|
+
recordBatch(progress, step);
|
|
172
|
+
}
|
|
152
173
|
for (;;) {
|
|
153
174
|
if (signal?.aborted)
|
|
154
175
|
return true;
|
|
@@ -163,7 +184,9 @@ async function scanAllBatches(ctx, progress, signal) {
|
|
|
163
184
|
}
|
|
164
185
|
function reportRunOutcome(log, progress) {
|
|
165
186
|
if (progress.failures.length > 0) {
|
|
166
|
-
log.error(`${progress.failures.length} event(s) could not be PII-encrypted
|
|
187
|
+
log.error(`${progress.failures.length} event(s) could not be PII-encrypted; the scan moved past them and ` +
|
|
188
|
+
"only these events are retried on each boot. Fix the cause, or erase subjects that stay unresolvable via " +
|
|
189
|
+
"backfillEventPiiEncryption(..., { eraseUnresolvableSubjects: true })", {
|
|
167
190
|
failureCount: progress.failures.length,
|
|
168
191
|
eventIds: progress.failures.slice(0, MAX_LOGGED_FAILURE_IDS).map((f) => f.eventId),
|
|
169
192
|
});
|
|
@@ -180,24 +203,27 @@ export async function runPiiEventBackfill(db, registry, options = {}) {
|
|
|
180
203
|
const fingerprint = piiAnnotationFingerprint(registry);
|
|
181
204
|
await ensureInfraTable(db, "kumiko_pii_backfill_state", createPiiBackfillStateTable);
|
|
182
205
|
await ensureInfraTable(db, "kumiko_pending_rebuilds", createPendingRebuildsTable);
|
|
183
|
-
const
|
|
206
|
+
const initialState = await readPiiBackfillState(db, fingerprint);
|
|
207
|
+
const { mode, scanCursor } = initialScanCursor(initialState);
|
|
184
208
|
const ctx = {
|
|
185
209
|
db,
|
|
186
210
|
registry,
|
|
187
211
|
fingerprint,
|
|
188
|
-
|
|
212
|
+
// The per-run suffix keeps a peer replica's re-queue of the same table (new
|
|
213
|
+
// migration_id via the upsert) from being deleted by this run's clear.
|
|
214
|
+
migrationId: `${PII_BACKFILL_MIGRATION_PREFIX}${fingerprint.slice(0, 12)}:${crypto.randomUUID()}`,
|
|
189
215
|
mode,
|
|
190
216
|
batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE,
|
|
191
217
|
};
|
|
192
218
|
const progress = {
|
|
193
219
|
scanCursor,
|
|
194
|
-
failureSeen: false,
|
|
195
220
|
scanCache: {},
|
|
196
221
|
totals: { scannedEvents: 0, updatedEvents: 0, encryptedFields: 0, erasedFields: 0 },
|
|
197
222
|
failures: [],
|
|
198
223
|
queuedTables: new Set(),
|
|
224
|
+
failedIdsSeen: new Set(),
|
|
199
225
|
};
|
|
200
|
-
const aborted = await scanAllBatches(ctx, progress, options.signal);
|
|
226
|
+
const aborted = await scanAllBatches(ctx, progress, options.signal, initialState?.failedEventIds ?? []);
|
|
201
227
|
reportRunOutcome(log, progress);
|
|
202
228
|
const rebuild = aborted ? null : await rebuildQueuedTables(db, registry, options.signal, log);
|
|
203
229
|
const queuedTables = [...progress.queuedTables];
|
|
@@ -218,12 +244,7 @@ export async function runPiiEventBackfill(db, registry, options = {}) {
|
|
|
218
244
|
rebuild,
|
|
219
245
|
};
|
|
220
246
|
}
|
|
221
|
-
|
|
222
|
-
function persistableCursor(batch, startCursor) {
|
|
223
|
-
if (batch.firstFailedEventId !== null) {
|
|
224
|
-
const beforeFailure = BigInt(batch.firstFailedEventId) - 1n;
|
|
225
|
-
return beforeFailure > startCursor ? beforeFailure : startCursor;
|
|
226
|
-
}
|
|
247
|
+
function advancedCursor(batch, startCursor) {
|
|
227
248
|
return batch.lastEventId === null ? startCursor : BigInt(batch.lastEventId);
|
|
228
249
|
}
|
|
229
250
|
function maxBigInt(a, b) {
|
|
@@ -12,5 +12,5 @@ export { type MetricEvent, type MetricEventHandler, RecordingMeter, } from "./re
|
|
|
12
12
|
export { type RecordedSpan, RecordingTracer, type RecordingTracerOptions, serializeSpanContext, } from "./recording-tracer.js";
|
|
13
13
|
export { wrapRedisClient } from "./redis-wrapper.js";
|
|
14
14
|
export { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, REDACTED, redactAttributes, redactHeaders, redactQueryString, redactValue, shouldRedactAttribute, } from "./sensitive-filter.js";
|
|
15
|
-
export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
|
|
15
|
+
export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerPendingGaps, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
|
|
16
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";
|
|
@@ -13,4 +13,4 @@ export { RecordingMeter, } from "./recording-meter.js";
|
|
|
13
13
|
export { RecordingTracer, serializeSpanContext, } from "./recording-tracer.js";
|
|
14
14
|
export { wrapRedisClient } from "./redis-wrapper.js";
|
|
15
15
|
export { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, REDACTED, redactAttributes, redactHeaders, redactQueryString, redactValue, shouldRedactAttribute, } from "./sensitive-filter.js";
|
|
16
|
-
export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
|
|
16
|
+
export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerPendingGaps, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
|
|
@@ -28,6 +28,10 @@ export declare function emitEventConsumerLag(meter: Meter, labels: {
|
|
|
28
28
|
readonly consumer: string;
|
|
29
29
|
readonly instanceId: string;
|
|
30
30
|
}, lagEvents: number): void;
|
|
31
|
+
export declare function emitEventConsumerPendingGaps(meter: Meter, labels: {
|
|
32
|
+
readonly consumer: string;
|
|
33
|
+
readonly instanceId: string;
|
|
34
|
+
}, gapRanges: number): void;
|
|
31
35
|
export declare function emitEventConsumerRearmExhausted(meter: Meter, labels: {
|
|
32
36
|
readonly consumer: string;
|
|
33
37
|
readonly instanceId: string;
|
|
@@ -61,6 +61,12 @@ export const STANDARD_METRIC_DEFS = [
|
|
|
61
61
|
// per-instance consumers (one cursor per dispatcher). Without this,
|
|
62
62
|
// Prometheus collapses per-instance shards into last-writer-wins and
|
|
63
63
|
// per-instance lag is invisible to alerting.
|
|
64
|
+
{
|
|
65
|
+
name: "kumiko_event_consumer_pending_gaps",
|
|
66
|
+
type: "gauge",
|
|
67
|
+
description: "Number of unresolved id ranges below the consumer's cursor (ids not yet visible or proven rolled back).",
|
|
68
|
+
labels: ["consumer", "instance_id"],
|
|
69
|
+
},
|
|
64
70
|
{
|
|
65
71
|
name: "kumiko_event_consumer_lag_events",
|
|
66
72
|
type: "gauge",
|
|
@@ -212,6 +218,12 @@ export function emitEventConsumerLag(meter, labels, lagEvents) {
|
|
|
212
218
|
instance_id: labels.instanceId,
|
|
213
219
|
});
|
|
214
220
|
}
|
|
221
|
+
export function emitEventConsumerPendingGaps(meter, labels, gapRanges) {
|
|
222
|
+
meter.gauge("kumiko_event_consumer_pending_gaps").set(gapRanges, {
|
|
223
|
+
consumer: labels.consumer,
|
|
224
|
+
instance_id: labels.instanceId,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
215
227
|
// Caller (event-dispatcher.ts) is responsible for calling this exactly
|
|
216
228
|
// once per (consumer, instanceId) transition into exhausted-dead — not
|
|
217
229
|
// once per poll pass while it stays dead.
|
|
@@ -46,7 +46,9 @@ export type DispatchContext = {
|
|
|
46
46
|
tracer: ReturnType<typeof getFallbackTracer>;
|
|
47
47
|
meter: ReturnType<typeof getFallbackMeter>;
|
|
48
48
|
membershipQuery: string;
|
|
49
|
+
memberReadTimeoutMs: number;
|
|
49
50
|
};
|
|
51
|
+
export declare const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10000;
|
|
50
52
|
export declare function resolveDbSource(ctx: DispatchContext, tx: DbTx | undefined): DbConnection | DbTx | undefined;
|
|
51
53
|
export declare function isMemberResolutionPrincipal(user: SessionUser): boolean;
|
|
52
54
|
export declare function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext;
|