@cosmicdrift/kumiko-framework 0.364.0 → 0.365.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.
Files changed (163) hide show
  1. package/AGENTS.md +36 -5
  2. package/agents/examples/anonymous-access-multitenant.md +9 -6
  3. package/agents/examples/anonymous-access.md +14 -26
  4. package/agents/examples/auth-mfa.md +5 -9
  5. package/agents/examples/custom-fields-basic.md +7 -6
  6. package/agents/examples/custom-handlers.md +72 -25
  7. package/agents/examples/dashboard-stats.md +229 -0
  8. package/agents/examples/encrypted-tenant-config.md +17 -8
  9. package/agents/examples/error-contract.md +1 -5
  10. package/agents/examples/feature-toggles.md +28 -17
  11. package/agents/examples/ledger-banking.md +7 -6
  12. package/agents/examples/ledger-invoicing.md +7 -6
  13. package/agents/examples/legal-pages.md +2 -2
  14. package/agents/examples/managed-pages.md +2 -2
  15. package/agents/examples/notes-history-basic.md +7 -6
  16. package/agents/examples/ownership.md +30 -22
  17. package/agents/examples/pdf-audit-export.md +87 -51
  18. package/agents/examples/session-revocation.md +1 -10
  19. package/agents/examples/tags-basic.md +7 -7
  20. package/agents/examples/user-data-rights.md +25 -17
  21. package/agents/examples/webhook-step.md +11 -1
  22. package/agents/examples/workflow-engine.md +2 -2
  23. package/agents/examples.md +2 -1
  24. package/agents/features.md +4 -4
  25. package/dist/api/auth-routes.d.ts +0 -1
  26. package/dist/api/auth-routes.js +44 -43
  27. package/dist/api/index.d.ts +1 -1
  28. package/dist/api/index.js +1 -1
  29. package/dist/api/server.d.ts +2 -2
  30. package/dist/api/server.js +29 -4
  31. package/dist/bun-db/connection.js +3 -16
  32. package/dist/bun-db/index.d.ts +1 -1
  33. package/dist/bun-db/index.js +1 -1
  34. package/dist/bun-db/query.d.ts +7 -0
  35. package/dist/bun-db/query.js +50 -18
  36. package/dist/crypto/in-memory-kms-adapter.js +4 -7
  37. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  38. package/dist/crypto/owner-transition-rekey.js +7 -3
  39. package/dist/crypto/pg-kms-adapter.js +10 -6
  40. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  41. package/dist/crypto/pii-field-encryption.js +9 -2
  42. package/dist/crypto/subject-resolver.d.ts +1 -0
  43. package/dist/crypto/subject-resolver.js +7 -0
  44. package/dist/db/api.d.ts +4 -3
  45. package/dist/db/apply-entity-event.js +26 -2
  46. package/dist/db/blind-index-cleanup.js +2 -1
  47. package/dist/db/bun-provider.js +9 -18
  48. package/dist/db/dialect.d.ts +1 -1
  49. package/dist/db/dialect.js +2 -0
  50. package/dist/db/eagerload.js +2 -1
  51. package/dist/db/event-store-executor-context.d.ts +1 -0
  52. package/dist/db/event-store-executor-context.js +1 -0
  53. package/dist/db/event-store-executor-write.d.ts +1 -1
  54. package/dist/db/event-store-executor-write.js +85 -58
  55. package/dist/db/index.d.ts +1 -0
  56. package/dist/db/index.js +1 -0
  57. package/dist/db/migrate-runner.d.ts +1 -0
  58. package/dist/db/migrate-runner.js +56 -31
  59. package/dist/db/queries/backfill-pii.js +63 -25
  60. package/dist/db/queries/event-consumer.js +2 -1
  61. package/dist/db/queries/event-store-transfer.js +15 -4
  62. package/dist/db/queries/event-store.js +11 -10
  63. package/dist/db/queries/pii-backfill-state.js +3 -2
  64. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  65. package/dist/db/queries/postgres-array-literal.js +6 -0
  66. package/dist/db/queries/projection-rebuild.js +3 -2
  67. package/dist/db/queries/shadow-swap.d.ts +1 -1
  68. package/dist/db/queries/shadow-swap.js +12 -8
  69. package/dist/db/query.d.ts +1 -1
  70. package/dist/db/query.js +1 -1
  71. package/dist/db/retired-framework-tables.js +22 -4
  72. package/dist/db/table-builder.js +6 -2
  73. package/dist/db/tenant-db.d.ts +2 -0
  74. package/dist/db/tenant-db.js +20 -1
  75. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  76. package/dist/derivatives/delete-stored-file.js +9 -0
  77. package/dist/derivatives/index.d.ts +1 -1
  78. package/dist/derivatives/index.js +1 -1
  79. package/dist/engine/boot-validator/entity-handler.js +3 -0
  80. package/dist/engine/boot-validator/query-refs.js +14 -2
  81. package/dist/engine/dedupe-features.js +31 -4
  82. package/dist/engine/entity-handlers.js +14 -7
  83. package/dist/engine/index.d.ts +2 -2
  84. package/dist/engine/index.js +1 -1
  85. package/dist/engine/registry-validate.d.ts +1 -0
  86. package/dist/engine/registry-validate.js +21 -0
  87. package/dist/engine/registry.js +2 -1
  88. package/dist/engine/screen-helpers.d.ts +1 -0
  89. package/dist/engine/screen-helpers.js +2 -2
  90. package/dist/engine/steps/_read-source.js +5 -2
  91. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  92. package/dist/engine/tier-resolver-extension.js +6 -0
  93. package/dist/engine/types/index.d.ts +1 -1
  94. package/dist/entrypoint/index.d.ts +2 -2
  95. package/dist/errors/docs-url.js +6 -6
  96. package/dist/errors/index.d.ts +1 -1
  97. package/dist/errors/index.js +1 -1
  98. package/dist/errors/subject-erased.d.ts +1 -0
  99. package/dist/errors/subject-erased.js +6 -0
  100. package/dist/event-store/admin-api.js +3 -2
  101. package/dist/event-store/errors.d.ts +9 -0
  102. package/dist/event-store/errors.js +20 -0
  103. package/dist/event-store/event-store.d.ts +6 -0
  104. package/dist/event-store/event-store.js +28 -6
  105. package/dist/event-store/index.d.ts +1 -1
  106. package/dist/event-store/index.js +1 -1
  107. package/dist/files/create-file-ref.d.ts +2 -1
  108. package/dist/files/create-file-ref.js +16 -2
  109. package/dist/files/dispose-removed-file-refs.js +21 -6
  110. package/dist/files/feature.d.ts +1 -0
  111. package/dist/files/feature.js +2 -1
  112. package/dist/files/file-handle.js +4 -0
  113. package/dist/files/file-routes.js +9 -5
  114. package/dist/files/index.d.ts +1 -1
  115. package/dist/files/index.js +1 -1
  116. package/dist/files/purge-expired-file-ref-bytes.js +2 -3
  117. package/dist/files/resolve-content-type.js +40 -4
  118. package/dist/files/storage-tracking.js +5 -4
  119. package/dist/files/types.js +5 -8
  120. package/dist/files/zip-entry-names.d.ts +1 -0
  121. package/dist/files/zip-entry-names.js +46 -0
  122. package/dist/jobs/job-runner.d.ts +2 -0
  123. package/dist/jobs/job-runner.js +183 -48
  124. package/dist/jobs/max-per-tenant.d.ts +8 -0
  125. package/dist/jobs/max-per-tenant.js +14 -0
  126. package/dist/observability/escape-hatch-report.d.ts +1 -0
  127. package/dist/observability/escape-hatch-report.js +31 -2
  128. package/dist/observability/index.d.ts +1 -1
  129. package/dist/observability/index.js +1 -1
  130. package/dist/observability/metrics-handle.d.ts +1 -0
  131. package/dist/observability/metrics-handle.js +7 -0
  132. package/dist/observability/standard-metrics.js +4 -4
  133. package/dist/pipeline/dispatch-query.d.ts +2 -1
  134. package/dist/pipeline/dispatch-query.js +6 -6
  135. package/dist/pipeline/dispatch-shared.d.ts +1 -1
  136. package/dist/pipeline/dispatch-shared.js +41 -30
  137. package/dist/pipeline/dispatch-stream.js +10 -4
  138. package/dist/pipeline/dispatcher.js +3 -1
  139. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  140. package/dist/pipeline/event-dispatcher.js +11 -9
  141. package/dist/pipeline/event-retention.d.ts +1 -0
  142. package/dist/pipeline/event-retention.js +7 -1
  143. package/dist/pipeline/index.d.ts +2 -0
  144. package/dist/pipeline/index.js +1 -0
  145. package/dist/pipeline/multi-stream-apply-context.d.ts +1 -0
  146. package/dist/pipeline/multi-stream-apply-context.js +1 -0
  147. package/dist/pipeline/notify-scope.js +3 -1
  148. package/dist/pipeline/pre-transaction-reservations.js +2 -1
  149. package/dist/schema-cli.js +12 -1
  150. package/dist/stack/db.d.ts +2 -2
  151. package/dist/stack/test-stack.js +2 -1
  152. package/dist/testing/index.d.ts +1 -0
  153. package/dist/testing/index.js +1 -0
  154. package/dist/testing/multipart-helper.d.ts +6 -0
  155. package/dist/testing/multipart-helper.js +1 -1
  156. package/dist/testing/zip-fixture.d.ts +1 -0
  157. package/dist/testing/zip-fixture.js +33 -0
  158. package/dist/ui-types/card-column-roles.js +4 -2
  159. package/dist/ui-types/index.d.ts +1 -1
  160. package/dist/ui-types/index.js +1 -1
  161. package/dist/upgrade-cli.js +46 -13
  162. package/package.json +4 -4
  163. package/src/changes.json +323 -0
