@milaboratories/pl-middle-layer 1.67.6 → 1.68.1

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.
Files changed (46) hide show
  1. package/dist/index.cjs +1 -0
  2. package/dist/index.d.ts +4 -3
  3. package/dist/index.js +2 -2
  4. package/dist/middle_layer/build_stamp.cjs +34 -0
  5. package/dist/middle_layer/build_stamp.cjs.map +1 -0
  6. package/dist/middle_layer/build_stamp.js +34 -0
  7. package/dist/middle_layer/build_stamp.js.map +1 -0
  8. package/dist/middle_layer/index.cjs +1 -0
  9. package/dist/middle_layer/index.d.ts +4 -3
  10. package/dist/middle_layer/index.js +2 -2
  11. package/dist/middle_layer/middle_layer.cjs +48 -0
  12. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  13. package/dist/middle_layer/middle_layer.d.ts +19 -0
  14. package/dist/middle_layer/middle_layer.d.ts.map +1 -1
  15. package/dist/middle_layer/middle_layer.js +48 -0
  16. package/dist/middle_layer/middle_layer.js.map +1 -1
  17. package/dist/middle_layer/ops.cjs +8 -2
  18. package/dist/middle_layer/ops.cjs.map +1 -1
  19. package/dist/middle_layer/ops.d.ts +35 -2
  20. package/dist/middle_layer/ops.d.ts.map +1 -1
  21. package/dist/middle_layer/ops.js +8 -2
  22. package/dist/middle_layer/ops.js.map +1 -1
  23. package/dist/middle_layer/project.cjs +163 -8
  24. package/dist/middle_layer/project.cjs.map +1 -1
  25. package/dist/middle_layer/project.d.ts +51 -1
  26. package/dist/middle_layer/project.d.ts.map +1 -1
  27. package/dist/middle_layer/project.js +165 -11
  28. package/dist/middle_layer/project.js.map +1 -1
  29. package/dist/middle_layer/project_list.d.ts +1 -0
  30. package/dist/middle_layer/project_list.d.ts.map +1 -1
  31. package/dist/middle_layer/tree_snapshot_store.cjs +283 -0
  32. package/dist/middle_layer/tree_snapshot_store.cjs.map +1 -0
  33. package/dist/middle_layer/tree_snapshot_store.d.ts +143 -0
  34. package/dist/middle_layer/tree_snapshot_store.d.ts.map +1 -0
  35. package/dist/middle_layer/tree_snapshot_store.js +280 -0
  36. package/dist/middle_layer/tree_snapshot_store.js.map +1 -0
  37. package/package.json +12 -12
  38. package/src/middle_layer/build_stamp.ts +36 -0
  39. package/src/middle_layer/index.ts +2 -1
  40. package/src/middle_layer/middle_layer.ts +77 -0
  41. package/src/middle_layer/ops.ts +46 -1
  42. package/src/middle_layer/project.ts +234 -14
  43. package/src/middle_layer/project_failsafe.test.ts +47 -0
  44. package/src/middle_layer/tree_snapshot_scenarios.test.ts +337 -0
  45. package/src/middle_layer/tree_snapshot_store.test.ts +331 -0
  46. package/src/middle_layer/tree_snapshot_store.ts +419 -0
