@substrat-run/kernel 0.158.0 → 0.160.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.
Files changed (95) hide show
  1. package/dist/billing-retention.d.ts +46 -0
  2. package/dist/billing-retention.d.ts.map +1 -0
  3. package/dist/billing-retention.js +78 -0
  4. package/dist/billing-retention.js.map +1 -0
  5. package/dist/byte-stream.d.ts +102 -0
  6. package/dist/byte-stream.d.ts.map +1 -0
  7. package/dist/byte-stream.js +153 -0
  8. package/dist/byte-stream.js.map +1 -0
  9. package/dist/connection-landings.d.ts +15 -3
  10. package/dist/connection-landings.d.ts.map +1 -1
  11. package/dist/connection-landings.js +10 -2
  12. package/dist/connection-landings.js.map +1 -1
  13. package/dist/deadline-fetch.d.ts +34 -0
  14. package/dist/deadline-fetch.d.ts.map +1 -0
  15. package/dist/deadline-fetch.js +231 -0
  16. package/dist/deadline-fetch.js.map +1 -0
  17. package/dist/deployment-wire-auth.d.ts +82 -0
  18. package/dist/deployment-wire-auth.d.ts.map +1 -0
  19. package/dist/deployment-wire-auth.js +150 -0
  20. package/dist/deployment-wire-auth.js.map +1 -0
  21. package/dist/entity-trash.d.ts +65 -8
  22. package/dist/entity-trash.d.ts.map +1 -1
  23. package/dist/entity-trash.js +80 -12
  24. package/dist/entity-trash.js.map +1 -1
  25. package/dist/export-break.d.ts +6 -0
  26. package/dist/export-break.d.ts.map +1 -1
  27. package/dist/export-break.js +11 -0
  28. package/dist/export-break.js.map +1 -1
  29. package/dist/index.d.ts +18 -9
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +16 -7
  32. package/dist/index.js.map +1 -1
  33. package/dist/invocation-line.d.ts +3 -2
  34. package/dist/invocation-line.d.ts.map +1 -1
  35. package/dist/invocation-line.js +2 -0
  36. package/dist/invocation-line.js.map +1 -1
  37. package/dist/meter-days.d.ts +107 -0
  38. package/dist/meter-days.d.ts.map +1 -0
  39. package/dist/meter-days.js +230 -0
  40. package/dist/meter-days.js.map +1 -0
  41. package/dist/meters.d.ts +19 -1
  42. package/dist/meters.d.ts.map +1 -1
  43. package/dist/meters.js +0 -0
  44. package/dist/meters.js.map +1 -1
  45. package/dist/ops-failure-reference.d.ts +31 -0
  46. package/dist/ops-failure-reference.d.ts.map +1 -0
  47. package/dist/ops-failure-reference.js +32 -0
  48. package/dist/ops-failure-reference.js.map +1 -0
  49. package/dist/platform-request-cap.d.ts +9 -0
  50. package/dist/platform-request-cap.d.ts.map +1 -0
  51. package/dist/platform-request-cap.js +42 -0
  52. package/dist/platform-request-cap.js.map +1 -0
  53. package/dist/platform-sweep.d.ts +170 -7
  54. package/dist/platform-sweep.d.ts.map +1 -1
  55. package/dist/platform-sweep.js +375 -45
  56. package/dist/platform-sweep.js.map +1 -1
  57. package/dist/request-rollup.d.ts +161 -0
  58. package/dist/request-rollup.d.ts.map +1 -0
  59. package/dist/request-rollup.js +273 -0
  60. package/dist/request-rollup.js.map +1 -0
  61. package/dist/reserved-roles.d.ts +157 -4
  62. package/dist/reserved-roles.d.ts.map +1 -1
  63. package/dist/reserved-roles.js +216 -2
  64. package/dist/reserved-roles.js.map +1 -1
  65. package/dist/schedule-state.d.ts +5 -0
  66. package/dist/schedule-state.d.ts.map +1 -1
  67. package/dist/scope-host.d.ts +260 -15
  68. package/dist/scope-host.d.ts.map +1 -1
  69. package/dist/scope-host.js +53 -2
  70. package/dist/scope-host.js.map +1 -1
  71. package/dist/scope-lifecycle.d.ts +7 -0
  72. package/dist/scope-lifecycle.d.ts.map +1 -1
  73. package/dist/scope-lifecycle.js +7 -0
  74. package/dist/scope-lifecycle.js.map +1 -1
  75. package/dist/scope-record.d.ts +16 -5
  76. package/dist/scope-record.d.ts.map +1 -1
  77. package/dist/scope-record.js +72 -7
  78. package/dist/scope-record.js.map +1 -1
  79. package/dist/spine-restore.d.ts +7 -3
  80. package/dist/spine-restore.d.ts.map +1 -1
  81. package/dist/spine-restore.js +14 -3
  82. package/dist/spine-restore.js.map +1 -1
  83. package/dist/storage-gauge.d.ts +47 -13
  84. package/dist/storage-gauge.d.ts.map +1 -1
  85. package/dist/storage-gauge.js +84 -27
  86. package/dist/storage-gauge.js.map +1 -1
  87. package/dist/subject-redaction.d.ts +9 -3
  88. package/dist/subject-redaction.d.ts.map +1 -1
  89. package/dist/subject-redaction.js +24 -12
  90. package/dist/subject-redaction.js.map +1 -1
  91. package/dist/vertical-owners.d.ts +69 -0
  92. package/dist/vertical-owners.d.ts.map +1 -0
  93. package/dist/vertical-owners.js +108 -0
  94. package/dist/vertical-owners.js.map +1 -0
  95. package/package.json +2 -2
@@ -1,6 +1,9 @@
1
1
  import { drainedEvent, instant, substratError } from '@substrat-run/contracts';
