@milaboratories/pl-tree 1.14.3 → 1.15.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/src/sync.ts CHANGED
@@ -10,6 +10,7 @@ import Denque from "denque";
10
10
  import { hasCapability, isNullSignedResourceId } from "@milaboratories/pl-client";
11
11
  import type { ExtendedResourceData, PlTreeState, ResourceUpdateStat } from "./state";
12
12
  import { ConcurrencyLimitingExecutor, msToHumanReadable } from "@milaboratories/ts-helpers";
13
+ import { loadDeltaTreeState } from "./delta_sync";
13
14
 
14
15
  /** Applied to list of fields in resource data. */
15
16
  export type PruningFunction = (resource: ExtendedResourceData) => FieldData[];
@@ -31,24 +32,81 @@ export interface TreeLoadingRequest {
31
32
  /** ResourceTree field filter passed to the backend when supported. */
32
33
  readonly fieldFilter?: Filter;
33
34
 
34
- /** ResourceTree traversal stop rules passed to the backend when supported. */
35
+ /** ResourceTree traversal stop rules passed to the backend when supported.
36
+ * @deprecated prune with {@link changedSinceToken} instead; ignored by the backend under one. */
35
37
  readonly traverseStopRules?: Filter;
38
+
39
+ /** The tree's roots. Delta seeds at these only when the non-final frontier is empty;
40
+ * resourceTree requires at least one seed. */
41
+ readonly roots: readonly SignedResourceId[];
42
+
43
+ /** Every id the mirror currently holds, final or not. Delta uses it to tell a reference it
44
+ * must resolve from one already satisfied locally. */
45
+ readonly knownResources: ReadonlySet<SignedResourceId>;
46
+
47
+ /** Change token from the transaction this request will run in, for a delta walk. Absent
48
+ * means "send the full tree", which is also what a token the server cannot use gets. */
49
+ readonly changedSinceToken?: Uint8Array;
36
50
  }
37
51
 
38
52
  /** Controls which tree-loading path is used.
39
- * - `"auto"` (default): use backend streaming when the backend advertises `treeFilter:v2`,
40
- * fall back to client-side BFS otherwise.
53
+ * - `"auto"` (default): use delta polling when the backend advertises `treeChangedSince:v1`,
54
+ * else backend streaming when it advertises `treeFilter:v2`, else client-side BFS.
41
55
  * - `"client-bfs"`: always use client-side BFS, even on capable backends.
42
56
  * - `"backend-streaming"`: always prefer backend streaming; if the capability is absent,
43
57
  * logs a warning and falls back to BFS (never throws).
58
+ * - `"backend-delta"`: always prefer delta polling, which hands the backend the transaction's
59
+ * change token and takes only what changed since it; if `treeChangedSince:v1` is absent,
60
+ * logs a warning and falls back to the best available path (never throws).
44
61
  */
45
- export type TraversalMode = "auto" | "client-bfs" | "backend-streaming";
62
+ export type TraversalMode = "auto" | "client-bfs" | "backend-streaming" | "backend-delta";
63
+
64
+ /** A concrete loading algorithm: a {@link TraversalMode} with `"auto"` and any unsupported
65
+ * preference already resolved against the server's capabilities. */
66
+ export type TreeLoadingAlgorithmName = "client-bfs" | "backend-streaming" | "backend-delta";
67
+
68
+ /** Resolves a traversal mode into the algorithm a tree will run. A tree calls this once, when
69
+ * it is made, and keeps the answer for its whole life, so the choice (and the fallback warning
70
+ * for a preference the backend cannot serve) happens once rather than on every poll. */
71
+ export function resolveTreeLoadingAlgorithm(
72
+ mode: TraversalMode,
73
+ capabilities: readonly string[] = [],
74
+ logger?: { warn: (msg: string) => void },
75
+ ): TreeLoadingAlgorithmName {
76
+ const streaming = supportsResourceTreeTraversal(capabilities);
77
+ const delta = supportsTreeDelta(capabilities);
78
+ switch (mode) {
79
+ case "client-bfs":
80
+ return "client-bfs";
81
+ case "backend-delta":
82
+ if (delta) return "backend-delta";
83
+ (logger ?? console).warn(
84
+ "traversalMode=backend-delta but backend lacks treeChangedSince:v1 capability; falling back to " +
85
+ (streaming ? "backend-streaming" : "client-bfs"),
86
+ );
87
+ return streaming ? "backend-streaming" : "client-bfs";
88
+ case "backend-streaming":
89
+ if (streaming) return "backend-streaming";
90
+ (logger ?? console).warn(
91
+ "traversalMode=backend-streaming but backend lacks treeFilter:v2 capability; falling back to BFS",
92
+ );
93
+ return "client-bfs";
94
+ case "auto":
95
+ // Delta first: its cost tracks what changed rather than what the tree holds. Streaming
96
+ // is the fallback for a backend that can shape a walk but not date one.
97
+ if (delta) return "backend-delta";
98
+ return streaming ? "backend-streaming" : "client-bfs";
99
+ }
100
+ }
46
101
 
