@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.
@@ -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 { MiLogger } from "@milaboratories/ts-helpers";
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
- // `fieldsRemoved` is included despite being a per-field count, because it is the one change
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
- this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;
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 { data, nextToken } = await this.pl.withReadTx(
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, so the next poll re-reads it.
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(e);
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 this.logger?.warn(e);
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: optional remainder up to pollingInterval — interruptible by
669
- // scheduleOnNextState so that an external nudge wakes the loop promptly.
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(0, this.effectivePollingInterval - MIN_POLLING_INTERVAL_MS);
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