@substrat-run/control-plane-api 0.154.0 → 0.156.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 +8 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +739 -108
- package/dist/api.js.map +1 -1
- package/dist/connection-binding-options.d.ts +13 -0
- package/dist/connection-binding-options.d.ts.map +1 -0
- package/dist/connection-binding-options.js +35 -0
- package/dist/connection-binding-options.js.map +1 -0
- package/dist/copy-job.d.ts +194 -0
- package/dist/copy-job.d.ts.map +1 -0
- package/dist/copy-job.js +237 -0
- package/dist/copy-job.js.map +1 -0
- package/dist/deploy.d.ts +1 -1
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +1 -1
- package/dist/deploy.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/platform-drain.d.ts +8 -0
- package/dist/platform-drain.d.ts.map +1 -1
- package/dist/platform-drain.js +2 -1
- package/dist/platform-drain.js.map +1 -1
- 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 +2 -2
- package/dist/platform-entry.generated.js.map +1 -1
- package/dist/vertical-client.d.ts +80 -7
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +118 -70
- package/dist/vertical-client.js.map +1 -1
- package/package.json +8 -6
package/dist/api.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PREVIEW_CONNECTIONS_REFUSAL } from './connection-relay.js';
|
|
2
|
+
import { connectionBindingOptions } from './connection-binding-options.js';
|
|
2
3
|
import { resolveObservabilityWindow } from './observability-window.js';
|
|
3
4
|
import { Hono } from 'hono';
|
|
4
5
|
import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_DRIVERS, STORE_RELEASE_MAX_BYTES, 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, FINDINGS_LIMIT_DEFAULT, FINDINGS_LIMIT_MAX, findingCursorOf, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, rebindAcknowledgement, 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, validationIssuesFrom, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, substratError, errorCodeOf, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, SOCIAL_RELAY_VERTICALS, } from '@substrat-run/contracts';
|
|
@@ -8,7 +9,8 @@ import { auditedChange, withAuditedOutcomes } from './audited-change.js';
|
|
|
8
9
|
import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isPrimaryScope, promoteReviewDigest, rebindReviewDigest, assertRebindReviewCurrent, migrationProgress, ulid, auditWarningOf, } from '@substrat-run/kernel';
|
|
9
10
|
import { TENANT_HEADER, confinedTenant } from './auth.js';
|
|
10
11
|
import { mintTenantToken } from './tenant-token.js';
|
|
11
|
-
import { connectionGrantsForScope, transferScope } from './vertical-client.js';
|
|
12
|
+
import { beginTransfer, connectionGrantsForScope, transferScope } from './vertical-client.js';
|
|
13
|
+
import { COPY_JOB_MAX_MS, copyJobAt, copyJobHeld, copyJobId, copyJobLive, copyTagId, versionIdOfOrder, versionOrderOf, } from './copy-job.js';
|
|
12
14
|
import { runningVersionOf, runningVersionStoresOf, scopeStoreReachOf, scopeStoresOf, unrouteScope } from './platform-scope-stores.js';
|
|
13
15
|
import { STORE_REBUILD_REQUEST_SUBREQUESTS, runStoreFleetRebuild, storeRebuildBudget, storeRebuildStatus } from './store-rebuild.js';
|
|
14
16
|
import { PUSH_HOLD_TTL_MS, runStoreDriverRollout, storeDriverRelease, storedReleaseLoader } from './store-releases.js';
|
|
@@ -25,7 +27,7 @@ import { NON_PROBLEM_REFUSAL, coded, mapError, relayed } from './errors.js';
|
|
|
25
27
|
import { maskDump, maskRecords } from './mask.js';
|
|
26
28
|
import { createPseudonymizer } from './pseudonymize.js';
|
|
27
29
|
import { openDump, sealDump } from './seal.js';
|
|
28
|
-
import { assertSandboxContract, verticalUploadBindings, migrationsDigestBindings, deployManifest, storedDeployManifest, pushedOwnerGrantProblems, pushedPermissionRegistry, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
|
|
30
|
+
import { assertSandboxContract, verticalUploadBindings, migrationsDigestBindings, deployManifest, storedDeployManifest, pushedOwnerGrantProblems, pushedModuleRegistryProblems, pushedPermissionRegistry, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
|
|
29
31
|
/**
|
|
30
32
|
* Answer with a problem document (#113 phase 4).
|
|
31
33
|
*
|
|
@@ -407,6 +409,14 @@ const createPreviewBody = z.object({
|
|
|
407
409
|
ttlHours: z.number().int().positive().max(720).nullable().optional(),
|
|
408
410
|
surface: surfaceName.optional(),
|
|
409
411
|
refresh: z.boolean().optional(),
|
|
412
|
+
// #2450 step 2: the caller can take a 202 and call again. A copy too large to finish in this
|
|
413
|
+
// request then moves in the background, and the 202 says how far it got; the same call, repeated,
|
|
414
|
+
// finishes the preview once it is done. A caller that does not say so is answered as before.
|
|
415
|
+
async: z.boolean().optional(),
|
|
416
|
+
// #2450: this call follows a 202 that named this call's own version, so it continues that copy and
|
|
417
|
+
// never starts one: it answers the copy's progress, finishes it once it is done, or says it is gone.
|
|
418
|
+
// A call after a 202 naming another push's copy (one being finished) is not a poll: it may start its own.
|
|
419
|
+
poll: z.boolean().optional(),
|
|
410
420
|
});
|
|
411
421
|
const listRolesQuery = z.object({
|
|
412
422
|
tenantId: tenantIdSchema.optional(),
|
|
@@ -2039,23 +2049,10 @@ export function createControlPlaneApi(options) {
|
|
|
2039
2049
|
if (target.status !== 'active' || !target.vertical || target.vertical !== row.vertical) {
|
|
2040
2050
|
return refuseStatus(c, 409, 'connection and target must share an active vertical scope');
|
|
2041
2051
|
}
|
|
2042
|
-
const
|
|
2052
|
+
const schema = connectionBindingOptions(row.provider).extend({
|
|
2043
2053
|
operation: z.string().regex(/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/),
|
|
2044
2054
|
permission: permissionKey,
|
|
2045
|
-
};
|
|
2046
|
-
const schema = row.provider === 'planima'
|
|
2047
|
-
? z.object({ ...fields,
|
|
2048
|
-
organizationId: z.number().int().positive().nullable().optional(),
|
|
2049
|
-
currency: z.string().regex(/^[A-Z]{3}$/).optional(),
|
|
2050
|
-
window: z.object({ fromYear: z.number().int(), toYear: z.number().int() })
|
|
2051
|
-
.refine((w) => w.toYear >= w.fromYear, 'window ends before it starts').nullable().optional(),
|
|
2052
|
-
horizonYears: z.number().int().min(0).optional(),
|
|
2053
|
-
}).strict()
|
|
2054
|
-
: row.provider === 'microsoft365'
|
|
2055
|
-
// #2101: the channel folder, by its drive path. The connector resolves it and holds the
|
|
2056
|
-
// fence to it; this only bounds the input.
|
|
2057
|
-
? z.object({ ...fields, folder: z.string().trim().min(1).max(400) }).strict()
|
|
2058
|
-
: z.object(fields).strict();
|
|
2055
|
+
});
|
|
2059
2056
|
const input = schema.parse(await c.req.json());
|
|
2060
2057
|
const grants = await c.var.host.connectionGrantsInScope(row.tenantId, scopeId);
|
|
2061
2058
|
if (!grants.some((g) => g.connectionId === row.id && g.permission === input.permission)) {
|
|
@@ -2746,6 +2743,209 @@ export function createControlPlaneApi(options) {
|
|
|
2746
2743
|
};
|
|
2747
2744
|
/** How many exports a carry tries before its source holds still long enough to bind (#1722 r12). */
|
|
2748
2745
|
const CARRY_EXPORT_ATTEMPTS = 3;
|
|
2746
|
+
const COPY_POLL_SECONDS = 5;
|
|
2747
|
+
const copyingAnswer = (job) => ({
|
|
2748
|
+
copying: true,
|
|
2749
|
+
scopeId: job.spec.scopeId,
|
|
2750
|
+
versionId: job.spec.versionId,
|
|
2751
|
+
progress: { pages: job.pages, rows: job.rows, attempt: job.attempt },
|
|
2752
|
+
retryAfter: COPY_POLL_SECONDS,
|
|
2753
|
+
});
|
|
2754
|
+
/**
|
|
2755
|
+
* #2450: a push older than the one a preview's job is copying for is refused, never allowed to
|
|
2756
|
+
* replace it. Otherwise two CI runs for one preview would cancel each other's copy at every poll.
|
|
2757
|
+
* Older by `versionOrderOf`: when the registry recorded each version, whatever its id. Only a job
|
|
2758
|
+
* that can still land its push's data holds an older one off (`copyJobLive`): one that failed, was
|
|
2759
|
+
* cancelled or was left unfinished past its lease is replaced like any other.
|
|
2760
|
+
*/
|
|
2761
|
+
const supersededRefusal = (job, mine, tag) => mine.order < job.spec.versionOrder && copyJobLive(job, Date.now())
|
|
2762
|
+
? new ControlPlaneError(409, `a newer push (version ${job.spec.versionId}) to preview '${tag}' ` +
|
|
2763
|
+
`${job.status === 'copying' ? 'is copying its data' : 'is finishing its copy'}, so this push ` +
|
|
2764
|
+
`(version ${mine.versionId}) was superseded and did nothing`)
|
|
2765
|
+
: null;
|
|
2766
|
+
/**
|
|
2767
|
+
* #2450: a create for an older version than one another call (a poll included) to the same preview
|
|
2768
|
+
* marked within `PREVIEW_CREATE_BOUND_MS` (`CopyJobs.markNewest`). It covers the moments no job
|
|
2769
|
+
* record names the newer push: a newer push forgets the fork job it replaces, then reaps that row
|
|
2770
|
+
* and provisions its own; and the time after its copy has landed and its job is forgotten. Once the
|
|
2771
|
+
* mark's hold has run out, and no newer push's job is copying, an older version (a deliberate
|
|
2772
|
+
* re-preview) is taken like any other create.
|
|
2773
|
+
*/
|
|
2774
|
+
const supersededByMark = (newest, mine, tag) => {
|
|
2775
|
+
const minutes = PREVIEW_CREATE_BOUND_MS / 60_000;
|
|
2776
|
+
return new ControlPlaneError(409, `a newer push (version ${versionIdOfOrder(newest)}) to preview '${tag}' called within the last ${minutes} min, ` +
|
|
2777
|
+
`so this push (version ${mine.versionId}) was superseded and did nothing. An older version is taken again ` +
|
|
2778
|
+
`${minutes} min after a newer push's last call, once no newer push's copy is running`);
|
|
2779
|
+
};
|
|
2780
|
+
/**
|
|
2781
|
+
* How long a call that claimed a done job holds it to finish it (#2450): twice the copy lease. The
|
|
2782
|
+
* holder renews the lease straight after claiming and can confirm the move only inside that lease,
|
|
2783
|
+
* so by the time its claim runs out it can no longer finish. One that died leaves the claim to run
|
|
2784
|
+
* out, and the next call then finds the lease gone, settles the move and frees the tag.
|
|
2785
|
+
*/
|
|
2786
|
+
const copyClaimMs = 2 * copyLeaseMs;
|
|
2787
|
+
/**
|
|
2788
|
+
* Stop a preview's copy job and settle its move (#2450): a refresh, a delete, a newer push that
|
|
2789
|
+
* replaces it, or the job's own failure. The move's entries settle `retained`, so reap and erasure
|
|
2790
|
+
* reach both scripts; the stage it left is discarded best-effort, and goes with any later wipe.
|
|
2791
|
+
* `unlessFinishing` (a newer push): a done job another call has claimed is that call's to finish or
|
|
2792
|
+
* give up, so it is left alone, and the answer is false.
|
|
2793
|
+
*/
|
|
2794
|
+
const settleJobMove = async (c, job, opts = {}) => {
|
|
2795
|
+
const jobs = options.copyJobs;
|
|
2796
|
+
if (!jobs)
|
|
2797
|
+
return true;
|
|
2798
|
+
// A step running right now writes nothing after this, and no call can claim the job after it:
|
|
2799
|
+
// its host keeps a cancel over a step's answer, and a claim takes only a done job.
|
|
2800
|
+
const latest = (await jobs.cancel(job.spec.id)) ?? job;
|
|
2801
|
+
if (opts.unlessFinishing && copyJobHeld(latest, Date.now()))
|
|
2802
|
+
return false;
|
|
2803
|
+
await settleJobEntries(c, job);
|
|
2804
|
+
await discardJobStages(job, latest);
|
|
2805
|
+
await jobs.forget(job.spec.id);
|
|
2806
|
+
return true;
|
|
2807
|
+
};
|
|
2808
|
+
/** A job's move, every entry settled `retained` (#2450), best-effort: a sweep settles what this misses. */
|
|
2809
|
+
const settleJobEntries = async (c, job) => {
|
|
2810
|
+
const { spec } = job;
|
|
2811
|
+
const actor = c.get('actor');
|
|
2812
|
+
const refs = spec.kind === 'carry' ? [spec.sourceRef, spec.destRef] : [spec.destRef];
|
|
2813
|
+
await Promise.allSettled(refs.map((ref) => c.var.admin.settleScopeScriptCopy(actor, spec.tenantId, spec.scopeId, ref, spec.moveId, 'retained')));
|
|
2814
|
+
};
|
|
2815
|
+
/**
|
|
2816
|
+
* Drop every stage these records of one job name at its destination (#2450), best-effort: once the
|
|
2817
|
+
* job is forgotten nothing else names them, and they would wait for a wipe of the scope there. A
|
|
2818
|
+
* stage that was loaded is gone already, and its discard changes nothing.
|
|
2819
|
+
*/
|
|
2820
|
+
const discardJobStages = async (...records) => {
|
|
2821
|
+
const { spec } = records[0];
|
|
2822
|
+
const staged = records.flatMap((r) => [r.state?.stageId, r.done && !Array.isArray(r.done.dump) ? r.done.dump.stageId : undefined]);
|
|
2823
|
+
const dest = await options.resolveVerticalRef?.(spec.destRef).catch(() => undefined);
|
|
2824
|
+
for (const stageId of new Set(staged.filter((id) => id !== undefined))) {
|
|
2825
|
+
await dest?.discardStagedScope(spec.scopeId, stageId).catch(() => undefined);
|
|
2826
|
+
}
|
|
2827
|
+
};
|
|
2828
|
+
/**
|
|
2829
|
+
* Run the whole finish of a job this call claimed (#2450), from the lease's renewal on. However it
|
|
2830
|
+
* fails (a refusal, a fence that lapsed, a renewal or a resolve that threw outright), the job's move
|
|
2831
|
+
* settles `retained` and the stage it was to load is discarded: its caller forgets the job next,
|
|
2832
|
+
* after which nothing would, and a refused load keeps its stage.
|
|
2833
|
+
*/
|
|
2834
|
+
const finishingJob = async (c, job, finish) => {
|
|
2835
|
+
try {
|
|
2836
|
+
return await finish();
|
|
2837
|
+
}
|
|
2838
|
+
catch (e) {
|
|
2839
|
+
await settleJobEntries(c, job);
|
|
2840
|
+
await discardJobStages(job);
|
|
2841
|
+
throw finishRefusal(c, job, e);
|
|
2842
|
+
}
|
|
2843
|
+
};
|
|
2844
|
+
/**
|
|
2845
|
+
* A finish of a claimed job that failed, as the route answers it (#2450). A refusal is answered as
|
|
2846
|
+
* itself. A fault (a 5xx: a load or a bind that failed past its own retries) is answered 409 naming
|
|
2847
|
+
* its status, as a job's own failure is (`refuseFailedJob`): the copy it finished is settled, and
|
|
2848
|
+
* only a new create starts it over, so it is no status to retry. The fault lands its ops-failure
|
|
2849
|
+
* row here, unless the step that met it recorded it.
|
|
2850
|
+
*/
|
|
2851
|
+
const finishRefusal = (c, job, e) => {
|
|
2852
|
+
const { status, body } = mapError(e);
|
|
2853
|
+
if (status < 500)
|
|
2854
|
+
return e;
|
|
2855
|
+
if (!(typeof e === 'object' && e !== null && recordedCauses.has(e))) {
|
|
2856
|
+
recordCopyFault(c, job, 'finish', status, messageOfThrow(e), { cause: e });
|
|
2857
|
+
}
|
|
2858
|
+
return new ControlPlaneError(409, `the background copy failed with ${status} while it was finished: ${body.detail ?? 'internal error'}`);
|
|
2859
|
+
};
|
|
2860
|
+
/**
|
|
2861
|
+
* The ops-failure row of a fault a copy met (#2450), which the route answers 409 rather than as a
|
|
2862
|
+
* 5xx the backstop would record (#559). A 501 is a capability statement, not a fault, and none.
|
|
2863
|
+
*/
|
|
2864
|
+
const recordCopyFault = (c, job, stage, status, message, opts = {}) => {
|
|
2865
|
+
if (status === 501)
|
|
2866
|
+
return;
|
|
2867
|
+
const entry = {
|
|
2868
|
+
actor: c.get('actor'),
|
|
2869
|
+
operation: 'preview.create',
|
|
2870
|
+
stage,
|
|
2871
|
+
tenantId: job.spec.tenantId,
|
|
2872
|
+
scopeId: job.spec.scopeId,
|
|
2873
|
+
vertical: job.spec.vertical,
|
|
2874
|
+
version: job.spec.versionId,
|
|
2875
|
+
status,
|
|
2876
|
+
message,
|
|
2877
|
+
};
|
|
2878
|
+
// A job's own record carries no throw: no cause, rather than an `undefined` one (`recordFailure`).
|
|
2879
|
+
if ('cause' in opts)
|
|
2880
|
+
recordFailure(entry, opts.cause);
|
|
2881
|
+
else
|
|
2882
|
+
recordFailure(entry);
|
|
2883
|
+
};
|
|
2884
|
+
/**
|
|
2885
|
+
* A preview's copy job (#2450), as of now (`copyJobAt`): one whose steps were lost past its
|
|
2886
|
+
* deadline reads as failed by it, so the route settles and answers it instead of waiting forever.
|
|
2887
|
+
*/
|
|
2888
|
+
const readCopyJob = async (id) => {
|
|
2889
|
+
const job = await options.copyJobs?.get(id);
|
|
2890
|
+
return job ? copyJobAt(job, Date.now()) : null;
|
|
2891
|
+
};
|
|
2892
|
+
/**
|
|
2893
|
+
* Every copy job a preview has (#2450), settled: before it is reaped or replaced. `by` is a create
|
|
2894
|
+
* replacing it, which a newer push's live copy refuses (`supersededRefusal`) before anything is
|
|
2895
|
+
* settled; a delete passes none, and removes whatever is there.
|
|
2896
|
+
*/
|
|
2897
|
+
const cancelPreviewJobs = async (c, preview, by) => {
|
|
2898
|
+
if (!options.copyJobs)
|
|
2899
|
+
return;
|
|
2900
|
+
const held = [];
|
|
2901
|
+
for (const kind of ['fork', 'carry']) {
|
|
2902
|
+
const job = await readCopyJob(copyJobId(kind, preview.id));
|
|
2903
|
+
if (job)
|
|
2904
|
+
held.push(job);
|
|
2905
|
+
}
|
|
2906
|
+
if (by) {
|
|
2907
|
+
for (const job of held) {
|
|
2908
|
+
const superseded = supersededRefusal(job, by.mine, by.tag);
|
|
2909
|
+
if (superseded)
|
|
2910
|
+
throw superseded;
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
for (const job of held)
|
|
2914
|
+
await settleJobMove(c, job);
|
|
2915
|
+
};
|
|
2916
|
+
/**
|
|
2917
|
+
* Finish a carry a job copied (#2450): renew the move's lease, which the restore's fence then
|
|
2918
|
+
* counts from, and run the same `carryFinish` an in-request carry runs. A move the job can no
|
|
2919
|
+
* longer finish is refused. A finish that fails is settled (`finishingJob`) and forgets the job;
|
|
2920
|
+
* one that lands leaves it claimed, for the caller to forget once its bind has landed or failed:
|
|
2921
|
+
* until then the preview is still bound to the version this carry replaces, and a newer push that
|
|
2922
|
+
* found no job would carry from there.
|
|
2923
|
+
*/
|
|
2924
|
+
const finishCarryJob = async (c, scope, versionId, job) => {
|
|
2925
|
+
const { spec } = job;
|
|
2926
|
+
const done = job.done;
|
|
2927
|
+
const actor = c.get('actor');
|
|
2928
|
+
try {
|
|
2929
|
+
return await finishingJob(c, job, async () => {
|
|
2930
|
+
const lease = await c.var.admin.renewScopeScriptCopy(actor, spec.tenantId, spec.scopeId, spec.moveId, { leaseMs: copyLeaseMs });
|
|
2931
|
+
const [source, dest] = await Promise.all([options.resolveVerticalRef?.(spec.sourceRef), options.resolveVerticalRef?.(spec.destRef)]);
|
|
2932
|
+
if (!lease || !source || !dest || !done || !spec.expected) {
|
|
2933
|
+
throw !lease
|
|
2934
|
+
? new ControlPlaneError(409, `the copy of scope ${spec.scopeId} was settled before it could be loaded; nothing was moved`)
|
|
2935
|
+
: new ControlPlaneError(502, `the copy of scope ${spec.scopeId} could not reach both of its scripts to finish`);
|
|
2936
|
+
}
|
|
2937
|
+
const expected = spec.expected;
|
|
2938
|
+
return carryWith(c, scope, versionId, {
|
|
2939
|
+
moveId: spec.moveId, erasureEpoch: spec.erasureEpoch, from: spec.sourceRef, to: spec.destRef,
|
|
2940
|
+
source, dest, restoredStamp: spec.restoredStamp, leasedAt: lease.renewedAt, expected,
|
|
2941
|
+
}, async () => done);
|
|
2942
|
+
});
|
|
2943
|
+
}
|
|
2944
|
+
catch (e) {
|
|
2945
|
+
await options.copyJobs?.forget(spec.id).catch(() => undefined);
|
|
2946
|
+
throw e;
|
|
2947
|
+
}
|
|
2948
|
+
};
|
|
2749
2949
|
/**
|
|
2750
2950
|
* Carry a scope's data onto the script a version bind is about to route it to (#1710).
|
|
2751
2951
|
* The caller binds only after this returns.
|
|
@@ -2794,6 +2994,16 @@ export function createControlPlaneApi(options) {
|
|
|
2794
2994
|
* between lineages is `rebindScopeOntoVertical`'s job, behind its migration gate.
|
|
2795
2995
|
*/
|
|
2796
2996
|
const carryOntoVersion = async (c, scope, versionId, opts = {}) => {
|
|
2997
|
+
const move = await carryBegin(c, scope, versionId, opts);
|
|
2998
|
+
if (!move)
|
|
2999
|
+
return null;
|
|
3000
|
+
// #2450: a page at a time into the destination's stage, so a scope of any size moves. The
|
|
3001
|
+
// stage moves neither the destination's load stamp nor its write revision, so the marker
|
|
3002
|
+
// `carryFinish` reads next still describes the store the restore commits into.
|
|
3003
|
+
return carryWith(c, scope, versionId, move, () => transferScope(move.source, move.dest, { from: scope.id }, { retry: retryTransient }));
|
|
3004
|
+
};
|
|
3005
|
+
/** The two ends a carry moves between, resolved and checked (`carryOntoVersion`), or null when nothing moves. */
|
|
3006
|
+
const carryEnds = async (c, scope, versionId, opts = {}) => {
|
|
2797
3007
|
const resolveVersion = options.resolveVerticalVersion;
|
|
2798
3008
|
if (!scope.vertical)
|
|
2799
3009
|
return null;
|
|
@@ -2829,6 +3039,15 @@ export function createControlPlaneApi(options) {
|
|
|
2829
3039
|
throw new ControlPlaneError(502, `cannot move scope ${scope.id} from '${from}' to '${to}': ${missing} could not be reached, ` +
|
|
2830
3040
|
`so the version was not bound (#1710)`);
|
|
2831
3041
|
}
|
|
3042
|
+
return { source, dest, from, to };
|
|
3043
|
+
};
|
|
3044
|
+
/** `carryOntoVersion`'s checks and its ledger entries, up to the copy (#2450): null when nothing moves. */
|
|
3045
|
+
const carryBegin = async (c, scope, versionId, opts = {}) => {
|
|
3046
|
+
const ends = await carryEnds(c, scope, versionId, opts);
|
|
3047
|
+
if (!ends)
|
|
3048
|
+
return null;
|
|
3049
|
+
const { source, dest, from, to } = ends;
|
|
3050
|
+
const actor = c.get('actor');
|
|
2832
3051
|
// Before exporting or restoring anything, retain the script identity in the directory, so a
|
|
2833
3052
|
// failed carry stays named for reap and erasure. Both entries are leased to this move: if this
|
|
2834
3053
|
// request dies, a sweep settles them once the lease runs out, and the move can then no longer
|
|
@@ -2845,10 +3064,47 @@ export function createControlPlaneApi(options) {
|
|
|
2845
3064
|
// cleanup. Name it before any bytes are restored so reap and erasure can find it.
|
|
2846
3065
|
await c.var.admin.recordScopeScriptCopy(actor, scope.tenantId, scope.id, to, moveId, { role: 'destination', loadStamp: restoredStamp, leaseMs: copyLeaseMs });
|
|
2847
3066
|
const erasureEpoch = await c.var.admin.scopeErasureEpoch(actor, scope.tenantId, scope.id);
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
3067
|
+
return {
|
|
3068
|
+
moveId, erasureEpoch, from, to, source, dest, restoredStamp, leasedAt: recordedAt,
|
|
3069
|
+
expected: { versionId: scope.verticalVersionId ?? null, servingRef: scope.servingRef ?? null },
|
|
3070
|
+
};
|
|
3071
|
+
}
|
|
3072
|
+
catch (error) {
|
|
3073
|
+
await settleCarryRetained(c, scope, versionId, { moveId, from, to });
|
|
3074
|
+
throw error;
|
|
3075
|
+
}
|
|
3076
|
+
};
|
|
3077
|
+
/** Neither script of an unconfirmed move can be discarded: a retry or reap can still reach both. */
|
|
3078
|
+
const settleCarryRetained = async (c, scope, versionId, move) => {
|
|
3079
|
+
const actor = c.get('actor');
|
|
3080
|
+
// A crash leaves them pending and blocks key finalization.
|
|
3081
|
+
const settled = await Promise.allSettled([
|
|
3082
|
+
c.var.admin.settleScopeScriptCopy(actor, scope.tenantId, scope.id, move.from, move.moveId, 'retained'),
|
|
3083
|
+
c.var.admin.settleScopeScriptCopy(actor, scope.tenantId, scope.id, move.to, move.moveId, 'retained'),
|
|
3084
|
+
]);
|
|
3085
|
+
for (const result of settled) {
|
|
3086
|
+
if (result.status === 'rejected')
|
|
3087
|
+
recordCarryCleanup(c, scope, versionId, 'copy-ledger', result.reason);
|
|
3088
|
+
}
|
|
3089
|
+
};
|
|
3090
|
+
/**
|
|
3091
|
+
* Finish a carry whose data `transfer` moves (#2450): the copy, then the #1722 checks and the
|
|
3092
|
+
* restore. Any failure settles both ends retained.
|
|
3093
|
+
*/
|
|
3094
|
+
const carryWith = async (c, scope, versionId, move, transfer) => {
|
|
3095
|
+
try {
|
|
3096
|
+
return await carryFinish(c, scope, move, await transfer());
|
|
3097
|
+
}
|
|
3098
|
+
catch (error) {
|
|
3099
|
+
await settleCarryRetained(c, scope, versionId, move);
|
|
3100
|
+
throw error;
|
|
3101
|
+
}
|
|
3102
|
+
};
|
|
3103
|
+
const carryFinish = async (c, scope, move, transferred) => {
|
|
3104
|
+
const actor = c.get('actor');
|
|
3105
|
+
const { moveId, erasureEpoch, from, to, source, dest, restoredStamp } = move;
|
|
3106
|
+
const { dump, loadStamp: sourceStamp, revision: sourceRevision, meta } = transferred;
|
|
3107
|
+
{
|
|
2852
3108
|
// What the destination holds now, read before the binding is checked again. The restore
|
|
2853
3109
|
// sends it back and is refused if the store moved since: another carry into the same script
|
|
2854
3110
|
// loaded it, or it went live under another carry's bind and took a write. That is a CI retry
|
|
@@ -2856,8 +3112,8 @@ export function createControlPlaneApi(options) {
|
|
|
2856
3112
|
const destMarker = await retryTransient(() => dest.loadMarker(scope.id));
|
|
2857
3113
|
const now = await c.var.admin.getScopeRecord(actor, scope.tenantId, scope.id);
|
|
2858
3114
|
if (!now ||
|
|
2859
|
-
(now.verticalVersionId ?? null) !==
|
|
2860
|
-
(now.servingRef ?? null) !==
|
|
3115
|
+
(now.verticalVersionId ?? null) !== move.expected.versionId ||
|
|
3116
|
+
(now.servingRef ?? null) !== move.expected.servingRef) {
|
|
2861
3117
|
throw new ControlPlaneError(412, `scope ${scope.id} was re-pointed while its data was being copied out of '${from}', so nothing was ` +
|
|
2862
3118
|
`moved or bound (#1722). Reload the scope and retry.`);
|
|
2863
3119
|
}
|
|
@@ -2871,7 +3127,7 @@ export function createControlPlaneApi(options) {
|
|
|
2871
3127
|
loadStamp: restoredStamp,
|
|
2872
3128
|
...(destMarker === 'unfenced' ? {} : { expect: destMarker }),
|
|
2873
3129
|
// The store refuses this load once the move's lease can have run out (#1722).
|
|
2874
|
-
fence: copyRestoreFence(moveId,
|
|
3130
|
+
fence: copyRestoreFence(moveId, move.leasedAt, copyLeaseMs, erasureEpoch),
|
|
2875
3131
|
});
|
|
2876
3132
|
return {
|
|
2877
3133
|
moveId,
|
|
@@ -2887,19 +3143,6 @@ export function createControlPlaneApi(options) {
|
|
|
2887
3143
|
restoredStamp,
|
|
2888
3144
|
};
|
|
2889
3145
|
}
|
|
2890
|
-
catch (error) {
|
|
2891
|
-
// Neither script can be discarded based on this unconfirmed move. A retry or reap
|
|
2892
|
-
// can still reach both. A crash leaves them pending and blocks key finalization.
|
|
2893
|
-
const settled = await Promise.allSettled([
|
|
2894
|
-
c.var.admin.settleScopeScriptCopy(actor, scope.tenantId, scope.id, from, moveId, 'retained'),
|
|
2895
|
-
c.var.admin.settleScopeScriptCopy(actor, scope.tenantId, scope.id, to, moveId, 'retained'),
|
|
2896
|
-
]);
|
|
2897
|
-
for (const result of settled) {
|
|
2898
|
-
if (result.status === 'rejected')
|
|
2899
|
-
recordCarryCleanup(c, scope, versionId, 'copy-ledger', result.reason);
|
|
2900
|
-
}
|
|
2901
|
-
throw error;
|
|
2902
|
-
}
|
|
2903
3146
|
};
|
|
2904
3147
|
/**
|
|
2905
3148
|
* Wipe one copy of a scope that no route reaches (#1722), without reaping it. Fenced where the
|
|
@@ -6403,13 +6646,19 @@ export function createControlPlaneApi(options) {
|
|
|
6403
6646
|
const checked = storedDeployManifest.safeParse(raw);
|
|
6404
6647
|
if (!checked.success)
|
|
6405
6648
|
return refuseManifest(checked.error.issues[0]);
|
|
6649
|
+
if (checked.data.modules !== undefined && !checked.data.registry) {
|
|
6650
|
+
return refuseManifest({ path: ['registry'], message: 'required when modules are present' });
|
|
6651
|
+
}
|
|
6406
6652
|
// #1869: a registry handed to this route now is NEW, so it answers to the push-time rule on
|
|
6407
6653
|
// entity-grant shapes (no kernel namespace as an entity type), as `deployManifest` does.
|
|
6408
|
-
//
|
|
6654
|
+
// A legacy manifest with neither modules nor registry stays accepted, as above.
|
|
6409
6655
|
if (checked.data.registry) {
|
|
6410
6656
|
const registry = pushedPermissionRegistry.safeParse(checked.data.registry);
|
|
6411
6657
|
if (!registry.success)
|
|
6412
6658
|
return refuseManifest(registry.error.issues[0], ['registry']);
|
|
6659
|
+
const moduleProblems = pushedModuleRegistryProblems(checked.data.modules, registry.data);
|
|
6660
|
+
if (moduleProblems.length > 0)
|
|
6661
|
+
return refuseManifest({ path: ['registry', 'permissions'], message: moduleProblems.join('; ') });
|
|
6413
6662
|
}
|
|
6414
6663
|
// #2430: an owner grant is a person's, so it holds no key the registry reserves.
|
|
6415
6664
|
const ownerGrants = pushedOwnerGrantProblems(checked.data);
|
|
@@ -7116,7 +7365,14 @@ export function createControlPlaneApi(options) {
|
|
|
7116
7365
|
const raw = form.get('manifest');
|
|
7117
7366
|
if (typeof raw !== 'string')
|
|
7118
7367
|
return refuse(c, 'validation_failed', 'missing manifest part');
|
|
7119
|
-
const
|
|
7368
|
+
const parsed = JSON.parse(raw);
|
|
7369
|
+
if (parsed !== null && typeof parsed === 'object' && 'modules' in parsed && !('registry' in parsed)) {
|
|
7370
|
+
return refuse(c, 'validation_failed', 'manifest registry: required when modules are present');
|
|
7371
|
+
}
|
|
7372
|
+
const manifest = deployManifest.parse(parsed);
|
|
7373
|
+
const moduleProblems = pushedModuleRegistryProblems(manifest.modules, manifest.registry);
|
|
7374
|
+
if (moduleProblems.length > 0)
|
|
7375
|
+
return refuse(c, 'validation_failed', `manifest registry.permissions: ${moduleProblems.join('; ')}`);
|
|
7120
7376
|
// #2430: an owner grant is a person's, so it holds no key the registry reserves.
|
|
7121
7377
|
const ownerGrants = pushedOwnerGrantProblems(manifest);
|
|
7122
7378
|
if (ownerGrants.length > 0)
|
|
@@ -8331,6 +8587,31 @@ export function createControlPlaneApi(options) {
|
|
|
8331
8587
|
source, opts) => {
|
|
8332
8588
|
const actor = c.get('actor');
|
|
8333
8589
|
const surface = opts.surface ?? 'app';
|
|
8590
|
+
// #2450 step 2: where a copy that outlasts this request may run — only for a caller that takes a
|
|
8591
|
+
// 202 and calls again, and on a host whose jobs can reach each end by its script.
|
|
8592
|
+
const jobs = opts.async && options.resolveVerticalRef ? options.copyJobs : undefined;
|
|
8593
|
+
// #2450: a call that continues the copy an earlier call of this push started. It never starts a
|
|
8594
|
+
// copy, reaps a row or forks one, so no answer lost or misread on the way back can turn into a
|
|
8595
|
+
// copy started over: only a new create (a new push) does that.
|
|
8596
|
+
const poll = jobs !== undefined && opts.poll === true;
|
|
8597
|
+
/** What a poll is answered when the copy it was waiting on is no longer there. */
|
|
8598
|
+
const copyGone = () => new ControlPlaneError(409, `the copy for preview '${opts.tag}' (version ${opts.versionId}) is no longer running: it failed, a newer push ` +
|
|
8599
|
+
`or a refresh replaced it, or the preview was deleted. Nothing was moved; a new create starts the copy over`);
|
|
8600
|
+
/**
|
|
8601
|
+
* A job's failure, as the route answers it. A refusal keeps the status and sentence the
|
|
8602
|
+
* synchronous copy would have answered. A fault the copy met (a 5xx: its deadline, an end it
|
|
8603
|
+
* could not reach, a stage it could not keep) is answered 409, naming that status: the call
|
|
8604
|
+
* reporting it did not fail, the copy did, earlier. A 502–504 reads to every client as a
|
|
8605
|
+
* gateway's, which it retries, though nothing about the copy will change. The fault is recorded
|
|
8606
|
+
* here, since its answer is no 5xx for the backstop to see (#559).
|
|
8607
|
+
*/
|
|
8608
|
+
const refuseFailedJob = (job) => {
|
|
8609
|
+
const error = job.error ?? { status: 409, message: `the copy of scope ${job.spec.scopeId} was cancelled before it finished; nothing was moved` };
|
|
8610
|
+
if (error.status < 500)
|
|
8611
|
+
return new ControlPlaneError(error.status, error.message);
|
|
8612
|
+
recordCopyFault(c, job, 'copy', error.status, error.message);
|
|
8613
|
+
return new ControlPlaneError(409, `the background copy failed with ${error.status}: ${error.message}`);
|
|
8614
|
+
};
|
|
8334
8615
|
// #527 guard: a preview MUST serve the version it just bound. Routing resolves
|
|
8335
8616
|
// `COALESCE(scope.servingRef, version.deploymentRef)` (control-plane-do `readRoute`),
|
|
8336
8617
|
// so a preview that still carries an inherited `serving_ref` — or otherwise resolves away
|
|
@@ -8356,6 +8637,19 @@ export function createControlPlaneApi(options) {
|
|
|
8356
8637
|
// PUSHES THE DEADLINE FORWARD: without this a preview CI re-pushes to would be reaped 72h
|
|
8357
8638
|
// after its first creation no matter how alive it is (preview-and-snapshots.md §9).
|
|
8358
8639
|
const expiresAt = opts.ttlHours === null ? null : new Date(Date.now() + (opts.ttlHours ?? 72) * 3_600_000).toISOString();
|
|
8640
|
+
/**
|
|
8641
|
+
* #2450: a preview whose copy a job is running keeps its deadline counted from the latest call,
|
|
8642
|
+
* as a finished create's is, so a copy longer than the TTL is not met by the sweep's reap. Each
|
|
8643
|
+
* write is audited, so it is written only once the stored deadline has fallen more than
|
|
8644
|
+
* `PREVIEW_CREATE_BOUND_MS` behind this call's. Best-effort: the finish writes it again.
|
|
8645
|
+
*/
|
|
8646
|
+
const keepAlive = async (row, moveId) => {
|
|
8647
|
+
const due = expiresAt === null
|
|
8648
|
+
? row.expiresAt !== null
|
|
8649
|
+
: row.expiresAt === null || Date.parse(row.expiresAt) < Date.parse(expiresAt) - PREVIEW_CREATE_BOUND_MS;
|
|
8650
|
+
if (due)
|
|
8651
|
+
await c.var.admin.keepAlivePendingScopeCopy(actor, tenantId, row.id, moveId, row.status, row.expiresAt, expiresAt).catch(() => undefined);
|
|
8652
|
+
};
|
|
8359
8653
|
// Residency (K-7/K-32): forking pins the copy's EXECUTION to the preview deployment.
|
|
8360
8654
|
// Anything tighter than `global` cannot be honoured until Regional Services, so refuse
|
|
8361
8655
|
// it here rather than record a residency claim with no mechanism (same gate as export).
|
|
@@ -8369,7 +8663,37 @@ export function createControlPlaneApi(options) {
|
|
|
8369
8663
|
// every push is its own script, so the rebind first CARRIES the preview's data into
|
|
8370
8664
|
// the new version's script (#1710). The preview's data then follows it from push to
|
|
8371
8665
|
// push, and each push's migrations run forward over it (§4's rehearsal case).
|
|
8372
|
-
|
|
8666
|
+
let existing = (await c.var.admin.listScopes(actor, { tenantId, vertical: slug })).find((s) => s.kind === 'preview' && s.slug === previewSlug(slug, opts.tag));
|
|
8667
|
+
// #2450 step 2: a fork a job is copying. While it copies, this call answers its progress, and
|
|
8668
|
+
// once it is done, this call finishes the preview. A failed job frees the tag with its error, so
|
|
8669
|
+
// a new create forks afresh; one a refresh or a newer push replaces is stopped, and its row
|
|
8670
|
+
// expired, so the code below reaps it at once.
|
|
8671
|
+
const forkJob = existing?.status === 'provisioning' ? await readCopyJob(copyJobId('fork', existing.id)) : null;
|
|
8672
|
+
// #2450: where this push stands against another's, on a host that runs copies as jobs.
|
|
8673
|
+
const incoming = options.copyJobs ? await c.var.admin.getVersion(actor, opts.versionId, slug) : undefined;
|
|
8674
|
+
if (options.copyJobs && !incoming)
|
|
8675
|
+
throw new ControlPlaneError(404, `unknown version ${opts.versionId} for vertical '${slug}'`);
|
|
8676
|
+
// Compared only where a job is found, which is only on such a host, where it was read.
|
|
8677
|
+
const mine = { versionId: opts.versionId, order: incoming ? versionOrderOf(incoming) : '' };
|
|
8678
|
+
// Every call marks its push the newest of this preview's, for as long as a create's request can
|
|
8679
|
+
// run, before it acts: an older one meanwhile is refused, whatever the rows and jobs say then. A
|
|
8680
|
+
// poll marks too, so the hold runs on past the push's last call however long its copy took.
|
|
8681
|
+
if (options.copyJobs) {
|
|
8682
|
+
const tagId = copyTagId(tenantId, previewSlug(slug, opts.tag));
|
|
8683
|
+
const newest = await options.copyJobs.markNewest(tagId, mine.order, PREVIEW_CREATE_BOUND_MS);
|
|
8684
|
+
if (newest > mine.order)
|
|
8685
|
+
throw supersededByMark(newest, mine, opts.tag);
|
|
8686
|
+
}
|
|
8687
|
+
// A poll goes on only to a fork job (below, where one for another version refuses it) or a live
|
|
8688
|
+
// preview (the carry, below). Anything else is a copy gone: never reaped, never forked afresh.
|
|
8689
|
+
// The row was listed before the job was read, so a call finishing the fork meanwhile (a poll
|
|
8690
|
+
// retried beside it) may have activated it since: it is read again before the copy is called gone.
|
|
8691
|
+
if (poll && !(existing && (forkJob || existing.status === 'active'))) {
|
|
8692
|
+
const fresh = existing ? await c.var.admin.getScopeRecord(actor, tenantId, existing.id) : undefined;
|
|
8693
|
+
if (fresh?.status !== 'active')
|
|
8694
|
+
throw copyGone();
|
|
8695
|
+
existing = fresh;
|
|
8696
|
+
}
|
|
8373
8697
|
// A preview only HAS data once its two-phase create finished: the directory row lands
|
|
8374
8698
|
// first as `provisioning` (K-31), the fork's export→restore runs, and `activateScope`
|
|
8375
8699
|
// is the last step. So a row still at `provisioning` is a create that DIED mid-fork —
|
|
@@ -8387,7 +8711,7 @@ export function createControlPlaneApi(options) {
|
|
|
8387
8711
|
// what a create in flight looks like, and reaping it wipes the scope that create is
|
|
8388
8712
|
// restoring into. So a young one, not marked dead by its own frame, is refused instead.
|
|
8389
8713
|
// `refresh` is the explicit ask to replace whatever is there, and still reaps.
|
|
8390
|
-
if (existing && !opts.refresh && existing.status === 'provisioning') {
|
|
8714
|
+
if (existing && !forkJob && !opts.refresh && existing.status === 'provisioning') {
|
|
8391
8715
|
const now = Date.now();
|
|
8392
8716
|
const reclaimAt = previewReclaimAt(existing, now);
|
|
8393
8717
|
if (now < reclaimAt) {
|
|
@@ -8452,6 +8776,179 @@ export function createControlPlaneApi(options) {
|
|
|
8452
8776
|
// The `--<tag>` URL's base — the source's canonical hostname (fork) or the tenant-app
|
|
8453
8777
|
// convention (clean-room). Computed once so reuse and fresh mint the same URL.
|
|
8454
8778
|
const baseHostname = await previewBaseHostname(actor, source, tenantId, slug, surface);
|
|
8779
|
+
// The founding #559 case lands its durable row HERE, not in onError: the previews
|
|
8780
|
+
// route answers a ControlPlaneError directly (its own catch, never the app-level
|
|
8781
|
+
// recorder), and only this frame knows the preview's scopeId (`intoId`) — the key that lets
|
|
8782
|
+
// the console explain the stranded `provisioning` row this throw leaves behind.
|
|
8783
|
+
const restoreOrRecord = async (fn, intoId) => {
|
|
8784
|
+
try {
|
|
8785
|
+
await retryTransient(fn);
|
|
8786
|
+
}
|
|
8787
|
+
catch (e) {
|
|
8788
|
+
if (e instanceof ControlPlaneError && e.status >= 500 && e.status !== 501) {
|
|
8789
|
+
recordFailure({
|
|
8790
|
+
actor,
|
|
8791
|
+
operation: 'preview.create',
|
|
8792
|
+
stage: 'restore',
|
|
8793
|
+
tenantId,
|
|
8794
|
+
scopeId: intoId,
|
|
8795
|
+
vertical: slug,
|
|
8796
|
+
version: opts.versionId,
|
|
8797
|
+
status: e.status,
|
|
8798
|
+
message: e.message,
|
|
8799
|
+
}, e);
|
|
8800
|
+
}
|
|
8801
|
+
throw e;
|
|
8802
|
+
}
|
|
8803
|
+
};
|
|
8804
|
+
/**
|
|
8805
|
+
* #1722 for a fork (#2450): a source that a staff move carried to another script while the fork
|
|
8806
|
+
* read it was wiped where it was read, and its pages then read as the tombstone it left. A fork
|
|
8807
|
+
* never loads that, as a carry never does (`carryFinish`).
|
|
8808
|
+
*/
|
|
8809
|
+
const refuseWipedSource = (meta, sourceId) => {
|
|
8810
|
+
if (dumpMetaValue(meta, CARRIED_AWAY_KEY) !== null) {
|
|
8811
|
+
throw new ControlPlaneError(412, `the source of preview '${opts.tag}' (scope ${sourceId}) was carried to another script and wiped while it ` +
|
|
8812
|
+
`was copied, so nothing was moved. Run the create again`);
|
|
8813
|
+
}
|
|
8814
|
+
};
|
|
8815
|
+
const abandonFork = async (previewId, fork, targetRef) => {
|
|
8816
|
+
// The fork stays named for reap and erasure; if this request had died instead, the sweep
|
|
8817
|
+
// would settle the entry once its lease ran out.
|
|
8818
|
+
if (fork && targetRef) {
|
|
8819
|
+
await c.var.admin.settleScopeScriptCopy(actor, tenantId, previewId, targetRef, fork.moveId, 'retained')
|
|
8820
|
+
.catch(() => undefined);
|
|
8821
|
+
}
|
|
8822
|
+
// Expire the row this create left behind, so the next create reaps it at once rather
|
|
8823
|
+
// than refusing it as in flight for the whole bound (#1920) — that next create is the
|
|
8824
|
+
// CLI's retry after a known-dead attempt (#1918). The row stays until then, or until
|
|
8825
|
+
// the GC sweep's next pass reaps it as expired; the #559 ops-failure row outlives both.
|
|
8826
|
+
// Best-effort: if this write fails too, the age bound still frees the tag.
|
|
8827
|
+
await c.var.admin
|
|
8828
|
+
.setScopeExpiresAt(actor, tenantId, previewId, new Date().toISOString())
|
|
8829
|
+
.catch(() => undefined);
|
|
8830
|
+
};
|
|
8831
|
+
const completeFork = async (p) => {
|
|
8832
|
+
await landFork(p);
|
|
8833
|
+
return servedFork(p);
|
|
8834
|
+
};
|
|
8835
|
+
/** A fork's data loaded, its row activated and the version bound: the fork has landed. */
|
|
8836
|
+
const landFork = async (p) => {
|
|
8837
|
+
try {
|
|
8838
|
+
if (p.load)
|
|
8839
|
+
await restoreOrRecord(p.load, p.previewId);
|
|
8840
|
+
await c.var.admin.activateScope(actor, tenantId, p.previewId);
|
|
8841
|
+
// Bind the PR version. A private vertical's push self-admitted, so this is accepted; a
|
|
8842
|
+
// preview scope also admits a pending version (#513), which is what a clean-room rehearsal
|
|
8843
|
+
// of not-yet-admitted code needs. It routes the row onto the fork, so it confirms the move.
|
|
8844
|
+
await c.var.admin.bindScopeVersion(actor, tenantId, p.previewId, opts.versionId, p.fork ? { confirmMove: { moveId: p.fork.moveId, source: 'retained' } } : undefined);
|
|
8845
|
+
}
|
|
8846
|
+
catch (e) {
|
|
8847
|
+
// A bind that committed and lost only its answer has landed the fork: abandoning it would
|
|
8848
|
+
// expire a row that serves this version. The carry draws the same line (`bindAfterCarry`'s caller).
|
|
8849
|
+
const now = await c.var.admin.getScopeRecord(actor, tenantId, p.previewId).catch(() => undefined);
|
|
8850
|
+
if (now?.status === 'active' && now.verticalVersionId === opts.versionId)
|
|
8851
|
+
return;
|
|
8852
|
+
await abandonFork(p.previewId, p.fork, p.targetRef);
|
|
8853
|
+
throw e;
|
|
8854
|
+
}
|
|
8855
|
+
};
|
|
8856
|
+
/** What follows a landed fork: its hostname, the #527 guard and its login. A fault here leaves it served. */
|
|
8857
|
+
const servedFork = async (p) => {
|
|
8858
|
+
const hostname = await bindPreviewHostname(c.var.admin, actor, baseHostname, tenantId, p.previewId, opts.tag, surface);
|
|
8859
|
+
await assertServesBoundVersion(p.previewId);
|
|
8860
|
+
const auth = await wireLogin(p.previewId, hostname, p.parent);
|
|
8861
|
+
return {
|
|
8862
|
+
scopeId: p.previewId,
|
|
8863
|
+
hostname,
|
|
8864
|
+
url: `https://${hostname}`,
|
|
8865
|
+
versionId: opts.versionId,
|
|
8866
|
+
reused: false,
|
|
8867
|
+
auth,
|
|
8868
|
+
notes: notesFor(p.parent),
|
|
8869
|
+
};
|
|
8870
|
+
};
|
|
8871
|
+
/**
|
|
8872
|
+
* Finish a fork a job copied (#2450): renew its lease, which the restore's fence then counts
|
|
8873
|
+
* from, and load the staged dump into the preview as an in-request fork would have. However it
|
|
8874
|
+
* fails before it has landed, its move is settled (`finishingJob`) and the row expired, so the
|
|
8875
|
+
* next create reaps it.
|
|
8876
|
+
*/
|
|
8877
|
+
const finishForkJob = async (preview, job) => {
|
|
8878
|
+
const { spec } = job;
|
|
8879
|
+
const fork = { moveId: spec.moveId };
|
|
8880
|
+
const done = job.done;
|
|
8881
|
+
try {
|
|
8882
|
+
// The copy's part of the finish ends once the fork has landed: a fault after that (its
|
|
8883
|
+
// hostname, the guard, its login) is the served preview's, answered as itself.
|
|
8884
|
+
const landed = await finishingJob(c, job, async () => {
|
|
8885
|
+
let load;
|
|
8886
|
+
let parent;
|
|
8887
|
+
try {
|
|
8888
|
+
const lease = await c.var.admin.renewScopeScriptCopy(actor, tenantId, preview.id, spec.moveId, { leaseMs: copyLeaseMs });
|
|
8889
|
+
if (!lease || !target || !done || !preview.forkedFrom) {
|
|
8890
|
+
throw new ControlPlaneError(409, `the copy into preview '${opts.tag}' was settled before it could be loaded; nothing was moved`);
|
|
8891
|
+
}
|
|
8892
|
+
const forkedFrom = preview.forkedFrom;
|
|
8893
|
+
refuseWipedSource(done.meta, forkedFrom);
|
|
8894
|
+
parent = (await c.var.admin.getScopeRecord(actor, tenantId, forkedFrom)) ?? null;
|
|
8895
|
+
const lineage = copyLineageOf({ kind: preview.kind ?? '', forkedFrom });
|
|
8896
|
+
const fence = copyRestoreFence(spec.moveId, lease.renewedAt, copyLeaseMs, spec.erasureEpoch);
|
|
8897
|
+
load = () => target.restoreScope(tenantId, preview.id, done.dump, {
|
|
8898
|
+
sourceScopeId: forkedFrom, exact: true, ...(lineage ? { markCopy: lineage } : {}),
|
|
8899
|
+
loadStamp: spec.restoredStamp, fence,
|
|
8900
|
+
});
|
|
8901
|
+
}
|
|
8902
|
+
catch (e) {
|
|
8903
|
+
// Nothing was loaded: the row goes as a failed load leaves it (`landFork`).
|
|
8904
|
+
await abandonFork(preview.id, fork, spec.destRef);
|
|
8905
|
+
throw e;
|
|
8906
|
+
}
|
|
8907
|
+
const landing = { previewId: preview.id, parent, fork, targetRef: spec.destRef, load };
|
|
8908
|
+
await landFork(landing);
|
|
8909
|
+
return landing;
|
|
8910
|
+
});
|
|
8911
|
+
// Its row's deadline counts from this create, as every create's does: the copy may have
|
|
8912
|
+
// outlasted the one its first call set.
|
|
8913
|
+
await c.var.admin.setScopeExpiresAt(actor, tenantId, preview.id, expiresAt);
|
|
8914
|
+
return await servedFork(landed);
|
|
8915
|
+
}
|
|
8916
|
+
finally {
|
|
8917
|
+
await options.copyJobs?.forget(spec.id).catch(() => undefined);
|
|
8918
|
+
}
|
|
8919
|
+
};
|
|
8920
|
+
if (existing && forkJob) {
|
|
8921
|
+
// A refresh too: it replaces whatever is there but a newer push's copy, as the mark holds it off.
|
|
8922
|
+
const superseded = supersededRefusal(forkJob, mine, opts.tag);
|
|
8923
|
+
if (superseded)
|
|
8924
|
+
throw superseded;
|
|
8925
|
+
const replaced = opts.refresh || forkJob.spec.versionId !== opts.versionId;
|
|
8926
|
+
// A poll's own fork is this one or none: it replaces nothing.
|
|
8927
|
+
if (poll && replaced)
|
|
8928
|
+
throw copyGone();
|
|
8929
|
+
const row = existing;
|
|
8930
|
+
const inProgress = async () => {
|
|
8931
|
+
if (!jobs)
|
|
8932
|
+
throw new ControlPlaneError(409, `a create for preview '${opts.tag}' is in progress (its data is still being copied). Retry once it finishes`);
|
|
8933
|
+
await keepAlive(row, forkJob.spec.moveId);
|
|
8934
|
+
return copyingAnswer(forkJob);
|
|
8935
|
+
};
|
|
8936
|
+
if (!replaced && forkJob.status === 'copying')
|
|
8937
|
+
return inProgress();
|
|
8938
|
+
if (!replaced && forkJob.status === 'done') {
|
|
8939
|
+
// Only the call that claims a done job finishes it. Any other waits, and never renews,
|
|
8940
|
+
// settles or expires what that call is finishing (or has just finished).
|
|
8941
|
+
const claimed = await options.copyJobs?.claim(forkJob.spec.id, copyClaimMs);
|
|
8942
|
+
return claimed ? finishForkJob(existing, claimed) : inProgress();
|
|
8943
|
+
}
|
|
8944
|
+
// A newer push leaves a finish another call has claimed to that call; a refresh replaces it.
|
|
8945
|
+
if (!(await settleJobMove(c, forkJob, { unlessFinishing: !opts.refresh })))
|
|
8946
|
+
return inProgress();
|
|
8947
|
+
await c.var.admin.setScopeExpiresAt(actor, tenantId, existing.id, new Date().toISOString()).catch(() => undefined);
|
|
8948
|
+
if (!replaced)
|
|
8949
|
+
throw refuseFailedJob(forkJob);
|
|
8950
|
+
existing = (await c.var.admin.getScopeRecord(actor, tenantId, existing.id)) ?? existing;
|
|
8951
|
+
}
|
|
8455
8952
|
// Found above, before any remote read, so an in-flight refusal costs one directory read.
|
|
8456
8953
|
const stale = existing !== undefined && (opts.refresh || existing.status !== 'active');
|
|
8457
8954
|
if (existing && !stale) {
|
|
@@ -8461,11 +8958,132 @@ export function createControlPlaneApi(options) {
|
|
|
8461
8958
|
// `dropServingRef`: a preview provisioned before #527 inherited a serving_ref that is
|
|
8462
8959
|
// cleared just below, so its route leaves the serving script too.
|
|
8463
8960
|
let carried;
|
|
8961
|
+
/** The job this call claimed and finished, if any: forgotten once the bind below is over. */
|
|
8962
|
+
let finishedJob;
|
|
8963
|
+
const preview = existing;
|
|
8964
|
+
/** The row the bind below expects: as listed, or as a poll read it again (it may be bound since). */
|
|
8965
|
+
let carryScope = existing;
|
|
8464
8966
|
try {
|
|
8465
|
-
|
|
8967
|
+
// #2450 step 2: a carry a job is copying, for this push or an older one.
|
|
8968
|
+
const held = await readCopyJob(copyJobId('carry', preview.id));
|
|
8969
|
+
const inProgress = async (job) => {
|
|
8970
|
+
if (!jobs)
|
|
8971
|
+
throw new ControlPlaneError(409, `a push to preview '${opts.tag}' is still copying its data. Retry once it finishes`);
|
|
8972
|
+
await keepAlive(preview, job.spec.moveId);
|
|
8973
|
+
return copyingAnswer(job);
|
|
8974
|
+
};
|
|
8975
|
+
if (held && held.spec.versionId !== opts.versionId) {
|
|
8976
|
+
const superseded = supersededRefusal(held, mine, opts.tag);
|
|
8977
|
+
if (superseded)
|
|
8978
|
+
throw superseded;
|
|
8979
|
+
// A poll's own carry is not an older push's: it replaces nothing.
|
|
8980
|
+
if (poll)
|
|
8981
|
+
throw copyGone();
|
|
8982
|
+
// An older push's copy: this push replaces it, unless another call is finishing it.
|
|
8983
|
+
if (!(await settleJobMove(c, held, { unlessFinishing: true })))
|
|
8984
|
+
return inProgress(held);
|
|
8985
|
+
}
|
|
8986
|
+
else if (held) {
|
|
8987
|
+
if (held.status === 'copying')
|
|
8988
|
+
return inProgress(held);
|
|
8989
|
+
if (held.status !== 'done') {
|
|
8990
|
+
await settleJobMove(c, held);
|
|
8991
|
+
throw refuseFailedJob(held);
|
|
8992
|
+
}
|
|
8993
|
+
if (preview.verticalVersionId === opts.versionId &&
|
|
8994
|
+
(await carryEnds(c, preview, opts.versionId, { dropServingRef: true })) === null) {
|
|
8995
|
+
// Its bind landed and only the job's forget was lost: the preview serves this version
|
|
8996
|
+
// with nothing left to move, so the job goes and the preview is answered, never
|
|
8997
|
+
// finished again. A stage a holder still has is that holder's to load or discard.
|
|
8998
|
+
if (!copyJobHeld(held, Date.now()))
|
|
8999
|
+
await discardJobStages(held);
|
|
9000
|
+
await options.copyJobs?.forget(held.spec.id).catch(() => undefined);
|
|
9001
|
+
carried = null;
|
|
9002
|
+
}
|
|
9003
|
+
else {
|
|
9004
|
+
// Only the call that claims a done job finishes it; any other waits (see the fork's).
|
|
9005
|
+
const claimed = await options.copyJobs?.claim(held.spec.id, copyClaimMs);
|
|
9006
|
+
if (!claimed)
|
|
9007
|
+
return inProgress(held);
|
|
9008
|
+
carried = await finishCarryJob(c, preview, opts.versionId, claimed);
|
|
9009
|
+
finishedJob = claimed;
|
|
9010
|
+
}
|
|
9011
|
+
}
|
|
9012
|
+
if (carried === undefined && poll) {
|
|
9013
|
+
// A poll starts no carry. With no job left, either the one it waited on landed (the answer
|
|
9014
|
+
// that said so was lost) and the preview serves this version with nothing left to move,
|
|
9015
|
+
// which is answered again; or the copy is gone. Read on the row as it is now, not as it was
|
|
9016
|
+
// listed: a call finishing the carry (a poll retried beside this one) may have bound it since.
|
|
9017
|
+
const fresh = (await c.var.admin.getScopeRecord(actor, tenantId, preview.id)) ?? preview;
|
|
9018
|
+
const served = fresh.verticalVersionId === opts.versionId &&
|
|
9019
|
+
(await carryEnds(c, fresh, opts.versionId, { dropServingRef: true })) === null;
|
|
9020
|
+
if (!served)
|
|
9021
|
+
throw copyGone();
|
|
9022
|
+
carryScope = fresh;
|
|
9023
|
+
carried = null;
|
|
9024
|
+
}
|
|
9025
|
+
if (carried === undefined && jobs) {
|
|
9026
|
+
const move = await carryBegin(c, preview, opts.versionId, { dropServingRef: true });
|
|
9027
|
+
if (!move) {
|
|
9028
|
+
carried = null;
|
|
9029
|
+
}
|
|
9030
|
+
else {
|
|
9031
|
+
// The first page moves here; a carry that needs more moves in a job, and the preview
|
|
9032
|
+
// keeps serving its data from the old script until a later call finishes it.
|
|
9033
|
+
let begun;
|
|
9034
|
+
try {
|
|
9035
|
+
begun = await beginTransfer({ source: move.source, dest: move.dest }, { from: preview.id }, { retry: retryTransient, alwaysStage: true });
|
|
9036
|
+
}
|
|
9037
|
+
catch (e) {
|
|
9038
|
+
await settleCarryRetained(c, preview, opts.versionId, move);
|
|
9039
|
+
// The destination keeps one stage. Its refusal proves this call staged nothing:
|
|
9040
|
+
// another carry owns page zero, but may not have started its job yet. A visible
|
|
9041
|
+
// job gives the CLI its normal 202 poll; in the short gap before that, 503 makes
|
|
9042
|
+
// its existing first-call retry wait for the winning handler to finish.
|
|
9043
|
+
if (e instanceof ControlPlaneError && e.status === 409 &&
|
|
9044
|
+
e.message === 'a different staged load is in progress; retry after its copy finishes') {
|
|
9045
|
+
const winner = await readCopyJob(copyJobId('carry', preview.id));
|
|
9046
|
+
if (winner) {
|
|
9047
|
+
const superseded = supersededRefusal(winner, mine, opts.tag);
|
|
9048
|
+
if (superseded)
|
|
9049
|
+
throw superseded;
|
|
9050
|
+
return inProgress(winner);
|
|
9051
|
+
}
|
|
9052
|
+
throw new ControlPlaneError(503, `a carry to preview '${opts.tag}' is staging its first page; retry`);
|
|
9053
|
+
}
|
|
9054
|
+
throw e;
|
|
9055
|
+
}
|
|
9056
|
+
if ('state' in begun) {
|
|
9057
|
+
const job = await jobs.start({
|
|
9058
|
+
id: copyJobId('carry', preview.id), kind: 'carry', tenantId, vertical: slug, scopeId: preview.id,
|
|
9059
|
+
versionId: opts.versionId, versionOrder: mine.order,
|
|
9060
|
+
moveId: move.moveId, sourceRef: move.from, destRef: move.to, actor, leaseMs: copyLeaseMs,
|
|
9061
|
+
erasureEpoch: move.erasureEpoch, restoredStamp: move.restoredStamp, expected: move.expected,
|
|
9062
|
+
startedAt: Date.now(), deadline: Date.now() + COPY_JOB_MAX_MS,
|
|
9063
|
+
}, begun.state);
|
|
9064
|
+
// Another call started this preview's job first: this call's move is not the job's, so
|
|
9065
|
+
// its entries settle and its own stage is discarded. The store checks that stage ID,
|
|
9066
|
+
// leaving the winner's stage alone. A newer push's job refuses this one now, as its
|
|
9067
|
+
// next call would; an older one's is replaced by that next call.
|
|
9068
|
+
if (job.spec.moveId !== move.moveId) {
|
|
9069
|
+
await settleCarryRetained(c, preview, opts.versionId, move);
|
|
9070
|
+
await move.dest.discardStagedScope(preview.id, begun.state.stageId).catch(() => undefined);
|
|
9071
|
+
const superseded = supersededRefusal(job, mine, opts.tag);
|
|
9072
|
+
if (superseded)
|
|
9073
|
+
throw superseded;
|
|
9074
|
+
}
|
|
9075
|
+
return copyingAnswer(job);
|
|
9076
|
+
}
|
|
9077
|
+
const done = begun.done;
|
|
9078
|
+
carried = await carryWith(c, preview, opts.versionId, move, async () => done);
|
|
9079
|
+
}
|
|
9080
|
+
}
|
|
9081
|
+
if (carried === undefined)
|
|
9082
|
+
carried = await carryOntoVersion(c, preview, opts.versionId, { dropServingRef: true });
|
|
8466
9083
|
}
|
|
8467
9084
|
catch (e) {
|
|
8468
|
-
if (e instanceof ControlPlaneError && e.status >= 500 && e.status !== 501
|
|
9085
|
+
if (e instanceof ControlPlaneError && e.status >= 500 && e.status !== 501 &&
|
|
9086
|
+
!(e.status === 503 && e.message.includes('is staging its first page; retry'))) {
|
|
8469
9087
|
recordFailure({
|
|
8470
9088
|
actor,
|
|
8471
9089
|
operation: 'preview.create',
|
|
@@ -8485,10 +9103,28 @@ export function createControlPlaneApi(options) {
|
|
|
8485
9103
|
// #527 has its inherited serving_ref cleared, so routing follows the bound version (its
|
|
8486
9104
|
// per-version script) and not the prod serving script; the OFF positions are re-asserted
|
|
8487
9105
|
// there (#1674); and the copy left in the old script is wiped.
|
|
8488
|
-
|
|
8489
|
-
|
|
8490
|
-
|
|
8491
|
-
|
|
9106
|
+
try {
|
|
9107
|
+
const scope = carryScope;
|
|
9108
|
+
await bindAfterCarry(c, scope, opts.versionId, carried, {
|
|
9109
|
+
clearServingRef: true,
|
|
9110
|
+
recarry: () => carryOntoVersion(c, scope, opts.versionId, { dropServingRef: true }),
|
|
9111
|
+
});
|
|
9112
|
+
}
|
|
9113
|
+
catch (e) {
|
|
9114
|
+
// A job's bind that failed is that copy's end, as its load's is (`finishRefusal`). A fault
|
|
9115
|
+
// after the bind landed (re-asserting the switches, the source's settle) is the served
|
|
9116
|
+
// preview's, answered as itself: a retry finds the preview serving this version.
|
|
9117
|
+
if (!finishedJob)
|
|
9118
|
+
throw e;
|
|
9119
|
+
const now = await c.var.admin.getScopeRecord(actor, tenantId, existing.id).catch(() => undefined);
|
|
9120
|
+
throw now?.verticalVersionId === opts.versionId ? e : finishRefusal(c, finishedJob, e);
|
|
9121
|
+
}
|
|
9122
|
+
finally {
|
|
9123
|
+
// #2450: a job this call finished stays claimed until its bind has landed or failed, so a
|
|
9124
|
+
// newer push polling meanwhile waits, rather than carry from the binding this one replaces.
|
|
9125
|
+
if (finishedJob)
|
|
9126
|
+
await options.copyJobs?.forget(finishedJob.spec.id).catch(() => undefined);
|
|
9127
|
+
}
|
|
8492
9128
|
// Renew (or clear) the preview's GC deadline so a reused preview does not silently die.
|
|
8493
9129
|
await c.var.admin.setScopeExpiresAt(actor, tenantId, existing.id, expiresAt);
|
|
8494
9130
|
const hostname = await bindPreviewHostname(c.var.admin, actor, baseHostname, tenantId, existing.id, opts.tag, surface);
|
|
@@ -8527,42 +9163,22 @@ export function createControlPlaneApi(options) {
|
|
|
8527
9163
|
// A marked row is also the GC sweep's to reap (its `expiresAt` has passed), so the sweep
|
|
8528
9164
|
// can get there between the lookup above and this reap. Gone is what the reap wanted.
|
|
8529
9165
|
if (existing && stale) {
|
|
9166
|
+
// #2450: a job still copying holds a live lease, which a reap waits on; stop it first. A newer
|
|
9167
|
+
// push's copy is not this create's to stop, a refresh's included: it refuses instead.
|
|
9168
|
+
await cancelPreviewJobs(c, existing, { mine, tag: opts.tag });
|
|
8530
9169
|
await reapPreview(c, existing).catch((e) => {
|
|
8531
9170
|
if (errorCodeOf(e) !== 'not_found')
|
|
8532
9171
|
throw e;
|
|
8533
9172
|
});
|
|
8534
9173
|
}
|
|
8535
9174
|
const previewId = scopeIdSchema.parse(ulid());
|
|
8536
|
-
// The founding #559 case lands its durable row HERE, not in onError: the previews
|
|
8537
|
-
// route answers a ControlPlaneError directly (its own catch, never the app-level
|
|
8538
|
-
// recorder), and only this frame knows the preview's scopeId — the key that lets
|
|
8539
|
-
// the console explain the stranded `provisioning` row this throw leaves behind.
|
|
8540
|
-
const restoreOrRecord = async (fn) => {
|
|
8541
|
-
try {
|
|
8542
|
-
await retryTransient(fn);
|
|
8543
|
-
}
|
|
8544
|
-
catch (e) {
|
|
8545
|
-
if (e instanceof ControlPlaneError && e.status >= 500 && e.status !== 501) {
|
|
8546
|
-
recordFailure({
|
|
8547
|
-
actor,
|
|
8548
|
-
operation: 'preview.create',
|
|
8549
|
-
stage: 'restore',
|
|
8550
|
-
tenantId,
|
|
8551
|
-
scopeId: previewId,
|
|
8552
|
-
vertical: slug,
|
|
8553
|
-
version: opts.versionId,
|
|
8554
|
-
status: e.status,
|
|
8555
|
-
message: e.message,
|
|
8556
|
-
}, e);
|
|
8557
|
-
}
|
|
8558
|
-
throw e;
|
|
8559
|
-
}
|
|
8560
|
-
};
|
|
8561
9175
|
let row;
|
|
8562
9176
|
/** `fenced`: the ledger stamp and lease of this fork's restore (#1722), when it has one. */
|
|
8563
9177
|
let restore;
|
|
8564
9178
|
/** #2450: the fork's data, moved into the preview's copy once the ledger names it. */
|
|
8565
9179
|
let forkData = null;
|
|
9180
|
+
/** #2450 step 2: a source a job can page from, when this create may run in the background. */
|
|
9181
|
+
let forkPaged = null;
|
|
8566
9182
|
/** #2009: the directory's classification of the row below, which every restore of it carries. */
|
|
8567
9183
|
const markCopyOfRow = () => {
|
|
8568
9184
|
const lineage = copyLineageOf({ kind: row.kind ?? '', forkedFrom: row.forkedFrom ?? null });
|
|
@@ -8576,6 +9192,12 @@ export function createControlPlaneApi(options) {
|
|
|
8576
9192
|
if (!target) {
|
|
8577
9193
|
throw new ControlPlaneError(501, 'preview needs dispatch resolution for the PR version');
|
|
8578
9194
|
}
|
|
9195
|
+
// #2450 step 2: what a job needs to move the fork's pages after this request, by reference.
|
|
9196
|
+
if (sourceClient && jobs) {
|
|
9197
|
+
const sourceRef = await routeOf(c, source);
|
|
9198
|
+
if (sourceRef)
|
|
9199
|
+
forkPaged = { source: sourceClient, sourceRef, sourceScopeId: source.id };
|
|
9200
|
+
}
|
|
8579
9201
|
const canonical = await c.var.admin.exportScope(actor, tenantId, source.id);
|
|
8580
9202
|
// #2450: from a vertical, the fork's data moves a page at a time into the preview's own stage
|
|
8581
9203
|
// there, so a scope of any size forks. Unstamped, as the whole export was: a fork writes
|
|
@@ -8583,7 +9205,11 @@ export function createControlPlaneApi(options) {
|
|
|
8583
9205
|
// (K-31: no copied data without a record) and ahead of the restore's own retries, so a
|
|
8584
9206
|
// restore blip reloads the same stage and an export fault is not recorded as a restore's.
|
|
8585
9207
|
forkData = sourceClient
|
|
8586
|
-
? async () =>
|
|
9208
|
+
? async () => {
|
|
9209
|
+
const moved = await transferScope(sourceClient, target, { from: source.id, to: previewId }, { retry: retryTransient, stamp: false });
|
|
9210
|
+
refuseWipedSource(moved.meta, source.id);
|
|
9211
|
+
return moved.dump;
|
|
9212
|
+
}
|
|
8587
9213
|
: async () => canonical.tables;
|
|
8588
9214
|
// Directory row FIRST as `provisioning` (K-31 two-phase): a crash before the data
|
|
8589
9215
|
// copy leaves an inert row that — carrying `forkedFrom` + `expiresAt` — the GC sweep
|
|
@@ -8635,6 +9261,7 @@ export function createControlPlaneApi(options) {
|
|
|
8635
9261
|
? ((await c.var.admin.getVersion(actor, opts.versionId, slug))?.deploymentRef ?? null)
|
|
8636
9262
|
: null;
|
|
8637
9263
|
let fork = null;
|
|
9264
|
+
let load = null;
|
|
8638
9265
|
try {
|
|
8639
9266
|
// Inside the try: a host writes the directory row FIRST and then migrates, projects and
|
|
8640
9267
|
// seats, so a provision that throws can leave a `provisioning` row too. A provision
|
|
@@ -8643,6 +9270,7 @@ export function createControlPlaneApi(options) {
|
|
|
8643
9270
|
await c.var.host.provisionScope(actor, row);
|
|
8644
9271
|
if (restore) {
|
|
8645
9272
|
let fenced = {};
|
|
9273
|
+
let move = null;
|
|
8646
9274
|
if (targetRef) {
|
|
8647
9275
|
const moveId = ulid();
|
|
8648
9276
|
const restoredStamp = ulid();
|
|
@@ -8650,48 +9278,44 @@ export function createControlPlaneApi(options) {
|
|
|
8650
9278
|
await c.var.admin.recordScopeScriptCopy(actor, tenantId, previewId, targetRef, moveId, { role: 'destination', loadStamp: restoredStamp, leaseMs: copyLeaseMs });
|
|
8651
9279
|
fork = { moveId };
|
|
8652
9280
|
const erasureEpoch = await c.var.admin.scopeErasureEpoch(actor, tenantId, previewId);
|
|
9281
|
+
move = { moveId, restoredStamp, erasureEpoch };
|
|
8653
9282
|
fenced = { loadStamp: restoredStamp, fence: copyRestoreFence(moveId, recordedAt, copyLeaseMs, erasureEpoch) };
|
|
8654
9283
|
}
|
|
8655
|
-
const
|
|
8656
|
-
|
|
8657
|
-
|
|
9284
|
+
const restoreWith = restore;
|
|
9285
|
+
let dump;
|
|
9286
|
+
if (forkPaged && jobs && move && targetRef && target) {
|
|
9287
|
+
// #2450 step 2: the first page moves here. A fork that needs more moves in a job, and a
|
|
9288
|
+
// later call to this route finishes it (`finishForkJob`); the row stays `provisioning`.
|
|
9289
|
+
const begun = await beginTransfer({ source: forkPaged.source, dest: target }, { from: forkPaged.sourceScopeId, to: previewId }, { retry: retryTransient, stamp: false, alwaysStage: true });
|
|
9290
|
+
if ('state' in begun) {
|
|
9291
|
+
const job = await jobs.start({
|
|
9292
|
+
id: copyJobId('fork', previewId), kind: 'fork', tenantId, vertical: slug, scopeId: previewId,
|
|
9293
|
+
versionId: opts.versionId, versionOrder: mine.order,
|
|
9294
|
+
moveId: move.moveId, sourceRef: forkPaged.sourceRef, destRef: targetRef, actor, leaseMs: copyLeaseMs,
|
|
9295
|
+
erasureEpoch: move.erasureEpoch, restoredStamp: move.restoredStamp,
|
|
9296
|
+
startedAt: Date.now(), deadline: Date.now() + COPY_JOB_MAX_MS,
|
|
9297
|
+
}, begun.state);
|
|
9298
|
+
// A job id names the preview, which this create just minted, so no other call can
|
|
9299
|
+
// hold it; a job answered for another move would be a defect, not a race.
|
|
9300
|
+
if (job.spec.moveId !== move.moveId) {
|
|
9301
|
+
throw new ControlPlaneError(500, `preview ${previewId} already had a copy job for another move`);
|
|
9302
|
+
}
|
|
9303
|
+
return copyingAnswer(job);
|
|
9304
|
+
}
|
|
9305
|
+
refuseWipedSource(begun.done.meta, forkPaged.sourceScopeId);
|
|
9306
|
+
dump = begun.done.dump;
|
|
9307
|
+
}
|
|
9308
|
+
else {
|
|
9309
|
+
dump = forkData ? await forkData() : undefined;
|
|
9310
|
+
}
|
|
9311
|
+
load = () => restoreWith(fenced, dump);
|
|
8658
9312
|
}
|
|
8659
|
-
await c.var.admin.activateScope(actor, tenantId, previewId);
|
|
8660
|
-
// Bind the PR version. A private vertical's push self-admitted, so this is accepted; a
|
|
8661
|
-
// preview scope also admits a pending version (#513), which is what a clean-room rehearsal
|
|
8662
|
-
// of not-yet-admitted code needs. It routes the row onto the fork, so it confirms the move.
|
|
8663
|
-
await c.var.admin.bindScopeVersion(actor, tenantId, previewId, opts.versionId, fork ? { confirmMove: { moveId: fork.moveId, source: 'retained' } } : undefined);
|
|
8664
9313
|
}
|
|
8665
9314
|
catch (e) {
|
|
8666
|
-
|
|
8667
|
-
// would settle the entry once its lease ran out.
|
|
8668
|
-
const unbound = fork;
|
|
8669
|
-
if (unbound && targetRef) {
|
|
8670
|
-
await c.var.admin.settleScopeScriptCopy(actor, tenantId, previewId, targetRef, unbound.moveId, 'retained')
|
|
8671
|
-
.catch(() => undefined);
|
|
8672
|
-
}
|
|
8673
|
-
// Expire the row this create left behind, so the next create reaps it at once rather
|
|
8674
|
-
// than refusing it as in flight for the whole bound (#1920) — that next create is the
|
|
8675
|
-
// CLI's retry after a known-dead attempt (#1918). The row stays until then, or until
|
|
8676
|
-
// the GC sweep's next pass reaps it as expired; the #559 ops-failure row outlives both.
|
|
8677
|
-
// Best-effort: if this write fails too, the age bound still frees the tag.
|
|
8678
|
-
await c.var.admin
|
|
8679
|
-
.setScopeExpiresAt(actor, tenantId, previewId, new Date().toISOString())
|
|
8680
|
-
.catch(() => undefined);
|
|
9315
|
+
await abandonFork(previewId, fork, targetRef);
|
|
8681
9316
|
throw e;
|
|
8682
9317
|
}
|
|
8683
|
-
|
|
8684
|
-
await assertServesBoundVersion(previewId);
|
|
8685
|
-
const auth = await wireLogin(previewId, hostname, source);
|
|
8686
|
-
return {
|
|
8687
|
-
scopeId: previewId,
|
|
8688
|
-
hostname,
|
|
8689
|
-
url: `https://${hostname}`,
|
|
8690
|
-
versionId: opts.versionId,
|
|
8691
|
-
reused: false,
|
|
8692
|
-
auth,
|
|
8693
|
-
notes: notesFor(source),
|
|
8694
|
-
};
|
|
9318
|
+
return completeFork({ previewId, parent: source, fork, targetRef, load });
|
|
8695
9319
|
};
|
|
8696
9320
|
// The owning tenant of a builder's OWN vertical — the gate every preview route shares. A
|
|
8697
9321
|
// preview forks THIS tenant's own scope and binds the version onto that fork (never an
|
|
@@ -8745,6 +9369,11 @@ export function createControlPlaneApi(options) {
|
|
|
8745
9369
|
}
|
|
8746
9370
|
try {
|
|
8747
9371
|
const out = await orchestratedPreview(c, tenantId, slug, source, body);
|
|
9372
|
+
// #2450 step 2: the copy runs on; the same call, repeated, finishes the preview.
|
|
9373
|
+
if ('copying' in out) {
|
|
9374
|
+
c.header('Retry-After', String(out.retryAfter));
|
|
9375
|
+
return c.json(out, 202);
|
|
9376
|
+
}
|
|
8748
9377
|
return c.json(out, out.reused ? 200 : 201);
|
|
8749
9378
|
}
|
|
8750
9379
|
catch (e) {
|
|
@@ -8792,6 +9421,8 @@ export function createControlPlaneApi(options) {
|
|
|
8792
9421
|
if (!preview)
|
|
8793
9422
|
return c.json({ deleted: null, note: `no preview '${tag}' for '${slug}'` });
|
|
8794
9423
|
try {
|
|
9424
|
+
// #2450: a copy still running holds a live lease, which the reap waits on; stop it first.
|
|
9425
|
+
await cancelPreviewJobs(c, preview);
|
|
8795
9426
|
// Storage-before-row, the same ordering as the fork hard-delete: wipe the DO in the
|
|
8796
9427
|
// PR version's deployment, then deleteSnapshot (fork-only re-check, hostnames + row,
|
|
8797
9428
|
// audit). A crash between the two converges on retry.
|