@@ -1,6 +1,6 @@
1
1
  import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
2
2
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
3
- import { Queue, UnrecoverableError, Worker } from "bullmq";
3
+ import { DelayedError, Queue, UnrecoverableError, Worker } from "bullmq";
4
4
  import { Redis } from "ioredis";
5
5
  import { requestContext } from "../api/request-context.js";
6
6
  import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db.js";
@@ -12,13 +12,14 @@ import { InternalError } from "../errors/index.js";
12
12
  import { isKumikoError } from "../errors/kumiko-error.js";
13
13
  import { createFileContext } from "../files/file-handle.js";
14
14
  import { createFallbackLogger } from "../logging/index.js";
15
- import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
15
+ import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, createMetricsForFactory, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
16
16
  import { createDistributedLock } from "../pipeline/distributed-lock.js";
17
17
  import { RedisKeys } from "../pipeline/redis-keys.js";
18
18
  import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
19
19
  import { buildPersonalDataGate, isPersonalDataGated, parseWriteOrigin, } from "../pipeline/write-origin.js";
20
20
  import { bridgeStub } from "../testing/handler-context.js";
21
21
  import { cronRepeatOptions } from "./cron-repeat-options.js";
22
+ import { resolveMaxPerTenant } from "./max-per-tenant.js";
22
23
  // Shape-checked after the BullMQ/Redis JSON round-trip. Unlike `_writeOrigin`
23
24
  // (a security-relevant escape hatch that must fail the run closed), a
