@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,331 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "vitest";
|
|
2
|
+
import type { PlClient, SignedResourceId } from "@milaboratories/pl-client";
|
|
3
|
+
import {
|
|
4
|
+
createSignedResourceId,
|
|
5
|
+
parseSignedResourceId,
|
|
6
|
+
toResourceSignature,
|
|
7
|
+
} from "@milaboratories/pl-client";
|
|
8
|
+
import type { PersistedTree } from "@milaboratories/pl-tree";
|
|
9
|
+
import type { MiLogger } from "@milaboratories/ts-helpers";
|
|
10
|
+
import fsp from "node:fs/promises";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { TreeSnapshotStore } from "./tree_snapshot_store";
|
|
14
|
+
|
|
15
|
+
const silent: MiLogger = { info: () => {}, warn: () => {}, error: () => {} };
|
|
16
|
+
|
|
17
|
+
const sig = (hex: string) => toResourceSignature(Buffer.from(hex, "hex"));
|
|
18
|
+
|
|
19
|
+
/** Only the fields the store reads. */
|
|
20
|
+
function fakeClient(
|
|
21
|
+
ops: { host?: string; user?: string | null; asUser?: string; instanceId?: string } = {},
|
|
22
|
+
): PlClient {
|
|
23
|
+
return {
|
|
24
|
+
conf: { hostAndPort: ops.host ?? "localhost:6345", asUser: ops.asUser },
|
|
25
|
+
serverInfo: { instanceId: ops.instanceId ?? "instance-1" },
|
|
26
|
+
authUser: ops.user === undefined ? "someone@example.com" : ops.user,
|
|
27
|
+
} as unknown as PlClient;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A snapshot with roots and no resources: enough to exercise the store, since the codec is
|
|
31
|
+
* tested against real trees in pl-tree. */
|
|
32
|
+
function snapshotFor(root: SignedResourceId): PersistedTree {
|
|
33
|
+
return {
|
|
34
|
+
witness: parseSignedResourceId(root).signature,
|
|
35
|
+
roots: [root],
|
|
36
|
+
resources: [],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let dir: string;
|
|
41
|
+
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
dir = await fsp.mkdtemp(path.join(os.tmpdir(), "tree-snapshots-"));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function storeIn(
|
|
47
|
+
dirPath: string = dir,
|
|
48
|
+
ops: { maxSizeBytes?: number; enabled?: boolean; client?: PlClient } = {},
|
|
49
|
+
): TreeSnapshotStore | undefined {
|
|
50
|
+
return TreeSnapshotStore.create(ops.client ?? fakeClient(), {
|
|
51
|
+
dir: dirPath,
|
|
52
|
+
maxSizeBytes: ops.maxSizeBytes ?? 256 * 1024 * 1024,
|
|
53
|
+
enabled: ops.enabled ?? true,
|
|
54
|
+
logger: silent,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const rootA = createSignedResourceId(1001n, sig("aaaa"));
|
|
59
|
+
const rootB = createSignedResourceId(1002n, sig("bbbb"));
|
|
60
|
+
|
|
61
|
+
async function files(): Promise<string[]> {
|
|
62
|
+
return (await fsp.readdir(dir)).sort();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("when the store should not exist at all", () => {
|
|
66
|
+
test("disabled by configuration", () => {
|
|
67
|
+
expect(storeIn(dir, { enabled: false })).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("client is impersonating another user", () => {
|
|
71
|
+
// Reading or writing here would leave another user's mirror at rest under the admin's
|
|
72
|
+
// identity, so nothing is persisted for an impersonated client.
|
|
73
|
+
const client = fakeClient({ asUser: "someone-else@example.com" });
|
|
74
|
+
expect(storeIn(dir, { client })).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("purge", () => {
|
|
79
|
+
test("removes our files, so turning the switch off reclaims the disk", async () => {
|
|
80
|
+
const store = storeIn()!;
|
|
81
|
+
await store.write(rootA, snapshotFor(rootA));
|
|
82
|
+
await store.write(rootB, snapshotFor(rootB));
|
|
83
|
+
expect(await files()).toHaveLength(2);
|
|
84
|
+
|
|
85
|
+
await TreeSnapshotStore.purge(dir, silent);
|
|
86
|
+
await expect(fsp.stat(dir)).rejects.toThrow();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("leaves anything that is not ours, and the directory holding it", async () => {
|
|
90
|
+
const store = storeIn()!;
|
|
91
|
+
await store.write(rootA, snapshotFor(rootA));
|
|
92
|
+
// The path is caller-supplied, so a misconfigured one must not take a stranger's files
|
|
93
|
+
// with it.
|
|
94
|
+
await fsp.writeFile(path.join(dir, "someone-elses.txt"), "not ours");
|
|
95
|
+
|
|
96
|
+
await TreeSnapshotStore.purge(dir, silent);
|
|
97
|
+
|
|
98
|
+
expect(await files()).toStrictEqual(["someone-elses.txt"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("is quiet about a directory that is not there", async () => {
|
|
102
|
+
await expect(
|
|
103
|
+
TreeSnapshotStore.purge(path.join(dir, "never-existed"), silent),
|
|
104
|
+
).resolves.toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("reporting failure", () => {
|
|
109
|
+
test("a failed write says so, rather than reporting a phantom success", async () => {
|
|
110
|
+
// A file where the directory should be, so every write fails.
|
|
111
|
+
const occupied = path.join(dir, "occupied");
|
|
112
|
+
await fsp.writeFile(occupied, "in the way");
|
|
113
|
+
const store = storeIn(occupied)!;
|
|
114
|
+
|
|
115
|
+
expect(await store.write(rootA, snapshotFor(rootA))).toBe(false);
|
|
116
|
+
expect(store.getStats().writeFailures).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("a successful write says so", async () => {
|
|
120
|
+
const store = storeIn()!;
|
|
121
|
+
expect(await store.write(rootA, snapshotFor(rootA))).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("an unreadable file is not reported as absent", async () => {
|
|
125
|
+
const store = storeIn()!;
|
|
126
|
+
await store.write(rootA, snapshotFor(rootA));
|
|
127
|
+
|
|
128
|
+
// Replace the file with a directory: present, but unopenable.
|
|
129
|
+
const [name] = await files();
|
|
130
|
+
const file = path.join(dir, name);
|
|
131
|
+
await fsp.rm(file);
|
|
132
|
+
await fsp.mkdir(file);
|
|
133
|
+
|
|
134
|
+
const read = await store.read(rootA);
|
|
135
|
+
expect(read).toStrictEqual({ ok: false, miss: "unreadable" });
|
|
136
|
+
expect(store.getStats().misses.absent).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("round trip", () => {
|
|
141
|
+
test("a written snapshot reads back", async () => {
|
|
142
|
+
const store = storeIn()!;
|
|
143
|
+
await store.write(rootA, snapshotFor(rootA));
|
|
144
|
+
|
|
145
|
+
const read = await store.read(rootA);
|
|
146
|
+
expect(read.ok).toBe(true);
|
|
147
|
+
if (!read.ok) throw new Error("unreachable");
|
|
148
|
+
expect(read.tree.roots).toStrictEqual([rootA]);
|
|
149
|
+
|
|
150
|
+
expect(store.getStats().writes).toBe(1);
|
|
151
|
+
expect(store.getStats().hits).toBe(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("nothing written means an absent miss", async () => {
|
|
155
|
+
const store = storeIn()!;
|
|
156
|
+
expect(await store.read(rootA)).toStrictEqual({ ok: false, miss: "absent" });
|
|
157
|
+
expect(store.getStats().misses.absent).toBe(1);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("one file per project, rewritten in place", async () => {
|
|
161
|
+
const store = storeIn()!;
|
|
162
|
+
await store.write(rootA, snapshotFor(rootA));
|
|
163
|
+
await store.write(rootA, snapshotFor(rootA));
|
|
164
|
+
await store.write(rootB, snapshotFor(rootB));
|
|
165
|
+
|
|
166
|
+
expect(await files()).toHaveLength(2);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("a successful write leaves no staging file behind", async () => {
|
|
170
|
+
const store = storeIn()!;
|
|
171
|
+
await store.write(rootA, snapshotFor(rootA));
|
|
172
|
+
expect((await files()).filter((f) => f.includes(".tmp."))).toStrictEqual([]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("discard removes the file", async () => {
|
|
176
|
+
const store = storeIn()!;
|
|
177
|
+
await store.write(rootA, snapshotFor(rootA));
|
|
178
|
+
await store.discard(rootA);
|
|
179
|
+
|
|
180
|
+
expect(await files()).toStrictEqual([]);
|
|
181
|
+
expect(await store.read(rootA)).toStrictEqual({ ok: false, miss: "absent" });
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("the session witness", () => {
|
|
186
|
+
test("a rotated signature is a miss, and the file is kept", async () => {
|
|
187
|
+
const store = storeIn()!;
|
|
188
|
+
await store.write(rootA, snapshotFor(rootA));
|
|
189
|
+
|
|
190
|
+
// Same resource, next session: same global id, different signature. The file is addressed
|
|
191
|
+
// by global id, so this is the same file, and only the witness distinguishes them.
|
|
192
|
+
const rotated = createSignedResourceId(1001n, sig("cccc"));
|
|
193
|
+
expect(await store.read(rotated)).toStrictEqual({ ok: false, miss: "session-rotated" });
|
|
194
|
+
|
|
195
|
+
// Kept on purpose: the bodies stay valid, only the signatures died, so a future signature
|
|
196
|
+
// refresh would have something to repair.
|
|
197
|
+
expect(await files()).toHaveLength(1);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("a snapshot that cannot be read", () => {
|
|
202
|
+
test("a truncated file misses without raising", async () => {
|
|
203
|
+
const store = storeIn()!;
|
|
204
|
+
await store.write(rootA, snapshotFor(rootA));
|
|
205
|
+
|
|
206
|
+
const [name] = await files();
|
|
207
|
+
const file = path.join(dir, name);
|
|
208
|
+
const bytes = await fsp.readFile(file);
|
|
209
|
+
await fsp.writeFile(file, bytes.subarray(0, bytes.length - 6));
|
|
210
|
+
|
|
211
|
+
const read = await store.read(rootA);
|
|
212
|
+
expect(read.ok).toBe(false);
|
|
213
|
+
if (read.ok) throw new Error("unreachable");
|
|
214
|
+
expect(["truncated", "checksum"]).toContain(read.miss);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("a foreign file in our own filename misses without raising", async () => {
|
|
218
|
+
const store = storeIn()!;
|
|
219
|
+
await store.write(rootA, snapshotFor(rootA));
|
|
220
|
+
const [name] = await files();
|
|
221
|
+
await fsp.writeFile(path.join(dir, name), "not a snapshot at all");
|
|
222
|
+
|
|
223
|
+
expect(await store.read(rootA)).toStrictEqual({ ok: false, miss: "not-a-snapshot" });
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("the key", () => {
|
|
228
|
+
test("another backend does not see this one's snapshots", async () => {
|
|
229
|
+
await storeIn()!.write(rootA, snapshotFor(rootA));
|
|
230
|
+
|
|
231
|
+
const other = storeIn(dir, { client: fakeClient({ host: "elsewhere:6345" }) })!;
|
|
232
|
+
expect(await other.read(rootA)).toStrictEqual({ ok: false, miss: "absent" });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("another user does not see this one's snapshots", async () => {
|
|
236
|
+
await storeIn()!.write(rootA, snapshotFor(rootA));
|
|
237
|
+
|
|
238
|
+
const other = storeIn(dir, { client: fakeClient({ user: "other@example.com" }) })!;
|
|
239
|
+
expect(await other.read(rootA)).toStrictEqual({ ok: false, miss: "absent" });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("a backend that reset its database does not see the old state's snapshots", async () => {
|
|
243
|
+
await storeIn()!.write(rootA, snapshotFor(rootA));
|
|
244
|
+
|
|
245
|
+
// Same address, same user, new instance: global ids are reused after a reset, so the
|
|
246
|
+
// address alone would be a hit against a tree that no longer exists.
|
|
247
|
+
const reset = storeIn(dir, { client: fakeClient({ instanceId: "instance-2" }) })!;
|
|
248
|
+
expect(await reset.read(rootA)).toStrictEqual({ ok: false, miss: "absent" });
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("eviction", () => {
|
|
253
|
+
test("drops what is not addressed to the current scope", async () => {
|
|
254
|
+
const store = storeIn()!;
|
|
255
|
+
await store.write(rootA, snapshotFor(rootA));
|
|
256
|
+
|
|
257
|
+
// A snapshot from another build, and a staging file from a write that was killed before
|
|
258
|
+
// its rename. Neither can ever be read again.
|
|
259
|
+
await fsp.writeFile(path.join(dir, "tree.1.otherbuild.0123456789abcdef.99.plts"), "old");
|
|
260
|
+
await fsp.writeFile(path.join(dir, "tree.1.thisbuild.0123456789abcdef.99.plts.tmp.ab"), "torn");
|
|
261
|
+
|
|
262
|
+
await store.evict();
|
|
263
|
+
|
|
264
|
+
expect(await files()).toHaveLength(1);
|
|
265
|
+
expect((await store.read(rootA)).ok).toBe(true);
|
|
266
|
+
expect(store.getStats().evicted).toBe(2);
|
|
267
|
+
expect(store.getStats().evictedForSize).toBe(0);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("leaves files that are not ours, whatever the directory holds", async () => {
|
|
271
|
+
const store = storeIn()!;
|
|
272
|
+
await store.write(rootA, snapshotFor(rootA));
|
|
273
|
+
// `treeSnapshotPath` is caller-supplied: pointed at an existing or shared directory,
|
|
274
|
+
// startup housekeeping must not take a stranger's files with it.
|
|
275
|
+
await fsp.writeFile(path.join(dir, "someone-elses.txt"), "not ours");
|
|
276
|
+
await fsp.writeFile(path.join(dir, "tree.txt"), "shares our prefix, not our suffix");
|
|
277
|
+
|
|
278
|
+
await store.evict();
|
|
279
|
+
|
|
280
|
+
expect(await files()).toContain("someone-elses.txt");
|
|
281
|
+
expect(await files()).toContain("tree.txt");
|
|
282
|
+
expect(store.getStats().evicted).toBe(0);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("keeps everything when under the ceiling", async () => {
|
|
286
|
+
const store = storeIn()!;
|
|
287
|
+
await store.write(rootA, snapshotFor(rootA));
|
|
288
|
+
await store.write(rootB, snapshotFor(rootB));
|
|
289
|
+
|
|
290
|
+
await store.evict();
|
|
291
|
+
expect(await files()).toHaveLength(2);
|
|
292
|
+
expect(store.getStats().evicted).toBe(0);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("trims to the ceiling, least recently written first", async () => {
|
|
296
|
+
const store = storeIn()!;
|
|
297
|
+
await store.write(rootA, snapshotFor(rootA));
|
|
298
|
+
await store.write(rootB, snapshotFor(rootB));
|
|
299
|
+
|
|
300
|
+
const names = await files();
|
|
301
|
+
const sizes = await Promise.all(names.map((n) => fsp.stat(path.join(dir, n))));
|
|
302
|
+
const perFile = Math.max(...sizes.map((s) => s.size));
|
|
303
|
+
|
|
304
|
+
// Age rootA's file so recency is unambiguous rather than dependent on write order timing.
|
|
305
|
+
const old = new Date(Date.now() - 60 * 60 * 1000);
|
|
306
|
+
const rootAFile = path.join(dir, names.find((n) => n.endsWith(".1001.plts"))!);
|
|
307
|
+
await fsp.utimes(rootAFile, old, old);
|
|
308
|
+
|
|
309
|
+
// Room for one file only.
|
|
310
|
+
const tight = storeIn(dir, { maxSizeBytes: perFile })!;
|
|
311
|
+
await tight.evict();
|
|
312
|
+
|
|
313
|
+
expect((await tight.read(rootA)).ok).toBe(false);
|
|
314
|
+
expect((await tight.read(rootB)).ok).toBe(true);
|
|
315
|
+
expect(tight.getStats().evictedForSize).toBe(1);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("an unusable directory costs the cache, not the startup", async () => {
|
|
319
|
+
// A path that cannot be a directory, because a file already occupies it.
|
|
320
|
+
const occupied = path.join(dir, "occupied");
|
|
321
|
+
await fsp.writeFile(occupied, "in the way");
|
|
322
|
+
|
|
323
|
+
const store = storeIn(occupied)!;
|
|
324
|
+
await expect(store.evict()).resolves.toBeUndefined();
|
|
325
|
+
await expect(store.write(rootA, snapshotFor(rootA))).resolves.toBe(false);
|
|
326
|
+
expect(store.getStats().writeFailures).toBe(1);
|
|
327
|
+
// "unreadable", not "absent": the directory is broken rather than empty, and that is the
|
|
328
|
+
// distinction someone reading the counters needs.
|
|
329
|
+
expect(await store.read(rootA)).toStrictEqual({ ok: false, miss: "unreadable" });
|
|
330
|
+
});
|
|
331
|
+
});
|