@substrat-run/kernel 0.147.0 → 0.149.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/attachment-sweep.d.ts +208 -0
- package/dist/attachment-sweep.d.ts.map +1 -0
- package/dist/attachment-sweep.js +318 -0
- package/dist/attachment-sweep.js.map +1 -0
- package/dist/audit-outcome.d.ts +1 -1
- package/dist/audit-outcome.d.ts.map +1 -1
- package/dist/audit-outcome.js +4 -2
- package/dist/audit-outcome.js.map +1 -1
- package/dist/export-break.d.ts +137 -0
- package/dist/export-break.d.ts.map +1 -0
- package/dist/export-break.js +219 -0
- package/dist/export-break.js.map +1 -0
- package/dist/index.d.ts +24 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -7
- package/dist/index.js.map +1 -1
- package/dist/install-receipts.d.ts +116 -0
- package/dist/install-receipts.d.ts.map +1 -0
- package/dist/install-receipts.js +162 -0
- package/dist/install-receipts.js.map +1 -0
- package/dist/invocation-line.d.ts +85 -0
- package/dist/invocation-line.d.ts.map +1 -1
- package/dist/invocation-line.js +43 -0
- package/dist/invocation-line.js.map +1 -1
- package/dist/map-bounded.d.ts +8 -0
- package/dist/map-bounded.d.ts.map +1 -0
- package/dist/map-bounded.js +16 -0
- package/dist/map-bounded.js.map +1 -0
- package/dist/migration-digest.d.ts +56 -0
- package/dist/migration-digest.d.ts.map +1 -1
- package/dist/migration-digest.js +92 -0
- package/dist/migration-digest.js.map +1 -1
- package/dist/migration-fence.d.ts +14 -0
- package/dist/migration-fence.d.ts.map +1 -0
- package/dist/migration-fence.js +515 -0
- package/dist/migration-fence.js.map +1 -0
- package/dist/migration-replay.d.ts +52 -0
- package/dist/migration-replay.d.ts.map +1 -0
- package/dist/migration-replay.js +140 -0
- package/dist/migration-replay.js.map +1 -0
- package/dist/module-migrations.d.ts +3 -3
- package/dist/module-migrations.js +3 -3
- package/dist/peer.d.ts +32 -2
- package/dist/peer.d.ts.map +1 -1
- package/dist/peer.js +100 -4
- package/dist/peer.js.map +1 -1
- package/dist/permission-eval.d.ts +8 -0
- package/dist/permission-eval.d.ts.map +1 -1
- package/dist/permission-eval.js +14 -0
- package/dist/permission-eval.js.map +1 -1
- package/dist/platform-sweep.d.ts +114 -361
- package/dist/platform-sweep.d.ts.map +1 -1
- package/dist/platform-sweep.js +134 -561
- package/dist/platform-sweep.js.map +1 -1
- package/dist/promote-review.d.ts +125 -0
- package/dist/promote-review.d.ts.map +1 -0
- package/dist/promote-review.js +181 -0
- package/dist/promote-review.js.map +1 -0
- package/dist/schedule-state.d.ts +91 -0
- package/dist/schedule-state.d.ts.map +1 -0
- package/dist/schedule-state.js +118 -0
- package/dist/schedule-state.js.map +1 -0
- package/dist/scope-copy-ledger.d.ts +29 -0
- package/dist/scope-copy-ledger.d.ts.map +1 -1
- package/dist/scope-copy-ledger.js +23 -0
- package/dist/scope-copy-ledger.js.map +1 -1
- package/dist/scope-copy.d.ts.map +1 -1
- package/dist/scope-copy.js +3 -2
- package/dist/scope-copy.js.map +1 -1
- package/dist/scope-host.d.ts +303 -11
- package/dist/scope-host.d.ts.map +1 -1
- package/dist/scope-host.js.map +1 -1
- package/dist/scope-identity.d.ts +68 -0
- package/dist/scope-identity.d.ts.map +1 -0
- package/dist/scope-identity.js +75 -0
- package/dist/scope-identity.js.map +1 -0
- package/dist/scope-store.d.ts +9 -6
- package/dist/scope-store.d.ts.map +1 -1
- package/dist/scope-store.js.map +1 -1
- package/dist/spine-restore.d.ts +2 -0
- package/dist/spine-restore.d.ts.map +1 -1
- package/dist/spine-restore.js +6 -1
- package/dist/spine-restore.js.map +1 -1
- package/dist/store-driver-releases.d.ts +306 -0
- package/dist/store-driver-releases.d.ts.map +1 -0
- package/dist/store-driver-releases.js +581 -0
- package/dist/store-driver-releases.js.map +1 -0
- package/dist/table-ownership.d.ts +5 -2
- package/dist/table-ownership.d.ts.map +1 -1
- package/dist/table-ownership.js +19 -10
- package/dist/table-ownership.js.map +1 -1
- package/dist/testing.d.ts +2 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +2 -0
- package/dist/testing.js.map +1 -1
- package/dist/upload-link.d.ts +142 -0
- package/dist/upload-link.d.ts.map +1 -0
- package/dist/upload-link.js +441 -0
- package/dist/upload-link.js.map +1 -0
- package/dist/version-migrations.d.ts +63 -1
- package/dist/version-migrations.d.ts.map +1 -1
- package/dist/version-migrations.js +174 -8
- package/dist/version-migrations.js.map +1 -1
- package/dist/vertical-scope-stores.d.ts +43 -0
- package/dist/vertical-scope-stores.d.ts.map +1 -0
- package/dist/vertical-scope-stores.js +195 -0
- package/dist/vertical-scope-stores.js.map +1 -0
- package/package.json +2 -2
package/dist/platform-sweep.js
CHANGED
|
@@ -1,197 +1,20 @@
|
|
|
1
|
-
import { drainedEvent,
|
|
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
3
|
import { assertRowLimit, backoffAt, TELEMETRY_PRUNE_BATCH } from './scope-host.js';
|
|
4
4
|
import { MIGRATION_FLAG_THRESHOLD, migrationFleet, migrationProgress, scopeMigrationState } from './migration-progress.js';
|
|
5
5
|
import { UNDRAINED_SKIPPED_IDS } from './outbox-event.js';
|
|
6
6
|
import { resolveVerticalInstanceFrom } from './peer.js';
|
|
7
|
+
import { isPrimaryScope, runningVersionOf, servingPointersFor } from './scope-identity.js';
|
|
8
|
+
import { mapBounded, message } from './map-bounded.js';
|
|
9
|
+
import { runningImportsOf } from './export-break.js';
|
|
7
10
|
/** How many access rows one pass ships, and one pass prunes, by default. */
|
|
8
11
|
const ACCESS_LOG_BATCH = 500;
|
|
9
12
|
/** How many events one pass drains PER SCOPE by default. */
|
|
10
13
|
const EVENT_DRAIN_BATCH = 200;
|
|
11
|
-
/**
|
|
12
|
-
* The platform sweep's per-scope gating state (#383), as both adapters build it.
|
|
13
|
-
*
|
|
14
|
-
* Shared rather than spelled twice because `lint:spine-ddl` can compare the copies
|
|
15
|
-
* only where they are DDL a `KERNEL_DDL` executes — and this table is also rebuilt
|
|
16
|
-
* by `SCHEDULE_STATE_REBUILD` below, on a store that predates the key. One
|
|
17
|
-
* definition is what keeps the rebuilt shape and the created shape the same shape;
|
|
18
|
-
* the gate then holds each adapter to including it.
|
|
19
|
-
*/
|
|
20
|
-
export const SCHEDULE_STATE_DDL = `
|
|
21
|
-
CREATE TABLE IF NOT EXISTS _substrat_schedule_state (
|
|
22
|
-
-- #1288: WHICH of the two families this row belongs to, named with the same two
|
|
23
|
-
-- words _substrat_sweep_runs already records its entries under:
|
|
24
|
-
-- * 'schedule' -- keyed by the operation (module/verb). last_run_at and
|
|
25
|
-
-- last_status are when that operation RAN here and how it ended.
|
|
26
|
-
-- * 'freshness' -- keyed freshness:<eventType> (#1232). last_run_at and
|
|
27
|
-
-- last_status are when the evaluator last RECORDED a verdict for that event
|
|
28
|
-
-- type and what it was. Nothing ran; the row gates what the sweep records.
|
|
29
|
-
kind TEXT NOT NULL,
|
|
30
|
-
schedule_op TEXT NOT NULL,
|
|
31
|
-
last_run_at TEXT,
|
|
32
|
-
last_status TEXT,
|
|
33
|
-
-- #1525: the invocation this row's last run happened in, or null. Null is a fact
|
|
34
|
-
-- here, not a gap: a 'freshness' row never invokes anything (the evaluator only
|
|
35
|
-
-- records a verdict), a 'schedule' row a pass SKIPPED carries no fresh call to
|
|
36
|
-
-- attribute and keeps whatever it already held, and a row written before this
|
|
37
|
-
-- column existed predates the possibility. Only a due schedule's own invoke
|
|
38
|
-
-- mints one and writes it here, in the same call that stamps it onto whatever
|
|
39
|
-
-- the operation emits — see runDueSchedules in each adapter.
|
|
40
|
-
invocation_id TEXT,
|
|
41
|
-
-- #2096: a purge horizon's lap in progress (entity-trash.ts, PurgeLap) -- where its
|
|
42
|
-
-- next pass resumes in the bin, and how many purges failed earlier in the lap. Only a
|
|
43
|
-
-- 'schedule' row of a purge schedule holds one, and only mid-lap: NULL is the start
|
|
44
|
-
-- of a lap, which is also the honest reading of every row written before the column.
|
|
45
|
-
purge_cursor TEXT,
|
|
46
|
-
-- The key LEADS with kind, and that is the point of #1288 rather than a tidy-up.
|
|
47
|
-
-- Until it did, the two families were told apart by the spelling of one column:
|
|
48
|
-
-- a freshness key could never LOOK like an operation (an event type has passed
|
|
49
|
-
-- contracts' eventType regex -- lowercase ns.verb, no colon, no slash), but the
|
|
50
|
-
-- other direction was convention only, because scheduleSpec.operation is
|
|
51
|
-
-- z.string().min(1). A module declaring a schedule literally named
|
|
52
|
-
-- "freshness:orders.placed" shared the evaluator's row and nothing refused it:
|
|
53
|
-
-- each write clobbered the other's verdict, and the sweep read back whichever
|
|
54
|
-
-- ran last. With kind in the key those are two rows that cannot meet.
|
|
55
|
-
PRIMARY KEY (kind, schedule_op)
|
|
56
|
-
);
|
|
57
|
-
`;
|
|
58
|
-
/**
|
|
59
|
-
* The `kind` a pre-#1288 row belongs to, derived from its `schedule_op` — the backfill's one
|
|
60
|
-
* rule, shared by `SCHEDULE_STATE_REBUILD` below and by a restore of a dump taken before the
|
|
61
|
-
* column (`spineRowsInsert`, #1883), so the two cannot file the same row differently.
|
|
62
|
-
*/
|
|
63
|
-
export const SCHEDULE_STATE_KIND_OF_OP = "CASE WHEN substr(schedule_op, 1, 10) = 'freshness:' THEN 'freshness' ELSE 'schedule' END";
|
|
64
|
-
/**
|
|
65
|
-
* `_substrat_schedule_state`, rebuilt with its #1288 key on a store created before
|
|
66
|
-
* it. Create-copy-drop-rename, because `kind` joins the PRIMARY KEY and SQLite
|
|
67
|
-
* cannot widen a key in place — the same shape the directory's `ensureIdentityKey`
|
|
68
|
-
* uses, and detected the same way (from `sqlite_master.sql`, which DO SQLite serves
|
|
69
|
-
* and `PRAGMA` does not, so both adapters migrate by one strategy).
|
|
70
|
-
*
|
|
71
|
-
* The new table is `SCHEDULE_STATE_DDL` under a temporary name, so the rebuilt shape
|
|
72
|
-
* cannot drift from the created one.
|
|
73
|
-
*
|
|
74
|
-
* The backfill derives `kind` from the `freshness:` prefix because that prefix IS how
|
|
75
|
-
* the two families were told apart until now: every row the evaluator ever wrote
|
|
76
|
-
* carries it, and no operation name in existence does. `substr(...) = 'freshness:'`
|
|
77
|
-
* rather than `LIKE`, which is case-insensitive over ASCII in SQLite and would file a
|
|
78
|
-
* schedule named `FRESHNESS:x` under the evaluator.
|
|
79
|
-
*
|
|
80
|
-
* Keys are copied VERBATIM, prefix included. What changes is which rows can coexist,
|
|
81
|
-
* not what any row says — so a deployment rolled back to code that looks a freshness
|
|
82
|
-
* key up under its old name still finds it.
|
|
83
|
-
*
|
|
84
|
-
* **Run these inside the adapter's transaction API**, which is what both callers do —
|
|
85
|
-
* `db.transaction` on the pure side, `ctx.storage.transactionSync` on the DO side.
|
|
86
|
-
* Un-wrapped, a stop between the CREATE and the DROP leaves the scratch table behind
|
|
87
|
-
* and the NEXT wake dies on `table _substrat_schedule_state_new already exists`, which
|
|
88
|
-
* is a scope that cannot open; a stop between the DROP and the RENAME is worse and
|
|
89
|
-
* quieter, because the next wake's `CREATE TABLE IF NOT EXISTS` puts an EMPTY table
|
|
90
|
-
* of the new shape in place, the detection below then reads it as already migrated,
|
|
91
|
-
* and every copied row stays orphaned in the scratch table. Atomically, neither state
|
|
92
|
-
* is reachable — which is why there is no recovery path here to go with them.
|
|
93
|
-
*
|
|
94
|
-
* The leading `DROP TABLE IF EXISTS` is belt to that braces, not the fix: it costs one
|
|
95
|
-
* statement and makes the rebuild idempotent against a scratch table left by anything
|
|
96
|
-
* below the transaction (a torn copy of the file). A restore cannot carry one in: since
|
|
97
|
-
* #1883 it refuses a spine table the kernel does not build.
|
|
98
|
-
*
|
|
99
|
-
* A restore never reaches this rebuild either: it builds the table from the current DDL and
|
|
100
|
-
* derives `kind` for a legacy dump's rows with `SCHEDULE_STATE_KIND_OF_OP`. This runs only
|
|
101
|
-
* for a store that already holds the old table when it wakes.
|
|
102
|
-
*/
|
|
103
|
-
export const SCHEDULE_STATE_REBUILD = `
|
|
104
|
-
DROP TABLE IF EXISTS _substrat_schedule_state_new;
|
|
105
|
-
${SCHEDULE_STATE_DDL.replace('CREATE TABLE IF NOT EXISTS _substrat_schedule_state', 'CREATE TABLE _substrat_schedule_state_new')}
|
|
106
|
-
-- #1525: invocation_id is not in this column list, deliberately — this rebuild runs
|
|
107
|
-
-- only on a pre-#1288 table, which predates invocation_id too, so there is nothing
|
|
108
|
-
-- to copy. The new table's default (NULL) is the honest answer for every such row.
|
|
109
|
-
INSERT INTO _substrat_schedule_state_new (kind, schedule_op, last_run_at, last_status)
|
|
110
|
-
SELECT ${SCHEDULE_STATE_KIND_OF_OP}, schedule_op, last_run_at, last_status
|
|
111
|
-
FROM _substrat_schedule_state;
|
|
112
|
-
DROP TABLE _substrat_schedule_state;
|
|
113
|
-
ALTER TABLE _substrat_schedule_state_new RENAME TO _substrat_schedule_state;
|
|
114
|
-
`;
|
|
115
|
-
/**
|
|
116
|
-
* Whether a store's `_substrat_schedule_state` already carries the #1288 key, read
|
|
117
|
-
* off the `sql` column of `sqlite_master`. `false` means the rebuild is due.
|
|
118
|
-
*/
|
|
119
|
-
export function scheduleStateHasKind(tableSql) {
|
|
120
|
-
return tableSql.includes('PRIMARY KEY (kind, schedule_op)');
|
|
121
|
-
}
|
|
122
14
|
/** The #1172 phase's per-pass bound when `provisionReconcileBatch` is unset (#1653). */
|
|
123
15
|
export const PROVISION_RECONCILE_BATCH = 50;
|
|
124
16
|
/** How many `unsupported` scope ids one report carries — the count stays exact. */
|
|
125
17
|
export const PROVISION_RECONCILE_REPORTED_IDS = 50;
|
|
126
|
-
/**
|
|
127
|
-
* A scope the platform may treat as the REAL install, rather than a copy of one (#1172,
|
|
128
|
-
* #1653). The directory is the only oracle for this, and it takes BOTH tests, not one.
|
|
129
|
-
*
|
|
130
|
-
* A fork is an archive or a preview of somebody else's data, and re-provisioning one
|
|
131
|
-
* runs the vertical's install-side hook against a copy, minting a second set of whatever
|
|
132
|
-
* it mints — or, since #1656, putting a copy of production on a sweeper that reaps and
|
|
133
|
-
* assigns. But a CLEAN-ROOM preview (#509) is an empty scope with no source to copy, so
|
|
134
|
-
* it carries `kind: 'preview'` and NO `forkedFrom` — the reap sweep keys on `kind` for
|
|
135
|
-
* exactly that reason. Filtering on lineage alone would let those through, which is the
|
|
136
|
-
* one shape of scope where the hook's effects are least wanted.
|
|
137
|
-
*/
|
|
138
|
-
export function isPrimaryScope(scope) {
|
|
139
|
-
return !scope.forkedFrom && scope.kind !== 'preview';
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* `isPrimaryScope` over a raw directory row, in the snake_case both adapters store (#2005) —
|
|
143
|
-
* so a reader holding a row asks the same one rule rather than restating it. No row is not
|
|
144
|
-
* primary: the answer that runs effects with platform authority is the one to be proven.
|
|
145
|
-
*/
|
|
146
|
-
export function isPrimaryScopeRow(row) {
|
|
147
|
-
return row !== undefined && isPrimaryScope({ kind: row.kind ?? '', forkedFrom: row.forked_from });
|
|
148
|
-
}
|
|
149
|
-
/**
|
|
150
|
-
* Why an effect a NON-primary scope asked for was settled without running (#2005). A fork,
|
|
151
|
-
* a snapshot and a preview of either kind are inert: they cause no outbound effects, so
|
|
152
|
-
* whatever they queue — a platform intent, an executor or connector delivery — is journaled
|
|
153
|
-
* as not executed, with this reason, rather than left pending forever or run off a copy.
|
|
154
|
-
* One string for every door, so a reader who has seen it once recognizes it everywhere.
|
|
155
|
-
*/
|
|
156
|
-
export const INERT_SCOPE_REASON = 'not executed: this scope is a preview or a fork, and those cause no outbound effects';
|
|
157
|
-
/**
|
|
158
|
-
* The version whose code actually executes for `scope` (#1653) — which is not always the
|
|
159
|
-
* one it is bound to.
|
|
160
|
-
*
|
|
161
|
-
* A scope on its vertical's stable serving script (`servingRef`, #286) runs whatever that
|
|
162
|
-
* script serves, and a promote re-uploads the script in place: the code changes under
|
|
163
|
-
* the scope at that moment, whatever its version pointer says. For a PRIVATE vertical
|
|
164
|
-
* the promote moves the pointer too, so the two agree. For a LISTED one it does not —
|
|
165
|
-
* the pointer is the tenant's, and moves only on their Update — so an install can run
|
|
166
|
-
* version N+1 with its provision state still at N. Measuring against the running version
|
|
167
|
-
* is what lets the reconcile follow the code instead of the pointer.
|
|
168
|
-
*
|
|
169
|
-
* Falls back to the bound version whenever the serving script cannot be named: a scope
|
|
170
|
-
* with no `servingRef` (legacy per-version dispatch, where the bound version's own
|
|
171
|
-
* script IS what runs), a vertical with nothing served in place yet, or a `servingRef`
|
|
172
|
-
* that is not the vertical's current one. The fallback is exactly the pre-#1653 answer,
|
|
173
|
-
* so an unknown never manufactures a reconcile.
|
|
174
|
-
*/
|
|
175
|
-
export function runningVersionOf(scope, serving) {
|
|
176
|
-
if (scope.servingRef && serving && scope.servingRef === serving.ref)
|
|
177
|
-
return serving.versionId;
|
|
178
|
-
return scope.verticalVersionId;
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* Every vertical's serving pointer, for `runningVersionOf` — one `listVerticals` read, and only
|
|
182
|
-
* when some scope is actually on a serving script, since nothing else can differ from its
|
|
183
|
-
* binding. Shared by the provision reconcile (#1653) and the cross-vertical narrowing (#1705).
|
|
184
|
-
*/
|
|
185
|
-
async function servingPointersFor(admin, actor, scopes) {
|
|
186
|
-
const serving = new Map();
|
|
187
|
-
if (!scopes.some((s) => s.servingRef))
|
|
188
|
-
return serving;
|
|
189
|
-
for (const v of await admin.listVerticals(actor)) {
|
|
190
|
-
if (v.servingRef && v.servingVersionId)
|
|
191
|
-
serving.set(v.slug, { ref: v.servingRef, versionId: v.servingVersionId });
|
|
192
|
-
}
|
|
193
|
-
return serving;
|
|
194
|
-
}
|
|
195
18
|
/**
|
|
196
19
|
* #2185: the slots of a #1172 window owed to installs behind on their retirements alone: a quarter,
|
|
197
20
|
* at least one. A window of one slot cannot be split, so it goes to them on a quarter of the passes.
|
|
@@ -247,6 +70,12 @@ export const STORAGE_SAMPLE_BATCH = 100;
|
|
|
247
70
|
export const STORAGE_SAMPLE_MAX_AGE_MS = 86_400_000;
|
|
248
71
|
/** How long one scope's store read may take before it counts as failed, by default (#2212). */
|
|
249
72
|
export const STORE_READ_TIMEOUT_MS = 15_000;
|
|
73
|
+
/** The orphan-attachment phase's per-pass bound when `batch` is unset (#2314). */
|
|
74
|
+
export const ORPHAN_ATTACHMENT_SWEEP_BATCH = 25;
|
|
75
|
+
/** How recent a scope's dry-run row must be before a pass deletes its bytes, by default (#2314). */
|
|
76
|
+
export const ORPHAN_SWEEP_DRY_RUN_WINDOW_MS = 7 * 86_400_000;
|
|
77
|
+
/** How many scope ids each standing-condition count of the report carries; the count stays exact. */
|
|
78
|
+
const ORPHAN_ATTACHMENT_REPORTED_IDS = 50;
|
|
250
79
|
/**
|
|
251
80
|
* How many consumer scopes one pass of the cross-vertical phase visits, by default (#1705).
|
|
252
81
|
*
|
|
@@ -257,20 +86,6 @@ export const STORE_READ_TIMEOUT_MS = 15_000;
|
|
|
257
86
|
* makes none.
|
|
258
87
|
*/
|
|
259
88
|
export const CROSS_VERTICAL_CONSUMERS_PER_PASS = 100;
|
|
260
|
-
const message = (err) => (err instanceof Error ? err.message : String(err));
|
|
261
|
-
/**
|
|
262
|
-
* Run `fn` over `items` with at most `limit` in flight. `fn` owns its errors —
|
|
263
|
-
* this never rejects, so one unit's failure cannot abort the pass.
|
|
264
|
-
*/
|
|
265
|
-
async function mapBounded(items, limit, fn) {
|
|
266
|
-
let next = 0;
|
|
267
|
-
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
268
|
-
while (next < items.length) {
|
|
269
|
-
await fn(items[next++]);
|
|
270
|
-
}
|
|
271
|
-
});
|
|
272
|
-
await Promise.all(workers);
|
|
273
|
-
}
|
|
274
89
|
/**
|
|
275
90
|
* One pass of the platform's scheduled work: drain every active scope's due
|
|
276
91
|
* executor deliveries, then reconcile every live connection against its provider.
|
|
@@ -308,6 +123,13 @@ function assertSweepBatches(options) {
|
|
|
308
123
|
if (gauge?.batch !== undefined && !(Number.isInteger(gauge.batch) && gauge.batch >= 0)) {
|
|
309
124
|
throw substratError('validation_failed', `storageGauge.batch must be a non-negative integer, got ${String(gauge.batch)}`);
|
|
310
125
|
}
|
|
126
|
+
const orphans = options.orphanAttachments;
|
|
127
|
+
if (orphans?.batch !== undefined && !(Number.isInteger(orphans.batch) && orphans.batch >= 0)) {
|
|
128
|
+
throw substratError('validation_failed', `orphanAttachments.batch must be a non-negative integer, got ${String(orphans.batch)}`);
|
|
129
|
+
}
|
|
130
|
+
if (orphans && orphans.mode !== 'dry-run' && orphans.mode !== 'delete') {
|
|
131
|
+
throw substratError('validation_failed', `orphanAttachments.mode must be 'dry-run' or 'delete', got ${String(orphans.mode)}`);
|
|
132
|
+
}
|
|
311
133
|
}
|
|
312
134
|
export async function runPlatformSweep(host, options) {
|
|
313
135
|
assertSweepBatches(options);
|
|
@@ -330,6 +152,7 @@ export async function runPlatformSweep(host, options) {
|
|
|
330
152
|
telemetry: null,
|
|
331
153
|
findings: null,
|
|
332
154
|
storage: null,
|
|
155
|
+
orphanAttachments: null,
|
|
333
156
|
errors: [],
|
|
334
157
|
};
|
|
335
158
|
// -- migration reconciliation (kernel-design §5.3, #49) ---------------------
|
|
@@ -691,6 +514,13 @@ export async function runPlatformSweep(host, options) {
|
|
|
691
514
|
});
|
|
692
515
|
}
|
|
693
516
|
}
|
|
517
|
+
// -- orphan attachment bytes (#2314) ------------------------------------------
|
|
518
|
+
// After the migration phase and skipping what failed there, like every phase that reaches a
|
|
519
|
+
// vertical. Opt-in, and its own window per pass: it lists a bucket prefix and wakes a scope.
|
|
520
|
+
if (options.orphanAttachments) {
|
|
521
|
+
const scopes = (await host.admin.listScopes(options.actor, { status: 'active' })).filter((s) => isPrimaryScope(s) && !failedThisPass.has(s.id));
|
|
522
|
+
report.orphanAttachments = await sweepOrphanAttachmentWindow(host, options, options.orphanAttachments, scopes, report);
|
|
523
|
+
}
|
|
694
524
|
if (options.gcSnapshots !== false) {
|
|
695
525
|
// Reap expired previews (§3/§9). Two shapes qualify, both throwaway-by-construction:
|
|
696
526
|
// a FORK (`forkedFrom` set — a snapshot of another scope) and a clean-room PREVIEW
|
|
@@ -1124,40 +954,6 @@ export async function runCrossVerticalFrom(host, options, producer) {
|
|
|
1124
954
|
* reported once per pass through `hint.doubt`, and a failure on one version never stops the
|
|
1125
955
|
* others.
|
|
1126
956
|
*/
|
|
1127
|
-
/**
|
|
1128
|
-
* What each scope's RUNNING version imports, from the registry (#1705): one `listVerticals` when
|
|
1129
|
-
* a scope is on a serving script, then one `readImports` per distinct running version, at most
|
|
1130
|
-
* `concurrency` in flight. A failure on one version never stops the others: a read that throws is
|
|
1131
|
-
* reported as `threw` beside the fact, since a caller may treat "the registry could not be asked"
|
|
1132
|
-
* differently from "the manifest does not parse". A scope bound to no version gets no fact. The narrowing and the promote gate
|
|
1133
|
-
* both ask it, so the two cannot come to disagree about which code a scope runs.
|
|
1134
|
-
*/
|
|
1135
|
-
async function runningImportsOf(admin, actor, scopes, readImports, concurrency) {
|
|
1136
|
-
const serving = await servingPointersFor(admin, actor, scopes);
|
|
1137
|
-
const versions = new Map();
|
|
1138
|
-
const keyed = [];
|
|
1139
|
-
for (const s of scopes) {
|
|
1140
|
-
if (!s.vertical)
|
|
1141
|
-
continue;
|
|
1142
|
-
const versionId = runningVersionOf(s, serving.get(s.vertical));
|
|
1143
|
-
const key = `${s.vertical}@${versionId ?? '(no version)'}`;
|
|
1144
|
-
if (versionId && !versions.has(key))
|
|
1145
|
-
versions.set(key, { slug: s.vertical, versionId });
|
|
1146
|
-
keyed.push({ scope: s, key, versionId });
|
|
1147
|
-
}
|
|
1148
|
-
const facts = new Map();
|
|
1149
|
-
const threw = new Map();
|
|
1150
|
-
await mapBounded([...versions], concurrency, async ([key, v]) => {
|
|
1151
|
-
try {
|
|
1152
|
-
facts.set(key, await readImports(v.slug, v.versionId));
|
|
1153
|
-
}
|
|
1154
|
-
catch (err) {
|
|
1155
|
-
facts.set(key, { kind: 'unreadable', reason: message(err) });
|
|
1156
|
-
threw.set(key, message(err));
|
|
1157
|
-
}
|
|
1158
|
-
});
|
|
1159
|
-
return keyed.map((k) => ({ ...k, fact: facts.get(k.key), ...(threw.has(k.key) ? { threw: threw.get(k.key) } : {}) }));
|
|
1160
|
-
}
|
|
1161
957
|
export function registryImportCandidates(input) {
|
|
1162
958
|
return async (scopes, hint) => {
|
|
1163
959
|
const running = await runningImportsOf(input.admin, input.actor, scopes, input.readImports, input.concurrency ?? 8);
|
|
@@ -1191,338 +987,6 @@ export function registryImportCandidates(input) {
|
|
|
1191
987
|
return out;
|
|
1192
988
|
};
|
|
1193
989
|
}
|
|
1194
|
-
/**
|
|
1195
|
-
* Who a promote breaks (#1705 PR 3): the installed consumers whose RUNNING code imports an
|
|
1196
|
-
* exported (type, schemaVersion) that the outgoing version promised and the incoming one drops
|
|
1197
|
-
* or exports at another version. The promote gate refuses on a non-empty answer unless it is
|
|
1198
|
-
* acknowledged (`exportBreak`), and the refusal lists them.
|
|
1199
|
-
*
|
|
1200
|
-
* - **What changed** is outgoing versus incoming, from the two versions' registries. An outgoing
|
|
1201
|
-
* version whose registry states no exports (a first promote, or a push by a CLI older than
|
|
1202
|
-
* 0.34.0) promised nothing the registry can name, so nothing is judged broken. Nothing it
|
|
1203
|
-
* exported at run time could be listed either, which is what the permission-digest gate
|
|
1204
|
-
* beside this one is for.
|
|
1205
|
-
* - **Who is a consumer** is the edge's own rule: a primary, active scope of ANOTHER vertical, in
|
|
1206
|
-
* a tenant where the producer is installed (any primary, active install, so two installs still
|
|
1207
|
-
* count), whose running version (`runningVersionOf`) declares the import. That over-approximates
|
|
1208
|
-
* on purpose, for a listed vertical's tenants still pinned to an older producer version: a
|
|
1209
|
-
* refusal that names one tenant too many costs an acknowledgement, and one that names too few
|
|
1210
|
-
* costs a silent stall.
|
|
1211
|
-
* - **A consumer version whose manifest does not parse** is not judged. It cannot be said to
|
|
1212
|
-
* break, and refusing every promote over a manifest nobody can parse would make the gate one
|
|
1213
|
-
* nobody can pass. A read that THREW is different: the registry could not be asked, so whom the
|
|
1214
|
-
* promote breaks is unknown, and this throws. The gate then refuses rather than failing open.
|
|
1215
|
-
* A failed directory read throws the same way.
|
|
1216
|
-
*
|
|
1217
|
-
* Cost: nothing at all unless an export changed. Then one fleet `listScopes`, one
|
|
1218
|
-
* `listVerticals` when a scope is on a serving script, and one `readImports` per distinct
|
|
1219
|
-
* running consumer version, at most `concurrency` in flight.
|
|
1220
|
-
*/
|
|
1221
|
-
export async function exportBreaksOf(input) {
|
|
1222
|
-
const act = input.act ?? 'promotion';
|
|
1223
|
-
// An outgoing manifest that does not parse promised SOMETHING nobody can read. Judging it as
|
|
1224
|
-
// "promised nothing" would pass every break unacknowledged, so it refuses, as a thrown read does.
|
|
1225
|
-
if (input.outgoing.kind === 'unreadable') {
|
|
1226
|
-
throw substratError('unavailable', `cannot say whom this ${act} breaks: the outgoing version's exports could not be read (${input.outgoing.reason})`);
|
|
1227
|
-
}
|
|
1228
|
-
if (input.outgoing.kind !== 'exports')
|
|
1229
|
-
return [];
|
|
1230
|
-
const incoming = new Map(input.incoming.kind === 'exports' ? input.incoming.rows.map((r) => [r.type, r.schemaVersion]) : []);
|
|
1231
|
-
const changed = new Map();
|
|
1232
|
-
for (const r of input.outgoing.rows) {
|
|
1233
|
-
const now = incoming.get(r.type) ?? null;
|
|
1234
|
-
if (now !== r.schemaVersion)
|
|
1235
|
-
changed.set(r.type, { schemaVersion: r.schemaVersion, incoming: now });
|
|
1236
|
-
}
|
|
1237
|
-
if (changed.size === 0)
|
|
1238
|
-
return [];
|
|
1239
|
-
const scopes = (await input.admin.listScopes(input.actor, { status: 'active', tenantId: input.tenantId })).filter((s) => isPrimaryScope(s) && s.vertical !== null);
|
|
1240
|
-
// Narrowed to one tenant, every scope read is already in a tenant that runs the producer.
|
|
1241
|
-
const installedIn = input.tenantId === undefined
|
|
1242
|
-
? new Set(scopes.filter((s) => s.vertical === input.producer).map((s) => s.tenantId))
|
|
1243
|
-
: null;
|
|
1244
|
-
const consumers = scopes.filter((s) => s.vertical !== input.producer && (installedIn?.has(s.tenantId) ?? true));
|
|
1245
|
-
if (consumers.length === 0)
|
|
1246
|
-
return [];
|
|
1247
|
-
const out = [];
|
|
1248
|
-
for (const { scope, key, versionId, fact, threw } of await runningImportsOf(input.admin, input.actor, consumers, input.readImports, input.concurrency ?? 8)) {
|
|
1249
|
-
if (threw !== undefined) {
|
|
1250
|
-
throw substratError('unavailable', `cannot say whom this ${act} breaks: the registry could not be asked what ${key} imports (${threw}) — retry`);
|
|
1251
|
-
}
|
|
1252
|
-
if (fact?.kind !== 'imports')
|
|
1253
|
-
continue;
|
|
1254
|
-
for (const row of fact.rows) {
|
|
1255
|
-
const hit = row.from === input.producer ? changed.get(row.type) : undefined;
|
|
1256
|
-
if (!hit || hit.schemaVersion !== row.schemaVersion)
|
|
1257
|
-
continue;
|
|
1258
|
-
out.push({
|
|
1259
|
-
tenantId: scope.tenantId,
|
|
1260
|
-
scopeId: scope.id,
|
|
1261
|
-
vertical: scope.vertical,
|
|
1262
|
-
version: versionId,
|
|
1263
|
-
type: row.type,
|
|
1264
|
-
schemaVersion: row.schemaVersion,
|
|
1265
|
-
incoming: hit.incoming,
|
|
1266
|
-
});
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
return out.sort((a, b) => a.tenantId.localeCompare(b.tenantId) || a.scopeId.localeCompare(b.scopeId) || a.type.localeCompare(b.type));
|
|
1270
|
-
}
|
|
1271
|
-
/** How `exportBreakRefusal` begins: what lets the promote route recognise it and add the listing. */
|
|
1272
|
-
const EXPORT_BREAK_REFUSAL = 'promotion drops or re-versions';
|
|
1273
|
-
/** Whether a throw is the promote gate's export-break refusal (`exportBreakRefusal`). */
|
|
1274
|
-
export function isExportBreakRefusal(err) {
|
|
1275
|
-
return errorCodeOf(err) === 'precondition_failed' && err instanceof Error && err.message.startsWith(EXPORT_BREAK_REFUSAL);
|
|
1276
|
-
}
|
|
1277
|
-
/**
|
|
1278
|
-
* The refusal a promote gets for a non-empty `exportBreaksOf` (#1705 PR 3). Counts only: the
|
|
1279
|
-
* adapters do not know who is promoting, and a builder must not learn from an error which other
|
|
1280
|
-
* tenants import its events. The route that knows the caller lists what they may see.
|
|
1281
|
-
*/
|
|
1282
|
-
export function exportBreakRefusal(breaks) {
|
|
1283
|
-
const types = new Set(breaks.map((b) => b.type)).size;
|
|
1284
|
-
const apps = new Set(breaks.map((b) => b.scopeId)).size;
|
|
1285
|
-
const tenants = new Set(breaks.map((b) => b.tenantId)).size;
|
|
1286
|
-
return (`${EXPORT_BREAK_REFUSAL} ${types} exported event type(s) that ${apps} installed app(s) in ` +
|
|
1287
|
-
`${tenants} tenant(s) import — their edges would stop delivering it. Acknowledge it explicitly ` +
|
|
1288
|
-
`(exportBreak) to promote, or keep the export and bump its consumers first`);
|
|
1289
|
-
}
|
|
1290
|
-
/** How `bindExportBreakRefusal` begins: what lets the bind route recognise it (#1756). */
|
|
1291
|
-
export const BIND_EXPORT_BREAK_REFUSAL = 'this bind drops or re-versions';
|
|
1292
|
-
/** Whether a throw is the bind gate's export-break refusal (`bindExportBreakRefusal`). */
|
|
1293
|
-
export function isBindExportBreakRefusal(err) {
|
|
1294
|
-
return errorCodeOf(err) === 'precondition_failed' && err instanceof Error && err.message.startsWith(BIND_EXPORT_BREAK_REFUSAL);
|
|
1295
|
-
}
|
|
1296
|
-
/**
|
|
1297
|
-
* The refusal a bind gets for a non-empty `bindExportBreaksOf` (#1756). Every break is in the
|
|
1298
|
-
* bound scope's own tenant, which the caller addressed, so it names no one else — but it stays in
|
|
1299
|
-
* counts, as the promote refusal does, and the route that knows the caller adds the listing.
|
|
1300
|
-
*/
|
|
1301
|
-
export function bindExportBreakRefusal(breaks) {
|
|
1302
|
-
const types = new Set(breaks.map((b) => b.type)).size;
|
|
1303
|
-
const apps = new Set(breaks.map((b) => b.scopeId)).size;
|
|
1304
|
-
return (`${BIND_EXPORT_BREAK_REFUSAL} ${types} exported event type(s) that ${apps} installed app(s) in ` +
|
|
1305
|
-
`this tenant import — their edges would stop delivering it. Acknowledge it explicitly ` +
|
|
1306
|
-
`(exportBreak) to bind, or bind a version that keeps the export`);
|
|
1307
|
-
}
|
|
1308
|
-
/**
|
|
1309
|
-
* What a bind or a route change moves `scope` between (#1756, #2178): the version it runs before
|
|
1310
|
-
* and the one it runs after (`runningVersionOf` on both of its inputs), or null when the move
|
|
1311
|
-
* changes nothing it runs — a re-point that stays on the serving script — or it ran nothing before
|
|
1312
|
-
* (a first bind). `servingRef` omitted: the route is unchanged.
|
|
1313
|
-
*/
|
|
1314
|
-
export function scopeMoveOf(scope, incomingVersionId, servingRef, serving) {
|
|
1315
|
-
const before = runningVersionOf(scope, serving);
|
|
1316
|
-
const after = runningVersionOf({ verticalVersionId: incomingVersionId, servingRef: servingRef === undefined ? scope.servingRef : servingRef }, serving);
|
|
1317
|
-
return before === null || after === null || before === after ? null : { before, after };
|
|
1318
|
-
}
|
|
1319
|
-
/**
|
|
1320
|
-
* #2178: the versions the active primary scopes of a vertical that a promote or a serve moves were
|
|
1321
|
-
* last provisioned against (`provisionedVersionId`, written only after a provision or reconcile
|
|
1322
|
-
* succeeds): those routed on a serving ref, and those of an owner tenant. A retirement reaches a
|
|
1323
|
-
* scope only at its reconcile, so a scope provisioned behind the retiring version still holds the
|
|
1324
|
-
* keys. #2185: and whether that reconcile FINISHED the version's retirements
|
|
1325
|
-
* (`retirementsFinishedVersionId` equal to the receipt), since one that left a retirement open still
|
|
1326
|
-
* writes the receipt. One row per (version, finished), with how many installs and one of them to
|
|
1327
|
-
* name, however many installs there are. Installs with NO receipt are a row too (`version_id` null):
|
|
1328
|
-
* a switch clears the receipt (#2045), a rewind and a restore do (#2185), and a new install has none until its first
|
|
1329
|
-
* reconcile. The sweep reconciles every unfinished or receiptless install on its next pass, so a
|
|
1330
|
-
* refusal they cause is transient unless that reconcile keeps failing, answers 'unsupported', or
|
|
1331
|
-
* cannot report its retirements (an `adapter-cloudflare` or `vertical-host` that predates the report). Binds
|
|
1332
|
-
* `(vertical, servingRef, ownerTenant)`; a null matches nothing. The directory has no index on
|
|
1333
|
-
* `vertical`, so this scans its scopes table: once per promote or serve.
|
|
1334
|
-
*
|
|
1335
|
-
* #2184: and which binding shapes the reconcile reported the installs have bound through a link
|
|
1336
|
-
* (`bound_shapes`, JSON `{version, types}`), counted only while its version is the receipt's, else
|
|
1337
|
-
* null (unknown). Grouped by it too: each set is a subset of one version's table-held bootstrap
|
|
1338
|
-
* shapes, sorted, so a fleet of one vertical has a handful.
|
|
1339
|
-
*
|
|
1340
|
-
* Review of #2220: and by the version each install RUNS (`runningVersionOf`: the served version on
|
|
1341
|
-
* the serving script, else its pointer), since a receipt behind it speaks for neither the bindings
|
|
1342
|
-
* nor the retirements of what it runs now. Bind its parameters with {@link provisionedVersionsParams}.
|
|
1343
|
-
*
|
|
1344
|
-
* So the rows are at most (receipt versions × running versions × 2 × distinct type sets), still
|
|
1345
|
-
* independent of how many installs there are.
|
|
1346
|
-
*/
|
|
1347
|
-
export const PROVISIONED_VERSIONS_SQL = `SELECT provisioned_version_id AS version_id,
|
|
1348
|
-
CASE WHEN serving_ref = ? THEN ? ELSE vertical_version_id END AS running_version_id,
|
|
1349
|
-
COALESCE(retirements_finished_version_id = provisioned_version_id, 0) AS finished,
|
|
1350
|
-
CASE WHEN json_extract(bound_shapes, '$.version') = provisioned_version_id
|
|
1351
|
-
THEN json_extract(bound_shapes, '$.types') END AS bound_types,
|
|
1352
|
-
COUNT(*) AS installs, MIN(scope_id) AS example
|
|
1353
|
-
FROM scopes
|
|
1354
|
-
WHERE vertical = ? AND status = 'active' AND forked_from IS NULL AND COALESCE(kind, '') != 'preview'
|
|
1355
|
-
AND (serving_ref = ? OR tenant_id = ?)
|
|
1356
|
-
GROUP BY provisioned_version_id, running_version_id, finished, bound_types`;
|
|
1357
|
-
/**
|
|
1358
|
-
* `PROVISIONED_VERSIONS_SQL`'s parameters: the installs of `vertical` on `serving`'s script (which
|
|
1359
|
-
* run its version) or owned by `ownerTenant`. Null for either matches nothing.
|
|
1360
|
-
*/
|
|
1361
|
-
export function provisionedVersionsParams(vertical, serving, ownerTenant) {
|
|
1362
|
-
return [serving?.ref ?? null, serving?.versionId ?? null, vertical, serving?.ref ?? null, ownerTenant];
|
|
1363
|
-
}
|
|
1364
|
-
/**
|
|
1365
|
-
* #2184: the directory's `bound_shapes` value — the version the reconcile ran against and the types
|
|
1366
|
-
* it reported bound, as one JSON value, so the pair is written and cleared together and a receipt
|
|
1367
|
-
* rewritten without it (a control plane that predates the column) reads as unknown. Null when
|
|
1368
|
-
* there is no receipt or the outcome does not say.
|
|
1369
|
-
*/
|
|
1370
|
-
export function boundShapesColumn(versionId, types) {
|
|
1371
|
-
return versionId !== null && types ? JSON.stringify({ version: versionId, types: [...new Set(types)].sort() }) : null;
|
|
1372
|
-
}
|
|
1373
|
-
/** #2184: `Scope.boundShapes` from the directory's `bound_shapes` value; null for null or anything unreadable. */
|
|
1374
|
-
export function boundShapesOfColumn(raw) {
|
|
1375
|
-
if (!raw)
|
|
1376
|
-
return null;
|
|
1377
|
-
try {
|
|
1378
|
-
const v = JSON.parse(raw);
|
|
1379
|
-
const ok = typeof v.version === 'string' && Array.isArray(v.types) && v.types.every((t) => typeof t === 'string');
|
|
1380
|
-
return ok ? { versionId: v.version, types: v.types } : null;
|
|
1381
|
-
}
|
|
1382
|
-
catch {
|
|
1383
|
-
return null;
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
/** `PROVISIONED_VERSIONS_SQL`'s rows, as either adapter's driver returns them. */
|
|
1387
|
-
export function installsReconciledOf(rows) {
|
|
1388
|
-
return rows.map((r) => ({
|
|
1389
|
-
versionId: r.version_id == null ? null : String(r.version_id),
|
|
1390
|
-
runningVersionId: r.running_version_id == null ? null : String(r.running_version_id),
|
|
1391
|
-
retirementsFinished: Number(r.finished) === 1,
|
|
1392
|
-
boundShapes: r.bound_types == null ? null : JSON.parse(String(r.bound_types)),
|
|
1393
|
-
installs: Number(r.installs),
|
|
1394
|
-
example: String(r.example),
|
|
1395
|
-
}));
|
|
1396
|
-
}
|
|
1397
|
-
/**
|
|
1398
|
-
* #2185: one install's own receipt as a {@link InstallsReconciled} group, for a bind or a route of
|
|
1399
|
-
* that install, judged by the same rule as the promote's `PROVISIONED_VERSIONS_SQL` rows, with the
|
|
1400
|
-
* version it runs before the move (`scopeMoveOf`'s `before`).
|
|
1401
|
-
*/
|
|
1402
|
-
export function installReconciled(scope, runningVersionId) {
|
|
1403
|
-
const versionId = scope.provisionedVersionId;
|
|
1404
|
-
return {
|
|
1405
|
-
versionId,
|
|
1406
|
-
runningVersionId,
|
|
1407
|
-
retirementsFinished: versionId !== null && scope.retirementsFinishedVersionId === versionId,
|
|
1408
|
-
boundShapes: versionId !== null && scope.boundShapes?.versionId === versionId ? scope.boundShapes.types : null,
|
|
1409
|
-
installs: 1,
|
|
1410
|
-
example: scope.id,
|
|
1411
|
-
};
|
|
1412
|
-
}
|
|
1413
|
-
/**
|
|
1414
|
-
* #2184: what the gate counts as bound for a group: the types its reconcile reported, or every
|
|
1415
|
-
* table-held bootstrap shape (`'unknown'`) when it did not, or when the group has no receipt.
|
|
1416
|
-
*/
|
|
1417
|
-
export function boundEverOf(g) {
|
|
1418
|
-
return g.versionId !== null && g.boundShapes !== null ? g.boundShapes : 'unknown';
|
|
1419
|
-
}
|
|
1420
|
-
/** Review of #2220: whether a group's receipt is behind the version its installs run. */
|
|
1421
|
-
const receiptLags = (g) => g.versionId !== null && g.runningVersionId !== null && g.runningVersionId !== g.versionId;
|
|
1422
|
-
/**
|
|
1423
|
-
* What the binding-shape gate judges a move onto `afterId` from, for one group of installs, given
|
|
1424
|
-
* the versions the move leaves (`froms`). One rule for both adapters' doors:
|
|
1425
|
-
*
|
|
1426
|
-
* - no receipt: every one of `froms`, as if nothing about it had run or been reported (the installs
|
|
1427
|
-
* run one of them; judging the others too can only over-refuse, and only while it lasts);
|
|
1428
|
-
* - a receipt behind the version they run (review of #2220): that running version the same way,
|
|
1429
|
-
* since whatever it bound or retired since the receipt is not in it — a version that added `bind`
|
|
1430
|
-
* may have reconciled and been claimed through without its receipt landing;
|
|
1431
|
-
* - a receipt: its own version, from its own report (#2185, #2184).
|
|
1432
|
-
*
|
|
1433
|
-
* Each is transient: the sweep reconciles every such install on its next pass.
|
|
1434
|
-
*/
|
|
1435
|
-
export function strandJudgmentsOf(g, froms, afterId) {
|
|
1436
|
-
const judgedFrom = installsReconciledNote(g);
|
|
1437
|
-
const unreported = { judgedFrom, retiredStillHeld: true, boundEver: 'unknown' };
|
|
1438
|
-
if (g.versionId === null)
|
|
1439
|
-
return froms.filter((f) => f !== afterId).map((from) => ({ from, opts: unreported }));
|
|
1440
|
-
const out = [];
|
|
1441
|
-
if (receiptLags(g) && g.runningVersionId !== afterId)
|
|
1442
|
-
out.push({ from: g.runningVersionId, opts: unreported });
|
|
1443
|
-
if (g.versionId !== afterId) {
|
|
1444
|
-
out.push({ from: g.versionId, opts: { judgedFrom, retiredStillHeld: !g.retirementsFinished, boundEver: boundEverOf(g) } });
|
|
1445
|
-
}
|
|
1446
|
-
return out;
|
|
1447
|
-
}
|
|
1448
|
-
/** What a refusal judged from installs' receipts says it was judged from. */
|
|
1449
|
-
export function installsReconciledNote(g) {
|
|
1450
|
-
// #2184: the types a link has bound people to, where the reconcile reported any.
|
|
1451
|
-
const bound = g.versionId !== null && g.boundShapes?.length ? `, where links have bound people to ${g.boundShapes.join(', ')}` : '';
|
|
1452
|
-
const unfinished = g.versionId !== null && !g.retirementsFinished;
|
|
1453
|
-
const unknown = g.versionId !== null && g.boundShapes === null;
|
|
1454
|
-
// Review of #2220: a receipt behind what they run speaks for none of it.
|
|
1455
|
-
const lags = receiptLags(g)
|
|
1456
|
-
? `, and run ${g.runningVersionId}, which that receipt does not speak for: every bootstrap shape of ${g.runningVersionId} ` +
|
|
1457
|
-
'with a table holder counts as bound and its retirements as not run'
|
|
1458
|
-
: '';
|
|
1459
|
-
if (unfinished || unknown) {
|
|
1460
|
-
const gaps = [unfinished && 'with its retirements not finished', unknown && 'without reporting which binding shapes links have bound'];
|
|
1461
|
-
const waits = [unfinished && 'their retirements to finish', unknown && 'their report'];
|
|
1462
|
-
const report = unfinished && unknown ? 'retirement and binding reports (#2185, #2184)' : unfinished ? 'retirement report (#2185)' : 'binding report (#2184)';
|
|
1463
|
-
return (`${g.installs} install(s) that reconciled ${g.versionId} ${gaps.filter(Boolean).join(' and ')}${bound} (one is ${g.example})${lags}` +
|
|
1464
|
-
(unknown ? `, so every bootstrap shape of ${g.versionId} with a table holder counts as bound` : '') +
|
|
1465
|
-
`; the drop waits for ${waits.filter(Boolean).join(' and ')}: the sweep reconciles them again each pass. ` +
|
|
1466
|
-
"An install whose vertical's @substrat-run/adapter-cloudflare or @substrat-run/vertical-host predates the " +
|
|
1467
|
-
`${report} stays here: push a version built on current releases of both packages`);
|
|
1468
|
-
}
|
|
1469
|
-
const which = g.versionId === null ? 'with no reconcile receipt (a switch, a rewind or a restore cleared it, or none was written yet)' : `that last reconciled ${g.versionId}${bound}`;
|
|
1470
|
-
return (`${g.installs} install(s) ${which} (one is ${g.example})${lags}; the drop waits for their reconcile. ` +
|
|
1471
|
-
"If that install's reconcile keeps failing or answers 'unsupported' (a vertical built on a vertical-host " +
|
|
1472
|
-
'that cannot reconcile), no sweep writes its receipt: fix the reconcile or push a version built on a ' +
|
|
1473
|
-
'current vertical-host, so the retirement reaches it');
|
|
1474
|
-
}
|
|
1475
|
-
/**
|
|
1476
|
-
* Whom moving `scope` breaks (#1756): the promote gate's question (`exportBreaksOf`), asked of
|
|
1477
|
-
* ONE install in its own tenant, since an edge never crosses one.
|
|
1478
|
-
*
|
|
1479
|
-
* A scope's exports leave from the code it RUNS, and `runningVersionOf` names that code from two
|
|
1480
|
-
* inputs: the version pointer and the serving ref. So a move is either or both of them changing —
|
|
1481
|
-
* a bind (`next.versionId`), or a route onto or off a serving script (`next.servingRef`, which is
|
|
1482
|
-
* how an adopt moves a scope before its pointer ever does). Both are judged by the same rule, on
|
|
1483
|
-
* the running version before against the running version after, the rule the consumers are judged
|
|
1484
|
-
* on too:
|
|
1485
|
-
*
|
|
1486
|
-
* - **Re-pointing a scope that stays on its vertical's serving script** changes nothing it runs,
|
|
1487
|
-
* so it is never refused. The change reached it when the serving script was replaced — at a
|
|
1488
|
-
* promote, which judged every tenant then. A route ONTO the serving script is not that case:
|
|
1489
|
-
* the scope goes from its own version to whatever is served, and that is judged.
|
|
1490
|
-
* - **A fork or a preview** is never an edge's producer (`resolveVerticalInstanceFrom` takes the
|
|
1491
|
-
* primary install only), so nothing it runs can break an edge.
|
|
1492
|
-
* - **A scope still provisioning** has never been active, so no edge has ever delivered from it.
|
|
1493
|
-
* - **A first bind** runs nothing before it, so it promised nothing.
|
|
1494
|
-
* - **A lineage crossing** (the version belongs to another vertical than the scope's, or the route
|
|
1495
|
-
* goes onto a script that is not this vertical's serving script) is not judged here, and nothing
|
|
1496
|
-
* else judges it either: every consumer importing `from` the old slug
|
|
1497
|
-
* loses its producer, and `rebind-vertical`'s acknowledgements are about migrations, not about
|
|
1498
|
-
* that. A known gap, older than this gate.
|
|
1499
|
-
*/
|
|
1500
|
-
export async function bindExportBreaksOf(input) {
|
|
1501
|
-
const { scope } = input;
|
|
1502
|
-
if (!isPrimaryScope(scope) || scope.status === 'provisioning')
|
|
1503
|
-
return [];
|
|
1504
|
-
if (scope.vertical === null || scope.vertical !== input.incoming.verticalSlug)
|
|
1505
|
-
return [];
|
|
1506
|
-
// A route onto a script that is not this vertical's serving script is a lineage crossing (or a
|
|
1507
|
-
// ref this host cannot name), and what the scope runs there is not this vertical's code at all:
|
|
1508
|
-
// the same known gap as above, never a phantom move judged against the wrong vertical.
|
|
1509
|
-
if (typeof input.servingRef === 'string' && input.servingRef !== input.serving?.ref)
|
|
1510
|
-
return [];
|
|
1511
|
-
const move = scopeMoveOf(scope, input.incoming.id, input.servingRef, input.serving);
|
|
1512
|
-
if (move === null)
|
|
1513
|
-
return [];
|
|
1514
|
-
const [outgoing, incoming] = await Promise.all([input.readExports(move.before), input.readExports(move.after)]);
|
|
1515
|
-
return exportBreaksOf({
|
|
1516
|
-
admin: input.admin,
|
|
1517
|
-
actor: input.actor,
|
|
1518
|
-
producer: scope.vertical,
|
|
1519
|
-
outgoing,
|
|
1520
|
-
incoming,
|
|
1521
|
-
readImports: input.readImports,
|
|
1522
|
-
tenantId: scope.tenantId,
|
|
1523
|
-
act: 'bind',
|
|
1524
|
-
});
|
|
1525
|
-
}
|
|
1526
990
|
/** A tenant read with names matched exactly — the edges' rule (see `edgeEnds`). */
|
|
1527
991
|
const exactIn = (id) => ({ id, slug: null });
|
|
1528
992
|
/**
|
|
@@ -2116,4 +1580,113 @@ async function sweepStorageGauge(admin, options, gauge, reachedThisPass, report)
|
|
|
2116
1580
|
}
|
|
2117
1581
|
return out;
|
|
2118
1582
|
}
|
|
1583
|
+
const pushCapped = (bucket, id) => {
|
|
1584
|
+
bucket.count += 1;
|
|
1585
|
+
if (bucket.scopeIds.length < ORPHAN_ATTACHMENT_REPORTED_IDS)
|
|
1586
|
+
bucket.scopeIds.push(id);
|
|
1587
|
+
};
|
|
1588
|
+
/**
|
|
1589
|
+
* Whether the admin log holds a dry-run row for the scope from within the window: the licence for
|
|
1590
|
+
* a destructive pass (`OrphanAttachmentSweepOptions`). A delete row in the same window is not one.
|
|
1591
|
+
*
|
|
1592
|
+
* One read per visited scope, in delete mode only, and on a host that records directory reads it is
|
|
1593
|
+
* one more access-log row each (K-24): at the default batch and a 15-minute cron, 2,400 a day. The
|
|
1594
|
+
* control plane's reach already reads the scope, its tenant, and its serving pointer or its bound
|
|
1595
|
+
* version per visit, which is three such rows, so this adds a quarter. It stays an audited
|
|
1596
|
+
* read on purpose: a quiet read of the admin log would be the one platform read K-24 does not see.
|
|
1597
|
+
* One read per pass for the whole window is not cheaper, since a row carries up to the pass's limit
|
|
1598
|
+
* of keys and the window holds a week of them.
|
|
1599
|
+
*/
|
|
1600
|
+
async function hasRecentDryRun(host, actor, scope, since) {
|
|
1601
|
+
const rows = await host.admin.auditLog(actor, {
|
|
1602
|
+
tenantId: scope.tenantId,
|
|
1603
|
+
scopeId: scope.id,
|
|
1604
|
+
action: 'sweepOrphanAttachments',
|
|
1605
|
+
since,
|
|
1606
|
+
order: 'desc',
|
|
1607
|
+
limit: 100,
|
|
1608
|
+
});
|
|
1609
|
+
return rows.some((r) => r.after?.dryRun === true);
|
|
1610
|
+
}
|
|
1611
|
+
/** The orphan-attachment phase (#2314): one window of scopes, each swept where it is served. */
|
|
1612
|
+
async function sweepOrphanAttachmentWindow(host, options, phase, scopes, report) {
|
|
1613
|
+
const batch = phase.batch ?? ORPHAN_ATTACHMENT_SWEEP_BATCH;
|
|
1614
|
+
const out = {
|
|
1615
|
+
mode: phase.mode,
|
|
1616
|
+
eligible: scopes.length,
|
|
1617
|
+
visited: 0,
|
|
1618
|
+
deferred: 0,
|
|
1619
|
+
dryRuns: 0,
|
|
1620
|
+
deletes: 0,
|
|
1621
|
+
awaitingDryRun: 0,
|
|
1622
|
+
noStore: { count: 0, scopeIds: [] },
|
|
1623
|
+
fenced: 0,
|
|
1624
|
+
orphans: 0,
|
|
1625
|
+
deleted: 0,
|
|
1626
|
+
refused: { count: 0, scopeIds: [] },
|
|
1627
|
+
unsupported: { count: 0, scopeIds: [] },
|
|
1628
|
+
};
|
|
1629
|
+
const window = reconcileWindow(scopes, batch, phase.rng ?? Math.random);
|
|
1630
|
+
out.visited = window.length;
|
|
1631
|
+
out.deferred = scopes.length - window.length;
|
|
1632
|
+
const nowMs = Date.parse(phase.now?.() ?? new Date().toISOString());
|
|
1633
|
+
const since = new Date(nowMs - (phase.dryRunWindowMs ?? ORPHAN_SWEEP_DRY_RUN_WINDOW_MS)).toISOString();
|
|
1634
|
+
await mapBounded(window, options.concurrency ?? 8, async (s) => {
|
|
1635
|
+
try {
|
|
1636
|
+
let dryRun = true;
|
|
1637
|
+
if (phase.mode === 'delete') {
|
|
1638
|
+
dryRun = !(await hasRecentDryRun(host, options.actor, s, since));
|
|
1639
|
+
if (dryRun)
|
|
1640
|
+
out.awaitingDryRun += 1;
|
|
1641
|
+
}
|
|
1642
|
+
const outcome = await phase.sweepScopeFn(s.tenantId, s.id, { dryRun });
|
|
1643
|
+
if ('refused' in outcome)
|
|
1644
|
+
return pushCapped(out.refused, s.id);
|
|
1645
|
+
if ('unsupported' in outcome)
|
|
1646
|
+
return pushCapped(out.unsupported, s.id);
|
|
1647
|
+
const r = outcome.swept;
|
|
1648
|
+
// The deployment's word on what it did, never the mode asked for: a deployment that ignored
|
|
1649
|
+
// the flag is reported as what it was.
|
|
1650
|
+
if (r.dryRun)
|
|
1651
|
+
out.dryRuns += 1;
|
|
1652
|
+
else
|
|
1653
|
+
out.deletes += 1;
|
|
1654
|
+
if (r.bucket === 'none')
|
|
1655
|
+
pushCapped(out.noStore, s.id);
|
|
1656
|
+
if (r.fencedUntil !== undefined)
|
|
1657
|
+
out.fenced += 1;
|
|
1658
|
+
out.orphans += r.orphans;
|
|
1659
|
+
out.deleted += r.deleted;
|
|
1660
|
+
if (r.orphans > 0 || r.deleted > 0) {
|
|
1661
|
+
const entry = {
|
|
1662
|
+
tenantId: s.tenantId,
|
|
1663
|
+
scopeId: s.id,
|
|
1664
|
+
dryRun: r.dryRun,
|
|
1665
|
+
listed: r.listed,
|
|
1666
|
+
candidates: r.candidates,
|
|
1667
|
+
orphans: r.orphans,
|
|
1668
|
+
deleted: r.deleted,
|
|
1669
|
+
keys: r.keys,
|
|
1670
|
+
...(r.fencedUntil !== undefined ? { fencedUntil: r.fencedUntil } : {}),
|
|
1671
|
+
};
|
|
1672
|
+
try {
|
|
1673
|
+
await host.admin.recordOrphanSweep(options.actor, entry);
|
|
1674
|
+
}
|
|
1675
|
+
catch (err) {
|
|
1676
|
+
// The bytes are already gone when a real pass deleted any: this line is then the only
|
|
1677
|
+
// record of which ones, so it carries the whole entry, keys and all, before the error.
|
|
1678
|
+
if (r.deleted > 0) {
|
|
1679
|
+
console.error(`orphan-attachment sweep: deleted ${r.deleted} object(s) but the admin-log row was not written: ${JSON.stringify(entry)}`);
|
|
1680
|
+
}
|
|
1681
|
+
throw new Error(`the admin-log row was not written (${message(err)})` +
|
|
1682
|
+
(r.deleted > 0 ? `; ${r.deleted} object(s) were deleted, and the log line names them` : ''));
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
catch (err) {
|
|
1687
|
+
report.errors.push({ kind: 'attachment-sweep', id: s.id, error: message(err) });
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
return out;
|
|
1691
|
+
}
|
|
2119
1692
|
//# sourceMappingURL=platform-sweep.js.map
|