24
25
  // malformed `_triggerEvent` only costs an idempotency-key convenience — the
@@ -82,6 +83,18 @@ export const ORPHAN_SCHEDULER_GRACE_MS = 60 * 60 * 1000;
82
83
  function schedulerSeenKey(queue) {
83
84
  return queue.toKey("kumiko-scheduler-seen");
84
85
  }
86
+ // "Seen" has to mean a live runner holds the scheduler, so runners re-stamp
87
+ // their own ids periodically; a single stamp at start() would let a runner
88
+ // restarting during a mixed-version window longer than the grace period delete
89
+ // a scheduler a healthy newer pod still owns.
90
+ async function markSchedulersSeen(queue, schedulerIds) {
91
+ // skip: no cron scheduler on this lane, nothing to stamp
92
+ if (schedulerIds.size === 0)
93
+ return;
94
+ const stamp = String(Date.now());
95
+ const entries = Object.fromEntries([...schedulerIds].map((id) => [id, stamp]));
96
+ await (await queue.client).hset(schedulerSeenKey(queue), entries);
97
+ }
85
98
  // A cron job dropped from the app (or an older scheduler-id format) leaves its
86
99
  // scheduler in Redis, where it keeps firing jobs the worker no longer knows
87
100
  // ("Unknown job"). Best-effort: a Redis hiccup here must not stop the start.
