@milaboratories/pl-tree 1.10.6 → 1.11.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/src/sync.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type {
2
2
  FieldData,
3
+ Filter,
3
4
  OptionalSignedResourceId,
4
5
  PlTransaction,
6
+ ResourceTreeFrame,
5
7
  SignedResourceId,
6
8
  } from "@milaboratories/pl-client";
7
9
  import Denque from "denque";
@@ -23,19 +25,32 @@ export interface TreeLoadingRequest {
23
25
  * be retrieved for them. */
24
26
  readonly finalResources: Set<SignedResourceId>;
25
27
 
26
- /** This function is applied to each resource data field list, before
27
- * using it continue traversal. This modification also is applied to
28
- * output data to make result self-consistent in terms that it will contain
29
- * all referenced resources, this is required to be able to pass it to tree
30
- * to update the state. */
28
+ /** Applied to each resource field list in fallback BFS mode and to streamed results. */
31
29
  readonly pruningFunction?: PruningFunction;
30
+
31
+ /** ResourceTree field filter passed to the backend when supported. */
32
+ readonly fieldFilter?: Filter;
33
+
34
+ /** ResourceTree traversal stop rules passed to the backend when supported. */
35
+ readonly traverseStopRules?: Filter;
32
36
  }
33
37
 
38
+ const CapabilityTreeFilter = "treeFilter:v1";
39
+
40
+ /** Controls which tree-loading path is used.
41
+ * - `"auto"` (default): use backend streaming when the backend advertises `treeFilter:v1`,
42
+ * fall back to client-side BFS otherwise.
43
+ * - `"client-bfs"`: always use client-side BFS, even on capable backends.
44
+ * - `"backend-streaming"`: always prefer backend streaming; if the capability is absent,
45
+ * logs a warning and falls back to BFS (never throws).
46
+ */
47
+ export type TraversalMode = "auto" | "client-bfs" | "backend-streaming";
48
+
34
49
  /** Given the current tree state, build the request object to pass to
35
50
  * {@link loadTreeState} to load updated state. */
