@substrat-run/control-plane-api 0.140.0 → 0.142.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/api.d.ts +3 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +498 -97
- package/dist/api.js.map +1 -1
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +40 -3
- package/dist/cf-observability.js.map +1 -1
- package/dist/field-coverage-read.d.ts +44 -0
- package/dist/field-coverage-read.d.ts.map +1 -0
- package/dist/field-coverage-read.js +45 -0
- package/dist/field-coverage-read.js.map +1 -0
- package/dist/field-coverage-tally.d.ts +12 -0
- package/dist/field-coverage-tally.d.ts.map +1 -1
- package/dist/field-coverage-tally.js +55 -14
- package/dist/field-coverage-tally.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +13 -0
- package/dist/observability.d.ts.map +1 -1
- package/dist/platform-entry.d.ts +4 -4
- package/dist/platform-entry.generated.d.ts +1 -1
- package/dist/platform-entry.generated.d.ts.map +1 -1
- package/dist/platform-entry.generated.js +3 -3
- package/dist/platform-entry.generated.js.map +1 -1
- package/dist/platform-entry.js +6 -6
- package/dist/platform-entry.js.map +1 -1
- package/dist/provider-error.d.ts +34 -5
- package/dist/provider-error.d.ts.map +1 -1
- package/dist/provider-error.js +60 -5
- package/dist/provider-error.js.map +1 -1
- package/dist/scope-copy-backfill.d.ts +106 -0
- package/dist/scope-copy-backfill.d.ts.map +1 -0
- package/dist/scope-copy-backfill.js +418 -0
- package/dist/scope-copy-backfill.js.map +1 -0
- package/dist/scope-copy-cleanup.d.ts +86 -0
- package/dist/scope-copy-cleanup.d.ts.map +1 -0
- package/dist/scope-copy-cleanup.js +234 -0
- package/dist/scope-copy-cleanup.js.map +1 -0
- package/dist/vertical-client.d.ts +5 -1
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +20 -5
- package/dist/vertical-client.js.map +1 -1
- package/package.json +10 -10
package/dist/api.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { resolveObservabilityWindow } from './observability-window.js';
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
3
|
import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, channelName, connectLinkId, connectionActivity, connectionActivitySource, connectionCredential, connectionCertificate, connectionFilter, connectionHealthState, connectionProvider, connectorDispatchKind, PROVISION_SIBLING_KIND, ARCHIVE_SCOPE_KIND, PROVISION_TENANT_KIND, SET_ENTITLEMENTS_KIND, MODEL_USAGE_KIND, SWEEP_RUNS_KIND, toConnectionHealthEntry, CONNECTION_EXPIRY_WARNING_DAYS, CONNECTION_STALE_DAYS, connectionProbe, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, platformScriptCollision, platformScriptRefusal, PLATFORM_SCRIPT_NAMES, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, errorCodeOf, capabilityFilterQuery, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, } from '@substrat-run/contracts';
|
|
4
|
+
import { SCOPE_COPY_LEASE_MS, copyRestoreFence } from '@substrat-run/kernel';
|
|
4
5
|
import { attributeFailure } from './failure-attribution.js';
|
|
5
6
|
import { auditedChange, withAuditedOutcomes } from './audited-change.js';
|
|
6
7
|
import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isPrimaryScope, migrationProgress, ulid, auditWarningOf, } from '@substrat-run/kernel';
|
|
7
8
|
import { TENANT_HEADER, confinedTenant } from './auth.js';
|
|
8
9
|
import { mintTenantToken } from './tenant-token.js';
|
|
9
10
|
import { connectionGrantsForScope } from './vertical-client.js';
|
|
11
|
+
import { assertNoUnreachableScopeCopies, listAllScopeScriptCopies, reapScopeScriptCopies, recordStrandedStorage } from './scope-copy-cleanup.js';
|
|
12
|
+
import { BACKFILL_PAGE_MAX, backfillScopeScriptCopies } from './scope-copy-backfill.js';
|
|
10
13
|
import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
|
|
11
14
|
import { versionReachedAt } from './scope-deployment.js';
|
|
12
15
|
import { reconcileConnectionGrants } from './connection-grants.js';
|
|
@@ -696,6 +699,7 @@ const TENANT_ROUTES = [
|
|
|
696
699
|
{ method: 'GET', re: /\/observability\/tenant-metrics$/, pin: 'query' },
|
|
697
700
|
{ method: 'GET', re: /\/observability\/tenant-metrics-series$/, pin: 'query' },
|
|
698
701
|
{ method: 'GET', re: /\/observability\/tenant-logs$/, pin: 'query' },
|
|
702
|
+
{ method: 'GET', re: /\/observability\/tenant-field-coverage$/, pin: 'query' },
|
|
699
703
|
// #1746: the request record's histogram, facets and list — same grain, same forced tenant.
|
|
700
704
|
{ method: 'GET', re: /\/observability\/tenant-request-volume$/, pin: 'query' },
|
|
701
705
|
{ method: 'GET', re: /\/observability\/tenant-request-facets$/, pin: 'query' },
|
|
@@ -798,6 +802,7 @@ const namedTenants = (c) => {
|
|
|
798
802
|
*/
|
|
799
803
|
export function createControlPlaneApi(options) {
|
|
800
804
|
const { host, authenticate, authenticateBuilder, authenticateTenantService } = options;
|
|
805
|
+
const copyLeaseMs = options.copyLeaseMs ?? SCOPE_COPY_LEASE_MS;
|
|
801
806
|
const app = new Hono();
|
|
802
807
|
// The CLI version advisory (#971), stamped on EVERY response — including the 401 the
|
|
803
808
|
// auth middleware below answers with, since a CLI too old to authenticate is exactly
|
|
@@ -959,6 +964,7 @@ export function createControlPlaneApi(options) {
|
|
|
959
964
|
{ method: 'GET', re: /\/observability\/tenant-metrics$/ },
|
|
960
965
|
{ method: 'GET', re: /\/observability\/tenant-metrics-series$/ },
|
|
961
966
|
{ method: 'GET', re: /\/observability\/tenant-logs$/ },
|
|
967
|
+
{ method: 'GET', re: /\/observability\/tenant-field-coverage$/ },
|
|
962
968
|
// #1746: the same lines, counted and listed. Tenant-keyed, so no fleet-wide fallback.
|
|
963
969
|
{ method: 'GET', re: /\/observability\/tenant-request-volume$/ },
|
|
964
970
|
{ method: 'GET', re: /\/observability\/tenant-request-facets$/ },
|
|
@@ -2064,6 +2070,7 @@ export function createControlPlaneApi(options) {
|
|
|
2064
2070
|
exact: source.exact,
|
|
2065
2071
|
loadStamp: source.loadStamp,
|
|
2066
2072
|
expect: source.expect,
|
|
2073
|
+
...(source.fence ? { fence: source.fence } : {}),
|
|
2067
2074
|
...(markCopy ? { markCopy } : {}),
|
|
2068
2075
|
}));
|
|
2069
2076
|
};
|
|
@@ -2101,13 +2108,9 @@ export function createControlPlaneApi(options) {
|
|
|
2101
2108
|
if (!source || !dest) {
|
|
2102
2109
|
throw new ControlPlaneError(501, 'adopt-serving needs dispatch resolution for both ends');
|
|
2103
2110
|
}
|
|
2104
|
-
const dump = await source.exportScope(scopeId);
|
|
2105
2111
|
// #1742: the recorded OFF positions ride the restore, applied in the replay's own event.
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
await c.var.admin
|
|
2109
|
-
.setScopeServingRef(actor, tenantId, scopeId, serving.ref, { acknowledge: opts.acknowledge })
|
|
2110
|
-
.catch(relayHostRefusal);
|
|
2112
|
+
// Data landed — only then does routing flip and the version pointer move.
|
|
2113
|
+
const restored = await moveOntoServing(c, scope, source, dest, serving.ref, { acknowledge: opts.acknowledge });
|
|
2111
2114
|
await c.var.admin
|
|
2112
2115
|
.bindScopeVersion(actor, tenantId, scopeId, serving.versionId, { acknowledge: opts.acknowledge })
|
|
2113
2116
|
.catch(relayHostRefusal);
|
|
@@ -2187,8 +2190,9 @@ export function createControlPlaneApi(options) {
|
|
|
2187
2190
|
* independent, and the scope's DO carries the SOURCE lineage's applied-migration
|
|
2188
2191
|
* journal. The digest gate refuses the crossing unless the scope's bound version and
|
|
2189
2192
|
* the target's serving version carry the same migration digest — or the operator
|
|
2190
|
-
* acknowledges having read both surfaces (`ackMigrations`).
|
|
2191
|
-
* is
|
|
2193
|
+
* acknowledges having read both surfaces (`ackMigrations`). Once the move is confirmed the
|
|
2194
|
+
* source script's copy is wiped by the sweep (#1722, `moveOntoServing`), so it is no backout:
|
|
2195
|
+
* rollback is an explicit snapshot or backup taken before the rebind.
|
|
2192
2196
|
*/
|
|
2193
2197
|
const rebindScopeOntoVertical = async (c, tenantId, scopeId, target, opts) => {
|
|
2194
2198
|
const actor = c.get('actor');
|
|
@@ -2220,10 +2224,25 @@ export function createControlPlaneApi(options) {
|
|
|
2220
2224
|
const move = { acknowledge: opts.acknowledge };
|
|
2221
2225
|
if (opts.abandonData) {
|
|
2222
2226
|
// Directory-only crossing: no bytes move, so the frontier gate has nothing to
|
|
2223
|
-
// protect — the target provisions its own schema from scratch. The source
|
|
2224
|
-
//
|
|
2225
|
-
//
|
|
2226
|
-
|
|
2227
|
+
// protect — the target provisions its own schema from scratch. The source script's
|
|
2228
|
+
// copy is the only copy of the scope's data, and its deployment predates the export a
|
|
2229
|
+
// fenced wipe needs, so it is kept (`retained`), never swept, and reap and erasure still
|
|
2230
|
+
// reach it. The scope serves nothing until `/verticals/:slug/instances` re-provisions it.
|
|
2231
|
+
const sourceRef = await routeOf(c, scope);
|
|
2232
|
+
const moveId = sourceRef && sourceRef !== serving.ref ? ulid() : null;
|
|
2233
|
+
if (moveId && sourceRef) {
|
|
2234
|
+
await c.var.admin.recordScopeScriptCopy(actor, tenantId, scopeId, sourceRef, moveId, { role: 'source' });
|
|
2235
|
+
}
|
|
2236
|
+
try {
|
|
2237
|
+
const erasureEpoch = await c.var.admin.scopeErasureEpoch(actor, tenantId, scopeId);
|
|
2238
|
+
await c.var.admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref, {
|
|
2239
|
+
...move, expectedErasureEpoch: erasureEpoch, ...(moveId ? { confirmMove: { moveId, source: 'retained' } } : {}),
|
|
2240
|
+
}).catch(relayHostRefusal);
|
|
2241
|
+
}
|
|
2242
|
+
finally {
|
|
2243
|
+
if (moveId)
|
|
2244
|
+
await c.var.admin.settleScopeScriptCopy(actor, tenantId, scopeId, sourceRef, moveId, 'retained');
|
|
2245
|
+
}
|
|
2227
2246
|
await c.var.admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId, move).catch(relayHostRefusal);
|
|
2228
2247
|
return { servingRef: serving.ref, versionId: serving.versionId, dataAbandoned: true };
|
|
2229
2248
|
}
|
|
@@ -2246,18 +2265,68 @@ export function createControlPlaneApi(options) {
|
|
|
2246
2265
|
if (!source || !dest) {
|
|
2247
2266
|
throw new ControlPlaneError(501, 'rebind-vertical needs dispatch resolution for both ends');
|
|
2248
2267
|
}
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
//
|
|
2252
|
-
//
|
|
2253
|
-
|
|
2254
|
-
// pre-migration state, and it is never deleted — that copy is the backout.
|
|
2255
|
-
await c.var.admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref, move).catch(relayHostRefusal);
|
|
2268
|
+
// #1742, as adopt. Data landed on the target script — only then do routing flip and the
|
|
2269
|
+
// pointer cross. `bindScopeVersion` rewrites `scopes.vertical` from the version row, audited.
|
|
2270
|
+
// No snapshot is taken here: the old copy is swept once the move is confirmed, so an
|
|
2271
|
+
// operator who wants a way back takes a snapshot or backup first.
|
|
2272
|
+
const restored = await moveOntoServing(c, scope, source, dest, serving.ref, move);
|
|
2256
2273
|
await c.var.admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId, move).catch(relayHostRefusal);
|
|
2257
2274
|
// #1674: re-assert the recorded OFF positions in the store the scope now routes to.
|
|
2258
2275
|
await c.var.admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: restored.switchedOff });
|
|
2259
2276
|
return { servingRef: serving.ref, versionId: serving.versionId, tables: restored.tables };
|
|
2260
2277
|
};
|
|
2278
|
+
/**
|
|
2279
|
+
* The copy move under adopt-serving and rebind-vertical (#1722): export from the script the
|
|
2280
|
+
* scope routes to, restore onto the serving script, then flip the route. Both ends are in the
|
|
2281
|
+
* ledger, leased to this move, before any bytes move, and the destination's entry names the
|
|
2282
|
+
* stamp its restore leaves, so a crash-recovery sweep can wipe an unbound restore if this
|
|
2283
|
+
* request dies. The route flips only while that lease is live and unclaimed, and the same
|
|
2284
|
+
* write settles the destination `done`.
|
|
2285
|
+
*
|
|
2286
|
+
* The old source is then `eligible`, as a carry's is: the scheduled sweep wipes it, fenced on
|
|
2287
|
+
* the stamp and revision this export read, so a copy that took a write since is kept for staff
|
|
2288
|
+
* rather than deleted. The old copy is not a backout; rollback is an explicit snapshot or
|
|
2289
|
+
* backup taken before the move (the decided policy). A source whose deployment predates the
|
|
2290
|
+
* stamped export cannot be wiped under a fence, and stays `retained`. A move that fails or is
|
|
2291
|
+
* never confirmed keeps its source too: the catch below settles both ends `retained`.
|
|
2292
|
+
*/
|
|
2293
|
+
const moveOntoServing = async (c, scope, source, dest, servingRef, routeOpts) => {
|
|
2294
|
+
const actor = c.get('actor');
|
|
2295
|
+
const { tenantId, id: scopeId } = scope;
|
|
2296
|
+
const sourceRef = await routeOf(c, scope);
|
|
2297
|
+
const moveId = sourceRef !== servingRef ? ulid() : null;
|
|
2298
|
+
const restoredStamp = ulid();
|
|
2299
|
+
const recordedAt = Date.now();
|
|
2300
|
+
if (moveId && sourceRef) {
|
|
2301
|
+
await c.var.admin.recordScopeScriptCopy(actor, tenantId, scopeId, sourceRef, moveId, { role: 'source', leaseMs: copyLeaseMs });
|
|
2302
|
+
}
|
|
2303
|
+
try {
|
|
2304
|
+
if (moveId) {
|
|
2305
|
+
await c.var.admin.recordScopeScriptCopy(actor, tenantId, scopeId, servingRef, moveId, { role: 'destination', loadStamp: restoredStamp, leaseMs: copyLeaseMs });
|
|
2306
|
+
}
|
|
2307
|
+
const erasureEpoch = await c.var.admin.scopeErasureEpoch(actor, tenantId, scopeId);
|
|
2308
|
+
const { tables: dump, loadStamp: sourceStamp, revision: sourceRevision } = await retryTransient(() => source.exportScopeStamped(scopeId));
|
|
2309
|
+
const restored = await restoreCarryingSwitches(actor, dest, tenantId, scopeId, dump, {
|
|
2310
|
+
scopeId, exact: true, loadStamp: restoredStamp,
|
|
2311
|
+
...(moveId ? { fence: copyRestoreFence(moveId, recordedAt, copyLeaseMs, erasureEpoch) } : {}),
|
|
2312
|
+
});
|
|
2313
|
+
await c.var.admin.setScopeServingRef(actor, tenantId, scopeId, servingRef, {
|
|
2314
|
+
...routeOpts, expectedErasureEpoch: erasureEpoch,
|
|
2315
|
+
...(moveId ? { confirmMove: sourceStamp
|
|
2316
|
+
? { moveId, source: 'eligible', sourceMarker: { loadStamp: sourceStamp, revision: sourceRevision } }
|
|
2317
|
+
: { moveId, source: 'retained' } } : {}),
|
|
2318
|
+
}).catch(relayHostRefusal);
|
|
2319
|
+
return restored;
|
|
2320
|
+
}
|
|
2321
|
+
catch (error) {
|
|
2322
|
+
if (moveId)
|
|
2323
|
+
await Promise.allSettled([
|
|
2324
|
+
...(sourceRef ? [c.var.admin.settleScopeScriptCopy(actor, tenantId, scopeId, sourceRef, moveId, 'retained')] : []),
|
|
2325
|
+
c.var.admin.settleScopeScriptCopy(actor, tenantId, scopeId, servingRef, moveId, 'retained'),
|
|
2326
|
+
]);
|
|
2327
|
+
throw error;
|
|
2328
|
+
}
|
|
2329
|
+
};
|
|
2261
2330
|
/** The script a scope's requests reach: its serving pin, else its bound version's own script. */
|
|
2262
2331
|
const routeOf = async (c, scope) => {
|
|
2263
2332
|
if (scope.servingRef)
|
|
@@ -2351,43 +2420,74 @@ export function createControlPlaneApi(options) {
|
|
|
2351
2420
|
throw new ControlPlaneError(502, `cannot move scope ${scope.id} from '${from}' to '${to}': ${missing} could not be reached, ` +
|
|
2352
2421
|
`so the version was not bound (#1710)`);
|
|
2353
2422
|
}
|
|
2354
|
-
|
|
2355
|
-
//
|
|
2356
|
-
//
|
|
2357
|
-
//
|
|
2358
|
-
|
|
2359
|
-
const destMarker = await retryTransient(() => dest.loadMarker(scope.id));
|
|
2360
|
-
const now = await c.var.admin.getScopeRecord(actor, scope.tenantId, scope.id);
|
|
2361
|
-
if (!now ||
|
|
2362
|
-
(now.verticalVersionId ?? null) !== (scope.verticalVersionId ?? null) ||
|
|
2363
|
-
(now.servingRef ?? null) !== (scope.servingRef ?? null)) {
|
|
2364
|
-
throw new ControlPlaneError(412, `scope ${scope.id} was re-pointed while its data was being copied out of '${from}', so nothing was ` +
|
|
2365
|
-
`moved or bound (#1722). Reload the scope and retry.`);
|
|
2366
|
-
}
|
|
2367
|
-
if (dumpMetaValue(dump, CARRIED_AWAY_KEY) !== null) {
|
|
2368
|
-
throw new ControlPlaneError(412, `the copy of scope ${scope.id} in '${from}' was already carried to another script and wiped, so nothing ` +
|
|
2369
|
-
`was moved or bound (#1722). Reload the scope and retry.`);
|
|
2370
|
-
}
|
|
2423
|
+
// Before exporting or restoring anything, retain the script identity in the directory, so a
|
|
2424
|
+
// failed carry stays named for reap and erasure. Both entries are leased to this move: if this
|
|
2425
|
+
// request dies, a sweep settles them once the lease runs out, and the move can then no longer
|
|
2426
|
+
// confirm its bind.
|
|
2427
|
+
const moveId = ulid();
|
|
2371
2428
|
// #1742: the recorded OFF positions ride the restore, as on adopt and rebind. #1722: so does
|
|
2372
|
-
// a stamp of this carry's own, which a refused bind's wipe of the copy expects
|
|
2429
|
+
// a stamp of this carry's own, which a refused bind's wipe of the copy expects, and which the
|
|
2430
|
+
// destination's entry names before the restore so a crash-recovery sweep can fence on it.
|
|
2373
2431
|
const restoredStamp = ulid();
|
|
2374
|
-
const
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
dest
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2432
|
+
const recordedAt = Date.now();
|
|
2433
|
+
await c.var.admin.recordScopeScriptCopy(actor, scope.tenantId, scope.id, from, moveId, { role: 'source', leaseMs: copyLeaseMs });
|
|
2434
|
+
try {
|
|
2435
|
+
// The unbound destination can also survive a refused bind or a failed best-effort
|
|
2436
|
+
// cleanup. Name it before any bytes are restored so reap and erasure can find it.
|
|
2437
|
+
await c.var.admin.recordScopeScriptCopy(actor, scope.tenantId, scope.id, to, moveId, { role: 'destination', loadStamp: restoredStamp, leaseMs: copyLeaseMs });
|
|
2438
|
+
const erasureEpoch = await c.var.admin.scopeErasureEpoch(actor, scope.tenantId, scope.id);
|
|
2439
|
+
const { tables: dump, loadStamp: sourceStamp, revision: sourceRevision } = await retryTransient(() => source.exportScopeStamped(scope.id));
|
|
2440
|
+
// What the destination holds now, read before the binding is checked again. The restore
|
|
2441
|
+
// sends it back and is refused if the store moved since: another carry into the same script
|
|
2442
|
+
// loaded it, or it went live under another carry's bind and took a write. That is a CI retry
|
|
2443
|
+
// of the same version, held before its restore while the first run bound and served.
|
|
2444
|
+
const destMarker = await retryTransient(() => dest.loadMarker(scope.id));
|
|
2445
|
+
const now = await c.var.admin.getScopeRecord(actor, scope.tenantId, scope.id);
|
|
2446
|
+
if (!now ||
|
|
2447
|
+
(now.verticalVersionId ?? null) !== (scope.verticalVersionId ?? null) ||
|
|
2448
|
+
(now.servingRef ?? null) !== (scope.servingRef ?? null)) {
|
|
2449
|
+
throw new ControlPlaneError(412, `scope ${scope.id} was re-pointed while its data was being copied out of '${from}', so nothing was ` +
|
|
2450
|
+
`moved or bound (#1722). Reload the scope and retry.`);
|
|
2451
|
+
}
|
|
2452
|
+
if (dumpMetaValue(dump, CARRIED_AWAY_KEY) !== null) {
|
|
2453
|
+
throw new ControlPlaneError(412, `the copy of scope ${scope.id} in '${from}' was already carried to another script and wiped, so nothing ` +
|
|
2454
|
+
`was moved or bound (#1722). Reload the scope and retry.`);
|
|
2455
|
+
}
|
|
2456
|
+
const restored = await restoreCarryingSwitches(actor, dest, scope.tenantId, scope.id, dump, {
|
|
2457
|
+
scopeId: scope.id,
|
|
2458
|
+
exact: true,
|
|
2459
|
+
loadStamp: restoredStamp,
|
|
2460
|
+
...(destMarker === 'unfenced' ? {} : { expect: destMarker }),
|
|
2461
|
+
// The store refuses this load once the move's lease can have run out (#1722).
|
|
2462
|
+
fence: copyRestoreFence(moveId, recordedAt, copyLeaseMs, erasureEpoch),
|
|
2463
|
+
});
|
|
2464
|
+
return {
|
|
2465
|
+
moveId,
|
|
2466
|
+
erasureEpoch,
|
|
2467
|
+
from,
|
|
2468
|
+
to,
|
|
2469
|
+
tables: restored.tables,
|
|
2470
|
+
switchedOff: restored.switchedOff,
|
|
2471
|
+
source,
|
|
2472
|
+
dest,
|
|
2473
|
+
sourceStamp,
|
|
2474
|
+
sourceRevision,
|
|
2475
|
+
restoredStamp,
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
catch (error) {
|
|
2479
|
+
// Neither script can be discarded based on this unconfirmed move. A retry or reap
|
|
2480
|
+
// can still reach both. A crash leaves them pending and blocks key finalization.
|
|
2481
|
+
const settled = await Promise.allSettled([
|
|
2482
|
+
c.var.admin.settleScopeScriptCopy(actor, scope.tenantId, scope.id, from, moveId, 'retained'),
|
|
2483
|
+
c.var.admin.settleScopeScriptCopy(actor, scope.tenantId, scope.id, to, moveId, 'retained'),
|
|
2484
|
+
]);
|
|
2485
|
+
for (const result of settled) {
|
|
2486
|
+
if (result.status === 'rejected')
|
|
2487
|
+
recordCarryCleanup(c, scope, versionId, 'copy-ledger', result.reason);
|
|
2488
|
+
}
|
|
2489
|
+
throw error;
|
|
2490
|
+
}
|
|
2391
2491
|
};
|
|
2392
2492
|
/**
|
|
2393
2493
|
* Wipe one copy of a scope that no route reaches (#1722), without reaping it. Fenced where the
|
|
@@ -2505,14 +2605,29 @@ export function createControlPlaneApi(options) {
|
|
|
2505
2605
|
await dropUnboundCopy(c, scope, versionId, stale);
|
|
2506
2606
|
throw new CarrySourceChanged(scope.id, stale.from, stale.to, attempt);
|
|
2507
2607
|
}
|
|
2608
|
+
// The fresh carry restored over the stale one's copy in the same script and holds its own
|
|
2609
|
+
// entries for both ends, so the stale move leaves nothing of its own behind (#1722).
|
|
2610
|
+
await supersedeMove(c, scope, versionId, stale);
|
|
2508
2611
|
carried = next;
|
|
2509
2612
|
}
|
|
2613
|
+
// #1722: the write that moves the route confirms the move, while its lease is live. That is the
|
|
2614
|
+
// bind itself, unless a pin still routes the scope to the source until it is cleared below.
|
|
2615
|
+
// The source is confirmed `retained`, not `eligible`: `settleCarriedSource` may still have to
|
|
2616
|
+
// carry it again (an overtaken destination), and promotes it only once that is ruled out, so
|
|
2617
|
+
// no sweep can wipe it first. A request that dies in between leaves it kept, not swept.
|
|
2618
|
+
const unpins = Boolean(opts.clearServingRef && scope.servingRef);
|
|
2619
|
+
const confirmMove = carried
|
|
2620
|
+
? { confirmMove: { moveId: carried.moveId, source: 'retained',
|
|
2621
|
+
sourceMarker: { loadStamp: carried.sourceStamp, revision: carried.sourceRevision } } }
|
|
2622
|
+
: {};
|
|
2510
2623
|
try {
|
|
2511
2624
|
await c.var.admin
|
|
2512
2625
|
.bindScopeVersion(actor, scope.tenantId, scope.id, versionId, {
|
|
2513
2626
|
acknowledge: opts.acknowledge,
|
|
2514
2627
|
...(opts.snapshot ? { snapshot: true } : {}),
|
|
2515
2628
|
expectedVersionId: scope.verticalVersionId ?? null,
|
|
2629
|
+
...(carried ? { expectedErasureEpoch: carried.erasureEpoch } : {}),
|
|
2630
|
+
...(unpins ? {} : confirmMove),
|
|
2516
2631
|
})
|
|
2517
2632
|
.catch(relayHostRefusal);
|
|
2518
2633
|
}
|
|
@@ -2521,8 +2636,15 @@ export function createControlPlaneApi(options) {
|
|
|
2521
2636
|
await dropUnboundCopy(c, scope, versionId, carried);
|
|
2522
2637
|
throw e;
|
|
2523
2638
|
}
|
|
2524
|
-
if (
|
|
2525
|
-
|
|
2639
|
+
if (unpins) {
|
|
2640
|
+
try {
|
|
2641
|
+
await c.var.admin.setScopeServingRef(actor, scope.tenantId, scope.id, null, carried ? { expectedErasureEpoch: carried.erasureEpoch, ...confirmMove } : undefined).catch(relayHostRefusal);
|
|
2642
|
+
}
|
|
2643
|
+
catch (e) {
|
|
2644
|
+
if (carried)
|
|
2645
|
+
await dropUnboundCopy(c, scope, versionId, carried);
|
|
2646
|
+
throw e;
|
|
2647
|
+
}
|
|
2526
2648
|
}
|
|
2527
2649
|
try {
|
|
2528
2650
|
await c.var.admin.reassertSystemSwitches(actor, { tenantId: scope.tenantId, scopeId: scope.id }, { appliedInUnit: carried?.switchedOff });
|
|
@@ -2534,19 +2656,36 @@ export function createControlPlaneApi(options) {
|
|
|
2534
2656
|
await settleCarriedSource(c, scope, versionId, carried);
|
|
2535
2657
|
}
|
|
2536
2658
|
};
|
|
2659
|
+
/** Settle a carry that a fresh carry into the same script replaced. Best effort, recorded. */
|
|
2660
|
+
const supersedeMove = async (c, scope, versionId, stale) => {
|
|
2661
|
+
const settled = await Promise.allSettled([stale.from, stale.to].map((ref) => c.var.admin.settleScopeScriptCopy(c.get('actor'), scope.tenantId, scope.id, ref, stale.moveId, 'done')));
|
|
2662
|
+
for (const result of settled) {
|
|
2663
|
+
if (result.status === 'rejected')
|
|
2664
|
+
recordCarryCleanup(c, scope, versionId, 'copy-ledger', result.reason);
|
|
2665
|
+
}
|
|
2666
|
+
};
|
|
2537
2667
|
/**
|
|
2538
2668
|
* The copy a carry restored for a bind that did not land, wiped unless the scope routes to
|
|
2539
2669
|
* that script now. Best effort: it never throws, and a failure is recorded.
|
|
2540
2670
|
*/
|
|
2541
2671
|
const dropUnboundCopy = async (c, scope, versionId, carried) => {
|
|
2672
|
+
let route = null;
|
|
2542
2673
|
try {
|
|
2543
|
-
|
|
2544
|
-
if (route === carried.to)
|
|
2674
|
+
route = await currentRoute(c, scope, versionId, carried);
|
|
2675
|
+
if (route === carried.to) {
|
|
2676
|
+
await c.var.admin.settleScopeScriptCopy(c.get('actor'), scope.tenantId, scope.id, carried.to, carried.moveId, 'done');
|
|
2545
2677
|
return;
|
|
2546
|
-
|
|
2678
|
+
}
|
|
2679
|
+
const wiped = await wipeCarriedCopy(carried.dest, scope, { loadStamp: carried.restoredStamp }, route ?? carried.from);
|
|
2680
|
+
await c.var.admin.settleScopeScriptCopy(c.get('actor'), scope.tenantId, scope.id, carried.to, carried.moveId, wiped ? 'done' : 'retained');
|
|
2547
2681
|
}
|
|
2548
2682
|
catch (e) {
|
|
2549
2683
|
recordCarryCleanup(c, scope, versionId, 'unbound-copy', e);
|
|
2684
|
+
await c.var.admin.settleScopeScriptCopy(c.get('actor'), scope.tenantId, scope.id, carried.to, carried.moveId, 'retained').catch((err) => recordCarryCleanup(c, scope, versionId, 'unbound-copy-ledger', err));
|
|
2685
|
+
}
|
|
2686
|
+
finally {
|
|
2687
|
+
await c.var.admin.settleScopeScriptCopy(c.get('actor'), scope.tenantId, scope.id, carried.from, carried.moveId, route === carried.from ? 'done' : 'retained')
|
|
2688
|
+
.catch((err) => recordCarryCleanup(c, scope, versionId, 'source-copy-ledger', err));
|
|
2550
2689
|
}
|
|
2551
2690
|
};
|
|
2552
2691
|
/**
|
|
@@ -2628,8 +2767,10 @@ export function createControlPlaneApi(options) {
|
|
|
2628
2767
|
// The caller read the route elsewhere, so a copy that changed in any way since the export (a
|
|
2629
2768
|
// load as much as a write: a staff restore-forward into it racing this carry, Codex #2008 r8)
|
|
2630
2769
|
// is kept in the store rather than left an unmarked orphan.
|
|
2631
|
-
if (await wipeCarriedCopy(carried.source, scope, expect, carried.to, true))
|
|
2770
|
+
if (await wipeCarriedCopy(carried.source, scope, expect, carried.to, true)) {
|
|
2771
|
+
await c.var.admin.settleScopeScriptCopy(c.get('actor'), scope.tenantId, scope.id, carried.from, carried.moveId, 'done', expect);
|
|
2632
2772
|
return;
|
|
2773
|
+
}
|
|
2633
2774
|
// A rollback that restored into it since made it the live store again: it is no kept copy, and
|
|
2634
2775
|
// a marker this refusal set while the rollback had not yet bound comes off.
|
|
2635
2776
|
const route = await currentRoute(c, scope, versionId, carried);
|
|
@@ -2637,8 +2778,11 @@ export function createControlPlaneApi(options) {
|
|
|
2637
2778
|
await releaseIfLive(c, scope, versionId, carried, carried.source, carried.from);
|
|
2638
2779
|
return;
|
|
2639
2780
|
}
|
|
2640
|
-
if (route === carried.to && (await bringClearForward(c, scope, versionId, carried)))
|
|
2781
|
+
if (route === carried.to && (await bringClearForward(c, scope, versionId, carried))) {
|
|
2782
|
+
await c.var.admin.settleScopeScriptCopy(c.get('actor'), scope.tenantId, scope.id, carried.from, carried.moveId, 'done', expect);
|
|
2641
2783
|
return;
|
|
2784
|
+
}
|
|
2785
|
+
await c.var.admin.settleScopeScriptCopy(c.get('actor'), scope.tenantId, scope.id, carried.from, carried.moveId, 'kept', expect);
|
|
2642
2786
|
recordCarryCleanup(c, scope, versionId, 'source-copy-kept', new ControlPlaneError(409, `scope ${scope.id}'s copy in '${carried.from}' changed after it was carried to '${carried.to}', so it was ` +
|
|
2643
2787
|
`kept: it holds what reached it in between, which the carry did not copy (#1722)`));
|
|
2644
2788
|
};
|
|
@@ -2658,35 +2802,68 @@ export function createControlPlaneApi(options) {
|
|
|
2658
2802
|
*/
|
|
2659
2803
|
const settleCarriedSource = async (c, scope, versionId, carried) => {
|
|
2660
2804
|
const actor = c.get('actor');
|
|
2805
|
+
const finishDestination = async () => {
|
|
2806
|
+
if ((await currentRoute(c, scope, versionId, carried)) === carried.to) {
|
|
2807
|
+
await c.var.admin.settleScopeScriptCopy(actor, scope.tenantId, scope.id, carried.to, carried.moveId, 'done');
|
|
2808
|
+
}
|
|
2809
|
+
else {
|
|
2810
|
+
await dropUnboundCopy(c, scope, versionId, carried);
|
|
2811
|
+
}
|
|
2812
|
+
};
|
|
2661
2813
|
try {
|
|
2662
2814
|
const route = await currentRoute(c, scope, versionId, carried);
|
|
2663
|
-
if (route
|
|
2815
|
+
if (route !== carried.to) {
|
|
2816
|
+
await dropUnboundCopy(c, scope, versionId, carried);
|
|
2664
2817
|
return;
|
|
2818
|
+
}
|
|
2665
2819
|
// The store this carry landed in is live; a kept marker another carry set while racing it
|
|
2666
2820
|
// does not belong on it (#1722 r8).
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
if (
|
|
2821
|
+
await releaseIfLive(c, scope, versionId, carried, carried.dest, carried.to);
|
|
2822
|
+
let sourceMarker = { loadStamp: carried.sourceStamp, revision: carried.sourceRevision };
|
|
2823
|
+
if (await isCarriedAway(carried.dest, scope)) {
|
|
2670
2824
|
const { tables: dump, loadStamp: stamp, revision } = await retryTransient(() => carried.source.exportScopeStamped(scope.id));
|
|
2671
2825
|
if (dumpMetaValue(dump, CARRIED_AWAY_KEY) !== null) {
|
|
2672
2826
|
throw new ControlPlaneError(500, `both copies of scope ${scope.id} hold the carried_away tombstone; nothing was wiped`);
|
|
2673
2827
|
}
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2828
|
+
// The re-carry is a copy move of its own (#1722): in the ledger and leased before it
|
|
2829
|
+
// writes, and fenced, so it cannot land after a reap or an erasure has settled the scope.
|
|
2830
|
+
const recarryId = ulid();
|
|
2831
|
+
const restoredStamp = ulid();
|
|
2832
|
+
const recordedAt = Date.now();
|
|
2833
|
+
await c.var.admin.recordScopeScriptCopy(actor, scope.tenantId, scope.id, carried.to, recarryId, { role: 'destination', loadStamp: restoredStamp, leaseMs: copyLeaseMs });
|
|
2834
|
+
try {
|
|
2835
|
+
const erasureEpoch = await c.var.admin.scopeErasureEpoch(actor, scope.tenantId, scope.id);
|
|
2836
|
+
await restoreCarryingSwitches(actor, carried.dest, scope.tenantId, scope.id, dump, {
|
|
2837
|
+
scopeId: scope.id,
|
|
2838
|
+
exact: true,
|
|
2839
|
+
loadStamp: restoredStamp,
|
|
2840
|
+
fence: copyRestoreFence(recarryId, recordedAt, copyLeaseMs, erasureEpoch),
|
|
2841
|
+
});
|
|
2842
|
+
}
|
|
2843
|
+
finally {
|
|
2844
|
+
// The scope routes to this store, so the route reaches whatever landed in it.
|
|
2845
|
+
await c.var.admin.settleScopeScriptCopy(actor, scope.tenantId, scope.id, carried.to, recarryId, 'done');
|
|
2846
|
+
}
|
|
2679
2847
|
if (await isCarriedAway(carried.dest, scope)) {
|
|
2680
2848
|
throw new ControlPlaneError(500, `scope ${scope.id}'s store in '${carried.to}' was wiped again; its source copy stays`);
|
|
2681
2849
|
}
|
|
2682
2850
|
// Re-carried, so the fence expects what the re-export read.
|
|
2683
|
-
|
|
2851
|
+
sourceMarker = { loadStamp: stamp, revision };
|
|
2852
|
+
}
|
|
2853
|
+
// Only now may the sweep wipe the source: the destination holds the data.
|
|
2854
|
+
const eligible = await c.var.admin.settleScopeScriptCopy(actor, scope.tenantId, scope.id, carried.from, carried.moveId, 'eligible', sourceMarker);
|
|
2855
|
+
if (!eligible) {
|
|
2856
|
+
await finishDestination(); // a later move through this script superseded this carry
|
|
2684
2857
|
return;
|
|
2685
2858
|
}
|
|
2686
|
-
await keepOrWipeSource(c, scope, versionId, carried,
|
|
2859
|
+
await keepOrWipeSource(c, scope, versionId, carried, sourceMarker);
|
|
2860
|
+
await finishDestination();
|
|
2687
2861
|
}
|
|
2688
2862
|
catch (e) {
|
|
2689
2863
|
recordCarryCleanup(c, scope, versionId, 'source-copy', e);
|
|
2864
|
+
// This call can no longer restore another destination copy. Clear its pending
|
|
2865
|
+
// move if it is still serving, even when the source wipe needs a sweep retry.
|
|
2866
|
+
await finishDestination().catch((err) => recordCarryCleanup(c, scope, versionId, 'destination-copy-ledger', err));
|
|
2690
2867
|
}
|
|
2691
2868
|
};
|
|
2692
2869
|
app.get('/tenants/:tenantId/scopes/:scopeId/tables', async (c) => {
|
|
@@ -3592,12 +3769,53 @@ export function createControlPlaneApi(options) {
|
|
|
3592
3769
|
// on); the DIRECTORY half — provenance row, activation, version bind — runs here.
|
|
3593
3770
|
// With no vertical client resolved (co-located host, tests, self-host) the host's
|
|
3594
3771
|
// in-process snapshotScope does both halves against its own SCOPE namespace.
|
|
3772
|
+
/**
|
|
3773
|
+
* The script a slug-resolved scope lives in, by the hosted resolver's rule: the vertical's
|
|
3774
|
+
* stable serving script, else its prod channel's version script (control-plane worker
|
|
3775
|
+
* `resolveVerticalFor`). Null unless the platform can also reach that script by ref.
|
|
3776
|
+
*/
|
|
3777
|
+
const slugRefOf = async (c, slug) => {
|
|
3778
|
+
const actor = c.get('actor');
|
|
3779
|
+
const serving = await c.var.admin.verticalServing(actor, slug).catch(() => null);
|
|
3780
|
+
let ref = serving?.ref ?? null;
|
|
3781
|
+
if (!ref) {
|
|
3782
|
+
const prod = (await c.var.admin.listChannels(actor, slug).catch(() => [])).find((ch) => ch.channel === 'prod');
|
|
3783
|
+
ref = prod ? ((await c.var.admin.getVersion(actor, prod.versionId, slug))?.deploymentRef ?? null) : null;
|
|
3784
|
+
}
|
|
3785
|
+
return ref && (await options.resolveVerticalRef?.(ref)) ? ref : null;
|
|
3786
|
+
};
|
|
3595
3787
|
const orchestratedSnapshot = async (c, tenantId, scope, opts) => {
|
|
3596
3788
|
const actor = c.get('actor');
|
|
3597
|
-
const
|
|
3789
|
+
const deployment = await deploymentForScope(c, scope);
|
|
3790
|
+
const vertical = deployment?.client;
|
|
3598
3791
|
if (!vertical)
|
|
3599
3792
|
return c.var.host.snapshotScope(actor, tenantId, scope.id, opts);
|
|
3600
3793
|
const snapId = scopeIdSchema.parse(ulid());
|
|
3794
|
+
// #1722: the script the copy lands in (the source's own), named in the ledger before the
|
|
3795
|
+
// copy, so a bind that fails, or a request that dies, leaves a fork reap and erasure reach.
|
|
3796
|
+
// A pinned or bound source names it with the same value its client was resolved from. A
|
|
3797
|
+
// source resolved by slug is resolved once, here (`slugRefOf`), and copied through THAT
|
|
3798
|
+
// ref's client, so a serving move or a promote between two reads cannot put the bytes in
|
|
3799
|
+
// one script and the ledger entry on another. Without a ref (no per-script resolution, or
|
|
3800
|
+
// a slug script the platform cannot reach by ref) the fork has no entry, and reap and
|
|
3801
|
+
// erasure reach it through the slug resolution instead.
|
|
3802
|
+
let ref = null;
|
|
3803
|
+
let copier = vertical;
|
|
3804
|
+
if (options.resolveVerticalRef) {
|
|
3805
|
+
if (deployment.via === 'serving-script')
|
|
3806
|
+
ref = scope.servingRef ?? null;
|
|
3807
|
+
else if (deployment.via === 'bound-version' && scope.vertical && scope.verticalVersionId) {
|
|
3808
|
+
ref = (await c.var.admin.getVersion(actor, scope.verticalVersionId, scope.vertical))?.deploymentRef ?? null;
|
|
3809
|
+
}
|
|
3810
|
+
else if (deployment.via === 'slug' && scope.vertical) {
|
|
3811
|
+
ref = await slugRefOf(c, scope.vertical);
|
|
3812
|
+
const byRef = ref ? await options.resolveVerticalRef(ref) : undefined;
|
|
3813
|
+
if (byRef)
|
|
3814
|
+
copier = byRef;
|
|
3815
|
+
else
|
|
3816
|
+
ref = null;
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3601
3819
|
// Directory row FIRST, as `provisioning` (K-31's two-phase shape, used as
|
|
3602
3820
|
// intended): a crash between the row and the data copy leaves an inert
|
|
3603
3821
|
// provisioning row — which, carrying provenance and an expiry, the GC sweep
|
|
@@ -3612,12 +3830,34 @@ export function createControlPlaneApi(options) {
|
|
|
3612
3830
|
forkedAt: new Date().toISOString(),
|
|
3613
3831
|
expiresAt: opts.expiresAt,
|
|
3614
3832
|
});
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3833
|
+
const moveId = ref ? ulid() : null;
|
|
3834
|
+
try {
|
|
3835
|
+
if (moveId && ref) {
|
|
3836
|
+
// No load stamp: the vertical's in-script copy leaves none, so a crash-recovery sweep keeps
|
|
3837
|
+
// this copy (`retained`) rather than wiping it, and reap and erasure still reach it.
|
|
3838
|
+
await c.var.admin.recordScopeScriptCopy(actor, tenantId, snapId, ref, moveId, { role: 'destination', leaseMs: copyLeaseMs });
|
|
3839
|
+
}
|
|
3840
|
+
await retryTransient(() => copier.snapshotScope({ sourceScopeId: scope.id, newScopeId: snapId, tenantId }));
|
|
3841
|
+
await c.var.admin.activateScope(actor, tenantId, snapId);
|
|
3842
|
+
// Bound to the SOURCE's current version: source and fork share a deployment, so
|
|
3843
|
+
// the fork resolves to the DO namespace its bytes actually live in.
|
|
3844
|
+
if (scope.verticalVersionId) {
|
|
3845
|
+
await c.var.admin.bindScopeVersion(actor, tenantId, snapId, scope.verticalVersionId);
|
|
3846
|
+
}
|
|
3847
|
+
// The fork's route is the script its bytes were copied into, never the serving pointer
|
|
3848
|
+
// `provisionScope` stamped (which may have moved since), and the write that sets it confirms
|
|
3849
|
+
// the move. A fork of a bound source routes by its bound version, whose script is `ref`: its
|
|
3850
|
+
// stamped pin is cleared, so a later bind of the fork carries its data like any other scope's.
|
|
3851
|
+
// A fork of a pinned or slug-resolved source is pinned to `ref`, the script it lives in.
|
|
3852
|
+
if (moveId && ref) {
|
|
3853
|
+
await c.var.admin.setScopeServingRef(actor, tenantId, snapId, deployment.via === 'bound-version' ? null : ref, { confirmMove: { moveId, source: 'retained' } });
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
catch (e) {
|
|
3857
|
+
if (moveId && ref) {
|
|
3858
|
+
await c.var.admin.settleScopeScriptCopy(actor, tenantId, snapId, ref, moveId, 'retained').catch(() => undefined);
|
|
3859
|
+
}
|
|
3860
|
+
throw e;
|
|
3621
3861
|
}
|
|
3622
3862
|
return snapId;
|
|
3623
3863
|
};
|
|
@@ -3677,6 +3917,37 @@ export function createControlPlaneApi(options) {
|
|
|
3677
3917
|
throw e;
|
|
3678
3918
|
}
|
|
3679
3919
|
};
|
|
3920
|
+
/**
|
|
3921
|
+
* Delete a scope's storage in every script that may hold it, before the directory forgets it.
|
|
3922
|
+
* Answers whether some of it is stranded (a script that predates `/internal/delete-scope`).
|
|
3923
|
+
*
|
|
3924
|
+
* #1722: with per-script resolution, the ledger names every copy a move or a fork wrote, and
|
|
3925
|
+
* the route names the live store; `reapScopeScriptCopies` reaches both, under the reap claim.
|
|
3926
|
+
* A scope nothing routes (no pin, no bound version) may still keep its store where its vertical
|
|
3927
|
+
* resolves by slug, which is where it was provisioned, so that deployment is reached too, as
|
|
3928
|
+
* it was before the ledger. Without per-script resolution there is only that deployment, and
|
|
3929
|
+
* a ledgered copy the platform cannot reach refuses the reap.
|
|
3930
|
+
*/
|
|
3931
|
+
const reapScopeStorage = async (c, scope) => {
|
|
3932
|
+
const actor = c.get('actor');
|
|
3933
|
+
// The deployment the scope's vertical resolves to, deleted through; a 501 there is recorded
|
|
3934
|
+
// as stranded storage, as `reapScopeScriptCopies` records its own.
|
|
3935
|
+
const viaDeployment = async () => {
|
|
3936
|
+
const stranded = await deleteScopeStorageOrStrand(await verticalForScope(c, scope), scope.tenantId, scope.id);
|
|
3937
|
+
if (stranded) {
|
|
3938
|
+
await recordStrandedStorage(c.var.admin, actor, scope.tenantId, scope.id, `the deployment its vertical '${scope.vertical}' resolves to`);
|
|
3939
|
+
}
|
|
3940
|
+
return stranded;
|
|
3941
|
+
};
|
|
3942
|
+
if (!options.resolveVerticalRef) {
|
|
3943
|
+
await assertNoUnreachableScopeCopies(c.var.admin, actor, scope.tenantId, scope.id);
|
|
3944
|
+
return viaDeployment();
|
|
3945
|
+
}
|
|
3946
|
+
const unrouted = (await routeOf(c, scope)) === null;
|
|
3947
|
+
const { storageStranded } = await reapScopeScriptCopies({ admin: c.var.admin, actor, resolveRef: options.resolveVerticalRef }, scope.tenantId, scope.id);
|
|
3948
|
+
const bySlug = unrouted ? await viaDeployment() : false;
|
|
3949
|
+
return storageStranded || bySlug;
|
|
3950
|
+
};
|
|
3680
3951
|
app.delete('/tenants/:tenantId/scopes/:scopeId', async (c) => {
|
|
3681
3952
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
3682
3953
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
@@ -3692,8 +3963,7 @@ export function createControlPlaneApi(options) {
|
|
|
3692
3963
|
// local/placeholder wipe, hostnames + directory row, audit) — the same
|
|
3693
3964
|
// storage-before-row ordering deleteSnapshot itself keeps, so a crash
|
|
3694
3965
|
// between the two converges on retry.
|
|
3695
|
-
const
|
|
3696
|
-
const storageStranded = await deleteScopeStorageOrStrand(vertical, tenantId, scopeId);
|
|
3966
|
+
const storageStranded = await reapScopeStorage(c, scope);
|
|
3697
3967
|
await c.var.host.deleteSnapshot(actor, tenantId, scopeId);
|
|
3698
3968
|
return c.json({ deleted: scopeId, ...(storageStranded ? { storageStranded: true } : {}) });
|
|
3699
3969
|
}
|
|
@@ -3819,7 +4089,42 @@ export function createControlPlaneApi(options) {
|
|
|
3819
4089
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3820
4090
|
if (!scope)
|
|
3821
4091
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
3822
|
-
|
|
4092
|
+
const copies = await listAllScopeScriptCopies(c.var.admin, c.get('actor'), tenantId, scopeId);
|
|
4093
|
+
// A move in flight can still restore a copy no inventory read now would name. Its entries
|
|
4094
|
+
// settle when it confirms or fails, or, if it died, when the sweep finds its lease run out.
|
|
4095
|
+
if (copies.some((copy) => copy.state === 'pending')) {
|
|
4096
|
+
throw new ControlPlaneError(412, `scope ${scopeId} has a copy move in flight; retry the erasure after it settles`);
|
|
4097
|
+
}
|
|
4098
|
+
const erasureEpoch = await c.var.admin.scopeErasureEpoch(c.get('actor'), tenantId, scopeId);
|
|
4099
|
+
const currentRef = await routeOf(c, scope);
|
|
4100
|
+
if (!options.resolveVerticalRef) {
|
|
4101
|
+
if (currentRef || copies.some((copy) => copy.state !== 'done')) {
|
|
4102
|
+
throw new ControlPlaneError(502, `scope ${scopeId} has script copies, but this control plane cannot reach their deployments`);
|
|
4103
|
+
}
|
|
4104
|
+
return c.json(await c.var.admin.shredSubject(c.get('actor'), tenantId, scopeId, subjectId));
|
|
4105
|
+
}
|
|
4106
|
+
// A scope nothing routes may still keep its store where its vertical resolves by slug (where
|
|
4107
|
+
// it was provisioned), so erasure reaches that deployment too, as reap does (`reapScopeStorage`).
|
|
4108
|
+
const bySlug = currentRef ? undefined : await verticalForScope(c, scope);
|
|
4109
|
+
if (!currentRef && !bySlug && copies.every((copy) => copy.state === 'done')) {
|
|
4110
|
+
return c.json(await c.var.admin.shredSubject(c.get('actor'), tenantId, scopeId, subjectId));
|
|
4111
|
+
}
|
|
4112
|
+
const refs = new Set(copies.filter((copy) => copy.state !== 'done').map((copy) => copy.scriptRef));
|
|
4113
|
+
if (currentRef)
|
|
4114
|
+
refs.add(currentRef);
|
|
4115
|
+
const redactions = [];
|
|
4116
|
+
for (const ref of refs) {
|
|
4117
|
+
const script = await options.resolveVerticalRef(ref);
|
|
4118
|
+
if (!script)
|
|
4119
|
+
throw new ControlPlaneError(502, `scope ${scopeId}'s copy in '${ref}' cannot be reached for erasure`);
|
|
4120
|
+
redactions.push(await script.redactSubject(scopeId, subjectId));
|
|
4121
|
+
}
|
|
4122
|
+
if (bySlug)
|
|
4123
|
+
redactions.push(await bySlug.redactSubject(scopeId, subjectId));
|
|
4124
|
+
return c.json(await c.var.admin.finalizeSubjectShred(c.get('actor'), tenantId, scopeId, subjectId, redactions, {
|
|
4125
|
+
versionId: scope.verticalVersionId, servingRef: scope.servingRef ?? null, epoch: erasureEpoch,
|
|
4126
|
+
copyCount: copies.length,
|
|
4127
|
+
}).catch(relayHostRefusal));
|
|
3823
4128
|
});
|
|
3824
4129
|
// -- directory backups (#40) -----------------------------------------------
|
|
3825
4130
|
// The platform's own disaster recovery, on the same store posture as the scope
|
|
@@ -4266,10 +4571,9 @@ export function createControlPlaneApi(options) {
|
|
|
4266
4571
|
}
|
|
4267
4572
|
}
|
|
4268
4573
|
try {
|
|
4269
|
-
const vertical = await verticalForScope(c, scope);
|
|
4270
4574
|
// By this point the backup contract has resolved (a copy landed, or the caller
|
|
4271
4575
|
// explicitly declined one), so stranding is a bookkeeping fact, not data loss.
|
|
4272
|
-
const storageStranded = await
|
|
4576
|
+
const storageStranded = await reapScopeStorage(c, scope);
|
|
4273
4577
|
await c.var.admin.reapScope(actor, tenantId, scopeId, {
|
|
4274
4578
|
...(backup ? { backupRef: backupRefOf(backup) } : {}),
|
|
4275
4579
|
});
|
|
@@ -5008,6 +5312,23 @@ export function createControlPlaneApi(options) {
|
|
|
5008
5312
|
await bind({ acknowledge });
|
|
5009
5313
|
return c.json(await c.var.admin.getScopeRecord(actor, tenantId, scopeId));
|
|
5010
5314
|
});
|
|
5315
|
+
// -- copy-ledger backfill (#1722) ------------------------------------------------------------
|
|
5316
|
+
// Records the scripts a scope was copied into before the ledger existed, read from the admin
|
|
5317
|
+
// log, as `retained`: reached by reap and erasure from then on, never wiped from here
|
|
5318
|
+
// (`scope-copy-backfill.ts`). A dry run unless the body says otherwise, one page per call,
|
|
5319
|
+
// resumed with the `nextCursor` it answers. Staff only, by default-deny: on neither
|
|
5320
|
+
// BUILDER_ROUTES nor TENANT_ROUTES.
|
|
5321
|
+
const copyBackfillBody = z.object({
|
|
5322
|
+
dryRun: z.boolean().default(true),
|
|
5323
|
+
cursor: z.string().min(1).optional(),
|
|
5324
|
+
limit: z.number().int().min(1).max(BACKFILL_PAGE_MAX).optional(),
|
|
5325
|
+
}).strict();
|
|
5326
|
+
app.post('/scope-copies/backfill', async (c) => {
|
|
5327
|
+
const body = await readJsonBody(c, copyBackfillBody);
|
|
5328
|
+
if (!options.resolveVerticalRef)
|
|
5329
|
+
throw new ControlPlaneError(501, 'the copy backfill needs dispatch resolution by script');
|
|
5330
|
+
return c.json(await backfillScopeScriptCopies({ admin: c.var.admin, actor: c.get('actor'), resolveRef: options.resolveVerticalRef }, body));
|
|
5331
|
+
});
|
|
5011
5332
|
// -- instances (K-31) -------------------------------------------------------
|
|
5012
5333
|
// The one place this surface calls OUT rather than sitting over `HostAdmin`, and
|
|
5013
5334
|
// it is unavoidable: only the vertical can create a usable scope DO, because the
|
|
@@ -6622,6 +6943,45 @@ export function createControlPlaneApi(options) {
|
|
|
6622
6943
|
const services = await appServices(c.get('actor'), input.tenantId, input.scopeId, input.vertical);
|
|
6623
6944
|
return c.json(await options.observability.tenantLogs({ ...input, services }));
|
|
6624
6945
|
});
|
|
6946
|
+
// #1331: one app's sampled field reports, joined to the router's own request lines.
|
|
6947
|
+
app.get('/observability/tenant-field-coverage', async (c) => {
|
|
6948
|
+
if (!options.observability?.fieldCoverage) {
|
|
6949
|
+
return c.json({ error: 'field coverage is not configured on this control plane' }, 501);
|
|
6950
|
+
}
|
|
6951
|
+
const tenantId = confinedTenant(c.get('principal')) ?? c.req.query('tenantId');
|
|
6952
|
+
if (!tenantId)
|
|
6953
|
+
throw new ControlPlaneError(400, 'tenantId is required');
|
|
6954
|
+
const input = z.object({
|
|
6955
|
+
tenantId: tenantIdSchema,
|
|
6956
|
+
scopeId: z.string().min(1).max(64),
|
|
6957
|
+
versionId: z.string().min(1).max(64),
|
|
6958
|
+
hours: z.coerce.number().int().min(1).max(72).default(24),
|
|
6959
|
+
since: z.string().datetime({ offset: true }).optional(),
|
|
6960
|
+
until: z.string().datetime({ offset: true }).optional(),
|
|
6961
|
+
}).parse({ tenantId, scopeId: c.req.query('scopeId'), versionId: c.req.query('versionId'),
|
|
6962
|
+
hours: c.req.query('hours'), since: c.req.query('since') || undefined, until: c.req.query('until') || undefined });
|
|
6963
|
+
try {
|
|
6964
|
+
resolveObservabilityWindow(input);
|
|
6965
|
+
}
|
|
6966
|
+
catch (e) {
|
|
6967
|
+
throw new ControlPlaneError(400, e.message);
|
|
6968
|
+
}
|
|
6969
|
+
const scopes = await host.admin.listScopes(c.get('actor'), { tenantId: input.tenantId });
|
|
6970
|
+
const installed = scopes.find((s) => s.id === input.scopeId);
|
|
6971
|
+
if (!installed?.vertical)
|
|
6972
|
+
return c.json({ error: 'app not found' }, 404);
|
|
6973
|
+
const services = scriptFamiliesOfScopes(scopes, { scopeId: input.scopeId });
|
|
6974
|
+
const json = await c.var.admin.versionManifest(c.get('actor'), installed.vertical, input.versionId);
|
|
6975
|
+
if (!json)
|
|
6976
|
+
return c.json({ error: 'version not found' }, 404);
|
|
6977
|
+
const outputSurface = storedDeployManifest.parse(JSON.parse(json)).outputSurface ?? [];
|
|
6978
|
+
const declared = Object.fromEntries(outputSurface.map((op) => [op.operationId, op.fields]));
|
|
6979
|
+
return c.json(await options.observability.fieldCoverage({
|
|
6980
|
+
tenantId: input.tenantId, vertical: installed.vertical, scopeId: input.scopeId, services, declared,
|
|
6981
|
+
versionId: input.versionId, hours: input.hours,
|
|
6982
|
+
...(input.since ? { since: input.since, until: input.until } : {}),
|
|
6983
|
+
}));
|
|
6984
|
+
});
|
|
6625
6985
|
// #1746: the per-request record, read at the tenant grain. The three routes take ONE
|
|
6626
6986
|
// query — tenant, window, facet filters (`tenant-request-query.ts`) — so the histogram,
|
|
6627
6987
|
// the facet counts and the list beside them always describe the same set of requests.
|
|
@@ -7067,15 +7427,29 @@ export function createControlPlaneApi(options) {
|
|
|
7067
7427
|
* its hostnames. Storage-before-row, the same ordering the DELETE route uses — a crash
|
|
7068
7428
|
* between the two converges on retry. Shared by that route and by the create path, which
|
|
7069
7429
|
* reaps a HALF-BUILT leftover before re-forking (see `orchestratedPreview`). */
|
|
7430
|
+
/** Answers whether some of the preview's storage was stranded (and recorded as such). */
|
|
7070
7431
|
const reapPreview = async (c, preview) => {
|
|
7071
7432
|
// A fork's own sign-in clients go FIRST (#1704): a failure here leaves the preview in
|
|
7072
7433
|
// place, so the retry a PR-close job makes still finds it — after the row is gone, nothing
|
|
7073
7434
|
// would name those clients again. A clean room never had one.
|
|
7074
7435
|
await retireClientsOfReapedScope(previewAuthDeps(c), preview);
|
|
7075
|
-
|
|
7076
|
-
if (
|
|
7077
|
-
|
|
7436
|
+
let storageStranded = false;
|
|
7437
|
+
if (options.resolveVerticalRef) {
|
|
7438
|
+
({ storageStranded } = await reapScopeScriptCopies({
|
|
7439
|
+
admin: c.var.admin,
|
|
7440
|
+
actor: c.get('actor'),
|
|
7441
|
+
resolveRef: options.resolveVerticalRef,
|
|
7442
|
+
}, preview.tenantId, preview.id));
|
|
7443
|
+
}
|
|
7444
|
+
else {
|
|
7445
|
+
await assertNoUnreachableScopeCopies(c.var.admin, c.get('actor'), preview.tenantId, preview.id);
|
|
7446
|
+
const vertical = await verticalForScope(c, preview);
|
|
7447
|
+
if (vertical)
|
|
7448
|
+
await vertical.deleteScope({ tenantId: preview.tenantId, scopeId: preview.id });
|
|
7449
|
+
}
|
|
7450
|
+
// Co-located hosts have no script ref; their storage is wiped by deleteSnapshot.
|
|
7078
7451
|
await c.var.host.deleteSnapshot(c.get('actor'), preview.tenantId, preview.id);
|
|
7452
|
+
return { storageStranded };
|
|
7079
7453
|
};
|
|
7080
7454
|
/** The preview-login seam (#1704, `preview-auth.ts`): the directory, and each issuer's deployment. */
|
|
7081
7455
|
const previewAuthDeps = (c) => ({
|
|
@@ -7372,6 +7746,7 @@ export function createControlPlaneApi(options) {
|
|
|
7372
7746
|
}
|
|
7373
7747
|
};
|
|
7374
7748
|
let row;
|
|
7749
|
+
/** `fenced`: the ledger stamp and lease of this fork's restore (#1722), when it has one. */
|
|
7375
7750
|
let restore;
|
|
7376
7751
|
/** #2009: the directory's classification of the row below, which every restore of it carries. */
|
|
7377
7752
|
const markCopyOfRow = () => {
|
|
@@ -7410,7 +7785,7 @@ export function createControlPlaneApi(options) {
|
|
|
7410
7785
|
// pushes a fresh version per try) — #559 (2).
|
|
7411
7786
|
// #2009: a load classifies nothing by itself, so the restore carries the directory's
|
|
7412
7787
|
// classification of the row below, as every restore onto a non-primary scope does.
|
|
7413
|
-
restore = () => target.restoreScope(tenantId, previewId, tables, { sourceScopeId: source.id, exact: true, ...markCopyOfRow() });
|
|
7788
|
+
restore = (fenced) => target.restoreScope(tenantId, previewId, tables, { sourceScopeId: source.id, exact: true, ...markCopyOfRow(), ...fenced });
|
|
7414
7789
|
}
|
|
7415
7790
|
else {
|
|
7416
7791
|
// A clean-room preview (#509 (b)): an EMPTY scope, no source to export. No `forkedFrom`
|
|
@@ -7429,19 +7804,49 @@ export function createControlPlaneApi(options) {
|
|
|
7429
7804
|
expiresAt: expiresAt ?? undefined,
|
|
7430
7805
|
};
|
|
7431
7806
|
// #2009: marked a copy on the directory's word, as the fork above.
|
|
7432
|
-
restore = target ? () => target.restoreScope(tenantId, previewId, [], markCopyOfRow()) : null;
|
|
7807
|
+
restore = target ? (fenced) => target.restoreScope(tenantId, previewId, [], { ...markCopyOfRow(), ...fenced }) : null;
|
|
7433
7808
|
}
|
|
7809
|
+
// #1722: the PR version's script, named in the ledger before the restore writes into it.
|
|
7810
|
+
// Nothing routes the new row there until the bind, so without the entry a failure between
|
|
7811
|
+
// the two leaves a fork of the source's data that reap and erasure never reach.
|
|
7812
|
+
const targetRef = restore && options.resolveVerticalRef
|
|
7813
|
+
? ((await c.var.admin.getVersion(actor, opts.versionId, slug))?.deploymentRef ?? null)
|
|
7814
|
+
: null;
|
|
7815
|
+
let fork = null;
|
|
7434
7816
|
try {
|
|
7435
7817
|
// Inside the try: a host writes the directory row FIRST and then migrates, projects and
|
|
7436
7818
|
// seats, so a provision that throws can leave a `provisioning` row too. A provision
|
|
7437
7819
|
// that never wrote one (the tag's slug taken by a racing create) marks nothing: the
|
|
7438
7820
|
// write below targets this create's own id, which does not exist, and is swallowed.
|
|
7439
7821
|
await c.var.host.provisionScope(actor, row);
|
|
7440
|
-
if (restore)
|
|
7441
|
-
|
|
7822
|
+
if (restore) {
|
|
7823
|
+
let fenced = {};
|
|
7824
|
+
if (targetRef) {
|
|
7825
|
+
const moveId = ulid();
|
|
7826
|
+
const restoredStamp = ulid();
|
|
7827
|
+
const recordedAt = Date.now();
|
|
7828
|
+
await c.var.admin.recordScopeScriptCopy(actor, tenantId, previewId, targetRef, moveId, { role: 'destination', loadStamp: restoredStamp, leaseMs: copyLeaseMs });
|
|
7829
|
+
fork = { moveId };
|
|
7830
|
+
const erasureEpoch = await c.var.admin.scopeErasureEpoch(actor, tenantId, previewId);
|
|
7831
|
+
fenced = { loadStamp: restoredStamp, fence: copyRestoreFence(moveId, recordedAt, copyLeaseMs, erasureEpoch) };
|
|
7832
|
+
}
|
|
7833
|
+
const load = restore;
|
|
7834
|
+
await restoreOrRecord(() => load(fenced));
|
|
7835
|
+
}
|
|
7442
7836
|
await c.var.admin.activateScope(actor, tenantId, previewId);
|
|
7837
|
+
// Bind the PR version. A private vertical's push self-admitted, so this is accepted; a
|
|
7838
|
+
// preview scope also admits a pending version (#513), which is what a clean-room rehearsal
|
|
7839
|
+
// of not-yet-admitted code needs. It routes the row onto the fork, so it confirms the move.
|
|
7840
|
+
await c.var.admin.bindScopeVersion(actor, tenantId, previewId, opts.versionId, fork ? { confirmMove: { moveId: fork.moveId, source: 'retained' } } : undefined);
|
|
7443
7841
|
}
|
|
7444
7842
|
catch (e) {
|
|
7843
|
+
// The fork stays named for reap and erasure; if this request had died instead, the sweep
|
|
7844
|
+
// would settle the entry once its lease ran out.
|
|
7845
|
+
const unbound = fork;
|
|
7846
|
+
if (unbound && targetRef) {
|
|
7847
|
+
await c.var.admin.settleScopeScriptCopy(actor, tenantId, previewId, targetRef, unbound.moveId, 'retained')
|
|
7848
|
+
.catch(() => undefined);
|
|
7849
|
+
}
|
|
7445
7850
|
// Expire the row this create left behind, so the next create reaps it at once rather
|
|
7446
7851
|
// than refusing it as in flight for the whole bound (#1920) — that next create is the
|
|
7447
7852
|
// CLI's retry after a known-dead attempt (#1918). The row stays until then, or until
|
|
@@ -7452,10 +7857,6 @@ export function createControlPlaneApi(options) {
|
|
|
7452
7857
|
.catch(() => undefined);
|
|
7453
7858
|
throw e;
|
|
7454
7859
|
}
|
|
7455
|
-
// Bind the PR version. A private vertical's push self-admitted, so this is accepted; a
|
|
7456
|
-
// preview scope also admits a pending version (#513), which is what a clean-room rehearsal
|
|
7457
|
-
// of not-yet-admitted code needs.
|
|
7458
|
-
await c.var.admin.bindScopeVersion(actor, tenantId, previewId, opts.versionId);
|
|
7459
7860
|
const hostname = await bindPreviewHostname(c.var.admin, actor, baseHostname, tenantId, previewId, opts.tag, surface);
|
|
7460
7861
|
await assertServesBoundVersion(previewId);
|
|
7461
7862
|
const auth = await wireLogin(previewId, hostname, source);
|
|
@@ -7571,8 +7972,8 @@ export function createControlPlaneApi(options) {
|
|
|
7571
7972
|
// Storage-before-row, the same ordering as the fork hard-delete: wipe the DO in the
|
|
7572
7973
|
// PR version's deployment, then deleteSnapshot (fork-only re-check, hostnames + row,
|
|
7573
7974
|
// audit). A crash between the two converges on retry.
|
|
7574
|
-
await reapPreview(c, preview);
|
|
7575
|
-
return c.json({ deleted: preview.id });
|
|
7975
|
+
const { storageStranded } = await reapPreview(c, preview);
|
|
7976
|
+
return c.json({ deleted: preview.id, ...(storageStranded ? { storageStranded: true } : {}) });
|
|
7576
7977
|
}
|
|
7577
7978
|
catch (e) {
|
|
7578
7979
|
if (e instanceof ControlPlaneError)
|