@amalgm/shell 0.1.110 → 0.1.112
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/cli.js +24 -1
- package/dist/cli.js.map +1 -1
- package/dist/files-trace.d.ts +41 -0
- package/dist/files-trace.js +138 -0
- package/dist/files-trace.js.map +1 -0
- package/dist/gateway.js +7 -1
- package/dist/gateway.js.map +1 -1
- package/dist/machine-host.d.ts +6 -0
- package/dist/machine-host.js +25 -11
- package/dist/machine-host.js.map +1 -1
- package/dist/paged-mutation-submit.d.ts +5 -3
- package/dist/paged-mutation-submit.js +16 -7
- package/dist/paged-mutation-submit.js.map +1 -1
- package/dist/runtime-command-host.js +7 -1
- package/dist/runtime-command-host.js.map +1 -1
- package/dist/user-ground-host.d.ts +41 -0
- package/dist/user-ground-host.js +486 -171
- package/dist/user-ground-host.js.map +1 -1
- package/package.json +1 -1
package/dist/user-ground-host.js
CHANGED
|
@@ -55,6 +55,15 @@ const WALK_CAPTURE_CONCURRENCY = 8;
|
|
|
55
55
|
const REGISTER_STALE_CAPTURE_ATTEMPTS = 3;
|
|
56
56
|
const DOWNLOAD_CONTENT_RETRY_ATTEMPTS = 3;
|
|
57
57
|
const APPLY_GROUND_WAIT_TIMEOUT_MS = 15_000;
|
|
58
|
+
function loadSnapshotPublication(entry) {
|
|
59
|
+
const snapshotRecords = snapshotFromRecords((JSON.parse(entry.snapshotBlob.toString("utf8")).records ?? [])).records;
|
|
60
|
+
const uploadIds = new Set(entry.uploadUuids);
|
|
61
|
+
return {
|
|
62
|
+
...entry,
|
|
63
|
+
snapshotRecords,
|
|
64
|
+
uploadRecords: snapshotRecords.filter((record) => uploadIds.has(record.uuid)),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
58
67
|
const GROUND_ROW_COLUMNS = [
|
|
59
68
|
"uuid", "resource_id", "root_uuid", "type", "parent_uuid", "name", "status", "version",
|
|
60
69
|
"payload_version", "transport_version", "relative_path", "absolute_path", "device_number",
|
|
@@ -196,6 +205,11 @@ export class UserGroundHost {
|
|
|
196
205
|
stage(input) {
|
|
197
206
|
emitFilesStage(this.options.onFilesStage, input);
|
|
198
207
|
}
|
|
208
|
+
/** Whether a counter worth real time on the critical path may be computed. */
|
|
209
|
+
measuring() {
|
|
210
|
+
return this.options.filesTraceActive?.()
|
|
211
|
+
?? (this.options.onFilesStage !== undefined || this.options.onDetectScan !== undefined);
|
|
212
|
+
}
|
|
199
213
|
reportDetectScan(evidence) {
|
|
200
214
|
this.options.onDetectScan?.(evidence);
|
|
201
215
|
this.stage({
|
|
@@ -213,7 +227,19 @@ export class UserGroundHost {
|
|
|
213
227
|
gitInspections: evidence.gitInspections,
|
|
214
228
|
repositoryCaptures: evidence.repositoryCaptures,
|
|
215
229
|
records: evidence.records ?? 0,
|
|
216
|
-
recordBytes: evidence.recordBytes
|
|
230
|
+
...(evidence.recordBytes === undefined ? {} : { recordBytes: evidence.recordBytes }),
|
|
231
|
+
// Where the scan actually went. `captureBusyMs / captureMs` is the
|
|
232
|
+
// capture pool's occupancy; at ~1 the pool ran one file at a time.
|
|
233
|
+
walkMs: Math.round(evidence.walkMs ?? 0),
|
|
234
|
+
gitMs: Math.round(evidence.gitMs ?? 0),
|
|
235
|
+
captureMs: Math.round(evidence.captureMs ?? 0),
|
|
236
|
+
captureBusyMs: Math.round(evidence.captureBusyMs ?? 0),
|
|
237
|
+
captureBytes: evidence.captureBytes ?? 0,
|
|
238
|
+
repoIdentityMs: Math.round(evidence.repositoryIdentityMs ?? 0),
|
|
239
|
+
repoCaptureMs: Math.round(evidence.repositoryCaptureMs ?? 0),
|
|
240
|
+
repoSealMs: Math.round(evidence.repositorySealMs ?? 0),
|
|
241
|
+
reconcileMs: Math.round(evidence.reconcileMs ?? 0),
|
|
242
|
+
persistMs: Math.round(evidence.persistMs ?? 0),
|
|
217
243
|
},
|
|
218
244
|
});
|
|
219
245
|
}
|
|
@@ -611,6 +637,7 @@ export class UserGroundHost {
|
|
|
611
637
|
database: this.databasePath(identity),
|
|
612
638
|
cacheDir: this.cacheDir(identity),
|
|
613
639
|
onScan: (evidence) => this.reportDetectScan(evidence),
|
|
640
|
+
measuring: this.measuring(),
|
|
614
641
|
registrationWorkspaceId: workspaceId,
|
|
615
642
|
suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion])),
|
|
616
643
|
});
|
|
@@ -1128,6 +1155,7 @@ export class UserGroundHost {
|
|
|
1128
1155
|
await reconcileGround({
|
|
1129
1156
|
identity, userRoot, database, cacheDir,
|
|
1130
1157
|
onScan: (evidence) => this.reportDetectScan(evidence),
|
|
1158
|
+
measuring: this.measuring(),
|
|
1131
1159
|
});
|
|
1132
1160
|
return localValue(identity, userRoot, database);
|
|
1133
1161
|
},
|
|
@@ -1842,6 +1870,7 @@ export class UserGroundHost {
|
|
|
1842
1870
|
database: this.databasePath(identity),
|
|
1843
1871
|
cacheDir: this.cacheDir(identity),
|
|
1844
1872
|
onScan: (evidence) => this.reportDetectScan(evidence),
|
|
1873
|
+
measuring: this.measuring(),
|
|
1845
1874
|
suspicions: reconcileSuspicions,
|
|
1846
1875
|
coreScan,
|
|
1847
1876
|
});
|
|
@@ -1939,7 +1968,7 @@ export class UserGroundHost {
|
|
|
1939
1968
|
headVersion: first.baseVersion,
|
|
1940
1969
|
checksum: "",
|
|
1941
1970
|
deliveryWatermark: first.deliveryWatermark,
|
|
1942
|
-
records:
|
|
1971
|
+
records: loadSnapshotPublication(first).snapshotRecords,
|
|
1943
1972
|
};
|
|
1944
1973
|
try {
|
|
1945
1974
|
await this.publishPendingSnapshots(identity);
|
|
@@ -1990,45 +2019,65 @@ export class UserGroundHost {
|
|
|
1990
2019
|
const state = this.cloudState;
|
|
1991
2020
|
if (!state)
|
|
1992
2021
|
throw new Error("cloud state is unavailable for graph publication");
|
|
1993
|
-
|
|
1994
|
-
|
|
2022
|
+
// Graph preparation is the leg between a committed local ground and the
|
|
2023
|
+
// first wire frame. It is pure CPU and SQLite, so it is measured by its
|
|
2024
|
+
// own parts: whoever reads this ledger must see which part is slow.
|
|
2025
|
+
const preparationStarted = performance.now();
|
|
2026
|
+
const leg = {};
|
|
2027
|
+
const timed = (name, work) => {
|
|
2028
|
+
const started = performance.now();
|
|
2029
|
+
const value = work();
|
|
2030
|
+
leg[`${name}Ms`] = performance.now() - started;
|
|
2031
|
+
return value;
|
|
2032
|
+
};
|
|
2033
|
+
if (trace?.journey === "register") {
|
|
2034
|
+
this.stage({
|
|
2035
|
+
primitive: "register",
|
|
2036
|
+
stage: "graph-preparation",
|
|
2037
|
+
status: "started",
|
|
2038
|
+
...(trace.workspaceId ? { workspaceId: trace.workspaceId } : {}),
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
const officialRecords = timed("officialRows", () => recordsAtDeliveryPrefix(state.records, readAcceptedResults(this.databasePath(identity), state.resourceId, state.deliveryWatermark, deliveryWatermark)));
|
|
2042
|
+
const localRecords = timed("groundRows", () => recordsRetainingMissingEvidence(readGroundRowsForRoots(this.databasePath(identity), "entities", publicationRootIds)
|
|
1995
2043
|
.map(portableRecord), readGroundRowsForRoots(this.databasePath(identity), "detection_notebook", publicationRootIds)
|
|
1996
|
-
.map(portableRecord));
|
|
1997
|
-
const merged = mergeMaterializedRoots(officialRecords, localRecords);
|
|
1998
|
-
const traveling = travelingRecords(merged, sha256Hex);
|
|
2044
|
+
.map(portableRecord)));
|
|
2045
|
+
const merged = timed("merge", () => mergeMaterializedRoots(officialRecords, localRecords));
|
|
2046
|
+
const traveling = timed("project", () => travelingRecords(merged, sha256Hex));
|
|
1999
2047
|
const impossibleGitLeaf = traveling.find((record) => (record.type === "file.text" || record.type === "file.binary" || record.type === "link")
|
|
2000
2048
|
&& record.payloadVersion?.startsWith("git:"));
|
|
2001
2049
|
if (impossibleGitLeaf) {
|
|
2002
2050
|
const parent = merged.find((record) => record.uuid === impossibleGitLeaf.parentUUID);
|
|
2003
2051
|
throw new Error(`repository-owned leaf escaped its rail: ${impossibleGitLeaf.name} parent=${parent?.type ?? "missing"}`);
|
|
2004
2052
|
}
|
|
2005
|
-
const snapshot = snapshotFromRecords(traveling);
|
|
2006
|
-
|
|
2007
|
-
|
|
2053
|
+
const snapshot = timed("normalize", () => snapshotFromRecords(traveling));
|
|
2054
|
+
// ONE canonical serialization: these exact bytes are the journal blob
|
|
2055
|
+
// and the checksum's input; they are never re-derived or re-parsed on
|
|
2056
|
+
// the in-process path.
|
|
2057
|
+
const snapshotBlob = timed("serialize", () => Buffer.from(stableJson(snapshot), "utf8"));
|
|
2058
|
+
const checksum = timed("checksum", () => sha256Hex(snapshotBlob));
|
|
2008
2059
|
const localIds = new Set(localRecords.map((record) => record.uuid));
|
|
2009
2060
|
// Graph membership is never proof of sealed cargo: a record in the cloud
|
|
2010
2061
|
// graph may name content sealed only under a prior content contract,
|
|
2011
2062
|
// whose namespace the current readers never touch. Only the authority's
|
|
2012
|
-
// (resource, hash, contract) seal evidence may skip an upload —
|
|
2013
|
-
//
|
|
2014
|
-
// always re-establishes cargo closure
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
return [];
|
|
2018
|
-
const artifact = artifactForRecord(record);
|
|
2019
|
-
return artifact ? [{ record, contentHash: artifact.contentHash }] : [];
|
|
2020
|
-
});
|
|
2021
|
-
const sealedArtifacts = await this.sealedContentHashes(state.resourceId, [...new Set(cargo.map((entry) => entry.contentHash))]);
|
|
2022
|
-
const uploadRecords = cargo
|
|
2023
|
-
.filter((entry) => !sealedArtifacts.has(entry.contentHash))
|
|
2024
|
-
.map((entry) => entry.record);
|
|
2063
|
+
// (resource, hash, contract) seal evidence may skip an upload — the
|
|
2064
|
+
// sealed-first upload asks that exact question itself and moves only
|
|
2065
|
+
// what is unsealed, so Register always re-establishes cargo closure
|
|
2066
|
+
// before returning, even when the graph itself is unchanged.
|
|
2067
|
+
const cargoRecords = snapshot.records.filter((record) => localIds.has(record.uuid) && artifactForRecord(record) !== null);
|
|
2025
2068
|
if (checksum === state.checksum && deliveryWatermark === state.deliveryWatermark) {
|
|
2069
|
+
this.finishPreparation(trace, preparationStarted, leg, {
|
|
2070
|
+
records: snapshot.records.length,
|
|
2071
|
+
snapshotBytes: snapshotBlob.byteLength,
|
|
2072
|
+
cargoRecords: cargoRecords.length,
|
|
2073
|
+
graphUnchanged: true,
|
|
2074
|
+
});
|
|
2026
2075
|
// The graph is already committed; only cargo can be missing. Repair
|
|
2027
2076
|
// uploads need no journal row — a crash simply re-runs the same
|
|
2028
2077
|
// repair on the next Register.
|
|
2029
|
-
if (
|
|
2078
|
+
if (cargoRecords.length > 0) {
|
|
2030
2079
|
try {
|
|
2031
|
-
await this.uploadSnapshotContent(identity, state.resourceId,
|
|
2080
|
+
await this.uploadSnapshotContent(identity, state.resourceId, cargoRecords, trace, manifestHints);
|
|
2032
2081
|
}
|
|
2033
2082
|
catch (error) {
|
|
2034
2083
|
throw pipelineStageError("cloud upload", error);
|
|
@@ -2036,30 +2085,68 @@ export class UserGroundHost {
|
|
|
2036
2085
|
}
|
|
2037
2086
|
return;
|
|
2038
2087
|
}
|
|
2039
|
-
const
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2088
|
+
const replacement = timed("replacement", () => {
|
|
2089
|
+
const rootIds = localRecords
|
|
2090
|
+
.filter((record) => record.parentUUID === null)
|
|
2091
|
+
.map((record) => record.uuid);
|
|
2092
|
+
const roots = new Set(rootIds);
|
|
2093
|
+
const rootOf = recordRootResolver(snapshot.records);
|
|
2094
|
+
return rootReplacementFromRecords(rootIds, snapshot.records.filter((record) => {
|
|
2095
|
+
const root = rootOf(record);
|
|
2096
|
+
return root !== null && roots.has(root);
|
|
2097
|
+
}));
|
|
2047
2098
|
});
|
|
2048
|
-
|
|
2049
|
-
|
|
2099
|
+
// The operation's canonical bytes: staged, hashed, and submitted as-is.
|
|
2100
|
+
const operationBlob = timed("operation", () => Buffer.from(stableJson(replacement), "utf8"));
|
|
2101
|
+
const entry = {
|
|
2050
2102
|
mutationId: randomUUID(),
|
|
2051
2103
|
resourceId: state.resourceId,
|
|
2052
2104
|
authorityEpoch: state.authorityEpoch,
|
|
2053
2105
|
baseVersion: state.headVersion,
|
|
2054
2106
|
snapshotChecksum: checksum,
|
|
2055
|
-
snapshotJson,
|
|
2056
2107
|
deliveryWatermark,
|
|
2057
|
-
|
|
2108
|
+
operationKind: replacement.kind,
|
|
2058
2109
|
replacementRootIds: replacement.rootIds,
|
|
2059
|
-
|
|
2110
|
+
uploadUuids: cargoRecords.map((record) => record.uuid),
|
|
2111
|
+
snapshotBlob,
|
|
2112
|
+
operationBlob,
|
|
2060
2113
|
status: "pending",
|
|
2114
|
+
submitted: false,
|
|
2115
|
+
};
|
|
2116
|
+
timed("journal", () => supersedeSnapshotJournal(this.databasePath(identity), entry));
|
|
2117
|
+
this.finishPreparation(trace, preparationStarted, leg, {
|
|
2118
|
+
records: snapshot.records.length,
|
|
2119
|
+
snapshotBytes: snapshotBlob.byteLength,
|
|
2120
|
+
operationBytes: operationBlob.byteLength,
|
|
2121
|
+
operationRoots: replacement.rootIds.length,
|
|
2122
|
+
cargoRecords: cargoRecords.length,
|
|
2123
|
+
graphUnchanged: false,
|
|
2124
|
+
});
|
|
2125
|
+
// Publish the freshly journaled intent directly: the records are already
|
|
2126
|
+
// in memory and the operation bytes already exist — nothing is re-read,
|
|
2127
|
+
// re-parsed, or re-serialized between prepare and the wire.
|
|
2128
|
+
await this.publishLoadedSnapshot(identity, {
|
|
2129
|
+
...entry,
|
|
2130
|
+
snapshotRecords: snapshot.records,
|
|
2131
|
+
uploadRecords: cargoRecords,
|
|
2132
|
+
}, trace, manifestHints);
|
|
2133
|
+
}
|
|
2134
|
+
/** Close the graph-preparation stage with every leg it spent, so a slow
|
|
2135
|
+
* preparation names the slow part instead of one opaque number. */
|
|
2136
|
+
finishPreparation(trace, startedAt, legs, counters) {
|
|
2137
|
+
if (trace?.journey !== "register")
|
|
2138
|
+
return;
|
|
2139
|
+
this.stage({
|
|
2140
|
+
primitive: "register",
|
|
2141
|
+
stage: "graph-preparation",
|
|
2142
|
+
status: "completed",
|
|
2143
|
+
durationMs: performance.now() - startedAt,
|
|
2144
|
+
...(trace.workspaceId ? { workspaceId: trace.workspaceId } : {}),
|
|
2145
|
+
measurements: {
|
|
2146
|
+
...counters,
|
|
2147
|
+
...Object.fromEntries(Object.entries(legs).map(([name, value]) => [name, Math.round(value)])),
|
|
2148
|
+
},
|
|
2061
2149
|
});
|
|
2062
|
-
await this.publishPendingSnapshots(identity, trace, manifestHints);
|
|
2063
2150
|
}
|
|
2064
2151
|
/** Which of these heads does the authority hold sealed under the current
|
|
2065
2152
|
* content contract? Batched so a whole workspace costs a handful of
|
|
@@ -2091,87 +2178,111 @@ export class UserGroundHost {
|
|
|
2091
2178
|
// snapshot superseding it can publish this resource again.
|
|
2092
2179
|
if (pending.status === "rejected")
|
|
2093
2180
|
continue;
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2181
|
+
await this.publishLoadedSnapshot(identity, loadSnapshotPublication(pending), trace, manifestHints);
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
async publishLoadedSnapshot(identity, publication, trace, manifestHints) {
|
|
2185
|
+
try {
|
|
2186
|
+
await this.uploadSnapshotContent(identity, publication.resourceId, publication.uploadRecords, trace, manifestHints);
|
|
2187
|
+
}
|
|
2188
|
+
catch (error) {
|
|
2189
|
+
// An unsubmitted pending row is a local intent the authority has
|
|
2190
|
+
// never seen. When its captured ground moved before upload, the
|
|
2191
|
+
// intent is simply stale: delete it and let the caller re-derive
|
|
2192
|
+
// one from current ground. A submitted row never lands here — its
|
|
2193
|
+
// upload sealed completely before the first submit frame left.
|
|
2194
|
+
if (staleCapture(error) && !publication.submitted) {
|
|
2195
|
+
deleteJournalEntry(this.databasePath(identity), publication.mutationId);
|
|
2109
2196
|
}
|
|
2110
|
-
|
|
2111
|
-
|
|
2197
|
+
throw pipelineStageError("cloud upload", error);
|
|
2198
|
+
}
|
|
2199
|
+
let frame;
|
|
2200
|
+
const graphCommitStarted = performance.now();
|
|
2201
|
+
if (trace?.journey === "register") {
|
|
2202
|
+
this.stage({
|
|
2203
|
+
primitive: "register",
|
|
2204
|
+
stage: "cloud-graph-commit",
|
|
2205
|
+
status: "started",
|
|
2206
|
+
...(trace.workspaceId ? { workspaceId: trace.workspaceId } : {}),
|
|
2207
|
+
...(trace.mutationId ? { mutationId: trace.mutationId } : {}),
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
const legMs = {};
|
|
2211
|
+
const local = (name, work) => {
|
|
2212
|
+
const at = performance.now();
|
|
2213
|
+
const value = work();
|
|
2214
|
+
legMs[`${name}Ms`] = performance.now() - at;
|
|
2215
|
+
return value;
|
|
2216
|
+
};
|
|
2217
|
+
if (!publication.submitted) {
|
|
2218
|
+
local("markSubmitted", () => markSnapshotJournalSubmitted(this.databasePath(identity), publication.mutationId));
|
|
2219
|
+
}
|
|
2220
|
+
try {
|
|
2221
|
+
frame = await submitMutationOperation(this.wire, {
|
|
2222
|
+
resourceId: publication.resourceId,
|
|
2223
|
+
authorityEpoch: publication.authorityEpoch,
|
|
2224
|
+
mutationId: publication.mutationId,
|
|
2225
|
+
deviceId: identity.deviceId,
|
|
2226
|
+
baseVersion: publication.baseVersion,
|
|
2227
|
+
contract: ENTITY_CLOUD_CONTRACT,
|
|
2228
|
+
schemaVersion: ENTITY_CLOUD_SCHEMA_VERSION,
|
|
2229
|
+
operationKind: publication.operationKind,
|
|
2230
|
+
deliveryWatermark: publication.deliveryWatermark,
|
|
2231
|
+
}, publication.operationBlob, (leg, durationMs) => { legMs[`${leg}Ms`] = durationMs; });
|
|
2232
|
+
}
|
|
2233
|
+
catch (error) {
|
|
2112
2234
|
if (trace?.journey === "register") {
|
|
2113
2235
|
this.stage({
|
|
2114
2236
|
primitive: "register",
|
|
2115
2237
|
stage: "cloud-graph-commit",
|
|
2116
|
-
status: "
|
|
2238
|
+
status: "failed",
|
|
2117
2239
|
...(trace.workspaceId ? { workspaceId: trace.workspaceId } : {}),
|
|
2118
|
-
|
|
2240
|
+
durationMs: performance.now() - graphCommitStarted,
|
|
2119
2241
|
});
|
|
2120
2242
|
}
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
authorityEpoch: pending.authorityEpoch,
|
|
2128
|
-
mutationId: pending.mutationId,
|
|
2129
|
-
deviceId: identity.deviceId,
|
|
2130
|
-
baseVersion: pending.baseVersion,
|
|
2131
|
-
contract: ENTITY_CLOUD_CONTRACT,
|
|
2132
|
-
schemaVersion: ENTITY_CLOUD_SCHEMA_VERSION,
|
|
2133
|
-
operationKind: replacement.kind,
|
|
2134
|
-
deliveryWatermark: pending.deliveryWatermark,
|
|
2135
|
-
}, replacement);
|
|
2136
|
-
}
|
|
2137
|
-
catch (error) {
|
|
2138
|
-
if (trace?.journey === "register") {
|
|
2139
|
-
this.stage({
|
|
2140
|
-
primitive: "register",
|
|
2141
|
-
stage: "cloud-graph-commit",
|
|
2142
|
-
status: "failed",
|
|
2143
|
-
...(trace.workspaceId ? { workspaceId: trace.workspaceId } : {}),
|
|
2144
|
-
durationMs: performance.now() - graphCommitStarted,
|
|
2145
|
-
});
|
|
2146
|
-
}
|
|
2147
|
-
// A non-retryable error frame is the authority's answer: this exact
|
|
2148
|
-
// mutation is refused forever. Settle the row so it is never
|
|
2149
|
-
// replayed and a newer snapshot may supersede it. Every other
|
|
2150
|
-
// failure leaves the outcome unknown and the row pending.
|
|
2151
|
-
if (error instanceof WireRequestError && error.retryable === false) {
|
|
2152
|
-
markSnapshotJournalRejected(this.databasePath(identity), pending.mutationId);
|
|
2153
|
-
}
|
|
2154
|
-
throw pipelineStageError("cloud graph commit", error);
|
|
2243
|
+
// A non-retryable error frame is the authority's answer: this exact
|
|
2244
|
+
// mutation is refused forever. Settle the row so it is never
|
|
2245
|
+
// replayed and a newer snapshot may supersede it. Every other
|
|
2246
|
+
// failure leaves the outcome unknown and the row pending.
|
|
2247
|
+
if (error instanceof WireRequestError && error.retryable === false) {
|
|
2248
|
+
markSnapshotJournalRejected(this.databasePath(identity), publication.mutationId);
|
|
2155
2249
|
}
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2250
|
+
throw pipelineStageError("cloud graph commit", error);
|
|
2251
|
+
}
|
|
2252
|
+
this.cloudState = {
|
|
2253
|
+
resourceId: publication.resourceId,
|
|
2254
|
+
authorityEpoch: Number(frame.authority_epoch),
|
|
2255
|
+
headVersion: Number(frame.version),
|
|
2256
|
+
checksum: publication.snapshotChecksum,
|
|
2257
|
+
deliveryWatermark: Number(frame.delivery_watermark),
|
|
2258
|
+
records: publication.snapshotRecords,
|
|
2259
|
+
};
|
|
2260
|
+
local("clearJournal", () => deleteJournalEntry(this.databasePath(identity), publication.mutationId));
|
|
2261
|
+
if (trace?.journey === "register") {
|
|
2262
|
+
// The authority reports where its side of the commit went; carrying
|
|
2263
|
+
// its legs here gives one ledger line the whole story: local journal
|
|
2264
|
+
// marks, wire time, gateway assemble/validate, and the database RPC.
|
|
2265
|
+
for (const [wire, leg] of [
|
|
2266
|
+
["assemble_ms", "serverAssembleMs"],
|
|
2267
|
+
["validate_ms", "serverValidateMs"],
|
|
2268
|
+
["database_ms", "serverDatabaseMs"],
|
|
2269
|
+
]) {
|
|
2270
|
+
if (typeof frame[wire] === "number")
|
|
2271
|
+
legMs[leg] = frame[wire];
|
|
2165
2272
|
}
|
|
2166
|
-
this.
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2273
|
+
this.stage({
|
|
2274
|
+
primitive: "register",
|
|
2275
|
+
stage: "cloud-graph-commit",
|
|
2276
|
+
status: "completed",
|
|
2277
|
+
...(trace.workspaceId ? { workspaceId: trace.workspaceId } : {}),
|
|
2278
|
+
durationMs: performance.now() - graphCommitStarted,
|
|
2279
|
+
measurements: {
|
|
2280
|
+
version: Number(frame.version),
|
|
2281
|
+
operationBytes: publication.operationBlob.byteLength,
|
|
2282
|
+
...Object.fromEntries(Object.entries(legMs)
|
|
2283
|
+
.map(([name, value]) => [name, Math.round(value)])),
|
|
2284
|
+
},
|
|
2285
|
+
});
|
|
2175
2286
|
}
|
|
2176
2287
|
}
|
|
2177
2288
|
}
|
|
@@ -2264,12 +2375,142 @@ function assertHealthyWatch(evidence, rootId) {
|
|
|
2264
2375
|
* file over a dozen times; the file itself is never deleted by the host, so
|
|
2265
2376
|
* every later open needs only the connection and its pragmas. */
|
|
2266
2377
|
const migratedDatabases = new Set();
|
|
2267
|
-
|
|
2378
|
+
/** The journal's mutable facts and its immutable payload live in separate
|
|
2379
|
+
* tables on purpose: SQLite rewrites a row's entire record — overflow pages
|
|
2380
|
+
* included — on any column update, so flipping submitted_at on a row that
|
|
2381
|
+
* carried a register-scale snapshot cost a full multi-megabyte rewrite
|
|
2382
|
+
* through the WAL at synchronous=FULL (measured ~7.5s of the production
|
|
2383
|
+
* cloud-commit stage on the 1-GB machine). The blob row is written exactly
|
|
2384
|
+
* once and read only on restart replay; every status transition touches only
|
|
2385
|
+
* the small row. */
|
|
2386
|
+
const SNAPSHOT_PUBLICATIONS_SCHEMA = `
|
|
2387
|
+
CREATE TABLE IF NOT EXISTS snapshot_publications (
|
|
2388
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2389
|
+
mutation_id TEXT NOT NULL UNIQUE,
|
|
2390
|
+
resource_id TEXT NOT NULL,
|
|
2391
|
+
meta_json TEXT NOT NULL,
|
|
2392
|
+
created_at INTEGER NOT NULL,
|
|
2393
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'rejected')),
|
|
2394
|
+
submitted_at INTEGER
|
|
2395
|
+
);
|
|
2396
|
+
CREATE TABLE IF NOT EXISTS snapshot_publication_blobs (
|
|
2397
|
+
mutation_id TEXT PRIMARY KEY,
|
|
2398
|
+
snapshot_blob BLOB NOT NULL,
|
|
2399
|
+
operation_blob BLOB NOT NULL
|
|
2400
|
+
);`;
|
|
2401
|
+
/** Converts an old-shape publication journal (one nested publication_json
|
|
2402
|
+
* text per row) into the blob journal, atomically, preserving every row's
|
|
2403
|
+
* meaning. A pending row may be a submitted mutation whose outcome exists
|
|
2404
|
+
* only at the authority — dropping it would violate the retry law that
|
|
2405
|
+
* recovers a lost acknowledgement — so conversion is structural: the nested
|
|
2406
|
+
* snapshot text becomes the snapshot blob verbatim, the stored replacement
|
|
2407
|
+
* becomes the operation blob, and semantic judgment stays where it always
|
|
2408
|
+
* was, with the reader (an unlawful row still blocks registration rather
|
|
2409
|
+
* than being silently replaced).
|
|
2410
|
+
*
|
|
2411
|
+
* The transform runs entirely inside SQLite. A production journal row is a
|
|
2412
|
+
* register-scale graph serialized three times over — the exact text whose
|
|
2413
|
+
* repeated JavaScript materialization blew the 512 MB heap — so no part of
|
|
2414
|
+
* it may ever become a JavaScript value here: json_extract lifts the nested
|
|
2415
|
+
* snapshot text straight into its blob, and the meta and operation rows are
|
|
2416
|
+
* json_object over subtrees, all in engine memory outside the V8 heap.
|
|
2417
|
+
*
|
|
2418
|
+
* Tables older than the three-state journal carry no status or submitted_at;
|
|
2419
|
+
* their rows were written before the pre-submit durability mark existed, so
|
|
2420
|
+
* the only safe reading is "possibly submitted": keep them pending and
|
|
2421
|
+
* marked submitted, which replays the same mutation id — always idempotent —
|
|
2422
|
+
* and never supersedes. A row whose publication_json does not parse fails
|
|
2423
|
+
* the open loudly with the journal intact; nothing is ever deleted on a
|
|
2424
|
+
* conversion failure. */
|
|
2425
|
+
function convertLegacySnapshotJournal(database, columns) {
|
|
2426
|
+
database.transaction(() => {
|
|
2427
|
+
database.exec("ALTER TABLE snapshot_publications RENAME TO snapshot_publications_legacy");
|
|
2428
|
+
database.exec(SNAPSHOT_PUBLICATIONS_SCHEMA);
|
|
2429
|
+
database.exec(`
|
|
2430
|
+
INSERT INTO snapshot_publications(
|
|
2431
|
+
mutation_id, resource_id, meta_json, created_at, status, submitted_at
|
|
2432
|
+
)
|
|
2433
|
+
SELECT
|
|
2434
|
+
mutation_id,
|
|
2435
|
+
resource_id,
|
|
2436
|
+
json_object(
|
|
2437
|
+
'authorityEpoch', json_extract(publication_json, '$.authorityEpoch'),
|
|
2438
|
+
'baseVersion', json_extract(publication_json, '$.baseVersion'),
|
|
2439
|
+
'deliveryWatermark', json_extract(publication_json, '$.deliveryWatermark'),
|
|
2440
|
+
'snapshotChecksum', json_extract(publication_json, '$.snapshotChecksum'),
|
|
2441
|
+
'operationKind', 'entity.roots.replace',
|
|
2442
|
+
'replacementRootIds', json(json_extract(publication_json, '$.replacementRootIds')),
|
|
2443
|
+
'uploadUuids', coalesce((
|
|
2444
|
+
SELECT json_group_array(json_extract(uploaded.value, '$.uuid'))
|
|
2445
|
+
FROM json_each(publication_json, '$.uploadRecords') AS uploaded
|
|
2446
|
+
), json_array())
|
|
2447
|
+
),
|
|
2448
|
+
created_at,
|
|
2449
|
+
${columns.has("status") ? "CASE status WHEN 'rejected' THEN 'rejected' ELSE 'pending' END" : "'pending'"},
|
|
2450
|
+
${columns.has("submitted_at") ? "submitted_at" : "created_at"}
|
|
2451
|
+
FROM snapshot_publications_legacy ORDER BY sequence
|
|
2452
|
+
`);
|
|
2453
|
+
database.exec(`
|
|
2454
|
+
INSERT INTO snapshot_publication_blobs(mutation_id, snapshot_blob, operation_blob)
|
|
2455
|
+
SELECT
|
|
2456
|
+
mutation_id,
|
|
2457
|
+
CAST(json_extract(publication_json, '$.snapshotJson') AS BLOB),
|
|
2458
|
+
CAST(json_object(
|
|
2459
|
+
'kind', 'entity.roots.replace',
|
|
2460
|
+
'rootIds', json(json_extract(publication_json, '$.replacementRootIds')),
|
|
2461
|
+
'records', json(json_extract(publication_json, '$.replacementRecords'))
|
|
2462
|
+
) AS BLOB)
|
|
2463
|
+
FROM snapshot_publications_legacy ORDER BY sequence
|
|
2464
|
+
`);
|
|
2465
|
+
database.exec("DROP TABLE snapshot_publications_legacy");
|
|
2466
|
+
})();
|
|
2467
|
+
}
|
|
2468
|
+
/** Converts the single-table blob journal (blobs stored on the publication
|
|
2469
|
+
* row itself) into the split journal, atomically and entirely inside SQLite —
|
|
2470
|
+
* the same engine-memory law as the nested-text conversion above. Every
|
|
2471
|
+
* field already has its final meaning; the blobs simply move to their
|
|
2472
|
+
* write-once table so status transitions stop rewriting them. */
|
|
2473
|
+
function splitSnapshotJournalBlobs(database) {
|
|
2474
|
+
database.transaction(() => {
|
|
2475
|
+
database.exec("ALTER TABLE snapshot_publications RENAME TO snapshot_publications_legacy");
|
|
2476
|
+
database.exec(SNAPSHOT_PUBLICATIONS_SCHEMA);
|
|
2477
|
+
database.exec(`
|
|
2478
|
+
INSERT INTO snapshot_publications(
|
|
2479
|
+
mutation_id, resource_id, meta_json, created_at, status, submitted_at
|
|
2480
|
+
)
|
|
2481
|
+
SELECT mutation_id, resource_id, meta_json, created_at, status, submitted_at
|
|
2482
|
+
FROM snapshot_publications_legacy ORDER BY sequence
|
|
2483
|
+
`);
|
|
2484
|
+
database.exec(`
|
|
2485
|
+
INSERT INTO snapshot_publication_blobs(mutation_id, snapshot_blob, operation_blob)
|
|
2486
|
+
SELECT mutation_id, snapshot_blob, operation_blob
|
|
2487
|
+
FROM snapshot_publications_legacy ORDER BY sequence
|
|
2488
|
+
`);
|
|
2489
|
+
database.exec("DROP TABLE snapshot_publications_legacy");
|
|
2490
|
+
})();
|
|
2491
|
+
}
|
|
2492
|
+
/** The single opener and schema-migration entry for a user ground database.
|
|
2493
|
+
* Exported so the upgrade path is provable in isolation — the journal
|
|
2494
|
+
* conversion law test opens a legacy file through this exact entry inside a
|
|
2495
|
+
* heap-capped child process. */
|
|
2496
|
+
export function initializeDatabase(file) {
|
|
2268
2497
|
ensurePrivateDir(dirname(file));
|
|
2269
2498
|
const database = new Database(file);
|
|
2270
2499
|
database.pragma("busy_timeout = 5000");
|
|
2271
2500
|
database.pragma("journal_mode = WAL");
|
|
2272
2501
|
database.pragma("synchronous = FULL");
|
|
2502
|
+
// The legacy probe runs on every open, before the migration memo: a journal
|
|
2503
|
+
// row may be a submitted publication whose outcome exists only at the
|
|
2504
|
+
// authority, so an old-shape table must convert — never drop — no matter
|
|
2505
|
+
// how this file arrived at this process. One PRAGMA against the in-memory
|
|
2506
|
+
// catalog costs nothing next to the connection open itself.
|
|
2507
|
+
const publicationColumns = new Set(database.prepare("PRAGMA table_info(snapshot_publications)").all().map(({ name }) => name));
|
|
2508
|
+
if (publicationColumns.has("publication_json")) {
|
|
2509
|
+
convertLegacySnapshotJournal(database, publicationColumns);
|
|
2510
|
+
}
|
|
2511
|
+
else if (publicationColumns.has("snapshot_blob")) {
|
|
2512
|
+
splitSnapshotJournalBlobs(database);
|
|
2513
|
+
}
|
|
2273
2514
|
if (migratedDatabases.has(file))
|
|
2274
2515
|
return database;
|
|
2275
2516
|
database.exec(`
|
|
@@ -2361,15 +2602,7 @@ function initializeDatabase(file) {
|
|
|
2361
2602
|
authority_id TEXT PRIMARY KEY,
|
|
2362
2603
|
delivery_sequence INTEGER NOT NULL CHECK(delivery_sequence >= 0)
|
|
2363
2604
|
);
|
|
2364
|
-
|
|
2365
|
-
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2366
|
-
mutation_id TEXT NOT NULL UNIQUE,
|
|
2367
|
-
resource_id TEXT NOT NULL,
|
|
2368
|
-
publication_json TEXT NOT NULL,
|
|
2369
|
-
created_at INTEGER NOT NULL,
|
|
2370
|
-
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'rejected')),
|
|
2371
|
-
submitted_at INTEGER
|
|
2372
|
-
);
|
|
2605
|
+
${SNAPSHOT_PUBLICATIONS_SCHEMA}
|
|
2373
2606
|
CREATE TABLE IF NOT EXISTS workspace_add_intents (
|
|
2374
2607
|
workspace_uuid TEXT PRIMARY KEY,
|
|
2375
2608
|
destination_path TEXT NOT NULL UNIQUE,
|
|
@@ -2405,13 +2638,6 @@ function initializeDatabase(file) {
|
|
|
2405
2638
|
if (!inboxColumns.has("resolved_at")) {
|
|
2406
2639
|
database.exec("ALTER TABLE record_inbox ADD COLUMN resolved_at INTEGER");
|
|
2407
2640
|
}
|
|
2408
|
-
const publicationColumns = new Set(database.prepare("PRAGMA table_info(snapshot_publications)").all().map(({ name }) => name));
|
|
2409
|
-
if (!publicationColumns.has("status")) {
|
|
2410
|
-
database.exec("ALTER TABLE snapshot_publications ADD COLUMN status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'rejected'))");
|
|
2411
|
-
}
|
|
2412
|
-
if (!publicationColumns.has("submitted_at")) {
|
|
2413
|
-
database.exec("ALTER TABLE snapshot_publications ADD COLUMN submitted_at INTEGER");
|
|
2414
|
-
}
|
|
2415
2641
|
database.exec("DROP INDEX IF EXISTS record_inbox_delivery");
|
|
2416
2642
|
database.exec("DROP TABLE IF EXISTS mutation_journal");
|
|
2417
2643
|
migratedDatabases.add(file);
|
|
@@ -2861,34 +3087,46 @@ function readSnapshotPublications(file) {
|
|
|
2861
3087
|
const database = initializeDatabase(file);
|
|
2862
3088
|
try {
|
|
2863
3089
|
return database.prepare(`
|
|
2864
|
-
SELECT mutation_id AS mutationId,
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
3090
|
+
SELECT publications.mutation_id AS mutationId,
|
|
3091
|
+
publications.resource_id AS resourceId,
|
|
3092
|
+
publications.meta_json AS metaJson,
|
|
3093
|
+
blobs.snapshot_blob AS snapshotBlob,
|
|
3094
|
+
blobs.operation_blob AS operationBlob,
|
|
3095
|
+
publications.status,
|
|
3096
|
+
publications.submitted_at AS submittedAt
|
|
3097
|
+
FROM snapshot_publications AS publications
|
|
3098
|
+
LEFT JOIN snapshot_publication_blobs AS blobs USING (mutation_id)
|
|
3099
|
+
ORDER BY publications.sequence
|
|
2868
3100
|
`).all().map((row) => {
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
3101
|
+
// A publication without its payload is damage, never a skippable row —
|
|
3102
|
+
// silently dropping it would let a new publication supersede a
|
|
3103
|
+
// submitted mutation whose outcome exists only at the authority.
|
|
3104
|
+
if (!row.snapshotBlob || !row.operationBlob) {
|
|
3105
|
+
throw new Error(`snapshot publication ${row.mutationId} has no journaled payload`);
|
|
2872
3106
|
}
|
|
2873
|
-
|
|
2874
|
-
|
|
3107
|
+
const meta = JSON.parse(row.metaJson);
|
|
3108
|
+
// No lawful publication carries zero roots — rootReplacementFromRecords
|
|
3109
|
+
// refuses them at build time, so a rootless row here is damage.
|
|
3110
|
+
if (!Array.isArray(meta.replacementRootIds) || meta.replacementRootIds.length === 0
|
|
3111
|
+
|| !Array.isArray(meta.uploadUuids) || meta.operationKind !== "entity.roots.replace") {
|
|
2875
3112
|
throw new Error(`snapshot publication ${row.mutationId} has no exact root replacement`);
|
|
2876
3113
|
}
|
|
2877
|
-
const deliveryWatermark = Number(
|
|
3114
|
+
const deliveryWatermark = Number(meta.deliveryWatermark);
|
|
2878
3115
|
if (!Number.isSafeInteger(deliveryWatermark) || deliveryWatermark < 0) {
|
|
2879
3116
|
throw new Error(`snapshot publication ${row.mutationId} has no delivery watermark`);
|
|
2880
3117
|
}
|
|
2881
3118
|
return {
|
|
2882
3119
|
mutationId: row.mutationId,
|
|
2883
3120
|
resourceId: row.resourceId,
|
|
2884
|
-
authorityEpoch: Number(
|
|
2885
|
-
baseVersion: Number(
|
|
3121
|
+
authorityEpoch: Number(meta.authorityEpoch),
|
|
3122
|
+
baseVersion: Number(meta.baseVersion),
|
|
2886
3123
|
deliveryWatermark,
|
|
2887
|
-
snapshotChecksum: String(
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
3124
|
+
snapshotChecksum: String(meta.snapshotChecksum),
|
|
3125
|
+
operationKind: meta.operationKind,
|
|
3126
|
+
replacementRootIds: meta.replacementRootIds.map(String),
|
|
3127
|
+
uploadUuids: meta.uploadUuids.map(String),
|
|
3128
|
+
snapshotBlob: row.snapshotBlob,
|
|
3129
|
+
operationBlob: row.operationBlob,
|
|
2892
3130
|
status: row.status === "rejected" ? "rejected" : "pending",
|
|
2893
3131
|
submitted: row.submittedAt !== null,
|
|
2894
3132
|
};
|
|
@@ -3055,22 +3293,30 @@ function supersedeSnapshotJournal(file, entry) {
|
|
|
3055
3293
|
if (unresolved) {
|
|
3056
3294
|
throw new Error("a submitted publication with an unknown outcome must resolve before supersession");
|
|
3057
3295
|
}
|
|
3296
|
+
database.prepare(`
|
|
3297
|
+
DELETE FROM snapshot_publication_blobs WHERE mutation_id IN (
|
|
3298
|
+
SELECT mutation_id FROM snapshot_publications WHERE resource_id = ?
|
|
3299
|
+
)
|
|
3300
|
+
`).run(entry.resourceId);
|
|
3058
3301
|
database.prepare("DELETE FROM snapshot_publications WHERE resource_id = ?")
|
|
3059
3302
|
.run(entry.resourceId);
|
|
3060
3303
|
database.prepare(`
|
|
3061
3304
|
INSERT INTO snapshot_publications(
|
|
3062
|
-
mutation_id, resource_id,
|
|
3305
|
+
mutation_id, resource_id, meta_json, created_at
|
|
3063
3306
|
) VALUES (?, ?, ?, ?)
|
|
3064
3307
|
`).run(entry.mutationId, entry.resourceId, stableJson({
|
|
3065
3308
|
authorityEpoch: entry.authorityEpoch,
|
|
3066
3309
|
baseVersion: entry.baseVersion,
|
|
3067
3310
|
deliveryWatermark: entry.deliveryWatermark,
|
|
3068
3311
|
snapshotChecksum: entry.snapshotChecksum,
|
|
3069
|
-
|
|
3070
|
-
uploadRecords: entry.uploadRecords,
|
|
3312
|
+
operationKind: entry.operationKind,
|
|
3071
3313
|
replacementRootIds: entry.replacementRootIds,
|
|
3072
|
-
|
|
3314
|
+
uploadUuids: entry.uploadUuids,
|
|
3073
3315
|
}), Date.now());
|
|
3316
|
+
database.prepare(`
|
|
3317
|
+
INSERT INTO snapshot_publication_blobs(mutation_id, snapshot_blob, operation_blob)
|
|
3318
|
+
VALUES (?, ?, ?)
|
|
3319
|
+
`).run(entry.mutationId, entry.snapshotBlob, entry.operationBlob);
|
|
3074
3320
|
})();
|
|
3075
3321
|
}
|
|
3076
3322
|
finally {
|
|
@@ -3140,7 +3386,10 @@ function captureChurn(error) {
|
|
|
3140
3386
|
function deleteJournalEntry(file, mutationId) {
|
|
3141
3387
|
const database = initializeDatabase(file);
|
|
3142
3388
|
try {
|
|
3143
|
-
database.
|
|
3389
|
+
database.transaction(() => {
|
|
3390
|
+
database.prepare("DELETE FROM snapshot_publication_blobs WHERE mutation_id = ?").run(mutationId);
|
|
3391
|
+
database.prepare("DELETE FROM snapshot_publications WHERE mutation_id = ?").run(mutationId);
|
|
3392
|
+
})();
|
|
3144
3393
|
}
|
|
3145
3394
|
finally {
|
|
3146
3395
|
database.close();
|
|
@@ -3520,7 +3769,7 @@ function materializedWorkspaceBindings(bindingDir) {
|
|
|
3520
3769
|
return bindings.sort((left, right) => left.workspaceId.localeCompare(right.workspaceId));
|
|
3521
3770
|
}
|
|
3522
3771
|
async function reconcileGround(input) {
|
|
3523
|
-
const { identity, userRoot, database, cacheDir, suspicions, coreScan, registrationWorkspaceId, onScan, } = input;
|
|
3772
|
+
const { identity, userRoot, database, cacheDir, suspicions, coreScan, registrationWorkspaceId, onScan, measuring = false, } = input;
|
|
3524
3773
|
const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
|
|
3525
3774
|
const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
|
|
3526
3775
|
const boundRootEntries = materializedWorkspaceBindings(bindingDir);
|
|
@@ -3615,9 +3864,24 @@ async function reconcileGround(input) {
|
|
|
3615
3864
|
contentReads: 0,
|
|
3616
3865
|
gitInspections: 0,
|
|
3617
3866
|
repositoryCaptures: 0,
|
|
3867
|
+
walkMs: 0,
|
|
3868
|
+
captureMs: 0,
|
|
3869
|
+
captureBusyMs: 0,
|
|
3870
|
+
captureBytes: 0,
|
|
3871
|
+
gitMs: 0,
|
|
3872
|
+
repositoryIdentityMs: 0,
|
|
3873
|
+
repositoryCaptureMs: 0,
|
|
3874
|
+
repositorySealMs: 0,
|
|
3875
|
+
reconcileMs: 0,
|
|
3876
|
+
persistMs: 0,
|
|
3618
3877
|
};
|
|
3619
3878
|
const started = performance.now();
|
|
3620
|
-
const result = await reconcileRoot({
|
|
3879
|
+
const result = await reconcileRoot({
|
|
3880
|
+
...parameters,
|
|
3881
|
+
evidence: counters,
|
|
3882
|
+
measureEvidence: measuring,
|
|
3883
|
+
materializedUUIDs,
|
|
3884
|
+
});
|
|
3621
3885
|
if (result.transferFailure !== null) {
|
|
3622
3886
|
transferFailures.push({ rootId: parameters.rootUUID, reason: result.transferFailure });
|
|
3623
3887
|
}
|
|
@@ -3655,7 +3919,14 @@ async function reconcileGround(input) {
|
|
|
3655
3919
|
durationMs: performance.now() - started,
|
|
3656
3920
|
plannedRows: proposals.length,
|
|
3657
3921
|
records: proposals.length,
|
|
3658
|
-
|
|
3922
|
+
// Encoding every proposal to learn its wire size costs one record
|
|
3923
|
+
// construction per entity — real time on the critical path of a cold
|
|
3924
|
+
// Register. It is evidence, so it is paid only when read.
|
|
3925
|
+
...(measuring
|
|
3926
|
+
? {
|
|
3927
|
+
recordBytes: proposals.reduce((bytes, proposal) => bytes + encodedRecordBytes(parameters.resourceId, proposal), 0),
|
|
3928
|
+
}
|
|
3929
|
+
: {}),
|
|
3659
3930
|
});
|
|
3660
3931
|
// Each root commits its local projection before Detect yields. Native
|
|
3661
3932
|
// callbacks can therefore preserve new suspicion between large roots.
|
|
@@ -3754,7 +4025,7 @@ async function reconcileGround(input) {
|
|
|
3754
4025
|
};
|
|
3755
4026
|
}
|
|
3756
4027
|
async function reconcileRoot(input) {
|
|
3757
|
-
const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, plansSendRecords, largeManifests, } = input;
|
|
4028
|
+
const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, measureEvidence = false, plansSendRecords, largeManifests, } = input;
|
|
3758
4029
|
// Prior rows reach this scan from every binding this root encloses. A row's
|
|
3759
4030
|
// stored relativePath is relative to the root that last committed it, which
|
|
3760
4031
|
// is a different frame when an enclosing boundary was just registered
|
|
@@ -3927,6 +4198,33 @@ async function reconcileRoot(input) {
|
|
|
3927
4198
|
// its own entry in place; every task is awaited before entries are read.
|
|
3928
4199
|
const captureTasks = [];
|
|
3929
4200
|
const captureGate = concurrencyGate(WALK_CAPTURE_CONCURRENCY);
|
|
4201
|
+
// The scan's three costs are metadata traversal, content capture, and Git
|
|
4202
|
+
// inspection. They interleave, so each is accumulated where it is spent
|
|
4203
|
+
// rather than inferred from the total afterwards.
|
|
4204
|
+
let captureOpenedAt = 0;
|
|
4205
|
+
let captureClosedAt = 0;
|
|
4206
|
+
const spent = (work, into) => {
|
|
4207
|
+
if (!measureEvidence || !evidence)
|
|
4208
|
+
return work();
|
|
4209
|
+
const at = performance.now();
|
|
4210
|
+
try {
|
|
4211
|
+
return work();
|
|
4212
|
+
}
|
|
4213
|
+
finally {
|
|
4214
|
+
evidence[into] += performance.now() - at;
|
|
4215
|
+
}
|
|
4216
|
+
};
|
|
4217
|
+
const spentAsync = async (work, into) => {
|
|
4218
|
+
if (!measureEvidence || !evidence)
|
|
4219
|
+
return work();
|
|
4220
|
+
const at = performance.now();
|
|
4221
|
+
try {
|
|
4222
|
+
return await work();
|
|
4223
|
+
}
|
|
4224
|
+
finally {
|
|
4225
|
+
evidence[into] += performance.now() - at;
|
|
4226
|
+
}
|
|
4227
|
+
};
|
|
3930
4228
|
const visit = async (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
|
|
3931
4229
|
? {
|
|
3932
4230
|
root: directory,
|
|
@@ -3934,8 +4232,8 @@ async function reconcileRoot(input) {
|
|
|
3934
4232
|
evidence: directory === rootPath ? rootRepositoryEvidence : null,
|
|
3935
4233
|
}
|
|
3936
4234
|
: null, forceObserve = rootRailChanged) => {
|
|
3937
|
-
const children = readdirSync(directory, { withFileTypes: true })
|
|
3938
|
-
.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
4235
|
+
const children = spent(() => readdirSync(directory, { withFileTypes: true })
|
|
4236
|
+
.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0), "walkMs");
|
|
3939
4237
|
for (const child of children) {
|
|
3940
4238
|
// Every materialization boundary reserves its root .amalgm/ directory
|
|
3941
4239
|
// for machine intents, staging, and recovery. This is structural host
|
|
@@ -3951,7 +4249,7 @@ async function reconcileRoot(input) {
|
|
|
3951
4249
|
if (!policy(relativePath))
|
|
3952
4250
|
continue;
|
|
3953
4251
|
const absolutePath = join(directory, child.name);
|
|
3954
|
-
const stats = lstatSync(absolutePath);
|
|
4252
|
+
const stats = spent(() => lstatSync(absolutePath), "walkMs");
|
|
3955
4253
|
evidence && (evidence.entries += 1);
|
|
3956
4254
|
// The walk is synchronous metadata work. Yield periodically so the
|
|
3957
4255
|
// wire, Receive, and Apply keep running while a large root is proved.
|
|
@@ -3982,7 +4280,7 @@ async function reconcileRoot(input) {
|
|
|
3982
4280
|
if (observe && repositoryPath && activeRepository && !activeRepository.evidence) {
|
|
3983
4281
|
evidence && (evidence.gitInspections += 1);
|
|
3984
4282
|
try {
|
|
3985
|
-
activeRepository.evidence = inspectGitRegistration(activeRepository.root, activeRepository.evidencePaths);
|
|
4283
|
+
activeRepository.evidence = spent(() => inspectGitRegistration(activeRepository.root, activeRepository.evidencePaths), "gitMs");
|
|
3986
4284
|
}
|
|
3987
4285
|
catch {
|
|
3988
4286
|
// Classification already settled from the marker. If Git cannot
|
|
@@ -4075,11 +4373,19 @@ async function reconcileRoot(input) {
|
|
|
4075
4373
|
});
|
|
4076
4374
|
if (deferredCapture) {
|
|
4077
4375
|
const entry = entries[entries.length - 1];
|
|
4376
|
+
if (measureEvidence && evidence && captureOpenedAt === 0)
|
|
4377
|
+
captureOpenedAt = performance.now();
|
|
4078
4378
|
captureTasks.push(captureGate(async () => {
|
|
4379
|
+
const taskStarted = measureEvidence ? performance.now() : 0;
|
|
4079
4380
|
let current = stats;
|
|
4080
4381
|
for (let attempt = 1;; attempt++) {
|
|
4081
4382
|
try {
|
|
4082
4383
|
const captured = await captureContentFile(absolutePath, current, activeRepository || !plansSendRecords ? null : cacheDir);
|
|
4384
|
+
if (measureEvidence && evidence) {
|
|
4385
|
+
evidence.captureBusyMs += performance.now() - taskStarted;
|
|
4386
|
+
evidence.captureBytes += captured.bytes;
|
|
4387
|
+
captureClosedAt = performance.now();
|
|
4388
|
+
}
|
|
4083
4389
|
entry.type = classifyFile({ binary: captured.binary });
|
|
4084
4390
|
entry.payloadVersion = captured.contentHash;
|
|
4085
4391
|
entry.contentVerifiedAtMs = Date.now();
|
|
@@ -4125,6 +4431,10 @@ async function reconcileRoot(input) {
|
|
|
4125
4431
|
};
|
|
4126
4432
|
await visit(rootPath, rootUUID, rootType);
|
|
4127
4433
|
await Promise.all(captureTasks);
|
|
4434
|
+
if (measureEvidence && evidence && captureOpenedAt !== 0) {
|
|
4435
|
+
evidence.captureMs += captureClosedAt - captureOpenedAt;
|
|
4436
|
+
}
|
|
4437
|
+
const reconcileStarted = measureEvidence ? performance.now() : 0;
|
|
4128
4438
|
// When every entry sits at its stored address with its stored type and
|
|
4129
4439
|
// physical fingerprint (honoring any fixed UUID), path-first claiming makes
|
|
4130
4440
|
// the multi-pass reconciliation the identity map — resolve directly. Any
|
|
@@ -4158,6 +4468,8 @@ async function reconcileRoot(input) {
|
|
|
4158
4468
|
inode: entry.inode,
|
|
4159
4469
|
...(entry.fixedUUID ? { fixedUUID: entry.fixedUUID } : {}),
|
|
4160
4470
|
})));
|
|
4471
|
+
if (measureEvidence && evidence)
|
|
4472
|
+
evidence.reconcileMs += performance.now() - reconcileStarted;
|
|
4161
4473
|
const resolvedByProvisional = new Map();
|
|
4162
4474
|
for (const entry of entries) {
|
|
4163
4475
|
const resolved = resolvedIdentity.get(entry.relativePath) ?? entry.uuid;
|
|
@@ -4224,13 +4536,13 @@ async function reconcileRoot(input) {
|
|
|
4224
4536
|
contentVerifiedAtMs: entry.contentVerifiedAtMs,
|
|
4225
4537
|
};
|
|
4226
4538
|
});
|
|
4227
|
-
const persistCurrentRows = () => {
|
|
4539
|
+
const persistCurrentRows = () => spent(() => {
|
|
4228
4540
|
const rows = rowsFromEntries();
|
|
4229
4541
|
if (input.suspicion === undefined) {
|
|
4230
4542
|
persistRows(database, identity, resourceId, rootUUID, rows, existingRows);
|
|
4231
4543
|
}
|
|
4232
4544
|
return rows;
|
|
4233
|
-
};
|
|
4545
|
+
}, "persistMs");
|
|
4234
4546
|
// Seal nested repositories first so their independent rows are complete
|
|
4235
4547
|
// before the enclosing graph is persisted. Their state is not an input to
|
|
4236
4548
|
// the enclosing identity map; only their address boundary is.
|
|
@@ -4358,7 +4670,7 @@ async function reconcileRoot(input) {
|
|
|
4358
4670
|
}
|
|
4359
4671
|
}
|
|
4360
4672
|
const fingerprintBefore = gitEvidenceFingerprint(repository.absolutePath);
|
|
4361
|
-
let repoIdentity = await refreshRepositoryIdentity
|
|
4673
|
+
let repoIdentity = await spentAsync(refreshRepositoryIdentity, "repositoryIdentityMs");
|
|
4362
4674
|
/* The rows and the transport identity are derived from the same fresh
|
|
4363
4675
|
* worktree evidence. A later Watch ring for one of these dirty paths is
|
|
4364
4676
|
* therefore ordinary equality, not a transport-only echo. */
|
|
@@ -4396,8 +4708,8 @@ async function reconcileRoot(input) {
|
|
|
4396
4708
|
let captured = null;
|
|
4397
4709
|
let coherent = false;
|
|
4398
4710
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
4399
|
-
captured = captureRepository(repository.absolutePath, repoIdentity, prior, previousIdentity);
|
|
4400
|
-
const after = await refreshRepositoryIdentity
|
|
4711
|
+
captured = spent(() => captureRepository(repository.absolutePath, repoIdentity, prior, previousIdentity), "repositoryCaptureMs");
|
|
4712
|
+
const after = await spentAsync(refreshRepositoryIdentity, "repositoryIdentityMs");
|
|
4401
4713
|
if (sameGitIdentity(after, repoIdentity)) {
|
|
4402
4714
|
repoIdentity = [...captured.identity];
|
|
4403
4715
|
coherent = true;
|
|
@@ -4419,15 +4731,18 @@ async function reconcileRoot(input) {
|
|
|
4419
4731
|
}
|
|
4420
4732
|
repository.payloadVersion = captured.stateId;
|
|
4421
4733
|
repository.transportVersion = captured.transportVersion;
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4734
|
+
const settled = captured;
|
|
4735
|
+
const currentLayout = spent(() => {
|
|
4736
|
+
if (settled.bytes) {
|
|
4737
|
+
sealCapturedArtifact(cacheDir, {
|
|
4738
|
+
entityId: repository.uuid,
|
|
4739
|
+
entityType: "repo.git",
|
|
4740
|
+
kind: "git",
|
|
4741
|
+
contentHash: settled.transportVersion,
|
|
4742
|
+
}, settled.bytes);
|
|
4743
|
+
}
|
|
4744
|
+
return inspectRepositoryTransportFile(join(cacheDir, `${settled.transportVersion}.bin`));
|
|
4745
|
+
}, "repositorySealMs");
|
|
4431
4746
|
replayByUuid.set(repository.uuid, {
|
|
4432
4747
|
kind: "repo.git",
|
|
4433
4748
|
stateId: currentLayout.stateId,
|