@substrat-run/kernel 0.159.0 → 0.161.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 +142 -0
  6. package/dist/byte-stream.d.ts.map +1 -0
  7. package/dist/byte-stream.js +274 -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 +125 -0
  18. package/dist/deployment-wire-auth.d.ts.map +1 -0
  19. package/dist/deployment-wire-auth.js +161 -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 +19 -10
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +17 -8
  32. package/dist/index.js.map +1 -1
  33. package/dist/meter-days.d.ts +107 -0
  34. package/dist/meter-days.d.ts.map +1 -0
  35. package/dist/meter-days.js +230 -0
  36. package/dist/meter-days.js.map +1 -0
  37. package/dist/meters.d.ts +19 -1
  38. package/dist/meters.d.ts.map +1 -1
  39. package/dist/meters.js +0 -0
  40. package/dist/meters.js.map +1 -1
  41. package/dist/ops-failure-reference.d.ts +43 -0
  42. package/dist/ops-failure-reference.d.ts.map +1 -0
  43. package/dist/ops-failure-reference.js +42 -0
  44. package/dist/ops-failure-reference.js.map +1 -0
  45. package/dist/platform-request-cap.d.ts +9 -0
  46. package/dist/platform-request-cap.d.ts.map +1 -0
  47. package/dist/platform-request-cap.js +42 -0
  48. package/dist/platform-request-cap.js.map +1 -0
  49. package/dist/platform-sweep.d.ts +187 -7
  50. package/dist/platform-sweep.d.ts.map +1 -1
  51. package/dist/platform-sweep.js +388 -45
  52. package/dist/platform-sweep.js.map +1 -1
  53. package/dist/promote-review.d.ts +6 -0
  54. package/dist/promote-review.d.ts.map +1 -1
  55. package/dist/promote-review.js +9 -0
  56. package/dist/promote-review.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 +192 -4
  62. package/dist/reserved-roles.d.ts.map +1 -1
  63. package/dist/reserved-roles.js +314 -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 +317 -15
  68. package/dist/scope-host.d.ts.map +1 -1
  69. package/dist/scope-host.js +93 -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 +23 -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';
