@substrat-run/adapter-cloudflare 0.122.1 → 0.125.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/control-plane-do.d.ts +8 -0
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +24 -7
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +176 -3
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +462 -28
- package/dist/host.js.map +1 -1
- package/dist/live-reads.d.ts +7 -9
- package/dist/live-reads.d.ts.map +1 -1
- package/dist/live-reads.js +7 -7
- package/dist/live-reads.js.map +1 -1
- package/dist/rewind-refusal.d.ts +11 -0
- package/dist/rewind-refusal.d.ts.map +1 -0
- package/dist/rewind-refusal.js +13 -0
- package/dist/rewind-refusal.js.map +1 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +393 -70
- package/dist/scope-do.js.map +1 -1
- package/package.json +6 -6
package/dist/host.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
|
|
2
|
+
import { delegatedReadParams, fromWireFailure, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, substratError, redrainEventsInput, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, verticalSlug, entityObjectRef, } from '@substrat-run/contracts';
|
|
2
3
|
import { normalizeHostname, toRouteTarget } from './route-resolver.js';
|
|
3
|
-
import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, runDueJobRuns, startJobRun, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, withRecorded, systemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, } from '@substrat-run/kernel';
|
|
4
|
+
import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, runDueJobRuns, startJobRun, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, } from '@substrat-run/kernel';
|
|
4
5
|
import { isOrangeToOrange, isUpgradeRequest, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
|
|
5
6
|
import { tenantStoreDatabaseName } from './d1.js';
|
|
6
7
|
import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
|
|
@@ -67,6 +68,43 @@ function nullControlPlane() {
|
|
|
67
68
|
},
|
|
68
69
|
});
|
|
69
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* #1819: the ONE object in a deployment's scope namespace that carries the rewind hold (see
|
|
73
|
+
* "the rewind hold" on `CloudflareScopeHost`). A name no `ScopeId` can parse to, so it is
|
|
74
|
+
* never a scope.
|
|
75
|
+
*/
|
|
76
|
+
export const SWITCH_HOLDS_NAME = 'substrat:switch-holds';
|
|
77
|
+
/**
|
|
78
|
+
* #1819: how long one read of the hold serves a sweep pass. The pass reads the whole hold once
|
|
79
|
+
* (it is normally empty) rather than once per scope, so a scope never rewound costs no RPC.
|
|
80
|
+
*/
|
|
81
|
+
export const SWITCH_HOLD_SNAPSHOT_MS = 2_000;
|
|
82
|
+
/**
|
|
83
|
+
* #1819: how long a rewind waits between writing its hold and rewinding. This is what makes the
|
|
84
|
+
* snapshot safe. The pass reads its snapshot only AFTER the scope's own state read, and re-reads
|
|
85
|
+
* one older than `SWITCH_HOLD_SNAPSHOT_MS`. A state read that met rewound storage therefore
|
|
86
|
+
* finished at least this long after the hold was written, so a snapshot taken before the hold
|
|
87
|
+
* is past its age by then and is re-read. Each side measures a duration on its own clock;
|
|
88
|
+
* nothing compares two clocks.
|
|
89
|
+
*/
|
|
90
|
+
export const SWITCH_HOLD_SETTLE_MS = SWITCH_HOLD_SNAPSHOT_MS + 1_000;
|
|
91
|
+
/**
|
|
92
|
+
* #1839: how many times a rewind waits again, after its settle, for a row a late OFF added to its
|
|
93
|
+
* claim to be `SWITCH_HOLD_SETTLE_MS` old. The settle argument above holds per ROW, so a row
|
|
94
|
+
* added during the settle needs its own full settle before the rewind arms. Each late OFF can
|
|
95
|
+
* cost up to one more settle; past this many, the rewind is REFUSED rather than armed with a young
|
|
96
|
+
* row, so a switch flapping through the wait cannot hold a rewind forever. See `settleClaim`.
|
|
97
|
+
*/
|
|
98
|
+
export const SWITCH_HOLD_EXTRA_WAITS = 3;
|
|
99
|
+
/**
|
|
100
|
+
* #1819: how long a claim may stay `pending` before a switch move treats it as armed with no
|
|
101
|
+
* doomed instance. A claim is pending from capture until its rewind armed or refused, which is
|
|
102
|
+
* `SWITCH_HOLD_SETTLE_MS` times at most `1 + SWITCH_HOLD_EXTRA_WAITS`, plus a few calls. One still
|
|
103
|
+
* pending long after that belongs to a rewinding request that died in between, and without this
|
|
104
|
+
* bound its hold could never be released. The age is `held_at`, stamped on the hold object's
|
|
105
|
+
* clock, against the releasing host's clock: minutes, not seconds.
|
|
106
|
+
*/
|
|
107
|
+
export const SWITCH_HOLD_PENDING_MAX_MS = 5 * 60_000;
|
|
70
108
|
export class CloudflareScopeHost {
|
|
71
109
|
admin;
|
|
72
110
|
scopeNs;
|
|
@@ -135,6 +173,17 @@ export class CloudflareScopeHost {
|
|
|
135
173
|
peerSwitchDelegation;
|
|
136
174
|
/** #1705 PR 3: the replay lever's reach into the deployment serving a consumer scope. */
|
|
137
175
|
importCursorDelegation;
|
|
176
|
+
/**
|
|
177
|
+
* #1819: the last read of the rewind hold, and when it was taken (before the read went out).
|
|
178
|
+
* Per instance, so per pass: the host is built per request, and never outlives one.
|
|
179
|
+
*/
|
|
180
|
+
holdSnapshot = null;
|
|
181
|
+
/**
|
|
182
|
+
* #1819: the hold read in flight, and when it went out. The sweepers run up to eight scopes
|
|
183
|
+
* at once on one host, and each can find the snapshot missing or old at the same moment; they
|
|
184
|
+
* join this one read rather than each sending their own.
|
|
185
|
+
*/
|
|
186
|
+
holdRefresh = null;
|
|
138
187
|
/**
|
|
139
188
|
* MUST be constructed per request. Never cache an instance across requests.
|
|
140
189
|
*
|
|
@@ -568,9 +617,12 @@ export class CloudflareScopeHost {
|
|
|
568
617
|
* `/internal/restore`. The control plane is the gate and the auditor; this end
|
|
569
618
|
* just replaces its own bytes with the dump's, migration frontier included.
|
|
570
619
|
*/
|
|
571
|
-
async restoreScopeLocal(scopeId, tables
|
|
572
|
-
|
|
573
|
-
|
|
620
|
+
async restoreScopeLocal(scopeId, tables,
|
|
621
|
+
/** #1742: the directory's recorded-off modules, switched off on THIS scope in the replay's
|
|
622
|
+
* own event — a dump from before a switch was pulled brings its grants back live. */
|
|
623
|
+
opts) {
|
|
624
|
+
const switchedOff = await this.scopeStub(scopeId).importDump(tables, scopeId, opts?.switchedOff ? { moduleIds: opts.switchedOff, at: new Date().toISOString() } : undefined);
|
|
625
|
+
return { tables: tables.length, ...(opts?.switchedOff ? { switchedOff } : {}) };
|
|
574
626
|
}
|
|
575
627
|
/**
|
|
576
628
|
* Re-apply the vertical's OWN role definitions to one scope, CP-lessly — the
|
|
@@ -592,6 +644,10 @@ export class CloudflareScopeHost {
|
|
|
592
644
|
*/
|
|
593
645
|
async deleteScopeLocal(scopeId) {
|
|
594
646
|
await this.scopeStub(scopeId).destroyStorage();
|
|
647
|
+
// #1819: a deleted scope runs nothing; its claims go with it. Best effort, never a failed reap.
|
|
648
|
+
await this.switchHoldsStub()
|
|
649
|
+
.switchHoldRelease(scopeId, null, null)
|
|
650
|
+
.catch(() => undefined);
|
|
595
651
|
}
|
|
596
652
|
/**
|
|
597
653
|
* Dump one scope's tables from THIS deployment — the data half of a governed
|
|
@@ -681,9 +737,12 @@ export class CloudflareScopeHost {
|
|
|
681
737
|
* Rewind one scope to a pre-migration bookmark (#286's backout) — schema AND data,
|
|
682
738
|
* discarding every write since; the DO enforces the freshness window and restarts
|
|
683
739
|
* itself to complete the restore. Behind the vertical's `/internal/rewind`.
|
|
740
|
+
*
|
|
741
|
+
* #1819: what the scope has switched off is held outside it first, so the rewound grants
|
|
742
|
+
* run nothing until the switch is back in the scope. See `rewindHolding`.
|
|
684
743
|
*/
|
|
685
744
|
async rewindScopeLocal(scopeId, bookmark, opts) {
|
|
686
|
-
return this.scopeStub(scopeId).rewindToBookmark(bookmark, opts);
|
|
745
|
+
return this.rewindHolding(scopeId, () => this.scopeStub(scopeId).rewindToBookmark(bookmark, opts));
|
|
687
746
|
}
|
|
688
747
|
registerModule(registration) {
|
|
689
748
|
const manifest = moduleManifest.parse(registration.manifest);
|
|
@@ -779,13 +838,14 @@ export class CloudflareScopeHost {
|
|
|
779
838
|
// per-scope schedule kill switch is `revokeFromSystem` (#1666), and its OFF marker is
|
|
780
839
|
// not a grant, so no reconcile can seat it away; `restoreToSystem` is the only way back.
|
|
781
840
|
// (`grantToSystem` still clears a tuple's tombstone, but it does not move the switch.)
|
|
841
|
+
const seats = [];
|
|
782
842
|
for (const [moduleId, schedules] of this.moduleSchedules) {
|
|
783
843
|
const perms = new Set();
|
|
784
844
|
for (const s of schedules)
|
|
785
845
|
for (const p of s.permissions)
|
|
786
846
|
perms.add(p);
|
|
787
847
|
for (const perm of perms) {
|
|
788
|
-
|
|
848
|
+
seats.push({ subject: `system:${moduleId}`, relation: `granted:${perm}`, object: `scope:${input.scopeId}`, expires_at: null });
|
|
789
849
|
}
|
|
790
850
|
}
|
|
791
851
|
// #1706: every declared PEER holds its keys on the scope from provisioning on — the one
|
|
@@ -793,14 +853,22 @@ export class CloudflareScopeHost {
|
|
|
793
853
|
// schedule grants above, so a grant a switch tombstoned stays tombstoned and a
|
|
794
854
|
// switched-off peer gets nothing seated.
|
|
795
855
|
for (const seat of peerSeats(collectPeers(this.peerSources), input.scopeId)) {
|
|
796
|
-
|
|
856
|
+
seats.push({ subject: seat.subject, relation: seat.relation, object: seat.object, expires_at: null });
|
|
797
857
|
}
|
|
798
858
|
// #1674: re-assert the recorded OFF positions after the seat (`system-switch-record.ts`).
|
|
799
859
|
// Only where this seat landed in the scope's real store — a CP-less host, or a scope
|
|
800
860
|
// bound to no vertical. A scope a vertical's deployment serves is seated there, later,
|
|
801
861
|
// and re-asserted after that deployment's reconcile instead.
|
|
802
|
-
|
|
803
|
-
|
|
862
|
+
//
|
|
863
|
+
// #1742: in the SAME unit as the seat, so no sweep can land between them; the re-assert
|
|
864
|
+
// after it then finds them off, and audits what the unit moved.
|
|
865
|
+
const ownStore = this.cpLess || record.vertical === null;
|
|
866
|
+
const recordedOff = ownStore ? await this.cp.switchedOffModulesOf(input.tenantId, input.scopeId) : [];
|
|
867
|
+
const switchedOff = await this.scopeStub(input.scopeId).seatTuples(seats, recordedOff.length
|
|
868
|
+
? { scopeId: input.scopeId, moduleIds: recordedOff, at: new Date().toISOString() }
|
|
869
|
+
: undefined);
|
|
870
|
+
if (ownStore) {
|
|
871
|
+
await this.admin.reassertSystemSwitches(actor, { tenantId: input.tenantId, scopeId: input.scopeId }, { appliedInUnit: switchedOff });
|
|
804
872
|
}
|
|
805
873
|
// Audit a real provision only; an idempotent re-provision changed nothing.
|
|
806
874
|
if (created) {
|
|
@@ -1120,8 +1188,17 @@ export class CloudflareScopeHost {
|
|
|
1120
1188
|
const existing = await this.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
1121
1189
|
if (!existing)
|
|
1122
1190
|
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
1123
|
-
|
|
1191
|
+
// #1742: a dump from before a switch was pulled brings the module's grants back live, so
|
|
1192
|
+
// the directory's recorded-off modules go back off in the replay's own event. Only where
|
|
1193
|
+
// this is the scope's real store, as for the provision's seat above; a hosted scope is
|
|
1194
|
+
// restored in its deployment, which the platform carries the same list to.
|
|
1195
|
+
const ownStore = this.cpLess || existing.vertical === null;
|
|
1196
|
+
const recordedOff = ownStore ? await this.cp.switchedOffModulesOf(tenantId, scopeId) : [];
|
|
1197
|
+
const switchedOff = await this.scopeStub(scopeId).importDump(dump.tables, scopeId, recordedOff.length ? { moduleIds: recordedOff, at: new Date().toISOString() } : undefined);
|
|
1124
1198
|
await this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
|
|
1199
|
+
if (recordedOff.length) {
|
|
1200
|
+
await this.admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: switchedOff });
|
|
1201
|
+
}
|
|
1125
1202
|
}
|
|
1126
1203
|
async snapshotScope(actor, tenantId, scopeId, opts) {
|
|
1127
1204
|
const source = await this.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
@@ -1554,7 +1631,12 @@ export class CloudflareScopeHost {
|
|
|
1554
1631
|
const state = await stub.systemScheduleState(moduleId);
|
|
1555
1632
|
if (state === 'ungranted')
|
|
1556
1633
|
return report;
|
|
1557
|
-
|
|
1634
|
+
// #1819: a scope rewound to before its switch was pulled answers `on`. The rewind held the
|
|
1635
|
+
// module outside the scope; read AFTER the state, which is the order `switchHeld` needs.
|
|
1636
|
+
const hold = state === 'on' ? await this.switchHeld(scopeId, moduleId) : { held: false };
|
|
1637
|
+
if (hold.error)
|
|
1638
|
+
report.errors.push({ operation: 'switch-hold', error: hold.error });
|
|
1639
|
+
if (state === 'off' || hold.held) {
|
|
1558
1640
|
for (const schedule of schedules) {
|
|
1559
1641
|
report.skipped += 1;
|
|
1560
1642
|
report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
|
|
@@ -1572,10 +1654,14 @@ export class CloudflareScopeHost {
|
|
|
1572
1654
|
report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
|
|
1573
1655
|
continue;
|
|
1574
1656
|
}
|
|
1657
|
+
// #1525: one id for THIS call, minted before the invoke so it is the same id
|
|
1658
|
+
// whether the operation succeeds or throws — an event or a denial the call
|
|
1659
|
+
// produces either way carries it, and so does the row below.
|
|
1660
|
+
const invocationId = ulid();
|
|
1575
1661
|
let status = 'ok';
|
|
1576
1662
|
try {
|
|
1577
1663
|
const scope = await this.getSystemScope(moduleId, tenantId, scopeId);
|
|
1578
|
-
await scope.invoke(schedule.operation, schedule.input);
|
|
1664
|
+
await scope.invoke(schedule.operation, schedule.input, { invocationId });
|
|
1579
1665
|
report.fired += 1;
|
|
1580
1666
|
}
|
|
1581
1667
|
catch (err) {
|
|
@@ -1588,7 +1674,7 @@ export class CloudflareScopeHost {
|
|
|
1588
1674
|
}
|
|
1589
1675
|
// #1288: 'schedule', whatever this operation happens to be called — including
|
|
1590
1676
|
// `freshness:<something>`, which is exactly the row the evaluator no longer eats.
|
|
1591
|
-
await stub.recordScheduleRun(schedule.operation, new Date(now).toISOString(), status, 'schedule');
|
|
1677
|
+
await stub.recordScheduleRun(schedule.operation, new Date(now).toISOString(), status, 'schedule', invocationId);
|
|
1592
1678
|
report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
|
|
1593
1679
|
}
|
|
1594
1680
|
return report;
|
|
@@ -1633,6 +1719,18 @@ export class CloudflareScopeHost {
|
|
|
1633
1719
|
return {
|
|
1634
1720
|
tenantId,
|
|
1635
1721
|
scopeId,
|
|
1722
|
+
// #1746: the door decided it — the same precedence `asPrincipalId` uses below.
|
|
1723
|
+
subjectKind: principal !== undefined
|
|
1724
|
+
? 'principal'
|
|
1725
|
+
: connectionId !== undefined
|
|
1726
|
+
? 'connection'
|
|
1727
|
+
: systemModuleId !== undefined
|
|
1728
|
+
? 'system'
|
|
1729
|
+
: capabilitySession !== undefined
|
|
1730
|
+
? 'capability'
|
|
1731
|
+
: verticalCaller !== undefined
|
|
1732
|
+
? 'vertical'
|
|
1733
|
+
: undefined,
|
|
1636
1734
|
invoke: async (operation, input, invokeOptions) => {
|
|
1637
1735
|
// Entitlement gate (§4.3): a module loads for a tenant only if the tenant holds its
|
|
1638
1736
|
// SKU flag. The COORDINATOR gates the console-managed path against the shared CP
|
|
@@ -1729,6 +1827,8 @@ export class CloudflareScopeHost {
|
|
|
1729
1827
|
invokeOptions?.onEntityVersion?.(envelope.concurrency.version);
|
|
1730
1828
|
if (envelope.idempotency?.replayed)
|
|
1731
1829
|
invokeOptions?.onIdempotentReplay?.();
|
|
1830
|
+
if (envelope.emitted)
|
|
1831
|
+
invokeOptions?.onEmitted?.(envelope.emitted);
|
|
1732
1832
|
return envelope.result;
|
|
1733
1833
|
},
|
|
1734
1834
|
};
|
|
@@ -1891,7 +1991,8 @@ export class CloudflareScopeHost {
|
|
|
1891
1991
|
};
|
|
1892
1992
|
const writeGrant = async (subject, permission, node, entity, expiresAt) => {
|
|
1893
1993
|
if (entity) {
|
|
1894
|
-
await this.writeScopeTuple(node.scopeId, subject, `granted:${permission}`,
|
|
1994
|
+
await this.writeScopeTuple(node.scopeId, subject, `granted:${permission}`, entityObjectRef(entity, 'HostAdmin'), // #1856: grant and grantToOrg both land here
|
|
1995
|
+
expiresAt ?? null);
|
|
1895
1996
|
}
|
|
1896
1997
|
else if (node.scopeId) {
|
|
1897
1998
|
await this.writeScopeTuple(node.scopeId, subject, `granted:${permission}`, `scope:${node.scopeId}`, expiresAt ?? null);
|
|
@@ -1997,9 +2098,11 @@ export class CloudflareScopeHost {
|
|
|
1997
2098
|
vertical = rec.vertical;
|
|
1998
2099
|
}
|
|
1999
2100
|
const delegation = this.cpLess || vertical !== null ? this.systemSwitchDelegation : undefined;
|
|
2101
|
+
// #1819: a scope whose store is here was rewound here, so `switchInScope` releases its
|
|
2102
|
+
// hold here too. A delegated move releases in the deployment serving the scope.
|
|
2000
2103
|
const move = (moduleId, to, at) => delegation
|
|
2001
2104
|
? delegation.switch({ tenantId, scopeId, moduleId, to })
|
|
2002
|
-
: this.
|
|
2105
|
+
: this.switchInScope(scopeId, moduleId, to, at);
|
|
2003
2106
|
return { vertical, move };
|
|
2004
2107
|
};
|
|
2005
2108
|
const switchSystem = async (actor, raw, to) => {
|
|
@@ -2198,20 +2301,52 @@ export class CloudflareScopeHost {
|
|
|
2198
2301
|
* so it lands wherever an operator's OFF would have — for a hosted scope, over the
|
|
2199
2302
|
* `/internal/system-switch` route every deployment since #1666 answers.
|
|
2200
2303
|
*/
|
|
2201
|
-
const reassertSystemSwitchesOf = async (actor, node) => {
|
|
2304
|
+
const reassertSystemSwitchesOf = async (actor, node, opts) => {
|
|
2202
2305
|
const { tenantId, scopeId } = node;
|
|
2203
2306
|
const { vertical, move } = await systemSwitchTarget(tenantId, scopeId);
|
|
2204
|
-
const
|
|
2307
|
+
const recorded = new Map(await this.cp.systemSwitchRecordsOf(tenantId, scopeId));
|
|
2308
|
+
// #1742 review: moves the deployment made from a stale list (restored ON after the list
|
|
2309
|
+
// was read), to undo before the OFF pass below.
|
|
2310
|
+
const reverts = staleCarryReverts(recorded, opts?.appliedInUnit);
|
|
2205
2311
|
// A hosted scope with no delegation configured (Copilot review): this host's own
|
|
2206
2312
|
// namespace is the module-less placeholder, where the switch would answer `held: false`
|
|
2207
2313
|
// quietly — a re-assert reported done, and a receipt written, while the deployment
|
|
2208
2314
|
// serving the scope keeps its schedules running. Refused loudly instead, as the status
|
|
2209
2315
|
// read is, and only when a re-assert is owed: nothing recorded, nothing to refuse.
|
|
2210
|
-
|
|
2316
|
+
const owed = reverts.length > 0 || [...recorded.values()].includes('off');
|
|
2317
|
+
if (owed && !this.cpLess && vertical !== null && !this.systemSwitchDelegation) {
|
|
2211
2318
|
throw substratError('unavailable', `no delegation configured for hosted scope ${scopeId} (vertical '${vertical}') — cannot re-assert ` +
|
|
2212
2319
|
`its switched-off schedules in the deployment serving it`);
|
|
2213
2320
|
}
|
|
2214
2321
|
const at = new Date().toISOString();
|
|
2322
|
+
const reverted = new Set();
|
|
2323
|
+
for (const moduleId of reverts) {
|
|
2324
|
+
// Re-read immediately before the move: a staff OFF that completed since the read above
|
|
2325
|
+
// (record `off`) must not get a transient ON a due schedule could run in.
|
|
2326
|
+
const current = new Map(await this.cp.systemSwitchRecordsOf(tenantId, scopeId));
|
|
2327
|
+
if (current.get(moduleId) !== 'on')
|
|
2328
|
+
continue;
|
|
2329
|
+
const outcome = await move(moduleId, 'on', at);
|
|
2330
|
+
reverted.add(moduleId);
|
|
2331
|
+
if (outcome.changed) {
|
|
2332
|
+
await this.recordAdmin(actor, 'reassertSystemSwitch', { tenantId, scopeId, vertical }, null, {
|
|
2333
|
+
operationId: ulid(),
|
|
2334
|
+
...staleCarryRevertRow(moduleId, outcome),
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
// Read AFTER the reverts: an OFF that landed meanwhile is switched off below.
|
|
2339
|
+
const modules = await this.cp.switchedOffModulesOf(tenantId, scopeId);
|
|
2340
|
+
// #1742: what the deployment already switched off inside its own unit, audited here —
|
|
2341
|
+
// the move below answers `changed: false` for it and would write no row.
|
|
2342
|
+
// A move the revert above undid is not credited as an in-unit OFF.
|
|
2343
|
+
const applied = opts?.appliedInUnit?.filter((a) => !reverted.has(a.moduleId));
|
|
2344
|
+
for (const row of inUnitMovesToAudit(modules, applied)) {
|
|
2345
|
+
await this.recordAdmin(actor, 'reassertSystemSwitch', { tenantId, scopeId, vertical }, null, {
|
|
2346
|
+
operationId: ulid(),
|
|
2347
|
+
...row,
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2215
2350
|
const results = [];
|
|
2216
2351
|
for (const moduleId of modules) {
|
|
2217
2352
|
const outcome = await move(moduleId, 'off', at);
|
|
@@ -2414,7 +2549,12 @@ export class CloudflareScopeHost {
|
|
|
2414
2549
|
}
|
|
2415
2550
|
}
|
|
2416
2551
|
else {
|
|
2417
|
-
|
|
2552
|
+
// #1743: refused while the record holds the module off on any scope — checked and
|
|
2553
|
+
// written in one control-plane call, where the record and the tenant tuple both live.
|
|
2554
|
+
const off = await this.cp.writeTenantSystemGrant(grant.node.tenantId, grant.moduleId, `granted:${grant.permission}`, grant.expiresAt ?? null);
|
|
2555
|
+
if (off.length > 0) {
|
|
2556
|
+
throw substratError('conflict', tenantSystemSwitchedOffMessage(grant.moduleId, off));
|
|
2557
|
+
}
|
|
2418
2558
|
}
|
|
2419
2559
|
await this.recordAdmin(actor, 'grantToSystem', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, { moduleId: grant.moduleId, permission: grant.permission, node: grant.node });
|
|
2420
2560
|
if (!grant.node.scopeId)
|
|
@@ -3050,7 +3190,8 @@ export class CloudflareScopeHost {
|
|
|
3050
3190
|
// host's namespace here would PITR an unrelated, unused DO.
|
|
3051
3191
|
return { rewindingTo: bookmark };
|
|
3052
3192
|
}
|
|
3053
|
-
|
|
3193
|
+
// #1819: the same rewind a dispatched vertical runs, holding what the scope switched off.
|
|
3194
|
+
return this.rewindScopeLocal(scopeId, bookmark, { force: opts?.force });
|
|
3054
3195
|
},
|
|
3055
3196
|
createOrg: async (actor, input) => {
|
|
3056
3197
|
const parsed = createOrgInput.parse(input);
|
|
@@ -3338,7 +3479,7 @@ export class CloudflareScopeHost {
|
|
|
3338
3479
|
facetEvents: async (actor, tenantId, scopeId, input) => {
|
|
3339
3480
|
await this.scopeRecordForRead(tenantId, scopeId);
|
|
3340
3481
|
const result = await this.scopeStub(scopeId).facetEvents(input);
|
|
3341
|
-
await this.recordAccess(actor, 'facetEvents', { tenantId, scopeId }, input, result.buckets.length);
|
|
3482
|
+
await this.recordAccess(actor, 'facetEvents', { tenantId, scopeId }, delegatedReadParams.facetEvents(input, result), result.buckets.length);
|
|
3342
3483
|
return result;
|
|
3343
3484
|
},
|
|
3344
3485
|
entityHistory: async (actor, tenantId, scopeId, input) => {
|
|
@@ -4052,6 +4193,7 @@ export class CloudflareScopeHost {
|
|
|
4052
4193
|
request_id: entry.requestId ?? null,
|
|
4053
4194
|
event_type: entry.eventType ?? null,
|
|
4054
4195
|
observed_at: entry.observedAt ?? null,
|
|
4196
|
+
platform_requests: entry.platformRequests == null ? null : JSON.stringify(entry.platformRequests),
|
|
4055
4197
|
at: entry.at ?? new Date().toISOString(),
|
|
4056
4198
|
});
|
|
4057
4199
|
},
|
|
@@ -4331,6 +4473,274 @@ export class CloudflareScopeHost {
|
|
|
4331
4473
|
// via newUniqueId (K-7) and stores the mapping in the directory — deferred.
|
|
4332
4474
|
return this.scopeNs.get(this.scopeNs.idFromName(scopeId));
|
|
4333
4475
|
}
|
|
4476
|
+
// -- the rewind hold (#1819) ------------------------------------------------
|
|
4477
|
+
// A PITR rewind to a bookmark from before a schedule kill switch was pulled brings the
|
|
4478
|
+
// module's `system:` grants back live, and nothing in the rewound storage says otherwise.
|
|
4479
|
+
// The scope's OFF modules are read while the pre-rewind storage still holds them, and each
|
|
4480
|
+
// rewind CLAIMS them on `SWITCH_HOLDS_NAME`, which the rewind cannot reach. A module is held
|
|
4481
|
+
// while any claim on it exists, and `runDueSchedules` skips a held module.
|
|
4482
|
+
//
|
|
4483
|
+
// A claim is released only by a switch move KNOWN to survive its rewind (`switchInScope`): a
|
|
4484
|
+
// move on the restored storage, after the rewind armed. A move during the settle, or one the
|
|
4485
|
+
// doomed instance applied, lands in storage the restart discards, so it releases nothing. A
|
|
4486
|
+
// definite refusal drops only that rewind's own claim, so a concurrent rewind keeps its own.
|
|
4487
|
+
// An OFF landing in that same discarded storage joins the claims that would discard it (#1839).
|
|
4488
|
+
switchHoldsStub() {
|
|
4489
|
+
return this.scopeNs.get(this.scopeNs.idFromName(SWITCH_HOLDS_NAME));
|
|
4490
|
+
}
|
|
4491
|
+
/**
|
|
4492
|
+
* Is this module held off on this scope? Read from one snapshot of the whole hold, re-read
|
|
4493
|
+
* once it is older than `SWITCH_HOLD_SNAPSHOT_MS`. Called only AFTER the scope's own state
|
|
4494
|
+
* read, which is the order `SWITCH_HOLD_SETTLE_MS` relies on.
|
|
4495
|
+
*
|
|
4496
|
+
* A failed read fails OPEN except for what this pass already knows is held. Failing closed
|
|
4497
|
+
* would turn one object's outage into every schedule of the deployment stopping; failing open
|
|
4498
|
+
* reopens only the gap this closes, for a rewind that lands during the outage, and the next
|
|
4499
|
+
* reconcile still switches that module off. The error is answered, for the pass to report.
|
|
4500
|
+
*/
|
|
4501
|
+
async switchHeld(scopeId, moduleId) {
|
|
4502
|
+
const at = Date.now();
|
|
4503
|
+
let error;
|
|
4504
|
+
if (!this.holdSnapshot || at - this.holdSnapshot.at > SWITCH_HOLD_SNAPSHOT_MS) {
|
|
4505
|
+
const inflight = this.holdRefresh;
|
|
4506
|
+
// Join a read that went out within the snapshot age of this consult: its result is then as
|
|
4507
|
+
// fresh as one this consult would take itself, which is what the settle argument needs.
|
|
4508
|
+
// Only the consult that sent the read reports its failure.
|
|
4509
|
+
if (inflight && at - inflight.at <= SWITCH_HOLD_SNAPSHOT_MS) {
|
|
4510
|
+
await inflight.done;
|
|
4511
|
+
}
|
|
4512
|
+
else {
|
|
4513
|
+
const refresh = { at, done: this.refreshHolds(at) };
|
|
4514
|
+
this.holdRefresh = refresh;
|
|
4515
|
+
try {
|
|
4516
|
+
({ error } = await refresh.done);
|
|
4517
|
+
}
|
|
4518
|
+
finally {
|
|
4519
|
+
if (this.holdRefresh === refresh)
|
|
4520
|
+
this.holdRefresh = null;
|
|
4521
|
+
}
|
|
4522
|
+
}
|
|
4523
|
+
}
|
|
4524
|
+
const held = this.holdSnapshot?.held.has(`${scopeId} ${moduleId}`) ?? false;
|
|
4525
|
+
return { held, ...(error ? { error } : {}) };
|
|
4526
|
+
}
|
|
4527
|
+
/** One read of the whole hold into the snapshot, stamped with when it went out. Never throws. */
|
|
4528
|
+
async refreshHolds(at) {
|
|
4529
|
+
try {
|
|
4530
|
+
const rows = await this.switchHoldsStub().switchHoldsAll();
|
|
4531
|
+
// A slow read that lands after a newer one never replaces it.
|
|
4532
|
+
if (!this.holdSnapshot || at >= this.holdSnapshot.at) {
|
|
4533
|
+
this.holdSnapshot = { at, held: new Set(rows.map((r) => `${r.scopeId} ${r.moduleId}`)) };
|
|
4534
|
+
}
|
|
4535
|
+
return {};
|
|
4536
|
+
}
|
|
4537
|
+
catch (err) {
|
|
4538
|
+
const known = this.holdSnapshot?.held;
|
|
4539
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
4540
|
+
// Deliberately re-stamped, so a failing hold object is retried once per snapshot age rather
|
|
4541
|
+
// than hammered per scope. For that long, other scopes reuse this possibly pre-hold set
|
|
4542
|
+
// silently, and only the consult that sent this read reports the error: the declared
|
|
4543
|
+
// fail-open, attributed once.
|
|
4544
|
+
if (!this.holdSnapshot || at >= this.holdSnapshot.at)
|
|
4545
|
+
this.holdSnapshot = { at, held: known ?? new Set() };
|
|
4546
|
+
return {
|
|
4547
|
+
error: known
|
|
4548
|
+
? `switch hold unreadable (${why}); the ${known.size} hold(s) from this pass's last good read ` +
|
|
4549
|
+
`still applied, any newer ones did not`
|
|
4550
|
+
: `switch hold unreadable (${why}); no earlier read in this pass, so no hold applied`,
|
|
4551
|
+
};
|
|
4552
|
+
}
|
|
4553
|
+
}
|
|
4554
|
+
/**
|
|
4555
|
+
* Move one module's switch in this host's own scope DO, and release the claims the move is
|
|
4556
|
+
* known to survive. The claims are read BEFORE the move (S0). A claim in S0 that was already
|
|
4557
|
+
* `armed` had its rewind armed before the move started, and a Durable Object has one live
|
|
4558
|
+
* instance at a time. So the instance that applied the move is either the doomed one (its
|
|
4559
|
+
* write is discarded: keep the claim) or one started after it, on the restored storage (the
|
|
4560
|
+
* write persists: release). A claim still `pending` in S0 may belong to a rewind that has not
|
|
4561
|
+
* armed yet, which would discard this move too: keep it, unless it is older than
|
|
4562
|
+
* `SWITCH_HOLD_PENDING_MAX_MS`. A claim created after S0 is never touched.
|
|
4563
|
+
*
|
|
4564
|
+
* An ON releases every claim in S0, surviving or not. A claim exists to keep off a module the
|
|
4565
|
+
* operator had off, and ON is their newer word: the directory records it before the move. If
|
|
4566
|
+
* the ON itself is discarded by a rewind, the scope comes back as the bookmark had it, and a
|
|
4567
|
+
* claim would then keep the module off while the directory and the status read both say ON.
|
|
4568
|
+
* The same call tombstones the module on the hold object (`switchHoldOn`), after the move: a
|
|
4569
|
+
* rewind that read the switch before this ON cannot claim the module from that read afterwards
|
|
4570
|
+
* (#1839). The release still covers only S0: a row a stale read inserted between S0 and the
|
|
4571
|
+
* tombstone, into a claim that did not hold the module at S0, stays. That window is a few calls,
|
|
4572
|
+
* and it errs toward OFF, until the next surviving move of the switch.
|
|
4573
|
+
*
|
|
4574
|
+
* An OFF that changed the switch JOINS the claims it would not survive (#1839): each claim in S0
|
|
4575
|
+
* or S1 (below) that is still pending (inside the bound), or armed with this move's instance as
|
|
4576
|
+
* its doomed one. Those are exactly the rewinds whose restart discards this OFF, and each of them captured
|
|
4577
|
+
* the module ON. The row takes the claim's state (`switchHoldJoin`, which never recreates a claim
|
|
4578
|
+
* with no rows left) and is stamped on the hold object's clock, and
|
|
4579
|
+
* a pending claim's rewind then waits for it to be a full settle old (`rewindHolding`). The
|
|
4580
|
+
* candidates are read twice, in S0 and again after the move (S1), so a claim written while
|
|
4581
|
+
* this move was queued is joined too. Both reads are scope-wide; S1 is taken only by an OFF
|
|
4582
|
+
* that changed the switch.
|
|
4583
|
+
*
|
|
4584
|
+
* A failed claims read does not stop the move. It is thrown after it, and the caller treats
|
|
4585
|
+
* that as a failed move, with the claims kept, which errs toward OFF. A failed join is thrown
|
|
4586
|
+
* the same way.
|
|
4587
|
+
*
|
|
4588
|
+
* An OFF then an ON of one module, milliseconds apart, can interleave so the ON's release reads
|
|
4589
|
+
* its claims before the OFF's join lands. The joined row then holds the module the operator just
|
|
4590
|
+
* turned ON, until the next surviving move of that switch releases it (another ON does).
|
|
4591
|
+
*/
|
|
4592
|
+
async switchInScope(scopeId, moduleId, to, at) {
|
|
4593
|
+
let scopeClaims;
|
|
4594
|
+
let readError;
|
|
4595
|
+
try {
|
|
4596
|
+
scopeClaims = await this.switchHoldsStub().switchHoldClaims(scopeId);
|
|
4597
|
+
}
|
|
4598
|
+
catch (err) {
|
|
4599
|
+
readError = err;
|
|
4600
|
+
}
|
|
4601
|
+
const { instance, ...outcome } = await this.scopeStub(scopeId).switchSystemSchedules(moduleId, scopeId, to, at);
|
|
4602
|
+
if (!scopeClaims)
|
|
4603
|
+
throw readError;
|
|
4604
|
+
const now = Date.now();
|
|
4605
|
+
const pendingLive = (c) => c.state === 'pending' && now - Date.parse(c.heldAt) <= SWITCH_HOLD_PENDING_MAX_MS;
|
|
4606
|
+
const before = scopeClaims.filter((c) => c.moduleId === moduleId);
|
|
4607
|
+
if (to === 'on') {
|
|
4608
|
+
// Every ON, claims or not: its tombstone is what keeps a rewind's stale read from claiming
|
|
4609
|
+
// the module after this (#1839).
|
|
4610
|
+
this.holdSnapshot = null;
|
|
4611
|
+
await this.switchHoldsStub().switchHoldOn(scopeId, moduleId, before.map((c) => c.claimId));
|
|
4612
|
+
}
|
|
4613
|
+
else if (outcome.held && before.length > 0) {
|
|
4614
|
+
const survived = before.filter((c) => !pendingLive(c) && c.doomed !== instance);
|
|
4615
|
+
if (survived.length > 0) {
|
|
4616
|
+
this.holdSnapshot = null;
|
|
4617
|
+
await this.switchHoldsStub().switchHoldRelease(scopeId, moduleId, survived.map((c) => c.claimId));
|
|
4618
|
+
}
|
|
4619
|
+
}
|
|
4620
|
+
if (to === 'off' && outcome.changed) {
|
|
4621
|
+
// S1, read after the move: a claim written while this move was queued behind the capture
|
|
4622
|
+
// is in S1 only. S0 still counts: a claim pending in S0 may have armed with another
|
|
4623
|
+
// instance by S1, and this move may have landed before that arm.
|
|
4624
|
+
const after = await this.switchHoldsStub().switchHoldClaims(scopeId);
|
|
4625
|
+
const joins = new Set([...scopeClaims, ...after].filter((c) => pendingLive(c) || c.doomed === instance).map((c) => c.claimId));
|
|
4626
|
+
if (joins.size > 0) {
|
|
4627
|
+
this.holdSnapshot = null;
|
|
4628
|
+
await this.switchHoldsStub().switchHoldJoin(scopeId, moduleId, [...joins]);
|
|
4629
|
+
}
|
|
4630
|
+
}
|
|
4631
|
+
return outcome;
|
|
4632
|
+
}
|
|
4633
|
+
/**
|
|
4634
|
+
* Rewind one scope, claiming what it has switched off, and what an earlier rewind still holds on
|
|
4635
|
+
* it. The OFF modules are read and claimed BEFORE the rewind, while this storage still holds
|
|
4636
|
+
* them, and the rewind then waits until every row of its claim is `SWITCH_HOLD_SETTLE_MS` old
|
|
4637
|
+
* (`settleClaim`). Its claim is `armed` once the rewind arms, naming the doomed
|
|
4638
|
+
* instance. Only a DEFINITE refusal drops the claim, and only this rewind's own. Any other throw
|
|
4639
|
+
* may come after the DO armed the bookmark, so the claim is armed on what a probe finds: the
|
|
4640
|
+
* serving instance if it armed, else none. A probe that fails leaves the claim pending, and
|
|
4641
|
+
* `SWITCH_HOLD_PENDING_MAX_MS` bounds that.
|
|
4642
|
+
*
|
|
4643
|
+
* With nothing to claim, the rewind neither claims nor settles. An OFF that lands between that
|
|
4644
|
+
* status read and the arm (a few calls) is then NOT held at all: the rewound scope runs that
|
|
4645
|
+
* module until the next reconcile re-asserts the switch, #1819's gap for those milliseconds.
|
|
4646
|
+
* Closing it would make every rewind claim and settle, 3 s on each, to cover a few calls.
|
|
4647
|
+
*/
|
|
4648
|
+
async rewindHolding(scopeId, rewind) {
|
|
4649
|
+
const holds = this.switchHoldsStub();
|
|
4650
|
+
// What this rewind must keep off: what the storage has off, AND what an earlier rewind still
|
|
4651
|
+
// holds here. A module held by an earlier rewind is ON in this storage (that is why it is
|
|
4652
|
+
// held), so the storage alone would give this rewind no claim on it. Then a re-assert that
|
|
4653
|
+
// lands on the instance THIS rewind dooms would release the earlier claim, and this
|
|
4654
|
+
// rewind's restart would discard the re-assert's write.
|
|
4655
|
+
// The token first: an ON that moves after these reads tombstones its module past it (#1839).
|
|
4656
|
+
const token = await holds.switchHoldToken();
|
|
4657
|
+
const [offInStorage, held] = await Promise.all([this.offInStorage(scopeId), holds.switchHoldsAll()]);
|
|
4658
|
+
const off = [...new Set([...offInStorage, ...held.filter((h) => h.scopeId === scopeId).map((h) => h.moduleId)])];
|
|
4659
|
+
if (off.length === 0)
|
|
4660
|
+
return { rewindingTo: (await rewind()).rewindingTo };
|
|
4661
|
+
const claimId = ulid();
|
|
4662
|
+
await holds.switchHoldClaim(scopeId, off, claimId, token);
|
|
4663
|
+
this.holdSnapshot = null;
|
|
4664
|
+
try {
|
|
4665
|
+
await this.settleClaim(holds, scopeId, claimId);
|
|
4666
|
+
}
|
|
4667
|
+
catch (err) {
|
|
4668
|
+
// Nothing was asked to arm: this rewind never happens, and what it claimed stays in storage.
|
|
4669
|
+
await holds.switchHoldDrop(scopeId, claimId).catch(() => undefined);
|
|
4670
|
+
throw err;
|
|
4671
|
+
}
|
|
4672
|
+
let result;
|
|
4673
|
+
try {
|
|
4674
|
+
result = await rewind();
|
|
4675
|
+
}
|
|
4676
|
+
catch (err) {
|
|
4677
|
+
if (isRewindRefusal(err)) {
|
|
4678
|
+
await holds.switchHoldDrop(scopeId, claimId).catch(() => undefined);
|
|
4679
|
+
}
|
|
4680
|
+
else {
|
|
4681
|
+
const probe = await this.scopeStub(scopeId)
|
|
4682
|
+
.rewindProbe()
|
|
4683
|
+
.catch(() => undefined);
|
|
4684
|
+
if (probe)
|
|
4685
|
+
await holds.switchHoldArm(scopeId, claimId, probe.armed ? probe.instance : null).catch(() => undefined);
|
|
4686
|
+
}
|
|
4687
|
+
throw err;
|
|
4688
|
+
}
|
|
4689
|
+
await holds.switchHoldArm(scopeId, claimId, result.instance ?? null).catch(() => undefined);
|
|
4690
|
+
return { rewindingTo: result.rewindingTo };
|
|
4691
|
+
}
|
|
4692
|
+
/** The modules this scope's own storage has switched off, as a rewind reads them before it arms. */
|
|
4693
|
+
async offInStorage(scopeId) {
|
|
4694
|
+
return (await this.systemGrantsStatusLocal(scopeId))
|
|
4695
|
+
.filter((e) => e.schedules === 'off')
|
|
4696
|
+
.map((e) => e.moduleId);
|
|
4697
|
+
}
|
|
4698
|
+
/**
|
|
4699
|
+
* #1839: wait until every row of this rewind's claim is `SWITCH_HOLD_SETTLE_MS` old. The settle
|
|
4700
|
+
* argument holds per row: a row younger than that when the rewound storage exists can be missed
|
|
4701
|
+
* by a pass whose hold snapshot was read just before it. So after the settle, each round reads
|
|
4702
|
+
* the scope's OFF modules again and claims them (an OFF that landed after the capture, in storage
|
|
4703
|
+
* this rewind discards), then asks the hold object how old the claim's youngest row is, joined
|
|
4704
|
+
* rows (`switchInScope`) included. The age is measured on the hold object's clock, the same one
|
|
4705
|
+
* that stamped the row; this host only sleeps the difference. Each status read is preceded by a
|
|
4706
|
+
* token read, so a module an operator turned ON after that read is not claimed from it
|
|
4707
|
+
* (`switchHoldClaim`).
|
|
4708
|
+
*
|
|
4709
|
+
* Bounded at `SWITCH_HOLD_EXTRA_WAITS` more waits: past that, the rewind is REFUSED, with the
|
|
4710
|
+
* refusal prefix. Nothing was asked to arm, so `rewindHolding` drops only this claim, and every
|
|
4711
|
+
* OFF pulled meanwhile stays in the scope's own storage. The owner retries, and the retry's
|
|
4712
|
+
* capture reads them all. Arming instead would leave a row younger than the settle, whose module
|
|
4713
|
+
* a pass with a snapshot read shortly before that row could run once on the rewound storage.
|
|
4714
|
+
*
|
|
4715
|
+
* What remains: an OFF whose join lands after the last age read and before the arm (a few calls),
|
|
4716
|
+
* or on the doomed instance in the moment between the arm and its restart. It is claimed, but by
|
|
4717
|
+
* a row younger than the settle: that module can run once, in a pass whose hold snapshot was read
|
|
4718
|
+
* in the `SWITCH_HOLD_SNAPSHOT_MS` before the row, until that snapshot expires. If the claim has
|
|
4719
|
+
* no rows by then, the OFF has nothing to join (`switchHoldJoin`), and in that same moment it is
|
|
4720
|
+
* not held at all. That happens when an ON emptied the claim, and also when a tombstone refused
|
|
4721
|
+
* every module the capture read, so the capture wrote no rows. An OFF before the last re-read is
|
|
4722
|
+
* claimed by the re-read.
|
|
4723
|
+
*/
|
|
4724
|
+
async settleClaim(holds, scopeId, claimId) {
|
|
4725
|
+
await new Promise((resolve) => setTimeout(resolve, SWITCH_HOLD_SETTLE_MS));
|
|
4726
|
+
for (let waits = 0;; waits++) {
|
|
4727
|
+
const token = await holds.switchHoldToken();
|
|
4728
|
+
const off = await this.offInStorage(scopeId);
|
|
4729
|
+
if (off.length > 0) {
|
|
4730
|
+
await holds.switchHoldClaim(scopeId, off, claimId, token);
|
|
4731
|
+
this.holdSnapshot = null;
|
|
4732
|
+
}
|
|
4733
|
+
const youngest = await holds.switchHoldYoungestMs(scopeId, claimId);
|
|
4734
|
+
if (youngest === null || youngest >= SWITCH_HOLD_SETTLE_MS)
|
|
4735
|
+
return;
|
|
4736
|
+
if (waits === SWITCH_HOLD_EXTRA_WAITS) {
|
|
4737
|
+
throw new Error(`${REWIND_REFUSED}schedule switches kept being pulled off while this rewind waited for them to ` +
|
|
4738
|
+
`settle (${waits} extra waits); nothing was rewound, and those switches stay off. Retry the rewind`);
|
|
4739
|
+
}
|
|
4740
|
+
// Never longer than one settle, even if the hold object's clock moved back under a restart.
|
|
4741
|
+
await new Promise((resolve) => setTimeout(resolve, SWITCH_HOLD_SETTLE_MS - Math.max(0, youngest)));
|
|
4742
|
+
}
|
|
4743
|
+
}
|
|
4334
4744
|
// -- live reads (#938): the door ------------------------------------------
|
|
4335
4745
|
// The coordinator's half of the live-read path. It decides two things and no more:
|
|
4336
4746
|
// whether this CONNECTION can carry a push at all, and who is asking. What a
|
|
@@ -4482,9 +4892,15 @@ export class CloudflareScopeHost {
|
|
|
4482
4892
|
async projectScope(tenantId, scopeId) {
|
|
4483
4893
|
if (!this.scopeLocalPermissions)
|
|
4484
4894
|
return;
|
|
4485
|
-
|
|
4895
|
+
// The directory decides which tenant this scope belongs to (#1738): the scope's first
|
|
4896
|
+
// projection pins its `provisioned_for` receipt, so a tenant taken from a request rather
|
|
4897
|
+
// than from the record must not reach `applyProjection` — it would pin the wrong one for
|
|
4898
|
+
// good. Read first, so a pair the directory does not hold writes nothing.
|
|
4486
4899
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
4487
|
-
|
|
4900
|
+
if (!scope)
|
|
4901
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4902
|
+
const { roles, tuples, entitlements, identities } = await this.tenantProjection(tenantId);
|
|
4903
|
+
const connectionKeys = await this.connectionKeyRows(tenantId, scope.vertical);
|
|
4488
4904
|
await this.scopeStub(scopeId).applyProjection(tenantId, roles, tuples, entitlements, undefined, identities, connectionKeys);
|
|
4489
4905
|
}
|
|
4490
4906
|
/**
|
|
@@ -4526,13 +4942,23 @@ export class CloudflareScopeHost {
|
|
|
4526
4942
|
// rather than once for the tenant — memoized, because a fan-out over twenty scopes
|
|
4527
4943
|
// of one vertical must not mint or re-read twenty times.
|
|
4528
4944
|
const keysByVertical = new Map();
|
|
4529
|
-
|
|
4945
|
+
// allSettled, not all (#1738): a scope that refuses (its `provisioned_for` receipt names
|
|
4946
|
+
// another tenant) must not stop its siblings converging, or one bad scope would freeze every
|
|
4947
|
+
// revoke for the tenant's healthy ones. The failure is still loud, after the rest have landed.
|
|
4948
|
+
const settled = await Promise.allSettled(scopes.map(async (s) => {
|
|
4530
4949
|
const vertical = s.vertical ?? '';
|
|
4531
4950
|
if (!keysByVertical.has(vertical)) {
|
|
4532
4951
|
keysByVertical.set(vertical, this.connectionKeyRows(tenantId, s.vertical));
|
|
4533
4952
|
}
|
|
4534
4953
|
await this.scopeStub(s.scope_id).applyProjection(tenantId, roles, tuples, entitlements, undefined, identities, await keysByVertical.get(vertical));
|
|
4535
4954
|
}));
|
|
4955
|
+
const failed = settled.flatMap((r, i) => (r.status === 'rejected' ? [{ scope: scopes[i].scope_id, reason: r.reason }] : []));
|
|
4956
|
+
if (failed.length === 0)
|
|
4957
|
+
return;
|
|
4958
|
+
// One aggregated error, so the caller learns which scopes did not converge and that the
|
|
4959
|
+
// rest did.
|
|
4960
|
+
const named = failed.map((f) => `${f.scope}: ${f.reason instanceof Error ? f.reason.message : String(f.reason)}`).join('; ');
|
|
4961
|
+
throw substratError('conflict', `projection reached ${scopes.length - failed.length} of ${scopes.length} scopes of tenant ${tenantId}; not converged — ${named}`);
|
|
4536
4962
|
}
|
|
4537
4963
|
/**
|
|
4538
4964
|
* Re-project a tenant's full state into every one of its scopes — the
|
|
@@ -4556,7 +4982,7 @@ export class CloudflareScopeHost {
|
|
|
4556
4982
|
async provisionScopeLocal(input) {
|
|
4557
4983
|
const stub = this.scopeStub(input.scopeId);
|
|
4558
4984
|
await this.migrateAndRecord(input.scopeId); // create the module tables (setMigrationState no-ops on a null CP)
|
|
4559
|
-
await stub.applyProjection(input.tenantId, input.roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source })), [], // no tenant-level tuples — a CP-less vertical grants at scope level only
|
|
4985
|
+
const switchedOff = await stub.applyProjection(input.tenantId, input.roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source })), [], // no tenant-level tuples — a CP-less vertical grants at scope level only
|
|
4560
4986
|
// Only PROJECT (and thereby switch on strict enforcement) when the platform actually
|
|
4561
4987
|
// supplies entitlements. Omitting them leaves the scope un-projected and trusting-
|
|
4562
4988
|
// upstream, so a vertical whose provision path predates #304 is never denied — it
|
|
@@ -4645,7 +5071,13 @@ export class CloudflareScopeHost {
|
|
|
4645
5071
|
key_id: k.keyId,
|
|
4646
5072
|
public_key: k.publicKey,
|
|
4647
5073
|
}))
|
|
5074
|
+
: undefined,
|
|
5075
|
+
// #1742: the recorded-off modules, switched off after the seat in the same unit, on the
|
|
5076
|
+
// scope this call provisions — the one the seat's tuples name.
|
|
5077
|
+
input.switchedOff
|
|
5078
|
+
? { scopeId: input.scopeId, moduleIds: input.switchedOff, at: new Date().toISOString() }
|
|
4648
5079
|
: undefined);
|
|
5080
|
+
return input.switchedOff ? { switchedOff } : {};
|
|
4649
5081
|
}
|
|
4650
5082
|
/**
|
|
4651
5083
|
* Resolve an external identity from a scope's PROJECTED links (#406) — the CP-less
|
|
@@ -4718,7 +5150,8 @@ export class CloudflareScopeHost {
|
|
|
4718
5150
|
* (writeTuple is INSERT OR REPLACE), so it is safe to re-issue on every link.
|
|
4719
5151
|
*/
|
|
4720
5152
|
async grantEntityLocal(scopeId, principal, permission, entity) {
|
|
4721
|
-
await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`,
|
|
5153
|
+
await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, entityObjectRef(entity, 'grantEntityLocal'), // #1856
|
|
5154
|
+
null);
|
|
4722
5155
|
}
|
|
4723
5156
|
// -- the connector write-back's far end (#574) -----------------------------
|
|
4724
5157
|
// A CP-less dispatch vertical cannot run a connector, so the shared control
|
|
@@ -4776,7 +5209,8 @@ export class CloudflareScopeHost {
|
|
|
4776
5209
|
async systemSwitchLocal(scopeId, moduleId, to) {
|
|
4777
5210
|
// Parsed on the way out: this is the wire answer the platform reads, and the DO's
|
|
4778
5211
|
// plain strings become the published shape here rather than on trust.
|
|
4779
|
-
|
|
5212
|
+
// #1819: `switchInScope` releases a rewind's hold on the module once the switch lands.
|
|
5213
|
+
return systemSwitchOutcome.parse(await this.switchInScope(scopeId, moduleId, to, new Date().toISOString()));
|
|
4780
5214
|
}
|
|
4781
5215
|
/**
|
|
4782
5216
|
* The far end of the status read for a scope served HERE (#1674): `/internal/system-grants`
|