@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.
- 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/state.cjs +55 -9
- package/dist/state.cjs.map +1 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +55 -9
- package/dist/state.js.map +1 -1
- package/dist/sync.cjs +47 -13
- 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 +45 -14
- 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 +4 -4
- 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/state.test.ts +157 -0
- package/src/state.ts +98 -16
- package/src/sync.test.ts +105 -1
- package/src/sync.ts +110 -29
- package/src/synchronized_tree.ts +88 -6
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;
|