@@ -236,6 +249,9 @@ function tenantFailureMessageKey(err, declaration) {
236
249
  // Leading underscore matches the existing internal-meta convention
237
250
  // (_triggeredById, _tenantId, _payload).
238
251
  const TRACE_CONTEXT_KEY = "_traceContext";
252
+ const DEFER_COUNT_KEY = "_deferCount";
253
+ const DEFER_DELAY_CAP_MS = 30_000;
254
+ const DEFER_BACKOFF_MAX_DOUBLINGS = 5;
239
255
  function readTraceContext(data) {
240
256
  const raw = data[TRACE_CONTEXT_KEY];
241
257
  if (!raw || typeof raw !== "object")
@@ -364,6 +380,7 @@ export function createJobRunner(options) {
364
380
  const { registry, context, redisUrl, consumerLane } = options;
365
381
  const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
366
382
  const bootRedisTimeoutMs = options.bootRedisTimeoutMs ?? BOOT_REDIS_TIMEOUT_MS;
383
+ const schedulerSeenRefreshMs = options.schedulerSeenRefreshMs ?? ORPHAN_SCHEDULER_GRACE_MS / 4;
367
384
  const redisOpts = parseRedisOpts(redisUrl);
368
385
  // Use the context's tracer when present (observability-provider injected at
369
386
  // boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
@@ -524,6 +541,11 @@ export function createJobRunner(options) {
524
541
  }
525
542
  let worker = null;
526
543
  let queueDepthTimer = null;
544
+ let schedulerSeenTimer = null;
545
+ let schedulerStampPending = false;
546
+ // A timed-out gate is abandoned, not aborted (the job context carries no
547
+ // AbortSignal); stop() waits for it so it cannot write after the shutdown.
548
+ const abandonedBootGates = new Set();
527
549
  // Forward reference to the runner's own API, exposed on the job-handler ctx
528
550
  // so a handler can dispatch a follow-up job (job→job chaining, e.g.
529
551
  // delivery.render → delivery.send). Assigned just before return; reads happen
@@ -537,18 +559,23 @@ export function createJobRunner(options) {
537
559
  // lane (jobDef.runIn is static), but walking both is cheap and avoids
538
560
  // a subtle bug if someone ever reassigns a job to a different lane
539
561
  // between deploys while old queue contents are still draining.
540
- async function isOverPerTenantLimit(jobName, tenantId, max) {
562
+ // `activeOnly` + `self` serve the defer check, which runs inside the job being
563
+ // counted: it must not see waiting jobs or itself, and only counts jobs that
564
+ // became active before it. Without that order, workers that fetch a burst of
565
+ // jobs together all see each other as active and all defer, again and again.
566
+ async function isOverPerTenantLimit(jobName, tenantId, max, scope = {}) {
541
567
  const results = await Promise.all([
542
568
  queues.api.getActive(),
543
- queues.api.getWaiting(),
544
569
  queues.worker.getActive(),
545
- queues.worker.getWaiting(),
570
+ ...(scope.activeOnly ? [] : [queues.api.getWaiting(), queues.worker.getWaiting()]),
546
571
  ]);
547
572
  let count = 0;
548
573
  for (const list of results) {
549
574
  for (const j of list) {
550
575
  if (j.name !== jobName)
551
576
  continue;
577
+ if (scope.self && !isActiveBefore(j, scope.self))
578
+ continue;
552
579
  const t = j.data?._tenantId; // @cast-boundary dynamic-key
553
580
  if (t === tenantId) {
554
581
  count += 1;
@@ -559,6 +586,22 @@ export function createJobRunner(options) {
559
586
  }
560
587
  return false;
561
588
  }
589
+ function isActiveBefore(other, self) {
590
+ if (other.id === self.id)
591
+ return false;
592
+ if (self.processedOn === undefined)
593
+ return true;
594
+ const otherProcessedOn = other.processedOn ?? 0;
595
+ if (otherProcessedOn !== self.processedOn)
596
+ return otherProcessedOn < self.processedOn;
597
+ return (other.id ?? "") < (self.id ?? "");
598
+ }
599
+ // Growing delay bounds Redis and worker load while one tenant has a large
600
+ // backlog: every deferred job would otherwise be re-checked every deferMs.
601
+ function deferDelayMs(deferMs, deferCount) {
602
+ const grown = Math.min(deferMs * 2 ** Math.min(deferCount, DEFER_BACKOFF_MAX_DOUBLINGS), DEFER_DELAY_CAP_MS);
603
+ return grown + Math.floor(Math.random() * deferMs);
604
+ }
562
605
  // Only err.message, never payload or the logs array: job payloads can carry PII.
563
606
  function logFailedAttempt(entry) {
564
607
  errorLogger.error("job failed", {
@@ -629,7 +672,7 @@ export function createJobRunner(options) {
629
672
  });
630
673
  }
631
674
  }
632
- async function handleJob(bullJob) {
675
+ async function handleJob(bullJob, token) {
633
676
  const rawName = bullJob.name;
634
677
  // Handle perTenant dispatch jobs — fan out to one job per tenant. The
635
678
  // fan-out re-enqueues into the lane the actual job is assigned to;
@@ -711,6 +754,25 @@ export function createJobRunner(options) {
711
754
  await reportPreRunFailure(jobName, bullJob, err);
712
755
  throw err;
713
756
  }
757
+ // maxPerTenant defer: checked before anything is recorded for this run, so a
758
+ // deferred pickup leaves no span, run-log entry or failure trace and does not
759
+ // consume an attempt (moveToDelayed + DelayedError, not a retry).
760
+ const maxPerTenant = resolveMaxPerTenant(jobDef);
761
+ const deferTenantId = bullJob.data["_tenantId"];
762
+ if (maxPerTenant?.onLimit === "defer" &&
763
+ typeof deferTenantId === "string" &&
764
+ token !== undefined &&
765
+ bullJob.moveToDelayed &&
766
+ (await isOverPerTenantLimit(jobName, deferTenantId, maxPerTenant.limit, {
767
+ activeOnly: true,
768
+ self: { id: bullJob.id, processedOn: bullJob.processedOn },
769
+ }))) {
770
+ const priorDeferCount = bullJob.data[DEFER_COUNT_KEY];
771
+ const deferCount = typeof priorDeferCount === "number" ? priorDeferCount : 0;
772
+ await bullJob.updateData?.({ ...bullJob.data, [DEFER_COUNT_KEY]: deferCount + 1 });
773
+ await bullJob.moveToDelayed(Date.now() + deferDelayMs(maxPerTenant.deferMs, deferCount), token);
774
+ throw new DelayedError();
775
+ }
714
776
  // Sequential gate: try to claim the per-name lock. If another worker
715
777
  // (or this worker on a different bullJob) holds it, re-enqueue with a
716
778
  // small delay and exit *successfully* — using throw would burn the
@@ -943,6 +1005,7 @@ export function createJobRunner(options) {
943
1005
  attempt,
944
1006
  finalAttempt,
945
1007
  log: createJobLogger(logs),
1008
+ metricsFor: createMetricsForFactory(context.meter),
946
1009
  ...(triggerName !== undefined && { triggerName }),
947
1010
  ...(triggerEvent !== undefined && { triggerEvent }),
948
1011
  write: (qn, payload) => {
@@ -1078,17 +1141,18 @@ export function createJobRunner(options) {
1078
1141
  });
1079
1142
  }, BOOT_GATE_SLOW_WARN_MS)
1080
1143
  : undefined;
1144
+ const gateRun = handleJob({
1145
+ id: bootJobIdForJobName(name),
1146
+ name,
1147
+ data: {},
1148
+ attemptsMade: 0,
1149
+ retryable: false,
1150
+ });
1151
+ const gateSettled = gateRun.then(() => undefined, () => undefined);
1152
+ abandonedBootGates.add(gateSettled);
1153
+ void gateSettled.then(() => abandonedBootGates.delete(gateSettled));
1081
1154
  try {
1082
- await Promise.race([
1083
- handleJob({
1084
- id: bootJobIdForJobName(name),
1085
- name,
1086
- data: {},
1087
- attemptsMade: 0,
1088
- retryable: false,
1089
- }),
1090
- ...(gateTimeout ? [gateTimeout.promise] : []),
1091
- ]);
1155
+ await Promise.race([gateRun, ...(gateTimeout ? [gateTimeout.promise] : [])]);
1092
1156
  }
1093
1157
  catch (err) {
1094
1158
  // Leaving the worker's Redis connections open would keep the event loop alive and turn the aborted boot into
@@ -1104,6 +1168,74 @@ export function createJobRunner(options) {
1104
1168
  }
1105
1169
  }
1106
1170
  }
1171
+ function cronSchedulerIdsForLane(lane) {
1172
+ const ids = new Set();
1173
+ for (const [name, jobDef] of allJobs) {
1174
+ if (laneForJob(jobDef) === lane && "cron" in jobDef.trigger) {
1175
+ ids.add(schedulerIdForJobName(name));
1176
+ }
1177
+ }
1178
+ return ids;
1179
+ }
1180
+ async function upsertCronSchedulers(queue, lane) {
1181
+ for (const [name, jobDef] of allJobs) {
1182
+ if (laneForJob(jobDef) !== lane)
1183
+ continue;
1184
+ if ("cron" in jobDef.trigger) {
1185
+ await queue.upsertJobScheduler(schedulerIdForJobName(name), cronRepeatOptions(jobDef.trigger), {
1186
+ name: jobDef.perTenant ? `_perTenant:${name}` : name,
1187
+ data: {},
1188
+ // Queue-level defaultJobOptions (jobRetentionOpts) covers
1189
+ // retention; a count here would sweep queue-wide again and
1190
+ // evict perTenant children and boot jobs.
1191
+ opts: {
1192
+ ...buildRetryBullOpts(jobDef),
1193
+ },
1194
+ });
1195
+ }
1196
+ }
1197
+ }
1198
+ // After a failed initial stamp an older runner may have removed our
1199
+ // schedulers as unseen, so the next tick stamps and re-upserts before the
1200
+ // flag clears.
1201
+ async function refreshSchedulerSeen(queue, lane, schedulerIds) {
1202
+ await markSchedulersSeen(queue, schedulerIds);
1203
+ if (schedulerStampPending) {
1204
+ await upsertCronSchedulers(queue, lane);
1205
+ schedulerStampPending = false;
1206
+ }
1207
+ }
1208
+ function startSchedulerSeenRefresh(queue, lane, schedulerIds) {
1209
+ if (schedulerIds.size === 0)
1210
+ return null;
1211
+ const timer = setInterval(() => {
1212
+ refreshSchedulerSeen(queue, lane, schedulerIds).catch((err) => {
1213
+ errorLogger.warn("scheduler seen refresh failed", {
1214
+ error: err instanceof Error ? err.message : String(err),
1215
+ });
1216
+ });
1217
+ }, schedulerSeenRefreshMs);
1218
+ timer.unref();
1219
+ return timer;
1220
+ }
1221
+ // Bounded: a gate that never returns must not block shutdown as well.
1222
+ async function settleAbandonedBootGates(maxWaitMs) {
1223
+ // skip: no timed-out gate left behind
1224
+ if (abandonedBootGates.size === 0)
1225
+ return;
1226
+ const wait = timeoutReject(maxWaitMs, "job-runner: abandoned boot gate still running at stop()");
1227
+ try {
1228
+ await Promise.race([Promise.all(abandonedBootGates), wait.promise]);
1229
+ }
1230
+ catch (err) {
1231
+ errorLogger.warn("abandoned boot gate did not finish before stop()", {
1232
+ error: err instanceof Error ? err.message : String(err),
1233
+ });
1234
+ }
1235
+ finally {
1236
+ wait.cancel();
1237
+ }
1238
+ }
1107
1239
  const runnerApi = {
1108
1240
  async start() {
1109
1241
  // skip: enqueuer-only runner — no BullMQ worker, no cron schedules,
@@ -1114,7 +1246,7 @@ export function createJobRunner(options) {
1114
1246
  return;
1115
1247
  }
1116
1248
  const consumerQueue = queues[consumerLane];
1117
- worker = new Worker(queueNameFor(queueNamePrefix, consumerLane), handleJob, {
1249
+ worker = new Worker(queueNameFor(queueNamePrefix, consumerLane), (job, token) => handleJob(job, token), {
1118
1250
  connection: redisOpts,
1119
1251
  concurrency: 5,
1120
1252
  // Not consuming until the boot gates passed: a job already queued from a
@@ -1152,28 +1284,18 @@ export function createJobRunner(options) {
1152
1284
  // Only schedule cron + boot for jobs that belong to this lane. Jobs
1153
1285
  // assigned to the other lane get their cron/boot wiring from the
1154
1286
  // runner running on that lane. Running both here would double-fire.
1155
- const expectedSchedulerIds = new Set();
1156
- for (const [name, jobDef] of allJobs) {
1157
- if (laneForJob(jobDef) !== consumerLane)
1158
- continue;
1159
- if ("cron" in jobDef.trigger) {
1160
- const schedulerId = schedulerIdForJobName(name);
1161
- expectedSchedulerIds.add(schedulerId);
1162
- await consumerQueue.upsertJobScheduler(schedulerId, cronRepeatOptions(jobDef.trigger), {
1163
- name: jobDef.perTenant ? `_perTenant:${name}` : name,
1164
- data: {},
1165
- // Queue-level defaultJobOptions (jobRetentionOpts) covers
1166
- // retention; a count here would sweep queue-wide again and
1167
- // evict perTenant children and boot jobs.
1168
- opts: {
1169
- ...buildRetryBullOpts(jobDef),
1170
- },
1171
- });
1172
- await (await consumerQueue.client).hset(schedulerSeenKey(consumerQueue), {
1173
- [schedulerId]: String(Date.now()),
1174
- });
1175
- }
1176
- }
1287
+ const expectedSchedulerIds = cronSchedulerIdsForLane(consumerLane);
1288
+ // Before the upserts: a seen entry without a scheduler is harmless, a
1289
+ // scheduler a concurrent older runner finds without one gets deleted.
1290
+ // Best-effort like the periodic refresh: a failed stamp must not stop the start.
1291
+ await markSchedulersSeen(consumerQueue, expectedSchedulerIds).catch((err) => {
1292
+ schedulerStampPending = true;
1293
+ errorLogger.warn("scheduler seen stamp failed", {
1294
+ error: err instanceof Error ? err.message : String(err),
1295
+ });
1296
+ });
1297
+ await upsertCronSchedulers(consumerQueue, consumerLane);
1298
+ schedulerSeenTimer = startSchedulerSeenRefresh(consumerQueue, consumerLane, expectedSchedulerIds);
1177
1299
  await removeOrphanJobSchedulers(consumerQueue, expectedSchedulerIds, consumerLane, {
1178
1300
  info: (msg, data) => context.log ? context.log.info(msg, data) : errorLogger.debug(msg, data),
1179
1301
  warn: errorLogger.warn,
@@ -1221,6 +1343,10 @@ export function createJobRunner(options) {
1221
1343
  clearInterval(queueDepthTimer);
1222
1344
  queueDepthTimer = null;
1223
1345
  }
1346
+ if (schedulerSeenTimer) {
1347
+ clearInterval(schedulerSeenTimer);
1348
+ schedulerSeenTimer = null;
1349
+ }
1224
1350
  if (worker) {
1225
1351
  await worker.close();
1226
1352
  worker = null;
@@ -1230,6 +1356,7 @@ export function createJobRunner(options) {
1230
1356
  // without listeners -> unhandled rejection (fw#1805). Wait for readiness
1231
1357
  // first; a failed/timed-out wait must not block the close itself.
1232
1358
  const stopReadyTimeoutMs = Math.min(bootRedisTimeoutMs, STOP_QUEUE_READY_TIMEOUT_MS);
1359
+ await settleAbandonedBootGates(stopReadyTimeoutMs);
1233
1360
  const readinessTimeout = timeoutReject(stopReadyTimeoutMs, `job-runner: Queue not ready within ${stopReadyTimeoutMs}ms during stop()`);
1234
1361
  try {
1235
1362
  await Promise.race([
@@ -1278,10 +1405,11 @@ export function createJobRunner(options) {
1278
1405
  // against; without it the guard is inactive (system jobs, ambient
1279
1406
  // dispatch). Fan-out children of perTenant jobs land here on their
1280
1407
  // recursive queue.add and DO carry _tenantId.
1281
- if (jobDef.maxPerTenant !== undefined) {
1408
+ const maxPerTenant = resolveMaxPerTenant(jobDef);
1409
+ if (maxPerTenant?.onLimit === "skip") {
1282
1410
  const tenantId = payload?._tenantId; // @cast-boundary dynamic-key
1283
1411
  if (tenantId !== undefined &&
1284
- (await isOverPerTenantLimit(jobName, tenantId, jobDef.maxPerTenant))) {
1412
+ (await isOverPerTenantLimit(jobName, tenantId, maxPerTenant.limit))) {
1285
1413
  return "skipped:max-per-tenant";
1286
1414
  }
1287
1415
  }
@@ -1323,6 +1451,8 @@ export function createJobRunner(options) {
1323
1451
  bullOpts["timeout"] = jobDef.timeout;
1324
1452
  if (meta?.priority !== undefined)
1325
1453
  bullOpts["priority"] = meta.priority;
1454
+ if (meta?.jobId !== undefined)
1455
+ bullOpts["jobId"] = meta.jobId;
1326
1456
  // Pack meta into job data with _ prefix
1327
1457
  const data = { ...payload };
1328
1458
  if (meta?.triggeredById !== undefined)
@@ -1384,8 +1514,9 @@ export function createJobRunner(options) {
1384
1514
  data["_correlationId"] = reqCtx.correlationId;
1385
1515
  // Same maxPerTenant guard as dispatch — events that fan into many
1386
1516
  // jobs must respect the per-tenant cap or the limit is one-sided.
1387
- if (jobDef.maxPerTenant !== undefined && user?.tenantId !== undefined) {
1388
- if (await isOverPerTenantLimit(name, String(user.tenantId), jobDef.maxPerTenant)) {
1517
+ const maxPerTenant = resolveMaxPerTenant(jobDef);
1518
+ if (maxPerTenant?.onLimit === "skip" && user?.tenantId !== undefined) {
1519
+ if (await isOverPerTenantLimit(name, String(user.tenantId), maxPerTenant.limit)) {
1389
1520
  continue;
1390
1521
  }
1391
1522
  }
@@ -1402,17 +1533,21 @@ export function createJobRunner(options) {
1402
1533
  async countPendingJobs() {
1403
1534
  let total = 0;
1404
1535
  for (const queue of Object.values(queues)) {
1405
- const counts = await queue.getJobCounts("waiting", "active", "prioritized", "waiting-children");
1536
+ // One getJobCounts call is a single atomic snapshot. Reading delayed in a
1537
+ // second call misses a deferred job that cycles delayed -> active between
1538
+ // the reads, so delayed is counted here and only repeat schedulers (which
1539
+ // stay delayed between cron fires) are subtracted afterwards.
1540
+ const counts = await queue.getJobCounts("waiting", "active", "prioritized", "waiting-children", "delayed");
1406
1541
  total +=
1407
1542
  (counts["waiting"] ?? 0) +
1408
1543
  (counts["active"] ?? 0) +
1409
1544
  (counts["prioritized"] ?? 0) +
1410
- (counts["waiting-children"] ?? 0);
1411
- // getJobCounts has no id-prefix filter for "delayed" — getJobs(["delayed"])
1412
- // loads the full Job objects (payload included) just to read .id; fine for
1545
+ (counts["waiting-children"] ?? 0) +
1546
+ (counts["delayed"] ?? 0);
1547
+ // getJobs(["delayed"]) loads full Job objects just to read .id; fine for
1413
1548
  // test-draining's low volume, not a pattern for a prod-path hot loop.
1414
1549
  const delayed = await queue.getJobs(["delayed"]);
1415
- total += delayed.filter((job) => !(job.id ?? "").startsWith("repeat:")).length;
1550
+ total -= delayed.filter((job) => (job.id ?? "").startsWith("repeat:")).length;
1416
1551
  }
1417
1552
  return total;
1418
1553
  },
@@ -0,0 +1,8 @@
1
+ import type { JobDefinition } from "../engine/types/index.js";
2
+ export declare const DEFAULT_MAX_PER_TENANT_DEFER_MS = 1000;
3
+ export type ResolvedMaxPerTenant = {
4
+ readonly limit: number;
5
+ readonly onLimit: "skip" | "defer";
6
+ readonly deferMs: number;
7
+ };
8
+ export declare function resolveMaxPerTenant(jobDef: Pick<JobDefinition, "maxPerTenant">): ResolvedMaxPerTenant | undefined;
@@ -0,0 +1,14 @@
1
+ export const DEFAULT_MAX_PER_TENANT_DEFER_MS = 1_000;
2
+ export function resolveMaxPerTenant(jobDef) {
3
+ const raw = jobDef.maxPerTenant;
4
+ if (raw === undefined)
5
+ return undefined;
6
+ if (typeof raw === "number") {
7
+ return { limit: raw, onLimit: "skip", deferMs: DEFAULT_MAX_PER_TENANT_DEFER_MS };
8
+ }
9
+ return {
10
+ limit: raw.limit,
11
+ onLimit: raw.onLimit,
12
+ deferMs: raw.deferMs ?? DEFAULT_MAX_PER_TENANT_DEFER_MS,
13
+ };
14
+ }
@@ -15,6 +15,7 @@ export type EscapeHatchProcessDedup = {
15
15
  };
16
16
  export declare function createEscapeHatchProcessDedup(kinds: readonly EscapeHatchKind[]): EscapeHatchProcessDedup;
17
17
  export declare function withEscapeHatchAuditScope<T>(run: () => Promise<T>): Promise<T>;
18
+ export declare function withEscapeHatchAuditScopeStream<T>(run: () => AsyncGenerator<T>): AsyncGenerator<T>;
18
19
  export declare function flushEscapeHatchAudits(): Promise<void>;
19
20
  export declare function reportEscapeHatchUse(event: EscapeHatchUseEvent, deps: {
20
21
  readonly sink?: EscapeHatchAuditSink;
@@ -40,9 +40,14 @@ const processPendingAudits = new Set();
40
40
  // The sink writes of the dispatch or job run currently executing.
41
41
  const scopePendingAudits = new AsyncLocalStorage();
42
42
  function trackPendingAudit(write) {
43
+ const scope = scopePendingAudits.getStore();
43
44
  processPendingAudits.add(write);
44
- scopePendingAudits.getStore()?.add(write);
45
- void write.finally(() => processPendingAudits.delete(write));
45
+ scope?.add(write);
46
+ // Settled writes leave the scope so a long-lived stream does not accumulate them.
47
+ void write.finally(() => {
48
+ processPendingAudits.delete(write);
49
+ scope?.delete(write);
50
+ });
46
51
  }
47
52
  // Awaits the audit writes started inside `run` once it has returned, so callers place it around
48
53
  // the transaction and the write is durable when the dispatch or run ends. Nested scopes belong
@@ -59,6 +64,30 @@ export async function withEscapeHatchAuditScope(run) {
59
64
  await Promise.allSettled([...pending]);
60
65
  }
61
66
  }
67
+ // Stream variant: the scope has to be entered on every pull because an async generator body
68
+ // runs in the caller's async context, and the wait happens once the stream ends or is closed.
69
+ export async function* withEscapeHatchAuditScopeStream(run) {
70
+ if (scopePendingAudits.getStore() !== undefined) {
71
+ yield* run();
72
+ // skip: an enclosing scope already waits for the pending audits
73
+ return;
74
+ }
75
+ const pending = new Set();
76
+ const source = run();
77
+ try {
78
+ while (true) {
79
+ const next = await scopePendingAudits.run(pending, () => source.next());
80
+ // skip: the source is exhausted
81
+ if (next.done)
82
+ return;
83
+ yield next.value;
84
+ }
85
+ }
86
+ finally {
87
+ await scopePendingAudits.run(pending, () => source.return(undefined));
88
+ await Promise.allSettled([...pending]);
89
+ }
90
+ }
62
91
  export async function flushEscapeHatchAudits() {
63
92
  while (processPendingAudits.size > 0) {
64
93
  await Promise.allSettled([...processPendingAudits]);
@@ -4,7 +4,7 @@ export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeH
4
4
  export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
5
5
  export { generateSpanId, generateTraceId } from "./ids.js";
6
6
  export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
7
- export { createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, createUnboundMetricsHandle, } from "./metrics-handle.js";
7
+ export { createMetricsForFactory, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, createUnboundMetricsHandle, } from "./metrics-handle.js";
8
8
  export { type ObservabilityWiring, prometheusMetricsEnvSchema, resolveObservabilityWiring, } from "./metrics-wiring.js";
9
9
  export { createNoopProvider } from "./noop-provider.js";
10
10
  export { createPrometheusMeter, type PrometheusMeter, type PrometheusMeterSnapshot, serializeOpenMetrics, } from "./prometheus-meter.js";
@@ -5,7 +5,7 @@ export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeH
5
5
  export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
6
6
  export { generateSpanId, generateTraceId } from "./ids.js";
7
7
  export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
8
- export { createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, createUnboundMetricsHandle, } from "./metrics-handle.js";
8
+ export { createMetricsForFactory, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, createUnboundMetricsHandle, } from "./metrics-handle.js";
9
9
  export { prometheusMetricsEnvSchema, resolveObservabilityWiring, } from "./metrics-wiring.js";
10
10
  export { createNoopProvider } from "./noop-provider.js";
11
11
  export { createPrometheusMeter, serializeOpenMetrics, } from "./prometheus-meter.js";
@@ -3,3 +3,4 @@ export declare function createMetricsHandle(meter: Meter, featureName: string):
3
3
  export declare function createSafeMetricsHandle(meter: Meter, featureName: string): MetricsHandle;
4
4
  export declare function createUnboundMetricsHandle(meter: Meter): MetricsHandle;
5
5
  export declare function createNoopMetricsHandle(): MetricsHandle;
6
+ export declare function createMetricsForFactory(meter: Meter | undefined): (featureName: string) => MetricsHandle;
@@ -100,3 +100,10 @@ export function createNoopMetricsHandle() {
100
100
  set(_name, _value, _labels) { },
101
101
  };
102
102
  }
103
+ // Shared factory behind ctx.metricsFor for handler and job contexts, so both
104
+ // keep the same no-op-on-unregistered contract (framework#1844).
105
+ export function createMetricsForFactory(meter) {
106
+ return meter
107
+ ? (featureName) => createSafeMetricsHandle(meter, featureName)
108
+ : () => createNoopMetricsHandle();
109
+ }
@@ -62,15 +62,15 @@ export const STANDARD_METRIC_DEFS = [
62
62
  // Prometheus collapses per-instance shards into last-writer-wins and
63
63
  // per-instance lag is invisible to alerting.
64
64
  {
65
- name: "kumiko_event_consumer_pending_gaps",
65
+ name: "kumiko_event_consumer_lag_events",
66
66
  type: "gauge",
67
- description: "Number of unresolved id ranges below the consumer's cursor (ids not yet visible or proven rolled back).",
67
+ description: "Number of events between the consumer's cursor and the events head.",
68
68
  labels: ["consumer", "instance_id"],
69
69
  },
70
70
  {
71
- name: "kumiko_event_consumer_lag_events",
71
+ name: "kumiko_event_consumer_pending_gaps",
72
72
  type: "gauge",
73
- description: "Number of events between the consumer's cursor and the events head.",
73
+ description: "Number of unresolved id ranges below the consumer's cursor (ids not yet visible or proven rolled back).",
74
74
  labels: ["consumer", "instance_id"],
75
75
  },
76
76
  {
@@ -1,4 +1,5 @@
1
1
  import type { DbTx } from "../db/connection.js";
2
2
  import type { SessionUser } from "../engine/types/index.js";
3
3
  import { type DispatchContext, type WriteOrigin } from "./dispatch-shared.js";
4
- export declare function executeQuery(ctx: DispatchContext, type: string, payload: unknown, user: SessionUser, origin: WriteOrigin, tx?: DbTx): Promise<unknown>;
4
+ import type { AfterCommitHook } from "./dispatcher-utils.js";
5
+ export declare function executeQuery(ctx: DispatchContext, type: string, payload: unknown, user: SessionUser, origin: WriteOrigin, tx?: DbTx, outerNotifySink?: AfterCommitHook[]): Promise<unknown>;
@@ -10,10 +10,10 @@ import { runInMemberReadOnlyTransaction } from "./member-read-only-transaction.j
10
10
  // Standalone query execution — used by the public dispatcher.query() and
11
11
  // by ctx.query/ctx.queryAs inside handlers. Runs the handler, applies
12
12
  // field-level read filters for the given user, logs the event.
13
- export async function executeQuery(ctx, type, payload, user, origin, tx) {
14
- return runHandlerInstrumented(ctx, type, "query", user, () => executeQueryInner(ctx, type, payload, user, origin, tx));
13
+ export async function executeQuery(ctx, type, payload, user, origin, tx, outerNotifySink) {
14
+ return runHandlerInstrumented(ctx, type, "query", user, () => executeQueryInner(ctx, type, payload, user, origin, tx, outerNotifySink));
15
15
  }
16
- async function executeQueryInner(ctx, type, payload, user, origin, tx) {
16
+ async function executeQueryInner(ctx, type, payload, user, origin, tx, outerNotifySink) {
17
17
  const { registry } = ctx;
18
18
  const handler = registry.getQueryHandler(type);
19
19
  if (!handler)
@@ -54,11 +54,11 @@ async function executeQueryInner(ctx, type, payload, user, origin, tx) {
54
54
  // A resolved member (ctx.queryAsMember) runs in a Postgres READ ONLY transaction, not just the ctx surface below.
55
55
  return isMemberResolutionPrincipal(user)
56
56
  ? runInMemberReadOnlyTransaction(ctx, tx, (readOnlyTx) => runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, readOnlyTx))
57
- : runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, tx);
57
+ : runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, tx, outerNotifySink);
58
58
  }
59
- async function runQueryHandler(ctx, type, handler, payload, includeDeleted, user, origin, tx) {
59
+ async function runQueryHandler(ctx, type, handler, payload, includeDeleted, user, origin, tx, outerNotifySink) {
60
60
  const { registry } = ctx;
61
- const handlerContext = await buildHandlerContext(ctx, type, user, origin, tx, undefined, includeDeleted);
61
+ const handlerContext = await buildHandlerContext(ctx, type, user, origin, tx, undefined, includeDeleted, undefined, outerNotifySink);
62
62
  let result = await handler.handler({ type, payload, user }, handlerContext);
63
63
  // postQuery-Hooks: fire BEFORE field-access-filter so hooks see raw data
64
64
  // and can merge custom-fields/computed-counts/tags/etc. Each hook is
@@ -57,7 +57,7 @@ export declare function perCommandNestedDenied(targetType: string): InternalErro
57
57
  export declare function denyTransactionBoundSurfaces(handlerContext: HandlerContext): HandlerContext;
58
58
  export declare function applyBeforeTransactionReadOnly(handlerContext: HandlerContext): HandlerContext;
59
59
  export type HandlerTransactionScope = "inTransaction" | "withoutTransaction" | "postCommit";
60
- export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean, transactionScope?: HandlerTransactionScope): Promise<HandlerContext>;
60
+ export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean, transactionScope?: HandlerTransactionScope, outerNotifySink?: AfterCommitHook[], readOnlyForTenant?: boolean): Promise<HandlerContext>;
61
61
  export declare function createHandlerBoundDigest(digest: (value: string) => string, handlerName: string): (value: string) => string;
62
62
  export declare function runHandlerInstrumented<T>(ctx: DispatchContext, type: string, operation: "query" | "write" | "stream", user: SessionUser, inner: () => Promise<T>): Promise<T>;
63
63
  export declare function runStreamInstrumented<T>(ctx: DispatchContext, type: string, user: SessionUser, inner: () => AsyncGenerator<T>): AsyncGenerator<T>;