@substrat-run/control-plane-api 0.170.0 → 0.172.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/aggregate-reads.d.ts +0 -14
- package/dist/aggregate-reads.d.ts.map +1 -1
- package/dist/aggregate-reads.js +15 -11
- package/dist/aggregate-reads.js.map +1 -1
- package/dist/aggregate-source.d.ts +23 -0
- package/dist/aggregate-source.d.ts.map +1 -1
- package/dist/aggregate-source.js +16 -1
- package/dist/aggregate-source.js.map +1 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +33 -9
- package/dist/api.js.map +1 -1
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +105 -21
- package/dist/cf-observability.js.map +1 -1
- package/dist/copy-job.d.ts.map +1 -1
- package/dist/copy-job.js +5 -3
- package/dist/copy-job.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +48 -2
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +38 -1
- package/dist/observability.js.map +1 -1
- package/dist/platform-drain.d.ts.map +1 -1
- package/dist/platform-drain.js +1 -0
- 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/source-builds.d.ts.map +1 -1
- package/dist/source-builds.js +14 -3
- package/dist/source-builds.js.map +1 -1
- package/dist/tenant-request-query.d.ts +12 -3
- package/dist/tenant-request-query.d.ts.map +1 -1
- package/dist/tenant-request-query.js +29 -6
- package/dist/tenant-request-query.js.map +1 -1
- package/dist/vertical-client.d.ts +96 -15
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +266 -34
- package/dist/vertical-client.js.map +1 -1
- package/package.json +8 -8
package/dist/vertical-client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from '@substrat-run/contracts';
|
|
2
|
-
import { PREVIEW_CLIENT_PATH, attachmentRecord, projectedConnectionGrant, denialFilterParams, capabilityFilterParams, capabilityPage, capabilityRevokeAnswer, capabilityRevokeRequest, mintedPreviewClient, ownerSeat, ownerClaimLink, memberInviteLink, memberRemoval, scopeMembers, ownerTransferResult, previewClientClaim, retiredPreviewClients, peerGrantsEntry, peerSwitchOutcome, systemSwitchOutcome, lifecycleDelivery, systemScheduleEntry, switchedOffInUnit, exportedBatch, importResult, importCursorMoved, importState, substratError, CONNECTOR_ATTACHMENT_META_HEADER, CONNECTOR_ATTACHMENT_META_MAX_BYTES, CONNECTOR_ATTACHMENT_BUFFERED_MAX_BYTES, CONNECTOR_ATTACHMENT_STREAM_MAX_BYTES, connectorAttachmentMetaBytes, connectorAttachmentMetaRefusal, connectorAttachmentBufferedRefusal, connectorAttachmentSizeRefusal, CONNECTOR_ATTACHMENT_RECORD_HEADER, LOAD_STAMP_HEADER, PLATFORM_SECRET_HEADER, WRITE_REVISION_HEADER, mergeDumpPages, scopeDumpPage, } from '@substrat-run/contracts';
|
|
2
|
+
import { CAPTURE_REFUSALS, PREVIEW_CLIENT_PATH, attachmentRecord, projectedConnectionGrant, denialFilterParams, capabilityFilterParams, capabilityPage, capabilityRevokeAnswer, capabilityRevokeRequest, mintedPreviewClient, ownerSeat, ownerClaimLink, memberInviteLink, memberRemoval, scopeMembers, ownerTransferResult, previewClientClaim, retiredPreviewClients, peerGrantsEntry, peerSwitchOutcome, systemSwitchOutcome, lifecycleDelivery, systemScheduleEntry, switchedOffInUnit, exportedBatch, importResult, importCursorMoved, importState, substratError, CONNECTOR_ATTACHMENT_META_HEADER, CONNECTOR_ATTACHMENT_META_MAX_BYTES, CONNECTOR_ATTACHMENT_BUFFERED_MAX_BYTES, CONNECTOR_ATTACHMENT_STREAM_MAX_BYTES, connectorAttachmentMetaBytes, connectorAttachmentMetaRefusal, connectorAttachmentBufferedRefusal, connectorAttachmentSizeRefusal, CONNECTOR_ATTACHMENT_RECORD_HEADER, LOAD_STAMP_HEADER, PLATFORM_SECRET_HEADER, WRITE_REVISION_HEADER, mergeDumpPages, scopeDumpChanges, scopeDumpPage, } from '@substrat-run/contracts';
|
|
3
3
|
import { STREAM_IDLE_TIMEOUT_MS, STREAM_MAX_DURATION_MS, attachmentSha256, deadlineFetch, isSizedByteStream, knownLengthInit, runtimeSha256, scopeNotHeldRefusal, ulid, undrainedEventsOf, verifiedStream } from '@substrat-run/kernel';
|
|
4
4
|
import { ControlPlaneError } from '@substrat-run/control-plane-client';
|
|
5
5
|
/**
|
|
@@ -1170,9 +1170,12 @@ export class VerticalClient {
|
|
|
1170
1170
|
* `exportScope` without. `'predates'` from a deployment built before the paged verbs (a 404, or
|
|
1171
1171
|
* its own 501), which exports whole.
|
|
1172
1172
|
*/
|
|
1173
|
-
async exportScopePage(scopeId, cursor,
|
|
1173
|
+
async exportScopePage(scopeId, cursor,
|
|
1174
|
+
/** `capture`: a copy's id, for a copy that catches up on writes (`exportScopeChanges`). */
|
|
1175
|
+
opts = {}) {
|
|
1174
1176
|
const base = this.options.baseUrl ?? 'https://vertical.invalid';
|
|
1175
1177
|
const query = `scopeId=${encodeURIComponent(scopeId)}${opts.stamp ? '&stamp=1' : ''}` +
|
|
1178
|
+
(opts.capture === undefined ? '' : `&capture=${encodeURIComponent(opts.capture)}`) +
|
|
1176
1179
|
(cursor === null ? '' : `&cursor=${encodeURIComponent(cursor)}`);
|
|
1177
1180
|
const answer = await withDeadline('export-page', PAGE_CALL_DEADLINE_MS, (signal) => this.routeAnswer('export-page', FENCED, () => this.options.fetch(`${base}/internal/export-page?${query}`, {
|
|
1178
1181
|
headers: { [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
|
|
@@ -1200,6 +1203,35 @@ export class VerticalClient {
|
|
|
1200
1203
|
async discardStagedScope(scopeId, stageId) {
|
|
1201
1204
|
await withDeadline('stage-discard', PAGE_CALL_DEADLINE_MS, (signal) => this.routeAnswer('stage-discard', FENCED, () => this.postRaw('/internal/stage/discard', { scopeId, stageId }, signal)));
|
|
1202
1205
|
}
|
|
1206
|
+
/**
|
|
1207
|
+
* One batch of what `scopeId` took since the capture `copyId`'s first page (#2450). Batch `seq`
|
|
1208
|
+
* acknowledges those before it, and the same `seq` asked again is served afresh, so a lost
|
|
1209
|
+
* answer is retried safely. `'predates'` from a deployment that cannot catch a copy up.
|
|
1210
|
+
*/
|
|
1211
|
+
async exportScopeChanges(scopeId, copyId, seq) {
|
|
1212
|
+
const answer = await withDeadline('export-changes', PAGE_CALL_DEADLINE_MS, (signal) => this.routeAnswer('export-changes', FENCED, () => this.postRaw('/internal/export-changes', { scopeId, copyId, seq }, signal)));
|
|
1213
|
+
if (answer === PREDATES)
|
|
1214
|
+
return 'predates';
|
|
1215
|
+
const changes = scopeDumpChanges.safeParse(answer);
|
|
1216
|
+
if (!changes.success)
|
|
1217
|
+
throw new ControlPlaneError(502, 'export-changes: the vertical answered something that is not a change batch');
|
|
1218
|
+
return changes.data;
|
|
1219
|
+
}
|
|
1220
|
+
/** End the capture `copyId` of `scopeId` (#2450): a copy that stopped. Best effort, as a discard is. */
|
|
1221
|
+
async endScopeCapture(scopeId, copyId) {
|
|
1222
|
+
await withDeadline('capture-end', PAGE_CALL_DEADLINE_MS, (signal) => this.routeAnswer('capture-end', FENCED, () => this.postRaw('/internal/capture/end', { scopeId, copyId }, signal)));
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Apply one batch of a capture's changes to the stage `stageId` here (#2450), numbered after its
|
|
1226
|
+
* pages. `'predates'` from a deployment that cannot catch a copy up; a batch already applied
|
|
1227
|
+
* answers `staged: false`.
|
|
1228
|
+
*/
|
|
1229
|
+
async stageScopeChanges(scopeId, stageId, seq, tables) {
|
|
1230
|
+
const answer = await withDeadline('stage-changes', PAGE_CALL_DEADLINE_MS, (signal) => this.routeAnswer('stage-changes', FENCED, () => this.postRaw('/internal/stage-changes', { scopeId, stageId, seq, tables }, signal)));
|
|
1231
|
+
if (answer === PREDATES)
|
|
1232
|
+
return 'predates';
|
|
1233
|
+
return { staged: answer?.staged === true };
|
|
1234
|
+
}
|
|
1203
1235
|
/** A platform-authenticated JSON POST, its response unread, for `routeAnswer`. */
|
|
1204
1236
|
postRaw(path, body, signal) {
|
|
1205
1237
|
const base = this.options.baseUrl ?? 'https://vertical.invalid';
|
|
@@ -1761,6 +1793,12 @@ export class VerticalClient {
|
|
|
1761
1793
|
}
|
|
1762
1794
|
/** How many times a copy is restarted because the source took a write while it was read (#2450). */
|
|
1763
1795
|
export const TRANSFER_ATTEMPTS = 3;
|
|
1796
|
+
/**
|
|
1797
|
+
* How many change batches one attempt's catch-up reads before it gives up (#2450): a source that
|
|
1798
|
+
* writes faster than the copy drains its log never reaches a final batch, and the copy then starts
|
|
1799
|
+
* again like one torn at a quiet moment, and is refused once the attempts are spent.
|
|
1800
|
+
*/
|
|
1801
|
+
export const CATCH_UP_BATCHES = 50;
|
|
1764
1802
|
/**
|
|
1765
1803
|
* Move a scope's data out of `source` and into `dest`'s stage, a page at a time (#2450), so no
|
|
1766
1804
|
* single call carries the whole scope: a Durable Object RPC message holds 32 MiB, and an isolate
|
|
@@ -1771,10 +1809,18 @@ export const TRANSFER_ATTEMPTS = 3;
|
|
|
1771
1809
|
* before. A destination that predates them gets the pages merged into one dump, which is the old
|
|
1772
1810
|
* ceiling, and only there.
|
|
1773
1811
|
*
|
|
1774
|
-
* Each page is read in one turn of the source, so it is consistent on its own
|
|
1775
|
-
*
|
|
1776
|
-
*
|
|
1777
|
-
*
|
|
1812
|
+
* Each page is read in one turn of the source, so it is consistent on its own. A carry's later
|
|
1813
|
+
* fences assume a copy that was true at one moment, and there are two ways to get one. A source
|
|
1814
|
+
* that can capture (#2450 step 3) logs every write from its first page on, and once the pages are
|
|
1815
|
+
* staged the copy catches up on them in change batches until a final batch, read in the same turn
|
|
1816
|
+
* as the log ran empty, names that moment. A source that cannot (an older deployment, or a table
|
|
1817
|
+
* with a unique expression index, which no row trigger can key) is copied at a quiet moment: the
|
|
1818
|
+
* pages are one read only while its write revision stays where the first page read it.
|
|
1819
|
+
*
|
|
1820
|
+
* Either way a copy that does not come true starts again, up to `TRANSFER_ATTEMPTS` times, and is
|
|
1821
|
+
* then refused (409) with nothing moved: a quiet-moment copy the source wrote under, or a catch-up
|
|
1822
|
+
* whose capture was lost (it expired, a migration moved the schema, a load replaced the store) or
|
|
1823
|
+
* that did not reach a final batch in `CATCH_UP_BATCHES`.
|
|
1778
1824
|
*
|
|
1779
1825
|
* `beginTransfer` and `transferStep` are the same copy in resumable steps, for one that outlasts a
|
|
1780
1826
|
* request; this runs them to the end.
|
|
@@ -1783,48 +1829,108 @@ export async function transferScope(source, dest,
|
|
|
1783
1829
|
/** The scope read at `source`, and (a fork, #2450) the scope `dest` stages into, when another. */
|
|
1784
1830
|
scope, opts = {}) {
|
|
1785
1831
|
let progress = await beginTransfer({ source, dest }, scope, opts);
|
|
1786
|
-
while ('state' in progress)
|
|
1787
|
-
|
|
1832
|
+
while ('state' in progress) {
|
|
1833
|
+
const { state } = progress;
|
|
1834
|
+
try {
|
|
1835
|
+
progress = await transferStep({ source, dest }, state, opts);
|
|
1836
|
+
}
|
|
1837
|
+
catch (e) {
|
|
1838
|
+
// Nobody resumes a copy run in one request, so its stage and capture go with it. (A job's
|
|
1839
|
+
// survive a fault, for the next step to resume.)
|
|
1840
|
+
await abandonCopy({ source, dest }, state);
|
|
1841
|
+
throw e;
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1788
1844
|
return progress.done;
|
|
1789
1845
|
}
|
|
1790
1846
|
/**
|
|
1791
1847
|
* Start a copy (#2450): read the source's first page and stage it. Finished at once when the scope
|
|
1792
1848
|
* fits that page (unless `alwaysStage`), when the source predates the paged verbs, or when the
|
|
1793
1849
|
* destination does (its pages are then merged here, as the whole dump it takes).
|
|
1850
|
+
*
|
|
1851
|
+
* The first page asks the source to capture under the stage's id. A source that refuses (412: a
|
|
1852
|
+
* table it cannot capture) is read again without one, and one that answers a plain page (it does
|
|
1853
|
+
* not echo `captured`) has its capture ended: either way the copy is read at a quiet moment. A copy
|
|
1854
|
+
* that catches up and fits its first page catches up here, so it is still finished at once.
|
|
1794
1855
|
*/
|
|
1795
|
-
export async function beginTransfer(
|
|
1856
|
+
export async function beginTransfer(ends, scope, opts = {}) {
|
|
1857
|
+
const { source, dest } = ends;
|
|
1796
1858
|
const retry = opts.retry ?? ((run) => run());
|
|
1797
1859
|
const stamp = opts.stamp ?? true;
|
|
1798
1860
|
const from = scope.from;
|
|
1799
1861
|
const into = scope.to ?? scope.from;
|
|
1862
|
+
// The stage's id names the capture too, so whoever discards a stage ends the capture beside it.
|
|
1863
|
+
const stageId = ulid();
|
|
1864
|
+
const ask = opts.capture !== false && typeof source.exportScopeChanges === 'function';
|
|
1865
|
+
/** The source refused to open the capture, so it holds none. */
|
|
1866
|
+
let refused = false;
|
|
1800
1867
|
// A client without the paged verbs (one a caller assembled from part of this one) is read as a
|
|
1801
1868
|
// deployment that predates them.
|
|
1802
|
-
const first = typeof source.exportScopePage
|
|
1803
|
-
?
|
|
1804
|
-
:
|
|
1869
|
+
const first = typeof source.exportScopePage !== 'function'
|
|
1870
|
+
? 'predates'
|
|
1871
|
+
: ask
|
|
1872
|
+
? await retry(() => source.exportScopePage(from, null, { stamp, capture: stageId })).catch((e) => {
|
|
1873
|
+
if (!captureRefusal(e, 'expressionIndex'))
|
|
1874
|
+
throw e;
|
|
1875
|
+
refused = true;
|
|
1876
|
+
return retry(() => source.exportScopePage(from, null, { stamp }));
|
|
1877
|
+
})
|
|
1878
|
+
: await retry(() => source.exportScopePage(from, null, { stamp }));
|
|
1805
1879
|
if (first === 'predates') {
|
|
1806
1880
|
if (!stamp) {
|
|
1807
1881
|
const tables = await retry(() => source.exportScope(from));
|
|
1808
|
-
return { done: { dump: tables, loadStamp: null, revision: null, meta: metaOf([tables]) } };
|
|
1882
|
+
return { done: { dump: tables, loadStamp: null, revision: null, meta: doneMeta(metaOf([tables])) } };
|
|
1809
1883
|
}
|
|
1810
1884
|
const whole = await retry(() => source.exportScopeStamped(from));
|
|
1811
|
-
return { done: { dump: whole.tables, loadStamp: whole.loadStamp, revision: whole.revision, meta: metaOf([whole.tables]) } };
|
|
1885
|
+
return { done: { dump: whole.tables, loadStamp: whole.loadStamp, revision: whole.revision, meta: doneMeta(metaOf([whole.tables])) } };
|
|
1812
1886
|
}
|
|
1887
|
+
const captured = ask && first.captured === true;
|
|
1888
|
+
// A host that took the capture but predates the echo (#2761) still opened one: end it.
|
|
1889
|
+
if (ask && !captured && !refused)
|
|
1890
|
+
await endCapture(ends, from, stageId);
|
|
1813
1891
|
if (first.next === null && !opts.alwaysStage) {
|
|
1814
|
-
|
|
1892
|
+
// One page is one read whatever the source takes next, so its capture is not needed.
|
|
1893
|
+
if (captured)
|
|
1894
|
+
await endCapture(ends, from, stageId);
|
|
1895
|
+
return { done: { dump: first.tables, loadStamp: first.loadStamp, revision: first.revision, meta: doneMeta(metaOf([first.tables])) } };
|
|
1896
|
+
}
|
|
1897
|
+
let opened;
|
|
1898
|
+
try {
|
|
1899
|
+
opened = typeof dest.stageScopePage === 'function'
|
|
1900
|
+
? await retry(() => dest.stageScopePage(into, stageId, 0, first.tables))
|
|
1901
|
+
: 'predates';
|
|
1902
|
+
}
|
|
1903
|
+
catch (e) {
|
|
1904
|
+
if (captured)
|
|
1905
|
+
await endCapture(ends, from, stageId);
|
|
1906
|
+
throw e;
|
|
1815
1907
|
}
|
|
1816
|
-
const stageId = ulid();
|
|
1817
|
-
const opened = typeof dest.stageScopePage === 'function'
|
|
1818
|
-
? await retry(() => dest.stageScopePage(into, stageId, 0, first.tables))
|
|
1819
|
-
: 'predates';
|
|
1820
1908
|
const state = {
|
|
1821
1909
|
from, into, stamp, stageId, seq: 0, cursor: first.next,
|
|
1822
1910
|
revision: first.revision, loadStamp: first.loadStamp,
|
|
1823
1911
|
attempt: opts.attempt ?? 1, pages: 1, rows: rowsIn(first.tables), meta: metaOf([first.tables]),
|
|
1912
|
+
...(captured && opened !== 'predates' ? { capture: true, batch: 0 } : {}),
|
|
1824
1913
|
};
|
|
1825
|
-
if (opened !== 'predates')
|
|
1826
|
-
|
|
1827
|
-
|
|
1914
|
+
if (opened !== 'predates') {
|
|
1915
|
+
if (state.cursor !== null)
|
|
1916
|
+
return { state };
|
|
1917
|
+
if (!state.capture)
|
|
1918
|
+
return { done: doneOf(state) };
|
|
1919
|
+
// Caught up here, within a step's budget: a source still writing is left for the next step.
|
|
1920
|
+
const until = Date.now() + BEGIN_CATCH_UP_MS;
|
|
1921
|
+
try {
|
|
1922
|
+
return await transferStep(ends, state, { ...opts, until: () => Date.now() >= until });
|
|
1923
|
+
}
|
|
1924
|
+
catch (e) {
|
|
1925
|
+
// Nothing names this copy yet, so nothing else would discard its stage or end its capture.
|
|
1926
|
+
await abandonCopy(ends, state);
|
|
1927
|
+
throw e;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
// A destination that cannot stage takes the dump whole, so the pages are held here instead, read
|
|
1931
|
+
// at a quiet moment: it cannot take a change batch either.
|
|
1932
|
+
if (captured)
|
|
1933
|
+
await endCapture(ends, from, stageId);
|
|
1828
1934
|
const held = [first.tables];
|
|
1829
1935
|
for (let cursor = first.next; cursor !== null;) {
|
|
1830
1936
|
const at = cursor;
|
|
@@ -1832,11 +1938,11 @@ export async function beginTransfer({ source, dest }, scope, opts = {}) {
|
|
|
1832
1938
|
if (page === 'predates')
|
|
1833
1939
|
throw stoppedPaging(from);
|
|
1834
1940
|
if (page.revision !== first.revision)
|
|
1835
|
-
return restart(
|
|
1941
|
+
return restart(ends, state, { ...opts, capture: false }, false);
|
|
1836
1942
|
held.push(page.tables);
|
|
1837
1943
|
cursor = page.next;
|
|
1838
1944
|
}
|
|
1839
|
-
return { done: { dump: mergeDumpPages(held), loadStamp: first.loadStamp, revision: first.revision, meta: metaOf(held) } };
|
|
1945
|
+
return { done: { dump: mergeDumpPages(held), loadStamp: first.loadStamp, revision: first.revision, meta: doneMeta(metaOf(held)) } };
|
|
1840
1946
|
}
|
|
1841
1947
|
/**
|
|
1842
1948
|
* Move the next pages of a copy (#2450), until it is staged whole or `until` says to stop (a job's
|
|
@@ -1845,17 +1951,32 @@ export async function beginTransfer({ source, dest }, scope, opts = {}) {
|
|
|
1845
1951
|
*
|
|
1846
1952
|
* `stopped` (a job being cancelled or forgotten): the copy starts nothing new. A torn copy then
|
|
1847
1953
|
* discards its stage and answers where it was, opening no other: whoever stopped it settles it.
|
|
1954
|
+
*
|
|
1955
|
+
* Once a captured copy's pages are staged, the step catches up: each change batch the source
|
|
1956
|
+
* serves is staged after the last page, until a final one. A batch is staged at the first number
|
|
1957
|
+
* the stage has not taken (`staged: false` means it has), so a step resumed from a record older
|
|
1958
|
+
* than the stage (a step that faulted after staging) applies the batch served now after the one
|
|
1959
|
+
* staged then, and the newer rows win.
|
|
1848
1960
|
*/
|
|
1849
|
-
export async function transferStep(
|
|
1961
|
+
export async function transferStep(ends, state, opts = {}) {
|
|
1962
|
+
const { source, dest } = ends;
|
|
1850
1963
|
const retry = opts.retry ?? ((run) => run());
|
|
1851
1964
|
let next = state;
|
|
1852
1965
|
while (next.cursor !== null) {
|
|
1853
1966
|
const at = next.cursor;
|
|
1854
|
-
const
|
|
1967
|
+
const capture = next.capture ? { capture: next.stageId } : {};
|
|
1968
|
+
const page = await retry(() => source.exportScopePage(next.from, at, { stamp: next.stamp, ...capture })).catch((e) => {
|
|
1969
|
+
// A capture that is gone (ended, expired, its schema moved) cannot vouch for the pages.
|
|
1970
|
+
if (next.capture && captureRefusal(e, 'gone', 'schemaMoved'))
|
|
1971
|
+
return 'lost';
|
|
1972
|
+
throw e;
|
|
1973
|
+
});
|
|
1974
|
+
if (page === 'lost')
|
|
1975
|
+
return restart(ends, next, opts, true);
|
|
1855
1976
|
if (page === 'predates')
|
|
1856
1977
|
throw stoppedPaging(next.from);
|
|
1857
|
-
if (page.revision !== next.revision)
|
|
1858
|
-
return restart(
|
|
1978
|
+
if (!next.capture && page.revision !== next.revision)
|
|
1979
|
+
return restart(ends, next, opts, true);
|
|
1859
1980
|
const seq = next.seq + 1;
|
|
1860
1981
|
const staged = await retry(() => dest.stageScopePage(next.into, next.stageId, seq, page.tables));
|
|
1861
1982
|
if (staged === 'predates') {
|
|
@@ -1867,18 +1988,71 @@ export async function transferStep({ source, dest }, state, opts = {}) {
|
|
|
1867
1988
|
cursor: page.next,
|
|
1868
1989
|
pages: next.pages + 1,
|
|
1869
1990
|
rows: next.rows + rowsIn(page.tables),
|
|
1870
|
-
meta:
|
|
1991
|
+
meta: metaOf([next.meta, page.tables]),
|
|
1871
1992
|
};
|
|
1872
1993
|
if (next.cursor !== null && opts.until?.())
|
|
1873
1994
|
return { state: next };
|
|
1874
1995
|
}
|
|
1996
|
+
while (next.capture) {
|
|
1997
|
+
const batch = next.batch ?? 0;
|
|
1998
|
+
// Stopped: no batch more, the first one included. A budget spent still lets one batch through.
|
|
1999
|
+
if (opts.stopped?.())
|
|
2000
|
+
return { state: next };
|
|
2001
|
+
if (batch >= (opts.catchUpBatches ?? CATCH_UP_BATCHES))
|
|
2002
|
+
return restart(ends, next, opts, true);
|
|
2003
|
+
if (batch > 0 && opts.until?.())
|
|
2004
|
+
return { state: next };
|
|
2005
|
+
const at = next;
|
|
2006
|
+
const changes = typeof source.exportScopeChanges !== 'function'
|
|
2007
|
+
? 'predates'
|
|
2008
|
+
: await retry(() => source.exportScopeChanges(at.from, at.stageId, batch)).catch((e) => {
|
|
2009
|
+
// Gone, or its schema moved: a final answer that was lost, asked again, is gone too.
|
|
2010
|
+
if (captureRefusal(e, 'gone', 'schemaMoved'))
|
|
2011
|
+
return 'lost';
|
|
2012
|
+
throw e;
|
|
2013
|
+
});
|
|
2014
|
+
if (changes === 'lost')
|
|
2015
|
+
return restart(ends, next, opts, true);
|
|
2016
|
+
// An end that cannot catch up: the copy is one read only if the source took no write since.
|
|
2017
|
+
if (changes === 'predates')
|
|
2018
|
+
return restart(ends, next, { ...opts, capture: false }, true);
|
|
2019
|
+
let seq = next.seq + 1;
|
|
2020
|
+
if (changes.tables.length > 0) {
|
|
2021
|
+
for (;;) {
|
|
2022
|
+
const staged = typeof dest.stageScopeChanges !== 'function'
|
|
2023
|
+
? 'predates'
|
|
2024
|
+
: await retry(() => dest.stageScopeChanges(at.into, at.stageId, seq, changes.tables));
|
|
2025
|
+
if (staged === 'predates')
|
|
2026
|
+
return restart(ends, next, { ...opts, capture: false }, true);
|
|
2027
|
+
if (staged.staged)
|
|
2028
|
+
break;
|
|
2029
|
+
seq++;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
else {
|
|
2033
|
+
seq = next.seq;
|
|
2034
|
+
}
|
|
2035
|
+
next = {
|
|
2036
|
+
...next,
|
|
2037
|
+
seq,
|
|
2038
|
+
batch: batch + 1,
|
|
2039
|
+
rows: next.rows + rowsIn(changes.tables),
|
|
2040
|
+
meta: foldMeta(next.meta, changes.tables),
|
|
2041
|
+
};
|
|
2042
|
+
if (changes.final) {
|
|
2043
|
+
// The source ended the capture with this batch: the copy is true at the moment it names.
|
|
2044
|
+
const { capture: _, batch: __, ...caught } = next;
|
|
2045
|
+
return { done: doneOf({ ...caught, revision: changes.revision }) };
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
1875
2048
|
return { done: doneOf(next) };
|
|
1876
2049
|
}
|
|
1877
2050
|
/** A torn copy: discard its stage and begin again, or refuse once the attempts are spent. */
|
|
1878
2051
|
async function restart(ends, state, opts, staged) {
|
|
1879
|
-
// Best effort: a stage nothing commits is replaced by the next one, and goes with a wipe.
|
|
2052
|
+
// Best effort: a stage nothing commits is replaced by the next one, and goes with a wipe. (An
|
|
2053
|
+
// unstaged copy holds no capture: it was ended when the destination turned out unable to stage.)
|
|
1880
2054
|
if (staged)
|
|
1881
|
-
await ends
|
|
2055
|
+
await abandonCopy(ends, state);
|
|
1882
2056
|
// Stopped: a new stage would outlast whoever stopped this copy, so none is opened.
|
|
1883
2057
|
if (opts.stopped?.())
|
|
1884
2058
|
return { state };
|
|
@@ -1889,9 +2063,67 @@ async function restart(ends, state, opts, staged) {
|
|
|
1889
2063
|
}
|
|
1890
2064
|
return beginTransfer(ends, { from: state.from, to: state.into }, { ...opts, stamp: state.stamp, attempt: state.attempt + 1 });
|
|
1891
2065
|
}
|
|
1892
|
-
|
|
2066
|
+
/**
|
|
2067
|
+
* End the capture `copyId` at the source (#2450), best effort: one left open expires on its own once
|
|
2068
|
+
* nobody reads it. Every copy that stops (failed, cancelled, superseded or restarted) calls it.
|
|
2069
|
+
*/
|
|
2070
|
+
export async function endCapture(ends, from, copyId) {
|
|
2071
|
+
await ends.source.endScopeCapture?.(from, copyId).catch(() => undefined);
|
|
2072
|
+
}
|
|
2073
|
+
/**
|
|
2074
|
+
* Give a copy up (#2450), best effort: discard its stage at the destination and end its capture at
|
|
2075
|
+
* the source, the two halves every copy that stops leaves behind. An end left out is skipped (one
|
|
2076
|
+
* that could not be resolved); its half then waits for a wipe, or expires.
|
|
2077
|
+
*/
|
|
2078
|
+
export async function abandonCopy(ends, state) {
|
|
2079
|
+
await ends.dest?.discardStagedScope(state.into, state.stageId).catch(() => undefined);
|
|
2080
|
+
if (state.capture && ends.source)
|
|
2081
|
+
await endCapture({ source: ends.source }, state.from, state.stageId);
|
|
2082
|
+
}
|
|
2083
|
+
/** How long a copy's first request catches up before it leaves the rest to a step, ms. */
|
|
2084
|
+
const BEGIN_CATCH_UP_MS = 20_000;
|
|
2085
|
+
/** Whether `e` is the source's capture refusing for one of `kinds` (`CAPTURE_REFUSALS`), not another 412. */
|
|
2086
|
+
const captureRefusal = (e, ...kinds) => e instanceof ControlPlaneError && e.status === 412 && kinds.some((k) => e.message.includes(CAPTURE_REFUSALS[k]));
|
|
2087
|
+
const doneOf = (state) => ({ dump: { stageId: state.stageId }, loadStamp: state.loadStamp, revision: state.revision, meta: doneMeta(state.meta) });
|
|
1893
2088
|
const rowsIn = (tables) => tables.reduce((n, t) => n + t.rows.length, 0);
|
|
1894
2089
|
const stoppedPaging = (scopeId) => new ControlPlaneError(502, `the source of scope ${scopeId} stopped answering dump pages part way; nothing was moved`);
|
|
1895
|
-
|
|
1896
|
-
|
|
2090
|
+
const META = '_substrat_meta';
|
|
2091
|
+
/**
|
|
2092
|
+
* The `_substrat_meta` fragments of some dump pages, merged, each row still named by its rowid
|
|
2093
|
+
* when every fragment carried them (a captured copy's), so a change batch can fold into it.
|
|
2094
|
+
*/
|
|
2095
|
+
const metaOf = (pages) => {
|
|
2096
|
+
let held;
|
|
2097
|
+
for (const t of pages.flat()) {
|
|
2098
|
+
if (t.name !== META)
|
|
2099
|
+
continue;
|
|
2100
|
+
if (!held) {
|
|
2101
|
+
held = { ...t, rows: [...t.rows], ...(t.rowids ? { rowids: [...t.rowids] } : {}) };
|
|
2102
|
+
continue;
|
|
2103
|
+
}
|
|
2104
|
+
held.rows.push(...t.rows);
|
|
2105
|
+
if (held.rowids && t.rowids)
|
|
2106
|
+
held.rowids.push(...t.rowids);
|
|
2107
|
+
else
|
|
2108
|
+
delete held.rowids;
|
|
2109
|
+
}
|
|
2110
|
+
return held ? [held] : [];
|
|
2111
|
+
};
|
|
2112
|
+
/** `meta` with a change batch's `_substrat_meta` change applied: by rowid, or whole when it came whole. */
|
|
2113
|
+
const foldMeta = (meta, tables) => {
|
|
2114
|
+
const change = tables.find((t) => t.name === META);
|
|
2115
|
+
if (!change)
|
|
2116
|
+
return meta;
|
|
2117
|
+
const { deleted, ...table } = change;
|
|
2118
|
+
if (!change.rowids)
|
|
2119
|
+
return [table];
|
|
2120
|
+
const held = meta[0];
|
|
2121
|
+
const rows = new Map(held?.rowids ? held.rowids.map((id, i) => [id, held.rows[i]]) : []);
|
|
2122
|
+
for (const id of deleted)
|
|
2123
|
+
rows.delete(id);
|
|
2124
|
+
change.rowids.forEach((id, i) => rows.set(id, change.rows[i]));
|
|
2125
|
+
return [{ ...(held ?? table), rows: [...rows.values()], rowids: [...rows.keys()] }];
|
|
2126
|
+
};
|
|
2127
|
+
/** What a finished copy reports of `_substrat_meta`: its rows, the rowids left behind. */
|
|
2128
|
+
const doneMeta = (meta) => meta.map(({ rowids: _, ...t }) => t);
|
|
1897
2129
|
//# sourceMappingURL=vertical-client.js.map
|