@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.
- package/dist/billing-retention.d.ts +46 -0
- package/dist/billing-retention.d.ts.map +1 -0
- package/dist/billing-retention.js +78 -0
- package/dist/billing-retention.js.map +1 -0
- package/dist/byte-stream.d.ts +142 -0
- package/dist/byte-stream.d.ts.map +1 -0
- package/dist/byte-stream.js +274 -0
- package/dist/byte-stream.js.map +1 -0
- package/dist/connection-landings.d.ts +15 -3
- package/dist/connection-landings.d.ts.map +1 -1
- package/dist/connection-landings.js +10 -2
- package/dist/connection-landings.js.map +1 -1
- package/dist/deadline-fetch.d.ts +34 -0
- package/dist/deadline-fetch.d.ts.map +1 -0
- package/dist/deadline-fetch.js +231 -0
- package/dist/deadline-fetch.js.map +1 -0
- package/dist/deployment-wire-auth.d.ts +125 -0
- package/dist/deployment-wire-auth.d.ts.map +1 -0
- package/dist/deployment-wire-auth.js +161 -0
- package/dist/deployment-wire-auth.js.map +1 -0
- package/dist/entity-trash.d.ts +65 -8
- package/dist/entity-trash.d.ts.map +1 -1
- package/dist/entity-trash.js +80 -12
- package/dist/entity-trash.js.map +1 -1
- package/dist/export-break.d.ts +6 -0
- package/dist/export-break.d.ts.map +1 -1
- package/dist/export-break.js +11 -0
- package/dist/export-break.js.map +1 -1
- package/dist/index.d.ts +19 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -8
- package/dist/index.js.map +1 -1
- package/dist/meter-days.d.ts +107 -0
- package/dist/meter-days.d.ts.map +1 -0
- package/dist/meter-days.js +230 -0
- package/dist/meter-days.js.map +1 -0
- package/dist/meters.d.ts +19 -1
- package/dist/meters.d.ts.map +1 -1
- package/dist/meters.js +0 -0
- package/dist/meters.js.map +1 -1
- package/dist/ops-failure-reference.d.ts +43 -0
- package/dist/ops-failure-reference.d.ts.map +1 -0
- package/dist/ops-failure-reference.js +42 -0
- package/dist/ops-failure-reference.js.map +1 -0
- package/dist/platform-request-cap.d.ts +9 -0
- package/dist/platform-request-cap.d.ts.map +1 -0
- package/dist/platform-request-cap.js +42 -0
- package/dist/platform-request-cap.js.map +1 -0
- package/dist/platform-sweep.d.ts +187 -7
- package/dist/platform-sweep.d.ts.map +1 -1
- package/dist/platform-sweep.js +388 -45
- package/dist/platform-sweep.js.map +1 -1
- package/dist/promote-review.d.ts +6 -0
- package/dist/promote-review.d.ts.map +1 -1
- package/dist/promote-review.js +9 -0
- package/dist/promote-review.js.map +1 -1
- package/dist/request-rollup.d.ts +161 -0
- package/dist/request-rollup.d.ts.map +1 -0
- package/dist/request-rollup.js +273 -0
- package/dist/request-rollup.js.map +1 -0
- package/dist/reserved-roles.d.ts +192 -4
- package/dist/reserved-roles.d.ts.map +1 -1
- package/dist/reserved-roles.js +314 -2
- package/dist/reserved-roles.js.map +1 -1
- package/dist/schedule-state.d.ts +5 -0
- package/dist/schedule-state.d.ts.map +1 -1
- package/dist/scope-host.d.ts +317 -15
- package/dist/scope-host.d.ts.map +1 -1
- package/dist/scope-host.js +93 -2
- package/dist/scope-host.js.map +1 -1
- package/dist/scope-lifecycle.d.ts +7 -0
- package/dist/scope-lifecycle.d.ts.map +1 -1
- package/dist/scope-lifecycle.js +7 -0
- package/dist/scope-lifecycle.js.map +1 -1
- package/dist/scope-record.d.ts +16 -5
- package/dist/scope-record.d.ts.map +1 -1
- package/dist/scope-record.js +72 -7
- package/dist/scope-record.js.map +1 -1
- package/dist/spine-restore.d.ts +7 -3
- package/dist/spine-restore.d.ts.map +1 -1
- package/dist/spine-restore.js +14 -3
- package/dist/spine-restore.js.map +1 -1
- package/dist/storage-gauge.d.ts +47 -13
- package/dist/storage-gauge.d.ts.map +1 -1
- package/dist/storage-gauge.js +84 -27
- package/dist/storage-gauge.js.map +1 -1
- package/dist/subject-redaction.d.ts +9 -3
- package/dist/subject-redaction.d.ts.map +1 -1
- package/dist/subject-redaction.js +23 -12
- package/dist/subject-redaction.js.map +1 -1
- package/dist/vertical-owners.d.ts +69 -0
- package/dist/vertical-owners.d.ts.map +1 -0
- package/dist/vertical-owners.js +108 -0
- package/dist/vertical-owners.js.map +1 -0
- package/package.json +2 -2
package/dist/platform-sweep.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1643
|
-
// database size just read,
|
|
1644
|
-
|
|
1645
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1652
|
-
|
|
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
|
-
|
|
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)
|