@milaboratories/pl-middle-layer 1.67.6 → 1.68.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/index.d.ts +3 -2
- package/dist/middle_layer/build_stamp.cjs +34 -0
- package/dist/middle_layer/build_stamp.cjs.map +1 -0
- package/dist/middle_layer/build_stamp.js +34 -0
- package/dist/middle_layer/build_stamp.js.map +1 -0
- package/dist/middle_layer/index.d.ts +3 -2
- package/dist/middle_layer/middle_layer.cjs +48 -0
- package/dist/middle_layer/middle_layer.cjs.map +1 -1
- package/dist/middle_layer/middle_layer.d.ts +19 -0
- package/dist/middle_layer/middle_layer.d.ts.map +1 -1
- package/dist/middle_layer/middle_layer.js +48 -0
- package/dist/middle_layer/middle_layer.js.map +1 -1
- package/dist/middle_layer/ops.cjs +8 -2
- package/dist/middle_layer/ops.cjs.map +1 -1
- package/dist/middle_layer/ops.d.ts +35 -2
- package/dist/middle_layer/ops.d.ts.map +1 -1
- package/dist/middle_layer/ops.js +8 -2
- package/dist/middle_layer/ops.js.map +1 -1
- package/dist/middle_layer/project.cjs +163 -8
- package/dist/middle_layer/project.cjs.map +1 -1
- package/dist/middle_layer/project.d.ts +51 -1
- package/dist/middle_layer/project.d.ts.map +1 -1
- package/dist/middle_layer/project.js +165 -11
- package/dist/middle_layer/project.js.map +1 -1
- package/dist/middle_layer/tree_snapshot_store.cjs +283 -0
- package/dist/middle_layer/tree_snapshot_store.cjs.map +1 -0
- package/dist/middle_layer/tree_snapshot_store.d.ts +143 -0
- package/dist/middle_layer/tree_snapshot_store.d.ts.map +1 -0
- package/dist/middle_layer/tree_snapshot_store.js +280 -0
- package/dist/middle_layer/tree_snapshot_store.js.map +1 -0
- package/package.json +10 -10
- package/src/middle_layer/build_stamp.ts +36 -0
- package/src/middle_layer/index.ts +1 -0
- package/src/middle_layer/middle_layer.ts +77 -0
- package/src/middle_layer/ops.ts +46 -1
- package/src/middle_layer/project.ts +234 -14
- package/src/middle_layer/project_failsafe.test.ts +47 -0
- package/src/middle_layer/tree_snapshot_scenarios.test.ts +337 -0
- package/src/middle_layer/tree_snapshot_store.test.ts +331 -0
- package/src/middle_layer/tree_snapshot_store.ts +419 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { ML_BUILD_STAMP } from "./build_stamp.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createPathAtomically, ensureDirExists } from "@milaboratories/ts-helpers";
|
|
4
|
+
import fsp from "node:fs/promises";
|
|
5
|
+
import { parseSignedResourceId } from "@milaboratories/pl-client";
|
|
6
|
+
import { PERSISTED_TREE_SCHEMA_VERSION, decodePersistedTree, encodePersistedTree, readPersistedTreeHeader } from "@milaboratories/pl-tree";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
//#region src/middle_layer/tree_snapshot_store.ts
|
|
9
|
+
function initialStat() {
|
|
10
|
+
return {
|
|
11
|
+
reads: 0,
|
|
12
|
+
hits: 0,
|
|
13
|
+
restores: 0,
|
|
14
|
+
misses: {
|
|
15
|
+
absent: 0,
|
|
16
|
+
unreadable: 0,
|
|
17
|
+
"session-rotated": 0,
|
|
18
|
+
"not-a-snapshot": 0,
|
|
19
|
+
"unknown-schema": 0,
|
|
20
|
+
truncated: 0,
|
|
21
|
+
checksum: 0,
|
|
22
|
+
malformed: 0
|
|
23
|
+
},
|
|
24
|
+
writes: 0,
|
|
25
|
+
writeFailures: 0,
|
|
26
|
+
bytesWritten: 0,
|
|
27
|
+
discarded: 0,
|
|
28
|
+
evicted: 0,
|
|
29
|
+
evictedForSize: 0,
|
|
30
|
+
bytesEvicted: 0,
|
|
31
|
+
millisReading: 0,
|
|
32
|
+
millisWriting: 0
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const FILE_PREFIX = "tree.";
|
|
36
|
+
const FILE_SUFFIX = ".plts";
|
|
37
|
+
/** Keeps a filename to characters every filesystem we target accepts. */
|
|
38
|
+
function safe(part) {
|
|
39
|
+
return part.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
40
|
+
}
|
|
41
|
+
/** Names this class writes: a finished snapshot, or the staging file of a write killed before
|
|
42
|
+
* its rename. The directory is caller-supplied and only defaults to one of ours, so nothing
|
|
43
|
+
* failing this is ever deleted, by the purge or by the startup eviction. */
|
|
44
|
+
function isOurFile(name) {
|
|
45
|
+
if (!name.startsWith(FILE_PREFIX)) return false;
|
|
46
|
+
return name.endsWith(FILE_SUFFIX) || name.includes(`${FILE_SUFFIX}.tmp.`);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Snapshots of project tree mirrors on the local filesystem.
|
|
50
|
+
*
|
|
51
|
+
* A snapshot is addressed by backend instance, authenticated user, root resource, build stamp
|
|
52
|
+
* and snapshot schema version. Everything except the root goes into the *scope*, which is
|
|
53
|
+
* fixed for the lifetime of a client; the root distinguishes one project from another, so
|
|
54
|
+
* there is one file per project per user per backend, rewritten in place.
|
|
55
|
+
*
|
|
56
|
+
* The session is deliberately not part of the key. It is witnessed inside the file and
|
|
57
|
+
* compared on read: a snapshot from an ended session is a miss, but the file is kept, because
|
|
58
|
+
* its bodies remain valid indefinitely and only its signatures have died. Deleting it would
|
|
59
|
+
* destroy the evidence a future signature refresh would repair.
|
|
60
|
+
*/
|
|
61
|
+
var TreeSnapshotStore = class TreeSnapshotStore {
|
|
62
|
+
ops;
|
|
63
|
+
scope;
|
|
64
|
+
stat = initialStat();
|
|
65
|
+
constructor(ops, scope) {
|
|
66
|
+
this.ops = ops;
|
|
67
|
+
this.scope = scope;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Builds a store for the given client, or returns undefined when nothing should be
|
|
71
|
+
* persisted for it.
|
|
72
|
+
*
|
|
73
|
+
* Returns undefined for an impersonated client: reading and writing under `asUser` would
|
|
74
|
+
* leave another user's mirror at rest under the admin's identity, usable only if the admin
|
|
75
|
+
* returned to that exact root. One condition removes both the hygiene question and the
|
|
76
|
+
* orphan one.
|
|
77
|
+
*/
|
|
78
|
+
static create(pl, ops) {
|
|
79
|
+
if (!ops.enabled) {
|
|
80
|
+
ops.logger.info("tree snapshots are disabled by configuration");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (pl.conf.asUser !== void 0) {
|
|
84
|
+
ops.logger.info("tree snapshots are disabled while the client is opened as another user");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const identity = createHash("sha256").update([
|
|
88
|
+
pl.conf.hostAndPort,
|
|
89
|
+
pl.serverInfo.instanceId ?? "",
|
|
90
|
+
pl.authUser ?? ""
|
|
91
|
+
].join("\0")).digest("hex").slice(0, 16);
|
|
92
|
+
return new TreeSnapshotStore(ops, `${PERSISTED_TREE_SCHEMA_VERSION}.${safe(ML_BUILD_STAMP)}.${identity}`);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Removes this class's files from the snapshot directory. Run when snapshots are switched
|
|
96
|
+
* off, so a user who turns the kill switch off because the disk is full or unwritable
|
|
97
|
+
* actually gets the space back, rather than leaving up to the size ceiling stranded there
|
|
98
|
+
* indefinitely.
|
|
99
|
+
*
|
|
100
|
+
* Deliberately keyed on the setting rather than on "there is no store": a store is also
|
|
101
|
+
* absent for an impersonated client, and deleting there would destroy the operator's own
|
|
102
|
+
* snapshots from their ordinary sessions. Never throws.
|
|
103
|
+
*/
|
|
104
|
+
static async purge(dir, logger) {
|
|
105
|
+
try {
|
|
106
|
+
for (const name of await fsp.readdir(dir)) {
|
|
107
|
+
if (!isOurFile(name)) continue;
|
|
108
|
+
await fsp.rm(path.join(dir, name), { force: true }).catch(() => {});
|
|
109
|
+
}
|
|
110
|
+
await fsp.rmdir(dir).catch(() => {});
|
|
111
|
+
} catch (e) {
|
|
112
|
+
if (e?.code === "ENOENT") return;
|
|
113
|
+
logger.warn(`failed to clear the tree snapshot directory: ${e instanceof Error ? e.message : String(e)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
getStats() {
|
|
117
|
+
return this.stat;
|
|
118
|
+
}
|
|
119
|
+
fileFor(root) {
|
|
120
|
+
const { globalId } = parseSignedResourceId(root);
|
|
121
|
+
return path.join(this.ops.dir, `${FILE_PREFIX}${this.scope}.${globalId}${FILE_SUFFIX}`);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Reads the snapshot for a project root, or reports why there is nothing to restore.
|
|
125
|
+
*
|
|
126
|
+
* `root` is the id as resolved in the current session. Its signature is what the stored
|
|
127
|
+
* witness is compared against, so a rotated session is detected without inflating the
|
|
128
|
+
* payload, and no separate session lookup is needed anywhere.
|
|
129
|
+
*/
|
|
130
|
+
async read(root) {
|
|
131
|
+
const started = Date.now();
|
|
132
|
+
this.stat.reads++;
|
|
133
|
+
try {
|
|
134
|
+
const file = this.fileFor(root);
|
|
135
|
+
let bytes;
|
|
136
|
+
try {
|
|
137
|
+
bytes = await fsp.readFile(file);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
const absent = e?.code === "ENOENT";
|
|
140
|
+
if (!absent) this.ops.logger.warn(`tree snapshot exists but could not be read: ${e instanceof Error ? e.message : String(e)}`);
|
|
141
|
+
return this.miss(absent ? "absent" : "unreadable");
|
|
142
|
+
}
|
|
143
|
+
const header = readPersistedTreeHeader(bytes);
|
|
144
|
+
if (!header.ok) return this.miss(header.reason);
|
|
145
|
+
const { signature } = parseSignedResourceId(root);
|
|
146
|
+
if (!Buffer.from(header.value.witness).equals(Buffer.from(signature))) return this.miss("session-rotated");
|
|
147
|
+
const decoded = await decodePersistedTree(bytes);
|
|
148
|
+
if (!decoded.ok) return this.miss(decoded.reason);
|
|
149
|
+
const now = /* @__PURE__ */ new Date();
|
|
150
|
+
await fsp.utimes(file, now, now).catch(() => {});
|
|
151
|
+
this.stat.hits++;
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
tree: decoded.value
|
|
155
|
+
};
|
|
156
|
+
} finally {
|
|
157
|
+
this.stat.millisReading += Date.now() - started;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
miss(miss) {
|
|
161
|
+
this.stat.misses[miss]++;
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
miss
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** Recorded by the caller once it knows the tree accepted the snapshot. The store cannot
|
|
168
|
+
* tell on its own: it hands over bytes, and whether they become a tree is the tree's call. */
|
|
169
|
+
noteRestored() {
|
|
170
|
+
this.stat.restores++;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Writes a snapshot, replacing any previous one for the same project.
|
|
174
|
+
*
|
|
175
|
+
* Never throws and never rejects: a write is an optimisation, and a full disk or a
|
|
176
|
+
* permissions problem must not fail the operation that triggered it. Staged and renamed
|
|
177
|
+
* into place, so a process killed mid-write leaves the previous snapshot rather than a torn
|
|
178
|
+
* one.
|
|
179
|
+
*/
|
|
180
|
+
async write(root, snapshot, ops = {}) {
|
|
181
|
+
const started = Date.now();
|
|
182
|
+
try {
|
|
183
|
+
const bytes = await encodePersistedTree(snapshot, { compress: ops.compress });
|
|
184
|
+
await ensureDirExists(this.ops.dir);
|
|
185
|
+
const file = this.fileFor(root);
|
|
186
|
+
await createPathAtomically(this.ops.logger, file, async (tempPath) => {
|
|
187
|
+
await fsp.writeFile(tempPath, bytes, { flag: "wx" });
|
|
188
|
+
});
|
|
189
|
+
this.stat.writes++;
|
|
190
|
+
this.stat.bytesWritten += bytes.length;
|
|
191
|
+
return true;
|
|
192
|
+
} catch (e) {
|
|
193
|
+
this.stat.writeFailures++;
|
|
194
|
+
this.ops.logger.warn(`failed to write tree snapshot: ${e instanceof Error ? e.message : String(e)}`);
|
|
195
|
+
return false;
|
|
196
|
+
} finally {
|
|
197
|
+
this.stat.millisWriting += Date.now() - started;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** Deletes the snapshot for a project. Used by the fail-safe, when a restored tree turns
|
|
201
|
+
* out not to match what the backend will serve. Never throws. */
|
|
202
|
+
async discard(root) {
|
|
203
|
+
try {
|
|
204
|
+
await fsp.rm(this.fileFor(root), { force: true });
|
|
205
|
+
this.stat.discarded++;
|
|
206
|
+
} catch (e) {
|
|
207
|
+
this.ops.logger.warn(`failed to discard tree snapshot: ${e instanceof Error ? e.message : String(e)}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Startup housekeeping. Drops every snapshot outside the current scope (another build,
|
|
212
|
+
* backend, user or schema version), then trims what is left to the size ceiling, least
|
|
213
|
+
* recently written first.
|
|
214
|
+
*
|
|
215
|
+
* Modification time stands in for recency of use: an open project is rewritten
|
|
216
|
+
* periodically, so the file's age tracks how recently the project was worked on. Read times
|
|
217
|
+
* would be a truer signal but atime is unreliable across platforms and mount options.
|
|
218
|
+
*
|
|
219
|
+
* Runs at startup only, so the ceiling bounds what a session starts with rather than capping
|
|
220
|
+
* it throughout: a long session opening many projects can exceed it until the next launch.
|
|
221
|
+
*
|
|
222
|
+
* Never throws: an unusable cache directory should cost the cache, not the startup.
|
|
223
|
+
*/
|
|
224
|
+
async evict() {
|
|
225
|
+
try {
|
|
226
|
+
await ensureDirExists(this.ops.dir);
|
|
227
|
+
const names = await fsp.readdir(this.ops.dir);
|
|
228
|
+
const current = [];
|
|
229
|
+
for (const name of names) {
|
|
230
|
+
const file = path.join(this.ops.dir, name);
|
|
231
|
+
const inScope = name.startsWith(`${FILE_PREFIX}${this.scope}.`) && name.endsWith(FILE_SUFFIX);
|
|
232
|
+
if (!inScope && !isOurFile(name)) continue;
|
|
233
|
+
let size = 0;
|
|
234
|
+
let mtimeMs = 0;
|
|
235
|
+
try {
|
|
236
|
+
const stat = await fsp.stat(file);
|
|
237
|
+
if (!stat.isFile()) continue;
|
|
238
|
+
size = stat.size;
|
|
239
|
+
mtimeMs = stat.mtimeMs;
|
|
240
|
+
} catch {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (inScope) {
|
|
244
|
+
current.push({
|
|
245
|
+
file,
|
|
246
|
+
size,
|
|
247
|
+
mtimeMs
|
|
248
|
+
});
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
await this.remove(file, size, false);
|
|
252
|
+
}
|
|
253
|
+
const ceiling = this.ops.maxSizeBytes;
|
|
254
|
+
let total = current.reduce((sum, e) => sum + e.size, 0);
|
|
255
|
+
if (total <= ceiling) return;
|
|
256
|
+
current.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
257
|
+
for (const entry of current) {
|
|
258
|
+
if (total <= ceiling) break;
|
|
259
|
+
if (await this.remove(entry.file, entry.size, true)) total -= entry.size;
|
|
260
|
+
}
|
|
261
|
+
} catch (e) {
|
|
262
|
+
this.ops.logger.warn(`tree snapshot eviction failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async remove(file, size, forSize) {
|
|
266
|
+
try {
|
|
267
|
+
await fsp.rm(file, { force: true });
|
|
268
|
+
this.stat.evicted++;
|
|
269
|
+
this.stat.bytesEvicted += size;
|
|
270
|
+
if (forSize) this.stat.evictedForSize++;
|
|
271
|
+
return true;
|
|
272
|
+
} catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
//#endregion
|
|
278
|
+
export { TreeSnapshotStore };
|
|
279
|
+
|
|
280
|
+
//# sourceMappingURL=tree_snapshot_store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tree_snapshot_store.js","names":[],"sources":["../../src/middle_layer/tree_snapshot_store.ts"],"sourcesContent":["import type { PersistedTree, PersistedTreeReadFailure } from \"@milaboratories/pl-tree\";\nimport {\n decodePersistedTree,\n encodePersistedTree,\n PERSISTED_TREE_SCHEMA_VERSION,\n readPersistedTreeHeader,\n} from \"@milaboratories/pl-tree\";\nimport type { PlClient, SignedResourceId } from \"@milaboratories/pl-client\";\nimport { parseSignedResourceId } from \"@milaboratories/pl-client\";\nimport type { MiLogger } from \"@milaboratories/ts-helpers\";\nimport { createPathAtomically, ensureDirExists } from \"@milaboratories/ts-helpers\";\nimport { createHash } from \"node:crypto\";\nimport fsp from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { ML_BUILD_STAMP } from \"./build_stamp\";\n\n/** Why a read did not produce a tree to restore from. Counted rather than inferred, because\n * \"no snapshot\" and \"a snapshot we refused\" are very different things when a warm reopen\n * fails to be warm and someone has to work out why. */\nexport type TreeSnapshotMiss =\n /** No file for this key: a first open, or the key moved (new build, new backend, new user). */\n | \"absent\"\n /** A file is there but could not be opened at all: permissions, a bad mount, an I/O error.\n * Distinct from `absent` because a first open and a broken cache directory need different\n * answers from whoever reads the counters. */\n | \"unreadable\"\n /** File exists, but its signatures belong to a session that has ended. Kept, not deleted. */\n | \"session-rotated\"\n /** File exists and could not be read. Carries the codec's reason. */\n | PersistedTreeReadFailure;\n\nexport type TreeSnapshotStat = {\n reads: number;\n /** Snapshots read successfully. A hit is not yet a warm open: the tree can still refuse to\n * apply it, which is what {@link restores} counts. */\n hits: number;\n /** Snapshots actually applied as a tree's initial state. This is the number that says a\n * reopen was warm. */\n restores: number;\n /** Miss counts by reason. */\n misses: Record<TreeSnapshotMiss, number>;\n writes: number;\n writeFailures: number;\n bytesWritten: number;\n /** Snapshots deleted by the fail-safe after a restored tree failed its first refresh. */\n discarded: number;\n /** Files removed at startup, and how many of those were dropped for being over the ceiling\n * rather than for belonging to another build, backend or user. */\n evicted: number;\n evictedForSize: number;\n bytesEvicted: number;\n millisReading: number;\n millisWriting: number;\n};\n\nfunction initialStat(): TreeSnapshotStat {\n return {\n reads: 0,\n hits: 0,\n restores: 0,\n misses: {\n absent: 0,\n unreadable: 0,\n \"session-rotated\": 0,\n \"not-a-snapshot\": 0,\n \"unknown-schema\": 0,\n truncated: 0,\n checksum: 0,\n malformed: 0,\n },\n writes: 0,\n writeFailures: 0,\n bytesWritten: 0,\n discarded: 0,\n evicted: 0,\n evictedForSize: 0,\n bytesEvicted: 0,\n millisReading: 0,\n millisWriting: 0,\n };\n}\n\nexport type TreeSnapshotStoreOps = {\n /** Directory holding the snapshots. One file per project. */\n readonly dir: string;\n /** Total bytes the directory may occupy after startup eviction. */\n readonly maxSizeBytes: number;\n readonly logger: MiLogger;\n};\n\nconst FILE_PREFIX = \"tree.\";\nconst FILE_SUFFIX = \".plts\";\n\n/** Keeps a filename to characters every filesystem we target accepts. */\nfunction safe(part: string): string {\n return part.replace(/[^A-Za-z0-9_-]/g, \"_\");\n}\n\n/** Names this class writes: a finished snapshot, or the staging file of a write killed before\n * its rename. The directory is caller-supplied and only defaults to one of ours, so nothing\n * failing this is ever deleted, by the purge or by the startup eviction. */\nfunction isOurFile(name: string): boolean {\n if (!name.startsWith(FILE_PREFIX)) return false;\n return name.endsWith(FILE_SUFFIX) || name.includes(`${FILE_SUFFIX}.tmp.`);\n}\n\n/**\n * Snapshots of project tree mirrors on the local filesystem.\n *\n * A snapshot is addressed by backend instance, authenticated user, root resource, build stamp\n * and snapshot schema version. Everything except the root goes into the *scope*, which is\n * fixed for the lifetime of a client; the root distinguishes one project from another, so\n * there is one file per project per user per backend, rewritten in place.\n *\n * The session is deliberately not part of the key. It is witnessed inside the file and\n * compared on read: a snapshot from an ended session is a miss, but the file is kept, because\n * its bodies remain valid indefinitely and only its signatures have died. Deleting it would\n * destroy the evidence a future signature refresh would repair.\n */\nexport class TreeSnapshotStore {\n private readonly stat = initialStat();\n\n private constructor(\n private readonly ops: TreeSnapshotStoreOps,\n /** Identifies backend, user, build and schema. Same for every project in this session. */\n private readonly scope: string,\n ) {}\n\n /**\n * Builds a store for the given client, or returns undefined when nothing should be\n * persisted for it.\n *\n * Returns undefined for an impersonated client: reading and writing under `asUser` would\n * leave another user's mirror at rest under the admin's identity, usable only if the admin\n * returned to that exact root. One condition removes both the hygiene question and the\n * orphan one.\n */\n public static create(\n pl: PlClient,\n ops: TreeSnapshotStoreOps & { readonly enabled: boolean },\n ): TreeSnapshotStore | undefined {\n if (!ops.enabled) {\n ops.logger.info(\"tree snapshots are disabled by configuration\");\n return undefined;\n }\n if (pl.conf.asUser !== undefined) {\n ops.logger.info(\"tree snapshots are disabled while the client is opened as another user\");\n return undefined;\n }\n\n // The backend *instance*, not merely its address: instanceId rotates whenever the backend\n // resets its database, which is exactly the case where the same address starts serving a\n // different state under reused global ids. Without it, a reset at a fixed address (a local\n // backend, whose working directory does not move) would be a key hit, and the only thing\n // left to catch it would be the witness, which is empty on both sides on a backend that\n // predates resource signatures.\n //\n // Hashed rather than spelled out: a login can be an email and an address can carry\n // characters a filename cannot. The hash only has to be stable and to differ when any part\n // differs, both of which it does. The NUL separator keeps the parts unambiguous.\n const identity = createHash(\"sha256\")\n .update([pl.conf.hostAndPort, pl.serverInfo.instanceId ?? \"\", pl.authUser ?? \"\"].join(\"\\0\"))\n .digest(\"hex\")\n .slice(0, 16);\n\n const scope = `${PERSISTED_TREE_SCHEMA_VERSION}.${safe(ML_BUILD_STAMP)}.${identity}`;\n return new TreeSnapshotStore(ops, scope);\n }\n\n /**\n * Removes this class's files from the snapshot directory. Run when snapshots are switched\n * off, so a user who turns the kill switch off because the disk is full or unwritable\n * actually gets the space back, rather than leaving up to the size ceiling stranded there\n * indefinitely.\n *\n * Deliberately keyed on the setting rather than on \"there is no store\": a store is also\n * absent for an impersonated client, and deleting there would destroy the operator's own\n * snapshots from their ordinary sessions. Never throws.\n */\n public static async purge(dir: string, logger: MiLogger): Promise<void> {\n try {\n // Deliberately NOT a recursive delete of `dir`. The path is caller-supplied and only\n // defaults to a directory of ours, so removing it wholesale would let a misconfigured\n // `treeSnapshotPath` take an unrelated directory with it, at the exact moment the user\n // reached for a switch labelled \"my disk is troublesome\". Only files this class writes\n // are removed, then the directory itself if that emptied it.\n for (const name of await fsp.readdir(dir)) {\n if (!isOurFile(name)) continue;\n await fsp.rm(path.join(dir, name), { force: true }).catch(() => {});\n }\n await fsp.rmdir(dir).catch(() => {\n // Still holds something that is not ours; leaving it is the point.\n });\n } catch (e: unknown) {\n if ((e as NodeJS.ErrnoException | null)?.code === \"ENOENT\") return;\n logger.warn(\n `failed to clear the tree snapshot directory: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n }\n\n public getStats(): Readonly<TreeSnapshotStat> {\n return this.stat;\n }\n\n private fileFor(root: SignedResourceId): string {\n // The root's global id, not the signed form: the signature changes every session while\n // the file must not.\n const { globalId } = parseSignedResourceId(root);\n return path.join(this.ops.dir, `${FILE_PREFIX}${this.scope}.${globalId}${FILE_SUFFIX}`);\n }\n\n /**\n * Reads the snapshot for a project root, or reports why there is nothing to restore.\n *\n * `root` is the id as resolved in the current session. Its signature is what the stored\n * witness is compared against, so a rotated session is detected without inflating the\n * payload, and no separate session lookup is needed anywhere.\n */\n public async read(\n root: SignedResourceId,\n ): Promise<{ ok: true; tree: PersistedTree } | { ok: false; miss: TreeSnapshotMiss }> {\n const started = Date.now();\n this.stat.reads++;\n try {\n const file = this.fileFor(root);\n let bytes: Buffer;\n try {\n bytes = await fsp.readFile(file);\n } catch (e: unknown) {\n // A missing file and an unreadable one both mean a cold open, but they are different\n // problems: one is an ordinary first open, the other is a cache directory that needs\n // attention, and the counters are the only place that difference is visible.\n const absent = (e as NodeJS.ErrnoException | null)?.code === \"ENOENT\";\n if (!absent)\n this.ops.logger.warn(\n `tree snapshot exists but could not be read: ${e instanceof Error ? e.message : String(e)}`,\n );\n return this.miss(absent ? \"absent\" : \"unreadable\");\n }\n\n const header = readPersistedTreeHeader(bytes);\n if (!header.ok) return this.miss(header.reason);\n\n // On a backend predating resource signatures both sides are empty and always match,\n // which is right: without signatures the ids are not session-bound in the first place.\n const { signature } = parseSignedResourceId(root);\n if (!Buffer.from(header.value.witness).equals(Buffer.from(signature)))\n // Kept, not deleted. See the class comment.\n return this.miss(\"session-rotated\");\n\n const decoded = await decodePersistedTree(bytes);\n if (!decoded.ok) return this.miss(decoded.reason);\n\n // Touched on a hit so the modification time tracks last *use*, which is what the size\n // trim is supposed to order by. Without this, a project reopened every day but never\n // changed is never rewritten, and so ages out ahead of one touched once and abandoned.\n const now = new Date();\n await fsp.utimes(file, now, now).catch(() => {\n // Ordering the trim is not worth failing a hit over.\n });\n\n this.stat.hits++;\n return { ok: true, tree: decoded.value };\n } finally {\n this.stat.millisReading += Date.now() - started;\n }\n }\n\n private miss(miss: TreeSnapshotMiss): { ok: false; miss: TreeSnapshotMiss } {\n this.stat.misses[miss]++;\n return { ok: false, miss };\n }\n\n /** Recorded by the caller once it knows the tree accepted the snapshot. The store cannot\n * tell on its own: it hands over bytes, and whether they become a tree is the tree's call. */\n public noteRestored(): void {\n this.stat.restores++;\n }\n\n /**\n * Writes a snapshot, replacing any previous one for the same project.\n *\n * Never throws and never rejects: a write is an optimisation, and a full disk or a\n * permissions problem must not fail the operation that triggered it. Staged and renamed\n * into place, so a process killed mid-write leaves the previous snapshot rather than a torn\n * one.\n */\n public async write(\n root: SignedResourceId,\n snapshot: PersistedTree,\n ops: { compress?: boolean } = {},\n ): Promise<boolean> {\n const started = Date.now();\n try {\n const bytes = await encodePersistedTree(snapshot, { compress: ops.compress });\n await ensureDirExists(this.ops.dir);\n\n const file = this.fileFor(root);\n await createPathAtomically(this.ops.logger, file, async (tempPath) => {\n // \"wx\" so a colliding temp name fails instead of overwriting another writer's file.\n await fsp.writeFile(tempPath, bytes, { flag: \"wx\" });\n });\n\n this.stat.writes++;\n this.stat.bytesWritten += bytes.length;\n return true;\n } catch (e: unknown) {\n this.stat.writeFailures++;\n this.ops.logger.warn(\n `failed to write tree snapshot: ${e instanceof Error ? e.message : String(e)}`,\n );\n return false;\n } finally {\n this.stat.millisWriting += Date.now() - started;\n }\n }\n\n /** Deletes the snapshot for a project. Used by the fail-safe, when a restored tree turns\n * out not to match what the backend will serve. Never throws. */\n public async discard(root: SignedResourceId): Promise<void> {\n try {\n await fsp.rm(this.fileFor(root), { force: true });\n this.stat.discarded++;\n } catch (e: unknown) {\n this.ops.logger.warn(\n `failed to discard tree snapshot: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n }\n\n /**\n * Startup housekeeping. Drops every snapshot outside the current scope (another build,\n * backend, user or schema version), then trims what is left to the size ceiling, least\n * recently written first.\n *\n * Modification time stands in for recency of use: an open project is rewritten\n * periodically, so the file's age tracks how recently the project was worked on. Read times\n * would be a truer signal but atime is unreliable across platforms and mount options.\n *\n * Runs at startup only, so the ceiling bounds what a session starts with rather than capping\n * it throughout: a long session opening many projects can exceed it until the next launch.\n *\n * Never throws: an unusable cache directory should cost the cache, not the startup.\n */\n public async evict(): Promise<void> {\n try {\n await ensureDirExists(this.ops.dir);\n const names = await fsp.readdir(this.ops.dir);\n\n const current: { file: string; size: number; mtimeMs: number }[] = [];\n\n for (const name of names) {\n const file = path.join(this.ops.dir, name);\n\n // Anything of ours not addressed to the current scope goes: another build, backend,\n // user or schema version, and also the staging files of a write that was killed\n // before its rename, which end in `.tmp.<hex>` rather than the suffix.\n const inScope =\n name.startsWith(`${FILE_PREFIX}${this.scope}.`) && name.endsWith(FILE_SUFFIX);\n\n // Out of scope is not the same as ours to delete: a `treeSnapshotPath` pointed at an\n // existing or shared directory would otherwise have every file in it removed at\n // startup. Same rule as `purge`, for the same reason.\n if (!inScope && !isOurFile(name)) continue;\n\n let size = 0;\n let mtimeMs = 0;\n try {\n const stat = await fsp.stat(file);\n if (!stat.isFile()) continue;\n size = stat.size;\n mtimeMs = stat.mtimeMs;\n } catch {\n continue; // vanished under us, or unreadable: nothing to account for\n }\n\n if (inScope) {\n current.push({ file, size, mtimeMs });\n continue;\n }\n\n await this.remove(file, size, false);\n }\n\n const ceiling = this.ops.maxSizeBytes;\n let total = current.reduce((sum, e) => sum + e.size, 0);\n if (total <= ceiling) return;\n\n // Oldest first, so the projects a user is actually working on are the ones that survive.\n current.sort((a, b) => a.mtimeMs - b.mtimeMs);\n for (const entry of current) {\n if (total <= ceiling) break;\n // Only a file that actually went stops counting against the ceiling. Subtracting\n // regardless would let one undeletable file end the trim early and leave the directory\n // over its limit.\n if (await this.remove(entry.file, entry.size, true)) total -= entry.size;\n }\n } catch (e: unknown) {\n this.ops.logger.warn(\n `tree snapshot eviction failed: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n }\n\n private async remove(file: string, size: number, forSize: boolean): Promise<boolean> {\n try {\n await fsp.rm(file, { force: true });\n this.stat.evicted++;\n this.stat.bytesEvicted += size;\n if (forSize) this.stat.evictedForSize++;\n return true;\n } catch {\n // A file we cannot delete is not worth failing startup over; it will be reconsidered\n // on the next one.\n return false;\n }\n }\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@milaboratories/pl-middle-layer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.68.0",
|
|
4
4
|
"description": "Pl Middle Layer",
|
|
5
5
|
"keywords": [],
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -30,25 +30,25 @@
|
|
|
30
30
|
"utility-types": "^3.11.0",
|
|
31
31
|
"yaml": "^2.8.0",
|
|
32
32
|
"zod": "~3.25.76",
|
|
33
|
-
"@milaboratories/
|
|
34
|
-
"@milaboratories/
|
|
33
|
+
"@milaboratories/columns-collection-driver": "0.2.4",
|
|
34
|
+
"@milaboratories/helpers": "1.14.5",
|
|
35
35
|
"@milaboratories/pf-driver": "1.9.1",
|
|
36
36
|
"@milaboratories/pf-spec-driver": "1.5.1",
|
|
37
|
+
"@milaboratories/computable": "2.9.8",
|
|
38
|
+
"@milaboratories/pl-drivers": "1.16.17",
|
|
37
39
|
"@milaboratories/pl-client": "3.14.7",
|
|
38
|
-
"@milaboratories/columns-collection-driver": "0.2.4",
|
|
39
|
-
"@milaboratories/pl-deployments": "3.0.16",
|
|
40
40
|
"@milaboratories/pl-errors": "1.4.36",
|
|
41
41
|
"@milaboratories/pl-http": "1.2.4",
|
|
42
42
|
"@milaboratories/pl-model-backend": "1.4.21",
|
|
43
|
+
"@milaboratories/pl-deployments": "3.0.16",
|
|
43
44
|
"@milaboratories/pl-model-common": "1.48.0",
|
|
44
|
-
"@milaboratories/pl-tree": "1.
|
|
45
|
+
"@milaboratories/pl-tree": "1.14.0",
|
|
45
46
|
"@milaboratories/pl-model-middle-layer": "1.32.0",
|
|
46
|
-
"@
|
|
47
|
-
"@milaboratories/ts-helpers": "1.8.6",
|
|
47
|
+
"@platforma-sdk/block-tools": "2.14.3",
|
|
48
48
|
"@milaboratories/resolve-helper": "1.1.3",
|
|
49
|
-
"@
|
|
49
|
+
"@milaboratories/ts-helpers": "1.8.6",
|
|
50
50
|
"@platforma-sdk/model": "1.83.0",
|
|
51
|
-
"@platforma-sdk/
|
|
51
|
+
"@platforma-sdk/workflow-tengo": "6.8.3"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/node": "~24.5.2",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Injected by rolldown at build time, see `build.node.config.js`. Absent when the package is
|
|
2
|
+
* consumed straight from sources (`USE_SOURCES=1`), because no build step runs then. */
|
|
3
|
+
declare const __PL_ML_BUILD_STAMP__: string | undefined;
|
|
4
|
+
|
|
5
|
+
function injectedStamp(): string | undefined {
|
|
6
|
+
try {
|
|
7
|
+
// Read inside a try: with no build step the identifier is an undeclared global, and
|
|
8
|
+
// reading it throws a ReferenceError rather than yielding undefined.
|
|
9
|
+
return __PL_ML_BUILD_STAMP__;
|
|
10
|
+
} catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Identifies the build of this package, and through it the rules that shape what a persisted
|
|
17
|
+
* tree mirror contains: the pruning function, the field filter and the traversal stop rules,
|
|
18
|
+
* all of which live in this package. (The finality predicate comes from pl-client and is NOT
|
|
19
|
+
* covered, which is harmless: finality is recomputed on restore, so it is the one rule that
|
|
20
|
+
* cannot poison a stored file.)
|
|
21
|
+
*
|
|
22
|
+
* Used as a cache-key component, so a change to those rules invalidates every snapshot, costing
|
|
23
|
+
* one cold open. Each built artifact bakes in one stamp, so reopens stay warm across restarts
|
|
24
|
+
* of an installed version. A build from a dirty worktree includes the build time, so editing
|
|
25
|
+
* those rules locally can never hit a snapshot written under the old ones. In practice release
|
|
26
|
+
* builds are dirty too, because CI writes version bumps into the worktree before building; that
|
|
27
|
+
* costs nothing, since the stamp only has to be stable within an artifact.
|
|
28
|
+
*
|
|
29
|
+
* With no build at all (sources mode) the value is a constant. That deliberately trades away
|
|
30
|
+
* the dirty-worktree guarantee: it means someone running from sources exercises the restore
|
|
31
|
+
* path at all, rather than every snapshot being a guaranteed miss for the one audience most
|
|
32
|
+
* likely to find its defects. The exposure it reintroduces, editing pruning rules from sources
|
|
33
|
+
* and hitting a mirror written under the old ones, is the local-development gap the design
|
|
34
|
+
* already accepts, and `treeSnapshots: false` or deleting the directory clears it.
|
|
35
|
+
*/
|
|
36
|
+
export const ML_BUILD_STAMP: string = injectedStamp() ?? "sources";
|
|
@@ -2,5 +2,6 @@ export { MiddleLayer } from "./middle_layer";
|
|
|
2
2
|
export { Project } from "./project";
|
|
3
3
|
export * from "./driver_kit";
|
|
4
4
|
export * from "./ops";
|
|
5
|
+
export type { TreeSnapshotMiss, TreeSnapshotStat } from "./tree_snapshot_store";
|
|
5
6
|
export { ProjectsField } from "./project_list";
|
|
6
7
|
export type { OutgoingShare, PendingShare } from "./sharing_list";
|
|
@@ -111,6 +111,13 @@ import type { Dispatcher } from "undici";
|
|
|
111
111
|
import { RetryAgent } from "undici";
|
|
112
112
|
import { getDebugFlags } from "../debug";
|
|
113
113
|
import { ProjectHelper } from "../model/project_helper";
|
|
114
|
+
import type { TreeSnapshotStat } from "./tree_snapshot_store";
|
|
115
|
+
import { TreeSnapshotStore } from "./tree_snapshot_store";
|
|
116
|
+
|
|
117
|
+
/** How long shutdown waits for close-boundary snapshot writes that are already running. Long
|
|
118
|
+
* enough for a ten-megabyte encode and write on ordinary storage, short enough that a wedged
|
|
119
|
+
* filesystem does not hold the quit open. */
|
|
120
|
+
const SNAPSHOT_DRAIN_TIMEOUT_MS = 5_000;
|
|
114
121
|
|
|
115
122
|
export interface MiddleLayerEnvironment {
|
|
116
123
|
dispose(): Promise<void>;
|
|
@@ -129,6 +136,9 @@ export interface MiddleLayerEnvironment {
|
|
|
129
136
|
readonly driverKit: MiddleLayerDriverKit;
|
|
130
137
|
readonly serviceRegistry: ModelServiceRegistry;
|
|
131
138
|
readonly projectHelper: ProjectHelper;
|
|
139
|
+
/** Persisted project tree mirrors. Undefined when snapshots are switched off, or when the
|
|
140
|
+
* client is impersonating another user, in which case nothing is read or written. */
|
|
141
|
+
readonly treeSnapshots?: TreeSnapshotStore;
|
|
132
142
|
}
|
|
133
143
|
|
|
134
144
|
/**
|
|
@@ -1132,6 +1142,36 @@ export class MiddleLayer {
|
|
|
1132
1142
|
|
|
1133
1143
|
private readonly openedProjects = new Map<ProjectId, Project>();
|
|
1134
1144
|
|
|
1145
|
+
/** Snapshot writes started by {@link closeProject} and not yet finished. Held only so
|
|
1146
|
+
* {@link close} can give them a bounded chance to land. */
|
|
1147
|
+
private readonly pendingSnapshotWrites = new Set<Promise<void>>();
|
|
1148
|
+
|
|
1149
|
+
private trackSnapshotWrite(write: Promise<void>): void {
|
|
1150
|
+
this.pendingSnapshotWrites.add(write);
|
|
1151
|
+
void write.finally(() => this.pendingSnapshotWrites.delete(write));
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** Waits for close-boundary snapshot writes that are already running, up to `timeoutMs`.
|
|
1155
|
+
*
|
|
1156
|
+
* This starts no work: quitting still performs no snapshot of its own. It only lets a write
|
|
1157
|
+
* that a project close already began finish, so closing a project and immediately quitting
|
|
1158
|
+
* does not routinely lose it. Bounded, because a wedged filesystem must not hang the quit,
|
|
1159
|
+
* and losing the write costs one cold open rather than any correctness. */
|
|
1160
|
+
private async drainSnapshotWrites(timeoutMs: number): Promise<void> {
|
|
1161
|
+
if (this.pendingSnapshotWrites.size === 0) return;
|
|
1162
|
+
|
|
1163
|
+
let timer: NodeJS.Timeout | undefined;
|
|
1164
|
+
const expiry = new Promise<void>((resolve) => {
|
|
1165
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
1166
|
+
timer.unref?.();
|
|
1167
|
+
});
|
|
1168
|
+
try {
|
|
1169
|
+
await Promise.race([Promise.allSettled(this.pendingSnapshotWrites), expiry]);
|
|
1170
|
+
} finally {
|
|
1171
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1135
1175
|
/** Opens a project, and starts corresponding project maintenance loop. */
|
|
1136
1176
|
public async openProject(id: ProjectId): Promise<void> {
|
|
1137
1177
|
if (this.openedProjects.has(id)) throw new Error(`Project ${id} already opened`);
|
|
@@ -1145,6 +1185,16 @@ export class MiddleLayer {
|
|
|
1145
1185
|
const prj = this.openedProjects.get(id);
|
|
1146
1186
|
if (prj === undefined) throw new Error(`Project ${id} not found among opened projects`);
|
|
1147
1187
|
this.openedProjects.delete(id);
|
|
1188
|
+
|
|
1189
|
+
// Snapshot before destroy, and here rather than inside destroy(): destroy() is also what
|
|
1190
|
+
// application shutdown runs, and quitting should perform no snapshot work. Terminating the
|
|
1191
|
+
// tree invalidates it, so the state has to be taken first either way.
|
|
1192
|
+
//
|
|
1193
|
+
// Started, not awaited. The capture happens synchronously inside, which is the part that
|
|
1194
|
+
// needs the tree alive; the encode and write are up to ten megabytes of work that closing a
|
|
1195
|
+
// project should not sit behind. Kept so shutdown can drain it.
|
|
1196
|
+
this.trackSnapshotWrite(prj.snapshotOnClose());
|
|
1197
|
+
|
|
1148
1198
|
await prj.destroy();
|
|
1149
1199
|
this.openedProjectsList.setValue([...this.openedProjects.keys()]);
|
|
1150
1200
|
}
|
|
@@ -1161,6 +1211,13 @@ export class MiddleLayer {
|
|
|
1161
1211
|
return this.openedProjects.has(id);
|
|
1162
1212
|
}
|
|
1163
1213
|
|
|
1214
|
+
/** Counters for the persisted project tree mirrors, or undefined when they are switched off.
|
|
1215
|
+
* Reads and hits are what show whether a reopen was actually warm, and the miss breakdown
|
|
1216
|
+
* says why it was not. */
|
|
1217
|
+
public get treeSnapshotStats(): Readonly<TreeSnapshotStat> | undefined {
|
|
1218
|
+
return this.env.treeSnapshots?.getStats();
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1164
1221
|
/**
|
|
1165
1222
|
* Deallocates all runtime resources consumed by this object and awaits
|
|
1166
1223
|
* actual termination of event loops and other processes associated with
|
|
@@ -1176,6 +1233,7 @@ export class MiddleLayer {
|
|
|
1176
1233
|
this.sharingStateTree.terminate(),
|
|
1177
1234
|
this.pendingSharesTree.terminate(),
|
|
1178
1235
|
]);
|
|
1236
|
+
await this.drainSnapshotWrites(SNAPSHOT_DRAIN_TIMEOUT_MS);
|
|
1179
1237
|
await this.env.dispose();
|
|
1180
1238
|
await this.pl.close();
|
|
1181
1239
|
}
|
|
@@ -1283,6 +1341,24 @@ export class MiddleLayer {
|
|
|
1283
1341
|
|
|
1284
1342
|
const serviceRegistry = createModelServiceRegistry({ logger });
|
|
1285
1343
|
|
|
1344
|
+
const treeSnapshots = TreeSnapshotStore.create(pl, {
|
|
1345
|
+
dir: ops.treeSnapshotPath,
|
|
1346
|
+
maxSizeBytes: ops.treeSnapshotOps.maxSizeBytes,
|
|
1347
|
+
enabled: ops.treeSnapshotOps.enabled,
|
|
1348
|
+
logger,
|
|
1349
|
+
});
|
|
1350
|
+
if (ops.treeSnapshotOps.enabled) {
|
|
1351
|
+
// Housekeeping before any project opens: drop snapshots from other builds, backends and
|
|
1352
|
+
// users, then trim to the ceiling.
|
|
1353
|
+
await treeSnapshots?.evict();
|
|
1354
|
+
} else {
|
|
1355
|
+
// Switched off, so reclaim what earlier sessions left on disk. The reason to reach for
|
|
1356
|
+
// this switch is usually the disk itself, and leaving the files behind would answer the
|
|
1357
|
+
// wrong half of that complaint. Keyed on the setting, not on the store being absent: it
|
|
1358
|
+
// is also absent for an impersonated client, whose session must not delete anything.
|
|
1359
|
+
await TreeSnapshotStore.purge(ops.treeSnapshotPath, logger);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1286
1362
|
const env: MiddleLayerEnvironment = {
|
|
1287
1363
|
pl,
|
|
1288
1364
|
blockEventDispatcher: new BlockEventDispatcher(),
|
|
@@ -1303,6 +1379,7 @@ export class MiddleLayer {
|
|
|
1303
1379
|
serviceRegistry,
|
|
1304
1380
|
quickJs,
|
|
1305
1381
|
projectHelper: new ProjectHelper(quickJs, logger),
|
|
1382
|
+
treeSnapshots,
|
|
1306
1383
|
dispose: async () => {
|
|
1307
1384
|
await serviceRegistry.dispose();
|
|
1308
1385
|
await retryHttpDispatcher.destroy();
|
package/src/middle_layer/ops.ts
CHANGED
|
@@ -222,6 +222,40 @@ export type DriverKitOpsConstructor = Omit<
|
|
|
222
222
|
export type MiddleLayerOpsPaths = DriverKitOpsPaths & {
|
|
223
223
|
/** Common root where to put frontend code. */
|
|
224
224
|
readonly frontendDownloadPath: string;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Directory holding persisted project tree mirrors, one file per project. Like
|
|
228
|
+
* {@link DriverKitOpsPaths.parquetCachePath} and unlike the spill directories, it is NOT
|
|
229
|
+
* emptied on startup: surviving a restart is the entire point. It is pruned instead, see
|
|
230
|
+
* {@link TreeSnapshotOps.maxSizeBytes}.
|
|
231
|
+
*/
|
|
232
|
+
readonly treeSnapshotPath: string;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/** Tuning for the persisted project tree mirrors. Their directory is
|
|
236
|
+
* {@link MiddleLayerOpsPaths.treeSnapshotPath}; this carries the behaviour knobs. */
|
|
237
|
+
export type TreeSnapshotOps = {
|
|
238
|
+
/**
|
|
239
|
+
* Whether project tree mirrors are persisted and restored at all.
|
|
240
|
+
*
|
|
241
|
+
* On by default. This is an operational kill switch, for a deployment where the cache
|
|
242
|
+
* directory turns out to be unwritable or otherwise troublesome, not a rollout gate: the
|
|
243
|
+
* floor of the feature is current behaviour, since a cache that never hits is a cold open.
|
|
244
|
+
*/
|
|
245
|
+
readonly enabled: boolean;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Minimum wall-clock gap between periodic writes for one project.
|
|
249
|
+
*
|
|
250
|
+
* Can be generous, because a stale snapshot is less complete rather than wrong: final
|
|
251
|
+
* resources never change and are never refetched, so this only bounds how much recent work
|
|
252
|
+
* comes back from the non-final frontier on restore.
|
|
253
|
+
*/
|
|
254
|
+
readonly writeInterval: number;
|
|
255
|
+
|
|
256
|
+
/** Total bytes the snapshot directory may occupy after startup eviction. Needed because a
|
|
257
|
+
* heavy project runs to roughly ten megabytes. */
|
|
258
|
+
readonly maxSizeBytes: number;
|
|
225
259
|
};
|
|
226
260
|
|
|
227
261
|
/** Debug options for middle layer. */
|
|
@@ -253,6 +287,10 @@ export type MiddleLayerOpsSettings = DriverKitOpsSettings & {
|
|
|
253
287
|
* `sharedAt + envelopeTtlMs`. Share-with-everybody envelopes never expire
|
|
254
288
|
* (`expiresAt: null`) and ignore this. */
|
|
255
289
|
readonly envelopeTtlMs: number;
|
|
290
|
+
|
|
291
|
+
/** Settings for persisting project tree mirrors to disk, so reopening a project transfers
|
|
292
|
+
* what changed rather than the tree again. */
|
|
293
|
+
readonly treeSnapshotOps: TreeSnapshotOps;
|
|
256
294
|
};
|
|
257
295
|
|
|
258
296
|
export type MiddleLayerOps = MiddleLayerOpsSettings & MiddleLayerOpsPaths;
|
|
@@ -266,6 +304,7 @@ export const DefaultMiddleLayerOpsSettings: Pick<
|
|
|
266
304
|
| "devBlockUpdateRecheckInterval"
|
|
267
305
|
| "debugOps"
|
|
268
306
|
| "envelopeTtlMs"
|
|
307
|
+
| "treeSnapshotOps"
|
|
269
308
|
> = {
|
|
270
309
|
...DefaultDriverKitOpsSettings,
|
|
271
310
|
defaultTreeOptions: {
|
|
@@ -279,17 +318,23 @@ export const DefaultMiddleLayerOpsSettings: Pick<
|
|
|
279
318
|
devBlockUpdateRecheckInterval: 1000,
|
|
280
319
|
projectRefreshInterval: 2000,
|
|
281
320
|
envelopeTtlMs: 14 * 24 * 3600 * 1000, // 14 days
|
|
321
|
+
treeSnapshotOps: {
|
|
322
|
+
enabled: true,
|
|
323
|
+
writeInterval: 5 * 60 * 1000, // 5 minutes
|
|
324
|
+
maxSizeBytes: 256 * 1024 * 1024, // 256 MB, roughly 25 heavy projects
|
|
325
|
+
},
|
|
282
326
|
};
|
|
283
327
|
|
|
284
328
|
export function DefaultMiddleLayerOpsPaths(
|
|
285
329
|
workDir: string,
|
|
286
330
|
): Pick<
|
|
287
331
|
MiddleLayerOpsPaths,
|
|
288
|
-
keyof ReturnType<typeof DefaultDriverKitOpsPaths> | "frontendDownloadPath"
|
|
332
|
+
keyof ReturnType<typeof DefaultDriverKitOpsPaths> | "frontendDownloadPath" | "treeSnapshotPath"
|
|
289
333
|
> {
|
|
290
334
|
return {
|
|
291
335
|
...DefaultDriverKitOpsPaths(workDir),
|
|
292
336
|
frontendDownloadPath: path.join(workDir, "frontend"),
|
|
337
|
+
treeSnapshotPath: path.join(workDir, "treeSnapshots"),
|
|
293
338
|
};
|
|
294
339
|
}
|
|
295
340
|
|