@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
|
@@ -11,7 +11,10 @@ import {
|
|
|
11
11
|
ensureSignedResourceIdNotNull,
|
|
12
12
|
field,
|
|
13
13
|
isNotFoundError,
|
|
14
|
+
isPermissionDenied,
|
|
14
15
|
isTimeoutOrCancelError,
|
|
16
|
+
isUnauthenticated,
|
|
17
|
+
parseSignedResourceId,
|
|
15
18
|
Pl,
|
|
16
19
|
resourceIdToString,
|
|
17
20
|
ResourceTypeName,
|
|
@@ -25,7 +28,12 @@ import type { BlockPackSpecAny } from "../model";
|
|
|
25
28
|
import { randomUUID } from "node:crypto";
|
|
26
29
|
import { withProject, withProjectAuthored } from "../mutator/project";
|
|
27
30
|
import type { ExtendedResourceData, PruningFunction } from "@milaboratories/pl-tree";
|
|
28
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
SynchronizedTreeState,
|
|
33
|
+
treeDumpStats,
|
|
34
|
+
TreeStateUpdateError,
|
|
35
|
+
} from "@milaboratories/pl-tree";
|
|
36
|
+
import type { TreeSnapshotStore } from "./tree_snapshot_store";
|
|
29
37
|
import { setTimeout } from "node:timers/promises";
|
|
30
38
|
import { frontendData } from "./frontend_path";
|
|
31
39
|
import type { NavigationState } from "@milaboratories/pl-model-common";
|
|
@@ -102,6 +110,21 @@ export class Project {
|
|
|
102
110
|
|
|
103
111
|
private readonly abortController = new AbortController();
|
|
104
112
|
|
|
113
|
+
/** Tree change generation as of the snapshot currently on disk, or -1 when this session has
|
|
114
|
+
* not written one. Compared against the tree's current generation to skip writing a mirror
|
|
115
|
+
* that has not moved, which is what makes a project left open and idle go quiet. */
|
|
116
|
+
private snapshotGeneration: number;
|
|
117
|
+
|
|
118
|
+
/** When a snapshot was last attempted, for the periodic write's wall-clock gate.
|
|
119
|
+
*
|
|
120
|
+
* Zero, not the construction time, so the first write lands on the first maintenance pass
|
|
121
|
+
* after the tree has settled rather than a full interval later. Sessions shorter than one
|
|
122
|
+
* interval are the common case for a desktop app that is quit with a project still open, and
|
|
123
|
+
* seeding this to now would leave every one of them with nothing on disk. Set on every
|
|
124
|
+
* attempt, successful or not, so a persistently failing write retries at the interval rather
|
|
125
|
+
* than on every pass of the loop. */
|
|
126
|
+
private lastSnapshotAt = 0;
|
|
127
|
+
|
|
105
128
|
private get destroyed() {
|
|
106
129
|
return this.abortController.signal.aborted;
|
|
107
130
|
}
|
|
@@ -111,7 +134,11 @@ export class Project {
|
|
|
111
134
|
public readonly id: ProjectId /* Project ID, exposed to outer consumers, who work with ML */,
|
|
112
135
|
readonly rid: SignedResourceId /* Contains signature, not exposed outside middle layer. */,
|
|
113
136
|
private readonly projectTree: SynchronizedTreeState,
|
|
137
|
+
/** Whether this tree was seeded from a snapshot. When it was, the file on disk already
|
|
138
|
+
* holds generation 0, so an idle warm reopen writes nothing at all. */
|
|
139
|
+
restoredFromSnapshot: boolean = false,
|
|
114
140
|
) {
|
|
141
|
+
this.snapshotGeneration = restoredFromSnapshot ? 0 : -1;
|
|
115
142
|
this.overview = projectOverview(
|
|
116
143
|
projectTree.entry(),
|
|
117
144
|
this.navigationStates,
|
|
@@ -129,6 +156,116 @@ export class Project {
|
|
|
129
156
|
return "project:" + this.id.toString();
|
|
130
157
|
}
|
|
131
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Periodic snapshot write, carried on the maintenance loop rather than a timer of its own.
|
|
161
|
+
*
|
|
162
|
+
* Gated on the tree having changed since the last snapshot, so a project left open and idle
|
|
163
|
+
* writes once and then goes quiet, and on wall clock, so a project changing continuously
|
|
164
|
+
* writes at most once per interval.
|
|
165
|
+
*/
|
|
166
|
+
private async maybeWriteSnapshot(): Promise<void> {
|
|
167
|
+
const store = this.env.treeSnapshots;
|
|
168
|
+
if (store === undefined) return;
|
|
169
|
+
|
|
170
|
+
const generation = this.projectTree.changeGeneration;
|
|
171
|
+
if (generation === this.snapshotGeneration) return;
|
|
172
|
+
if (Date.now() - this.lastSnapshotAt < this.env.ops.treeSnapshotOps.writeInterval) return;
|
|
173
|
+
|
|
174
|
+
await this.writeSnapshot(store, generation);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Starts the close-boundary snapshot and returns without waiting for the write.
|
|
179
|
+
*
|
|
180
|
+
* On top of the periodic write, since closing is a natural point to persist. Change-gated but
|
|
181
|
+
* not interval-gated: rewriting a mirror that has not moved is pure waste, but a mirror that
|
|
182
|
+
* has moved is worth keeping however recently the last write happened.
|
|
183
|
+
*
|
|
184
|
+
* The **capture is synchronous and happens here**, before the caller destroys the tree,
|
|
185
|
+
* because destroying it invalidates it and a later capture would be refused. Only the encode
|
|
186
|
+
* and the write are deferred: they are up to ten megabytes of work, and project switching
|
|
187
|
+
* should not wait for them. Deferring is safe only because a capture is a copy rather than a
|
|
188
|
+
* view of the tree.
|
|
189
|
+
*
|
|
190
|
+
* The returned promise never rejects. The caller is expected to keep it so it can be drained
|
|
191
|
+
* at shutdown, not to await it here.
|
|
192
|
+
*/
|
|
193
|
+
public snapshotOnClose(): Promise<void> {
|
|
194
|
+
const store = this.env.treeSnapshots;
|
|
195
|
+
if (store === undefined) return Promise.resolve();
|
|
196
|
+
|
|
197
|
+
const generation = this.projectTree.changeGeneration;
|
|
198
|
+
if (generation === this.snapshotGeneration) return Promise.resolve();
|
|
199
|
+
|
|
200
|
+
let snapshot;
|
|
201
|
+
try {
|
|
202
|
+
snapshot = this.projectTree.capture(parseSignedResourceId(this.rid).signature);
|
|
203
|
+
} catch (e: unknown) {
|
|
204
|
+
this.env.logger.warn(
|
|
205
|
+
new Error(`failed to capture tree snapshot for project ${this.id} on close`, { cause: e }),
|
|
206
|
+
);
|
|
207
|
+
return Promise.resolve();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.lastSnapshotAt = Date.now();
|
|
211
|
+
|
|
212
|
+
// Queued behind any in-flight periodic write rather than racing it. Both would land
|
|
213
|
+
// atomically, but the loser would be a wasted encode of the same mirror.
|
|
214
|
+
const previous = this.snapshotInFlight ?? Promise.resolve();
|
|
215
|
+
const write = previous.then(async () => {
|
|
216
|
+
if (this.snapshotGeneration >= generation) return; // the in-flight write covered it
|
|
217
|
+
if (await store.write(this.rid, snapshot)) this.snapshotGeneration = generation;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
this.snapshotInFlight = write.finally(() => {
|
|
221
|
+
this.snapshotInFlight = undefined;
|
|
222
|
+
});
|
|
223
|
+
return this.snapshotInFlight;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** In-flight snapshot write, if any. Both triggers can fire close together (the close write
|
|
227
|
+
* lands while the loop is mid-write), and encoding ten megabytes twice for the same mirror
|
|
228
|
+
* is worth avoiding. */
|
|
229
|
+
private snapshotInFlight: Promise<void> | undefined;
|
|
230
|
+
|
|
231
|
+
/** Serializes writes, and skips one that the in-flight write has already made redundant. */
|
|
232
|
+
private async writeSnapshot(store: TreeSnapshotStore, generation: number): Promise<void> {
|
|
233
|
+
// A loop, not a single check: with three or more callers, re-checking only once would let
|
|
234
|
+
// a waiter install its own promise over another's and clear the field while that write is
|
|
235
|
+
// still running. Two callers is the most that can happen today, so this is a guard against
|
|
236
|
+
// the next caller rather than a live fix.
|
|
237
|
+
while (this.snapshotInFlight !== undefined) {
|
|
238
|
+
await this.snapshotInFlight;
|
|
239
|
+
if (this.snapshotGeneration >= generation) return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
this.snapshotInFlight = this.captureAndWrite(store, generation).finally(() => {
|
|
243
|
+
this.snapshotInFlight = undefined;
|
|
244
|
+
});
|
|
245
|
+
await this.snapshotInFlight;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Captures and writes, never throwing: a snapshot is an optimisation and must not fail
|
|
249
|
+
* whatever triggered it. */
|
|
250
|
+
private async captureAndWrite(store: TreeSnapshotStore, generation: number): Promise<void> {
|
|
251
|
+
// Recorded before the attempt and regardless of its outcome, so a failing disk is retried
|
|
252
|
+
// once per interval instead of on every pass of the maintenance loop.
|
|
253
|
+
this.lastSnapshotAt = Date.now();
|
|
254
|
+
try {
|
|
255
|
+
// The root's signature is the session witness a later open compares against.
|
|
256
|
+
const snapshot = this.projectTree.capture(parseSignedResourceId(this.rid).signature);
|
|
257
|
+
|
|
258
|
+
// Only a real write advances the change gate. Marking the generation persisted after a
|
|
259
|
+
// failed write would tell both triggers the tree is already on disk, so one transient
|
|
260
|
+
// I/O error would cost the rest of the session, close write included.
|
|
261
|
+
if (await store.write(this.rid, snapshot)) this.snapshotGeneration = generation;
|
|
262
|
+
} catch (e: unknown) {
|
|
263
|
+
this.env.logger.warn(
|
|
264
|
+
new Error(`failed to capture tree snapshot for project ${this.id}`, { cause: e }),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
132
269
|
private async refreshLoop(): Promise<void> {
|
|
133
270
|
let retryState: InfiniteRetryState | undefined;
|
|
134
271
|
while (!this.destroyed) {
|
|
@@ -147,6 +284,8 @@ export class Project {
|
|
|
147
284
|
signal: this.abortController.signal,
|
|
148
285
|
});
|
|
149
286
|
|
|
287
|
+
await this.maybeWriteSnapshot();
|
|
288
|
+
|
|
150
289
|
// Block computables housekeeping
|
|
151
290
|
const overviewLight = await this.overviewLight.getValue();
|
|
152
291
|
const existingBlocks = new Set(overviewLight.listOfBlocks);
|
|
@@ -727,18 +866,8 @@ export class Project {
|
|
|
727
866
|
// Doing a no-op mutation to apply all migration and schema fixes
|
|
728
867
|
await withProject(env.projectHelper, env.pl, rid, (_) => {}, { name: "init" });
|
|
729
868
|
|
|
730
|
-
// Loading project tree
|
|
731
|
-
const projectTree = await
|
|
732
|
-
env.pl,
|
|
733
|
-
rid,
|
|
734
|
-
{
|
|
735
|
-
...env.ops.defaultTreeOptions,
|
|
736
|
-
pruning: projectTreePruning(env.logger),
|
|
737
|
-
fieldFilter: projectTreeFieldFilter(),
|
|
738
|
-
traverseStopRules: projectTreeTraverseStopRules(),
|
|
739
|
-
},
|
|
740
|
-
env.logger,
|
|
741
|
-
);
|
|
869
|
+
// Loading project tree, warm from a persisted mirror when one is usable
|
|
870
|
+
const { tree: projectTree, restored } = await loadProjectTree(env, rid);
|
|
742
871
|
|
|
743
872
|
if (env.ops.debugOps.dumpInitialTreeState) {
|
|
744
873
|
const state = projectTree.dumpState();
|
|
@@ -748,8 +877,99 @@ export class Project {
|
|
|
748
877
|
await fs.writeFile(`${resourceIdToString(rid)}.stats.json`, stringifyForDump(stats));
|
|
749
878
|
}
|
|
750
879
|
|
|
751
|
-
return new Project(env, id, rid, projectTree);
|
|
880
|
+
return new Project(env, id, rid, projectTree, restored);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Opens the project tree, seeded from a persisted mirror when there is a usable one.
|
|
886
|
+
*
|
|
887
|
+
* Carries the fail-safe: if the restored tree fails its first refresh on authentication,
|
|
888
|
+
* permission or an inconsistency, the snapshot is deleted and the open is retried cold. Once,
|
|
889
|
+
* and only for that first refresh, so a genuinely dead session still surfaces as itself rather
|
|
890
|
+
* than being masked as a slow open.
|
|
891
|
+
*
|
|
892
|
+
* The fail-safe is what bounds every case the cache key does not cover: a rotated master
|
|
893
|
+
* secret, a revoked grant, a snapshot valid in itself but no longer matching what the backend
|
|
894
|
+
* will serve. Without it, an explicit-root tree propagates the refresh failure rather than
|
|
895
|
+
* healing, so the project would fail to open on every attempt until someone deleted the cache
|
|
896
|
+
* directory by hand.
|
|
897
|
+
*/
|
|
898
|
+
async function loadProjectTree(
|
|
899
|
+
env: MiddleLayerEnvironment,
|
|
900
|
+
rid: SignedResourceId,
|
|
901
|
+
): Promise<{ tree: SynchronizedTreeState; restored: boolean }> {
|
|
902
|
+
const treeOps = {
|
|
903
|
+
...env.ops.defaultTreeOptions,
|
|
904
|
+
pruning: projectTreePruning(env.logger),
|
|
905
|
+
fieldFilter: projectTreeFieldFilter(),
|
|
906
|
+
traverseStopRules: projectTreeTraverseStopRules(),
|
|
907
|
+
};
|
|
908
|
+
const cold = async () => ({
|
|
909
|
+
tree: await SynchronizedTreeState.init(env.pl, rid, treeOps, env.logger),
|
|
910
|
+
restored: false,
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
const store = env.treeSnapshots;
|
|
914
|
+
if (store === undefined) return await cold();
|
|
915
|
+
|
|
916
|
+
const snapshot = await store.read(rid);
|
|
917
|
+
if (!snapshot.ok) {
|
|
918
|
+
env.logger.info(`project tree opening cold, snapshot miss: ${snapshot.miss}`);
|
|
919
|
+
return await cold();
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
try {
|
|
923
|
+
const tree = await SynchronizedTreeState.init(
|
|
924
|
+
env.pl,
|
|
925
|
+
rid,
|
|
926
|
+
{ ...treeOps, restoreFrom: snapshot.tree },
|
|
927
|
+
env.logger,
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
// Read from the tree rather than assumed: a snapshot can be handed over and still be
|
|
931
|
+
// refused, in which case this open was cold and the file on disk does not describe the
|
|
932
|
+
// tree we now hold.
|
|
933
|
+
const restored = tree.wasRestoredFromSnapshot;
|
|
934
|
+
if (restored) store.noteRestored();
|
|
935
|
+
else env.logger.info("project tree opening cold: the snapshot was not applied");
|
|
936
|
+
|
|
937
|
+
return { tree, restored };
|
|
938
|
+
} catch (e: unknown) {
|
|
939
|
+
// Retry cold on ANY failure of the warm open, not only on the classified ones. A cold open
|
|
940
|
+
// is exactly what this code did before snapshots existed, so the retry cannot regress
|
|
941
|
+
// anything, whereas rethrowing here leaves a project that fails to open on every attempt
|
|
942
|
+
// until someone deletes the cache directory by hand: the snapshot stays on disk and the
|
|
943
|
+
// next open restores it and fails the same way. That is the outcome this fail-safe exists
|
|
944
|
+
// to prevent, and the error classes that can reach here are not a closed set.
|
|
945
|
+
env.logger.warn(
|
|
946
|
+
new Error("restored project tree failed its first refresh, opening cold", { cause: e }),
|
|
947
|
+
);
|
|
948
|
+
|
|
949
|
+
// Deleting is reserved for failures that implicate the snapshot itself. Anything else (a
|
|
950
|
+
// timeout, a dropped connection) says nothing about the file, and throwing it away would
|
|
951
|
+
// destroy a mirror that is still good, along with the evidence a later signature refresh
|
|
952
|
+
// would repair.
|
|
953
|
+
if (isSnapshotFailsafeError(e)) await store.discard(rid);
|
|
954
|
+
|
|
955
|
+
return await cold();
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** The failures that implicate the snapshot rather than the link or the session: a rotated
|
|
960
|
+
* master secret, a revoked grant, or state the tree cannot reconcile. Only these delete the
|
|
961
|
+
* file; every other failure still falls back to a cold open, it just keeps the file.
|
|
962
|
+
*
|
|
963
|
+
* The cause chain is walked because a wrapper anywhere between the tree update and here would
|
|
964
|
+
* otherwise silently disarm the inconsistency arm. `isUnauthenticated` and `isPermissionDenied`
|
|
965
|
+
* do their own one-level unwrapping. */
|
|
966
|
+
export function isSnapshotFailsafeError(e: unknown): boolean {
|
|
967
|
+
if (isUnauthenticated(e) || isPermissionDenied(e)) return true;
|
|
968
|
+
for (let cause: unknown = e, depth = 0; cause !== undefined && depth < 8; depth++) {
|
|
969
|
+
if (cause instanceof TreeStateUpdateError) return true;
|
|
970
|
+
cause = (cause as { cause?: unknown } | null)?.cause;
|
|
752
971
|
}
|
|
972
|
+
return false;
|
|
753
973
|
}
|
|
754
974
|
|
|
755
975
|
export function projectTreePruning(logger: MiLogger): PruningFunction {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { TreeStateUpdateError } from "@milaboratories/pl-tree";
|
|
3
|
+
import { PermissionDeniedError, UnauthenticatedError } from "@milaboratories/pl-client";
|
|
4
|
+
import { isSnapshotFailsafeError } from "./project";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which first-refresh failures implicate the snapshot itself, and so delete it.
|
|
8
|
+
*
|
|
9
|
+
* Every failure retries cold regardless, so a mistake here cannot leave a project unopenable.
|
|
10
|
+
* What it can do is either destroy a good mirror over a transient link problem, or keep a bad
|
|
11
|
+
* one and pay a wasted warm attempt on every open.
|
|
12
|
+
*/
|
|
13
|
+
describe("the fail-safe classification", () => {
|
|
14
|
+
test("authentication and permission failures implicate the snapshot", () => {
|
|
15
|
+
expect(isSnapshotFailsafeError(new UnauthenticatedError("token expired"))).toBe(true);
|
|
16
|
+
expect(isSnapshotFailsafeError(new PermissionDeniedError("grant revoked"))).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("a tree inconsistency implicates the snapshot", () => {
|
|
20
|
+
expect(isSnapshotFailsafeError(new TreeStateUpdateError("orphan resource"))).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("a wrapped tree inconsistency still implicates it", () => {
|
|
24
|
+
// The cause chain is walked precisely so that a wrapper introduced anywhere between the
|
|
25
|
+
// tree update and the caller cannot silently disarm this arm of the fail-safe.
|
|
26
|
+
const wrapped = new Error("refresh failed", {
|
|
27
|
+
cause: new Error("while loading", { cause: new TreeStateUpdateError("orphan resource") }),
|
|
28
|
+
});
|
|
29
|
+
expect(isSnapshotFailsafeError(wrapped)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a link failure does not, so the mirror is kept", () => {
|
|
33
|
+
expect(isSnapshotFailsafeError(new Error("socket hang up"))).toBe(false);
|
|
34
|
+
expect(isSnapshotFailsafeError(new Error("deadline exceeded"))).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("nothing exotic throws", () => {
|
|
38
|
+
// A cause chain that loops must not hang the classifier.
|
|
39
|
+
const looped: { cause?: unknown } = {};
|
|
40
|
+
looped.cause = looped;
|
|
41
|
+
|
|
42
|
+
expect(isSnapshotFailsafeError(looped)).toBe(false);
|
|
43
|
+
expect(isSnapshotFailsafeError(undefined)).toBe(false);
|
|
44
|
+
expect(isSnapshotFailsafeError(null)).toBe(false);
|
|
45
|
+
expect(isSnapshotFailsafeError("a string")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { TestHelpers } from "@milaboratories/pl-client";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import fsp from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import * as tp from "node:timers/promises";
|
|
7
|
+
import { MiddleLayer } from "./middle_layer";
|
|
8
|
+
import type { ProjectId } from "../model/project_model";
|
|
9
|
+
import type { TreeSnapshotOps } from "./ops";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The acceptance scenarios that need a live backend. Each one runs several middle layers in
|
|
13
|
+
* turn against one backend root and one work folder, which is what makes a reopen a reopen:
|
|
14
|
+
* the projects are the same projects and the snapshot directory is the same directory.
|
|
15
|
+
*
|
|
16
|
+
* `MiddleLayer.close()` closes the client it was given, so every middle layer here gets its
|
|
17
|
+
* own client. They share a session, because the test client reuses one cached token, and a
|
|
18
|
+
* shared session is exactly what a warm reopen needs.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const WORK_ROOT = path.resolve(import.meta.dirname, "..", "..", "work");
|
|
22
|
+
|
|
23
|
+
/** Short intervals so the periodic write is observable inside a test rather than in five
|
|
24
|
+
* minutes. Everything else is left at its default. */
|
|
25
|
+
function fastSnapshots(overrides: Partial<TreeSnapshotOps> = {}): TreeSnapshotOps {
|
|
26
|
+
return {
|
|
27
|
+
enabled: true,
|
|
28
|
+
writeInterval: 250,
|
|
29
|
+
maxSizeBytes: 256 * 1024 * 1024,
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Scenario = {
|
|
35
|
+
/** Opens another middle layer, on its own client, over the same root and work folder. */
|
|
36
|
+
open: (treeSnapshotOps?: TreeSnapshotOps) => Promise<MiddleLayer>;
|
|
37
|
+
/** Closes one, so it is not closed twice during cleanup. */
|
|
38
|
+
close: (ml: MiddleLayer) => Promise<void>;
|
|
39
|
+
/** Creates a project, opens it, and lets its tree settle so there is a mirror worth writing.
|
|
40
|
+
* Tracked for cleanup. */
|
|
41
|
+
project: (ml: MiddleLayer, label: string) => Promise<ProjectId>;
|
|
42
|
+
/** The shared snapshot directory. */
|
|
43
|
+
snapshotDir: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Each middle layer gets its own client, because `MiddleLayer.close()` closes the client it
|
|
48
|
+
* was given, and a reopen has to survive that.
|
|
49
|
+
*
|
|
50
|
+
* The clients use the caller's own root rather than a temporary one: `PlClient.init` with an
|
|
51
|
+
* `alternativeRoot` name always creates a fresh ephemeral root and overwrites the field, so a
|
|
52
|
+
* second client asking for the same name gets an empty project list, which is precisely the
|
|
53
|
+
* state a reopen must not start from. The projects created here are deleted afterwards.
|
|
54
|
+
*/
|
|
55
|
+
async function withScenario(body: (scenario: Scenario) => Promise<void>): Promise<void> {
|
|
56
|
+
const workFolder = path.resolve(WORK_ROOT, randomUUID());
|
|
57
|
+
const live = new Set<MiddleLayer>();
|
|
58
|
+
const projects = new Set<ProjectId>();
|
|
59
|
+
|
|
60
|
+
const openMl = async (treeSnapshotOps: TreeSnapshotOps) => {
|
|
61
|
+
const client = await TestHelpers.getTestClient();
|
|
62
|
+
const ml = await MiddleLayer.init(client, workFolder, {
|
|
63
|
+
defaultTreeOptions: { pollingInterval: 250, stopPollingDelay: 500 },
|
|
64
|
+
devBlockUpdateRecheckInterval: 300,
|
|
65
|
+
projectRefreshInterval: 250,
|
|
66
|
+
localSecret: MiddleLayer.generateLocalSecret(),
|
|
67
|
+
localProjections: [],
|
|
68
|
+
openFileDialogCallback: () => {
|
|
69
|
+
throw new Error("Not implemented.");
|
|
70
|
+
},
|
|
71
|
+
treeSnapshotOps,
|
|
72
|
+
});
|
|
73
|
+
live.add(ml);
|
|
74
|
+
return ml;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const scenario: Scenario = {
|
|
78
|
+
snapshotDir: path.join(workFolder, "treeSnapshots"),
|
|
79
|
+
open: async (treeSnapshotOps = fastSnapshots()) => await openMl(treeSnapshotOps),
|
|
80
|
+
close: async (ml: MiddleLayer) => {
|
|
81
|
+
live.delete(ml);
|
|
82
|
+
await ml.close();
|
|
83
|
+
},
|
|
84
|
+
project: async (ml: MiddleLayer, label: string) => {
|
|
85
|
+
const id = await ml.createProject({ label: `${label} ${randomUUID()}` });
|
|
86
|
+
projects.add(id);
|
|
87
|
+
await ml.openProject(id);
|
|
88
|
+
// Reading the overview forces the tree to load and the computables to resolve.
|
|
89
|
+
await ml.getOpenedProject(id).overview.awaitStableValue();
|
|
90
|
+
return id;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await body(scenario);
|
|
96
|
+
} finally {
|
|
97
|
+
for (const ml of live) await ml.close().catch(() => {});
|
|
98
|
+
|
|
99
|
+
// The root outlives the test, so the projects have to be cleaned up explicitly.
|
|
100
|
+
if (projects.size > 0) {
|
|
101
|
+
const cleanup = await openMl({ ...fastSnapshots(), enabled: false });
|
|
102
|
+
try {
|
|
103
|
+
for (const id of projects) await cleanup.deleteProject(id).catch(() => {});
|
|
104
|
+
} finally {
|
|
105
|
+
await cleanup.close().catch(() => {});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
await fsp.rm(workFolder, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function snapshotFiles(dir: string): Promise<string[]> {
|
|
113
|
+
try {
|
|
114
|
+
return (await fsp.readdir(dir)).sort();
|
|
115
|
+
} catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe("reopening a project", () => {
|
|
121
|
+
test("the close write makes the next open warm", async () => {
|
|
122
|
+
await withScenario(async ({ open, close, project, snapshotDir }) => {
|
|
123
|
+
const first = await open();
|
|
124
|
+
const id = await project(first, "warm reopen");
|
|
125
|
+
await first.closeProject(id);
|
|
126
|
+
|
|
127
|
+
// The close write is started, not awaited, so that closing a project does not sit behind
|
|
128
|
+
// an encode. Shutdown drains it, which is what makes it observable here.
|
|
129
|
+
await close(first);
|
|
130
|
+
|
|
131
|
+
// One file for the one project, written at the close boundary.
|
|
132
|
+
expect(await snapshotFiles(snapshotDir)).toHaveLength(1);
|
|
133
|
+
expect(first.treeSnapshotStats?.writes).toBeGreaterThanOrEqual(1);
|
|
134
|
+
|
|
135
|
+
const second = await open();
|
|
136
|
+
await second.openProject(id);
|
|
137
|
+
// The claim: the reopen read the snapshot and restored from it.
|
|
138
|
+
expect(second.treeSnapshotStats?.hits).toBe(1);
|
|
139
|
+
expect(second.treeSnapshotStats?.misses.absent).toBe(0);
|
|
140
|
+
// Read is not enough: this is the tree actually accepting the mirror.
|
|
141
|
+
expect(second.treeSnapshotStats?.restores).toBe(1);
|
|
142
|
+
|
|
143
|
+
// And the project is genuinely usable, not merely restored.
|
|
144
|
+
const overview = await second.getOpenedProject(id).overview.awaitStableValue();
|
|
145
|
+
expect(overview.meta.label).toContain("warm reopen");
|
|
146
|
+
await close(second);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("closing a project does not wait for its snapshot to be written", async () => {
|
|
151
|
+
await withScenario(async ({ open, close, project, snapshotDir }) => {
|
|
152
|
+
const ml = await open();
|
|
153
|
+
const id = await project(ml, "unblocked close");
|
|
154
|
+
|
|
155
|
+
await ml.closeProject(id);
|
|
156
|
+
// The capture happened synchronously inside closeProject, but the encode and write are
|
|
157
|
+
// deferred, so the file is normally not there yet. Asserted as "not blocked on it"
|
|
158
|
+
// rather than "definitely absent": a tiny mirror can beat us to the assertion, and the
|
|
159
|
+
// point is that close does not await, not that the write is slow.
|
|
160
|
+
const writesRightAfterClose = ml.treeSnapshotStats!.writes;
|
|
161
|
+
|
|
162
|
+
await close(ml); // drains
|
|
163
|
+
expect(ml.treeSnapshotStats!.writes).toBeGreaterThanOrEqual(writesRightAfterClose);
|
|
164
|
+
expect(await snapshotFiles(snapshotDir)).toHaveLength(1);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("project switching: both returns hit", async () => {
|
|
169
|
+
await withScenario(async ({ open, close, project, snapshotDir }) => {
|
|
170
|
+
const first = await open();
|
|
171
|
+
const a = await project(first, "A");
|
|
172
|
+
const b = await project(first, "B");
|
|
173
|
+
await first.closeProject(a);
|
|
174
|
+
await first.closeProject(b);
|
|
175
|
+
await close(first); // drains both deferred close writes
|
|
176
|
+
expect(await snapshotFiles(snapshotDir)).toHaveLength(2);
|
|
177
|
+
|
|
178
|
+
const second = await open();
|
|
179
|
+
await second.openProject(a);
|
|
180
|
+
await second.openProject(b);
|
|
181
|
+
expect(second.treeSnapshotStats?.hits).toBe(2);
|
|
182
|
+
expect(second.treeSnapshotStats?.restores).toBe(2);
|
|
183
|
+
await close(second);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("a killed process is covered by the periodic write", async () => {
|
|
188
|
+
await withScenario(async ({ open, close, project, snapshotDir }) => {
|
|
189
|
+
const first = await open(fastSnapshots({ writeInterval: 250 }));
|
|
190
|
+
const id = await project(first, "killed");
|
|
191
|
+
|
|
192
|
+
// Never closed, standing in for a reboot, a lost connection or a kill. The periodic
|
|
193
|
+
// write on the maintenance loop is the only thing that can have saved this.
|
|
194
|
+
//
|
|
195
|
+
// What this does NOT reproduce is the relaunch: both middle layers here share a session,
|
|
196
|
+
// because the test client reuses one cached token. In production the equivalent is the
|
|
197
|
+
// desktop app reconnecting with the JWT it persisted, which keeps the session and so the
|
|
198
|
+
// signatures; a change that made relaunch re-login instead would break the warm reopen
|
|
199
|
+
// and no assertion here would notice.
|
|
200
|
+
await tp.setTimeout(1500);
|
|
201
|
+
expect(first.treeSnapshotStats?.writes).toBeGreaterThanOrEqual(1);
|
|
202
|
+
expect(await snapshotFiles(snapshotDir)).toHaveLength(1);
|
|
203
|
+
|
|
204
|
+
// Closing the middle layer without closing the project: close() must not snapshot, so
|
|
205
|
+
// whatever is on disk came from the periodic write.
|
|
206
|
+
const writesBefore = first.treeSnapshotStats!.writes;
|
|
207
|
+
await close(first);
|
|
208
|
+
expect(first.treeSnapshotStats?.writes).toBe(writesBefore);
|
|
209
|
+
|
|
210
|
+
const second = await open();
|
|
211
|
+
await second.openProject(id);
|
|
212
|
+
expect(second.treeSnapshotStats?.hits).toBe(1);
|
|
213
|
+
expect(second.treeSnapshotStats?.restores).toBe(1);
|
|
214
|
+
await close(second);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("write cadence", () => {
|
|
220
|
+
test("open and idle writes once, then goes quiet", async () => {
|
|
221
|
+
await withScenario(async ({ open, close, project }) => {
|
|
222
|
+
const ml = await open(fastSnapshots({ writeInterval: 250 }));
|
|
223
|
+
await project(ml, "idle");
|
|
224
|
+
|
|
225
|
+
// Several intervals of nothing happening.
|
|
226
|
+
await tp.setTimeout(2000);
|
|
227
|
+
|
|
228
|
+
// Exactly one, not "at most one": a cold open loads a tree, so the change gate is open
|
|
229
|
+
// and the first maintenance pass writes. Asserting <= 1 would pass with zero writes and
|
|
230
|
+
// prove nothing about the periodic trigger existing at all.
|
|
231
|
+
expect(ml.treeSnapshotStats?.writes).toBe(1);
|
|
232
|
+
|
|
233
|
+
// And then quiet, because the gate closes on a mirror that has not moved.
|
|
234
|
+
await tp.setTimeout(1500);
|
|
235
|
+
expect(ml.treeSnapshotStats?.writes).toBe(1);
|
|
236
|
+
await close(ml);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("a project that keeps changing writes at most once per interval", async () => {
|
|
241
|
+
await withScenario(async ({ open, close, project }) => {
|
|
242
|
+
const ml = await open(fastSnapshots({ writeInterval: 1000 }));
|
|
243
|
+
const id = await project(ml, "changing");
|
|
244
|
+
|
|
245
|
+
// Keep the tree moving for roughly three intervals.
|
|
246
|
+
const until = Date.now() + 3000;
|
|
247
|
+
let n = 0;
|
|
248
|
+
while (Date.now() < until) {
|
|
249
|
+
await ml.setProjectMeta(id, { label: `changing ${n++}` });
|
|
250
|
+
await tp.setTimeout(150);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Bounded by wall clock, not by how often the tree changed.
|
|
254
|
+
expect(ml.treeSnapshotStats!.writes).toBeLessThanOrEqual(4);
|
|
255
|
+
expect(n).toBeGreaterThan(4);
|
|
256
|
+
await close(ml);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("when the snapshot cannot be used", () => {
|
|
262
|
+
test("a rotated signature is a miss, the file is kept, and the project still opens", async () => {
|
|
263
|
+
await withScenario(async ({ open, close, project, snapshotDir }) => {
|
|
264
|
+
const first = await open();
|
|
265
|
+
const id = await project(first, "rotated");
|
|
266
|
+
await first.closeProject(id);
|
|
267
|
+
await close(first);
|
|
268
|
+
|
|
269
|
+
// Rewrite the witness in the header to stand in for a session that has ended. Its
|
|
270
|
+
// offset is fixed: magic (4) + schema (2) + flags (2), then a u16 length and the bytes.
|
|
271
|
+
const [name] = await snapshotFiles(snapshotDir);
|
|
272
|
+
const file = path.join(snapshotDir, name);
|
|
273
|
+
const bytes = await fsp.readFile(file);
|
|
274
|
+
const witnessLength = bytes.readUInt16LE(8);
|
|
275
|
+
expect(witnessLength).toBeGreaterThan(0);
|
|
276
|
+
bytes[10] = bytes[10] ^ 0xff;
|
|
277
|
+
await fsp.writeFile(file, bytes);
|
|
278
|
+
|
|
279
|
+
const second = await open();
|
|
280
|
+
await second.openProject(id);
|
|
281
|
+
expect(second.treeSnapshotStats?.hits).toBe(0);
|
|
282
|
+
expect(second.treeSnapshotStats?.restores).toBe(0);
|
|
283
|
+
expect(second.treeSnapshotStats?.misses["session-rotated"]).toBe(1);
|
|
284
|
+
|
|
285
|
+
// Kept: the bodies are still good, only the signatures died.
|
|
286
|
+
expect(await snapshotFiles(snapshotDir)).toHaveLength(1);
|
|
287
|
+
|
|
288
|
+
const overview = await second.getOpenedProject(id).overview.awaitStableValue();
|
|
289
|
+
expect(overview.meta.label).toContain("rotated");
|
|
290
|
+
await close(second);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("a truncated snapshot opens cold without raising", async () => {
|
|
295
|
+
await withScenario(async ({ open, close, project, snapshotDir }) => {
|
|
296
|
+
const first = await open();
|
|
297
|
+
const id = await project(first, "poisoned");
|
|
298
|
+
await first.closeProject(id);
|
|
299
|
+
await close(first);
|
|
300
|
+
|
|
301
|
+
const [name] = await snapshotFiles(snapshotDir);
|
|
302
|
+
const file = path.join(snapshotDir, name);
|
|
303
|
+
const bytes = await fsp.readFile(file);
|
|
304
|
+
await fsp.writeFile(file, bytes.subarray(0, Math.floor(bytes.length / 2)));
|
|
305
|
+
|
|
306
|
+
const second = await open();
|
|
307
|
+
await second.openProject(id);
|
|
308
|
+
expect(second.treeSnapshotStats?.hits).toBe(0);
|
|
309
|
+
expect(second.treeSnapshotStats?.restores).toBe(0);
|
|
310
|
+
|
|
311
|
+
const overview = await second.getOpenedProject(id).overview.awaitStableValue();
|
|
312
|
+
expect(overview.meta.label).toContain("poisoned");
|
|
313
|
+
await close(second);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("the kill switch", () => {
|
|
319
|
+
test("nothing is read or written when snapshots are off", async () => {
|
|
320
|
+
await withScenario(async ({ open, close, project, snapshotDir }) => {
|
|
321
|
+
const first = await open(fastSnapshots({ enabled: false }));
|
|
322
|
+
const id = await project(first, "disabled");
|
|
323
|
+
await tp.setTimeout(1000);
|
|
324
|
+
await first.closeProject(id);
|
|
325
|
+
|
|
326
|
+
expect(first.treeSnapshotStats).toBeUndefined();
|
|
327
|
+
expect(await snapshotFiles(snapshotDir)).toStrictEqual([]);
|
|
328
|
+
await close(first);
|
|
329
|
+
|
|
330
|
+
// And a project created while off still opens once it is back on.
|
|
331
|
+
const second = await open();
|
|
332
|
+
await second.openProject(id);
|
|
333
|
+
expect(second.treeSnapshotStats?.misses.absent).toBe(1);
|
|
334
|
+
await close(second);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
});
|