36
51
  export function constructTreeLoadingRequest(
37
52
  tree: PlTreeState,
38
- pruningFunction?: PruningFunction,
53
+ options: Pick<TreeLoadingRequest, "pruningFunction" | "fieldFilter" | "traverseStopRules"> = {},
39
54
  ): TreeLoadingRequest {
40
55
  const seedResources: SignedResourceId[] = [];
41
56
  const finalResources = new Set<SignedResourceId>();
@@ -47,7 +62,13 @@ export function constructTreeLoadingRequest(
47
62
  // if tree is empty, seeding tree reconstruction from the specified root
48
63
  if (seedResources.length === 0 && finalResources.size === 0) seedResources.push(tree.root);
49
64
 
50
- return { seedResources, finalResources, pruningFunction };
65
+ return {
66
+ seedResources,
67
+ finalResources,
68
+ pruningFunction: options.pruningFunction,
69
+ fieldFilter: options.fieldFilter,
70
+ traverseStopRules: options.traverseStopRules,
71
+ };
51
72
  }
52
73
 
53
74
  export type TreeLoadingStat = {
@@ -61,6 +82,10 @@ export type TreeLoadingStat = {
61
82
  prunedFields: number;
62
83
  finalResourcesSkipped: number;
63
84
  millisSpent: number;
85
+ /** Stop-marker frames whose id was already final locally and were skipped. */
86
+ stopMarkersSkipped: number;
87
+ /** Number of follow-up resourceTree() calls issued to resolve unknown stop markers. */
88
+ stopMarkerFollowUpRoundTrips: number;
64
89
  };
65
90
 
66
91
  export function initialTreeLoadingStat(): TreeLoadingStat {
@@ -75,6 +100,8 @@ export function initialTreeLoadingStat(): TreeLoadingStat {
75
100
  prunedFields: 0,
76
101
  finalResourcesSkipped: 0,
77
102
  millisSpent: 0,
103
+ stopMarkersSkipped: 0,
104
+ stopMarkerFollowUpRoundTrips: 0,
78
105
  };
79
106
  }
80
107
 
@@ -88,24 +115,30 @@ export function formatTreeLoadingStat(stat: TreeLoadingStat): string {
88
115
  result += `Data Bytes: ${stat.retrievedResourceDataBytes}\n`;
89
116
  result += `KV Bytes: ${stat.retrievedKeyValueBytes}\n`;
90
117
  result += `Pruned fields: ${stat.prunedFields}\n`;
91
- result += `Final resources skipped: ${stat.finalResourcesSkipped}`;
118
+ result += `Final resources skipped: ${stat.finalResourcesSkipped}\n`;
119
+ result += `Stop markers skipped: ${stat.stopMarkersSkipped}\n`;
120
+ result += `Stop marker follow-up round-trips: ${stat.stopMarkerFollowUpRoundTrips}`;
92
121
  return result;
93
122
  }
94
123
 
95
- /** Given the transaction (preferably read-only) and loading request, executes
96
- * the tree traversal algorithm, and collects fresh states of resources
97
- * to update the tree state. */
98
- export async function loadTreeState(
124
+ function supportsResourceTreeTraversal(capabilities: readonly string[] = []): boolean {
125
+ return capabilities.includes(CapabilityTreeFilter);
126
+ }
127
+
128
+ function collectStatsForResource(resource: ExtendedResourceData, stats?: TreeLoadingStat) {
129
+ if (!stats) return;
130
+ stats.retrievedResources++;
131
+ stats.retrievedFields += resource.fields.length;
132
+ stats.retrievedKeyValues += resource.kv.length;
133
+ stats.retrievedResourceDataBytes += resource.data?.length ?? 0;
134
+ for (const kv of resource.kv) stats.retrievedKeyValueBytes += kv.value.length;
135
+ }
136
+
137
+ async function loadTreeStateViaBfs(
99
138
  tx: PlTransaction,
100
139
  loadingRequest: TreeLoadingRequest,
101
140
  stats?: TreeLoadingStat,
102
141
  ): Promise<ExtendedResourceData[]> {
103
- // saving start timestamp to add time spent in this function to the stats at the end of the method
104
- const startTimestamp = Date.now();
105
-
106
- // counting the request
107
- if (stats) stats.requests++;
108
-
109
142
  const { seedResources, finalResources, pruningFunction } = loadingRequest;
110
143
 
111
144
  // Limits the number of concurrent gRPC fetches to bound peak memory
@@ -187,23 +220,163 @@ export async function loadTreeState(
187
220
  }
188
221
 
189
222
  // collecting stats
190
- if (stats) {
191
- stats.retrievedResources++;
192
- stats.retrievedFields += nextResource.fields.length;
193
- stats.retrievedKeyValues += nextResource.kv.length;
194
- stats.retrievedResourceDataBytes += nextResource.data?.length ?? 0;
195
- for (const kv of nextResource.kv) stats.retrievedKeyValueBytes += kv.value.length;
196
- }
223
+ collectStatsForResource(nextResource, stats);
197
224
 
198
225
  // aggregating the state
199
226
  result.push(nextResource);
200
227
  }
201
228
 
202
- // adding the time we spent in this method to stats
203
- if (stats) {
204
- stats.millisSpent += Date.now() - startTimestamp;
205
- stats.roundTrips += numberOfRoundTrips;
229
+ if (stats) stats.roundTrips += numberOfRoundTrips;
230
+
231
+ return result;
232
+ }
233
+
234
+ async function processResourceTreeStream(
235
+ treeItems: AsyncIterable<ResourceTreeFrame>,
236
+ finalResources: Set<SignedResourceId>,
237
+ pruningFunction: PruningFunction | undefined,
238
+ stats: TreeLoadingStat | undefined,
239
+ ): Promise<{ result: ExtendedResourceData[]; followUpSeeds: SignedResourceId[] }> {
240
+ const result: ExtendedResourceData[] = [];
241
+ const followUpSeeds: SignedResourceId[] = [];
242
+
243
+ // backend returns two types of frames:
244
+ // - 'resource' frames contain the resource state and are processed normally
245
+ // - 'stopMarker' frames indicate resources that are ignored due stop rules fired
246
+ //
247
+ // Usually stop rules indicates the resources with final state. In that case middle layer
248
+ // should make a decision: has it already loaded the resource or should it be requested for get the latest state?
249
+ for await (const frame of treeItems) {
250
+ if (frame.frameKind === "stopMarker") {
251
+ if (finalResources.has(frame.id)) {
252
+ if (stats) stats.stopMarkersSkipped++;
253
+ continue;
254
+ }
255
+ followUpSeeds.push(frame.id);
256
+ continue;
257
+ }
258
+
259
+ // Normal resource frame.
260
+ if (finalResources.has(frame.id)) {
261
+ if (stats) stats.finalResourcesSkipped++;
262
+ continue;
263
+ }
264
+
265
+ let nextResource: ExtendedResourceData = {
266
+ id: frame.id,
267
+ type: frame.type,
268
+ kind: frame.kind,
269
+ data: frame.data,
270
+ resourceReady: frame.resourceReady,
271
+ error: frame.error,
272
+ originalResourceId: frame.originalResourceId,
273
+ // traverseWasStopped: backend matched traverse stop rules — children were not streamed.
274
+ // Mark as terminal; fields are resolved below.
275
+ final: frame.final || frame.traverseWasStopped,
276
+ inputsLocked: frame.inputsLocked,
277
+ outputsLocked: frame.outputsLocked,
278
+ fields: frame.fields,
279
+ kv: frame.kv,
280
+ };
281
+
282
+ // Apply field rules: traverseWasStopped drops all fields to keep the refCount
283
+ // invariant; pruning function further filters the remaining fields.
284
+ const rawFields = frame.traverseWasStopped ? [] : nextResource.fields;
285
+ const resolvedFields =
286
+ pruningFunction !== undefined
287
+ ? pruningFunction({ ...nextResource, fields: rawFields })
288
+ : rawFields;
289
+ if (stats) stats.prunedFields += nextResource.fields.length - resolvedFields.length;
290
+ nextResource = { ...nextResource, fields: resolvedFields };
291
+
292
+ collectStatsForResource(nextResource, stats);
293
+ result.push(nextResource);
294
+ }
295
+
296
+ return { result, followUpSeeds };
297
+ }
298
+
299
+ async function loadTreeStateViaResourceTree(
300
+ tx: PlTransaction,
301
+ loadingRequest: TreeLoadingRequest,
302
+ stats?: TreeLoadingStat,
303
+ logger?: { warn: (msg: string) => void; info?: (msg: unknown) => void },
304
+ ): Promise<ExtendedResourceData[]> {
305
+ const { seedResources, finalResources, pruningFunction, fieldFilter, traverseStopRules } =
306
+ loadingRequest;
307
+
308
+ // Round 0: initial tree traversal.
309
+ const treeItems = tx.resourceTree(seedResources, {
310
+ includeKv: true,
311
+ fieldFilter,
312
+ traverseStopRules,
313
+ });
314
+
315
+ const { result, followUpSeeds } = await processResourceTreeStream(
316
+ treeItems,
317
+ finalResources,
318
+ pruningFunction,
319
+ stats,
320
+ );
321
+ if (stats) stats.roundTrips++;
322
+
323
+ // Client must request full resource tree in case when stop-marker seeds are returned,
324
+ // to ensure all resources are loaded and stop-marker frames are processed.
325
+ if (followUpSeeds.length > 0) {
326
+ const followUpItems = tx.resourceTree(followUpSeeds, {
327
+ includeKv: true,
328
+ fieldFilter,
329
+ });
330
+ const { result: followUpResult } = await processResourceTreeStream(
331
+ followUpItems,
332
+ finalResources,
333
+ pruningFunction,
334
+ stats,
335
+ );
336
+ result.push(...followUpResult);
337
+ if (stats) {
338
+ logger?.info?.(
339
+ `loadTreeStateViaResourceTree: follow-up request for ${followUpSeeds.length} stop-marker seeds: ${JSON.stringify(followUpSeeds)}`,
340
+ );
341
+ stats.roundTrips++;
342
+ stats.stopMarkerFollowUpRoundTrips++;
343
+ }
206
344
  }
207
345
 
208
346
  return result;
209
347
  }
348
+
349
+ /** Given the transaction (preferably read-only) and loading request, executes
350
+ * the tree traversal algorithm, and collects fresh states of resources
351
+ * to update the tree state. */
352
+ export async function loadTreeState(
353
+ tx: PlTransaction,
354
+ loadingRequest: TreeLoadingRequest,
355
+ stats?: TreeLoadingStat,
356
+ capabilities: readonly string[] = [],
357
+ mode: TraversalMode = "auto",
358
+ logger?: { warn: (msg: string) => void; info?: (msg: unknown) => void },
359
+ ): Promise<ExtendedResourceData[]> {
360
+ const startTimestamp = Date.now();
361
+ if (stats) stats.requests++;
362
+
363
+ try {
364
+ const wantsStreaming =
365
+ mode === "backend-streaming" ||
366
+ (mode === "auto" && supportsResourceTreeTraversal(capabilities));
367
+
368
+ if (wantsStreaming && !supportsResourceTreeTraversal(capabilities)) {
369
+ const msg =
370
+ "traversalMode=backend-streaming but backend lacks treeFilter:v1 capability; falling back to BFS";
371
+ if (logger) logger.warn(msg);
372
+ else console.warn(msg);
373
+ return await loadTreeStateViaBfs(tx, loadingRequest, stats);
374
+ }
375
+
376
+ return wantsStreaming
377
+ ? await loadTreeStateViaResourceTree(tx, loadingRequest, stats, logger)
378
+ : await loadTreeStateViaBfs(tx, loadingRequest, stats);
379
+ } finally {
380
+ if (stats) stats.millisSpent += Date.now() - startTimestamp;
381
+ }
382
+ }
@@ -6,12 +6,18 @@ import type {
6
6
  SignedResourceId,
7
7
  TxOps,
8
8
  } from "@milaboratories/pl-client";
9
+ import type { Filter } from "@milaboratories/pl-client";
9
10
  import { isTimeoutOrCancelError } from "@milaboratories/pl-client";
10
11
  import type { ExtendedResourceData } from "./state";
11
12
  import { PlTreeState, TreeStateUpdateError } from "./state";
12
- import type { PruningFunction, TreeLoadingStat } from "./sync";
13
+ import type { PruningFunction, TraversalMode, TreeLoadingStat } from "./sync";
13
14
  import { constructTreeLoadingRequest, initialTreeLoadingStat, loadTreeState } from "./sync";
14
15
  import * as tp from "node:timers/promises";
16
+
17
+ /** Hard floor between consecutive tree-refresh calls.
18
+ * Applies even when {@link scheduleOnNextState} has woken the loop early,
19
+ * preventing tight polling loops during rapid state transitions. */
20
+ const MIN_POLLING_INTERVAL_MS = 100;
15
21
  import type { MiLogger } from "@milaboratories/ts-helpers";
16
22
 
17
23
  type StatLoggingMode = "cumulative" | "per-request";
@@ -20,10 +26,15 @@ export type SynchronizedTreeOps = {
20
26
  /** Override final predicate from the PlClient */
21
27
  finalPredicateOverride?: FinalResourceDataPredicate;
22
28
 
23
- /** Pruning function to limit set of fields through which tree will
24
- * traverse during state synchronization */
29
+ /** Pruning function for legacy fallback path. */
25
30
  pruning?: PruningFunction;
26
31
 
32
+ /** ResourceTree field filter for modern backend path. */
33
+ fieldFilter?: Filter;
34
+
35
+ /** ResourceTree traversal stop rules for modern backend path. */
36
+ traverseStopRules?: Filter;
37
+
27
38
  /** Interval after last sync to sleep before the next one */
28
39
  pollingInterval: number;
29
40
  /** For how long to continue polling after the last derived value access */
@@ -34,6 +45,9 @@ export type SynchronizedTreeOps = {
34
45
 
35
46
  /** Timeout for initial tree loading. If not specified, will use default for RO tx from pl-client. */
36
47
  initialTreeLoadingTimeout?: number;
48
+
49
+ /** Controls which tree-loading path to use. Default `"auto"`. */
50
+ traversalMode?: TraversalMode;
37
51
  };
38
52
 
39
53
  type ScheduledRefresh = {
@@ -46,6 +60,9 @@ export class SynchronizedTreeState {
46
60
  private state: PlTreeState;
47
61
  private readonly pollingInterval: number;
48
62
  private readonly pruning?: PruningFunction;
63
+ private readonly fieldFilter?: Filter;
64
+ private readonly traverseStopRules?: Filter;
65
+ private readonly traversalMode: TraversalMode;
49
66
  private readonly logStat?: StatLoggingMode;
50
67
  private readonly hooks: PollingComputableHooks;
51
68
  private readonly abortController = new AbortController();
@@ -56,8 +73,20 @@ export class SynchronizedTreeState {
56
73
  ops: SynchronizedTreeOps,
57
74
  private readonly logger?: MiLogger,
58
75
  ) {
59
- const { finalPredicateOverride, pruning, pollingInterval, stopPollingDelay, logStat } = ops;
76
+ const {
77
+ finalPredicateOverride,
78
+ pruning,
79
+ fieldFilter,
80
+ traverseStopRules,
81
+ traversalMode,
82
+ pollingInterval,
83
+ stopPollingDelay,
84
+ logStat,
85
+ } = ops;
60
86
  this.pruning = pruning;
87
+ this.fieldFilter = fieldFilter;
88
+ this.traverseStopRules = traverseStopRules;
89
+ this.traversalMode = traversalMode ?? "auto";
61
90
  this.pollingInterval = pollingInterval;
62
91
  this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;
63
92
  this.logStat = logStat;
@@ -123,11 +152,22 @@ export class SynchronizedTreeState {
123
152
  /** Executed from the main loop, and initialization procedure. */
124
153
  private async refresh(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {
125
154
  if (this.terminated) throw new Error("tree synchronization is terminated");
126
- const request = constructTreeLoadingRequest(this.state, this.pruning);
155
+ const request = constructTreeLoadingRequest(this.state, {
156
+ pruningFunction: this.pruning,
157
+ fieldFilter: this.fieldFilter,
158
+ traverseStopRules: this.traverseStopRules,
159
+ });
127
160
  const data = await this.pl.withReadTx(
128
161
  "ReadingTree",
129
162
  async (tx) => {
130
- return await loadTreeState(tx, request, stats);
163
+ return await loadTreeState(
164
+ tx,
165
+ request,
166
+ stats,
167
+ this.pl.serverInfo.capabilities ?? [],
168
+ this.traversalMode,
169
+ this.logger,
170
+ );
131
171
  },
132
172
  txOps,
133
173
  );
@@ -204,23 +244,40 @@ export class SynchronizedTreeState {
204
244
 
205
245
  if (!this.keepRunning || this.terminated) break;
206
246
 
247
+ // Phase 1: mandatory floor — always wait at least MIN_POLLING_INTERVAL_MS.
248
+ // Not interruptible by scheduleOnNextState; only termination aborts it.
249
+ try {
250
+ await tp.setTimeout(MIN_POLLING_INTERVAL_MS, undefined, {
251
+ signal: this.abortController.signal,
252
+ });
253
+ } catch (e: unknown) {
254
+ if (!isTimeoutOrCancelError(e)) throw new Error("Unexpected error", { cause: e });
255
+ if (this.abortController.signal.aborted) break;
256
+ }
257
+
258
+ if (!this.keepRunning || this.terminated) break;
259
+
260
+ // Phase 2: optional remainder up to pollingInterval — interruptible by
261
+ // scheduleOnNextState so that an external nudge wakes the loop promptly.
207
262
  if (this.scheduledOnNextState.length === 0) {
208
- try {
209
- this.currentLoopDelayInterrupt = new AbortController();
210
- await tp.setTimeout(this.pollingInterval, undefined, {
211
- signal: AbortSignal.any([
212
- this.abortController.signal,
213
- this.currentLoopDelayInterrupt.signal,
214
- ]),
215
- });
216
- } catch (e: unknown) {
217
- if (!isTimeoutOrCancelError(e)) throw new Error("Unexpected error", { cause: e });
218
- // If the main abort controller fired, this is a permanent termination
219
- if (this.abortController.signal.aborted) break;
220
- // Otherwise it was just the loop delay interrupt (scheduleOnNextState),
221
- // continue to the next iteration
222
- } finally {
223
- this.currentLoopDelayInterrupt = undefined;
263
+ const remaining = Math.max(0, this.pollingInterval - MIN_POLLING_INTERVAL_MS);
264
+ if (remaining > 0) {
265
+ try {
266
+ this.currentLoopDelayInterrupt = new AbortController();
267
+ await tp.setTimeout(remaining, undefined, {
268
+ signal: AbortSignal.any([
269
+ this.abortController.signal,
270
+ this.currentLoopDelayInterrupt.signal,
271
+ ]),
272
+ });
273
+ } catch (e: unknown) {
274
+ if (!isTimeoutOrCancelError(e)) throw new Error("Unexpected error", { cause: e });
275
+ if (this.abortController.signal.aborted) break;
276
+ // Otherwise it was just the loop delay interrupt (scheduleOnNextState),
277
+ // continue to the next iteration
278
+ } finally {
279
+ this.currentLoopDelayInterrupt = undefined;
280
+ }
224
281
  }
225
282
  }
226
283
  }