@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/dist/delta_sync.cjs +114 -0
- package/dist/delta_sync.cjs.map +1 -0
- package/dist/delta_sync.js +114 -0
- package/dist/delta_sync.js.map +1 -0
- package/dist/index.cjs +3 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/sync.cjs +45 -11
- package/dist/sync.cjs.map +1 -1
- package/dist/sync.d.ts +42 -7
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +43 -12
- package/dist/sync.js.map +1 -1
- package/dist/synchronized_tree.cjs +46 -3
- package/dist/synchronized_tree.cjs.map +1 -1
- package/dist/synchronized_tree.d.ts +28 -1
- package/dist/synchronized_tree.d.ts.map +1 -1
- package/dist/synchronized_tree.js +47 -4
- package/dist/synchronized_tree.js.map +1 -1
- package/package.json +5 -5
- package/src/algorithm_equivalence.test.ts +197 -0
- package/src/delta_benchmark.test.ts +260 -0
- package/src/delta_sync.test.ts +490 -0
- package/src/delta_sync.ts +221 -0
- package/src/delta_sync_live.test.ts +259 -0
- package/src/sync.test.ts +74 -1
- package/src/sync.ts +107 -27
- package/src/synchronized_tree.ts +88 -6
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
|
|
40
|
-
*
|
|
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<
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
[
|
|
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
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
}
|
package/src/synchronized_tree.ts
CHANGED
|
@@ -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 {
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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;
|