2
2
  import { STORAGE_GAUGE_PRUNE_BATCH, STORED_SCOPE_STATUSES, holdsStore } from './storage-gauge.js';
3
- import { assertRowLimit, backoffAt, TELEMETRY_PRUNE_BATCH } from './scope-host.js';
3
+ import { BILLING_ROLLUP_RETENTION_MONTHS, billingRollupHorizon, utcDayOf } from './billing-retention.js';
4
+ import { METER_DAYS_PRUNE_BATCH, METER_SNAPSHOT_TIMEOUT_MS } from './meter-days.js';
5
+ import { REQUEST_ROLLUP_DAYS_PER_PASS, REQUEST_ROLLUP_LOOKBACK_DAYS, REQUEST_ROLLUP_MAX_LOOKBACK_DAYS, REQUEST_ROLLUP_PRUNE_BATCH, REQUEST_ROLLUP_READ_TIMEOUT_MS, REQUEST_ROLLUP_RETRY_MS, REQUEST_ROLLUP_SETTLE_MS, isRequestRollupSaturated, requestRollupDueDays, } from './request-rollup.js';
6
+ import { assertRowLimit, backoffAt, fetchLikeOf, TELEMETRY_PRUNE_BATCH } from './scope-host.js';
4
7
  import { MIGRATION_FLAG_THRESHOLD, migrationFleet, migrationProgress, scopeMigrationState } from './migration-progress.js';
5
8
  import { UNDRAINED_SKIPPED_IDS } from './outbox-event.js';
6
9
  import { resolveVerticalInstanceFrom } from './peer.js';
