@milaboratories/pl-tree 1.14.4 → 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.
@@ -0,0 +1,260 @@
1
+ import { test } from "vitest";
2
+ import { field, hasCapability, TestHelpers } from "@milaboratories/pl-client";
3
+ import type { PlClient, PlTransaction, SignedResourceId } from "@milaboratories/pl-client";
4
+ import { DefaultFinalResourceDataPredicate } from "@milaboratories/pl-client";
5
+ import { TestStructuralResourceType1 } from "./test_utils";
6
+ import { PlTreeState } from "./state";
7
+ import { constructTreeLoadingRequest, initialTreeLoadingStat, loadTreeState } from "./sync";
8
+ import type { TraversalMode, TreeLoadingStat } from "./sync";
9
+ import type { FieldData } from "@milaboratories/pl-client";
10
+ import type { ExtendedResourceData } from "./state";
11
+
12
+ /**
13
+ * Cost comparison across the tree loading algorithms, crossed with pruning on and off.
14
+ *
15
+ * Asserts nothing, and no-ops without `PL_TREE_BENCH=1`. Drives `loadTreeState` directly so
16
+ * each poll is one deliberate round and the stat object is visible, neither of which is true
17
+ * through `SynchronizedTreeState`.
18
+ *
19
+ * PL_TREE_BENCH=1 pnpm exec vitest run src/delta_benchmark.test.ts
20
+ *
21
+ * Delta arms report as skipped on a backend without `treeChangedSince:v1`, rather than
22
+ * silently measuring the fallback.
23
+ */
24
+
25
+ /** Without a payload every struct is empty and the downlink-bytes column - the whole point of
26
+ * delta - reads as zero on every arm. */
27
+ const PAYLOAD = Buffer.alloc(2048, "x");
28
+
29
+ /** Modest on purpose: the shape of the numbers shows up well before a 7k-resource project.
30
+ * Note the seed count scales with the mirror, so this is too small to price that. */
31
+ const CHILDREN = 12;
32
+ const GRANDCHILDREN = 6;
33
+ /** Polls per arm after the initial load. */
34
+ const POLL_CYCLES = 3;
35
+
36
+ type Arm = { label: string; mode: TraversalMode; pruning: boolean };
37
+
38
+ const ARMS: Arm[] = [
39
+ { label: "client-bfs prune=on ", mode: "client-bfs", pruning: true },
40
+ { label: "client-bfs prune=off", mode: "client-bfs", pruning: false },
41
+ { label: "backend-streaming prune=on ", mode: "backend-streaming", pruning: true },
42
+ { label: "backend-streaming prune=off", mode: "backend-streaming", pruning: false },
43
+ { label: "backend-delta prune=on ", mode: "backend-delta", pruning: true },
44
+ { label: "backend-delta prune=off", mode: "backend-delta", pruning: false },
45
+ ];
46
+
47
+ /** Stands in for the real project pruning, without importing the middle layer. */
48
+ const benchPruning = (r: ExtendedResourceData): FieldData[] =>
49
+ r.fields.filter((f) => !f.name.startsWith("pruneMe"));
50
+
51
+ async function seedTree(
52
+ pl: PlClient,
53
+ ): Promise<{ root: SignedResourceId; leaves: SignedResourceId[] }> {
54
+ return await pl.withWriteTx(
55
+ "BenchSeed",
56
+ async (tx) => {
57
+ const root = tx.createStruct(TestStructuralResourceType1, PAYLOAD);
58
+ const rootField = field(tx.clientRoot, "benchRoot");
59
+ tx.createField(rootField, "Dynamic");
60
+ tx.setField(rootField, root);
61
+
62
+ const leaves: Promise<SignedResourceId>[] = [];
63
+ for (let c = 0; c < CHILDREN; c++) {
64
+ const child = tx.createStruct(TestStructuralResourceType1, PAYLOAD);
65
+ const cf = field(root, `child${c}`);
66
+ tx.createField(cf, "Dynamic");
67
+ tx.setField(cf, child);
68
+
69
+ // A field the pruning function removes, so prune=on and prune=off differ.
70
+ const pruned = tx.createStruct(TestStructuralResourceType1, PAYLOAD);
71
+ const pf = field(child, "pruneMe");
72
+ tx.createField(pf, "Dynamic");
73
+ tx.setField(pf, pruned);
74
+
75
+ for (let g = 0; g < GRANDCHILDREN; g++) {
76
+ const grand = tx.createStruct(TestStructuralResourceType1, PAYLOAD);
77
+ const gf = field(child, `g${g}`);
78
+ tx.createField(gf, "Dynamic");
79
+ tx.setField(gf, grand);
80
+ leaves.push(grand.globalId);
81
+ }
82
+ }
83
+
84
+ await tx.commit();
85
+ return { root: await root.globalId, leaves: await Promise.all(leaves) };
86
+ },
87
+ { sync: true },
88
+ );
89
+ }
90
+
91
+ /** One mutation between polls: a KV write on a leaf. The parent is not rewritten, so this is
92
+ * the quiet-parent shape, which is the case frontier seeding exists to reach. */
93
+ async function touchLeaf(pl: PlClient, leaf: SignedResourceId, arm: string, round: number) {
94
+ await pl.withWriteTx(
95
+ "BenchTouch",
96
+ async (tx: PlTransaction) => {
97
+ // Keyed by arm: the tree is shared and never reset, and state.ts compares KV values,
98
+ // so a later arm rewriting the same key with the same bytes observes no change at all.
99
+ tx.setKValue(leaf, `bench-${arm}-${round}`, Buffer.from(`r${round}`));
100
+ await tx.commit();
101
+ },
102
+ { sync: true },
103
+ );
104
+ }
105
+
106
+ type Row = {
107
+ arm: string;
108
+ roundTrips: number;
109
+ resources: number;
110
+ bytes: number;
111
+ seeds: number;
112
+ resolutions: number;
113
+ unchanged: number;
114
+ wastedBytes: number;
115
+ /** Steady-state only: the cold load's `resourcesNew` would dwarf it and hide a lost
116
+ * update inside a sum of ~100. */
117
+ changedSteady: number;
118
+ prunedFields: number;
119
+ ms: number;
120
+ };
121
+
122
+ async function runArm(
123
+ pl: PlClient,
124
+ arm: Arm,
125
+ seed: { root: SignedResourceId; leaves: SignedResourceId[] },
126
+ ): Promise<Row | undefined> {
127
+ const caps = pl.serverInfo.capabilities ?? [];
128
+ if (arm.mode === "backend-delta" && !hasCapability(caps, "treeChangedSince:v1")) return undefined;
129
+
130
+ // Scalar, not an array: the constructor takes SignedResourceId | Set<SignedResourceId>.
131
+ const state = new PlTreeState(seed.root, DefaultFinalResourceDataPredicate);
132
+ const stat: TreeLoadingStat = initialTreeLoadingStat();
133
+ let token: Uint8Array | undefined;
134
+ let changedAtColdLoad = 0;
135
+
136
+ // Cold load plus POLL_CYCLES polls, one mutation before each. The cold load is included on
137
+ // purpose: it is where the arms are meant to look alike.
138
+ for (let cycle = 0; cycle <= POLL_CYCLES; cycle++) {
139
+ if (cycle > 0) {
140
+ const leaf = seed.leaves[cycle % seed.leaves.length];
141
+ if (leaf !== undefined)
142
+ await touchLeaf(pl, leaf, arm.label.trim().replace(/\s+/g, "-"), cycle);
143
+ }
144
+
145
+ const request = constructTreeLoadingRequest(state, {
146
+ pruningFunction: arm.pruning ? benchPruning : undefined,
147
+ changedSinceToken: token,
148
+ });
149
+ if (request.seedResources.length === 0 && request.finalResources.size === 0) continue;
150
+
151
+ const { data, next } = await pl.withReadTx("BenchRead", async (tx) => {
152
+ const next = arm.mode === "backend-delta" ? await tx.getNextSinceToken() : undefined;
153
+ const data = await loadTreeState(tx, request, stat, caps, arm.mode);
154
+ return { data, next };
155
+ });
156
+
157
+ state.updateFromResourceData(data, { allowOrphanInputs: true, stat });
158
+ if (next !== undefined) token = next;
159
+
160
+ // Freeze the cold-load contribution so the steady-state figure below is only the polls.
161
+ if (cycle === 0) changedAtColdLoad = stat.resourcesNew + stat.resourcesChanged;
162
+ }
163
+
164
+ return {
165
+ arm: arm.label,
166
+ roundTrips: stat.roundTrips,
167
+ resources: stat.retrievedResources,
168
+ bytes: stat.retrievedResourceDataBytes + stat.retrievedKeyValueBytes,
169
+ seeds: stat.deltaSeedsSent,
170
+ resolutions: stat.deltaResolutionRounds,
171
+ unchanged: stat.resourcesUnchanged,
172
+ wastedBytes: stat.bytesUnchanged,
173
+ changedSteady: stat.resourcesNew + stat.resourcesChanged - changedAtColdLoad,
174
+ prunedFields: stat.prunedFields,
175
+ ms: stat.millisSpent,
176
+ };
177
+ }
178
+
179
+ function report(rows: Row[], skipped: string[], failed: string[] = []) {
180
+ const pad = (s: string | number, n: number) => String(s).padStart(n);
181
+ const lines = [
182
+ "",
183
+ "=== tree loading cost ===",
184
+ `tree: ${CHILDREN} children x ${GRANDCHILDREN} grandchildren, ${POLL_CYCLES} polls after load,`,
185
+ ` one KV write on a leaf between polls (quiet-parent shape)`,
186
+ "",
187
+ `${"arm".padEnd(28)} ${pad("trips", 6)} ${pad("res", 6)} ${pad("bytes", 8)} ${pad("seeds", 6)} ${pad("resolv", 7)} ${pad("unchgd", 7)} ${pad("wasted", 8)} ${pad("chgd", 5)} ${pad("pruned", 7)} ${pad("ms", 7)}`,
188
+ ];
189
+ for (const r of rows) {
190
+ lines.push(
191
+ `${r.arm.padEnd(28)} ${pad(r.roundTrips, 6)} ${pad(r.resources, 6)} ${pad(r.bytes, 8)} ${pad(r.seeds, 6)} ${pad(r.resolutions, 7)} ${pad(r.unchanged, 7)} ${pad(r.wastedBytes, 8)} ${pad(r.changedSteady, 5)} ${pad(r.prunedFields, 7)} ${pad(r.ms, 7)}`,
192
+ );
193
+ }
194
+
195
+ // The correctness guard, and it has to be the change COUNT. Each arm makes exactly
196
+ // POLL_CYCLES mutations, so each must observe that many changes; fewer means an update was
197
+ // lost, not that work was saved. Mirror CONTENTS cannot be compared across arms, since
198
+ // per-arm keys on a shared tree leave later arms legitimately holding more KV.
199
+ lines.push("");
200
+ for (const r of rows) {
201
+ if (r.changedSteady === POLL_CYCLES) continue;
202
+ lines.push(
203
+ `LOST UPDATES: ${r.arm.trim()} saw ${r.changedSteady} of ${POLL_CYCLES} steady changes`,
204
+ );
205
+ }
206
+ if (rows.every((r) => r.changedSteady === POLL_CYCLES)) {
207
+ lines.push(`change check: every arm observed all ${POLL_CYCLES} steady-state changes`);
208
+ }
209
+
210
+ if (skipped.length > 0) {
211
+ lines.push("", `skipped (backend lacks treeChangedSince:v1): ${skipped.join(", ")}`);
212
+ }
213
+ if (failed.length > 0) {
214
+ lines.push("", "FAILED ARMS:");
215
+ for (const f of failed) lines.push(` ${f}`);
216
+ }
217
+
218
+ lines.push(
219
+ "",
220
+ " res/bytes what the arm actually pulled down; lower is the win",
221
+ " unchgd resources re-fetched only to be found unchanged. Delta drives this down but",
222
+ " not to 0: a resolution round fetches unconditionally, so an unchanged",
223
+ " resolved resource legitimately lands here",
224
+ " wasted bytes in that unchanged bucket",
225
+ " chgd steady-state changes only, cold load excluded. Compare across arms: fewer",
226
+ " means an update was lost, not that work was saved",
227
+ " pruned fields dropped client-side. Backend arms prune after the frames arrive, so",
228
+ " their trips/res/bytes do NOT differ between prune=on and prune=off; only the",
229
+ " BFS arms avoid traversing a pruned field",
230
+ " seeds seed ids sent, summed over rounds; scales",
231
+ " with the mirror, so this tree is too small to show the real uplink cost",
232
+ "",
233
+ );
234
+ console.log(lines.join("\n"));
235
+ }
236
+
237
+ test("benchmark: tree loading cost by algorithm", async () => {
238
+ if (process.env.PL_TREE_BENCH !== "1") return;
239
+
240
+ await TestHelpers.withTempRoot(async (pl) => {
241
+ const seed = await seedTree(pl);
242
+ const rows: Row[] = [];
243
+ const skipped: string[] = [];
244
+
245
+ const failed: string[] = [];
246
+ for (const arm of ARMS) {
247
+ // One arm failing must not lose the other five: this runs against a real backend, and
248
+ // an algorithm that errors is itself a result worth reporting.
249
+ try {
250
+ const row = await runArm(pl, arm, seed);
251
+ if (row === undefined) skipped.push(arm.label.trim());
252
+ else rows.push(row);
253
+ } catch (e: unknown) {
254
+ failed.push(`${arm.label.trim()}: ${e instanceof Error ? e.message : String(e)}`);
255
+ }
256
+ }
257
+
258
+ report(rows, skipped, failed);
259
+ });
260
+ }, 600_000);