@@ -68,6 +71,15 @@ function reconcileWindow(behind, batch, rng) {
68
71
  export class StorageReadUnsupported extends Error {
69
72
  unsupported = true;
70
73
  }
74
+ /**
75
+ * What a delegated event read throws when the scope's deployment says it does not hold the scope
76
+ * (#2544). Standing, like `StorageReadUnsupported`: the drain counts it in `EventDrainReport.notHeld`
77
+ * and keeps it out of `report.errors`. Recognised by its flag, not `instanceof`, for the same reason.
78
+ */
79
+ export class ScopeNotHeld extends Error {
80
+ notHeld = true;
81
+ }
82
+ const isNotHeld = (err) => typeof err === 'object' && err !== null && err.notHeld === true;
71
83
  const isUnsupported = (err) => typeof err === 'object' && err !== null && err.unsupported === true;
72
84
  /** `work`, or a rejection once `ms` have passed. The work is not cancelled; the pass stops waiting. */
73
85
  async function withinDeadline(work, ms, what) {
@@ -188,6 +200,36 @@ function assertSweepBatches(options) {
188
200
  if (gauge?.batch !== undefined && !(Number.isInteger(gauge.batch) && gauge.batch >= 0)) {
189
201
  throw substratError('validation_failed', `storageGauge.batch must be a non-negative integer, got ${String(gauge.batch)}`);
190
202
  }
203
+ const rollup = options.requestRollup;
204
+ if (rollup?.pruneBatch !== undefined)
205
+ assertRowLimit('requestRollup.pruneBatch', rollup.pruneBatch);
206
+ if (rollup?.batch !== undefined && !(Number.isInteger(rollup.batch) && rollup.batch >= 0)) {
207
+ throw substratError('validation_failed', `requestRollup.batch must be a non-negative integer, got ${String(rollup.batch)}`);
208
+ }
209
+ if (rollup?.retentionMonths !== undefined)
210
+ assertBillingRetention('requestRollup.retentionMonths', rollup.retentionMonths);
211
+ // Each of these corrupts the kept history silently rather than failing: a lookback past what
212
+ // Analytics Engine keeps records aged-out days as days without traffic, and a negative settle
213
+ // reads the open day and freezes it. So they are refused here, before anything is read.
214
+ const lookback = rollup?.lookbackDays;
215
+ if (lookback !== undefined && !(Number.isInteger(lookback) && lookback >= 1 && lookback <= REQUEST_ROLLUP_MAX_LOOKBACK_DAYS)) {
216
+ throw substratError('validation_failed', `requestRollup.lookbackDays must be a whole number of days from 1 to ${REQUEST_ROLLUP_MAX_LOOKBACK_DAYS}, got ${String(lookback)}`);
217
+ }
218
+ for (const name of ['settleMs', 'retryMs']) {
219
+ const ms = rollup?.[name];
220
+ if (ms !== undefined && !(Number.isFinite(ms) && ms >= 0)) {
221
+ throw substratError('validation_failed', `requestRollup.${name} must be a non-negative number of ms, got ${String(ms)}`);
222
+ }
223
+ }
224
+ if (rollup?.readTimeoutMs !== undefined)
225
+ assertPositiveMs('requestRollup.readTimeoutMs', rollup.readTimeoutMs);
226
+ const snapshot = options.meterSnapshot;
227
+ if (snapshot?.pruneBatch !== undefined)
228
+ assertRowLimit('meterSnapshot.pruneBatch', snapshot.pruneBatch);
229
+ if (snapshot?.retentionMonths !== undefined)
230
+ assertBillingRetention('meterSnapshot.retentionMonths', snapshot.retentionMonths);
231
+ if (snapshot?.timeoutMs !== undefined)
232
+ assertPositiveMs('meterSnapshot.timeoutMs', snapshot.timeoutMs);
191
233
  const orphans = options.orphanAttachments;
192
234
  if (orphans?.batch !== undefined && !(Number.isInteger(orphans.batch) && orphans.batch >= 0)) {
193
235
  throw substratError('validation_failed', `orphanAttachments.batch must be a non-negative integer, got ${String(orphans.batch)}`);
@@ -196,6 +238,69 @@ function assertSweepBatches(options) {
196
238
  throw substratError('validation_failed', `orphanAttachments.mode must be 'dry-run' or 'delete', got ${String(orphans.mode)}`);
197
239
  }
198
240
  }
241
+ /** A billing retention option, refused unless `billingRollupHorizon` takes it. */
242
+ function assertBillingRetention(name, months) {
243
+ try {
244
+ billingRollupHorizon(0, months);
245
+ }
246
+ catch (err) {
247
+ throw substratError('validation_failed', `${name}: ${message(err)}`);
248
+ }
249
+ }
250
+ /** A deadline option, refused unless it is a positive, finite number of ms. */
251
+ function assertPositiveMs(name, ms) {
252
+ if (!(Number.isFinite(ms) && ms > 0)) {
253
+ throw substratError('validation_failed', `${name} must be a positive number of ms, got ${String(ms)}`);
254
+ }
255
+ }
256
+ /**
257
+ * How many unreadable directory rows one pass names before it only says there are more: the
258
+ * sweep's `errors`, and the hostname reconcile's `unreadableScopes`, so each pass's report stays
259
+ * bounded however many rows a directory holds.
260
+ */
261
+ export const UNREADABLE_SCOPE_ROWS_NAMED = 20;
262
+ /**
263
+ * The scope read every fleet-wide phase of one pass shares. One row that does not parse used to
264
+ * throw out of `listScopes` for the whole list, and since no phase reads the directory inside a
265
+ * try, out of the pass: every phase after it went unrun until the row was fixed by hand. Read
266
+ * through `listScopesReadable`, the row is stepped over, and named in `errors` ONCE per pass
267
+ * however many phases met it, by its id and the fields that failed, never their values. At most
268
+ * `UNREADABLE_SCOPE_ROWS_NAMED` are named, then one line says there are more. A host that
269
+ * predates `listScopesReadable` is read through `listScopes`, strict as before.
270
+ */
271
+ function scopeReaderFor(admin, actor, report) {
272
+ const readable = admin.listScopesReadable?.bind(admin);
273
+ if (!readable)
274
+ return (filter) => admin.listScopes(actor, filter);
275
+ const named = new Set();
276
+ let capped = false;
277
+ return async (filter) => {
278
+ const { scopes, unreadable } = await readable(actor, filter);
279
+ for (const row of unreadable) {
280
+ if (capped)
281
+ break;
282
+ if (named.has(row.scopeId))
283
+ continue;
284
+ if (named.size < UNREADABLE_SCOPE_ROWS_NAMED) {
285
+ named.add(row.scopeId);
286
+ report.errors.push({
287
+ kind: 'scope-row',
288
+ id: row.scopeId,
289
+ error: `the directory row of tenant ${row.tenantId} does not read as a scope (${row.fields.join(', ')}); stepped over this pass`,
290
+ });
291
+ }
292
+ else {
293
+ capped = true;
294
+ report.errors.push({
295
+ kind: 'scope-row',
296
+ id: '*',
297
+ 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`,
298
+ });
299
+ }
300
+ }
301
+ return scopes;
302
+ };
303
+ }
199
304
  export async function runPlatformSweep(host, options) {
200
305
  assertSweepBatches(options);
201
306
  const concurrency = options.concurrency ?? 8;
@@ -219,8 +324,13 @@ export async function runPlatformSweep(host, options) {
219
324
  findings: null,
220
325
  storage: null,
221
326
  orphanAttachments: null,
327
+ requestRollup: null,
328
+ meterSnapshot: null,
222
329
  errors: [],
223
330
  };
331
+ // Every fleet-wide read of the directory below goes through this, so one unreadable row is
332
+ // stepped over by each phase and named once. The tenant reap does not: see there.
333
+ const listScopes = scopeReaderFor(host.admin, options.actor, report);
224
334
  // -- migration reconciliation (kernel-design §5.3, #49) ---------------------
225
335
  // FIRST, deliberately: `drainDue` wakes scopes (and wake migrates lazily), so
226
336
  // running this phase after it would mean every failed scope gets its attempt
@@ -244,9 +354,7 @@ export async function runPlatformSweep(host, options) {
244
354
  const now = new Date().toISOString();
245
355
  // `provisioning` included: a scope stuck there because its migration failed
246
356
  // 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
- });
357
+ const listed = await listScopes({ status: ['active', 'provisioning'] });
250
358
  const fleet = migrationFleet(listed);
251
359
  // Due = behind and never-failed (wake it — that IS the sweep's job), or
252
360
  // failed with its backoff window elapsed. A failed scope inside its window
@@ -328,7 +436,7 @@ export async function runPlatformSweep(host, options) {
328
436
  const reachedThisPass = new Map();
329
437
  const viaPlatformDrain = Boolean(options.drainPlatformRequestsFn);
330
438
  if (options.drainRetries !== false) {
331
- const scopes = await host.admin.listScopes(options.actor, { status: 'active' });
439
+ const scopes = await listScopes({ status: 'active' });
332
440
  await mapBounded(scopes, concurrency, async (s) => {
333
441
  if (failedThisPass.has(s.id))
334
442
  return; // fails closed — draining is only noise
@@ -356,7 +464,7 @@ export async function runPlatformSweep(host, options) {
356
464
  const drainedThisPass = new Map();
357
465
  const drain = options.drainPlatformRequestsFn;
358
466
  if (drain) {
359
- const scopes = await host.admin.listScopes(options.actor, { status: 'active' });
467
+ const scopes = await listScopes({ status: 'active' });
360
468
  await mapBounded(scopes, concurrency, async (s) => {
361
469
  if (failedThisPass.has(s.id)) {
362
470
  report.platformRequestTotals.skipped += 1;
@@ -381,7 +489,7 @@ export async function runPlatformSweep(host, options) {
381
489
  if (options.storageGauge &&
382
490
  typeof host.admin.recordScopeStorage === 'function' &&
383
491
  typeof host.admin.listScopeStorageAttempts === 'function') {
384
- report.storage = await sweepStorageGauge(host.admin, options, options.storageGauge, reachedThisPass, report);
492
+ report.storage = await sweepStorageGauge(host.admin, options, options.storageGauge, reachedThisPass, report, listScopes);
385
493
  }
386
494
  // -- provision reconcile (#1172, #1653) -------------------------------------
387
495
  // A vertical's `onProvision` runs once per scope, at install. So a scope serving code
@@ -404,7 +512,7 @@ export async function runPlatformSweep(host, options) {
404
512
  unsupported: { count: 0, scopeIds: [] },
405
513
  deferred: 0,
406
514
  };
407
- const scopes = (await host.admin.listScopes(options.actor, { status: 'active' })).filter((s) => isPrimaryScope(s) && !failedThisPass.has(s.id));
515
+ const scopes = (await listScopes({ status: 'active' })).filter((s) => isPrimaryScope(s) && !failedThisPass.has(s.id));
408
516
  const serving = await servingPointersFor(host.admin, options.actor, scopes);
409
517
  const behind = [];
410
518
  for (const s of scopes) {
@@ -484,21 +592,27 @@ export async function runPlatformSweep(host, options) {
484
592
  // Primaries only (`isPrimaryScope`, #2005) — a fork, a snapshot or a preview of either
485
593
  // kind is a copy or a throwaway; firing its schedules would run real recurring side
486
594
  // 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);
595
+ const scopes = (await listScopes({ status: 'active' })).filter(isPrimaryScope);
488
596
  await mapBounded(scopes, concurrency, async (s) => {
489
597
  if (failedThisPass.has(s.id))
490
598
  return;
491
599
  let touched = false;
492
600
  let platformRequests = 0;
601
+ // #2517: the requests this scope's purge runs enqueued so far in the tick, across modules —
602
+ // nothing drains between them, so a later purge head the cap refuses waits for the next tick.
603
+ let purgeRequests = 0;
493
604
  for (const reg of registrations) {
494
605
  try {
495
- const r = await host.runDueSchedules(reg.moduleId, s.tenantId, s.id);
606
+ const r = await host.runDueSchedules(reg.moduleId, s.tenantId, s.id, { earlierPurgeRequests: purgeRequests });
496
607
  if (r.fired > 0 || r.failed > 0)
497
608
  touched = true;
498
609
  schedules.fired += r.fired;
499
610
  schedules.skipped += r.skipped;
500
611
  schedules.failed += r.failed;
612
+ if (r.deferred)
613
+ schedules.deferred = (schedules.deferred ?? 0) + r.deferred;
501
614
  platformRequests += r.platformRequests ?? 0;
615
+ purgeRequests += r.purgeRequests ?? 0;
502
616
  for (const e of r.errors) {
503
617
  report.errors.push({ kind: 'schedule', id: `${s.id}:${e.operation}`, error: e.error });
504
618
  }
@@ -566,7 +680,7 @@ export async function runPlatformSweep(host, options) {
566
680
  const freshRegs = host.registeredFreshness().filter((r) => r.freshness.length > 0);
567
681
  if (freshRegs.length > 0) {
568
682
  // The same primaries the schedule phase fires on (#2005).
569
- const scopes = (await host.admin.listScopes(options.actor, { status: 'active' })).filter(isPrimaryScope);
683
+ const scopes = (await listScopes({ status: 'active' })).filter(isPrimaryScope);
570
684
  await mapBounded(scopes, concurrency, async (s) => {
571
685
  if (failedThisPass.has(s.id))
572
686
  return;
@@ -600,7 +714,7 @@ export async function runPlatformSweep(host, options) {
600
714
  // After the migration phase and skipping what failed there, like every phase that reaches a
601
715
  // vertical. Opt-in, and its own window per pass: it lists a bucket prefix and wakes a scope.
602
716
  if (options.orphanAttachments) {
603
- const scopes = (await host.admin.listScopes(options.actor, { status: 'active' })).filter((s) => isPrimaryScope(s) && !failedThisPass.has(s.id));
717
+ const scopes = (await listScopes({ status: 'active' })).filter((s) => isPrimaryScope(s) && !failedThisPass.has(s.id));
604
718
  report.orphanAttachments = await sweepOrphanAttachmentWindow(host, options, options.orphanAttachments, scopes, report);
605
719
  }
606
720
  if (options.gcSnapshots !== false) {
@@ -612,7 +726,7 @@ export async function runPlatformSweep(host, options) {
612
726
  // `deleteSnapshot` re-checks this same predicate, so a mislabeled row fails closed there,
613
727
  // never silently deletes.
614
728
  const now = new Date().toISOString();
615
- const scopes = await host.admin.listScopes(options.actor);
729
+ const scopes = await listScopes();
616
730
  const expired = scopes.filter((s) => (s.forkedFrom !== null || s.kind === 'preview') && s.expiresAt !== null && s.expiresAt <= now);
617
731
  const reap = options.deleteSnapshotFn ??
618
732
  ((tenantId, scopeId) => host.deleteSnapshot(options.actor, tenantId, scopeId));
@@ -635,7 +749,7 @@ export async function runPlatformSweep(host, options) {
635
749
  // status below the seam, so a row that changed underneath fails closed there.
636
750
  if (options.reapArchivedAfterDays !== undefined && options.reapArchivedAfterDays >= 0) {
637
751
  const cutoff = new Date(Date.now() - options.reapArchivedAfterDays * 86_400_000).toISOString();
638
- const archived = await host.admin.listScopes(options.actor, { status: ['archived'] });
752
+ const archived = await listScopes({ status: ['archived'] });
639
753
  const due = archived.filter((s) => s.archivedAt !== null && s.archivedAt <= cutoff);
640
754
  const reap = options.reapScopeFn ??
641
755
  // Retention reap is a deliberate policy on already-archived scopes: force past the
@@ -672,6 +786,11 @@ export async function runPlatformSweep(host, options) {
672
786
  // Co-located default: reap every one of the tenant's scopes through the scope
673
787
  // seam (archive-if-needed first — reapScope only accepts `archived`), then clear
674
788
  // the tenant's directory rows. A scope already `reaped` is skipped.
789
+ //
790
+ // STRICT, unlike every other read in this pass: `reapTenant` below clears the tenant's
791
+ // directory, so a scope this loop stepped over would never have its storage reaped and
792
+ // would lose the rows that find it. A row that does not parse fails this tenant's reap
793
+ // instead, into `errors` as `reap-tenant`, and the tenant waits for the row to be fixed.
675
794
  const scopes = await host.admin.listScopes(options.actor, { tenantId });
676
795
  for (const s of scopes) {
677
796
  if (s.status === 'reaped')
@@ -694,7 +813,7 @@ export async function runPlatformSweep(host, options) {
694
813
  });
695
814
  }
696
815
  const connections = await host.admin.listConnections(options.actor, {});
697
- const landings = options.connectionLandings ? new LandingPass(host, options.actor, options.connectionLandings, concurrency) : null;
816
+ const landings = options.connectionLandings ? new LandingPass(host, options.actor, options.connectionLandings, concurrency, listScopes) : null;
698
817
  await mapBounded(connections, concurrency, async (c) => {
699
818
  // #1232: the connection's durable sweep record. A connection row carries no
700
819
  // scope (a connection spans scopes by construction) and no version.
@@ -734,7 +853,8 @@ export async function runPlatformSweep(host, options) {
734
853
  report.connectionLandings.failed += landed.failed.length;
735
854
  }
736
855
  try {
737
- const result = await sweeper(host, c.id, { fetch: options.fetch });
856
+ // #2498: a sized stream body the connector sends reaches the injected fetch converted.
857
+ const result = await sweeper(host, c.id, { fetch: fetchLikeOf(options.fetch) });
738
858
  const answer = options.connectionLandings?.binders[c.provider] ? connectorSweepAnswerOf(result) : null;
739
859
  report.connectionsSwept += 1;
740
860
  // #2316: a resolved sweep is not a landed one. A scope the connector could not land in, or a
@@ -769,7 +889,7 @@ export async function runPlatformSweep(host, options) {
769
889
  // own outbox. An import is never re-emitted into the consumer's outbox, so the two phases
770
890
  // never ship the same event twice.
771
891
  if (options.crossVertical) {
772
- report.crossVertical = await sweepCrossVertical(host, options, options.crossVertical, failedThisPass, report);
892
+ report.crossVertical = await sweepCrossVertical(host, options, options.crossVertical, failedThisPass, report, listScopes);
773
893
  }
774
894
  // -- drain each scope's domain events to Tier 2 (#1334, master-plan §5.3) ---
775
895
  // Before the access-log drain below, which is deliberately last: this phase
@@ -785,7 +905,7 @@ export async function runPlatformSweep(host, options) {
785
905
  // fleet-wide fault.
786
906
  const configured = options.eventDrainBatch ?? EVENT_DRAIN_BATCH;
787
907
  const budget = Number.isFinite(configured) && configured >= 1 ? Math.floor(configured) : EVENT_DRAIN_BATCH;
788
- const drainable = await host.admin.listScopes(options.actor, { status: 'active' });
908
+ const drainable = await listScopes({ status: 'active' });
789
909
  await mapBounded(drainable, concurrency, async (s) => {
790
910
  try {
791
911
  const { shipped, skipped } = await drainScopeEvents(host, options, sink, {
@@ -806,6 +926,10 @@ export async function runPlatformSweep(host, options) {
806
926
  report.eventDrain.incomplete += 1;
807
927
  }
808
928
  catch (err) {
929
+ if (isNotHeld(err)) {
930
+ (report.eventDrain.notHeld ??= []).push({ tenantId: s.tenantId, scopeId: s.id });
931
+ return;
932
+ }
809
933
  // One scope's failure never sinks the sweep, and never stamps: a throw
810
934
  // anywhere in read→ship→mark leaves its events exactly where they were,
811
935
  // to be taken again next tick.
@@ -836,6 +960,26 @@ export async function runPlatformSweep(host, options) {
836
960
  report.errors.push({ kind: 'findings', id: 'findings', error: message(err) });
837
961
  }
838
962
  }
963
+ // -- the daily meter snapshot (#2501) -----------------------------------------
964
+ // After all the scheduled work, so the day's reading is the directory as this pass leaves it,
965
+ // reaps included, and just before the request rollup: the snapshot is one directory transaction,
966
+ // and a slow Analytics Engine answer must not push its reading later in the day. Its one read of
967
+ // the day records is shipped by the access-log drain below, in the same pass.
968
+ if (options.meterSnapshot &&
969
+ typeof host.admin.recordMeterDay === 'function' &&
970
+ typeof host.admin.listMeterDayTakes === 'function') {
971
+ report.meterSnapshot = await sweepMeterSnapshot(host.admin, options.actor, options.meterSnapshot, report);
972
+ }
973
+ // -- the daily request rollup (#2501) ----------------------------------------
974
+ // After all the scheduled work, deliberately: it waits on Analytics Engine, an outside service,
975
+ // and nothing in the pass depends on it, so a slow answer must not hold back the work that has
976
+ // to happen every pass. Each read is bounded (`RequestRollupSweepOptions.readTimeoutMs`), so the
977
+ // most it can delay is the access-log drain below, which then ships this phase's own reads too.
978
+ if (options.requestRollup &&
979
+ typeof host.admin.recordRequestDay === 'function' &&
980
+ typeof host.admin.listRequestRollupDays === 'function') {
981
+ report.requestRollup = await sweepRequestRollup(host.admin, options.actor, options.requestRollup, report);
982
+ }
839
983
  // -- drain the staff access log to Tier 2, then prune it (K-24, §4.4) --------
840
984
  // LAST, deliberately: every phase above reads the directory through the audited
841
985
  // seam, so each one writes access rows. Running the drain last means a pass ships
@@ -857,7 +1001,7 @@ export async function runPlatformSweep(host, options) {
857
1001
  * One pass over every cross-vertical edge (#1705). Bounded like the event drain: one batch per
858
1002
  * edge per pass, reported rather than looped, so one busy producer cannot starve the rest.
859
1003
  */
860
- async function sweepCrossVertical(host, options, cv, failedThisPass, report,
1004
+ async function sweepCrossVertical(host, options, cv, failedThisPass, report, listScopes,
861
1005
  /** Run only this producer's outgoing edges (the router kick). Absent: every edge. */
862
1006
  only) {
863
1007
  const out = {
@@ -889,7 +1033,7 @@ only) {
889
1033
  //
890
1034
  // A producer-scoped run reads only that tenant's scopes: both ends of an edge are in one
891
1035
  // 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);
1036
+ const scopes = (await listScopes({ status: 'active', ...(only ? { tenantId: only.tenantId } : {}) })).filter((s) => isPrimaryScope(s) && s.vertical !== null);
893
1037
  // The kick names a scope, and the edges run are the ones whose producer RESOLVES to it. A
894
1038
  // fork, a preview, a second install or a scope of another tenant is not a producer, so a
895
1039
  // kick naming one runs nothing (the sweep would resolve the same way).
@@ -1044,7 +1188,8 @@ only) {
1044
1188
  */
1045
1189
  export async function runCrossVerticalFrom(host, options, producer) {
1046
1190
  const report = { errors: [] };
1047
- const crossVertical = await sweepCrossVertical(host, options, options.crossVertical, new Set(), report, producer);
1191
+ const listScopes = scopeReaderFor(host.admin, options.actor, report);
1192
+ const crossVertical = await sweepCrossVertical(host, options, options.crossVertical, new Set(), report, listScopes, producer);
1048
1193
  return { crossVertical, errors: report.errors };
1049
1194
  }
1050
1195
  /**
@@ -1590,7 +1735,7 @@ const cmp = (x, y) => (x < y ? -1 : x > y ? 1 : 0);
1590
1735
  * in one directory call, then prune past retention. A failed read is an error entry; its
1591
1736
  * scope keeps its last sample and waits a day like any other.
1592
1737
  */
1593
- async function sweepStorageGauge(admin, options, gauge, reachedThisPass, report) {
1738
+ async function sweepStorageGauge(admin, options, gauge, reachedThisPass, report, listScopes) {
1594
1739
  const out = {
1595
1740
  reached: reachedThisPass.size,
1596
1741
  resting: 0,
@@ -1610,7 +1755,7 @@ async function sweepStorageGauge(admin, options, gauge, reachedThisPass, report)
1610
1755
  if (batch > 0) {
1611
1756
  // Not one archived before it ever held data (`holdsStore`): reading it would create the
1612
1757
  // 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);
1758
+ const resting = (await listScopes({ status: STORED_SCOPE_STATUSES.filter((s) => s !== 'active') })).filter(holdsStore);
1614
1759
  out.resting = resting.length;
1615
1760
  const candidates = [...reachedThisPass.values(), ...resting.filter((s) => !reachedThisPass.has(s.id))];
1616
1761
  const tried = new Map((await admin.listScopeStorageAttempts(options.actor)).map((a) => [a.scopeId, a.attemptedAt]));
@@ -1639,33 +1784,48 @@ async function sweepStorageGauge(admin, options, gauge, reachedThisPass, report)
1639
1784
  }
1640
1785
  if (!Number.isInteger(bytes) || bytes < 0)
1641
1786
  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) {
1787
+ // The store read and the attachment read are each their own: a failure of one must not
1788
+ // throw away the database size just read, nor the other's reading, nor replace what the
1789
+ // day already holds of it. A read the deployment cannot make is not a failure and not a
1790
+ // size of 0: what it would measure is left as it is. Neither waits for the other, so a
1791
+ // slow scope holds its slot for one deadline, not two.
1792
+ const beside = {};
1793
+ const timeoutMs = gauge.storeReadTimeoutMs ?? STORE_READ_TIMEOUT_MS;
1794
+ /** One read beside the database: its failure message, or nothing. */
1795
+ const readBeside = async (read, label, keep) => {
1796
+ if (!read)
1797
+ return undefined;
1646
1798
  try {
1647
- stores = await withinDeadline(gauge.readStores(s), gauge.storeReadTimeoutMs ?? STORE_READ_TIMEOUT_MS, `scope store read of ${s.id}`);
1799
+ const value = await withinDeadline(read.call(gauge, s), timeoutMs, `${label} of ${s.id}`);
1800
+ if (value !== null)
1801
+ keep(value);
1648
1802
  }
1649
1803
  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;
1804
+ if (!isUnsupported(err))
1805
+ return message(err);
1806
+ out.unsupported += 1;
1661
1807
  }
1808
+ return undefined;
1809
+ };
1810
+ // Listed in a fixed order, so a scope failing both always reports them the same way.
1811
+ const failures = (await Promise.all([
1812
+ readBeside(gauge.readStores, 'scope store read', (stores) => {
1813
+ beside.stores = stores;
1814
+ }),
1815
+ readBeside(gauge.readAttachments, 'attachment read', (attachments) => {
1816
+ if (!Number.isInteger(attachments) || attachments < 0)
1817
+ throw new Error(`not a size: ${String(attachments)}`);
1818
+ beside.attachments = attachments;
1819
+ }),
1820
+ ])).filter((failure) => failure !== undefined);
1821
+ if (failures.length > 0) {
1822
+ const error = failures.join('; ');
1823
+ out.failed += 1;
1824
+ attempts.push({ ...attempt, bytes, ...beside, error });
1825
+ report.errors.push({ kind: 'storage', id: s.id, error });
1826
+ return;
1662
1827
  }
1663
- if (stores === null) {
1664
- attempts.push({ ...attempt, bytes });
1665
- }
1666
- else {
1667
- attempts.push({ ...attempt, bytes, ...(stores ? { stores } : {}) });
1668
- }
1828
+ attempts.push({ ...attempt, bytes, ...beside });
1669
1829
  out.read += 1;
1670
1830
  }
1671
1831
  catch (err) {
@@ -1691,6 +1851,189 @@ async function sweepStorageGauge(admin, options, gauge, reachedThisPass, report)
1691
1851
  }
1692
1852
  return out;
1693
1853
  }
1854
+ const NO_ATTEMPT = { error: null, since: null, failures: 0 };
1855
+ /** The newest of a ledger's attempts (#2501), and the run of identical failures it belongs to when it failed. */
1856
+ function previousAttempt(attempts) {
1857
+ let newest;
1858
+ for (const a of attempts)
1859
+ if (newest === undefined || a.attemptedAt > newest.attemptedAt)
1860
+ newest = a;
1861
+ return newest ? { error: newest.error, since: newest.failingSince ?? newest.attemptedAt, failures: newest.failingCount ?? 1 } : NO_ATTEMPT;
1862
+ }
1863
+ /**
1864
+ * A failure's run (#2501): the previous attempt's run carried on when this failure repeats its error,
1865
+ * a new run begun `at` otherwise. A repeated failure is a standing one, which reached the failure
1866
+ * digest when its run began; the run keeps its start and length on the ledger and in the report,
1867
+ * where the pass's log line shows them.
1868
+ */
1869
+ function extendRun(previous, error, at) {
1870
+ const repeated = error === previous.error;
1871
+ return {
1872
+ repeated,
1873
+ run: { error, since: repeated && previous.since ? previous.since : at, failures: repeated ? previous.failures + 1 : 1 },
1874
+ };
1875
+ }
1876
+ /**
1877
+ * One pass of the request-rollup phase (#2501): read the oldest due days one at a time, record
1878
+ * each day's counts or its failure, then prune past retention. A failed day writes nothing of the
1879
+ * day, waits `retryMs` like any other attempt, and ends the pass's reads: the days after it stay
1880
+ * due. A failure that repeats the previous attempt's is a standing one (a credential, a dataset
1881
+ * name, an outage), reported when its streak began and not again until a read succeeds.
1882
+ */
1883
+ async function sweepRequestRollup(admin, actor, rollup, report) {
1884
+ const out = {
1885
+ due: 0,
1886
+ deferred: 0,
1887
+ read: 0,
1888
+ failed: 0,
1889
+ repeated: 0,
1890
+ streak: null,
1891
+ saturated: 0,
1892
+ saturatedDays: [],
1893
+ recorded: 0,
1894
+ pruned: 0,
1895
+ };
1896
+ const batch = rollup.batch ?? REQUEST_ROLLUP_DAYS_PER_PASS;
1897
+ const months = rollup.retentionMonths === undefined ? BILLING_ROLLUP_RETENTION_MONTHS : rollup.retentionMonths;
1898
+ const now = rollup.now ?? (() => new Date().toISOString());
1899
+ try {
1900
+ const nowMs = Date.parse(now());
1901
+ if (batch > 0) {
1902
+ const reads = await admin.listRequestRollupDays(actor);
1903
+ // A saturated day is a state of that day, not a failure of the read, so it belongs to no run.
1904
+ let previous = previousAttempt(reads.filter((r) => !r.saturated));
1905
+ const due = requestRollupDueDays({
1906
+ nowMs,
1907
+ reads,
1908
+ lookbackDays: rollup.lookbackDays ?? REQUEST_ROLLUP_LOOKBACK_DAYS,
1909
+ settleMs: rollup.settleMs ?? REQUEST_ROLLUP_SETTLE_MS,
1910
+ retryMs: rollup.retryMs ?? REQUEST_ROLLUP_RETRY_MS,
1911
+ horizon: billingRollupHorizon(nowMs, months),
1912
+ });
1913
+ const take = due.slice(0, batch);
1914
+ out.due = due.length;
1915
+ out.deferred = due.length - take.length;
1916
+ // One day at a time: each is one Analytics Engine query, and the account's query budget
1917
+ // is shared with every observability read.
1918
+ const saturated = [];
1919
+ for (const day of take) {
1920
+ try {
1921
+ const counts = await withinDeadline(rollup.read(day), rollup.readTimeoutMs ?? REQUEST_ROLLUP_READ_TIMEOUT_MS, 'the request rollup read');
1922
+ out.recorded += (await admin.recordRequestDay(actor, { day, attemptedAt: now(), counts })).recorded;
1923
+ out.read += 1;
1924
+ // A read that succeeds ends any run, so a failure later in this pass starts a new one.
1925
+ previous = NO_ATTEMPT;
1926
+ }
1927
+ catch (err) {
1928
+ const error = message(err);
1929
+ const attemptedAt = now();
1930
+ if (isRequestRollupSaturated(err)) {
1931
+ // A standing state of this day, not a failed read: kept visible on the day's record,
1932
+ // never read again, and no reason to stop, so the days after it are still rolled up.
1933
+ out.saturated += 1;
1934
+ saturated.push(day);
1935
+ await admin.recordRequestDay(actor, { day, attemptedAt, error, saturated: true }).catch(() => undefined);
1936
+ continue;
1937
+ }
1938
+ out.failed += 1;
1939
+ const { run, repeated } = extendRun(previous, error, attemptedAt);
1940
+ out.streak = run;
1941
+ if (repeated)
1942
+ out.repeated += 1;
1943
+ else
1944
+ report.errors.push({ kind: 'request-rollup', id: day, error });
1945
+ // The failure is the day's attempt, so the day waits `retryMs` rather than taking a slot
1946
+ // on every pass. If even this write fails, the day is simply due again next pass.
1947
+ await admin
1948
+ .recordRequestDay(actor, { day, attemptedAt, error, failingSince: run.since, failingCount: run.failures })
1949
+ .catch(() => undefined);
1950
+ // The next day would most likely fail the same way, and each read is a fleet-wide query
1951
+ // against the account's shared budget: one failure ends the pass's reads.
1952
+ break;
1953
+ }
1954
+ }
1955
+ // No read failed this pass and none succeeded (a success resets `previous`): a run the ledger
1956
+ // holds still stands, though this pass had nothing due to retry. It is reported as it is,
1957
+ // with no attempt added and nothing pushed to the digest, which mailed it when it began.
1958
+ if (out.streak === null && previous.error !== null && previous.since !== null) {
1959
+ out.streak = { error: previous.error, since: previous.since, failures: previous.failures };
1960
+ }
1961
+ // Each saturated day reaches the digest once, on the pass that found it: it is not read again.
1962
+ out.saturatedDays = saturated;
1963
+ if (saturated.length > 0) {
1964
+ report.errors.push({
1965
+ kind: 'request-rollup',
1966
+ id: 'saturated',
1967
+ error: `${saturated.length} day(s) hold more groups than one read answers and are kept unread: ${saturated.join(', ')}`,
1968
+ });
1969
+ }
1970
+ }
1971
+ if (typeof admin.pruneRequestDays === 'function') {
1972
+ out.pruned = await admin.pruneRequestDays(actor, rollup.pruneBatch ?? REQUEST_ROLLUP_PRUNE_BATCH, months);
1973
+ }
1974
+ }
1975
+ catch (err) {
1976
+ // A directory read or the prune failed: whatever days were recorded stay recorded, and the
1977
+ // next pass finds the rest due.
1978
+ report.errors.push({ kind: 'request-rollup', id: 'directory', error: message(err) });
1979
+ }
1980
+ return out;
1981
+ }
1982
+ const DAY_MS = 86_400_000;
1983
+ /**
1984
+ * One pass of the meter-snapshot phase (#2501): take the snapshot of the UTC day the pass runs in,
1985
+ * unless the day already has one, then prune past retention. A failed attempt writes nothing of the
1986
+ * day and is recorded, and the next pass of the day tries again. A failure that repeats the attempt
1987
+ * before it, across days too, is a standing one: reported when its run began, and not again until
1988
+ * a snapshot is taken.
1989
+ */
1990
+ async function sweepMeterSnapshot(admin, actor, snapshot, report) {
1991
+ const out = { day: null, taken: false, tenants: 0, failed: 0, repeated: 0, streak: null, pruned: 0 };
1992
+ const months = snapshot.retentionMonths === undefined ? BILLING_ROLLUP_RETENTION_MONTHS : snapshot.retentionMonths;
1993
+ const now = snapshot.now ?? (() => new Date().toISOString());
1994
+ try {
1995
+ const at = new Date(now()).toISOString();
1996
+ const day = at.slice(0, 10);
1997
+ out.day = day;
1998
+ // The attempt before this pass is today's, or yesterday's when today has none: one a day at the
1999
+ // least while the phase runs, so two days hold it.
2000
+ const takes = await admin.listMeterDayTakes(actor, { since: utcDayOf(Date.parse(at) - DAY_MS), until: day });
2001
+ // A day already taken is not tried again, so a later pass of the day can never report a failure
2002
+ // about a snapshot that stands. The directory checks again inside its own transaction, which is
2003
+ // what settles two passes racing for the same day.
2004
+ if (!takes.some((t) => t.day === day && t.takenAt !== null)) {
2005
+ try {
2006
+ const taken = await withinDeadline(admin.recordMeterDay(actor, { at }), snapshot.timeoutMs ?? METER_SNAPSHOT_TIMEOUT_MS, 'the meter snapshot');
2007
+ out.taken = taken.taken;
2008
+ out.tenants = taken.tenants;
2009
+ }
2010
+ catch (err) {
2011
+ const error = message(err);
2012
+ out.failed = 1;
2013
+ const { run, repeated } = extendRun(previousAttempt(takes), error, at);
2014
+ out.streak = run;
2015
+ if (repeated)
2016
+ out.repeated = 1;
2017
+ else
2018
+ report.errors.push({ kind: 'meter-snapshot', id: day, error });
2019
+ // The attempt's own instant, so its record lands on its day even when the deadline ran past
2020
+ // midnight. If even this write fails, the next pass simply tries again.
2021
+ await admin
2022
+ .recordMeterDay(actor, { at, error, failingSince: run.since, failingCount: run.failures })
2023
+ .catch(() => undefined);
2024
+ }
2025
+ }
2026
+ if (typeof admin.pruneMeterDays === 'function') {
2027
+ out.pruned = await admin.pruneMeterDays(actor, snapshot.pruneBatch ?? METER_DAYS_PRUNE_BATCH, months);
2028
+ }
2029
+ }
2030
+ catch (err) {
2031
+ // A directory read or the prune failed: a snapshot already taken stays taken, and the next pass
2032
+ // tries the day again if it was not.
2033
+ report.errors.push({ kind: 'meter-snapshot', id: 'directory', error: message(err) });
2034
+ }
2035
+ return out;
2036
+ }
1694
2037
  const pushCapped = (bucket, id) => {
1695
2038
  bucket.count += 1;
1696
2039
  if (bucket.scopeIds.length < ORPHAN_ATTACHMENT_REPORTED_IDS)