@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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { PlTransaction, SignedResourceId } from "@milaboratories/pl-client";
|
|
2
|
+
import { isNotNullSignedResourceId } from "@milaboratories/pl-client";
|
|
3
|
+
import type { ExtendedResourceData } from "./state";
|
|
4
|
+
import { TreeStateUpdateError } from "./state";
|
|
5
|
+
import type { TreeLoadingRequest, TreeLoadingStat } from "./sync";
|
|
6
|
+
import { collectStatsForResource } from "./sync";
|
|
7
|
+
|
|
8
|
+
/** Emit everything at or below this depth from a resolution seed, whatever its change token
|
|
9
|
+
* says. It bounds descent as well as emission: under a token the walk otherwise ends at the
|
|
10
|
+
* first unchanged resource (api.proto, changed_since_token), so without this a round returns
|
|
11
|
+
* the seeds alone and a newly attached subtree costs one sequential round trip per level.
|
|
12
|
+
*
|
|
13
|
+
* Deep enough to clear a subtree in one round, which is the trade: real server-side walk and
|
|
14
|
+
* downlink for those levels, against a round trip each at ~1.4s on a slow link. Not free -
|
|
15
|
+
* lower it if resolution rounds ever dominate a poll. */
|
|
16
|
+
const RESOLUTION_DEPTH = 32;
|
|
17
|
+
|
|
18
|
+
/** Every id a body points at. Exactly what `updateFromResourceData` refcounts, and it throws
|
|
19
|
+
* `orphan resource` for any that resolves to nothing. */
|
|
20
|
+
function referencesOf(resource: ExtendedResourceData): SignedResourceId[] {
|
|
21
|
+
const refs: SignedResourceId[] = [];
|
|
22
|
+
if (isNotNullSignedResourceId(resource.error)) refs.push(resource.error);
|
|
23
|
+
for (const field of resource.fields) {
|
|
24
|
+
if (isNotNullSignedResourceId(field.value)) refs.push(field.value);
|
|
25
|
+
if (isNotNullSignedResourceId(field.error)) refs.push(field.error);
|
|
26
|
+
}
|
|
27
|
+
return refs;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One delta poll: hand the backend this transaction's change token, take only what is newer.
|
|
32
|
+
*
|
|
33
|
+
* The caller commits the token, and only after the whole batch applies - advancing it past a
|
|
34
|
+
* partial apply loses the dropped resources for good.
|
|
35
|
+
*
|
|
36
|
+
* Removals arrive as a rewrite of the referring resource, never as absence, and the refcount
|
|
37
|
+
* GC does the rest. There is deliberately no periodic full reconcile; the blind spot is a
|
|
38
|
+
* removal under a resource the mirror marked final, which streaming and BFS share.
|
|
39
|
+
*/
|
|
40
|
+
export async function loadDeltaTreeState(
|
|
41
|
+
tx: PlTransaction,
|
|
42
|
+
loadingRequest: TreeLoadingRequest,
|
|
43
|
+
stats?: TreeLoadingStat,
|
|
44
|
+
logger?: { warn: (msg: string) => void },
|
|
45
|
+
): Promise<ExtendedResourceData[]> {
|
|
46
|
+
const {
|
|
47
|
+
seedResources,
|
|
48
|
+
finalResources,
|
|
49
|
+
roots,
|
|
50
|
+
knownResources,
|
|
51
|
+
pruningFunction,
|
|
52
|
+
fieldFilter,
|
|
53
|
+
traverseStopRules,
|
|
54
|
+
changedSinceToken,
|
|
55
|
+
} = loadingRequest;
|
|
56
|
+
|
|
57
|
+
// Stop rules are never sent. The backend ignores them under a usable token anyway, but a
|
|
58
|
+
// token-less poll then walks unpruned where streaming would have pruned. Sending them only
|
|
59
|
+
// when the token is absent would change the request shape between polls, which a token
|
|
60
|
+
// holder must not do - so the cost is warned about instead.
|
|
61
|
+
if (traverseStopRules !== undefined && changedSinceToken === undefined) {
|
|
62
|
+
logger?.warn(
|
|
63
|
+
"delta poll: traverseStopRules supplied but not sent, and this poll carries no token," +
|
|
64
|
+
" so the backend walk is unpruned where the streaming path would have pruned it",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Seed at every non-final resource, not the roots. Required, not an optimisation: the
|
|
69
|
+
// backend ends a walk at the first unchanged resource, so a root-seeded poll never reaches a
|
|
70
|
+
// change under a quiet parent - measured at 0 of 3 changes delivered. algorithm_equivalence
|
|
71
|
+
// fails on its KV step if this is ever narrowed back to the roots.
|
|
72
|
+
//
|
|
73
|
+
// The seed set changes every poll and the token is deliberately NOT discarded for it, though
|
|
74
|
+
// api.proto counts it as a shape input. Safe because every seed is a resource the mirror
|
|
75
|
+
// already holds, so a change can only widen the result, and widening hides nothing.
|
|
76
|
+
// Discarding per poll would make every poll a full read and defeat the mechanism.
|
|
77
|
+
//
|
|
78
|
+
// The roots stand in when the frontier is empty: resourceTree needs at least one seed.
|
|
79
|
+
const seeds = seedResources.length > 0 ? seedResources : [...roots];
|
|
80
|
+
if (seeds.length === 0) return [];
|
|
81
|
+
|
|
82
|
+
const collected = new Map<SignedResourceId, ExtendedResourceData>();
|
|
83
|
+
// References neither the mirror nor this batch can satisfy; applying with one outstanding
|
|
84
|
+
// invalidates the whole tree. Maintained incrementally as frames arrive: rescanning
|
|
85
|
+
// everything collected once per round would be quadratic in a poll that needs several.
|
|
86
|
+
const missing = new Set<SignedResourceId>();
|
|
87
|
+
// Same ids, in arrival order, so a round consumes only what is new rather than walking the
|
|
88
|
+
// whole set. An id enters both exactly once: `collected` is checked before the add and never
|
|
89
|
+
// cleared, so nothing re-enters after it resolves.
|
|
90
|
+
const pending: SignedResourceId[] = [];
|
|
91
|
+
const collect = (resource: ExtendedResourceData) => {
|
|
92
|
+
collected.set(resource.id, resource);
|
|
93
|
+
missing.delete(resource.id);
|
|
94
|
+
for (const ref of referencesOf(resource))
|
|
95
|
+
if (!collected.has(ref) && !knownResources.has(ref) && !missing.has(ref)) {
|
|
96
|
+
missing.add(ref);
|
|
97
|
+
pending.push(ref);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
let warnedStopMarker = false;
|
|
102
|
+
const consume = async (walkSeeds: SignedResourceId[], unconditionalDepth?: number) => {
|
|
103
|
+
if (stats) {
|
|
104
|
+
stats.roundTrips++;
|
|
105
|
+
stats.streamRounds++;
|
|
106
|
+
stats.deltaSeedsSent += walkSeeds.length;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for await (const frame of tx.resourceTree(walkSeeds, {
|
|
110
|
+
includeKv: true,
|
|
111
|
+
fieldFilter,
|
|
112
|
+
changedSinceToken,
|
|
113
|
+
unconditionalDepth,
|
|
114
|
+
})) {
|
|
115
|
+
// A body-less frame. The client reads that as a stop marker, and a stop marker can only
|
|
116
|
+
// follow traverse_stop_rules, which this path never sends. Nothing here needs it, so it
|
|
117
|
+
// is skipped - but not silently: reaching this means the backend put a meaning on a
|
|
118
|
+
// body-less frame that this client does not know, and a quiet skip would leave no trace
|
|
119
|
+
// of it. Warn once per poll rather than per frame, since a contract change would emit
|
|
120
|
+
// one of these for every resource visited.
|
|
121
|
+
if (frame.frameKind === "stopMarker") {
|
|
122
|
+
if (!warnedStopMarker) {
|
|
123
|
+
warnedStopMarker = true;
|
|
124
|
+
logger?.warn(
|
|
125
|
+
`delta poll: ignoring a body-less frame for ${frame.id}; this poll sent no ` +
|
|
126
|
+
`traverse stop rules, so the backend is using body-less frames for something ` +
|
|
127
|
+
`this client does not interpret`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (stats) stats.resourceFrames++;
|
|
134
|
+
|
|
135
|
+
// updateFromResourceData throws on any body for a held final resource and invalidates
|
|
136
|
+
// the tree. A token-less poll walks unpruned and emits exactly those, which is what the
|
|
137
|
+
// first poll after a restored snapshot does. Both sibling algorithms skip the same way.
|
|
138
|
+
// Safe for references: finalResources is a subset of knownResources.
|
|
139
|
+
if (finalResources.has(frame.id)) {
|
|
140
|
+
if (stats) stats.finalResourcesSkipped++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// An explicit literal rather than two rest-spreads of the frame. Measured ~15% off the
|
|
145
|
+
// per-frame cost: a spread copies every property twice and gives the object a hidden
|
|
146
|
+
// class of its own, where this is the shape the streaming path already produces.
|
|
147
|
+
const resource: ExtendedResourceData = {
|
|
148
|
+
id: frame.id,
|
|
149
|
+
type: frame.type,
|
|
150
|
+
kind: frame.kind,
|
|
151
|
+
data: frame.data,
|
|
152
|
+
resourceReady: frame.resourceReady,
|
|
153
|
+
error: frame.error,
|
|
154
|
+
originalResourceId: frame.originalResourceId,
|
|
155
|
+
final: frame.final,
|
|
156
|
+
inputsLocked: frame.inputsLocked,
|
|
157
|
+
outputsLocked: frame.outputsLocked,
|
|
158
|
+
fields:
|
|
159
|
+
pruningFunction !== undefined
|
|
160
|
+
? pruningFunction(frame as unknown as ExtendedResourceData)
|
|
161
|
+
: frame.fields,
|
|
162
|
+
kv: frame.kv,
|
|
163
|
+
};
|
|
164
|
+
if (stats) stats.prunedFields += frame.fields.length - resource.fields.length;
|
|
165
|
+
|
|
166
|
+
collect(resource);
|
|
167
|
+
collectStatsForResource(resource, stats);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
await consume(seeds);
|
|
172
|
+
|
|
173
|
+
// Captured before the resolution rounds. They fetch unconditionally by design, so counting
|
|
174
|
+
// afterwards lets a modest unheld subtree inflate the total past a small mirror and report a
|
|
175
|
+
// refused token that was never refused.
|
|
176
|
+
const collectedFromPoll = collected.size;
|
|
177
|
+
|
|
178
|
+
// A body may reference a resource the response did not carry - a field repointed at one we
|
|
179
|
+
// never held. Ids already asked for are never re-requested, so the loop terminates: the tree
|
|
180
|
+
// is finite and `fetched` only grows.
|
|
181
|
+
//
|
|
182
|
+
// Deliberately unbounded on rounds. A round-count cap would turn a legal tree shape into a
|
|
183
|
+
// desktop error, since how deep the references chain is a property of what the backend
|
|
184
|
+
// allowed rather than of anything wrong here, and the streaming path's stop-marker follow-up
|
|
185
|
+
// loop has always run on the same assumption.
|
|
186
|
+
const fetched = new Set<SignedResourceId>();
|
|
187
|
+
while (missing.size > 0) {
|
|
188
|
+
const round = pending.splice(0).filter((id) => !fetched.has(id) && missing.has(id));
|
|
189
|
+
if (round.length === 0) {
|
|
190
|
+
// Asked for all of these and none arrived; a soft-deleted referent gets here, since
|
|
191
|
+
// those frames are dropped client-side. TreeStateUpdateError specifically: the poll loop
|
|
192
|
+
// rebuilds and discards the token for that class, where a plain Error is only logged and
|
|
193
|
+
// the identical request fails forever.
|
|
194
|
+
throw new TreeStateUpdateError(
|
|
195
|
+
`delta poll: ${missing.size} referenced resource(s) could not be resolved, first ${missing.values().next().value}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
for (const id of round) fetched.add(id);
|
|
199
|
+
|
|
200
|
+
if (stats) stats.deltaResolutionRounds++;
|
|
201
|
+
await consume(round, RESOLUTION_DEPTH);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A refused token is answered with the full tree and no error, so this is the only tell.
|
|
205
|
+
// Reported, not acted on: the response is a correct superset, and the token stored after
|
|
206
|
+
// this poll comes from the current instance, so it self-heals next poll.
|
|
207
|
+
if (
|
|
208
|
+
changedSinceToken !== undefined &&
|
|
209
|
+
knownResources.size > 0 &&
|
|
210
|
+
collectedFromPoll >= knownResources.size
|
|
211
|
+
) {
|
|
212
|
+
if (stats) stats.deltaSuspectedFullAnswers++;
|
|
213
|
+
logger?.warn(
|
|
214
|
+
`delta poll: sent a token but received ${collectedFromPoll} resources against a mirror of ` +
|
|
215
|
+
`${knownResources.size}; the backend may have refused the token (instance change or ` +
|
|
216
|
+
`rewound numbering), in which case this poll cost a full tree read`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return [...collected.values()];
|
|
221
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
asSignedResourceId,
|
|
4
|
+
field,
|
|
5
|
+
hasCapability,
|
|
6
|
+
parseSignedResourceId,
|
|
7
|
+
TestHelpers,
|
|
8
|
+
} from "@milaboratories/pl-client";
|
|
9
|
+
import type { PlClient } from "@milaboratories/pl-client";
|
|
10
|
+
import type { ExtendedResourceData } from "./state";
|
|
11
|
+
import { TestStructuralResourceType1 } from "./test_utils";
|
|
12
|
+
import { SynchronizedTreeState } from "./synchronized_tree";
|
|
13
|
+
import { ConsoleLoggerAdapter } from "@milaboratories/ts-helpers";
|
|
14
|
+
import type { TraversalMode } from "./sync";
|
|
15
|
+
import tp from "timers/promises";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Delta polling against a real backend, for correctness only. Cost (bytes and round trips per
|
|
19
|
+
* poll) is the benchmark's job, since stats are loop-internal with no public accessor.
|
|
20
|
+
*
|
|
21
|
+
* Everything here needs `treeChangedSince:v1`, which
|
|
22
|
+
* only a backend built from pl PR #2163 advertises, so each test skips rather than fails on
|
|
23
|
+
* one without it. The unit coverage in `delta_sync.test.ts` pins the client's own logic; what
|
|
24
|
+
* cannot be faked, and is what these are for, is the backend's own emission rule.
|
|
25
|
+
*/
|
|
26
|
+
/** Marks the test SKIPPED, not passed, when the backend cannot serve delta.
|
|
27
|
+
*
|
|
28
|
+
* These previously returned early, so on a backend without the capability the whole delta
|
|
29
|
+
* suite reported four green ticks over zero delta code. Skipped and passed are different
|
|
30
|
+
* signals and a run summary has to be able to tell them apart. */
|
|
31
|
+
function skipUnlessDelta(pl: PlClient, ctx: { skip: (note?: string) => void }): boolean {
|
|
32
|
+
if (hasCapability(pl.serverInfo.capabilities ?? [], "treeChangedSince:v1")) return true;
|
|
33
|
+
ctx.skip("backend does not advertise treeChangedSince:v1 (needs pl PR #2163)");
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const logger = new ConsoleLoggerAdapter(console);
|
|
38
|
+
|
|
39
|
+
/** Whole-mirror comparison, so a retained-but-unreferenced resource or a dropped property
|
|
40
|
+
* shows up rather than only a differing count. */
|
|
41
|
+
function canonicalShape(resources: ExtendedResourceData[]): string {
|
|
42
|
+
return resources
|
|
43
|
+
.map(
|
|
44
|
+
(r) =>
|
|
45
|
+
`${r.id}|${r.kind}|${r.final}|${r.inputsLocked}|${r.outputsLocked}|` +
|
|
46
|
+
`${r.fields
|
|
47
|
+
.map((f) => `${f.name}=${f.value}`)
|
|
48
|
+
.sort()
|
|
49
|
+
.join(",")}|kv:${r.kv
|
|
50
|
+
.map((kv) => kv.key)
|
|
51
|
+
.sort()
|
|
52
|
+
.join(",")}`,
|
|
53
|
+
)
|
|
54
|
+
.sort()
|
|
55
|
+
.join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A root with one child holding data, which is enough of a tree to poll. */
|
|
59
|
+
async function seedTree(pl: PlClient) {
|
|
60
|
+
return await pl.withWriteTx(
|
|
61
|
+
"DeltaSeed",
|
|
62
|
+
async (tx) => {
|
|
63
|
+
const root = tx.createStruct(TestStructuralResourceType1);
|
|
64
|
+
const rootField = field(tx.clientRoot, "deltaRoot");
|
|
65
|
+
tx.createField(rootField, "Dynamic");
|
|
66
|
+
tx.setField(rootField, root);
|
|
67
|
+
|
|
68
|
+
const child = tx.createStruct(TestStructuralResourceType1);
|
|
69
|
+
const childField = field(root, "child");
|
|
70
|
+
tx.createField(childField, "Dynamic");
|
|
71
|
+
tx.setField(childField, child);
|
|
72
|
+
|
|
73
|
+
await tx.commit();
|
|
74
|
+
return { root: await root.globalId, child: await child.globalId };
|
|
75
|
+
},
|
|
76
|
+
{ sync: true },
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function openTree(
|
|
81
|
+
pl: PlClient,
|
|
82
|
+
root: string,
|
|
83
|
+
traversalMode: TraversalMode,
|
|
84
|
+
extra: Record<string, unknown> = {},
|
|
85
|
+
) {
|
|
86
|
+
return await SynchronizedTreeState.init(
|
|
87
|
+
pl,
|
|
88
|
+
root as never,
|
|
89
|
+
{ stopPollingDelay: 50, pollingInterval: 10, traversalMode, ...extra } as never,
|
|
90
|
+
logger,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test("a delta poll delivers a change on a resource the mirror already holds", async (ctx) => {
|
|
95
|
+
await TestHelpers.withTempRoot(async (pl) => {
|
|
96
|
+
if (!skipUnlessDelta(pl, ctx)) return;
|
|
97
|
+
|
|
98
|
+
const { root, child } = await seedTree(pl);
|
|
99
|
+
const tree = await openTree(pl, root, "backend-delta");
|
|
100
|
+
try {
|
|
101
|
+
await tree.refreshState();
|
|
102
|
+
|
|
103
|
+
// Attach a new resource under the child, so the child's own state moves.
|
|
104
|
+
await pl.withWriteTx(
|
|
105
|
+
"DeltaMutate",
|
|
106
|
+
async (tx) => {
|
|
107
|
+
const grandchild = tx.createStruct(TestStructuralResourceType1);
|
|
108
|
+
const f = field(child as never, "grandchild");
|
|
109
|
+
tx.createField(f, "Dynamic");
|
|
110
|
+
tx.setField(f, grandchild);
|
|
111
|
+
await tx.commit();
|
|
112
|
+
},
|
|
113
|
+
{ sync: true },
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
await tree.refreshState();
|
|
117
|
+
|
|
118
|
+
// The grandchild is a resource the mirror never held, referenced by a body the delta
|
|
119
|
+
// did carry: exactly the case the resolution round exists for.
|
|
120
|
+
const dump = tree.dumpState();
|
|
121
|
+
expect(dump.some((r) => r.fields.some((f) => f.name === "grandchild"))).toBe(true);
|
|
122
|
+
} finally {
|
|
123
|
+
await tree.terminate();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("delta and streaming converge on the same mirror", async (ctx) => {
|
|
129
|
+
await TestHelpers.withTempRoot(async (pl) => {
|
|
130
|
+
if (!skipUnlessDelta(pl, ctx)) return;
|
|
131
|
+
|
|
132
|
+
const { root, child } = await seedTree(pl);
|
|
133
|
+
|
|
134
|
+
const delta = await openTree(pl, root, "backend-delta");
|
|
135
|
+
const streaming = await openTree(pl, root, "backend-streaming");
|
|
136
|
+
try {
|
|
137
|
+
await Promise.all([delta.refreshState(), streaming.refreshState()]);
|
|
138
|
+
|
|
139
|
+
await pl.withWriteTx(
|
|
140
|
+
"DeltaConverge",
|
|
141
|
+
async (tx) => {
|
|
142
|
+
const extra = tx.createStruct(TestStructuralResourceType1);
|
|
143
|
+
const f = field(child as never, "extra");
|
|
144
|
+
tx.createField(f, "Dynamic");
|
|
145
|
+
tx.setField(f, extra);
|
|
146
|
+
await tx.commit();
|
|
147
|
+
},
|
|
148
|
+
{ sync: true },
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Poll both twice: delta needs the second to see the change settle, and streaming is
|
|
152
|
+
// idempotent, so this cannot favour either.
|
|
153
|
+
await Promise.all([delta.refreshState(), streaming.refreshState()]);
|
|
154
|
+
await Promise.all([delta.refreshState(), streaming.refreshState()]);
|
|
155
|
+
|
|
156
|
+
const shape = (t: SynchronizedTreeState) =>
|
|
157
|
+
t
|
|
158
|
+
.dumpState()
|
|
159
|
+
.map(
|
|
160
|
+
(r) =>
|
|
161
|
+
`${r.id}|${r.fields
|
|
162
|
+
.map((f) => `${f.name}=${f.value}`)
|
|
163
|
+
.sort()
|
|
164
|
+
.join(",")}`,
|
|
165
|
+
)
|
|
166
|
+
.sort();
|
|
167
|
+
|
|
168
|
+
expect(shape(delta)).toEqual(shape(streaming));
|
|
169
|
+
} finally {
|
|
170
|
+
await Promise.all([delta.terminate(), streaming.terminate()]);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("a quiet parent: a change under an unchanged resource still arrives", async (ctx) => {
|
|
176
|
+
await TestHelpers.withTempRoot(async (pl) => {
|
|
177
|
+
if (!skipUnlessDelta(pl, ctx)) return;
|
|
178
|
+
|
|
179
|
+
const { root, child } = await seedTree(pl);
|
|
180
|
+
const tree = await openTree(pl, root, "backend-delta");
|
|
181
|
+
try {
|
|
182
|
+
await tree.refreshState();
|
|
183
|
+
|
|
184
|
+
// A KV write on the child does not rewrite the root, so the root stays quiet. With
|
|
185
|
+
// the child is its own seed, so the walk reaches it anyway. This is
|
|
186
|
+
// the assertion that justifies seeding the frontier rather than the roots.
|
|
187
|
+
await pl.withWriteTx(
|
|
188
|
+
"DeltaQuietParent",
|
|
189
|
+
async (tx) => {
|
|
190
|
+
tx.setKValue(child as never, "quiet", Buffer.from("value"));
|
|
191
|
+
await tx.commit();
|
|
192
|
+
},
|
|
193
|
+
{ sync: true },
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// Give the poll loop a couple of cycles: the change has to be reached, not just sent.
|
|
197
|
+
for (let i = 0; i < 3; i++) {
|
|
198
|
+
await tree.refreshState();
|
|
199
|
+
await tp.setTimeout(20);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const dump = tree.dumpState();
|
|
203
|
+
const childState = dump.find((r) => r.id === child);
|
|
204
|
+
expect(childState?.kv.some((kv) => kv.key === "quiet")).toBe(true);
|
|
205
|
+
} finally {
|
|
206
|
+
await tree.terminate();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a delta tree restored from a snapshot converges with streaming", async (ctx) => {
|
|
212
|
+
await TestHelpers.withTempRoot(async (pl) => {
|
|
213
|
+
if (!skipUnlessDelta(pl, ctx)) return;
|
|
214
|
+
|
|
215
|
+
const { root } = await seedTree(pl);
|
|
216
|
+
|
|
217
|
+
// Load through streaming, then hand that mirror to a delta tree via restoreFrom. This is
|
|
218
|
+
// the ordinary upgrade path and the one shape a cold open cannot produce: a POPULATED
|
|
219
|
+
// mirror whose first delta poll carries no token, so it is a full walk that also sends no
|
|
220
|
+
// stop rules. Bodies for resources the mirror holds as final must be skipped, or the apply
|
|
221
|
+
// throws; resources below them must not be retained unreferenced.
|
|
222
|
+
//
|
|
223
|
+
// The earlier version of this test opened a fresh delta tree after terminating the
|
|
224
|
+
// streaming one, which gave an EMPTY mirror and asserted nothing.
|
|
225
|
+
// The fixture must actually contain a final resource or the skip branch is never reached:
|
|
226
|
+
// DefaultFinalResourceDataPredicate marks none of the test resource types final, which is
|
|
227
|
+
// why an earlier version of this test passed with the skip disabled. Everything except the
|
|
228
|
+
// root goes final, so the root stays a seed and the tree can still poll.
|
|
229
|
+
const finalPredicateOverride = (r: { id: unknown }) => r.id !== root;
|
|
230
|
+
|
|
231
|
+
const warm = await openTree(pl, root, "backend-streaming", { finalPredicateOverride });
|
|
232
|
+
await warm.refreshState();
|
|
233
|
+
const witness = parseSignedResourceId(asSignedResourceId(root)).signature;
|
|
234
|
+
const snapshot = warm.capture(witness);
|
|
235
|
+
const streamingShape = canonicalShape(warm.dumpState());
|
|
236
|
+
await warm.terminate();
|
|
237
|
+
|
|
238
|
+
const delta = await SynchronizedTreeState.init(
|
|
239
|
+
pl,
|
|
240
|
+
root as never,
|
|
241
|
+
{
|
|
242
|
+
stopPollingDelay: 50,
|
|
243
|
+
pollingInterval: 10,
|
|
244
|
+
traversalMode: "backend-delta",
|
|
245
|
+
restoreFrom: snapshot,
|
|
246
|
+
finalPredicateOverride,
|
|
247
|
+
},
|
|
248
|
+
logger,
|
|
249
|
+
);
|
|
250
|
+
try {
|
|
251
|
+
expect(delta.wasRestoredFromSnapshot).toBe(true);
|
|
252
|
+
await delta.refreshState();
|
|
253
|
+
await delta.refreshState();
|
|
254
|
+
expect(canonicalShape(delta.dumpState())).toBe(streamingShape);
|
|
255
|
+
} finally {
|
|
256
|
+
await delta.terminate();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}, 300_000);
|
package/src/state.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { expect, test } from "vitest";
|
|
2
2
|
import { Computable } from "@milaboratories/computable";
|
|
3
|
+
import type { Watcher } from "@milaboratories/computable";
|
|
4
|
+
import type { FieldData } from "@milaboratories/pl-client";
|
|
3
5
|
import {
|
|
4
6
|
createSignedResourceId,
|
|
5
7
|
DefaultFinalResourceDataPredicate,
|
|
@@ -20,6 +22,15 @@ import {
|
|
|
20
22
|
|
|
21
23
|
const rid = createSignedResourceId;
|
|
22
24
|
|
|
25
|
+
/** Minimal Watcher for tests that read tree state directly, outside a Computable. */
|
|
26
|
+
class NoopWatcher implements Watcher {
|
|
27
|
+
isChanged = false;
|
|
28
|
+
markChanged(): void {
|
|
29
|
+
this.isChanged = true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const w = () => new NoopWatcher();
|
|
33
|
+
|
|
23
34
|
test("simple tree test 1", async () => {
|
|
24
35
|
const tree = new PlTreeState(TestDynamicRootId1, DefaultFinalResourceDataPredicate);
|
|
25
36
|
const entry = tree.entry();
|
|
@@ -317,3 +328,149 @@ test("exception - ready without locks 2", () => {
|
|
|
317
328
|
]),
|
|
318
329
|
).toThrow(/ready without input or output lock/);
|
|
319
330
|
});
|
|
331
|
+
|
|
332
|
+
// The field and kv update loops walk the stored entries in lockstep with the incoming ones
|
|
333
|
+
// and only fall back to a hash lookup once the two orders diverge. These cover the
|
|
334
|
+
// divergence shapes: reordering, an insertion in the middle, a removal. A reorder that
|
|
335
|
+
// changes nothing must invalidate nothing and must not disturb refCounts, which is what
|
|
336
|
+
// pins the fallback: mistaking a reordered field for a new one still leaves the right field
|
|
337
|
+
// set behind, but double-counts the reference and fires spurious change notifications.
|
|
338
|
+
|
|
339
|
+
const rootRes = (fields: FieldData[]) => [{ ...TestDynamicRootState1, fields }];
|
|
340
|
+
|
|
341
|
+
test("a pure field reorder changes nothing and invalidates nothing", () => {
|
|
342
|
+
const tree = new PlTreeState(TestDynamicRootId1, DefaultFinalResourceDataPredicate);
|
|
343
|
+
tree.updateFromResourceData(rootRes([dField("a"), dField("b"), dField("c")]));
|
|
344
|
+
|
|
345
|
+
const watcher = w();
|
|
346
|
+
const root = tree.get(watcher, TestDynamicRootId1);
|
|
347
|
+
root.listDynamicFields(watcher);
|
|
348
|
+
for (const name of ["a", "b", "c"]) root.getField(watcher, name, () => {});
|
|
349
|
+
expect(watcher.isChanged).toStrictEqual(false);
|
|
350
|
+
|
|
351
|
+
tree.updateFromResourceData(rootRes([dField("c"), dField("b"), dField("a")]));
|
|
352
|
+
|
|
353
|
+
expect(watcher.isChanged).toStrictEqual(false);
|
|
354
|
+
expect(
|
|
355
|
+
tree
|
|
356
|
+
.get(w(), TestDynamicRootId1)
|
|
357
|
+
.fields.map((f) => f.name)
|
|
358
|
+
.sort(),
|
|
359
|
+
).toStrictEqual(["a", "b", "c"]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("field inserted mid-order, then removed, is tracked", () => {
|
|
363
|
+
const tree = new PlTreeState(TestDynamicRootId1, DefaultFinalResourceDataPredicate);
|
|
364
|
+
const names = () =>
|
|
365
|
+
tree
|
|
366
|
+
.get(w(), TestDynamicRootId1)
|
|
367
|
+
.fields.map((f) => f.name)
|
|
368
|
+
.sort();
|
|
369
|
+
|
|
370
|
+
tree.updateFromResourceData(rootRes([dField("a"), dField("c")]));
|
|
371
|
+
expect(names()).toStrictEqual(["a", "c"]);
|
|
372
|
+
|
|
373
|
+
// "b" appears between two fields that are already stored: the walk diverges at "b"
|
|
374
|
+
tree.updateFromResourceData(rootRes([dField("a"), dField("b"), dField("c")]));
|
|
375
|
+
expect(names()).toStrictEqual(["a", "b", "c"]);
|
|
376
|
+
|
|
377
|
+
// and disappears again, in a shuffled order
|
|
378
|
+
tree.updateFromResourceData(rootRes([dField("c"), dField("a")]));
|
|
379
|
+
expect(names()).toStrictEqual(["a", "c"]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("reordering fields does not inflate refCounts", () => {
|
|
383
|
+
const tree = new PlTreeState(TestDynamicRootId1, DefaultFinalResourceDataPredicate);
|
|
384
|
+
const leaf = (n: bigint) => ({
|
|
385
|
+
...TestValueResourceState1,
|
|
386
|
+
id: rid(n),
|
|
387
|
+
data: new TextEncoder().encode(`v${n}`),
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
tree.updateFromResourceData([
|
|
391
|
+
{
|
|
392
|
+
...TestDynamicRootState1,
|
|
393
|
+
fields: [dField("a", rid(1n)), dField("b", rid(2n))],
|
|
394
|
+
},
|
|
395
|
+
leaf(1n),
|
|
396
|
+
leaf(2n),
|
|
397
|
+
]);
|
|
398
|
+
|
|
399
|
+
// reorder and repoint in one update: the two fields swap targets
|
|
400
|
+
tree.updateFromResourceData([
|
|
401
|
+
{
|
|
402
|
+
...TestDynamicRootState1,
|
|
403
|
+
fields: [dField("b", rid(1n)), dField("a", rid(2n))],
|
|
404
|
+
},
|
|
405
|
+
leaf(1n),
|
|
406
|
+
leaf(2n),
|
|
407
|
+
]);
|
|
408
|
+
|
|
409
|
+
const byName = new Map(tree.get(w(), TestDynamicRootId1).fields.map((f) => [f.name, f.value]));
|
|
410
|
+
expect(byName.get("a")).toStrictEqual(rid(2n));
|
|
411
|
+
expect(byName.get("b")).toStrictEqual(rid(1n));
|
|
412
|
+
|
|
413
|
+
// dropping "a" must collect leaf 2: its refCount has to be exactly 1, so a reorder that
|
|
414
|
+
// was mistaken for an insertion (and double-incremented) would leave it alive here
|
|
415
|
+
tree.updateFromResourceData(rootRes([dField("b", rid(1n))]));
|
|
416
|
+
expect(tree.get(w(), rid(1n)).getDataAsString()).toStrictEqual("v1");
|
|
417
|
+
expect(() => tree.get(w(), rid(2n))).toThrow(/not found/);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("kv reordering neither invalidates watchers nor loses entries", () => {
|
|
421
|
+
const tree = new PlTreeState(TestDynamicRootId1, DefaultFinalResourceDataPredicate);
|
|
422
|
+
const kvOf = (entries: [string, string][]) => [
|
|
423
|
+
{
|
|
424
|
+
...TestDynamicRootState1,
|
|
425
|
+
fields: [],
|
|
426
|
+
kv: entries.map(([key, value]) => ({ key, value: Buffer.from(value) })),
|
|
427
|
+
},
|
|
428
|
+
];
|
|
429
|
+
const read = (key: string) => tree.get(w(), TestDynamicRootId1).getKeyValueString(w(), key);
|
|
430
|
+
|
|
431
|
+
tree.updateFromResourceData(
|
|
432
|
+
kvOf([
|
|
433
|
+
["k1", "one"],
|
|
434
|
+
["k2", "two"],
|
|
435
|
+
]),
|
|
436
|
+
);
|
|
437
|
+
expect([read("k1"), read("k2")]).toStrictEqual(["one", "two"]);
|
|
438
|
+
|
|
439
|
+
// reversed order, same values: no watcher may fire
|
|
440
|
+
const watcher = w();
|
|
441
|
+
const root = tree.get(watcher, TestDynamicRootId1);
|
|
442
|
+
root.getKeyValue(watcher, "k1");
|
|
443
|
+
root.getKeyValue(watcher, "k2");
|
|
444
|
+
tree.updateFromResourceData(
|
|
445
|
+
kvOf([
|
|
446
|
+
["k2", "two"],
|
|
447
|
+
["k1", "one"],
|
|
448
|
+
]),
|
|
449
|
+
);
|
|
450
|
+
expect(watcher.isChanged).toStrictEqual(false);
|
|
451
|
+
|
|
452
|
+
// a key inserted before the stored ones, so the walk diverges immediately
|
|
453
|
+
tree.updateFromResourceData(
|
|
454
|
+
kvOf([
|
|
455
|
+
["k0", "zero"],
|
|
456
|
+
["k2", "two"],
|
|
457
|
+
["k1", "ONE"],
|
|
458
|
+
]),
|
|
459
|
+
);
|
|
460
|
+
expect([read("k0"), read("k1"), read("k2")]).toStrictEqual(["zero", "ONE", "two"]);
|
|
461
|
+
|
|
462
|
+
// and a deletion
|
|
463
|
+
tree.updateFromResourceData(kvOf([["k1", "ONE"]]));
|
|
464
|
+
expect([read("k0"), read("k1"), read("k2")]).toStrictEqual([undefined, "ONE", undefined]);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("removal of a typed field still throws after a reorder", () => {
|
|
468
|
+
const tree = new PlTreeState(TestDynamicRootId1, DefaultFinalResourceDataPredicate);
|
|
469
|
+
|
|
470
|
+
tree.updateFromResourceData(rootRes([iField("a"), iField("b"), dField("c")]));
|
|
471
|
+
|
|
472
|
+
// reordered, and the input field "b" is gone: the removal scan must still see it
|
|
473
|
+
expect(() => tree.updateFromResourceData(rootRes([dField("c"), iField("a")]))).toThrow(
|
|
474
|
+
/removal of Input field b/,
|
|
475
|
+
);
|
|
476
|
+
});
|