@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/state.ts
CHANGED
|
@@ -513,6 +513,53 @@ export class PlTreeState {
|
|
|
513
513
|
const incrementRefs: SignedResourceId[] = [];
|
|
514
514
|
const decrementRefs: SignedResourceId[] = [];
|
|
515
515
|
|
|
516
|
+
// `resourcesAdded` is notified once, after the loop: markChanged detaches every watcher on
|
|
517
|
+
// its first call and no watcher can attach in the middle of this synchronous loop, so a
|
|
518
|
+
// single notification covers every resource added here. It also builds the marker string
|
|
519
|
+
// just once, which is worth doing because resourceIdToString on a signed id parses a
|
|
520
|
+
// BigInt and hex-decodes a Buffer.
|
|
521
|
+
let addedCount = 0;
|
|
522
|
+
let firstAdded: SignedResourceId | undefined;
|
|
523
|
+
|
|
524
|
+
// Diagnostic context for unexpectedTransitionError, refreshed once per resource. It lives
|
|
525
|
+
// out here so the loop body allocates neither a closure nor a snapshot per resource on the
|
|
526
|
+
// path where nothing goes wrong. Only the mutable half of BasicResourceData needs
|
|
527
|
+
// capturing: id, kind, type and data are readonly on PlTreeResource, so they are read back
|
|
528
|
+
// from the resource when the message is built.
|
|
529
|
+
let errRd: ExtendedResourceData;
|
|
530
|
+
let errRes: PlTreeResource | undefined;
|
|
531
|
+
let errOriginalResourceId: OptionalSignedResourceId = NullSignedResourceId;
|
|
532
|
+
let errError: OptionalSignedResourceId = NullSignedResourceId;
|
|
533
|
+
let errInputsLocked = false;
|
|
534
|
+
let errOutputsLocked = false;
|
|
535
|
+
let errResourceReady = false;
|
|
536
|
+
let errFinal = false;
|
|
537
|
+
const unexpectedTransitionError = (reason: string): never => {
|
|
538
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
539
|
+
const { fields, ...rdWithoutFields } = errRd;
|
|
540
|
+
const statBeforeMutation: BasicResourceData | undefined =
|
|
541
|
+
errRes === undefined
|
|
542
|
+
? undefined
|
|
543
|
+
: {
|
|
544
|
+
id: errRes.id,
|
|
545
|
+
kind: errRes.kind,
|
|
546
|
+
type: errRes.type,
|
|
547
|
+
data: errRes.data,
|
|
548
|
+
resourceReady: errResourceReady,
|
|
549
|
+
inputsLocked: errInputsLocked,
|
|
550
|
+
outputsLocked: errOutputsLocked,
|
|
551
|
+
error: errError,
|
|
552
|
+
originalResourceId: errOriginalResourceId,
|
|
553
|
+
final: errFinal,
|
|
554
|
+
};
|
|
555
|
+
this.invalidateTree();
|
|
556
|
+
throw new TreeStateUpdateError(
|
|
557
|
+
`Unexpected resource state transition (${reason}): ${stringifyWithResourceId(
|
|
558
|
+
rdWithoutFields,
|
|
559
|
+
)} -> ${stringifyWithResourceId(statBeforeMutation)}`,
|
|
560
|
+
);
|
|
561
|
+
};
|
|
562
|
+
|
|
516
563
|
// patching / creating resources
|
|
517
564
|
for (const rd of resourceData) {
|
|
518
565
|
let resource = this.resources.get(rd.id);
|
|
@@ -522,20 +569,17 @@ export class PlTreeState {
|
|
|
522
569
|
// they never change; this flag isolates value/flag-only changes from real metadata churn.
|
|
523
570
|
let metadataChanged = false;
|
|
524
571
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
528
|
-
const { fields, ...rdWithoutFields } = rd;
|
|
529
|
-
this.invalidateTree();
|
|
530
|
-
throw new TreeStateUpdateError(
|
|
531
|
-
`Unexpected resource state transition (${reason}): ${stringifyWithResourceId(
|
|
532
|
-
rdWithoutFields,
|
|
533
|
-
)} -> ${stringifyWithResourceId(statBeforeMutation)}`,
|
|
534
|
-
);
|
|
535
|
-
};
|
|
572
|
+
errRd = rd;
|
|
573
|
+
errRes = resource;
|
|
536
574
|
|
|
537
575
|
if (resource !== undefined) {
|
|
538
576
|
// updating existing resource
|
|
577
|
+
errOriginalResourceId = resource.originalResourceId;
|
|
578
|
+
errError = resource.error;
|
|
579
|
+
errInputsLocked = resource.inputsLocked;
|
|
580
|
+
errOutputsLocked = resource.outputsLocked;
|
|
581
|
+
errResourceReady = resource.resourceReady;
|
|
582
|
+
errFinal = resource.finalFlag;
|
|
539
583
|
|
|
540
584
|
if (resource.finalState)
|
|
541
585
|
unexpectedTransitionError("resource state can\t be updated after it is marked as final");
|
|
@@ -569,9 +613,27 @@ export class PlTreeState {
|
|
|
569
613
|
if (stat) stat.errorsAttached++;
|
|
570
614
|
}
|
|
571
615
|
|
|
572
|
-
// updating fields
|
|
616
|
+
// updating fields.
|
|
617
|
+
//
|
|
618
|
+
// Fields normally arrive in the order they are already stored, so the stored ones are
|
|
619
|
+
// walked in lockstep with the incoming ones and matched by direct name comparison
|
|
620
|
+
// rather than through a fieldsMap lookup: every field name in a poll response is
|
|
621
|
+
// freshly decoded from protobuf, so V8 has no cached hash for any of them.
|
|
622
|
+
//
|
|
623
|
+
// Any divergence (reordering, an insertion, a removal) abandons the walk, and the rest
|
|
624
|
+
// of the resource resolves through fieldsMap.get, which is always correct. The walk is
|
|
625
|
+
// abandoned before any fieldsMap.set, so it never observes an insertion made through
|
|
626
|
+
// the live iterator.
|
|
627
|
+
let walk: MapIterator<PlTreeField> | undefined =
|
|
628
|
+
rd.fields.length > 0 ? resource.fieldsMap.values() : undefined;
|
|
573
629
|
for (const fd of rd.fields) {
|
|
574
|
-
let field
|
|
630
|
+
let field: PlTreeField | undefined;
|
|
631
|
+
if (walk !== undefined) {
|
|
632
|
+
const next = walk.next();
|
|
633
|
+
if (next.done === true || next.value.name !== fd.name) walk = undefined;
|
|
634
|
+
else field = next.value;
|
|
635
|
+
}
|
|
636
|
+
if (field === undefined) field = resource.fieldsMap.get(fd.name);
|
|
575
637
|
|
|
576
638
|
if (!field) {
|
|
577
639
|
// new field
|
|
@@ -755,11 +817,23 @@ export class PlTreeState {
|
|
|
755
817
|
if (stat) stat.readyFlips++;
|
|
756
818
|
}
|
|
757
819
|
|
|
758
|
-
// syncing kv
|
|
820
|
+
// syncing kv. Same lockstep walk as the fields above, for the same reason: kv keys
|
|
821
|
+
// arrive in a stable order and are freshly decoded strings. The walk is only set up
|
|
822
|
+
// when there is something to compare against, so a resource with no kv allocates no
|
|
823
|
+
// iterator.
|
|
824
|
+
let kvWalk: MapIterator<[string, Uint8Array]> | undefined =
|
|
825
|
+
rd.kv.length > 0 ? resource.kv.entries() : undefined;
|
|
759
826
|
for (const kv of rd.kv) {
|
|
760
|
-
|
|
827
|
+
let current: Uint8Array | undefined;
|
|
828
|
+
if (kvWalk !== undefined) {
|
|
829
|
+
const next = kvWalk.next();
|
|
830
|
+
if (next.done === true || next.value[0] !== kv.key) kvWalk = undefined;
|
|
831
|
+
else current = next.value[1];
|
|
832
|
+
}
|
|
833
|
+
if (current === undefined) current = resource.kv.get(kv.key);
|
|
761
834
|
if (current === undefined) {
|
|
762
835
|
resource.kv.set(kv.key, kv.value);
|
|
836
|
+
kvWalk = undefined;
|
|
763
837
|
notEmpty(resource.kvChangedPerKey).markChanged(
|
|
764
838
|
kv.key,
|
|
765
839
|
`kv added for ${resourceIdToString(resource.id)}: ${kv.key}`,
|
|
@@ -832,7 +906,8 @@ export class PlTreeState {
|
|
|
832
906
|
|
|
833
907
|
// adding the resource to the heap
|
|
834
908
|
this.resources.set(resource.id, resource);
|
|
835
|
-
|
|
909
|
+
if (addedCount === 0) firstAdded = resource.id;
|
|
910
|
+
addedCount++;
|
|
836
911
|
}
|
|
837
912
|
|
|
838
913
|
if (stat) {
|
|
@@ -849,6 +924,13 @@ export class PlTreeState {
|
|
|
849
924
|
}
|
|
850
925
|
}
|
|
851
926
|
|
|
927
|
+
if (firstAdded !== undefined)
|
|
928
|
+
this.resourcesAdded.markChanged(
|
|
929
|
+
addedCount === 1
|
|
930
|
+
? `new resource ${resourceIdToString(firstAdded)} added`
|
|
931
|
+
: `${addedCount} new resources added, first ${resourceIdToString(firstAdded)}`,
|
|
932
|
+
);
|
|
933
|
+
|
|
852
934
|
// applying refCount increments
|
|
853
935
|
for (const rid of incrementRefs) {
|
|
854
936
|
const res = this.resources.get(rid);
|
package/src/sync.test.ts
CHANGED
|
@@ -6,7 +6,12 @@ import {
|
|
|
6
6
|
TestHelpers,
|
|
7
7
|
} from "@milaboratories/pl-client";
|
|
8
8
|
import { PlTreeState } from "./state";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
constructTreeLoadingRequest,
|
|
11
|
+
initialTreeLoadingStat,
|
|
12
|
+
loadTreeState,
|
|
13
|
+
resolveTreeLoadingAlgorithm,
|
|
14
|
+
} from "./sync";
|
|
10
15
|
import type { TraversalMode } from "./sync";
|
|
11
16
|
import { Computable } from "@milaboratories/computable";
|
|
12
17
|
import { TestStructuralResourceType1 } from "./test_utils";
|
|
@@ -476,6 +481,37 @@ test("stop-marker-unknown-triggers-followup: unknown marker fetched in follow-up
|
|
|
476
481
|
expect(callCount).toBe(2);
|
|
477
482
|
});
|
|
478
483
|
|
|
484
|
+
test("stop-marker-followup-large: a very large follow-up round does not overflow the stack", async () => {
|
|
485
|
+
// Regression: result.push(...followUpResult) threw RangeError once the follow-up round
|
|
486
|
+
// exceeded the argument limit (~200k elements on Node 26).
|
|
487
|
+
const followUpSize = 300_000;
|
|
488
|
+
let callCount = 0;
|
|
489
|
+
const tx = {
|
|
490
|
+
resourceTree: () => {
|
|
491
|
+
callCount++;
|
|
492
|
+
if (callCount === 1) {
|
|
493
|
+
return (async function* () {
|
|
494
|
+
yield makeStopMarker("NG:0x1");
|
|
495
|
+
})();
|
|
496
|
+
} else {
|
|
497
|
+
return (async function* () {
|
|
498
|
+
for (let i = 0; i < followUpSize; i++) yield makeFullResource(`NG:0x${i.toString(16)}`);
|
|
499
|
+
})();
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
} as unknown as Parameters<typeof loadTreeState>[0];
|
|
503
|
+
|
|
504
|
+
const request = {
|
|
505
|
+
seedResources: ["NG:0x99"],
|
|
506
|
+
finalResources: new Set<string>(),
|
|
507
|
+
} as unknown as Parameters<typeof loadTreeState>[1];
|
|
508
|
+
|
|
509
|
+
const result = await loadTreeState(tx, request, undefined, stopMarkerCaps);
|
|
510
|
+
|
|
511
|
+
expect(result).toHaveLength(followUpSize);
|
|
512
|
+
expect(callCount).toBe(2);
|
|
513
|
+
});
|
|
514
|
+
|
|
479
515
|
test("legacy-backend-compat: no stop markers in stream → no follow-up call", async () => {
|
|
480
516
|
// Old backends emit only full-resource frames; pendingFollowUp stays empty
|
|
481
517
|
// and the follow-up loop body never executes.
|
|
@@ -541,3 +577,71 @@ test("orphan-invariant-preserved: error referent streamed alongside stop marker"
|
|
|
541
577
|
expect(ids).toEqual(["NG:0x1", "NG:0xE"]);
|
|
542
578
|
// No throw means the invariant held throughout loadTreeState
|
|
543
579
|
});
|
|
580
|
+
|
|
581
|
+
//
|
|
582
|
+
// Algorithm selection. These pin the contract that a capable backend is polled with a token
|
|
583
|
+
// by default, and that an unavailable preference degrades rather than throwing.
|
|
584
|
+
//
|
|
585
|
+
|
|
586
|
+
test("auto prefers delta, then streaming, then BFS", () => {
|
|
587
|
+
expect(resolveTreeLoadingAlgorithm("auto", ["treeChangedSince:v1", "treeFilter:v2"])).toBe(
|
|
588
|
+
"backend-delta",
|
|
589
|
+
);
|
|
590
|
+
// Delta needs no treeFilter:v2 of its own.
|
|
591
|
+
expect(resolveTreeLoadingAlgorithm("auto", ["treeChangedSince:v1"])).toBe("backend-delta");
|
|
592
|
+
expect(resolveTreeLoadingAlgorithm("auto", ["treeFilter:v2"])).toBe("backend-streaming");
|
|
593
|
+
expect(resolveTreeLoadingAlgorithm("auto", [])).toBe("client-bfs");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("an explicit mode is honoured over what auto would pick", () => {
|
|
597
|
+
const capable = ["treeChangedSince:v1", "treeFilter:v2"];
|
|
598
|
+
expect(resolveTreeLoadingAlgorithm("client-bfs", capable)).toBe("client-bfs");
|
|
599
|
+
expect(resolveTreeLoadingAlgorithm("backend-streaming", capable)).toBe("backend-streaming");
|
|
600
|
+
expect(resolveTreeLoadingAlgorithm("backend-delta", capable)).toBe("backend-delta");
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("backend-delta degrades to the best available path, with a warning, never a throw", () => {
|
|
604
|
+
const warnings: string[] = [];
|
|
605
|
+
const logger = { warn: (m: string) => warnings.push(m) };
|
|
606
|
+
|
|
607
|
+
expect(resolveTreeLoadingAlgorithm("backend-delta", ["treeFilter:v2"], logger)).toBe(
|
|
608
|
+
"backend-streaming",
|
|
609
|
+
);
|
|
610
|
+
expect(resolveTreeLoadingAlgorithm("backend-delta", [], logger)).toBe("client-bfs");
|
|
611
|
+
|
|
612
|
+
expect(warnings).toHaveLength(2);
|
|
613
|
+
for (const w of warnings) expect(w).toContain("treeChangedSince:v1");
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("loadTreeState routes into the delta path and passes the token through", async () => {
|
|
617
|
+
const received: { seeds?: string[]; token?: Uint8Array; stopRules?: unknown } = {};
|
|
618
|
+
const tx = {
|
|
619
|
+
resourceTree: (seeds: string[], opts: Record<string, unknown>) => {
|
|
620
|
+
received.seeds = seeds;
|
|
621
|
+
received.token = opts.changedSinceToken as Uint8Array;
|
|
622
|
+
received.stopRules = opts.traverseStopRules;
|
|
623
|
+
return (async function* () {})();
|
|
624
|
+
},
|
|
625
|
+
} as unknown as Parameters<typeof loadTreeState>[0];
|
|
626
|
+
|
|
627
|
+
const request = {
|
|
628
|
+
seedResources: ["NG:0x1"],
|
|
629
|
+
finalResources: new Set<string>(),
|
|
630
|
+
roots: ["NG:0x1"],
|
|
631
|
+
knownResources: new Set<string>(["NG:0x1"]),
|
|
632
|
+
changedSinceToken: new Uint8Array([9]),
|
|
633
|
+
} as unknown as Parameters<typeof loadTreeState>[1];
|
|
634
|
+
|
|
635
|
+
const stat = initialTreeLoadingStat();
|
|
636
|
+
// Via the mode, not by calling loadDeltaTreeState directly: this is the only test that
|
|
637
|
+
// proves the dispatch in loadTreeState reaches delta at all.
|
|
638
|
+
await loadTreeState(tx, request, stat, ["treeChangedSince:v1"], "auto", { warn: () => {} });
|
|
639
|
+
|
|
640
|
+
expect(received.seeds).toEqual(["NG:0x1"]);
|
|
641
|
+
expect(received.token).toEqual(new Uint8Array([9]));
|
|
642
|
+
expect(received.stopRules).toBeUndefined();
|
|
643
|
+
// True for delta as well as streaming: it marks a backend path, which is what state.ts
|
|
644
|
+
// reads to decide the BFS-only wasted-fetch attribution.
|
|
645
|
+
expect(stat.usedStreaming).toBe(true);
|
|
646
|
+
expect(stat.deltaSeedsSent).toBe(1);
|
|
647
|
+
});
|
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;
|
|
@@ -366,7 +446,7 @@ async function loadTreeStateViaResourceTree(
|
|
|
366
446
|
traverseStopRules,
|
|
367
447
|
});
|
|
368
448
|
|
|
369
|
-
|
|
449
|
+
let { result, followUpSeeds } = await processResourceTreeStream(
|
|
370
450
|
treeItems,
|
|
371
451
|
finalResources,
|
|
372
452
|
pruningFunction,
|
|
@@ -401,7 +481,8 @@ async function loadTreeStateViaResourceTree(
|
|
|
401
481
|
pruningFunction,
|
|
402
482
|
stats,
|
|
403
483
|
);
|
|
404
|
-
|
|
484
|
+
// spreading fails due to exceeding stack argument size
|
|
485
|
+
result = result.concat(followUpResult);
|
|
405
486
|
if (stats) {
|
|
406
487
|
logger?.info?.(
|
|
407
488
|
`loadTreeStateViaResourceTree: follow-up request for ${roundSeeds.length} stop-marker seeds: ${JSON.stringify(roundSeeds)}`,
|
|
@@ -431,23 +512,23 @@ export async function loadTreeState(
|
|
|
431
512
|
if (stats) stats.requests++;
|
|
432
513
|
|
|
433
514
|
try {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
515
|
+
// A tree passes its pinned algorithm here, which resolves to itself. The mode form is
|
|
516
|
+
// kept for callers that run a single load.
|
|
517
|
+
const algorithm = resolveTreeLoadingAlgorithm(mode, capabilities, logger);
|
|
518
|
+
// Both backend paths, not just streaming: state.ts reads this to attribute a BFS-only
|
|
519
|
+
// wasted-fetch counter, and leaving it false for delta reports phantom waste.
|
|
520
|
+
if (stats) stats.usedStreaming = algorithm !== "client-bfs";
|
|
521
|
+
|
|
522
|
+
switch (algorithm) {
|
|
523
|
+
case "backend-delta":
|
|
524
|
+
return await loadDeltaTreeState(tx, loadingRequest, stats, logger);
|
|
525
|
+
case "backend-streaming":
|
|
526
|
+
return await loadTreeStateViaResourceTree(tx, loadingRequest, stats, logger);
|
|
527
|
+
case "client-bfs":
|
|
528
|
+
return await loadTreeStateViaBfs(tx, loadingRequest, stats);
|
|
529
|
+
default:
|
|
530
|
+
throw new Error(`unknown tree loading algorithm: ${algorithm as string}`);
|
|
446
531
|
}
|
|
447
|
-
|
|
448
|
-
return wantsStreaming
|
|
449
|
-
? await loadTreeStateViaResourceTree(tx, loadingRequest, stats, logger)
|
|
450
|
-
: await loadTreeStateViaBfs(tx, loadingRequest, stats);
|
|
451
532
|
} finally {
|
|
452
533
|
if (stats) stats.millisSpent += Date.now() - startTimestamp;
|
|
453
534
|
}
|