@@ -0,0 +1,419 @@
1
+ import type { PersistedTree, PersistedTreeReadFailure } from "@milaboratories/pl-tree";
2
+ import {
3
+ decodePersistedTree,
4
+ encodePersistedTree,
5
+ PERSISTED_TREE_SCHEMA_VERSION,
6
+ readPersistedTreeHeader,
7
+ } from "@milaboratories/pl-tree";
8
+ import type { PlClient, SignedResourceId } from "@milaboratories/pl-client";
9
+ import { parseSignedResourceId } from "@milaboratories/pl-client";
10
+ import type { MiLogger } from "@milaboratories/ts-helpers";
11
+ import { createPathAtomically, ensureDirExists } from "@milaboratories/ts-helpers";
12
+ import { createHash } from "node:crypto";
13
+ import fsp from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { ML_BUILD_STAMP } from "./build_stamp";
16
+
17
+ /** Why a read did not produce a tree to restore from. Counted rather than inferred, because
18
+ * "no snapshot" and "a snapshot we refused" are very different things when a warm reopen
19
+ * fails to be warm and someone has to work out why. */
20
+ export type TreeSnapshotMiss =
21
+ /** No file for this key: a first open, or the key moved (new build, new backend, new user). */
22
+ | "absent"
23
+ /** A file is there but could not be opened at all: permissions, a bad mount, an I/O error.
24
+ * Distinct from `absent` because a first open and a broken cache directory need different
25
+ * answers from whoever reads the counters. */
26
+ | "unreadable"
27
+ /** File exists, but its signatures belong to a session that has ended. Kept, not deleted. */
28
+ | "session-rotated"
29
+ /** File exists and could not be read. Carries the codec's reason. */
30
+ | PersistedTreeReadFailure;
31
+
32
+ export type TreeSnapshotStat = {
33
+ reads: number;
34
+ /** Snapshots read successfully. A hit is not yet a warm open: the tree can still refuse to
35
+ * apply it, which is what {@link restores} counts. */
36
+ hits: number;
37
+ /** Snapshots actually applied as a tree's initial state. This is the number that says a
38
+ * reopen was warm. */
39
+ restores: number;
40
+ /** Miss counts by reason. */
41
+ misses: Record<TreeSnapshotMiss, number>;
42
+ writes: number;
43
+ writeFailures: number;
44
+ bytesWritten: number;
45
+ /** Snapshots deleted by the fail-safe after a restored tree failed its first refresh. */
46
+ discarded: number;
47
+ /** Files removed at startup, and how many of those were dropped for being over the ceiling
48
+ * rather than for belonging to another build, backend or user. */
49
+ evicted: number;
50
+ evictedForSize: number;
51
+ bytesEvicted: number;
52
+ millisReading: number;
53
+ millisWriting: number;
54
+ };
55
+
56
+ function initialStat(): TreeSnapshotStat {
57
+ return {
58
+ reads: 0,
59
+ hits: 0,
60
+ restores: 0,
61
+ misses: {
62
+ absent: 0,
63
+ unreadable: 0,
64
+ "session-rotated": 0,
65
+ "not-a-snapshot": 0,
66
+ "unknown-schema": 0,
67
+ truncated: 0,
68
+ checksum: 0,
69
+ malformed: 0,
70
+ },
71
+ writes: 0,
72
+ writeFailures: 0,
73
+ bytesWritten: 0,
74
+ discarded: 0,
75
+ evicted: 0,
76
+ evictedForSize: 0,
77
+ bytesEvicted: 0,
78
+ millisReading: 0,
79
+ millisWriting: 0,
80
+ };
81
+ }
82
+
83
+ export type TreeSnapshotStoreOps = {
84
+ /** Directory holding the snapshots. One file per project. */
85
+ readonly dir: string;
86
+ /** Total bytes the directory may occupy after startup eviction. */
87
+ readonly maxSizeBytes: number;
88
+ readonly logger: MiLogger;
89
+ };
90
+
91
+ const FILE_PREFIX = "tree.";
92
+ const FILE_SUFFIX = ".plts";
93
+
94
+ /** Keeps a filename to characters every filesystem we target accepts. */
95
+ function safe(part: string): string {
96
+ return part.replace(/[^A-Za-z0-9_-]/g, "_");
97
+ }
98
+
99
+ /** Names this class writes: a finished snapshot, or the staging file of a write killed before
100
+ * its rename. The directory is caller-supplied and only defaults to one of ours, so nothing
101
+ * failing this is ever deleted, by the purge or by the startup eviction. */
102
+ function isOurFile(name: string): boolean {
103
+ if (!name.startsWith(FILE_PREFIX)) return false;
104
+ return name.endsWith(FILE_SUFFIX) || name.includes(`${FILE_SUFFIX}.tmp.`);
105
+ }
106
+
107
+ /**
108
+ * Snapshots of project tree mirrors on the local filesystem.
109
+ *
110
+ * A snapshot is addressed by backend instance, authenticated user, root resource, build stamp
111
+ * and snapshot schema version. Everything except the root goes into the *scope*, which is
112
+ * fixed for the lifetime of a client; the root distinguishes one project from another, so
113
+ * there is one file per project per user per backend, rewritten in place.
114
+ *
115
+ * The session is deliberately not part of the key. It is witnessed inside the file and
116
+ * compared on read: a snapshot from an ended session is a miss, but the file is kept, because
117
+ * its bodies remain valid indefinitely and only its signatures have died. Deleting it would
118
+ * destroy the evidence a future signature refresh would repair.
119
+ */
120
+ export class TreeSnapshotStore {
121
+ private readonly stat = initialStat();
122
+
123
+ private constructor(
124
+ private readonly ops: TreeSnapshotStoreOps,
125
+ /** Identifies backend, user, build and schema. Same for every project in this session. */
126
+ private readonly scope: string,
127
+ ) {}
128
+
129
+ /**
130
+ * Builds a store for the given client, or returns undefined when nothing should be
131
+ * persisted for it.
132
+ *
133
+ * Returns undefined for an impersonated client: reading and writing under `asUser` would
134
+ * leave another user's mirror at rest under the admin's identity, usable only if the admin
135
+ * returned to that exact root. One condition removes both the hygiene question and the
136
+ * orphan one.
137
+ */
138
+ public static create(
139
+ pl: PlClient,
140
+ ops: TreeSnapshotStoreOps & { readonly enabled: boolean },
141
+ ): TreeSnapshotStore | undefined {
142
+ if (!ops.enabled) {
143
+ ops.logger.info("tree snapshots are disabled by configuration");
144
+ return undefined;
145
+ }
146
+ if (pl.conf.asUser !== undefined) {
147
+ ops.logger.info("tree snapshots are disabled while the client is opened as another user");
148
+ return undefined;
149
+ }
150
+
151
+ // The backend *instance*, not merely its address: instanceId rotates whenever the backend
152
+ // resets its database, which is exactly the case where the same address starts serving a
153
+ // different state under reused global ids. Without it, a reset at a fixed address (a local
154
+ // backend, whose working directory does not move) would be a key hit, and the only thing
155
+ // left to catch it would be the witness, which is empty on both sides on a backend that
156
+ // predates resource signatures.
157
+ //
158
+ // Hashed rather than spelled out: a login can be an email and an address can carry
159
+ // characters a filename cannot. The hash only has to be stable and to differ when any part
160
+ // differs, both of which it does. The NUL separator keeps the parts unambiguous.
161
+ const identity = createHash("sha256")
162
+ .update([pl.conf.hostAndPort, pl.serverInfo.instanceId ?? "", pl.authUser ?? ""].join("\0"))
163
+ .digest("hex")
164
+ .slice(0, 16);
165
+
166
+ const scope = `${PERSISTED_TREE_SCHEMA_VERSION}.${safe(ML_BUILD_STAMP)}.${identity}`;
167
+ return new TreeSnapshotStore(ops, scope);
168
+ }
169
+
170
+ /**
171
+ * Removes this class's files from the snapshot directory. Run when snapshots are switched
172
+ * off, so a user who turns the kill switch off because the disk is full or unwritable
173
+ * actually gets the space back, rather than leaving up to the size ceiling stranded there
174
+ * indefinitely.
175
+ *
176
+ * Deliberately keyed on the setting rather than on "there is no store": a store is also
177
+ * absent for an impersonated client, and deleting there would destroy the operator's own
178
+ * snapshots from their ordinary sessions. Never throws.
179
+ */
180
+ public static async purge(dir: string, logger: MiLogger): Promise<void> {
181
+ try {
182
+ // Deliberately NOT a recursive delete of `dir`. The path is caller-supplied and only
183
+ // defaults to a directory of ours, so removing it wholesale would let a misconfigured
184
+ // `treeSnapshotPath` take an unrelated directory with it, at the exact moment the user
185
+ // reached for a switch labelled "my disk is troublesome". Only files this class writes
186
+ // are removed, then the directory itself if that emptied it.
187
+ for (const name of await fsp.readdir(dir)) {
188
+ if (!isOurFile(name)) continue;
189
+ await fsp.rm(path.join(dir, name), { force: true }).catch(() => {});
190
+ }
191
+ await fsp.rmdir(dir).catch(() => {
192
+ // Still holds something that is not ours; leaving it is the point.
193
+ });
194
+ } catch (e: unknown) {
195
+ if ((e as NodeJS.ErrnoException | null)?.code === "ENOENT") return;
196
+ logger.warn(
197
+ `failed to clear the tree snapshot directory: ${e instanceof Error ? e.message : String(e)}`,
198
+ );
199
+ }
200
+ }
201
+
202
+ public getStats(): Readonly<TreeSnapshotStat> {
203
+ return this.stat;
204
+ }
205
+
206
+ private fileFor(root: SignedResourceId): string {
207
+ // The root's global id, not the signed form: the signature changes every session while
208
+ // the file must not.
209
+ const { globalId } = parseSignedResourceId(root);
210
+ return path.join(this.ops.dir, `${FILE_PREFIX}${this.scope}.${globalId}${FILE_SUFFIX}`);
211
+ }
212
+
213
+ /**
214
+ * Reads the snapshot for a project root, or reports why there is nothing to restore.
215
+ *
216
+ * `root` is the id as resolved in the current session. Its signature is what the stored
217
+ * witness is compared against, so a rotated session is detected without inflating the
218
+ * payload, and no separate session lookup is needed anywhere.
219
+ */
220
+ public async read(
221
+ root: SignedResourceId,
222
+ ): Promise<{ ok: true; tree: PersistedTree } | { ok: false; miss: TreeSnapshotMiss }> {
223
+ const started = Date.now();
224
+ this.stat.reads++;
225
+ try {
226
+ const file = this.fileFor(root);
227
+ let bytes: Buffer;
228
+ try {
229
+ bytes = await fsp.readFile(file);
230
+ } catch (e: unknown) {
231
+ // A missing file and an unreadable one both mean a cold open, but they are different
232
+ // problems: one is an ordinary first open, the other is a cache directory that needs
233
+ // attention, and the counters are the only place that difference is visible.
234
+ const absent = (e as NodeJS.ErrnoException | null)?.code === "ENOENT";
235
+ if (!absent)
236
+ this.ops.logger.warn(
237
+ `tree snapshot exists but could not be read: ${e instanceof Error ? e.message : String(e)}`,
238
+ );
239
+ return this.miss(absent ? "absent" : "unreadable");
240
+ }
241
+
242
+ const header = readPersistedTreeHeader(bytes);
243
+ if (!header.ok) return this.miss(header.reason);
244
+
245
+ // On a backend predating resource signatures both sides are empty and always match,
246
+ // which is right: without signatures the ids are not session-bound in the first place.
247
+ const { signature } = parseSignedResourceId(root);
248
+ if (!Buffer.from(header.value.witness).equals(Buffer.from(signature)))
249
+ // Kept, not deleted. See the class comment.
250
+ return this.miss("session-rotated");
251
+
252
+ const decoded = await decodePersistedTree(bytes);
253
+ if (!decoded.ok) return this.miss(decoded.reason);
254
+
255
+ // Touched on a hit so the modification time tracks last *use*, which is what the size
256
+ // trim is supposed to order by. Without this, a project reopened every day but never
257
+ // changed is never rewritten, and so ages out ahead of one touched once and abandoned.
258
+ const now = new Date();
259
+ await fsp.utimes(file, now, now).catch(() => {
260
+ // Ordering the trim is not worth failing a hit over.
261
+ });
262
+
263
+ this.stat.hits++;
264
+ return { ok: true, tree: decoded.value };
265
+ } finally {
266
+ this.stat.millisReading += Date.now() - started;
267
+ }
268
+ }
269
+
270
+ private miss(miss: TreeSnapshotMiss): { ok: false; miss: TreeSnapshotMiss } {
271
+ this.stat.misses[miss]++;
272
+ return { ok: false, miss };
273
+ }
274
+
275
+ /** Recorded by the caller once it knows the tree accepted the snapshot. The store cannot
276
+ * tell on its own: it hands over bytes, and whether they become a tree is the tree's call. */
277
+ public noteRestored(): void {
278
+ this.stat.restores++;
279
+ }
280
+
281
+ /**
282
+ * Writes a snapshot, replacing any previous one for the same project.
283
+ *
284
+ * Never throws and never rejects: a write is an optimisation, and a full disk or a
285
+ * permissions problem must not fail the operation that triggered it. Staged and renamed
286
+ * into place, so a process killed mid-write leaves the previous snapshot rather than a torn
287
+ * one.
288
+ */
289
+ public async write(
290
+ root: SignedResourceId,
291
+ snapshot: PersistedTree,
292
+ ops: { compress?: boolean } = {},
293
+ ): Promise<boolean> {
294
+ const started = Date.now();
295
+ try {
296
+ const bytes = await encodePersistedTree(snapshot, { compress: ops.compress });
297
+ await ensureDirExists(this.ops.dir);
298
+
299
+ const file = this.fileFor(root);
300
+ await createPathAtomically(this.ops.logger, file, async (tempPath) => {
301
+ // "wx" so a colliding temp name fails instead of overwriting another writer's file.
302
+ await fsp.writeFile(tempPath, bytes, { flag: "wx" });
303
+ });
304
+
305
+ this.stat.writes++;
306
+ this.stat.bytesWritten += bytes.length;
307
+ return true;
308
+ } catch (e: unknown) {
309
+ this.stat.writeFailures++;
310
+ this.ops.logger.warn(
311
+ `failed to write tree snapshot: ${e instanceof Error ? e.message : String(e)}`,
312
+ );
313
+ return false;
314
+ } finally {
315
+ this.stat.millisWriting += Date.now() - started;
316
+ }
317
+ }
318
+
319
+ /** Deletes the snapshot for a project. Used by the fail-safe, when a restored tree turns
320
+ * out not to match what the backend will serve. Never throws. */
321
+ public async discard(root: SignedResourceId): Promise<void> {
322
+ try {
323
+ await fsp.rm(this.fileFor(root), { force: true });
324
+ this.stat.discarded++;
325
+ } catch (e: unknown) {
326
+ this.ops.logger.warn(
327
+ `failed to discard tree snapshot: ${e instanceof Error ? e.message : String(e)}`,
328
+ );
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Startup housekeeping. Drops every snapshot outside the current scope (another build,
334
+ * backend, user or schema version), then trims what is left to the size ceiling, least
335
+ * recently written first.
336
+ *
337
+ * Modification time stands in for recency of use: an open project is rewritten
338
+ * periodically, so the file's age tracks how recently the project was worked on. Read times
339
+ * would be a truer signal but atime is unreliable across platforms and mount options.
340
+ *
341
+ * Runs at startup only, so the ceiling bounds what a session starts with rather than capping
342
+ * it throughout: a long session opening many projects can exceed it until the next launch.
343
+ *
344
+ * Never throws: an unusable cache directory should cost the cache, not the startup.
345
+ */
346
+ public async evict(): Promise<void> {
347
+ try {
348
+ await ensureDirExists(this.ops.dir);
349
+ const names = await fsp.readdir(this.ops.dir);
350
+
351
+ const current: { file: string; size: number; mtimeMs: number }[] = [];
352
+
353
+ for (const name of names) {
354
+ const file = path.join(this.ops.dir, name);
355
+
356
+ // Anything of ours not addressed to the current scope goes: another build, backend,
357
+ // user or schema version, and also the staging files of a write that was killed
358
+ // before its rename, which end in `.tmp.<hex>` rather than the suffix.
359
+ const inScope =
360
+ name.startsWith(`${FILE_PREFIX}${this.scope}.`) && name.endsWith(FILE_SUFFIX);
361
+
362
+ // Out of scope is not the same as ours to delete: a `treeSnapshotPath` pointed at an
363
+ // existing or shared directory would otherwise have every file in it removed at
364
+ // startup. Same rule as `purge`, for the same reason.
365
+ if (!inScope && !isOurFile(name)) continue;
366
+
367
+ let size = 0;
368
+ let mtimeMs = 0;
369
+ try {
370
+ const stat = await fsp.stat(file);
371
+ if (!stat.isFile()) continue;
372
+ size = stat.size;
373
+ mtimeMs = stat.mtimeMs;
374
+ } catch {
375
+ continue; // vanished under us, or unreadable: nothing to account for
376
+ }
377
+
378
+ if (inScope) {
379
+ current.push({ file, size, mtimeMs });
380
+ continue;
381
+ }
382
+
383
+ await this.remove(file, size, false);
384
+ }
385
+
386
+ const ceiling = this.ops.maxSizeBytes;
387
+ let total = current.reduce((sum, e) => sum + e.size, 0);
388
+ if (total <= ceiling) return;
389
+
390
+ // Oldest first, so the projects a user is actually working on are the ones that survive.
391
+ current.sort((a, b) => a.mtimeMs - b.mtimeMs);
392
+ for (const entry of current) {
393
+ if (total <= ceiling) break;
394
+ // Only a file that actually went stops counting against the ceiling. Subtracting
395
+ // regardless would let one undeletable file end the trim early and leave the directory
396
+ // over its limit.
397
+ if (await this.remove(entry.file, entry.size, true)) total -= entry.size;
398
+ }
399
+ } catch (e: unknown) {
400
+ this.ops.logger.warn(
401
+ `tree snapshot eviction failed: ${e instanceof Error ? e.message : String(e)}`,
402
+ );
403
+ }
404
+ }
405
+
406
+ private async remove(file: string, size: number, forSize: boolean): Promise<boolean> {
407
+ try {
408
+ await fsp.rm(file, { force: true });
409
+ this.stat.evicted++;
410
+ this.stat.bytesEvicted += size;
411
+ if (forSize) this.stat.evictedForSize++;
412
+ return true;
413
+ } catch {
414
+ // A file we cannot delete is not worth failing startup over; it will be reconsidered
415
+ // on the next one.
416
+ return false;
417
+ }
418
+ }
419
+ }