@milaboratories/pl-tree 1.13.7 → 1.14.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.
- package/dist/accessors.d.ts +15 -17
- package/dist/accessors.d.ts.map +1 -1
- package/dist/dump.d.ts +14 -10
- package/dist/dump.d.ts.map +1 -1
- package/dist/index.cjs +7 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/persisted_tree.cjs +465 -0
- package/dist/persisted_tree.cjs.map +1 -0
- package/dist/persisted_tree.d.ts +99 -0
- package/dist/persisted_tree.d.ts.map +1 -0
- package/dist/persisted_tree.js +460 -0
- package/dist/persisted_tree.js.map +1 -0
- package/dist/snapshot.d.ts +12 -14
- package/dist/snapshot.d.ts.map +1 -1
- package/dist/state.cjs +7 -0
- package/dist/state.cjs.map +1 -1
- package/dist/state.d.ts +13 -11
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +7 -0
- package/dist/state.js.map +1 -1
- package/dist/sync.d.ts +26 -19
- package/dist/sync.d.ts.map +1 -1
- package/dist/synchronized_tree.cjs +62 -4
- package/dist/synchronized_tree.cjs.map +1 -1
- package/dist/synchronized_tree.d.ts +64 -17
- package/dist/synchronized_tree.d.ts.map +1 -1
- package/dist/synchronized_tree.js +62 -4
- package/dist/synchronized_tree.js.map +1 -1
- package/dist/traversal_ops.d.ts +12 -10
- package/dist/traversal_ops.d.ts.map +1 -1
- package/dist/value_and_error.d.ts +3 -4
- package/dist/value_and_error.d.ts.map +1 -1
- package/dist/value_or_error.d.ts +1 -2
- package/dist/value_or_error.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/index.ts +1 -0
- package/src/persisted_tree.test.ts +291 -0
- package/src/persisted_tree.ts +708 -0
- package/src/state.ts +8 -0
- package/src/synchronized_tree.ts +102 -5
package/src/state.ts
CHANGED
|
@@ -469,6 +469,14 @@ export class PlTreeState {
|
|
|
469
469
|
this.resources.forEach((v) => cb(v));
|
|
470
470
|
}
|
|
471
471
|
|
|
472
|
+
/** False once the tree has been invalidated (an inconsistent update, or termination of
|
|
473
|
+
* its synchronization loop). {@link dumpState} deliberately reads through an invalid
|
|
474
|
+
* tree, so anything persisting that dump must check this first: the contents of an
|
|
475
|
+
* invalidated tree are not something to write to disk. */
|
|
476
|
+
public get isValid(): boolean {
|
|
477
|
+
return this._isValid;
|
|
478
|
+
}
|
|
479
|
+
|
|
472
480
|
private checkValid() {
|
|
473
481
|
if (!this._isValid) throw new Error(this.invalidationMessage ?? "tree is in invalid state");
|
|
474
482
|
}
|
package/src/synchronized_tree.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { PlTreeEntry, PlTreeRootsEntry } from "./accessors";
|
|
|
3
3
|
import type {
|
|
4
4
|
FinalResourceDataPredicate,
|
|
5
5
|
PlClient,
|
|
6
|
+
ResourceSignature,
|
|
6
7
|
ResourceType,
|
|
7
8
|
SignedResourceId,
|
|
8
9
|
TxOps,
|
|
@@ -17,6 +18,8 @@ import type { ExtendedResourceData } from "./state";
|
|
|
17
18
|
import { PlTreeState, TreeStateUpdateError } from "./state";
|
|
18
19
|
import type { PruningFunction, TraversalMode, TreeLoadingStat } from "./sync";
|
|
19
20
|
import { constructTreeLoadingRequest, initialTreeLoadingStat, loadTreeState } from "./sync";
|
|
21
|
+
import type { PersistedTree } from "./persisted_tree";
|
|
22
|
+
import { captureTreeState, restoreTreeState } from "./persisted_tree";
|
|
20
23
|
import * as tp from "node:timers/promises";
|
|
21
24
|
import type { MiLogger } from "@milaboratories/ts-helpers";
|
|
22
25
|
|
|
@@ -74,6 +77,19 @@ export type SynchronizedTreeOps = {
|
|
|
74
77
|
|
|
75
78
|
/** Controls which tree-loading path to use. Default `"auto"`. */
|
|
76
79
|
traversalMode?: TraversalMode;
|
|
80
|
+
|
|
81
|
+
/** A previously persisted mirror to seed the tree with, before its first refresh, so that
|
|
82
|
+
* refresh transfers only what changed while the tree was gone.
|
|
83
|
+
*
|
|
84
|
+
* A snapshot that cannot be applied, or does not belong to this tree, is logged and dropped,
|
|
85
|
+
* leaving an ordinary cold open.
|
|
86
|
+
*
|
|
87
|
+
* A snapshot that applies but whose ids are dead is NOT handled here: its resources become
|
|
88
|
+
* this tree's seeds, so the first refresh fails and {@link init} rejects, where a cold open
|
|
89
|
+
* would have succeeded. Establishing that the signatures are still live is the caller's job
|
|
90
|
+
* (see {@link PersistedTree.witness}), as is deciding what to do when the first refresh is
|
|
91
|
+
* refused anyway. Ignored for trees with shared-type seeds, which rediscover their roots. */
|
|
92
|
+
restoreFrom?: PersistedTree;
|
|
77
93
|
};
|
|
78
94
|
|
|
79
95
|
/** An explicit resource to serve as a tree root. Several explicit seeds may be passed. */
|
|
@@ -116,7 +132,13 @@ const DISCOVERY_INTERVAL_MS = 3_000;
|
|
|
116
132
|
* `resourcesUnchanged` is excluded by design, since a cycle that only re-fetched unchanged
|
|
117
133
|
* state is exactly the idle case the backoff exists for. */
|
|
118
134
|
function countedChanges(stat: TreeLoadingStat): number {
|
|
119
|
-
|
|
135
|
+
// `fieldsRemoved` is included despite being a per-field count, because it is the one change
|
|
136
|
+
// that never shows up in `resourcesChanged`: the removed-dynamic-field branch in
|
|
137
|
+
// `updateFromResourceData` does not set its `changed` flag, so a cycle that only dropped a
|
|
138
|
+
// field (and garbage-collected whatever it pointed at) otherwise reads as an idle cycle.
|
|
139
|
+
// That double-counts a resource that both changed and lost a field, which is harmless here:
|
|
140
|
+
// every caller compares this against an earlier value rather than reading it as a total.
|
|
141
|
+
return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal + stat.fieldsRemoved;
|
|
120
142
|
}
|
|
121
143
|
|
|
122
144
|
/** The poll-cadence policy, as a pure function of the last cycle's outcome.
|
|
@@ -174,6 +196,15 @@ export class SynchronizedTreeState {
|
|
|
174
196
|
/** Roots discovered for shared-type seeds on the last discovery poll. */
|
|
175
197
|
private discoveredRoots: SignedResourceId[] = [];
|
|
176
198
|
|
|
199
|
+
/** Bumped once per refresh cycle that brought something new: a resource appeared, changed,
|
|
200
|
+
* or became final. Lets a holder tell whether the tree has moved since it last persisted
|
|
201
|
+
* it, without diffing state. Read through {@link changeGeneration}. */
|
|
202
|
+
private changeGenerationCounter = 0;
|
|
203
|
+
|
|
204
|
+
/** Whether a snapshot was actually applied. Read through
|
|
205
|
+
* {@link wasRestoredFromSnapshot}. */
|
|
206
|
+
private restoredFromSnapshot = false;
|
|
207
|
+
|
|
177
208
|
private constructor(
|
|
178
209
|
private readonly pl: PlClient,
|
|
179
210
|
seeds: TreeSeed[],
|
|
@@ -218,6 +249,60 @@ export class SynchronizedTreeState {
|
|
|
218
249
|
return new Set([...this.explicitRoots, ...this.discoveredRoots]);
|
|
219
250
|
}
|
|
220
251
|
|
|
252
|
+
/** How many refresh cycles brought something new. Only ever increases. Equal values at two
|
|
253
|
+
* points in time mean nothing was added, changed or settled in between, which is what makes
|
|
254
|
+
* a periodic snapshot write skippable on an idle tree. */
|
|
255
|
+
public get changeGeneration(): number {
|
|
256
|
+
return this.changeGenerationCounter;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** True only if a snapshot was actually applied as this tree's initial state. A snapshot can
|
|
260
|
+
* be supplied and still be refused (wrong roots, or state the update call will not accept),
|
|
261
|
+
* in which case this stays false and the tree started empty like any other. Passing
|
|
262
|
+
* `restoreFrom` is therefore not evidence of a warm start; this is. */
|
|
263
|
+
public get wasRestoredFromSnapshot(): boolean {
|
|
264
|
+
return this.restoredFromSnapshot;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Captures the current mirror for persistence.
|
|
268
|
+
*
|
|
269
|
+
* Must be called before {@link terminate}: terminating invalidates the tree, and capturing
|
|
270
|
+
* an invalidated tree is refused rather than silently written. */
|
|
271
|
+
public capture(witness: ResourceSignature): PersistedTree {
|
|
272
|
+
if (this.terminated) throw new Error("tree synchronization is terminated");
|
|
273
|
+
return captureTreeState(this.state, witness);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Installs a snapshot as this tree's state. Returns false if the snapshot was refused, in
|
|
277
|
+
* which case the tree is left as it was and the open proceeds cold.
|
|
278
|
+
*
|
|
279
|
+
* Only meaningful before the first refresh, which is why it is private and driven from
|
|
280
|
+
* {@link init}: replacing the state of a running tree would strand its observers. */
|
|
281
|
+
private restore(snapshot: PersistedTree): boolean {
|
|
282
|
+
if (this.sharedSeeds.length > 0) {
|
|
283
|
+
this.logger?.warn("ignoring tree snapshot: trees with shared-type seeds are not restored");
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const roots = this.currentRootSet();
|
|
288
|
+
const snapshotRoots = new Set(snapshot.roots);
|
|
289
|
+
if (snapshotRoots.size !== roots.size || ![...snapshotRoots].every((r) => roots.has(r))) {
|
|
290
|
+
// A snapshot addressed to a different root is a mis-keyed file, not a stale one.
|
|
291
|
+
this.logger?.warn("ignoring tree snapshot: its roots are not this tree's roots");
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const restored = restoreTreeState(snapshot, this.finalPredicate, {
|
|
296
|
+
roots,
|
|
297
|
+
logger: this.logger,
|
|
298
|
+
});
|
|
299
|
+
if (restored === undefined) return false;
|
|
300
|
+
|
|
301
|
+
this.state = restored;
|
|
302
|
+
this.restoredFromSnapshot = true;
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
|
|
221
306
|
/** Resolves the single root for the backward-compatible single-root accessors, throwing
|
|
222
307
|
* if the tree does not have exactly one root (guards legacy callers against multi-root). */
|
|
223
308
|
private soleRoot(): SignedResourceId {
|
|
@@ -439,7 +524,9 @@ export class SynchronizedTreeState {
|
|
|
439
524
|
// actual tree synchronization
|
|
440
525
|
await this.refresh(stat);
|
|
441
526
|
|
|
442
|
-
|
|
527
|
+
const changed = countedChanges(stat) > changesBefore;
|
|
528
|
+
if (changed) this.changeGenerationCounter++;
|
|
529
|
+
this.updatePollingInterval(changed);
|
|
443
530
|
|
|
444
531
|
// logging stats if we were asked to
|
|
445
532
|
if (this.logStat && this.logger)
|
|
@@ -569,7 +656,13 @@ export class SynchronizedTreeState {
|
|
|
569
656
|
) {
|
|
570
657
|
const tree = new SynchronizedTreeState(pl, normalizeSeeds(seeds), ops, logger);
|
|
571
658
|
|
|
572
|
-
|
|
659
|
+
// Seed from the snapshot before the first refresh, so that refresh is the one that
|
|
660
|
+
// transfers only what changed. A refused snapshot leaves an ordinary cold open.
|
|
661
|
+
const restored = ops.restoreFrom !== undefined && tree.restore(ops.restoreFrom);
|
|
662
|
+
|
|
663
|
+
// Always collected, even when not logging: the initial load's change count is what seeds
|
|
664
|
+
// the change generation, so a holder can tell a populated tree from an untouched one.
|
|
665
|
+
const stat = initialTreeLoadingStat();
|
|
573
666
|
|
|
574
667
|
let ok = false;
|
|
575
668
|
|
|
@@ -581,10 +674,14 @@ export class SynchronizedTreeState {
|
|
|
581
674
|
});
|
|
582
675
|
ok = true;
|
|
583
676
|
} finally {
|
|
677
|
+
if (countedChanges(stat) > 0) tree.changeGenerationCounter++;
|
|
678
|
+
|
|
584
679
|
// logging stats if we were asked to (even if error occured)
|
|
585
|
-
if (
|
|
680
|
+
if (ops.logStat && logger)
|
|
586
681
|
logger.info(
|
|
587
|
-
`Tree stat (initial load, ${ok ? "success" : "failure"}
|
|
682
|
+
`Tree stat (initial load, ${ok ? "success" : "failure"}, ${
|
|
683
|
+
restored ? "restored from snapshot" : "cold"
|
|
684
|
+
}): ${JSON.stringify(stat)}`,
|
|
588
685
|
);
|
|
589
686
|
}
|
|
590
687
|
|