@@ -188,6 +191,36 @@ function assertSweepBatches(options) {
188
191
  if (gauge?.batch !== undefined && !(Number.isInteger(gauge.batch) && gauge.batch >= 0)) {
189
192
  throw substratError('validation_failed', `storageGauge.batch must be a non-negative integer, got ${String(gauge.batch)}`);
190
193
  }
194
+ const rollup = options.requestRollup;
195
+ if (rollup?.pruneBatch !== undefined)
196
+ assertRowLimit('requestRollup.pruneBatch', rollup.pruneBatch);
197
+ if (rollup?.batch !== undefined && !(Number.isInteger(rollup.batch) && rollup.batch >= 0)) {
198
+ throw substratError('validation_failed', `requestRollup.batch must be a non-negative integer, got ${String(rollup.batch)}`);
199
+ }
200
+ if (rollup?.retentionMonths !== undefined)
201
+ assertBillingRetention('requestRollup.retentionMonths', rollup.retentionMonths);
202
+ // Each of these corrupts the kept history silently rather than failing: a lookback past what
203
+ // Analytics Engine keeps records aged-out days as days without traffic, and a negative settle
204
+ // reads the open day and freezes it. So they are refused here, before anything is read.
205
+ const lookback = rollup?.lookbackDays;
206
+ if (lookback !== undefined && !(Number.isInteger(lookback) && lookback >= 1 && lookback <= REQUEST_ROLLUP_MAX_LOOKBACK_DAYS)) {
207
+ throw substratError('validation_failed', `requestRollup.lookbackDays must be a whole number of days from 1 to ${REQUEST_ROLLUP_MAX_LOOKBACK_DAYS}, got ${String(lookback)}`);
208
+ }
209
+ for (const name of ['settleMs', 'retryMs']) {
210
+ const ms = rollup?.[name];
211
+ if (ms !== undefined && !(Number.isFinite(ms) && ms >= 0)) {
212
+ throw substratError('validation_failed', `requestRollup.${name} must be a non-negative number of ms, got ${String(ms)}`);
213
+ }
214
+ }
215
+ if (rollup?.readTimeoutMs !== undefined)
216
+ assertPositiveMs('requestRollup.readTimeoutMs', rollup.readTimeoutMs);
217
+ const snapshot = options.meterSnapshot;
218
+ if (snapshot?.pruneBatch !== undefined)
219
+ assertRowLimit('meterSnapshot.pruneBatch', snapshot.pruneBatch);
220
+ if (snapshot?.retentionMonths !== undefined)
221
+ assertBillingRetention('meterSnapshot.retentionMonths', snapshot.retentionMonths);
222
+ if (snapshot?.timeoutMs !== undefined)
223
+ assertPositiveMs('meterSnapshot.timeoutMs', snapshot.timeoutMs);
191
224
  const orphans = options.orphanAttachments;
192
225
  if (orphans?.batch !== undefined && !(Number.isInteger(orphans.batch) && orphans.batch >= 0)) {
193
226
  throw substratError('validation_failed', `orphanAttachments.batch must be a non-negative integer, got ${String(orphans.batch)}`);
@@ -196,6 +229,69 @@ function assertSweepBatches(options) {
196
229
  throw substratError('validation_failed', `orphanAttachments.mode must be 'dry-run' or 'delete', got ${String(orphans.mode)}`);
197
230
  }
198
231
  }
232
+ /** A billing retention option, refused unless `billingRollupHorizon` takes it. */
233
+ function assertBillingRetention(name, months) {
234
+ try {
235
+ billingRollupHorizon(0, months);
236
+ }
237
+ catch (err) {
238
+ throw substratError('validation_failed', `${name}: ${message(err)}`);
239
+ }
240
+ }
241
+ /** A deadline option, refused unless it is a positive, finite number of ms. */
242
+ function assertPositiveMs(name, ms) {
243
+ if (!(Number.isFinite(ms) && ms > 0)) {
244
+ throw substratError('validation_failed', `${name} must be a positive number of ms, got ${String(ms)}`);
245
+ }
246
+ }
247
+ /**
248
+ * How many unreadable directory rows one pass names before it only says there are more: the
249
+ * sweep's `errors`, and the hostname reconcile's `unreadableScopes`, so each pass's report stays
250
+ * bounded however many rows a directory holds.
251
+ */
252
+ export const UNREADABLE_SCOPE_ROWS_NAMED = 20;
253
+ /**
254
+ * The scope read every fleet-wide phase of one pass shares. One row that does not parse used to
255
+ * throw out of `listScopes` for the whole list, and since no phase reads the directory inside a
256
+ * try, out of the pass: every phase after it went unrun until the row was fixed by hand. Read
257
+ * through `listScopesReadable`, the row is stepped over, and named in `errors` ONCE per pass
258
+ * however many phases met it, by its id and the fields that failed, never their values. At most
259
+ * `UNREADABLE_SCOPE_ROWS_NAMED` are named, then one line says there are more. A host that
260
+ * predates `listScopesReadable` is read through `listScopes`, strict as before.
261
+ */
262
+ function scopeReaderFor(admin, actor, report) {
263
+ const readable = admin.listScopesReadable?.bind(admin);
264
+ if (!readable)
265
+ return (filter) => admin.listScopes(actor, filter);
266
+ const named = new Set();
267
+ let capped = false;
268
+ return async (filter) => {
269
+ const { scopes, unreadable } = await readable(actor, filter);
270
+ for (const row of unreadable) {
271
+ if (capped)
272
+ break;
273
+ if (named.has(row.scopeId))
274
+ continue;
275
+ if (named.size < UNREADABLE_SCOPE_ROWS_NAMED) {
276
+ named.add(row.scopeId);
277
+ report.errors.push({
278
+ kind: 'scope-row',
279
+ id: row.scopeId,
280
+ error: `the directory row of tenant ${row.tenantId} does not read as a scope (${row.fields.join(', ')}); stepped over this pass`,
281
+ });
282
+ }
283
+ else {
284
+ capped = true;
285
+ report.errors.push({
286
+ kind: 'scope-row',
287
+ id: '*',
288
+ error: `more than ${UNREADABLE_SCOPE_ROWS_NAMED} directory rows do not read as a scope; only the first ${UNREADABLE_SCOPE_ROWS_NAMED} are named this pass`,
289
+ });
290
+ }
291
+ }
292
+ return scopes;
293
+ };
294
+ }
199
295
  export async function runPlatformSweep(host, options) {
200
296
  assertSweepBatches(options);
201
297
  const concurrency = options.concurrency ?? 8;
@@ -219,8 +315,13 @@ export async function runPlatformSweep(host, options) {
219
315
  findings: null,
220
316
  storage: null,
221
317
  orphanAttachments: null,
318
+ requestRollup: null,
319
+ meterSnapshot: null,
222
320
  errors: [],
223
321
  };
322
+ // Every fleet-wide read of the directory below goes through this, so one unreadable row is
323
+ // stepped over by each phase and named once. The tenant reap does not: see there.
324
+ const listScopes = scopeReaderFor(host.admin, options.actor, report);
224
325
  // -- migration reconciliation (kernel-design §5.3, #49) ---------------------
225
326
  // FIRST, deliberately: `drainDue` wakes scopes (and wake migrates lazily), so
226
327
  // running this phase after it would mean every failed scope gets its attempt
@@ -244,9 +345,7 @@ export async function runPlatformSweep(host, options) {
244
345
  const now = new Date().toISOString();
245
346
  // `provisioning` included: a scope stuck there because its migration failed
246
347
  // is precisely a straggler (the pure adapter's getScope makes the same call).
247
- const listed = await host.admin.listScopes(options.actor, {
248
- status: ['active', 'provisioning'],
249
- });
348
+ const listed = await listScopes({ status: ['active', 'provisioning'] });
250
349
  const fleet = migrationFleet(listed);
251
350
  // Due = behind and never-failed (wake it — that IS the sweep's job), or
252
351
  // failed with its backoff window elapsed. A failed scope inside its window
@@ -328,7 +427,7 @@ export async function runPlatformSweep(host, options) {
328
427
  const reachedThisPass = new Map();
329
428
  const viaPlatformDrain = Boolean(options.drainPlatformRequestsFn);
330
429
  if (options.drainRetries !== false) {
331
- const scopes = await host.admin.listScopes(options.actor, { status: 'active' });
430
+ const scopes = await listScopes({ status: 'active' });
332
431
  await mapBounded(scopes, concurrency, async (s) => {
333
432
  if (failedThisPass.has(s.id))
334
433
  return; // fails closed — draining is only noise
@@ -356,7 +455,7 @@ export async function runPlatformSweep(host, options) {
356
455
  const drainedThisPass = new Map();
357
456
  const drain = options.drainPlatformRequestsFn;
358
457
  if (drain) {
359
- const scopes = await host.admin.listScopes(options.actor, { status: 'active' });
458
+ const scopes = await listScopes({ status: 'active' });
360
459
  await mapBounded(scopes, concurrency, async (s) => {
361
460
  if (failedThisPass.has(s.id)) {
362
461
  report.platformRequestTotals.skipped += 1;
@@ -381,7 +480,7 @@ export async function runPlatformSweep(host, options) {
381
480
  if (options.storageGauge &&
382
481
  typeof host.admin.recordScopeStorage === 'function' &&
383
482
  typeof host.admin.listScopeStorageAttempts === 'function') {
384
- report.storage = await sweepStorageGauge(host.admin, options, options.storageGauge, reachedThisPass, report);
483
+ report.storage = await sweepStorageGauge(host.admin, options, options.storageGauge, reachedThisPass, report, listScopes);
385
484
  }
386
485
  // -- provision reconcile (#1172, #1653) -------------------------------------
387
486
  // A vertical's `onProvision` runs once per scope, at install. So a scope serving code
@@ -404,7 +503,7 @@ export async function runPlatformSweep(host, options) {
404
503
  unsupported: { count: 0, scopeIds: [] },
405
504
  deferred: 0,
406
505
  };
407
- const scopes = (await host.admin.listScopes(options.actor, { status: 'active' })).filter((s) => isPrimaryScope(s) && !failedThisPass.has(s.id));
506
+ const scopes = (await listScopes({ status: 'active' })).filter((s) => isPrimaryScope(s) && !failedThisPass.has(s.id));
408
507
  const serving = await servingPointersFor(host.admin, options.actor, scopes);
409
508
  const behind = [];
410
509
  for (const s of scopes) {
@@ -484,21 +583,27 @@ export async function runPlatformSweep(host, options) {
484
583
  // Primaries only (`isPrimaryScope`, #2005) — a fork, a snapshot or a preview of either
485
584
  // kind is a copy or a throwaway; firing its schedules would run real recurring side
486
585
  // effects off it. Lineage alone (`forkedFrom === null`) let a clean-room preview through.
487
- const scopes = (await host.admin.listScopes(options.actor, { status: 'active' })).filter(isPrimaryScope);
586
+ const scopes = (await listScopes({ status: 'active' })).filter(isPrimaryScope);
488
587
  await mapBounded(scopes, concurrency, async (s) => {
489
588
  if (failedThisPass.has(s.id))
490
589
  return;
491
590
  let touched = false;
492
591
  let platformRequests = 0;
592
+ // #2517: the requests this scope's purge runs enqueued so far in the tick, across modules —
593
+ // nothing drains between them, so a later purge head the cap refuses waits for the next tick.
594
+ let purgeRequests = 0;
493
595
  for (const reg of registrations) {
494
596
  try {
495
- const r = await host.runDueSchedules(reg.moduleId, s.tenantId, s.id);
597
+ const r = await host.runDueSchedules(reg.moduleId, s.tenantId, s.id, { earlierPurgeRequests: purgeRequests });
496
598
  if (r.fired > 0 || r.failed > 0)
497
599
  touched = true;
498
600
  schedules.fired += r.fired;
499
601
  schedules.skipped += r.skipped;
500
602
  schedules.failed += r.failed;
603
+ if (r.deferred)
604
+ schedules.deferred = (schedules.deferred ?? 0) + r.deferred;
501
605
  platformRequests += r.platformRequests ?? 0;
606
+ purgeRequests += r.purgeRequests ?? 0;
502
607
  for (const e of r.errors) {
503
608
  report.errors.push({ kind: 'schedule', id: `${s.id}:${e.operation}`, error: e.error });
504
609
  }
@@ -566,7 +671,7 @@ export async function runPlatformSweep(host, options) {
566
671
  const freshRegs = host.registeredFreshness().filter((r) => r.freshness.length > 0);
567
672
  if (freshRegs.length > 0) {
568
673
  // The same primaries the schedule phase fires on (#2005).
569
- const scopes = (await host.admin.listScopes(options.actor, { status: 'active' })).filter(isPrimaryScope);
674
+ const scopes = (await listScopes({ status: 'active' })).filter(isPrimaryScope);
570
675
  await mapBounded(scopes, concurrency, async (s) => {
571
676
  if (failedThisPass.has(s.id))
572
677
  return;
@@ -600,7 +705,7 @@ export async function runPlatformSweep(host, options) {
600
705
  // After the migration phase and skipping what failed there, like every phase that reaches a
601
706
  // vertical. Opt-in, and its own window per pass: it lists a bucket prefix and wakes a scope.
602
707
  if (options.orphanAttachments) {
603
- const scopes = (await host.admin.listScopes(options.actor, { status: 'active' })).filter((s) => isPrimaryScope(s) && !failedThisPass.has(s.id));
708
+ const scopes = (await listScopes({ status: 'active' })).filter((s) => isPrimaryScope(s) && !failedThisPass.has(s.id));
604
709
  report.orphanAttachments = await sweepOrphanAttachmentWindow(host, options, options.orphanAttachments, scopes, report);
605
710
  }
606
711
  if (options.gcSnapshots !== false) {
@@ -612,7 +717,7 @@ export async function runPlatformSweep(host, options) {
612
717
  // `deleteSnapshot` re-checks this same predicate, so a mislabeled row fails closed there,
613
718
  // never silently deletes.
614
719
  const now = new Date().toISOString();
615
- const scopes = await host.admin.listScopes(options.actor);
720
+ const scopes = await listScopes();
616
721
  const expired = scopes.filter((s) => (s.forkedFrom !== null || s.kind === 'preview') && s.expiresAt !== null && s.expiresAt <= now);
617
722
  const reap = options.deleteSnapshotFn ??
618
723
  ((tenantId, scopeId) => host.deleteSnapshot(options.actor, tenantId, scopeId));
@@ -635,7 +740,7 @@ export async function runPlatformSweep(host, options) {
635
740
  // status below the seam, so a row that changed underneath fails closed there.
636
741
  if (options.reapArchivedAfterDays !== undefined && options.reapArchivedAfterDays >= 0) {
637
742
  const cutoff = new Date(Date.now() - options.reapArchivedAfterDays * 86_400_000).toISOString();
638
- const archived = await host.admin.listScopes(options.actor, { status: ['archived'] });
743
+ const archived = await listScopes({ status: ['archived'] });
639
744
  const due = archived.filter((s) => s.archivedAt !== null && s.archivedAt <= cutoff);
640
745
  const reap = options.reapScopeFn ??
641
746
  // Retention reap is a deliberate policy on already-archived scopes: force past the
@@ -672,6 +777,11 @@ export async function runPlatformSweep(host, options) {
672
777
  // Co-located default: reap every one of the tenant's scopes through the scope
673
778
  // seam (archive-if-needed first — reapScope only accepts `archived`), then clear
674
779
  // the tenant's directory rows. A scope already `reaped` is skipped.
780
+ //
781
+ // STRICT, unlike every other read in this pass: `reapTenant` below clears the tenant's
782
+ // directory, so a scope this loop stepped over would never have its storage reaped and
783
+ // would lose the rows that find it. A row that does not parse fails this tenant's reap
784
+ // instead, into `errors` as `reap-tenant`, and the tenant waits for the row to be fixed.
675
785
  const scopes = await host.admin.listScopes(options.actor, { tenantId });
676
786
  for (const s of scopes) {
677
787
  if (s.status === 'reaped')
@@ -694,7 +804,7 @@ export async function runPlatformSweep(host, options) {
694
804
  });
695
805
  }
696
806
  const connections = await host.admin.listConnections(options.actor, {});
697
- const landings = options.connectionLandings ? new LandingPass(host, options.actor, options.connectionLandings, concurrency) : null;
807
+ const landings = options.connectionLandings ? new LandingPass(host, options.actor, options.connectionLandings, concurrency, listScopes) : null;
698
808
  await mapBounded(connections, concurrency, async (c) => {
699
809
  // #1232: the connection's durable sweep record. A connection row carries no
700
810
  // scope (a connection spans scopes by construction) and no version.
@@ -734,7 +844,8 @@ export async function runPlatformSweep(host, options) {
734
844
  report.connectionLandings.failed += landed.failed.length;
735
845
  }
736
846
  try {
737
- const result = await sweeper(host, c.id, { fetch: options.fetch });
847
+ // #2498: a sized stream body the connector sends reaches the injected fetch converted.
848
+ const result = await sweeper(host, c.id, { fetch: fetchLikeOf(options.fetch) });
738
849
  const answer = options.connectionLandings?.binders[c.provider] ? connectorSweepAnswerOf(result) : null;
739
850
  report.connectionsSwept += 1;
740
851
  // #2316: a resolved sweep is not a landed one. A scope the connector could not land in, or a
@@ -769,7 +880,7 @@ export async function runPlatformSweep(host, options) {
769
880
  // own outbox. An import is never re-emitted into the consumer's outbox, so the two phases
770
881
  // never ship the same event twice.
771
882
  if (options.crossVertical) {
772
- report.crossVertical = await sweepCrossVertical(host, options, options.crossVertical, failedThisPass, report);
883
+ report.crossVertical = await sweepCrossVertical(host, options, options.crossVertical, failedThisPass, report, listScopes);
773
884
  }
774
885
  // -- drain each scope's domain events to Tier 2 (#1334, master-plan §5.3) ---
775
886
  // Before the access-log drain below, which is deliberately last: this phase
@@ -785,7 +896,7 @@ export async function runPlatformSweep(host, options) {
785
896
  // fleet-wide fault.
786
897
  const configured = options.eventDrainBatch ?? EVENT_DRAIN_BATCH;
787
898
  const budget = Number.isFinite(configured) && configured >= 1 ? Math.floor(configured) : EVENT_DRAIN_BATCH;
788
- const drainable = await host.admin.listScopes(options.actor, { status: 'active' });
899
+ const drainable = await listScopes({ status: 'active' });
789
900
  await mapBounded(drainable, concurrency, async (s) => {
790
901
  try {
791
902
  const { shipped, skipped } = await drainScopeEvents(host, options, sink, {
@@ -836,6 +947,26 @@ export async function runPlatformSweep(host, options) {
836
947
  report.errors.push({ kind: 'findings', id: 'findings', error: message(err) });
837
948
  }
838
949
  }
950
+ // -- the daily meter snapshot (#2501) -----------------------------------------
951
+ // After all the scheduled work, so the day's reading is the directory as this pass leaves it,
952
+ // reaps included, and just before the request rollup: the snapshot is one directory transaction,
953
+ // and a slow Analytics Engine answer must not push its reading later in the day. Its one read of
954
+ // the day records is shipped by the access-log drain below, in the same pass.
955
+ if (options.meterSnapshot &&
956
+ typeof host.admin.recordMeterDay === 'function' &&
957
+ typeof host.admin.listMeterDayTakes === 'function') {
958
+ report.meterSnapshot = await sweepMeterSnapshot(host.admin, options.actor, options.meterSnapshot, report);
959
+ }
960
+ // -- the daily request rollup (#2501) ----------------------------------------
961
+ // After all the scheduled work, deliberately: it waits on Analytics Engine, an outside service,
962
+ // and nothing in the pass depends on it, so a slow answer must not hold back the work that has
963
+ // to happen every pass. Each read is bounded (`RequestRollupSweepOptions.readTimeoutMs`), so the
964
+ // most it can delay is the access-log drain below, which then ships this phase's own reads too.
965
+ if (options.requestRollup &&
966
+ typeof host.admin.recordRequestDay === 'function' &&
967
+ typeof host.admin.listRequestRollupDays === 'function') {
968
+ report.requestRollup = await sweepRequestRollup(host.admin, options.actor, options.requestRollup, report);
969
+ }
839
970
  // -- drain the staff access log to Tier 2, then prune it (K-24, §4.4) --------
840
971
  // LAST, deliberately: every phase above reads the directory through the audited
841
972
  // seam, so each one writes access rows. Running the drain last means a pass ships
@@ -857,7 +988,7 @@ export async function runPlatformSweep(host, options) {
857
988
  * One pass over every cross-vertical edge (#1705). Bounded like the event drain: one batch per
858
989
  * edge per pass, reported rather than looped, so one busy producer cannot starve the rest.
859
990
  */
860
- async function sweepCrossVertical(host, options, cv, failedThisPass, report,
991
+ async function sweepCrossVertical(host, options, cv, failedThisPass, report, listScopes,
861
992
  /** Run only this producer's outgoing edges (the router kick). Absent: every edge. */
862
993
  only) {
863
994
  const out = {
@@ -889,7 +1020,7 @@ only) {
889
1020
  //
890
1021
  // A producer-scoped run reads only that tenant's scopes: both ends of an edge are in one
891
1022
  // tenant, so nothing outside it can be a consumer of this producer or change how it resolves.
892
- const scopes = (await host.admin.listScopes(options.actor, { status: 'active', ...(only ? { tenantId: only.tenantId } : {}) })).filter((s) => isPrimaryScope(s) && s.vertical !== null);
1023
+ const scopes = (await listScopes({ status: 'active', ...(only ? { tenantId: only.tenantId } : {}) })).filter((s) => isPrimaryScope(s) && s.vertical !== null);
893
1024
  // The kick names a scope, and the edges run are the ones whose producer RESOLVES to it. A
894
1025
  // fork, a preview, a second install or a scope of another tenant is not a producer, so a
895
1026
  // kick naming one runs nothing (the sweep would resolve the same way).
@@ -1044,7 +1175,8 @@ only) {
1044
1175
  */
1045
1176
  export async function runCrossVerticalFrom(host, options, producer) {
1046
1177
  const report = { errors: [] };
1047
- const crossVertical = await sweepCrossVertical(host, options, options.crossVertical, new Set(), report, producer);
1178
+ const listScopes = scopeReaderFor(host.admin, options.actor, report);
1179
+ const crossVertical = await sweepCrossVertical(host, options, options.crossVertical, new Set(), report, listScopes, producer);
1048
1180
  return { crossVertical, errors: report.errors };
1049
1181
  }
1050
1182
  /**
@@ -1590,7 +1722,7 @@ const cmp = (x, y) => (x < y ? -1 : x > y ? 1 : 0);
1590
1722
  * in one directory call, then prune past retention. A failed read is an error entry; its
1591
1723
  * scope keeps its last sample and waits a day like any other.
1592
1724
  */
1593
- async function sweepStorageGauge(admin, options, gauge, reachedThisPass, report) {
1725
+ async function sweepStorageGauge(admin, options, gauge, reachedThisPass, report, listScopes) {
1594
1726
  const out = {
1595
1727
  reached: reachedThisPass.size,
1596
1728
  resting: 0,
@@ -1610,7 +1742,7 @@ async function sweepStorageGauge(admin, options, gauge, reachedThisPass, report)
1610
1742
  if (batch > 0) {
1611
1743
  // Not one archived before it ever held data (`holdsStore`): reading it would create the
1612
1744
  // spine it never had, and bill it.
1613
- const resting = (await admin.listScopes(options.actor, { status: STORED_SCOPE_STATUSES.filter((s) => s !== 'active') })).filter(holdsStore);
1745
+ const resting = (await listScopes({ status: STORED_SCOPE_STATUSES.filter((s) => s !== 'active') })).filter(holdsStore);
1614
1746
  out.resting = resting.length;
1615
1747
  const candidates = [...reachedThisPass.values(), ...resting.filter((s) => !reachedThisPass.has(s.id))];
1616
1748
  const tried = new Map((await admin.listScopeStorageAttempts(options.actor)).map((a) => [a.scopeId, a.attemptedAt]));
@@ -1639,33 +1771,48 @@ async function sweepStorageGauge(admin, options, gauge, reachedThisPass, report)
1639
1771
  }
1640
1772
  if (!Number.isInteger(bytes) || bytes < 0)
1641
1773
  throw new Error(`not a size: ${String(bytes)}`);
1642
- // The store read is its own attempt. A failure here must not throw away the
1643
- // database size just read, and must not replace the day's store rows.
1644
- let stores;
1645
- if (gauge.readStores) {
1774
+ // The store read and the attachment read are each their own: a failure of one must not
1775
+ // throw away the database size just read, nor the other's reading, nor replace what the
1776
+ // day already holds of it. A read the deployment cannot make is not a failure and not a
1777
+ // size of 0: what it would measure is left as it is. Neither waits for the other, so a
1778
+ // slow scope holds its slot for one deadline, not two.
1779
+ const beside = {};
1780
+ const timeoutMs = gauge.storeReadTimeoutMs ?? STORE_READ_TIMEOUT_MS;
1781
+ /** One read beside the database: its failure message, or nothing. */
1782
+ const readBeside = async (read, label, keep) => {
1783
+ if (!read)
1784
+ return undefined;
1646
1785
  try {
1647
- stores = await withinDeadline(gauge.readStores(s), gauge.storeReadTimeoutMs ?? STORE_READ_TIMEOUT_MS, `scope store read of ${s.id}`);
1786
+ const value = await withinDeadline(read.call(gauge, s), timeoutMs, `${label} of ${s.id}`);
1787
+ if (value !== null)
1788
+ keep(value);
1648
1789
  }
1649
1790
  catch (err) {
1650
- if (isUnsupported(err)) {
1651
- // Not a failure, and not a size: the database sample stands, the store rows stay.
1652
- out.read += 1;
1653
- out.unsupported += 1;
1654
- attempts.push({ ...attempt, bytes });
1655
- return;
1656
- }
1657
- out.failed += 1;
1658
- attempts.push({ ...attempt, bytes, error: message(err) });
1659
- report.errors.push({ kind: 'storage', id: s.id, error: message(err) });
1660
- return;
1791
+ if (!isUnsupported(err))
1792
+ return message(err);
1793
+ out.unsupported += 1;
1661
1794
  }
1795
+ return undefined;
1796
+ };
1797
+ // Listed in a fixed order, so a scope failing both always reports them the same way.
1798
+ const failures = (await Promise.all([
1799
+ readBeside(gauge.readStores, 'scope store read', (stores) => {
1800
+ beside.stores = stores;
1801
+ }),
1802
+ readBeside(gauge.readAttachments, 'attachment read', (attachments) => {
1803
+ if (!Number.isInteger(attachments) || attachments < 0)
1804
+ throw new Error(`not a size: ${String(attachments)}`);
1805
+ beside.attachments = attachments;
1806
+ }),
1807
+ ])).filter((failure) => failure !== undefined);
1808
+ if (failures.length > 0) {
1809
+ const error = failures.join('; ');
1810
+ out.failed += 1;
1811
+ attempts.push({ ...attempt, bytes, ...beside, error });
1812
+ report.errors.push({ kind: 'storage', id: s.id, error });
1813
+ return;
1662
1814
  }
1663
- if (stores === null) {
1664
- attempts.push({ ...attempt, bytes });
1665
- }
1666
- else {
1667
- attempts.push({ ...attempt, bytes, ...(stores ? { stores } : {}) });
1668
- }
1815
+ attempts.push({ ...attempt, bytes, ...beside });
1669
1816
  out.read += 1;
1670
1817
  }
1671
1818
  catch (err) {
@@ -1691,6 +1838,189 @@ async function sweepStorageGauge(admin, options, gauge, reachedThisPass, report)
1691
1838
  }
1692
1839
  return out;
1693
1840
  }
1841
+ const NO_ATTEMPT = { error: null, since: null, failures: 0 };
1842
+ /** The newest of a ledger's attempts (#2501), and the run of identical failures it belongs to when it failed. */
1843
+ function previousAttempt(attempts) {
1844
+ let newest;
1845
+ for (const a of attempts)
1846
+ if (newest === undefined || a.attemptedAt > newest.attemptedAt)
1847
+ newest = a;
1848
+ return newest ? { error: newest.error, since: newest.failingSince ?? newest.attemptedAt, failures: newest.failingCount ?? 1 } : NO_ATTEMPT;
1849
+ }
1850
+ /**
1851
+ * A failure's run (#2501): the previous attempt's run carried on when this failure repeats its error,
1852
+ * a new run begun `at` otherwise. A repeated failure is a standing one, which reached the failure
1853
+ * digest when its run began; the run keeps its start and length on the ledger and in the report,
1854
+ * where the pass's log line shows them.
1855
+ */
1856
+ function extendRun(previous, error, at) {
1857
+ const repeated = error === previous.error;
1858
+ return {
1859
+ repeated,
1860
+ run: { error, since: repeated && previous.since ? previous.since : at, failures: repeated ? previous.failures + 1 : 1 },
1861
+ };
1862
+ }
1863
+ /**
1864
+ * One pass of the request-rollup phase (#2501): read the oldest due days one at a time, record
1865
+ * each day's counts or its failure, then prune past retention. A failed day writes nothing of the
1866
+ * day, waits `retryMs` like any other attempt, and ends the pass's reads: the days after it stay
1867
+ * due. A failure that repeats the previous attempt's is a standing one (a credential, a dataset
1868
+ * name, an outage), reported when its streak began and not again until a read succeeds.
1869
+ */
1870
+ async function sweepRequestRollup(admin, actor, rollup, report) {
1871
+ const out = {
1872
+ due: 0,
1873
+ deferred: 0,
1874
+ read: 0,
1875
+ failed: 0,
1876
+ repeated: 0,
1877
+ streak: null,
1878
+ saturated: 0,
1879
+ saturatedDays: [],
1880
+ recorded: 0,
1881
+ pruned: 0,
1882
+ };
1883
+ const batch = rollup.batch ?? REQUEST_ROLLUP_DAYS_PER_PASS;
1884
+ const months = rollup.retentionMonths === undefined ? BILLING_ROLLUP_RETENTION_MONTHS : rollup.retentionMonths;
1885
+ const now = rollup.now ?? (() => new Date().toISOString());
1886
+ try {
1887
+ const nowMs = Date.parse(now());
1888
+ if (batch > 0) {
1889
+ const reads = await admin.listRequestRollupDays(actor);
1890
+ // A saturated day is a state of that day, not a failure of the read, so it belongs to no run.
1891
+ let previous = previousAttempt(reads.filter((r) => !r.saturated));
1892
+ const due = requestRollupDueDays({
1893
+ nowMs,
1894
+ reads,
1895
+ lookbackDays: rollup.lookbackDays ?? REQUEST_ROLLUP_LOOKBACK_DAYS,
1896
+ settleMs: rollup.settleMs ?? REQUEST_ROLLUP_SETTLE_MS,
1897
+ retryMs: rollup.retryMs ?? REQUEST_ROLLUP_RETRY_MS,
1898
+ horizon: billingRollupHorizon(nowMs, months),
1899
+ });
1900
+ const take = due.slice(0, batch);
1901
+ out.due = due.length;
1902
+ out.deferred = due.length - take.length;
1903
+ // One day at a time: each is one Analytics Engine query, and the account's query budget
1904
+ // is shared with every observability read.
1905
+ const saturated = [];
1906
+ for (const day of take) {
1907
+ try {
1908
+ const counts = await withinDeadline(rollup.read(day), rollup.readTimeoutMs ?? REQUEST_ROLLUP_READ_TIMEOUT_MS, 'the request rollup read');
1909
+ out.recorded += (await admin.recordRequestDay(actor, { day, attemptedAt: now(), counts })).recorded;
1910
+ out.read += 1;
1911
+ // A read that succeeds ends any run, so a failure later in this pass starts a new one.
1912
+ previous = NO_ATTEMPT;
1913
+ }
1914
+ catch (err) {
1915
+ const error = message(err);
1916
+ const attemptedAt = now();
1917
+ if (isRequestRollupSaturated(err)) {
1918
+ // A standing state of this day, not a failed read: kept visible on the day's record,
1919
+ // never read again, and no reason to stop, so the days after it are still rolled up.
1920
+ out.saturated += 1;
1921
+ saturated.push(day);
1922
+ await admin.recordRequestDay(actor, { day, attemptedAt, error, saturated: true }).catch(() => undefined);
1923
+ continue;
1924
+ }
1925
+ out.failed += 1;
1926
+ const { run, repeated } = extendRun(previous, error, attemptedAt);
1927
+ out.streak = run;
1928
+ if (repeated)
1929
+ out.repeated += 1;
1930
+ else
1931
+ report.errors.push({ kind: 'request-rollup', id: day, error });
1932
+ // The failure is the day's attempt, so the day waits `retryMs` rather than taking a slot
1933
+ // on every pass. If even this write fails, the day is simply due again next pass.
1934
+ await admin
1935
+ .recordRequestDay(actor, { day, attemptedAt, error, failingSince: run.since, failingCount: run.failures })
1936
+ .catch(() => undefined);
1937
+ // The next day would most likely fail the same way, and each read is a fleet-wide query
1938
+ // against the account's shared budget: one failure ends the pass's reads.
1939
+ break;
1940
+ }
1941
+ }
1942
+ // No read failed this pass and none succeeded (a success resets `previous`): a run the ledger
1943
+ // holds still stands, though this pass had nothing due to retry. It is reported as it is,
1944
+ // with no attempt added and nothing pushed to the digest, which mailed it when it began.
1945
+ if (out.streak === null && previous.error !== null && previous.since !== null) {
1946
+ out.streak = { error: previous.error, since: previous.since, failures: previous.failures };
1947
+ }
1948
+ // Each saturated day reaches the digest once, on the pass that found it: it is not read again.
1949
+ out.saturatedDays = saturated;
1950
+ if (saturated.length > 0) {
1951
+ report.errors.push({
1952
+ kind: 'request-rollup',
1953
+ id: 'saturated',
1954
+ error: `${saturated.length} day(s) hold more groups than one read answers and are kept unread: ${saturated.join(', ')}`,
1955
+ });
1956
+ }
1957
+ }
1958
+ if (typeof admin.pruneRequestDays === 'function') {
1959
+ out.pruned = await admin.pruneRequestDays(actor, rollup.pruneBatch ?? REQUEST_ROLLUP_PRUNE_BATCH, months);
1960
+ }
1961
+ }
1962
+ catch (err) {
1963
+ // A directory read or the prune failed: whatever days were recorded stay recorded, and the
1964
+ // next pass finds the rest due.
1965
+ report.errors.push({ kind: 'request-rollup', id: 'directory', error: message(err) });
1966
+ }
1967
+ return out;
1968
+ }
1969
+ const DAY_MS = 86_400_000;
1970
+ /**
1971
+ * One pass of the meter-snapshot phase (#2501): take the snapshot of the UTC day the pass runs in,
1972
+ * unless the day already has one, then prune past retention. A failed attempt writes nothing of the
1973
+ * day and is recorded, and the next pass of the day tries again. A failure that repeats the attempt
1974
+ * before it, across days too, is a standing one: reported when its run began, and not again until
1975
+ * a snapshot is taken.
1976
+ */
1977
+ async function sweepMeterSnapshot(admin, actor, snapshot, report) {
1978
+ const out = { day: null, taken: false, tenants: 0, failed: 0, repeated: 0, streak: null, pruned: 0 };
1979
+ const months = snapshot.retentionMonths === undefined ? BILLING_ROLLUP_RETENTION_MONTHS : snapshot.retentionMonths;
1980
+ const now = snapshot.now ?? (() => new Date().toISOString());
1981
+ try {
1982
+ const at = new Date(now()).toISOString();
1983
+ const day = at.slice(0, 10);
1984
+ out.day = day;
1985
+ // The attempt before this pass is today's, or yesterday's when today has none: one a day at the
1986
+ // least while the phase runs, so two days hold it.
1987
+ const takes = await admin.listMeterDayTakes(actor, { since: utcDayOf(Date.parse(at) - DAY_MS), until: day });
1988
+ // A day already taken is not tried again, so a later pass of the day can never report a failure
1989
+ // about a snapshot that stands. The directory checks again inside its own transaction, which is
1990
+ // what settles two passes racing for the same day.
1991
+ if (!takes.some((t) => t.day === day && t.takenAt !== null)) {
1992
+ try {
1993
+ const taken = await withinDeadline(admin.recordMeterDay(actor, { at }), snapshot.timeoutMs ?? METER_SNAPSHOT_TIMEOUT_MS, 'the meter snapshot');
1994
+ out.taken = taken.taken;
1995
+ out.tenants = taken.tenants;
1996
+ }
1997
+ catch (err) {
1998
+ const error = message(err);
1999
+ out.failed = 1;
2000
+ const { run, repeated } = extendRun(previousAttempt(takes), error, at);
2001
+ out.streak = run;
2002
+ if (repeated)
2003
+ out.repeated = 1;
2004
+ else
2005
+ report.errors.push({ kind: 'meter-snapshot', id: day, error });
2006
+ // The attempt's own instant, so its record lands on its day even when the deadline ran past
2007
+ // midnight. If even this write fails, the next pass simply tries again.
2008
+ await admin
2009
+ .recordMeterDay(actor, { at, error, failingSince: run.since, failingCount: run.failures })
2010
+ .catch(() => undefined);
2011
+ }
2012
+ }
2013
+ if (typeof admin.pruneMeterDays === 'function') {
2014
+ out.pruned = await admin.pruneMeterDays(actor, snapshot.pruneBatch ?? METER_DAYS_PRUNE_BATCH, months);
2015
+ }
2016
+ }
2017
+ catch (err) {
2018
+ // A directory read or the prune failed: a snapshot already taken stays taken, and the next pass
2019
+ // tries the day again if it was not.
2020
+ report.errors.push({ kind: 'meter-snapshot', id: 'directory', error: message(err) });
2021
+ }
2022
+ return out;
2023
+ }
1694
2024
  const pushCapped = (bucket, id) => {
1695
2025
  bucket.count += 1;
1696
2026
  if (bucket.scopeIds.length < ORPHAN_ATTACHMENT_REPORTED_IDS)