@milaboratories/pl-tree 1.13.3 → 1.13.5

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/state.ts CHANGED
@@ -68,6 +68,49 @@ export type ResourceDataWithFinalState = ResourceData & {
68
68
  finalState: boolean;
69
69
  };
70
70
 
71
+ /** Cross-cycle re-fetch duplication counters, populated by {@link PlTreeState.updateFromResourceData}.
72
+ * Separates genuinely new/changed resources from redundant re-fetches of unchanged ones
73
+ * (the delta-skip opportunity: intra-cycle dedup already happens in the loader, so all waste here
74
+ * is cross-cycle). */
75
+ export interface ResourceUpdateStat {
76
+ /** Resources seen for the first time in this update. */
77
+ resourcesNew: number;
78
+ /** Resources already held whose state actually changed. */
79
+ resourcesChanged: number;
80
+ /** Resources already held that came back unchanged: a pure duplicate re-fetch. */
81
+ resourcesUnchanged: number;
82
+ /** data + KV bytes of the unchanged bucket: wasted downlink. */
83
+ bytesUnchanged: number;
84
+ /** Changed resources whose immutable metadata (type, kind, field set) did not change,
85
+ * i.e. metadata re-streamed even though only a value or flag flipped. */
86
+ metadataStableChanged: number;
87
+ /** Per-resource fetches spent on unchanged resources (BFS only; streaming re-sends them
88
+ * inside one stream, so this stays 0). */
89
+ bfsRequestsWasted: number;
90
+ /** Whether the current load used backend streaming; set by the loader, read here to
91
+ * attribute {@link bfsRequestsWasted}. */
92
+ usedStreaming: boolean;
93
+ // Breakdown of what changed among resourcesChanged (a resource can bump several).
94
+ /** New fields appeared on a held resource. */
95
+ fieldsAdded: number;
96
+ /** Dynamic fields disappeared from a held resource. */
97
+ fieldsRemoved: number;
98
+ /** Existing field value pointer changed (a new value/result was attached). */
99
+ fieldsChanged: number;
100
+ /** KV entries added, changed, or deleted. */
101
+ kvChanged: number;
102
+ /** resourceReady flipped. */
103
+ readyFlips: number;
104
+ /** inputsLocked or outputsLocked transitioned. */
105
+ locksChanged: number;
106
+ /** An error resource was attached. */
107
+ errorsAttached: number;
108
+ /** originalResourceId resolved (duplicate -> original). */
109
+ duplicatesResolved: number;
110
+ /** Held resources that transitioned to final this update. */
111
+ resourcesMarkedFinal: number;
112
+ }
113
+
71
114
  /** Never store instances of this class, always get fresh instance from {@link PlTreeState} */