47
102
  /** Given the current tree state, build the request object to pass to
48
103
  * {@link loadTreeState} to load updated state. */
49
104
  export function constructTreeLoadingRequest(
50
105
  tree: PlTreeState,
51
- options: Pick<TreeLoadingRequest, "pruningFunction" | "fieldFilter" | "traverseStopRules"> = {},
106
+ options: Pick<
107
+ TreeLoadingRequest,
108
+ "pruningFunction" | "fieldFilter" | "traverseStopRules" | "changedSinceToken"
109
+ > = {},
52
110
  ): TreeLoadingRequest {
53
111
  const seedResources: SignedResourceId[] = [];
54
112
  const finalResources = new Set<SignedResourceId>();
@@ -64,9 +122,12 @@ export function constructTreeLoadingRequest(
64
122
  return {
65
123
  seedResources,
66
124
  finalResources,
125
+ roots: [...tree.roots],
126
+ knownResources: materialized,
67
127
  pruningFunction: options.pruningFunction,
68
128
  fieldFilter: options.fieldFilter,
69
129
  traverseStopRules: options.traverseStopRules,
130
+ changedSinceToken: options.changedSinceToken,
70
131
  };
71
132
  }
72
133
 
@@ -85,9 +146,10 @@ export type TreeLoadingStat = ResourceUpdateStat & {
85
146
  stopMarkersSkipped: number;
86
147
  /** Number of follow-up resourceTree() calls issued to resolve unknown stop markers. */
87
148
  stopMarkerFollowUpRoundTrips: number;
88
- /** Streaming (resourceTree) path: resourceTree() streams consumed (1, or 2 with a follow-up). */
149
+ /** Backend paths: resourceTree() streams consumed. Streaming spends 1, or 2 with a
150
+ * follow-up; delta spends 1 plus one per resolution round. */
89
151
  streamRounds: number;
90
- /** Streaming path: resource frames received. */
152
+ /** Backend paths: resource frames received. */
91
153
  resourceFrames: number;
92
154
  /** Streaming path: stopMarker frames received (both skipped and follow-up). */
93
155
  stopMarkerFrames: number;
@@ -99,6 +161,16 @@ export type TreeLoadingStat = ResourceUpdateStat & {
99
161
  bfsResourcesRequested: number;
100
162
  /** BFS path: requested resources that no longer exist (undefined reply). */
101
163
  bfsResourcesNotFound: number;
164
+ /** Delta path: seed ids handed to the backend, summed over every round of the poll.
165
+ * This is what seeding the whole frontier costs on the uplink. */
166
+ deltaSeedsSent: number;
167
+ /** Delta path: extra rounds spent resolving references a delta body pointed at but the
168
+ * response did not carry. */
169
+ deltaResolutionRounds: number;
170
+ /** Delta path: polls that sent a token and got back a response the size of the whole
171
+ * mirror, which is what a refused token looks like from here - rejection is silent, so this
172
+ * is the only tell. Heuristic: a genuinely large change set trips it too. */
173
+ deltaSuspectedFullAnswers: number;
102
174
  };
103
175
 
104
176
  export function initialTreeLoadingStat(): TreeLoadingStat {
@@ -122,6 +194,9 @@ export function initialTreeLoadingStat(): TreeLoadingStat {
122
194
  traverseWasStoppedCount: 0,
123
195
  bfsResourcesRequested: 0,
124
196
  bfsResourcesNotFound: 0,
197
+ deltaSeedsSent: 0,
198
+ deltaResolutionRounds: 0,
199
+ deltaSuspectedFullAnswers: 0,
125
200
  resourcesNew: 0,
126
201
  resourcesChanged: 0,
127
202
  resourcesUnchanged: 0,
@@ -161,15 +236,20 @@ Unchanged bytes (wasted downlink): ${stat.bytesUnchanged}
161
236
  Changed with stable metadata: ${stat.metadataStableChanged}
162
237
  BFS fetches wasted on unchanged: ${stat.bfsRequestsWasted}
163
238
  Used streaming: ${stat.usedStreaming}
164
- [streaming] rounds: ${stat.streamRounds}, resource frames: ${stat.resourceFrames}, stop-marker frames: ${stat.stopMarkerFrames}, stop->follow-up: ${stat.stopMarkersFollowUp}, traverse-stopped: ${stat.traverseWasStoppedCount}
165
- [bfs] resources requested: ${stat.bfsResourcesRequested}, not found: ${stat.bfsResourcesNotFound}`;
239
+ [backend] rounds: ${stat.streamRounds}, resource frames: ${stat.resourceFrames}, stop-marker frames: ${stat.stopMarkerFrames}, stop->follow-up: ${stat.stopMarkersFollowUp}, traverse-stopped: ${stat.traverseWasStoppedCount}
240
+ [bfs] resources requested: ${stat.bfsResourcesRequested}, not found: ${stat.bfsResourcesNotFound}
241
+ [delta] seeds sent: ${stat.deltaSeedsSent}, resolution rounds: ${stat.deltaResolutionRounds}, suspected full answers: ${stat.deltaSuspectedFullAnswers}`;
166
242
  }
167
243
 
168
- function supportsResourceTreeTraversal(capabilities: readonly string[] = []): boolean {
244
+ export function supportsResourceTreeTraversal(capabilities: readonly string[] = []): boolean {
169
245
  return hasCapability(capabilities, "treeFilter:v2");
170
246
  }
171
247
 
172
- function collectStatsForResource(resource: ExtendedResourceData, stats?: TreeLoadingStat) {
248
+ function supportsTreeDelta(capabilities: readonly string[] = []): boolean {
249
+ return hasCapability(capabilities, "treeChangedSince:v1");
250
+ }
251
+
252
+ export function collectStatsForResource(resource: ExtendedResourceData, stats?: TreeLoadingStat) {
173
253
  if (!stats) return;
174
254
  stats.retrievedResources++;
175
255
  stats.retrievedFields += resource.fields.length;
@@ -431,23 +511,23 @@ export async function loadTreeState(
431
511
  if (stats) stats.requests++;
432
512
 
433
513
  try {
434
- const wantsStreaming =
435
- mode === "backend-streaming" ||
436
- (mode === "auto" && supportsResourceTreeTraversal(capabilities));
437
-
438
- if (stats) stats.usedStreaming = wantsStreaming && supportsResourceTreeTraversal(capabilities);
439
-
440
- if (wantsStreaming && !supportsResourceTreeTraversal(capabilities)) {
441
- const msg =
442
- "traversalMode=backend-streaming but backend lacks treeFilter:v2 capability; falling back to BFS";
443
- if (logger) logger.warn(msg);
444
- else console.warn(msg);
445
- return await loadTreeStateViaBfs(tx, loadingRequest, stats);
514
+ // A tree passes its pinned algorithm here, which resolves to itself. The mode form is
515
+ // kept for callers that run a single load.
516
+ const algorithm = resolveTreeLoadingAlgorithm(mode, capabilities, logger);
517
+ // Both backend paths, not just streaming: state.ts reads this to attribute a BFS-only
518
+ // wasted-fetch counter, and leaving it false for delta reports phantom waste.
519
+ if (stats) stats.usedStreaming = algorithm !== "client-bfs";
520
+
521
+ switch (algorithm) {
522
+ case "backend-delta":
523
+ return await loadDeltaTreeState(tx, loadingRequest, stats, logger);
524
+ case "backend-streaming":
525
+ return await loadTreeStateViaResourceTree(tx, loadingRequest, stats, logger);
526
+ case "client-bfs":
527
+ return await loadTreeStateViaBfs(tx, loadingRequest, stats);
528
+ default:
529
+ throw new Error(`unknown tree loading algorithm: ${algorithm as string}`);
446
530
  }
447
-
448
- return wantsStreaming
449
- ? await loadTreeStateViaResourceTree(tx, loadingRequest, stats, logger)
450
- : await loadTreeStateViaBfs(tx, loadingRequest, stats);
451
531
  } finally {
452
532
  if (stats) stats.millisSpent += Date.now() - startTimestamp;
453
533
  }
@@ -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;