@cosmicdrift/kumiko-framework 0.305.0 → 0.307.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/api/__tests__/redis-sse-broker.integration.test.ts +66 -0
- package/src/api/__tests__/server-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +71 -0
- package/src/api/__tests__/sse-broker.test.ts +49 -0
- package/src/api/redis-sse-broker.ts +17 -3
- package/src/api/request-context.ts +29 -4
- package/src/api/routes.ts +26 -1
- package/src/api/sse-broker.ts +29 -11
- package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
- package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
- package/src/bun-db/query.ts +42 -18
- package/src/changes.json +92 -0
- package/src/db/__tests__/pg-error.test.ts +14 -0
- package/src/db/__tests__/system-db-view-export.test.ts +107 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
- package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
- package/src/db/index.ts +1 -1
- package/src/db/pg-error.ts +13 -0
- package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
- package/src/db/queries/event-consumer.ts +57 -3
- package/src/db/queries/event-store.ts +69 -0
- package/src/db/tenant-db.ts +133 -18
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +1 -1
- package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
- package/src/engine/extension-names.ts +55 -25
- package/src/engine/extensions/storage-provider.ts +14 -41
- package/src/engine/extensions/tenant-data.ts +4 -0
- package/src/engine/extensions/tenant-resource.ts +40 -0
- package/src/engine/extensions/user-data.ts +8 -7
- package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
- package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
- package/src/engine/feature-ast/entity-field-types.ts +41 -0
- package/src/engine/feature-ast/extractors/handlers.ts +217 -84
- package/src/engine/feature-ast/extractors/hooks.ts +72 -15
- package/src/engine/feature-ast/extractors/round2.ts +21 -0
- package/src/engine/feature-ast/extractors/shared.ts +9 -0
- package/src/engine/feature-ast/index.ts +11 -1
- package/src/engine/feature-ast/patch.ts +338 -5
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
- package/src/engine/feature-ast/patterns.ts +22 -15
- package/src/engine/feature-ast/render.ts +1 -0
- package/src/engine/feature-ui-extensions.ts +8 -7
- package/src/engine/index.ts +21 -5
- package/src/engine/types/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +6 -0
- package/src/event-store/__tests__/event-attribution.integration.test.ts +53 -3
- package/src/event-store/admin-api.ts +5 -0
- package/src/event-store/event-store.ts +16 -7
- package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
- package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
- package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
- package/src/jobs/job-runner.ts +211 -19
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
- package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
- package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
- package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/dispatch-batch.ts +102 -29
- package/src/pipeline/dispatch-stream.ts +7 -3
- package/src/pipeline/dispatcher-utils.ts +21 -2
- package/src/pipeline/dispatcher.ts +71 -6
- package/src/pipeline/event-consumer-state.ts +26 -0
- package/src/pipeline/event-dispatcher-admin.ts +32 -5
- package/src/pipeline/event-dispatcher-delivery.ts +109 -57
- package/src/pipeline/event-dispatcher.ts +167 -50
- package/src/pipeline/idempotency.ts +16 -0
- package/src/pipeline/pending-gap-ranges.ts +72 -0
- package/src/pipeline/system-hooks.ts +8 -1
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/pipeline/write-origin.ts +31 -10
- package/src/stack/test-stack.ts +1 -1
- package/src/testing/closed-connection-error.ts +62 -0
- package/src/testing/index.ts +1 -0
- package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
package/src/jobs/job-runner.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
1
2
|
import { type JobsOptions, Queue, Worker } from "bullmq";
|
|
2
3
|
import { Redis } from "ioredis";
|
|
3
4
|
import { requestContext } from "../api/request-context";
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
SYSTEM_TENANT_ID,
|
|
19
20
|
type TenantId,
|
|
20
21
|
} from "../engine/types";
|
|
22
|
+
import { InternalError } from "../errors";
|
|
21
23
|
import { isKumikoError } from "../errors/kumiko-error";
|
|
22
24
|
import { createFileContext } from "../files/file-handle";
|
|
23
25
|
import { createFallbackLogger } from "../logging";
|
|
@@ -33,8 +35,22 @@ import {
|
|
|
33
35
|
import { createEscapeHatchReporter } from "../observability/escape-hatch-report";
|
|
34
36
|
import { createDistributedLock, type DistributedLock } from "../pipeline/distributed-lock";
|
|
35
37
|
import { RedisKeys } from "../pipeline/redis-keys";
|
|
38
|
+
import {
|
|
39
|
+
buildPersonalDataGate,
|
|
40
|
+
isPersonalDataGated,
|
|
41
|
+
parseWriteOrigin,
|
|
42
|
+
} from "../pipeline/write-origin";
|
|
36
43
|
import { bridgeStub } from "../testing/handler-context";
|
|
37
44
|
|
|
45
|
+
// A payload's own `_writeOrigin` is never trusted; only the ambient gated origin is stamped.
|
|
46
|
+
function stampGatedWriteOrigin(data: Record<string, unknown>): void {
|
|
47
|
+
delete data["_writeOrigin"];
|
|
48
|
+
const origin = requestContext.get()?.writeOrigin;
|
|
49
|
+
if (origin && isPersonalDataGated(origin)) {
|
|
50
|
+
data["_writeOrigin"] = origin;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
38
54
|
// Queue-name convention: <prefix>-<lane>. The prefix is fixed in prod
|
|
39
55
|
// ("kumiko-jobs") — it must match between enqueuers and consumers, and an
|
|
40
56
|
// accidental drift would silently drop jobs. Tests override via
|
|
@@ -85,8 +101,10 @@ function legacySchedulerIdForJobName(jobName: string): string {
|
|
|
85
101
|
// for the same trigger (retry after failure, a Redis drop, an instance
|
|
86
102
|
// restarting mid-run) re-derives the *same* child ids, and BullMQ's
|
|
87
103
|
// existing-jobId add() no-ops the second batch instead of creating
|
|
88
|
-
// duplicates — the
|
|
89
|
-
//
|
|
104
|
+
// duplicates — safe only as long as the child job hash outlives the
|
|
105
|
+
// wrapper's retry window, enforced at construction against
|
|
106
|
+
// COMPLETED_JOB_RETENTION_AGE_SEC (see the invariant check in
|
|
107
|
+
// createJobRunner). Same colon-in-BullMQ-id hazard as schedulerIdForJobName
|
|
90
108
|
// (fw#1603/#1604) — the wrapper id already contains ":", so strip
|
|
91
109
|
// separators from both halves before joining instead of interpolating raw.
|
|
92
110
|
function perTenantChildJobId(wrapperJobId: string, tenantId: string): string {
|
|
@@ -225,6 +243,13 @@ export type JobRunnerOptions = {
|
|
|
225
243
|
// before failing boot. Defaults to BOOT_REDIS_TIMEOUT_MS; tests shrink it
|
|
226
244
|
// to keep an unreachable-Redis assertion fast.
|
|
227
245
|
bootRedisTimeoutMs?: number | undefined;
|
|
246
|
+
// Override how long completed/failed jobs stay in Redis before BullMQ's
|
|
247
|
+
// lazy queue-wide sweep evicts them. Defaults to
|
|
248
|
+
// COMPLETED_JOB_RETENTION_AGE_SEC / FAILED_JOB_RETENTION_AGE_SEC; tests
|
|
249
|
+
// shrink both to exercise the sweep without a real 24h/7d wait.
|
|
250
|
+
jobRetention?:
|
|
251
|
+
| { completedAgeSec?: number | undefined; failedAgeSec?: number | undefined }
|
|
252
|
+
| undefined;
|
|
228
253
|
getActiveTenantIds?: () => Promise<TenantId[]>;
|
|
229
254
|
onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
|
|
230
255
|
onJobComplete?: (
|
|
@@ -323,9 +348,10 @@ function timeoutReject(
|
|
|
323
348
|
// waiting.
|
|
324
349
|
const DEFAULT_JOB_BACKOFF_DELAY_MS = 1_000;
|
|
325
350
|
|
|
326
|
-
// Shared by dispatch()
|
|
327
|
-
//
|
|
328
|
-
//
|
|
351
|
+
// Shared by every enqueue path (dispatch(), handleEvent(), cron, runOnBoot,
|
|
352
|
+
// perTenant wrapper/children, sequential re-enqueue) — a job with `retries`
|
|
353
|
+
// set must retry the same way regardless of how it got enqueued, or it
|
|
354
|
+
// fails for good on the very first error on whichever path skips this.
|
|
329
355
|
function buildRetryBullOpts(jobDef: JobDefinition): Pick<JobsOptions, "attempts" | "backoff"> {
|
|
330
356
|
const opts: Pick<JobsOptions, "attempts" | "backoff"> = {};
|
|
331
357
|
if (jobDef.retries !== undefined) opts.attempts = jobDef.retries + 1;
|
|
@@ -341,6 +367,34 @@ function buildRetryBullOpts(jobDef: JobDefinition): Pick<JobsOptions, "attempts"
|
|
|
341
367
|
return opts;
|
|
342
368
|
}
|
|
343
369
|
|
|
370
|
+
// BullMQ sweeps queue-wide: any job finishing with keepJobs set can evict
|
|
371
|
+
// OTHER jobs in the same completed/failed zset (moveToFinished lua,
|
|
372
|
+
// removeJobsByMaxAge/removeJobsByMaxCount), not just the finishing job, and
|
|
373
|
+
// the sweep only runs lazily on a later finish into that set. Per-job
|
|
374
|
+
// retention is therefore meaningless on a shared queue — only queue-wide,
|
|
375
|
+
// age-only retention (no count) is safe: a count would evict perTenant
|
|
376
|
+
// children still inside their wrapper's retry window, and boot jobs. The
|
|
377
|
+
// completed age must stay above every perTenant wrapper's own worst-case
|
|
378
|
+
// retry window (see maxRetryWindowMs and the invariant check in
|
|
379
|
+
// createJobRunner below), because child dedup (perTenantChildJobId) relies
|
|
380
|
+
// on the children still existing in Redis when a wrapper retry re-derives
|
|
381
|
+
// their ids. The margin here is generous — queue wait time isn't bounded by
|
|
382
|
+
// backoff — to cover realistic windows. Audit trail lives in read_job_runs,
|
|
383
|
+
// not BullMQ (fw#3199).
|
|
384
|
+
const COMPLETED_JOB_RETENTION_AGE_SEC = 86_400; // 24h
|
|
385
|
+
const FAILED_JOB_RETENTION_AGE_SEC = 604_800; // 7d
|
|
386
|
+
|
|
387
|
+
// Total wall-clock time BullMQ can hold a perTenant wrapper across all its
|
|
388
|
+
// retries, consistent with buildRetryBullOpts's own backoff computation.
|
|
389
|
+
function maxRetryWindowMs(jobDef: JobDefinition): number {
|
|
390
|
+
if (!jobDef.backoff || jobDef.retries === undefined) return 0;
|
|
391
|
+
const delayMs =
|
|
392
|
+
(typeof jobDef.backoff === "string" ? undefined : jobDef.backoff.delayMs) ??
|
|
393
|
+
DEFAULT_JOB_BACKOFF_DELAY_MS;
|
|
394
|
+
const type = typeof jobDef.backoff === "string" ? jobDef.backoff : jobDef.backoff.type;
|
|
395
|
+
return type === "exponential" ? delayMs * (2 ** jobDef.retries - 1) : jobDef.retries * delayMs;
|
|
396
|
+
}
|
|
397
|
+
|
|
344
398
|
export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
345
399
|
const { registry, context, redisUrl, consumerLane } = options;
|
|
346
400
|
const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
|
|
@@ -397,6 +451,49 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
397
451
|
|
|
398
452
|
const allJobs = registry.getAllJobs();
|
|
399
453
|
|
|
454
|
+
function positiveIntRetentionSec(
|
|
455
|
+
value: number | undefined,
|
|
456
|
+
fallback: number,
|
|
457
|
+
label: string,
|
|
458
|
+
): number {
|
|
459
|
+
const resolved = value ?? fallback;
|
|
460
|
+
if (!Number.isInteger(resolved) || resolved <= 0) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
`job-runner: jobRetention.${label} must be a positive integer, got ${resolved}`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
return resolved;
|
|
466
|
+
}
|
|
467
|
+
const completedAgeSec = positiveIntRetentionSec(
|
|
468
|
+
options.jobRetention?.completedAgeSec,
|
|
469
|
+
COMPLETED_JOB_RETENTION_AGE_SEC,
|
|
470
|
+
"completedAgeSec",
|
|
471
|
+
);
|
|
472
|
+
const failedAgeSec = positiveIntRetentionSec(
|
|
473
|
+
options.jobRetention?.failedAgeSec,
|
|
474
|
+
FAILED_JOB_RETENTION_AGE_SEC,
|
|
475
|
+
"failedAgeSec",
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
// perTenant child dedup (perTenantChildJobId) only holds as long as the
|
|
479
|
+
// children are still in Redis when a wrapper retry re-derives their ids,
|
|
480
|
+
// which requires the completed-job retention to outlast the wrapper's own
|
|
481
|
+
// worst-case retry window. Enforced here, before any Redis connection
|
|
482
|
+
// opens below, so a misconfigured job fails boot loudly instead of
|
|
483
|
+
// silently losing dedup in prod.
|
|
484
|
+
for (const [name, jobDef] of allJobs) {
|
|
485
|
+
if (!jobDef.perTenant) continue;
|
|
486
|
+
const windowMs = maxRetryWindowMs(jobDef);
|
|
487
|
+
if (windowMs >= completedAgeSec * 1000) {
|
|
488
|
+
throw new Error(
|
|
489
|
+
`job-runner: perTenant job "${name}" has a retry window of ${windowMs}ms, which is >= ` +
|
|
490
|
+
`the completed-job retention (${completedAgeSec}s). Child dedup relies on children ` +
|
|
491
|
+
"staying in Redis for the whole retry window — lower retries/backoff or raise " +
|
|
492
|
+
"jobRetention.completedAgeSec.",
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
400
497
|
// Resolve the lane for a job — "worker" is the default because that's the
|
|
401
498
|
// sensible prod lane (heavy async off the request path). Jobs that opted
|
|
402
499
|
// into "api" must have been validated at registry boot already.
|
|
@@ -440,9 +537,22 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
440
537
|
// queue matching the target job's runIn. Client-creation is cheap (shared
|
|
441
538
|
// ioredis connection via bullmq), so this doesn't scale with number of
|
|
442
539
|
// processes.
|
|
540
|
+
// Queue-wide, age-only retention (see COMPLETED_JOB_RETENTION_AGE_SEC
|
|
541
|
+
// above for why) — merged into every add()/addBulk()/upsertJobScheduler()
|
|
542
|
+
// template on both lanes.
|
|
543
|
+
const jobRetentionOpts: Pick<JobsOptions, "removeOnComplete" | "removeOnFail"> = {
|
|
544
|
+
removeOnComplete: { age: completedAgeSec },
|
|
545
|
+
removeOnFail: { age: failedAgeSec },
|
|
546
|
+
};
|
|
443
547
|
const queues: Readonly<Record<JobRunIn, Queue>> = {
|
|
444
|
-
api: new Queue(queueNameFor(queueNamePrefix, "api"), {
|
|
445
|
-
|
|
548
|
+
api: new Queue(queueNameFor(queueNamePrefix, "api"), {
|
|
549
|
+
connection: redisOpts,
|
|
550
|
+
defaultJobOptions: jobRetentionOpts,
|
|
551
|
+
}),
|
|
552
|
+
worker: new Queue(queueNameFor(queueNamePrefix, "worker"), {
|
|
553
|
+
connection: redisOpts,
|
|
554
|
+
defaultJobOptions: jobRetentionOpts,
|
|
555
|
+
}),
|
|
446
556
|
};
|
|
447
557
|
// Same unhandled-'error'-crash hazard as lockRedis above, just via
|
|
448
558
|
// BullMQ's internal ioredis client (fw#1805).
|
|
@@ -536,9 +646,15 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
536
646
|
await targetQueue.add(
|
|
537
647
|
actualName,
|
|
538
648
|
{ ...bullJob.data, _tenantId: tenantId },
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
649
|
+
{
|
|
650
|
+
...buildRetryBullOpts(actualDef),
|
|
651
|
+
// Dedup over wrapper retries only holds as long as the children
|
|
652
|
+
// stay in Redis for the wrapper's whole retry window — see the
|
|
653
|
+
// COMPLETED_JOB_RETENTION_AGE_SEC invariant check above.
|
|
654
|
+
...(wrapperJobId !== undefined
|
|
655
|
+
? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
|
|
656
|
+
: {}),
|
|
657
|
+
},
|
|
542
658
|
);
|
|
543
659
|
}
|
|
544
660
|
// skip: fan-out dispatcher job, per-tenant children enqueued
|
|
@@ -567,8 +683,13 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
567
683
|
// same queue the worker just picked from (since only the consuming
|
|
568
684
|
// lane runs handleJob at all), but route explicitly — no implicit
|
|
569
685
|
// coupling to "whichever queue the caller happened to be on".
|
|
686
|
+
// The re-enqueued job starts with a full retry budget; a remaining
|
|
687
|
+
// budget is deliberately not carried over, since finalAttempt/
|
|
688
|
+
// tenantVisibleFailure are computed from jobDef.retries against
|
|
689
|
+
// attemptsMade, not against some inherited remainder.
|
|
570
690
|
await queues[laneForJob(jobDef)].add(jobName, bullJob.data, {
|
|
571
691
|
delay: SEQUENTIAL_RETRY_DELAY_MS,
|
|
692
|
+
...buildRetryBullOpts(jobDef),
|
|
572
693
|
});
|
|
573
694
|
// skip: lock taken, work re-enqueued with delay, current invocation done
|
|
574
695
|
return;
|
|
@@ -626,6 +747,21 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
626
747
|
// multi-trigger dispatch; exposed as jobContext.triggerName so handlers
|
|
627
748
|
// don't dig through the raw payload themselves.
|
|
628
749
|
const triggerName = rawData["_triggerName"] as string | undefined; // @cast-boundary dynamic-key
|
|
750
|
+
|
|
751
|
+
// Absent = legacy or ungated root; present but invalid fails the run closed.
|
|
752
|
+
const rawWriteOrigin = rawData["_writeOrigin"]; // @cast-boundary dynamic-key
|
|
753
|
+
let jobOrigin: WriteOrigin | undefined;
|
|
754
|
+
let writeOriginInvalid = false;
|
|
755
|
+
if (rawWriteOrigin !== undefined) {
|
|
756
|
+
const parsed = parseWriteOrigin(rawWriteOrigin);
|
|
757
|
+
if (parsed) {
|
|
758
|
+
jobOrigin = { ...parsed, viaJob: jobName };
|
|
759
|
+
} else {
|
|
760
|
+
writeOriginInvalid = true;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const jobPersonalDataGate = jobOrigin ? buildPersonalDataGate(registry, jobOrigin) : undefined;
|
|
764
|
+
|
|
629
765
|
// Mirror dispatch-shared.ts buildHandlerContext: ctx.files must resolve
|
|
630
766
|
// through the same _fileProviderResolver for jobs as for write-handlers,
|
|
631
767
|
// otherwise event-triggered jobs silently get an unresolved ctx.files.
|
|
@@ -644,8 +780,19 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
644
780
|
// systemScope() status (pre-existing, not something this change alters)
|
|
645
781
|
// — isSystemJob below is what actually keeps ctx.systemDb off a
|
|
646
782
|
// non-system job; it is the only thing standing between this db and an
|
|
647
|
-
// unchecked cross-tenant escape hatch for such a job.
|
|
648
|
-
|
|
783
|
+
// unchecked cross-tenant escape hatch for such a job. Gated like jobDb so
|
|
784
|
+
// ctx.systemDb, built from it, is gated too.
|
|
785
|
+
const tenantScopedDb = configDb
|
|
786
|
+
? createTenantDb(
|
|
787
|
+
configDb,
|
|
788
|
+
tenantId,
|
|
789
|
+
"system",
|
|
790
|
+
undefined,
|
|
791
|
+
undefined,
|
|
792
|
+
undefined,
|
|
793
|
+
jobPersonalDataGate ? { personalDataGate: jobPersonalDataGate } : undefined,
|
|
794
|
+
)
|
|
795
|
+
: undefined;
|
|
649
796
|
const isSystemJob = registry.isJobSystemScoped(jobName);
|
|
650
797
|
// One reporter for ctx.systemDb and ctx.db.unsafeRaw() so both dedupe in the same window.
|
|
651
798
|
const reportEscapeHatch = createEscapeHatchReporter({
|
|
@@ -663,6 +810,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
663
810
|
? createTenantDb(configDb, tenantId, "tenant", context.tracer, context.meter, undefined, {
|
|
664
811
|
unsafeRaw: jobDef.escapeHatch,
|
|
665
812
|
report: reportEscapeHatch,
|
|
813
|
+
...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
|
|
666
814
|
})
|
|
667
815
|
: undefined;
|
|
668
816
|
const config =
|
|
@@ -713,7 +861,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
713
861
|
"JobContext.write called before dispatcher attached — call attachDispatcher() first",
|
|
714
862
|
);
|
|
715
863
|
}
|
|
716
|
-
return dispatchWriteRef.write(jobSystemUser, qn, payload);
|
|
864
|
+
return dispatchWriteRef.write(jobSystemUser, qn, payload, jobOrigin);
|
|
717
865
|
},
|
|
718
866
|
writeAs: (user: SessionUser, qn: string, payload: unknown) => {
|
|
719
867
|
if (!dispatchWriteRef) {
|
|
@@ -721,7 +869,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
721
869
|
"JobContext.writeAs called before dispatcher attached — call attachDispatcher() first",
|
|
722
870
|
);
|
|
723
871
|
}
|
|
724
|
-
return dispatchWriteRef.write(user, qn, payload);
|
|
872
|
+
return dispatchWriteRef.write(user, qn, payload, jobOrigin);
|
|
725
873
|
},
|
|
726
874
|
queryAs: (user: SessionUser, qn: string, payload: unknown) => {
|
|
727
875
|
if (!dispatchWriteRef) {
|
|
@@ -729,7 +877,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
729
877
|
"JobContext.queryAs called before dispatcher attached — call attachDispatcher() first",
|
|
730
878
|
);
|
|
731
879
|
}
|
|
732
|
-
return dispatchWriteRef.queryAs(user, qn, payload);
|
|
880
|
+
return dispatchWriteRef.queryAs(user, qn, payload, jobOrigin);
|
|
733
881
|
},
|
|
734
882
|
queryAsMember: (userId: string, qn: string, payload: unknown) => {
|
|
735
883
|
if (!dispatchWriteRef) {
|
|
@@ -761,6 +909,13 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
761
909
|
|
|
762
910
|
const runInSpan = async (): Promise<void> => {
|
|
763
911
|
try {
|
|
912
|
+
if (writeOriginInvalid) {
|
|
913
|
+
throw new InternalError({
|
|
914
|
+
message:
|
|
915
|
+
`Job "${jobName}" received an unparseable _writeOrigin — refusing to run without ` +
|
|
916
|
+
"a trustworthy anonymous-root gate.",
|
|
917
|
+
});
|
|
918
|
+
}
|
|
764
919
|
await requestContext.run(
|
|
765
920
|
{
|
|
766
921
|
requestId: jobRequestId,
|
|
@@ -768,6 +923,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
768
923
|
// #3043 — events a job writes carry the job as their origin.
|
|
769
924
|
handler: jobName,
|
|
770
925
|
feature: qnScope(jobName),
|
|
926
|
+
writeOrigin: jobOrigin,
|
|
771
927
|
},
|
|
772
928
|
() => jobDef.handler(payload, jobContext),
|
|
773
929
|
);
|
|
@@ -914,20 +1070,48 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
914
1070
|
{
|
|
915
1071
|
name: jobDef.perTenant ? `_perTenant:${name}` : name,
|
|
916
1072
|
data: {},
|
|
1073
|
+
// Queue-level defaultJobOptions (jobRetentionOpts) covers
|
|
1074
|
+
// retention; a count here would sweep queue-wide again and
|
|
1075
|
+
// evict perTenant children and boot jobs.
|
|
917
1076
|
opts: {
|
|
918
|
-
|
|
919
|
-
removeOnFail: { count: 50 },
|
|
1077
|
+
...buildRetryBullOpts(jobDef),
|
|
920
1078
|
},
|
|
921
1079
|
},
|
|
922
1080
|
);
|
|
923
1081
|
}
|
|
924
1082
|
}
|
|
925
1083
|
|
|
1084
|
+
// Persistent marker outside the swept completed/failed sets: a plain
|
|
1085
|
+
// Redis hash at one key per consumer queue, fields = boot job ids
|
|
1086
|
+
// already enqueued (BullMQ's IRedisClient has no set commands, only
|
|
1087
|
+
// hash/hexists — a hash with dummy values does the same job). The job
|
|
1088
|
+
// hash itself (removeOnComplete/-Fail: age-bound) is not a safe dedup
|
|
1089
|
+
// target any more — retention now evicts it eventually, which would
|
|
1090
|
+
// otherwise re-run a boot job "once per dataset" every time it ages
|
|
1091
|
+
// out. Order matters: HEXISTS check, then add(), then HSET.
|
|
1092
|
+
// Concurrent starts still dedupe on the existing job hash from add()'s
|
|
1093
|
+
// own jobId no-op; a crash between add() and HSET dedupes again on the
|
|
1094
|
+
// *next* boot (the job hash is still there) instead of losing the
|
|
1095
|
+
// boot job forever, which HSET-first would risk if the process died
|
|
1096
|
+
// before add() ran.
|
|
1097
|
+
const bootEnqueuedKey = consumerQueue.toKey("kumiko-boot-enqueued");
|
|
926
1098
|
for (const [name, jobDef] of allJobs) {
|
|
927
1099
|
if (laneForJob(jobDef) !== consumerLane) continue;
|
|
928
1100
|
if (jobDef.runOnBoot) {
|
|
929
1101
|
const bootName = jobDef.perTenant ? `_perTenant:${name}` : name;
|
|
930
|
-
|
|
1102
|
+
const bootJobId = bootJobIdForJobName(name);
|
|
1103
|
+
const client = await consumerQueue.client;
|
|
1104
|
+
const alreadyEnqueued = await client.hexists(bootEnqueuedKey, bootJobId);
|
|
1105
|
+
if (alreadyEnqueued) continue;
|
|
1106
|
+
await consumerQueue.add(
|
|
1107
|
+
bootName,
|
|
1108
|
+
{},
|
|
1109
|
+
{
|
|
1110
|
+
jobId: bootJobId,
|
|
1111
|
+
...buildRetryBullOpts(jobDef),
|
|
1112
|
+
},
|
|
1113
|
+
);
|
|
1114
|
+
await client.hset(bootEnqueuedKey, { [bootJobId]: 1 });
|
|
931
1115
|
}
|
|
932
1116
|
}
|
|
933
1117
|
|
|
@@ -974,7 +1158,13 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
974
1158
|
|
|
975
1159
|
// perTenant: dispatch the fan-out wrapper instead
|
|
976
1160
|
if (jobDef.perTenant) {
|
|
977
|
-
const
|
|
1161
|
+
const perTenantData: Record<string, unknown> = { ...(payload ?? {}) };
|
|
1162
|
+
stampGatedWriteOrigin(perTenantData);
|
|
1163
|
+
const job = await targetQueue.add(
|
|
1164
|
+
`_perTenant:${jobName}`,
|
|
1165
|
+
perTenantData,
|
|
1166
|
+
buildRetryBullOpts(jobDef),
|
|
1167
|
+
);
|
|
978
1168
|
return job.id ?? "unknown";
|
|
979
1169
|
}
|
|
980
1170
|
|
|
@@ -1047,6 +1237,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
1047
1237
|
// stamp the same correlation as the HTTP request that scheduled it.
|
|
1048
1238
|
const reqCtx = requestContext.get();
|
|
1049
1239
|
if (reqCtx?.correlationId) data["_correlationId"] = reqCtx.correlationId;
|
|
1240
|
+
stampGatedWriteOrigin(data);
|
|
1050
1241
|
|
|
1051
1242
|
const job = await targetQueue.add(jobName, data, bullOpts);
|
|
1052
1243
|
return job.id ?? "unknown";
|
|
@@ -1091,6 +1282,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
1091
1282
|
continue;
|
|
1092
1283
|
}
|
|
1093
1284
|
}
|
|
1285
|
+
stampGatedWriteOrigin(data);
|
|
1094
1286
|
// Route to the job's declared lane, not a fixed queue — that's
|
|
1095
1287
|
// the whole reason both queues are held.
|
|
1096
1288
|
await queues[laneForJob(jobDef)].add(name, data, buildRetryBullOpts(jobDef));
|
|
@@ -177,35 +177,52 @@ const bridgeFeature = defineFeature("ctxbridge", (r) => {
|
|
|
177
177
|
},
|
|
178
178
|
);
|
|
179
179
|
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
// (should — durability writes must survive a client disconnect).
|
|
183
|
-
//
|
|
184
|
-
// The throwing side reads via ctx.db.selectMany, not the event-store
|
|
185
|
-
// executor's create() — the latter writes through db.raw (bypassing
|
|
186
|
-
// TenantDb's withDbSpan/signal check entirely), so it wouldn't exercise
|
|
187
|
-
// the signal wiring this test is meant to prove. insertOne isn't an
|
|
188
|
-
// option either: bagTable is executor-managed (WritableTable rejects its
|
|
189
|
-
// EXECUTOR_ONLY brand) — direct writes would drift it past its event
|
|
190
|
-
// stream. selectMany has no such restriction (reads keep the plain
|
|
191
|
-
// SchemaTable param) and still goes through the same signal check.
|
|
180
|
+
// Proves runBatch strips the request signal: both inserts land and
|
|
181
|
+
// ctx.signal is undefined even though the request was pre-aborted.
|
|
192
182
|
r.writeHandler(
|
|
193
183
|
"bag:create-signal-probe",
|
|
194
184
|
z.object({ label: z.string() }),
|
|
195
185
|
async (event, ctx) => {
|
|
196
186
|
const crud = createEventStoreExecutor(bagTable, bagEntity, { entityName: "bag" });
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
await ctx.db?.selectMany(bagTable, {});
|
|
200
|
-
} catch (err) {
|
|
201
|
-
dbThrewAbortError = err instanceof Error && err.name === "AbortError";
|
|
202
|
-
}
|
|
187
|
+
await ctx.db?.selectMany(bagTable, {});
|
|
188
|
+
await crud.create({ label: `${event.payload.label}-inside-tx` }, event.user, ctx.db);
|
|
203
189
|
const outsideTx = ctx.dbOutsideTransaction;
|
|
204
190
|
if (!outsideTx) {
|
|
205
191
|
throw new Error("bag:create-signal-probe requires ctx.dbOutsideTransaction");
|
|
206
192
|
}
|
|
207
193
|
await crud.create({ label: `${event.payload.label}-outside-tx` }, event.user, outsideTx);
|
|
208
|
-
return { isSuccess: true as const, data: {
|
|
194
|
+
return { isSuccess: true as const, data: { signalSeen: ctx.signal !== undefined } };
|
|
195
|
+
},
|
|
196
|
+
{ access: { roles: ["Admin"] } },
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Query path counterpart — ctx.db.selectMany hits signal.throwIfAborted().
|
|
200
|
+
r.queryHandler(
|
|
201
|
+
"bag:list-signal-probe",
|
|
202
|
+
z.object({}),
|
|
203
|
+
async (_query, ctx) => selectMany(ctx.db, bagTable),
|
|
204
|
+
{ access: { roles: ["Admin"] } },
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// Negative control: never touches ctx.db, so a pre-aborted signal must still 500.
|
|
208
|
+
r.queryHandler(
|
|
209
|
+
"bag:query-boom",
|
|
210
|
+
z.object({}),
|
|
211
|
+
async () => {
|
|
212
|
+
throw new Error("unrelated boom");
|
|
213
|
+
},
|
|
214
|
+
{ access: { roles: ["Admin"] } },
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Pre-write ctx.db read — crud.create's own insert writes via db.raw and
|
|
218
|
+
// doesn't check the signal, so this is what makes the test discriminating.
|
|
219
|
+
r.writeHandler(
|
|
220
|
+
"bag:create-plain",
|
|
221
|
+
z.object({ label: z.string() }),
|
|
222
|
+
async (event, ctx) => {
|
|
223
|
+
await ctx.db?.selectMany(bagTable, {});
|
|
224
|
+
const crud = createEventStoreExecutor(bagTable, bagEntity, { entityName: "bag" });
|
|
225
|
+
return crud.create(event.payload, event.user, ctx.db);
|
|
209
226
|
},
|
|
210
227
|
{ access: { roles: ["Admin"] } },
|
|
211
228
|
);
|
|
@@ -366,7 +383,7 @@ describe("ctx.dbOutsideTransaction", () => {
|
|
|
366
383
|
expect(labels).toEqual(["probe-outside-tx"]);
|
|
367
384
|
});
|
|
368
385
|
|
|
369
|
-
test("an already-aborted request signal
|
|
386
|
+
test("an already-aborted request signal still commits both ctx.db and ctx.dbOutsideTransaction writes", async () => {
|
|
370
387
|
const controller = new AbortController();
|
|
371
388
|
controller.abort();
|
|
372
389
|
const token = await stack.jwt.sign(admin);
|
|
@@ -385,16 +402,86 @@ describe("ctx.dbOutsideTransaction", () => {
|
|
|
385
402
|
|
|
386
403
|
const body = (await res.json()) as {
|
|
387
404
|
isSuccess: boolean;
|
|
388
|
-
data?: {
|
|
405
|
+
data?: { signalSeen: boolean };
|
|
389
406
|
};
|
|
390
407
|
expect(body.isSuccess).toBe(true);
|
|
391
|
-
expect(body.data?.
|
|
408
|
+
expect(body.data?.signalSeen).toBe(false);
|
|
392
409
|
|
|
393
|
-
//
|
|
394
|
-
// could write, and there was nothing to roll back for it.
|
|
410
|
+
// Both inserts landed — the client's disconnect doesn't touch the write.
|
|
395
411
|
const bags = await selectMany(stack.db, bagTable);
|
|
396
|
-
const labels = (bags as Array<Record<string, unknown>>).map((row) => row["label"]);
|
|
397
|
-
expect(labels).toEqual(["signal-probe-outside-tx"]);
|
|
412
|
+
const labels = (bags as Array<Record<string, unknown>>).map((row) => row["label"]).sort();
|
|
413
|
+
expect(labels).toEqual(["signal-probe-inside-tx", "signal-probe-outside-tx"]);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("an already-aborted signal doesn't stop idempotent retries from committing", async () => {
|
|
417
|
+
const controller = new AbortController();
|
|
418
|
+
controller.abort();
|
|
419
|
+
const token = await stack.jwt.sign(admin);
|
|
420
|
+
const requestId = "retry-under-abort-1";
|
|
421
|
+
|
|
422
|
+
const requestOptions = {
|
|
423
|
+
method: "POST",
|
|
424
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
425
|
+
body: JSON.stringify({
|
|
426
|
+
type: "ctxbridge:write:bag:create-plain",
|
|
427
|
+
payload: { label: "retried" },
|
|
428
|
+
requestId,
|
|
429
|
+
}),
|
|
430
|
+
signal: controller.signal,
|
|
431
|
+
} as const;
|
|
432
|
+
|
|
433
|
+
const first = await stack.app.request(
|
|
434
|
+
new Request("http://test.local/api/write", requestOptions),
|
|
435
|
+
);
|
|
436
|
+
const second = await stack.app.request(
|
|
437
|
+
new Request("http://test.local/api/write", requestOptions),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
expect((await first.json()).isSuccess).toBe(true);
|
|
441
|
+
expect((await second.json()).isSuccess).toBe(true);
|
|
442
|
+
|
|
443
|
+
const bags = await selectMany(stack.db, bagTable);
|
|
444
|
+
expect(bags).toHaveLength(1);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
describe("query dispatch surfaces a client abort as 499, not a server fault", () => {
|
|
449
|
+
test("a pre-aborted signal 499s instead of 500ing", async () => {
|
|
450
|
+
const controller = new AbortController();
|
|
451
|
+
controller.abort();
|
|
452
|
+
const token = await stack.jwt.sign(admin);
|
|
453
|
+
|
|
454
|
+
const res = await stack.app.request(
|
|
455
|
+
new Request("http://test.local/api/query", {
|
|
456
|
+
method: "POST",
|
|
457
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
458
|
+
body: JSON.stringify({
|
|
459
|
+
type: "ctxbridge:query:bag:list-signal-probe",
|
|
460
|
+
payload: {},
|
|
461
|
+
}),
|
|
462
|
+
signal: controller.signal,
|
|
463
|
+
}),
|
|
464
|
+
);
|
|
465
|
+
expect(res.status).toBe(499);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("a pre-aborted signal still 500s when the handler fails for an unrelated reason", async () => {
|
|
469
|
+
const controller = new AbortController();
|
|
470
|
+
controller.abort();
|
|
471
|
+
const token = await stack.jwt.sign(admin);
|
|
472
|
+
|
|
473
|
+
const res = await stack.app.request(
|
|
474
|
+
new Request("http://test.local/api/query", {
|
|
475
|
+
method: "POST",
|
|
476
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
477
|
+
body: JSON.stringify({
|
|
478
|
+
type: "ctxbridge:query:bag:query-boom",
|
|
479
|
+
payload: {},
|
|
480
|
+
}),
|
|
481
|
+
signal: controller.signal,
|
|
482
|
+
}),
|
|
483
|
+
);
|
|
484
|
+
expect(res.status).toBe(500);
|
|
398
485
|
});
|
|
399
486
|
});
|
|
400
487
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { createSystemUser } from "../../engine/system-user";
|
|
3
3
|
import { InternalError } from "../../errors";
|
|
4
|
+
import { VersionConflictError as EventStoreVersionConflictError } from "../../event-store/errors";
|
|
4
5
|
import {
|
|
5
6
|
describeShape,
|
|
6
7
|
dispatcherSpanAttributes,
|
|
@@ -115,6 +116,13 @@ describe("wrapToKumiko", () => {
|
|
|
115
116
|
expect(wrapped.code).toBe("internal_error");
|
|
116
117
|
expect(wrapped.cause).toBeInstanceOf(TypeError);
|
|
117
118
|
});
|
|
119
|
+
|
|
120
|
+
test("maps an event-store version conflict to a 409 version_conflict", () => {
|
|
121
|
+
const wrapped = wrapToKumiko(new EventStoreVersionConflictError("agg-1", 3));
|
|
122
|
+
expect(wrapped.code).toBe("version_conflict");
|
|
123
|
+
expect(wrapped.httpStatus).toBe(409);
|
|
124
|
+
expect(wrapped.details).toMatchObject({ entityId: "agg-1", expectedVersion: 3 });
|
|
125
|
+
});
|
|
118
126
|
});
|
|
119
127
|
|
|
120
128
|
describe("extractNestedSpecs", () => {
|
|
@@ -1082,5 +1082,10 @@ function createMockIdempotencyGuard() {
|
|
|
1082
1082
|
pendingTokens.delete(key);
|
|
1083
1083
|
results.set(key, JSON.stringify(result));
|
|
1084
1084
|
},
|
|
1085
|
+
async release(tenantId: string, userId: string, requestId: string, token: string) {
|
|
1086
|
+
const key = `${tenantId}:${userId}:${requestId}`;
|
|
1087
|
+
if (pendingTokens.get(key) !== token) return;
|
|
1088
|
+
pendingTokens.delete(key);
|
|
1089
|
+
},
|
|
1085
1090
|
};
|
|
1086
1091
|
}
|