72
115
  export class PlTreeResource implements ResourceDataWithFinalState {
73
116
  /** Tracks number of other resources referencing this resource. Used to perform garbage collection in tree patching procedure */
@@ -451,7 +494,11 @@ export class PlTreeState {
451
494
  return res;
452
495
  }
453
496
 
454
- updateFromResourceData(resourceData: ExtendedResourceData[], allowOrphanInputs: boolean = false) {
497
+ updateFromResourceData(
498
+ resourceData: ExtendedResourceData[],
499
+ opts: { allowOrphanInputs?: boolean; stat?: ResourceUpdateStat } = {},
500
+ ) {
501
+ const { allowOrphanInputs = false, stat } = opts;
455
502
  this.checkValid();
456
503
 
457
504
  // All resources for which recount should be incremented, first are aggregated in this list
@@ -461,6 +508,11 @@ export class PlTreeState {
461
508
  // patching / creating resources
462
509
  for (const rd of resourceData) {
463
510
  let resource = this.resources.get(rd.id);
511
+ const held = resource !== undefined;
512
+ let changed = false;
513
+ // Structural/metadata change (new or removed field). type and kind are readonly, so
514
+ // they never change; this flag isolates value/flag-only changes from real metadata churn.
515
+ let metadataChanged = false;
464
516
 
465
517
  const statBeforeMutation = resource?.basicState;
466
518
  const unexpectedTransitionError = (reason: string): never => {
@@ -480,7 +532,6 @@ export class PlTreeState {
480
532
  if (resource.finalState)
481
533
  unexpectedTransitionError("resource state can\t be updated after it is marked as final");
482
534
 
483
- let changed = false;
484
535
  // updating resource version, even if it was not changed
485
536
  resource.version += 1;
486
537
 
@@ -494,6 +545,7 @@ export class PlTreeState {
494
545
  `originalResourceId changed for ${resourceIdToString(resource.id)}`,
495
546
  );
496
547
  changed = true;
548
+ if (stat) stat.duplicatesResolved++;
497
549
  }
498
550
 
499
551
  // error
@@ -506,6 +558,7 @@ export class PlTreeState {
506
558
  `error changed for ${resourceIdToString(resource.id)}`,
507
559
  );
508
560
  changed = true;
561
+ if (stat) stat.errorsAttached++;
509
562
  }
510
563
 
511
564
  // updating fields
@@ -552,6 +605,8 @@ export class PlTreeState {
552
605
  resource.fieldsMap.set(fd.name, field);
553
606
 
554
607
  changed = true;
608
+ metadataChanged = true;
609
+ if (stat) stat.fieldsAdded++;
555
610
  } else {
556
611
  // change of old field
557
612
 
@@ -596,6 +651,7 @@ export class PlTreeState {
596
651
  `field ${fd.name} value changed in ${resourceIdToString(resource.id)}`,
597
652
  );
598
653
  changed = true;
654
+ if (stat) stat.fieldsChanged++;
599
655
  }
600
656
 
601
657
  // field error
@@ -640,6 +696,8 @@ export class PlTreeState {
640
696
  `dynamic field ${fieldName} removed from ${resourceIdToString(resource!.id)}`,
641
697
  );
642
698
  fields.delete(fieldName);
699
+ metadataChanged = true;
700
+ if (stat) stat.fieldsRemoved++;
643
701
 
644
702
  if (isNotNullSignedResourceId(field.value)) decrementRefs.push(field.value);
645
703
  if (isNotNullSignedResourceId(field.error)) decrementRefs.push(field.error);
@@ -658,6 +716,7 @@ export class PlTreeState {
658
716
  `inputs locked for ${resourceIdToString(resource.id)}`,
659
717
  );
660
718
  changed = true;
719
+ if (stat) stat.locksChanged++;
661
720
  }
662
721
 
663
722
  // outputsLocked
@@ -669,6 +728,7 @@ export class PlTreeState {
669
728
  `outputs locked for ${resourceIdToString(resource.id)}`,
670
729
  );
671
730
  changed = true;
731
+ if (stat) stat.locksChanged++;
672
732
  }
673
733
 
674
734
  // ready flag
@@ -684,6 +744,7 @@ export class PlTreeState {
684
744
  `ready flag changed to ${rd.resourceReady} for ${resourceIdToString(resource.id)}`,
685
745
  );
686
746
  changed = true;
747
+ if (stat) stat.readyFlips++;
687
748
  }
688
749
 
689
750
  // syncing kv
@@ -695,12 +756,16 @@ export class PlTreeState {
695
756
  kv.key,
696
757
  `kv added for ${resourceIdToString(resource.id)}: ${kv.key}`,
697
758
  );
759
+ changed = true;
760
+ if (stat) stat.kvChanged++;
698
761
  } else if (Buffer.compare(current, kv.value) !== 0) {
699
762
  resource.kv.set(kv.key, kv.value);
700
763
  notEmpty(resource.kvChangedPerKey).markChanged(
701
764
  kv.key,
702
765
  `kv changed for ${resourceIdToString(resource.id)}: ${kv.key}`,
703
766
  );
767
+ changed = true;
768
+ if (stat) stat.kvChanged++;
704
769
  }
705
770
  }
706
771
 
@@ -716,6 +781,8 @@ export class PlTreeState {
716
781
  key,
717
782
  `kv deleted for ${resourceIdToString(resource!.id)}: ${key}`,
718
783
  );
784
+ changed = true;
785
+ if (stat) stat.kvChanged++;
719
786
  }
720
787
  });
721
788
  }
