@milaboratories/pl-tree 1.14.4 → 1.15.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.
@@ -16,8 +16,19 @@ import {
16
16
  } from "@milaboratories/pl-client";
17
17
  import type { ExtendedResourceData } from "./state";
18
18
  import { PlTreeState, TreeStateUpdateError } from "./state";
19
- import type { PruningFunction, TraversalMode, TreeLoadingStat } from "./sync";
20
- import { constructTreeLoadingRequest, initialTreeLoadingStat, loadTreeState } from "./sync";
19
+ import type {
20
+ PruningFunction,
21
+ TraversalMode,
22
+ TreeLoadingAlgorithmName,
23
+ TreeLoadingStat,
24
+ } from "./sync";
25
+ import {
26
+ constructTreeLoadingRequest,
27
+ initialTreeLoadingStat,
28
+ loadTreeState,
29
+ resolveTreeLoadingAlgorithm,
30
+ supportsResourceTreeTraversal,
31
+ } from "./sync";
21
32
  import type { PersistedTree } from "./persisted_tree";
22
33
  import { captureTreeState, restoreTreeState } from "./persisted_tree";
23
34
  import * as tp from "node:timers/promises";
@@ -61,7 +72,10 @@ export type SynchronizedTreeOps = {
61
72
  /** ResourceTree field filter for modern backend path. */
62
73
  fieldFilter?: Filter;
63
74
 
64
- /** ResourceTree traversal stop rules for modern backend path. */
75
+ /** ResourceTree traversal stop rules for the streaming path.
76
+ * @deprecated the backend ignores these under a change token, and the delta algorithm - what
77
+ * `auto` now picks on a capable backend - never sends them. They still prune a
78
+ * `backend-streaming` walk and a token-less delta poll's fallback. */
65
79
  traverseStopRules?: Filter;
66
80
 
67
81
  /** Interval after last sync to sleep before the next one */
@@ -185,6 +199,15 @@ export class SynchronizedTreeState {
185
199
  private readonly fieldFilter?: Filter;
186
200
  private readonly traverseStopRules?: Filter;
187
201
  private readonly traversalMode: TraversalMode;
202
+ /** Resolved once from {@link traversalMode} and the server's capabilities, and used by every
203
+ * poll of this tree. Selecting per poll was only sound while no algorithm kept state between
204
+ * polls; pinning it here is what lets one do so. Only ever reassigned by the one-way
205
+ * demotion in {@link loadAndApply} when the backend advertises delta but issues no token. */
206
+ private algorithm: TreeLoadingAlgorithmName;
207
+ /** Change token the last successful delta apply was dated at, handed to the next poll so
208
+ * the backend sends only what moved since. Undefined until the first delta poll commits
209
+ * one, and again whenever {@link discardDeltaToken} drops it. */
210
+ private deltaToken: Uint8Array | undefined;
188
211
  private readonly logStat?: StatLoggingMode;
189
212
  private readonly hooks: PollingComputableHooks;
190
213
  private readonly abortController = new AbortController();
@@ -225,6 +248,12 @@ export class SynchronizedTreeState {
225
248
  this.fieldFilter = fieldFilter;
226
249
  this.traverseStopRules = traverseStopRules;
227
250
  this.traversalMode = traversalMode ?? "auto";
251
+ this.algorithm = resolveTreeLoadingAlgorithm(
252
+ this.traversalMode,
253
+ pl.serverInfo.capabilities ?? [],
254
+ logger,
255
+ );
256
+ logger?.info(`tree loading algorithm: ${this.algorithm} (traversalMode=${this.traversalMode})`);
228
257
  this.pollingInterval = pollingInterval;
229
258
  this.effectivePollingInterval = pollingInterval;
230
259
  this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;
@@ -430,6 +459,7 @@ export class SynchronizedTreeState {
430
459
  pruningFunction: this.pruning,
431
460
  fieldFilter: this.fieldFilter,
432
461
  traverseStopRules: this.traverseStopRules,
462
+ changedSinceToken: this.deltaToken,
433
463
  });
434
464
  // A shared-type-seed tree with no currently-discovered roots is legitimately empty:
435
465
  // there is nothing to traverse, and tx.resourceTree([]) would throw "at least one seed
@@ -437,21 +467,63 @@ export class SynchronizedTreeState {
437
467
  // adds roots later via setRoots(), which schedules the next refresh. Explicit-root trees
438
468
  // never hit this (their root set is non-empty by construction).
439
469
  if (request.seedResources.length === 0 && request.finalResources.size === 0) return;
440
- const data = await this.pl.withReadTx(
470
+ const { data, nextToken } = await this.pl.withReadTx(
441
471
  "ReadingTree",
442
472
  async (tx) => {
443
- return await loadTreeState(
473
+ // Started, not awaited, before the walk. The token dates the transaction rather than
474
+ // the response, so it is not an input to the request - the request carries the
475
+ // PREVIOUS poll's token. Awaiting it here would block on the tx-open response before
476
+ // sending the tree request, costing a whole round trip that streaming does not pay,
477
+ // because requests pipeline on one bidi stream and withReadTx does not await the open.
478
+ const tokenPromise =
479
+ this.algorithm === "backend-delta" ? tx.getNextSinceToken() : undefined;
480
+ const data = await loadTreeState(
444
481
  tx,
445
482
  request,
446
483
  stats,
447
484
  this.pl.serverInfo.capabilities ?? [],
448
- this.traversalMode,
485
+ this.algorithm,
449
486
  this.logger,
450
487
  );
488
+ return { data, nextToken: await tokenPromise };
451
489
  },
452
490
  txOps,
453
491
  );
454
492
  this.state.updateFromResourceData(data, { allowOrphanInputs: true, stat: stats });
493
+
494
+ // 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.
496
+ if (nextToken !== undefined) this.deltaToken = nextToken;
497
+ else if (this.algorithm === "backend-delta") this.demoteFromDelta();
498
+ }
499
+
500
+ /** Give up on delta for the life of this tree, once, when the backend advertises
501
+ * `treeChangedSince:v1` but hands out no token.
502
+ *
503
+ * Without this the tree stays on delta with `deltaToken` permanently unset, and every poll
504
+ * is then a token-less delta poll: a full tree read that also sends no stop rules, so it
505
+ * transfers the subtrees the streaming path prunes away. Nothing else detects it -
506
+ * `deltaSuspectedFullAnswers` only fires when a token WAS sent - so it would run at the
507
+ * poll interval, forever, silently. Streaming is the correct destination: it is what `auto`
508
+ * would have picked without the capability, and it restores the stop rules. */
509
+ private demoteFromDelta() {
510
+ this.algorithm = supportsResourceTreeTraversal(this.pl.serverInfo.capabilities ?? [])
511
+ ? "backend-streaming"
512
+ : "client-bfs";
513
+ this.logger?.warn(
514
+ `tree: backend advertises treeChangedSince:v1 but issued no change token; ` +
515
+ `falling back to ${this.algorithm} for the life of this tree`,
516
+ );
517
+ }
518
+
519
+ /** Discards the change token, so the next delta poll asks for the full tree. Required
520
+ * whenever the mirror stops being a faithful record of what the token says we hold: a
521
+ * rebuild after {@link TreeStateUpdateError}, or a root-set change, which reshapes the
522
+ * traversal the token was earned under. */
523
+ private discardDeltaToken(reason: string) {
524
+ if (this.deltaToken === undefined) return;
525
+ this.deltaToken = undefined;
526
+ this.logger?.info(`tree delta token discarded (${reason}); next poll reads the full tree`);
455
527
  }
456
528
 
457
529
  /** Discovery sync for shared-type seeds: re-polls `ListUserResources` (gRPC-only) and
@@ -477,8 +549,16 @@ export class SynchronizedTreeState {
477
549
  for (const id of ids) discovered.add(id);
478
550
  }
479
551
 
552
+ const rootsChanged =
553
+ discovered.size !== this.discoveredRoots.length ||
554
+ this.discoveredRoots.some((id) => !discovered.has(id));
555
+
480
556
  this.discoveredRoots = [...discovered];
481
557
  this.state.setRoots(this.currentRootSet());
558
+
559
+ // A root arriving brings a subtree the token would skip as unchanged, and one leaving
560
+ // takes its subtree with it. Either way the token no longer describes what we hold.
561
+ if (rootsChanged) this.discardDeltaToken("root set changed");
482
562
  }
483
563
 
484
564
  /** If true this tree state is permanently terminaed. */
@@ -557,6 +637,8 @@ export class SynchronizedTreeState {
557
637
  this.state.invalidateTree("stat update error");
558
638
  // creating new tree with the full current root set (re-discovered on next iteration)
559
639
  this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);
640
+ // The new mirror holds nothing, so the old token would skip everything.
641
+ this.discardDeltaToken("tree rebuilt after update error");
560
642
 
561
643
  // scheduling state update without delay
562
644
  continue;