@milaboratories/pl-tree 1.15.7 → 1.16.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.cjs +6 -2
- package/dist/accessors.cjs.map +1 -1
- package/dist/accessors.d.ts.map +1 -1
- package/dist/accessors.js +6 -2
- package/dist/accessors.js.map +1 -1
- package/dist/delta_sync.cjs +18 -8
- package/dist/delta_sync.cjs.map +1 -1
- package/dist/delta_sync.js +18 -8
- package/dist/delta_sync.js.map +1 -1
- package/dist/state.cjs +108 -30
- package/dist/state.cjs.map +1 -1
- package/dist/state.d.ts +27 -2
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +108 -30
- package/dist/state.js.map +1 -1
- package/dist/sync.cjs +2 -1
- package/dist/sync.cjs.map +1 -1
- package/dist/sync.d.ts +2 -0
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +2 -1
- package/dist/sync.js.map +1 -1
- package/dist/synchronized_tree.cjs +46 -9
- package/dist/synchronized_tree.cjs.map +1 -1
- package/dist/synchronized_tree.d.ts +6 -0
- package/dist/synchronized_tree.d.ts.map +1 -1
- package/dist/synchronized_tree.js +47 -10
- package/dist/synchronized_tree.js.map +1 -1
- package/package.json +4 -4
- package/src/accessors.test.ts +108 -0
- package/src/accessors.ts +8 -3
- package/src/delta_sync.test.ts +88 -3
- package/src/delta_sync.ts +19 -8
- package/src/state.test.ts +397 -2
- package/src/state.ts +137 -46
- package/src/state_notifications.model.test.ts +264 -0
- package/src/sync.ts +6 -3
- package/src/synchronized_tree.test.ts +550 -1
- package/src/synchronized_tree.ts +102 -20
package/src/synchronized_tree.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
isUnauthenticated,
|
|
14
14
|
isTimeoutOrCancelError,
|
|
15
15
|
isUnimplementedError,
|
|
16
|
+
resourceIdToString,
|
|
16
17
|
} from "@milaboratories/pl-client";
|
|
17
18
|
import type { ExtendedResourceData } from "./state";
|
|
18
19
|
import { PlTreeState, TreeStateUpdateError } from "./state";
|
|
@@ -32,7 +33,12 @@ import {
|
|
|
32
33
|
import type { PersistedTree } from "./persisted_tree";
|
|
33
34
|
import { captureTreeState, restoreTreeState } from "./persisted_tree";
|
|
34
35
|
import * as tp from "node:timers/promises";
|
|
35
|
-
import type {
|
|
36
|
+
import type {
|
|
37
|
+
InfiniteRetryOptions,
|
|
38
|
+
InfiniteRetryState,
|
|
39
|
+
MiLogger,
|
|
40
|
+
} from "@milaboratories/ts-helpers";
|
|
41
|
+
import { createInfiniteRetryState, nextInfiniteRetryState } from "@milaboratories/ts-helpers";
|
|
36
42
|
|
|
37
43
|
/** Hard floor between consecutive tree-refresh calls.
|
|
38
44
|
* Applies even when {@link scheduleOnNextState} has woken the loop early,
|
|
@@ -60,6 +66,17 @@ const RTT_POLL_FACTOR = 2;
|
|
|
60
66
|
* rather than slow, and spacing polls further only delays noticing it recovered. */
|
|
61
67
|
const MAX_RTT_POLL_INTERVAL_MS = 30_000;
|
|
62
68
|
|
|
69
|
+
/** Waits between rebuilds after consecutive {@link TreeStateUpdateError}s. The first retry
|
|
70
|
+
* comes after the ordinary floor, so a one-off inconsistency heals as fast as a normal poll;
|
|
71
|
+
* a persistent one settles at one full read per {@link MAX_POLLING_INTERVAL_MS}. */
|
|
72
|
+
const REBUILD_RETRY: InfiniteRetryOptions = {
|
|
73
|
+
type: "exponentialWithMaxDelayBackoff",
|
|
74
|
+
initialDelay: MIN_POLLING_INTERVAL_MS,
|
|
75
|
+
maxDelay: MAX_POLLING_INTERVAL_MS,
|
|
76
|
+
backoffMultiplier: 2,
|
|
77
|
+
jitter: 0.2,
|
|
78
|
+
};
|
|
79
|
+
|
|
63
80
|
type StatLoggingMode = "cumulative" | "per-request";
|
|
64
81
|
|
|
65
82
|
export type SynchronizedTreeOps = {
|
|
@@ -92,6 +109,13 @@ export type SynchronizedTreeOps = {
|
|
|
92
109
|
/** Controls which tree-loading path to use. Default `"auto"`. */
|
|
93
110
|
traversalMode?: TraversalMode;
|
|
94
111
|
|
|
112
|
+
/** Treat every explicit root as never final, whatever its type, so every poll seeds it and,
|
|
113
|
+
* once it is held, checks that it still exists. Without it a root the predicate calls final is never re-read,
|
|
114
|
+
* and a deleted final root is never noticed. Roots discovered for shared-type seeds are not
|
|
115
|
+
* covered: a discovered resource may already be held final, and discovery itself drops a root
|
|
116
|
+
* that is gone. */
|
|
117
|
+
rootsNeverFinal?: boolean;
|
|
118
|
+
|
|
95
119
|
/** A previously persisted mirror to seed the tree with, before its first refresh, so that
|
|
96
120
|
* refresh transfers only what changed while the tree was gone.
|
|
97
121
|
*
|
|
@@ -146,13 +170,7 @@ const DISCOVERY_INTERVAL_MS = 3_000;
|
|
|
146
170
|
* `resourcesUnchanged` is excluded by design, since a cycle that only re-fetched unchanged
|
|
147
171
|
* state is exactly the idle case the backoff exists for. */
|
|
148
172
|
function countedChanges(stat: TreeLoadingStat): number {
|
|
149
|
-
|
|
150
|
-
// that never shows up in `resourcesChanged`: the removed-dynamic-field branch in
|
|
151
|
-
// `updateFromResourceData` does not set its `changed` flag, so a cycle that only dropped a
|
|
152
|
-
// field (and garbage-collected whatever it pointed at) otherwise reads as an idle cycle.
|
|
153
|
-
// That double-counts a resource that both changed and lost a field, which is harmless here:
|
|
154
|
-
// every caller compares this against an earlier value rather than reading it as a total.
|
|
155
|
-
return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal + stat.fieldsRemoved;
|
|
173
|
+
return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal + stat.rootsDropped;
|
|
156
174
|
}
|
|
157
175
|
|
|
158
176
|
/** The poll-cadence policy, as a pure function of the last cycle's outcome.
|
|
@@ -243,6 +261,7 @@ export class SynchronizedTreeState {
|
|
|
243
261
|
pollingInterval,
|
|
244
262
|
stopPollingDelay,
|
|
245
263
|
logStat,
|
|
264
|
+
rootsNeverFinal,
|
|
246
265
|
} = ops;
|
|
247
266
|
this.pruning = pruning;
|
|
248
267
|
this.fieldFilter = fieldFilter;
|
|
@@ -256,7 +275,15 @@ export class SynchronizedTreeState {
|
|
|
256
275
|
logger?.info(`tree loading algorithm: ${this.algorithm} (traversalMode=${this.traversalMode})`);
|
|
257
276
|
this.pollingInterval = pollingInterval;
|
|
258
277
|
this.effectivePollingInterval = pollingInterval;
|
|
259
|
-
|
|
278
|
+
const basePredicate = finalPredicateOverride ?? pl.finalPredicate;
|
|
279
|
+
// Explicit roots only: they are fixed before the first resource is ever evaluated, so no
|
|
280
|
+
// root can have been marked final before the option applied to it.
|
|
281
|
+
const explicitRoots = new Set(
|
|
282
|
+
seeds.flatMap((s): SignedResourceId[] => (s.kind === "resource" ? [s.root] : [])),
|
|
283
|
+
);
|
|
284
|
+
this.finalPredicate = rootsNeverFinal
|
|
285
|
+
? (r) => !explicitRoots.has(r.id) && basePredicate(r)
|
|
286
|
+
: basePredicate;
|
|
260
287
|
this.logStat = logStat;
|
|
261
288
|
|
|
262
289
|
this.explicitRoots = seeds
|
|
@@ -467,7 +494,8 @@ export class SynchronizedTreeState {
|
|
|
467
494
|
// adds roots later via setRoots(), which schedules the next refresh. Explicit-root trees
|
|
468
495
|
// never hit this (their root set is non-empty by construction).
|
|
469
496
|
if (request.seedResources.length === 0 && request.finalResources.size === 0) return;
|
|
470
|
-
const
|
|
497
|
+
const checkedRoots = this.state.nonFinalRoots();
|
|
498
|
+
const { data, nextToken, rootsExist } = await this.pl.withReadTx(
|
|
471
499
|
"ReadingTree",
|
|
472
500
|
async (tx) => {
|
|
473
501
|
// Started, not awaited, before the walk. The token dates the transaction rather than
|
|
@@ -477,6 +505,12 @@ export class SynchronizedTreeState {
|
|
|
477
505
|
// because requests pipeline on one bidi stream and withReadTx does not await the open.
|
|
478
506
|
const tokenPromise =
|
|
479
507
|
this.algorithm === "backend-delta" ? tx.getNextSinceToken() : undefined;
|
|
508
|
+
// A walk seeded at a deleted resource yields nothing and no error, so a deleted root
|
|
509
|
+
// would stay in the mirror for good. Checked in the same transaction, pipelined the
|
|
510
|
+
// same way as the token. The catch only keeps a rejection that lands while the walk is
|
|
511
|
+
// still running from being reported as unhandled; it is awaited below.
|
|
512
|
+
const existence = Promise.all(checkedRoots.map((rid) => tx.resourceExists(rid)));
|
|
513
|
+
existence.catch(() => {});
|
|
480
514
|
const data = await loadTreeState(
|
|
481
515
|
tx,
|
|
482
516
|
request,
|
|
@@ -485,14 +519,32 @@ export class SynchronizedTreeState {
|
|
|
485
519
|
this.algorithm,
|
|
486
520
|
this.logger,
|
|
487
521
|
);
|
|
488
|
-
return { data, nextToken: await tokenPromise };
|
|
522
|
+
return { data, nextToken: await tokenPromise, rootsExist: await existence };
|
|
489
523
|
},
|
|
490
524
|
txOps,
|
|
491
525
|
);
|
|
492
526
|
this.state.updateFromResourceData(data, { allowOrphanInputs: true, stat: stats });
|
|
493
527
|
|
|
528
|
+
// Repeated until nothing more drops: a deleted root held by another deleted root is
|
|
529
|
+
// refused (still referenced) until its holder has been dropped.
|
|
530
|
+
let gone = checkedRoots.filter((_, i) => !rootsExist[i]);
|
|
531
|
+
let dropped = true;
|
|
532
|
+
while (dropped) {
|
|
533
|
+
dropped = false;
|
|
534
|
+
gone = gone.filter((rid) => {
|
|
535
|
+
if (!this.state.dropDeletedRoot(rid)) return true;
|
|
536
|
+
dropped = true;
|
|
537
|
+
if (stats) stats.rootsDropped++;
|
|
538
|
+
this.logger?.warn(
|
|
539
|
+
`tree root ${resourceIdToString(rid)} no longer exists; dropped from the tree`,
|
|
540
|
+
);
|
|
541
|
+
return false;
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
494
545
|
// Only with the whole batch applied: advancing past a partial apply loses the dropped
|
|
495
|
-
// resources for good. A throw above leaves the old token
|
|
546
|
+
// resources for good. A throw above leaves the old token; for an update error the loop then
|
|
547
|
+
// rebuilds the mirror and discards it.
|
|
496
548
|
if (nextToken !== undefined) this.deltaToken = nextToken;
|
|
497
549
|
else if (this.algorithm === "backend-delta") this.demoteFromDelta();
|
|
498
550
|
}
|
|
@@ -574,6 +626,9 @@ export class SynchronizedTreeState {
|
|
|
574
626
|
// paces the discovery poll for shared-type seeds; 0 forces discovery on the first pass.
|
|
575
627
|
let lastDiscovery = 0;
|
|
576
628
|
|
|
629
|
+
// Set while the tree is being rebuilt after consecutive update errors; spaces the rebuilds.
|
|
630
|
+
let rebuildRetry: InfiniteRetryState | undefined;
|
|
631
|
+
|
|
577
632
|
while (true) {
|
|
578
633
|
if (!this.keepRunning || this.terminated) break;
|
|
579
634
|
|
|
@@ -615,6 +670,8 @@ export class SynchronizedTreeState {
|
|
|
615
670
|
);
|
|
616
671
|
lastUpdate = Date.now();
|
|
617
672
|
|
|
673
|
+
rebuildRetry = undefined;
|
|
674
|
+
|
|
618
675
|
// notifying that we got new state
|
|
619
676
|
if (toNotify !== undefined) for (const n of toNotify) n.resolve();
|
|
620
677
|
} catch (e: any) {
|
|
@@ -630,8 +687,20 @@ export class SynchronizedTreeState {
|
|
|
630
687
|
|
|
631
688
|
// catching tree update errors, as they may leave our tree in inconsistent state
|
|
632
689
|
if (e instanceof TreeStateUpdateError) {
|
|
690
|
+
rebuildRetry =
|
|
691
|
+
rebuildRetry === undefined
|
|
692
|
+
? createInfiniteRetryState(REBUILD_RETRY)
|
|
693
|
+
: nextInfiniteRetryState(rebuildRetry);
|
|
694
|
+
|
|
633
695
|
// important error logging, this should never happen
|
|
634
|
-
this.logger?.error(
|
|
696
|
+
this.logger?.error(
|
|
697
|
+
new Error(
|
|
698
|
+
`tree rebuilt after an update error; next read in ${Math.round(rebuildRetry.nextDelay)}ms`,
|
|
699
|
+
{
|
|
700
|
+
cause: e,
|
|
701
|
+
},
|
|
702
|
+
),
|
|
703
|
+
);
|
|
635
704
|
|
|
636
705
|
// marking everybody who used previous state as changed
|
|
637
706
|
this.state.invalidateTree("stat update error");
|
|
@@ -640,14 +709,15 @@ export class SynchronizedTreeState {
|
|
|
640
709
|
// The new mirror holds nothing, so the old token would skip everything.
|
|
641
710
|
this.discardDeltaToken("tree rebuilt after update error");
|
|
642
711
|
|
|
643
|
-
// scheduling state update without delay
|
|
644
|
-
continue;
|
|
645
|
-
|
|
646
712
|
// unfortunately external observer may still see tree in its default
|
|
647
713
|
// empty state, though this is best we can do in this exceptional
|
|
648
714
|
// situation, and hope on caching layers inside computables to present
|
|
649
715
|
// some stale state until we reconstruct the tree again
|
|
650
|
-
} else
|
|
716
|
+
} else {
|
|
717
|
+
// Not an inconsistency: the ordinary cadence applies, not the rebuild backoff.
|
|
718
|
+
rebuildRetry = undefined;
|
|
719
|
+
this.logger?.warn(e);
|
|
720
|
+
}
|
|
651
721
|
}
|
|
652
722
|
|
|
653
723
|
if (!this.keepRunning || this.terminated) break;
|
|
@@ -665,10 +735,16 @@ export class SynchronizedTreeState {
|
|
|
665
735
|
|
|
666
736
|
if (!this.keepRunning || this.terminated) break;
|
|
667
737
|
|
|
668
|
-
// Phase 2:
|
|
669
|
-
//
|
|
738
|
+
// Phase 2: the interruptible remainder — up to pollingInterval, or, while rebuilding, up
|
|
739
|
+
// to the rebuild backoff, so a persistent update error cannot become a hot loop of full
|
|
740
|
+
// reads. The polling interval does not apply while rebuilding: readers see the empty
|
|
741
|
+
// rebuilt tree until the next read. A nudge (scheduleOnNextState) cuts either short; the
|
|
742
|
+
// floor above still bounds nudged reads.
|
|
670
743
|
if (this.scheduledOnNextState.length === 0) {
|
|
671
|
-
const remaining = Math.max(
|
|
744
|
+
const remaining = Math.max(
|
|
745
|
+
0,
|
|
746
|
+
(rebuildRetry?.nextDelay ?? this.effectivePollingInterval) - MIN_POLLING_INTERVAL_MS,
|
|
747
|
+
);
|
|
672
748
|
if (remaining > 0) {
|
|
673
749
|
try {
|
|
674
750
|
this.currentLoopDelayInterrupt = new AbortController();
|
|
@@ -711,6 +787,12 @@ export class SynchronizedTreeState {
|
|
|
711
787
|
this.terminated = true;
|
|
712
788
|
this.abortController.abort();
|
|
713
789
|
|
|
790
|
+
// Refreshes still queued would otherwise never settle: the loop takes them only at the top
|
|
791
|
+
// of an iteration, and a terminated loop runs no further iteration.
|
|
792
|
+
const pending = this.scheduledOnNextState;
|
|
793
|
+
this.scheduledOnNextState = [];
|
|
794
|
+
for (const n of pending) n.reject(new Error("tree synchronization is terminated"));
|
|
795
|
+
|
|
714
796
|
if (this.currentLoop === undefined) return;
|
|
715
797
|
await this.currentLoop;
|
|
716
798
|
|