@@ -723,7 +790,10 @@ export class PlTreeState {
723
790
  if (changed) {
724
791
  // if resource was changed, updating resource data version
725
792
  resource.dataVersion = resource.version;
726
- if (this.isFinalPredicate(resource)) resource.markFinal();
793
+ if (this.isFinalPredicate(resource)) {
794
+ resource.markFinal();
795
+ if (stat) stat.resourcesMarkedFinal++;
796
+ }
727
797
  }
728
798
  } else {
729
799
  // creating new resource
@@ -756,6 +826,19 @@ export class PlTreeState {
756
826
  this.resources.set(resource.id, resource);
757
827
  this.resourcesAdded.markChanged(`new resource ${resourceIdToString(resource.id)} added`);
758
828
  }
829
+
830
+ if (stat) {
831
+ if (!held) stat.resourcesNew++;
832
+ else if (changed) {
833
+ stat.resourcesChanged++;
834
+ if (!metadataChanged) stat.metadataStableChanged++;
835
+ } else {
836
+ stat.resourcesUnchanged++;
837
+ stat.bytesUnchanged += rd.data?.length ?? 0;
838
+ for (const kv of rd.kv) stat.bytesUnchanged += kv.value.length;
839
+ if (!stat.usedStreaming) stat.bfsRequestsWasted++;
840
+ }
841
+ }
759
842
  }
760
843
 
761
844
  // applying refCount increments
package/src/sync.ts CHANGED
@@ -8,7 +8,7 @@ import type {
8
8
  } from "@milaboratories/pl-client";
9
9
  import Denque from "denque";
10
10
  import { hasCapability, isNullSignedResourceId } from "@milaboratories/pl-client";
11
- import type { ExtendedResourceData, PlTreeState } from "./state";
11
+ import type { ExtendedResourceData, PlTreeState, ResourceUpdateStat } from "./state";
12
12
  import { ConcurrencyLimitingExecutor, msToHumanReadable } from "@milaboratories/ts-helpers";
13
13
 
14
14
  /** Applied to list of fields in resource data. */
@@ -70,7 +70,7 @@ export function constructTreeLoadingRequest(
70
70
  };
71
71
  }
72
72
 
73
- export type TreeLoadingStat = {
73
+ export type TreeLoadingStat = ResourceUpdateStat & {
74
74
  requests: number;
75
75
  roundTrips: number;
76
76
  retrievedResources: number;
@@ -85,6 +85,20 @@ export type TreeLoadingStat = {
85
85
  stopMarkersSkipped: number;
86
86
  /** Number of follow-up resourceTree() calls issued to resolve unknown stop markers. */
87
87
  stopMarkerFollowUpRoundTrips: number;
88
+ /** Streaming (resourceTree) path: resourceTree() streams consumed (1, or 2 with a follow-up). */
89
+ streamRounds: number;
90
+ /** Streaming path: resource frames received. */
91
+ resourceFrames: number;
92
+ /** Streaming path: stopMarker frames received (both skipped and follow-up). */
93
+ stopMarkerFrames: number;
94
+ /** Streaming path: stop markers that were not final locally and triggered a follow-up fetch. */
95
+ stopMarkersFollowUp: number;
96
+ /** Streaming path: frames where the backend stopped traversal (final or traverseWasStopped). */
97
+ traverseWasStoppedCount: number;
98
+ /** BFS path: resource states actually requested from the backend this cycle (after intra-cycle dedup). */
99
+ bfsResourcesRequested: number;
100
+ /** BFS path: requested resources that no longer exist (undefined reply). */
101
+ bfsResourcesNotFound: number;
88
102
  };
89
103
 
90
104
  export function initialTreeLoadingStat(): TreeLoadingStat {
@@ -101,23 +115,54 @@ export function initialTreeLoadingStat(): TreeLoadingStat {
101
115
  millisSpent: 0,
102
116
  stopMarkersSkipped: 0,
103
117
  stopMarkerFollowUpRoundTrips: 0,
118
+ streamRounds: 0,
119
+ resourceFrames: 0,
120
+ stopMarkerFrames: 0,
121
+ stopMarkersFollowUp: 0,
122
+ traverseWasStoppedCount: 0,
123
+ bfsResourcesRequested: 0,
124
+ bfsResourcesNotFound: 0,
125
+ resourcesNew: 0,
126
+ resourcesChanged: 0,
127
+ resourcesUnchanged: 0,
128
+ bytesUnchanged: 0,
129
+ metadataStableChanged: 0,
130
+ bfsRequestsWasted: 0,
131
+ usedStreaming: false,
132
+ fieldsAdded: 0,
133
+ fieldsRemoved: 0,
134
+ fieldsChanged: 0,
135
+ kvChanged: 0,
136
+ readyFlips: 0,
137
+ locksChanged: 0,
138
+ errorsAttached: 0,
139
+ duplicatesResolved: 0,
140
+ resourcesMarkedFinal: 0,
104
141
  };
105
142
  }
106
143
 
107
144
  export function formatTreeLoadingStat(stat: TreeLoadingStat): string {
108
- let result = `Requests: ${stat.requests}\n`;
109
- result += `Total time: ${msToHumanReadable(stat.millisSpent)}\n`;
110
- result += `Round-trips: ${stat.roundTrips}\n`;
111
- result += `Resources: ${stat.retrievedResources}\n`;
112
- result += `Fields: ${stat.retrievedFields}\n`;
113
- result += `KV: ${stat.retrievedKeyValues}\n`;
114
- result += `Data Bytes: ${stat.retrievedResourceDataBytes}\n`;
115
- result += `KV Bytes: ${stat.retrievedKeyValueBytes}\n`;
116
- result += `Pruned fields: ${stat.prunedFields}\n`;
117
- result += `Final resources skipped: ${stat.finalResourcesSkipped}\n`;
118
- result += `Stop markers skipped: ${stat.stopMarkersSkipped}\n`;
119
- result += `Stop marker follow-up round-trips: ${stat.stopMarkerFollowUpRoundTrips}`;
120
- return result;
145
+ return `Requests: ${stat.requests}
146
+ Total time: ${msToHumanReadable(stat.millisSpent)}
147
+ Round-trips: ${stat.roundTrips}
148
+ Resources: ${stat.retrievedResources}
149
+ Fields: ${stat.retrievedFields}
150
+ KV: ${stat.retrievedKeyValues}
151
+ Data Bytes: ${stat.retrievedResourceDataBytes}
152
+ KV Bytes: ${stat.retrievedKeyValueBytes}
153
+ Pruned fields: ${stat.prunedFields}
154
+ Final resources skipped: ${stat.finalResourcesSkipped}
155
+ Stop markers skipped: ${stat.stopMarkersSkipped}
156
+ Stop marker follow-up round-trips: ${stat.stopMarkerFollowUpRoundTrips}
157
+ New resources: ${stat.resourcesNew}
158
+ Changed resources: ${stat.resourcesChanged}
159
+ Unchanged (duplicate re-fetch) resources: ${stat.resourcesUnchanged}
160
+ Unchanged bytes (wasted downlink): ${stat.bytesUnchanged}
161
+ Changed with stable metadata: ${stat.metadataStableChanged}
162
+ BFS fetches wasted on unchanged: ${stat.bfsRequestsWasted}
163
+ Used streaming: ${stat.usedStreaming}
164
+ [streaming] rounds: ${stat.streamRounds}, resource frames: ${stat.resourceFrames}, stop-marker frames: ${stat.stopMarkerFrames}, stop->follow-up: ${stat.stopMarkersFollowUp}, traverse-stopped: ${stat.traverseWasStoppedCount}
165
+ [bfs] resources requested: ${stat.bfsResourcesRequested}, not found: ${stat.bfsResourcesNotFound}`;
121
166
  }
122
167
 
123
168
  function supportsResourceTreeTraversal(capabilities: readonly string[] = []): boolean {
@@ -164,6 +209,7 @@ async function loadTreeStateViaBfs(
164
209
  }
165
210
 
166
211
  requested.add(rid);
212
+ if (stats) stats.bfsResourcesRequested++;
167
213
 
168
214
  pending.push(
169
215
  limiter.run(async () => {
@@ -183,7 +229,10 @@ async function loadTreeStateViaBfs(
183
229
  roundTripToggle = true;
184
230
  }
185
231
 
186
- if (resource === undefined) return undefined;
232
+ if (resource === undefined) {
233
+ if (stats) stats.bfsResourcesNotFound++;
234
+ return undefined;
235
+ }
187
236
  if (kv === undefined) throw new Error("Inconsistent replies");
188
237
 
189
238
  return { ...resource, kv };
@@ -247,15 +296,21 @@ async function processResourceTreeStream(
247
296
  // should make a decision: has it already loaded the resource or should it be requested for get the latest state?
248
297
  for await (const frame of treeItems) {
249
298
  if (frame.frameKind === "stopMarker") {
299
+ if (stats) stats.stopMarkerFrames++;
250
300
  if (finalResources.has(frame.id)) {
251
301
  if (stats) stats.stopMarkersSkipped++;
252
302
  continue;
253
303
  }
304
+ if (stats) stats.stopMarkersFollowUp++;
254
305
  followUpSeeds.push(frame.id);
255
306
  continue;
256
307
  }
257
308
 
258
309
  // Normal resource frame.
310
+ if (stats) {
311
+ stats.resourceFrames++;
312
+ if (frame.traverseWasStopped) stats.traverseWasStoppedCount++;
313
+ }
259
314
  if (finalResources.has(frame.id)) {
260
315
  if (stats) stats.finalResourcesSkipped++;
261
316
  continue;
@@ -317,16 +372,30 @@ async function loadTreeStateViaResourceTree(
317
372
  pruningFunction,
318
373
  stats,
319
374
  );
320
- if (stats) stats.roundTrips++;
375
+ if (stats) {
376
+ stats.roundTrips++;
377
+ stats.streamRounds++;
378
+ }
321
379
 
322
- // Client must request full resource tree in case when stop-marker seeds are returned,
323
- // to ensure all resources are loaded and stop-marker frames are processed.
324
- if (followUpSeeds.length > 0) {
325
- const followUpItems = tx.resourceTree(followUpSeeds, {
380
+ // Resolve stop-marker seeds by fetching them (see the note below on why the stop rule is not
381
+ // reapplied). Loop in case a fetch still yields stop markers; dedup fetched ids so shared refs
382
+ // or diamonds cannot loop forever (the id set is finite, so the loop terminates). A referenced
383
+ // resource left unloaded would make updateFromResourceData throw "orphan resource".
384
+ let pendingSeeds = followUpSeeds;
385
+ const fetchedSeeds = new Set<SignedResourceId>();
386
+ while (pendingSeeds.length > 0) {
387
+ const roundSeeds = pendingSeeds.filter((id) => !fetchedSeeds.has(id));
388
+ if (roundSeeds.length === 0) break;
389
+ for (const id of roundSeeds) fetchedSeeds.add(id);
390
+
391
+ // No traverseStopRules here on purpose: the seeds are the stop-marked resources, and the
392
+ // stop rule (finality-based) would re-flag them as stop markers instead of loading their
393
+ // state, leaving resources that reference them as orphans. The retry must fetch them plainly.
394
+ const followUpItems = tx.resourceTree(roundSeeds, {
326
395
  includeKv: true,
327
396
  fieldFilter,
328
397
  });
329
- const { result: followUpResult } = await processResourceTreeStream(
398
+ const { result: followUpResult, followUpSeeds: nextSeeds } = await processResourceTreeStream(
330
399
  followUpItems,
331
400
  finalResources,
332
401
  pruningFunction,
@@ -335,11 +404,13 @@ async function loadTreeStateViaResourceTree(
335
404
  result.push(...followUpResult);
336
405
  if (stats) {
337
406
  logger?.info?.(
338
- `loadTreeStateViaResourceTree: follow-up request for ${followUpSeeds.length} stop-marker seeds: ${JSON.stringify(followUpSeeds)}`,
407
+ `loadTreeStateViaResourceTree: follow-up request for ${roundSeeds.length} stop-marker seeds: ${JSON.stringify(roundSeeds)}`,
339
408
  );
340
409
  stats.roundTrips++;
410
+ stats.streamRounds++;
341
411
  stats.stopMarkerFollowUpRoundTrips++;
342
412
  }
413
+ pendingSeeds = nextSeeds;
343
414
  }
344
415
 
345
416
  return result;
@@ -364,6 +435,8 @@ export async function loadTreeState(
364
435
  mode === "backend-streaming" ||
365
436
  (mode === "auto" && supportsResourceTreeTraversal(capabilities));
366
437
 
438
+ if (stats) stats.usedStreaming = wantsStreaming && supportsResourceTreeTraversal(capabilities);
439
+
367
440
  if (wantsStreaming && !supportsResourceTreeTraversal(capabilities)) {
368
441
  const msg =
369
442
  "traversalMode=backend-streaming but backend lacks treeFilter:v2 capability; falling back to BFS";
@@ -276,7 +276,7 @@ export class SynchronizedTreeState {
276
276
  },
277
277
  txOps,
278
278
  );
279
- this.state.updateFromResourceData(data, true);
279
+ this.state.updateFromResourceData(data, { allowOrphanInputs: true, stat: stats });
280
280
  }
281
281
 
282
282
  /** Discovery sync for shared-type seeds: re-polls `ListUserResources` (gRPC-only) and