@milaboratories/pl-tree 1.13.5 → 1.13.6

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/index.cjs CHANGED
@@ -17,6 +17,7 @@ exports.PlTreeState = require_state.PlTreeState;
17
17
  exports.SynchronizedTreeState = require_synchronized_tree.SynchronizedTreeState;
18
18
  exports.TreeStateUpdateError = require_state.TreeStateUpdateError;
19
19
  exports.constructTreeLoadingRequest = require_sync.constructTreeLoadingRequest;
20
+ exports.derivePollingInterval = require_synchronized_tree.derivePollingInterval;
20
21
  exports.formatTreeLoadingStat = require_sync.formatTreeLoadingStat;
21
22
  exports.initialTreeLoadingStat = require_sync.initialTreeLoadingStat;
22
23
  exports.isPlTreeEntry = require_accessors.isPlTreeEntry;
package/dist/index.d.ts CHANGED
@@ -5,6 +5,6 @@ import { PlError, PlTreeEntry, PlTreeEntryAccessor, PlTreeNodeAccessor, PlTreeRo
5
5
  import { ExtendedResourceData, PlTreeResource, PlTreeState, ResourceDataWithFinalState, ResourceUpdateStat, TreeStateUpdateError } from "./state.js";
6
6
  import { PruningFunction, TraversalMode, TreeLoadingRequest, TreeLoadingStat, constructTreeLoadingRequest, formatTreeLoadingStat, initialTreeLoadingStat, loadTreeState } from "./sync.js";
7
7
  import { InferSnapshot, ResourceSnapshot, ResourceSnapshotSchema, ResourceWithData, ResourceWithMetadata, makeResourceSnapshot, rsSchema, treeEntryToResourceWithData, treeEntryToResourceWithMetadata } from "./snapshot.js";
8
- import { ExplicitRootSeed, SharedTypeSeed, SynchronizedTreeOps, SynchronizedTreeState, TreeSeed } from "./synchronized_tree.js";
8
+ import { ExplicitRootSeed, SharedTypeSeed, SynchronizedTreeOps, SynchronizedTreeState, TreeSeed, derivePollingInterval } from "./synchronized_tree.js";
9
9
  import { ResourceStats, TreeDumpStats, treeDumpStats } from "./dump.js";
10
- export { CommonFieldTraverseOps, CommonTraversalOps, ExplicitRootSeed, ExtendedResourceData, FieldTraversalStep, GetFieldStep, InferSnapshot, PlError, PlTreeEntry, PlTreeEntryAccessor, PlTreeNodeAccessor, PlTreeResource, PlTreeRootsAccessor, PlTreeRootsEntry, PlTreeState, PruningFunction, ResourceDataWithFinalState, ResourceInfo, ResourceSnapshot, ResourceSnapshotSchema, ResourceStats, ResourceTraversalOps, ResourceUpdateStat, ResourceWithData, ResourceWithMetadata, SharedTypeSeed, SynchronizedTreeOps, SynchronizedTreeState, TraversalMode, TreeAccessorData, TreeAccessorInstanceData, TreeDumpStats, TreeLoadingRequest, TreeLoadingStat, TreeSeed, TreeStateUpdateError, ValueAndError, ValueOrError, constructTreeLoadingRequest, formatTreeLoadingStat, initialTreeLoadingStat, isPlTreeEntry, isPlTreeEntryAccessor, isPlTreeNodeAccessor, isPlTreeRootsAccessor, isPlTreeRootsEntry, loadTreeState, makeResourceSnapshot, mapValueAndError, mapValueAndErrorIfDefined, rsSchema, treeDumpStats, treeEntryToResourceInfo, treeEntryToResourceWithData, treeEntryToResourceWithMetadata };
10
+ export { CommonFieldTraverseOps, CommonTraversalOps, ExplicitRootSeed, ExtendedResourceData, FieldTraversalStep, GetFieldStep, InferSnapshot, PlError, PlTreeEntry, PlTreeEntryAccessor, PlTreeNodeAccessor, PlTreeResource, PlTreeRootsAccessor, PlTreeRootsEntry, PlTreeState, PruningFunction, ResourceDataWithFinalState, ResourceInfo, ResourceSnapshot, ResourceSnapshotSchema, ResourceStats, ResourceTraversalOps, ResourceUpdateStat, ResourceWithData, ResourceWithMetadata, SharedTypeSeed, SynchronizedTreeOps, SynchronizedTreeState, TraversalMode, TreeAccessorData, TreeAccessorInstanceData, TreeDumpStats, TreeLoadingRequest, TreeLoadingStat, TreeSeed, TreeStateUpdateError, ValueAndError, ValueOrError, constructTreeLoadingRequest, derivePollingInterval, formatTreeLoadingStat, initialTreeLoadingStat, isPlTreeEntry, isPlTreeEntryAccessor, isPlTreeNodeAccessor, isPlTreeRootsAccessor, isPlTreeRootsEntry, loadTreeState, makeResourceSnapshot, mapValueAndError, mapValueAndErrorIfDefined, rsSchema, treeDumpStats, treeEntryToResourceInfo, treeEntryToResourceWithData, treeEntryToResourceWithMetadata };
package/dist/index.js CHANGED
@@ -3,6 +3,6 @@ import { PlError, PlTreeEntry, PlTreeEntryAccessor, PlTreeNodeAccessor, PlTreeRo
3
3
  import { PlTreeResource, PlTreeState, TreeStateUpdateError } from "./state.js";
4
4
  import { constructTreeLoadingRequest, formatTreeLoadingStat, initialTreeLoadingStat, loadTreeState } from "./sync.js";
5
5
  import { makeResourceSnapshot, rsSchema, treeEntryToResourceWithData, treeEntryToResourceWithMetadata } from "./snapshot.js";
6
- import { SynchronizedTreeState } from "./synchronized_tree.js";
6
+ import { SynchronizedTreeState, derivePollingInterval } from "./synchronized_tree.js";
7
7
  import { treeDumpStats } from "./dump.js";
8
- export { PlError, PlTreeEntry, PlTreeEntryAccessor, PlTreeNodeAccessor, PlTreeResource, PlTreeRootsAccessor, PlTreeRootsEntry, PlTreeState, SynchronizedTreeState, TreeStateUpdateError, constructTreeLoadingRequest, formatTreeLoadingStat, initialTreeLoadingStat, isPlTreeEntry, isPlTreeEntryAccessor, isPlTreeNodeAccessor, isPlTreeRootsAccessor, isPlTreeRootsEntry, loadTreeState, makeResourceSnapshot, mapValueAndError, mapValueAndErrorIfDefined, rsSchema, treeDumpStats, treeEntryToResourceInfo, treeEntryToResourceWithData, treeEntryToResourceWithMetadata };
8
+ export { PlError, PlTreeEntry, PlTreeEntryAccessor, PlTreeNodeAccessor, PlTreeResource, PlTreeRootsAccessor, PlTreeRootsEntry, PlTreeState, SynchronizedTreeState, TreeStateUpdateError, constructTreeLoadingRequest, derivePollingInterval, formatTreeLoadingStat, initialTreeLoadingStat, isPlTreeEntry, isPlTreeEntryAccessor, isPlTreeNodeAccessor, isPlTreeRootsAccessor, isPlTreeRootsEntry, loadTreeState, makeResourceSnapshot, mapValueAndError, mapValueAndErrorIfDefined, rsSchema, treeDumpStats, treeEntryToResourceInfo, treeEntryToResourceWithData, treeEntryToResourceWithMetadata };
@@ -11,6 +11,23 @@ node_timers_promises = require_runtime.__toESM(node_timers_promises, 1);
11
11
  * Applies even when {@link scheduleOnNextState} has woken the loop early,
12
12
  * preventing tight polling loops during rapid state transitions. */
13
13
  const MIN_POLLING_INTERVAL_MS = 100;
14
+ /** Ceiling for the adaptive poll interval. Caps how stale an idle tree can get before the
15
+ * next look, and bounds how far the idle backoff can push the interval out. */
16
+ const MAX_POLLING_INTERVAL_MS = 5e3;
17
+ /** Applied to the interval after a cycle that changed nothing. An idle tree walks its
18
+ * interval out towards {@link MAX_POLLING_INTERVAL_MS} instead of re-polling at full rate;
19
+ * the first cycle that changes anything resets it. */
20
+ const IDLE_BACKOFF_MULTIPLIER = 1.5;
21
+ /** The client's measured RTT becomes an interval floor, scaled by this. Every refresh costs
22
+ * at least one round trip, so polling faster than a small multiple of the RTT only queues
23
+ * round-trips the link cannot service. This is what replaces the fixed interval on a
24
+ * high-latency link; on a fast link the configured `pollingInterval` still dominates. */
25
+ const RTT_POLL_FACTOR = 2;
26
+ /** Ceiling for the RTT-derived floor. The estimate is sampled at connect time and never
27
+ * re-sampled, so without a bound one slow ping pins the cadence high for the whole session.
28
+ * Mirrors `MAX_ADAPTIVE_REQUEST_TIMEOUT` on the deadline side: past this the link is stuck
29
+ * rather than slow, and spacing polls further only delays noticing it recovered. */
30
+ const MAX_RTT_POLL_INTERVAL_MS = 3e4;
14
31
  /** Normalizes the {@link SynchronizedTreeState.init} seed argument — a bare
15
32
  * {@link SignedResourceId}, a single {@link TreeSeed}, or an array — to `TreeSeed[]`, so
16
33
  * every existing single-root caller is unchanged. */
@@ -22,19 +39,39 @@ function normalizeSeeds(seeds) {
22
39
  root: seeds
23
40
  }];
24
41
  }
25
- /** How often, in main-loop iterations, a tree with shared-type seeds re-polls
26
- * ListUserResources to reconcile its discovered roots. 1 would mean every iteration.
42
+ /** How often a tree with shared-type seeds re-polls ListUserResources to reconcile its
43
+ * discovered roots.
27
44
  *
28
45
  * Discovery (a full ListUserResources stream) is far heavier than an ordinary incremental
29
- * refresh, so it must NOT run on every fast refresh tick. The refresh cadence is the tree's
30
- * `pollingInterval` floored by {@link MIN_POLLING_INTERVAL_MS} (~200ms-1s in practice — the
31
- * shared-seed discovery tree runs at the 200ms default). At N = 15 discovery fires roughly
32
- * every 15 × 200ms ≈ 3s, decoupling it from the fast refresh loop while keeping the latency
33
- * of noticing a new/removed share to a few seconds — acceptable for a human-driven share flow.
46
+ * refresh, so it must NOT run on every refresh tick. This is a wall-clock interval rather
47
+ * than a count of iterations: the refresh cadence is adaptive (it stretches towards
48
+ * {@link MAX_POLLING_INTERVAL_MS} on an idle tree and scales with RTT on a slow link),
49
+ * so a fixed iteration count would let discovery latency drift out with it. Keeping it in
50
+ * milliseconds pins the latency of noticing a new/removed share regardless of cadence, which
51
+ * is what a human-driven share flow cares about.
34
52
  *
35
53
  * Only trees with shared-type seeds gate on this; {@link discover} is a no-op for single-root
36
54
  * and explicit-seed trees (empty `sharedSeeds`), so the value never affects them. */
37
- const DISCOVERY_EVERY_N_REFRESHES = 15;
55
+ const DISCOVERY_INTERVAL_MS = 3e3;
56
+ /** Counters that mean "this cycle brought something new". Deliberately not the full change
57
+ * breakdown: those fields are sub-counts of `resourcesChanged` and would double-count.
58
+ * `resourcesUnchanged` is excluded by design, since a cycle that only re-fetched unchanged
59
+ * state is exactly the idle case the backoff exists for. */
60
+ function countedChanges(stat) {
61
+ return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal;
62
+ }
63
+ /** The poll-cadence policy, as a pure function of the last cycle's outcome.
64
+ *
65
+ * `configuredMs` is the tree's static `pollingInterval` and acts as the lower bound, so no
66
+ * link can be polled faster than configured. `rttMs` raises that bound on a slow link.
67
+ * `currentMs` is the interval in force for the cycle that just finished, which is what the
68
+ * idle backoff compounds on. */
69
+ function derivePollingInterval(opts) {
70
+ const { configuredMs, currentMs, rttMs, changed } = opts;
71
+ const floor = rttMs === void 0 ? configuredMs : Math.max(configuredMs, Math.min(MAX_RTT_POLL_INTERVAL_MS, Math.ceil(rttMs * RTT_POLL_FACTOR)));
72
+ const next = changed ? floor : Math.max(floor, currentMs * IDLE_BACKOFF_MULTIPLIER);
73
+ return Math.max(floor, Math.min(MAX_POLLING_INTERVAL_MS, next));
74
+ }
38
75
  var SynchronizedTreeState = class SynchronizedTreeState {
39
76
  pl;
40
77
  logger;
@@ -63,6 +100,7 @@ var SynchronizedTreeState = class SynchronizedTreeState {
63
100
  this.traverseStopRules = traverseStopRules;
64
101
  this.traversalMode = traversalMode ?? "auto";
65
102
  this.pollingInterval = pollingInterval;
103
+ this.effectivePollingInterval = pollingInterval;
66
104
  this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;
67
105
  this.logStat = logStat;
68
106
  this.explicitRoots = seeds.filter((s) => s.kind === "resource").map((s) => s.root);
@@ -113,6 +151,24 @@ var SynchronizedTreeState = class SynchronizedTreeState {
113
151
  }
114
152
  currentLoopDelayInterrupt = void 0;
115
153
  scheduledOnNextState = [];
154
+ /** Interval actually used for the current wait. Starts at the configured `pollingInterval`
155
+ * and is re-derived after every cycle by {@link updatePollingInterval}. */
156
+ effectivePollingInterval;
157
+ /** Re-derives {@link effectivePollingInterval} after a cycle.
158
+ *
159
+ * Two independent effects. The floor scales with the client's measured RTT, so a
160
+ * high-latency link stops queueing round-trips it cannot service. On top of that, a cycle
161
+ * that changed nothing multiplies the interval out towards
162
+ * {@link MAX_POLLING_INTERVAL_MS}, while any change snaps it straight back to the floor so
163
+ * an active tree stays responsive. */
164
+ updatePollingInterval(changed) {
165
+ this.effectivePollingInterval = derivePollingInterval({
166
+ configuredMs: this.pollingInterval,
167
+ currentMs: this.effectivePollingInterval,
168
+ rttMs: this.pl.rttEstimateMs,
169
+ changed
170
+ });
171
+ }
116
172
  /** Called from computable hooks when external observer asks for state refresh */
117
173
  scheduleOnNextState(resolve, reject) {
118
174
  if (this.terminated) reject(/* @__PURE__ */ new Error("tree synchronization is terminated"));
@@ -121,6 +177,7 @@ var SynchronizedTreeState = class SynchronizedTreeState {
121
177
  resolve,
122
178
  reject
123
179
  });
180
+ this.updatePollingInterval(true);
124
181
  if (this.currentLoopDelayInterrupt) {
125
182
  this.currentLoopDelayInterrupt.abort();
126
183
  this.currentLoopDelayInterrupt = void 0;
@@ -195,9 +252,9 @@ var SynchronizedTreeState = class SynchronizedTreeState {
195
252
  /** If true this tree state is permanently terminaed. */
196
253
  terminated = false;
197
254
  async mainLoop() {
198
- let stat = this.logStat ? require_sync.initialTreeLoadingStat() : void 0;
255
+ let stat = require_sync.initialTreeLoadingStat();
199
256
  let lastUpdate = Date.now();
200
- let iteration = 0;
257
+ let lastDiscovery = 0;
201
258
  while (true) {
202
259
  if (!this.keepRunning || this.terminated) break;
203
260
  let toNotify = void 0;
@@ -207,14 +264,18 @@ var SynchronizedTreeState = class SynchronizedTreeState {
207
264
  }
208
265
  try {
209
266
  if (this.logStat === "per-request") stat = require_sync.initialTreeLoadingStat();
210
- if (this.sharedSeeds.length > 0 && iteration % DISCOVERY_EVERY_N_REFRESHES === 0) await this.discover();
211
- iteration++;
267
+ if (this.sharedSeeds.length > 0 && Date.now() - lastDiscovery >= DISCOVERY_INTERVAL_MS) {
268
+ await this.discover();
269
+ lastDiscovery = Date.now();
270
+ }
271
+ const changesBefore = countedChanges(stat);
212
272
  await this.refresh(stat);
213
- if (stat && this.logger) this.logger.info(`Tree stat (success, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`);
273
+ this.updatePollingInterval(countedChanges(stat) > changesBefore);
274
+ if (this.logStat && this.logger) this.logger.info(`Tree stat (success, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`);
214
275
  lastUpdate = Date.now();
215
276
  if (toNotify !== void 0) for (const n of toNotify) n.resolve();
216
277
  } catch (e) {
217
- if (stat && this.logger) this.logger.info(`Tree stat (error, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`);
278
+ if (this.logStat && this.logger) this.logger.info(`Tree stat (error, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`);
218
279
  lastUpdate = Date.now();
219
280
  if (toNotify !== void 0) for (const n of toNotify) n.reject(e);
220
281
  if (e instanceof require_state.TreeStateUpdateError) {
@@ -233,7 +294,7 @@ var SynchronizedTreeState = class SynchronizedTreeState {
233
294
  }
234
295
  if (!this.keepRunning || this.terminated) break;
235
296
  if (this.scheduledOnNextState.length === 0) {
236
- const remaining = Math.max(0, this.pollingInterval - MIN_POLLING_INTERVAL_MS);
297
+ const remaining = Math.max(0, this.effectivePollingInterval - MIN_POLLING_INTERVAL_MS);
237
298
  if (remaining > 0) try {
238
299
  this.currentLoopDelayInterrupt = new AbortController();
239
300
  await node_timers_promises.setTimeout(remaining, void 0, { signal: AbortSignal.any([this.abortController.signal, this.currentLoopDelayInterrupt.signal]) });
@@ -294,5 +355,6 @@ var SynchronizedTreeState = class SynchronizedTreeState {
294
355
  };
295
356
  //#endregion
296
357
  exports.SynchronizedTreeState = SynchronizedTreeState;
358
+ exports.derivePollingInterval = derivePollingInterval;
297
359
 
298
360
  //# sourceMappingURL=synchronized_tree.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"synchronized_tree.cjs","names":["PlTreeState","PollingComputableHooks","PlTreeEntry","PlTreeRootsEntry","constructTreeLoadingRequest","loadTreeState","initialTreeLoadingStat","TreeStateUpdateError","tp"],"sources":["../src/synchronized_tree.ts"],"sourcesContent":["import { PollingComputableHooks } from \"@milaboratories/computable\";\nimport { PlTreeEntry, PlTreeRootsEntry } from \"./accessors\";\nimport type {\n FinalResourceDataPredicate,\n PlClient,\n ResourceType,\n SignedResourceId,\n TxOps,\n} from \"@milaboratories/pl-client\";\nimport type { Filter } from \"@milaboratories/pl-client\";\nimport {\n isUnauthenticated,\n isTimeoutOrCancelError,\n isUnimplementedError,\n} from \"@milaboratories/pl-client\";\nimport type { ExtendedResourceData } from \"./state\";\nimport { PlTreeState, TreeStateUpdateError } from \"./state\";\nimport type { PruningFunction, TraversalMode, TreeLoadingStat } from \"./sync\";\nimport { constructTreeLoadingRequest, initialTreeLoadingStat, loadTreeState } from \"./sync\";\nimport * as tp from \"node:timers/promises\";\nimport type { MiLogger } from \"@milaboratories/ts-helpers\";\n\n/** Hard floor between consecutive tree-refresh calls.\n * Applies even when {@link scheduleOnNextState} has woken the loop early,\n * preventing tight polling loops during rapid state transitions. */\nconst MIN_POLLING_INTERVAL_MS = 100;\n\ntype StatLoggingMode = \"cumulative\" | \"per-request\";\n\nexport type SynchronizedTreeOps = {\n /** Override final predicate from the PlClient */\n finalPredicateOverride?: FinalResourceDataPredicate;\n\n /** Pruning function for legacy fallback path. */\n pruning?: PruningFunction;\n\n /** ResourceTree field filter for modern backend path. */\n fieldFilter?: Filter;\n\n /** ResourceTree traversal stop rules for modern backend path. */\n traverseStopRules?: Filter;\n\n /** Interval after last sync to sleep before the next one */\n pollingInterval: number;\n /** For how long to continue polling after the last derived value access */\n stopPollingDelay: number;\n\n /** If one of the values, tree will log stats of each polling request */\n logStat?: StatLoggingMode;\n\n /** Timeout for initial tree loading. If not specified, will use default for RO tx from pl-client. */\n initialTreeLoadingTimeout?: number;\n\n /** Controls which tree-loading path to use. Default `\"auto\"`. */\n traversalMode?: TraversalMode;\n};\n\n/** An explicit resource to serve as a tree root. Several explicit seeds may be passed. */\nexport type ExplicitRootSeed = { kind: \"resource\"; root: SignedResourceId };\n\n/** Discovers, as roots, every resource of this type shared with the current user.\n * Matched against SharedResource.resourceType by NAME (version optional/ignored). The\n * discovered set is DYNAMIC — roots appear/disappear as grants are added/revoked/expire. */\nexport type SharedTypeSeed = { kind: \"shared\"; resourceType: ResourceType };\n\nexport type TreeSeed = ExplicitRootSeed | SharedTypeSeed;\n\n/** Normalizes the {@link SynchronizedTreeState.init} seed argument — a bare\n * {@link SignedResourceId}, a single {@link TreeSeed}, or an array — to `TreeSeed[]`, so\n * every existing single-root caller is unchanged. */\nfunction normalizeSeeds(seeds: SignedResourceId | TreeSeed | TreeSeed[]): TreeSeed[] {\n if (Array.isArray(seeds)) return seeds;\n if (typeof seeds === \"object\" && seeds !== null && \"kind\" in seeds) return [seeds];\n // bare SignedResourceId\n return [{ kind: \"resource\", root: seeds }];\n}\n\n/** How often, in main-loop iterations, a tree with shared-type seeds re-polls\n * ListUserResources to reconcile its discovered roots. 1 would mean every iteration.\n *\n * Discovery (a full ListUserResources stream) is far heavier than an ordinary incremental\n * refresh, so it must NOT run on every fast refresh tick. The refresh cadence is the tree's\n * `pollingInterval` floored by {@link MIN_POLLING_INTERVAL_MS} (~200ms-1s in practice — the\n * shared-seed discovery tree runs at the 200ms default). At N = 15 discovery fires roughly\n * every 15 × 200ms ≈ 3s, decoupling it from the fast refresh loop while keeping the latency\n * of noticing a new/removed share to a few seconds — acceptable for a human-driven share flow.\n *\n * Only trees with shared-type seeds gate on this; {@link discover} is a no-op for single-root\n * and explicit-seed trees (empty `sharedSeeds`), so the value never affects them. */\nconst DISCOVERY_EVERY_N_REFRESHES = 15;\n\ntype ScheduledRefresh = {\n resolve: () => void;\n reject: (err: any) => void;\n};\n\nexport class SynchronizedTreeState {\n private readonly finalPredicate: FinalResourceDataPredicate;\n private state: PlTreeState;\n private readonly pollingInterval: number;\n private readonly pruning?: PruningFunction;\n private readonly fieldFilter?: Filter;\n private readonly traverseStopRules?: Filter;\n private readonly traversalMode: TraversalMode;\n private readonly logStat?: StatLoggingMode;\n private readonly hooks: PollingComputableHooks;\n private readonly abortController = new AbortController();\n\n /** Explicit-resource seeds: fixed roots, present from construction. */\n private readonly explicitRoots: SignedResourceId[];\n /** Shared-type seeds (discovered roots), if any. */\n private readonly sharedSeeds: SharedTypeSeed[];\n /** Roots discovered for shared-type seeds on the last discovery poll. */\n private discoveredRoots: SignedResourceId[] = [];\n\n private constructor(\n private readonly pl: PlClient,\n seeds: TreeSeed[],\n ops: SynchronizedTreeOps,\n private readonly logger?: MiLogger,\n ) {\n const {\n finalPredicateOverride,\n pruning,\n fieldFilter,\n traverseStopRules,\n traversalMode,\n pollingInterval,\n stopPollingDelay,\n logStat,\n } = ops;\n this.pruning = pruning;\n this.fieldFilter = fieldFilter;\n this.traverseStopRules = traverseStopRules;\n this.traversalMode = traversalMode ?? \"auto\";\n this.pollingInterval = pollingInterval;\n this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;\n this.logStat = logStat;\n\n this.explicitRoots = seeds\n .filter((s): s is ExplicitRootSeed => s.kind === \"resource\")\n .map((s) => s.root);\n this.sharedSeeds = seeds.filter((s): s is SharedTypeSeed => s.kind === \"shared\");\n\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n this.hooks = new PollingComputableHooks(\n () => this.startUpdating(),\n () => this.stopUpdating(),\n { stopDebounce: stopPollingDelay },\n (resolve, reject) => this.scheduleOnNextState(resolve, reject),\n );\n }\n\n /** The current protected root set: explicit roots plus the latest discovered roots. */\n private currentRootSet(): Set<SignedResourceId> {\n return new Set([...this.explicitRoots, ...this.discoveredRoots]);\n }\n\n /** Resolves the single root for the backward-compatible single-root accessors, throwing\n * if the tree does not have exactly one root (guards legacy callers against multi-root). */\n private soleRoot(): SignedResourceId {\n const roots = this.currentRootSet();\n if (roots.size !== 1)\n throw new Error(\n `single-root accessor used on a tree with ${roots.size} roots; use rootsEntry() instead`,\n );\n return roots.values().next().value!;\n }\n\n /** @deprecated use \"entry\" instead */\n public accessor(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return this.entry(rid);\n }\n\n /** Backward-compatible single-root entry. With no `rid` it returns the sole root's entry\n * and THROWS if the tree has zero or more than one root. An explicit `rid` addresses any\n * resource in the heap, as today. */\n public entry(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeEntry(\n { treeProvider: () => this.state, hooks: this.hooks },\n rid ?? this.soleRoot(),\n );\n }\n\n /** Reactive provider for the current root SET. Reading it inside a Computable tracks the\n * set as a dependency, so the Computable recomputes when discovered roots appear/disappear. */\n public rootsEntry(): PlTreeRootsEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeRootsEntry({ treeProvider: () => this.state, hooks: this.hooks });\n }\n\n /** Can be used to externally kick off the synchronization polling loop, and\n * await for the first synchronization to happen. */\n public async refreshState(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n await this.hooks.refreshState();\n }\n\n private currentLoopDelayInterrupt: AbortController | undefined = undefined;\n private scheduledOnNextState: ScheduledRefresh[] = [];\n\n /** Called from computable hooks when external observer asks for state refresh */\n private scheduleOnNextState(resolve: () => void, reject: (err: any) => void): void {\n if (this.terminated) reject(new Error(\"tree synchronization is terminated\"));\n else {\n this.scheduledOnNextState.push({ resolve, reject });\n if (this.currentLoopDelayInterrupt) {\n this.currentLoopDelayInterrupt.abort();\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n\n /** Called from observer */\n private startUpdating(): void {\n if (this.terminated) return;\n this.keepRunning = true;\n if (this.currentLoop === undefined) this.currentLoop = this.mainLoop();\n }\n\n /** Called from observer */\n private stopUpdating(): void {\n this.keepRunning = false;\n }\n\n /** If true, main loop will continue polling pl state. */\n private keepRunning = false;\n /** Actual state of main loop. */\n private currentLoop: Promise<void> | undefined = undefined;\n\n /** Executed from the main loop, and initialization procedure. */\n private async refresh(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n try {\n await this.loadAndApply(stats, txOps);\n } catch (e) {\n // Discovery-tree self-heal: a discovered root whose grant was revoked/expired fails the whole\n // ResourceTree poll with Unauthenticated. Re-discover (drops dead roots) and retry once. This is\n // self-discriminating — a genuinely dead session also fails discover()'s own call, so real auth\n // loss still propagates. Only for discovery trees; explicit-root trees propagate as-is.\n if (this.sharedSeeds.length > 0 && isUnauthenticated(e)) {\n this.logger?.warn(\n \"discovery tree: Unauthenticated on ResourceTree (likely revoked/expired root); re-discovering and retrying\",\n );\n await this.discover();\n await this.loadAndApply(stats, txOps);\n } else throw e;\n }\n }\n\n private async loadAndApply(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n const request = constructTreeLoadingRequest(this.state, {\n pruningFunction: this.pruning,\n fieldFilter: this.fieldFilter,\n traverseStopRules: this.traverseStopRules,\n });\n // A shared-type-seed tree with no currently-discovered roots is legitimately empty:\n // there is nothing to traverse, and tx.resourceTree([]) would throw \"at least one seed\n // must be provided\". Skip the backend load and leave the (empty) state as-is — discovery\n // adds roots later via setRoots(), which schedules the next refresh. Explicit-root trees\n // never hit this (their root set is non-empty by construction).\n if (request.seedResources.length === 0 && request.finalResources.size === 0) return;\n const data = await this.pl.withReadTx(\n \"ReadingTree\",\n async (tx) => {\n return await loadTreeState(\n tx,\n request,\n stats,\n this.pl.serverInfo.capabilities ?? [],\n this.traversalMode,\n this.logger,\n );\n },\n txOps,\n );\n this.state.updateFromResourceData(data, { allowOrphanInputs: true, stat: stats });\n }\n\n /** Discovery sync for shared-type seeds: re-polls `ListUserResources` (gRPC-only) and\n * reconciles the discovered root set against the heap. A longer poll result adds roots; a\n * shorter one removes them (grant revoked/expired) — the removed roots' subtrees cascade\n * to collection via the ordinary refcount GC ({@link PlTreeState.setRoots}). No-op when the\n * tree has no shared-type seeds, or silently no-op on a REST client where `ListUserResources`\n * is unavailable. */\n private async discover(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n if (this.sharedSeeds.length === 0) return;\n\n const discovered = new Set<SignedResourceId>();\n for (const seed of this.sharedSeeds) {\n let ids: SignedResourceId[];\n try {\n // match by name only (permissive; ignores version, so it survives schema bumps)\n ids = await this.pl.userResources.listSharedResourcesByType(seed.resourceType.name);\n } catch (e: unknown) {\n if (isUnimplementedError(e)) continue;\n throw e;\n }\n for (const id of ids) discovered.add(id);\n }\n\n this.discoveredRoots = [...discovered];\n this.state.setRoots(this.currentRootSet());\n }\n\n /** If true this tree state is permanently terminaed. */\n private terminated = false;\n\n private async mainLoop() {\n // will hold request stats\n let stat = this.logStat ? initialTreeLoadingStat() : undefined;\n\n let lastUpdate = Date.now();\n\n // counts refresh iterations to pace the discovery poll for shared-type seeds.\n let iteration = 0;\n\n while (true) {\n if (!this.keepRunning || this.terminated) break;\n\n // saving those who want to be notified about new state here\n // because those who will be added during the tree retrieval\n // should be notified only on the next round\n let toNotify: ScheduledRefresh[] | undefined = undefined;\n if (this.scheduledOnNextState.length > 0) {\n toNotify = this.scheduledOnNextState;\n this.scheduledOnNextState = [];\n }\n\n try {\n // resetting stats if we were asked to collect non-cumulative stats\n if (this.logStat === \"per-request\") stat = initialTreeLoadingStat();\n\n // discovery sync for shared-type seeds: reconcile the discovered root set before\n // refreshing, so newly discovered roots are materialized in this same iteration.\n if (this.sharedSeeds.length > 0 && iteration % DISCOVERY_EVERY_N_REFRESHES === 0) {\n await this.discover();\n }\n iteration++;\n\n // actual tree synchronization\n await this.refresh(stat);\n\n // logging stats if we were asked to\n if (stat && this.logger)\n this.logger.info(\n `Tree stat (success, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we got new state\n if (toNotify !== undefined) for (const n of toNotify) n.resolve();\n } catch (e: any) {\n // logging stats if we were asked to (even if error occured)\n if (stat && this.logger)\n this.logger.info(\n `Tree stat (error, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we failed to refresh the state\n if (toNotify !== undefined) for (const n of toNotify) n.reject(e);\n\n // catching tree update errors, as they may leave our tree in inconsistent state\n if (e instanceof TreeStateUpdateError) {\n // important error logging, this should never happen\n this.logger?.error(e);\n\n // marking everybody who used previous state as changed\n this.state.invalidateTree(\"stat update error\");\n // creating new tree with the full current root set (re-discovered on next iteration)\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n\n // scheduling state update without delay\n continue;\n\n // unfortunately external observer may still see tree in its default\n // empty state, though this is best we can do in this exceptional\n // situation, and hope on caching layers inside computables to present\n // some stale state until we reconstruct the tree again\n } else this.logger?.warn(e);\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 1: mandatory floor — always wait at least MIN_POLLING_INTERVAL_MS.\n // Not interruptible by scheduleOnNextState; only termination aborts it.\n try {\n await tp.setTimeout(MIN_POLLING_INTERVAL_MS, undefined, {\n signal: this.abortController.signal,\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 2: optional remainder up to pollingInterval — interruptible by\n // scheduleOnNextState so that an external nudge wakes the loop promptly.\n if (this.scheduledOnNextState.length === 0) {\n const remaining = Math.max(0, this.pollingInterval - MIN_POLLING_INTERVAL_MS);\n if (remaining > 0) {\n try {\n this.currentLoopDelayInterrupt = new AbortController();\n await tp.setTimeout(remaining, undefined, {\n signal: AbortSignal.any([\n this.abortController.signal,\n this.currentLoopDelayInterrupt.signal,\n ]),\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n // Otherwise it was just the loop delay interrupt (scheduleOnNextState),\n // continue to the next iteration\n } finally {\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n }\n\n // reset only as a very last line\n this.currentLoop = undefined;\n }\n\n /**\n * Dumps the current state of the tree.\n * @returns An array of ExtendedResourceData objects representing the current state of the tree.\n */\n public dumpState(): ExtendedResourceData[] {\n return this.state.dumpState();\n }\n\n /**\n * Terminates the internal loop, and permanently destoys all internal state, so\n * all computables using this state will resolve to errors.\n * */\n public async terminate(): Promise<void> {\n this.keepRunning = false;\n this.terminated = true;\n this.abortController.abort();\n\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n\n this.state.invalidateTree(\"synchronization terminated for the tree\");\n }\n\n /** @deprecated */\n public async awaitSyncLoopTermination(): Promise<void> {\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n }\n\n /**\n * Initializes a synchronized tree from one or more seeds.\n *\n * @param seeds a bare {@link SignedResourceId} (the original single-root contract), a\n * single {@link TreeSeed}, or an array of seeds. Bare ids and explicit-resource seeds\n * become roots immediately; shared-type seeds discover their roots via `ListUserResources`.\n */\n public static async init(\n pl: PlClient,\n seeds: SignedResourceId | TreeSeed | TreeSeed[],\n ops: SynchronizedTreeOps,\n logger?: MiLogger,\n ) {\n const tree = new SynchronizedTreeState(pl, normalizeSeeds(seeds), ops, logger);\n\n const stat = ops.logStat ? initialTreeLoadingStat() : undefined;\n\n let ok = false;\n\n try {\n // resolve shared-type seeds before the first refresh so discovered roots load now\n await tree.discover();\n await tree.refresh(stat, {\n timeout: ops.initialTreeLoadingTimeout,\n });\n ok = true;\n } finally {\n // logging stats if we were asked to (even if error occured)\n if (stat && logger)\n logger.info(\n `Tree stat (initial load, ${ok ? \"success\" : \"failure\"}): ${JSON.stringify(stat)}`,\n );\n }\n\n return tree;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"synchronized_tree.cjs","names":["PlTreeState","PollingComputableHooks","PlTreeEntry","PlTreeRootsEntry","constructTreeLoadingRequest","loadTreeState","initialTreeLoadingStat","TreeStateUpdateError","tp"],"sources":["../src/synchronized_tree.ts"],"sourcesContent":["import { PollingComputableHooks } from \"@milaboratories/computable\";\nimport { PlTreeEntry, PlTreeRootsEntry } from \"./accessors\";\nimport type {\n FinalResourceDataPredicate,\n PlClient,\n ResourceType,\n SignedResourceId,\n TxOps,\n} from \"@milaboratories/pl-client\";\nimport type { Filter } from \"@milaboratories/pl-client\";\nimport {\n isUnauthenticated,\n isTimeoutOrCancelError,\n isUnimplementedError,\n} from \"@milaboratories/pl-client\";\nimport type { ExtendedResourceData } from \"./state\";\nimport { PlTreeState, TreeStateUpdateError } from \"./state\";\nimport type { PruningFunction, TraversalMode, TreeLoadingStat } from \"./sync\";\nimport { constructTreeLoadingRequest, initialTreeLoadingStat, loadTreeState } from \"./sync\";\nimport * as tp from \"node:timers/promises\";\nimport type { MiLogger } from \"@milaboratories/ts-helpers\";\n\n/** Hard floor between consecutive tree-refresh calls.\n * Applies even when {@link scheduleOnNextState} has woken the loop early,\n * preventing tight polling loops during rapid state transitions. */\nconst MIN_POLLING_INTERVAL_MS = 100;\n\n/** Ceiling for the adaptive poll interval. Caps how stale an idle tree can get before the\n * next look, and bounds how far the idle backoff can push the interval out. */\nconst MAX_POLLING_INTERVAL_MS = 5_000;\n\n/** Applied to the interval after a cycle that changed nothing. An idle tree walks its\n * interval out towards {@link MAX_POLLING_INTERVAL_MS} instead of re-polling at full rate;\n * the first cycle that changes anything resets it. */\nconst IDLE_BACKOFF_MULTIPLIER = 1.5;\n\n/** The client's measured RTT becomes an interval floor, scaled by this. Every refresh costs\n * at least one round trip, so polling faster than a small multiple of the RTT only queues\n * round-trips the link cannot service. This is what replaces the fixed interval on a\n * high-latency link; on a fast link the configured `pollingInterval` still dominates. */\nconst RTT_POLL_FACTOR = 2;\n\n/** Ceiling for the RTT-derived floor. The estimate is sampled at connect time and never\n * re-sampled, so without a bound one slow ping pins the cadence high for the whole session.\n * Mirrors `MAX_ADAPTIVE_REQUEST_TIMEOUT` on the deadline side: past this the link is stuck\n * rather than slow, and spacing polls further only delays noticing it recovered. */\nconst MAX_RTT_POLL_INTERVAL_MS = 30_000;\n\ntype StatLoggingMode = \"cumulative\" | \"per-request\";\n\nexport type SynchronizedTreeOps = {\n /** Override final predicate from the PlClient */\n finalPredicateOverride?: FinalResourceDataPredicate;\n\n /** Pruning function for legacy fallback path. */\n pruning?: PruningFunction;\n\n /** ResourceTree field filter for modern backend path. */\n fieldFilter?: Filter;\n\n /** ResourceTree traversal stop rules for modern backend path. */\n traverseStopRules?: Filter;\n\n /** Interval after last sync to sleep before the next one */\n pollingInterval: number;\n /** For how long to continue polling after the last derived value access */\n stopPollingDelay: number;\n\n /** If one of the values, tree will log stats of each polling request */\n logStat?: StatLoggingMode;\n\n /** Timeout for initial tree loading. If not specified, will use default for RO tx from pl-client. */\n initialTreeLoadingTimeout?: number;\n\n /** Controls which tree-loading path to use. Default `\"auto\"`. */\n traversalMode?: TraversalMode;\n};\n\n/** An explicit resource to serve as a tree root. Several explicit seeds may be passed. */\nexport type ExplicitRootSeed = { kind: \"resource\"; root: SignedResourceId };\n\n/** Discovers, as roots, every resource of this type shared with the current user.\n * Matched against SharedResource.resourceType by NAME (version optional/ignored). The\n * discovered set is DYNAMIC — roots appear/disappear as grants are added/revoked/expire. */\nexport type SharedTypeSeed = { kind: \"shared\"; resourceType: ResourceType };\n\nexport type TreeSeed = ExplicitRootSeed | SharedTypeSeed;\n\n/** Normalizes the {@link SynchronizedTreeState.init} seed argument — a bare\n * {@link SignedResourceId}, a single {@link TreeSeed}, or an array — to `TreeSeed[]`, so\n * every existing single-root caller is unchanged. */\nfunction normalizeSeeds(seeds: SignedResourceId | TreeSeed | TreeSeed[]): TreeSeed[] {\n if (Array.isArray(seeds)) return seeds;\n if (typeof seeds === \"object\" && seeds !== null && \"kind\" in seeds) return [seeds];\n // bare SignedResourceId\n return [{ kind: \"resource\", root: seeds }];\n}\n\n/** How often a tree with shared-type seeds re-polls ListUserResources to reconcile its\n * discovered roots.\n *\n * Discovery (a full ListUserResources stream) is far heavier than an ordinary incremental\n * refresh, so it must NOT run on every refresh tick. This is a wall-clock interval rather\n * than a count of iterations: the refresh cadence is adaptive (it stretches towards\n * {@link MAX_POLLING_INTERVAL_MS} on an idle tree and scales with RTT on a slow link),\n * so a fixed iteration count would let discovery latency drift out with it. Keeping it in\n * milliseconds pins the latency of noticing a new/removed share regardless of cadence, which\n * is what a human-driven share flow cares about.\n *\n * Only trees with shared-type seeds gate on this; {@link discover} is a no-op for single-root\n * and explicit-seed trees (empty `sharedSeeds`), so the value never affects them. */\nconst DISCOVERY_INTERVAL_MS = 3_000;\n\n/** Counters that mean \"this cycle brought something new\". Deliberately not the full change\n * breakdown: those fields are sub-counts of `resourcesChanged` and would double-count.\n * `resourcesUnchanged` is excluded by design, since a cycle that only re-fetched unchanged\n * state is exactly the idle case the backoff exists for. */\nfunction countedChanges(stat: TreeLoadingStat): number {\n return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal;\n}\n\n/** The poll-cadence policy, as a pure function of the last cycle's outcome.\n *\n * `configuredMs` is the tree's static `pollingInterval` and acts as the lower bound, so no\n * link can be polled faster than configured. `rttMs` raises that bound on a slow link.\n * `currentMs` is the interval in force for the cycle that just finished, which is what the\n * idle backoff compounds on. */\nexport function derivePollingInterval(opts: {\n configuredMs: number;\n currentMs: number;\n rttMs: number | undefined;\n changed: boolean;\n}): number {\n const { configuredMs, currentMs, rttMs, changed } = opts;\n\n // The cap applies to the RTT-derived part only, so `configuredMs` stays an absolute lower\n // bound even if it is ever set above the cap.\n const floor =\n rttMs === undefined\n ? configuredMs\n : Math.max(\n configuredMs,\n Math.min(MAX_RTT_POLL_INTERVAL_MS, Math.ceil(rttMs * RTT_POLL_FACTOR)),\n );\n\n const next = changed ? floor : Math.max(floor, currentMs * IDLE_BACKOFF_MULTIPLIER);\n\n // The ceiling never cuts below the floor: a link slower than MAX_POLLING_INTERVAL_MS still\n // gets its RTT-derived spacing rather than being forced to re-poll early.\n return Math.max(floor, Math.min(MAX_POLLING_INTERVAL_MS, next));\n}\n\ntype ScheduledRefresh = {\n resolve: () => void;\n reject: (err: any) => void;\n};\n\nexport class SynchronizedTreeState {\n private readonly finalPredicate: FinalResourceDataPredicate;\n private state: PlTreeState;\n private readonly pollingInterval: number;\n private readonly pruning?: PruningFunction;\n private readonly fieldFilter?: Filter;\n private readonly traverseStopRules?: Filter;\n private readonly traversalMode: TraversalMode;\n private readonly logStat?: StatLoggingMode;\n private readonly hooks: PollingComputableHooks;\n private readonly abortController = new AbortController();\n\n /** Explicit-resource seeds: fixed roots, present from construction. */\n private readonly explicitRoots: SignedResourceId[];\n /** Shared-type seeds (discovered roots), if any. */\n private readonly sharedSeeds: SharedTypeSeed[];\n /** Roots discovered for shared-type seeds on the last discovery poll. */\n private discoveredRoots: SignedResourceId[] = [];\n\n private constructor(\n private readonly pl: PlClient,\n seeds: TreeSeed[],\n ops: SynchronizedTreeOps,\n private readonly logger?: MiLogger,\n ) {\n const {\n finalPredicateOverride,\n pruning,\n fieldFilter,\n traverseStopRules,\n traversalMode,\n pollingInterval,\n stopPollingDelay,\n logStat,\n } = ops;\n this.pruning = pruning;\n this.fieldFilter = fieldFilter;\n this.traverseStopRules = traverseStopRules;\n this.traversalMode = traversalMode ?? \"auto\";\n this.pollingInterval = pollingInterval;\n this.effectivePollingInterval = pollingInterval;\n this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;\n this.logStat = logStat;\n\n this.explicitRoots = seeds\n .filter((s): s is ExplicitRootSeed => s.kind === \"resource\")\n .map((s) => s.root);\n this.sharedSeeds = seeds.filter((s): s is SharedTypeSeed => s.kind === \"shared\");\n\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n this.hooks = new PollingComputableHooks(\n () => this.startUpdating(),\n () => this.stopUpdating(),\n { stopDebounce: stopPollingDelay },\n (resolve, reject) => this.scheduleOnNextState(resolve, reject),\n );\n }\n\n /** The current protected root set: explicit roots plus the latest discovered roots. */\n private currentRootSet(): Set<SignedResourceId> {\n return new Set([...this.explicitRoots, ...this.discoveredRoots]);\n }\n\n /** Resolves the single root for the backward-compatible single-root accessors, throwing\n * if the tree does not have exactly one root (guards legacy callers against multi-root). */\n private soleRoot(): SignedResourceId {\n const roots = this.currentRootSet();\n if (roots.size !== 1)\n throw new Error(\n `single-root accessor used on a tree with ${roots.size} roots; use rootsEntry() instead`,\n );\n return roots.values().next().value!;\n }\n\n /** @deprecated use \"entry\" instead */\n public accessor(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return this.entry(rid);\n }\n\n /** Backward-compatible single-root entry. With no `rid` it returns the sole root's entry\n * and THROWS if the tree has zero or more than one root. An explicit `rid` addresses any\n * resource in the heap, as today. */\n public entry(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeEntry(\n { treeProvider: () => this.state, hooks: this.hooks },\n rid ?? this.soleRoot(),\n );\n }\n\n /** Reactive provider for the current root SET. Reading it inside a Computable tracks the\n * set as a dependency, so the Computable recomputes when discovered roots appear/disappear. */\n public rootsEntry(): PlTreeRootsEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeRootsEntry({ treeProvider: () => this.state, hooks: this.hooks });\n }\n\n /** Can be used to externally kick off the synchronization polling loop, and\n * await for the first synchronization to happen. */\n public async refreshState(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n await this.hooks.refreshState();\n }\n\n private currentLoopDelayInterrupt: AbortController | undefined = undefined;\n private scheduledOnNextState: ScheduledRefresh[] = [];\n\n /** Interval actually used for the current wait. Starts at the configured `pollingInterval`\n * and is re-derived after every cycle by {@link updatePollingInterval}. */\n private effectivePollingInterval: number;\n\n /** Re-derives {@link effectivePollingInterval} after a cycle.\n *\n * Two independent effects. The floor scales with the client's measured RTT, so a\n * high-latency link stops queueing round-trips it cannot service. On top of that, a cycle\n * that changed nothing multiplies the interval out towards\n * {@link MAX_POLLING_INTERVAL_MS}, while any change snaps it straight back to the floor so\n * an active tree stays responsive. */\n private updatePollingInterval(changed: boolean): void {\n this.effectivePollingInterval = derivePollingInterval({\n configuredMs: this.pollingInterval,\n currentMs: this.effectivePollingInterval,\n rttMs: this.pl.rttEstimateMs,\n changed,\n });\n }\n\n /** Called from computable hooks when external observer asks for state refresh */\n private scheduleOnNextState(resolve: () => void, reject: (err: any) => void): void {\n if (this.terminated) reject(new Error(\"tree synchronization is terminated\"));\n else {\n this.scheduledOnNextState.push({ resolve, reject });\n // Someone is waiting on fresh state, so this tree is not idle after all: drop any\n // accumulated backoff, otherwise the cycles right after a nudge stay slow. Routed\n // through the policy rather than assigning the configured value directly, so the RTT\n // floor survives the reset. Assigning it raw would poll a high-latency link faster than\n // it can answer, and the interval would stay there until the next cycle that completes:\n // the error path never reaches updatePollingInterval, so a failing nudged refresh would\n // keep retrying at the un-floored rate.\n this.updatePollingInterval(true);\n if (this.currentLoopDelayInterrupt) {\n this.currentLoopDelayInterrupt.abort();\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n\n /** Called from observer */\n private startUpdating(): void {\n if (this.terminated) return;\n this.keepRunning = true;\n if (this.currentLoop === undefined) this.currentLoop = this.mainLoop();\n }\n\n /** Called from observer */\n private stopUpdating(): void {\n this.keepRunning = false;\n }\n\n /** If true, main loop will continue polling pl state. */\n private keepRunning = false;\n /** Actual state of main loop. */\n private currentLoop: Promise<void> | undefined = undefined;\n\n /** Executed from the main loop, and initialization procedure. */\n private async refresh(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n try {\n await this.loadAndApply(stats, txOps);\n } catch (e) {\n // Discovery-tree self-heal: a discovered root whose grant was revoked/expired fails the whole\n // ResourceTree poll with Unauthenticated. Re-discover (drops dead roots) and retry once. This is\n // self-discriminating — a genuinely dead session also fails discover()'s own call, so real auth\n // loss still propagates. Only for discovery trees; explicit-root trees propagate as-is.\n if (this.sharedSeeds.length > 0 && isUnauthenticated(e)) {\n this.logger?.warn(\n \"discovery tree: Unauthenticated on ResourceTree (likely revoked/expired root); re-discovering and retrying\",\n );\n await this.discover();\n await this.loadAndApply(stats, txOps);\n } else throw e;\n }\n }\n\n private async loadAndApply(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n const request = constructTreeLoadingRequest(this.state, {\n pruningFunction: this.pruning,\n fieldFilter: this.fieldFilter,\n traverseStopRules: this.traverseStopRules,\n });\n // A shared-type-seed tree with no currently-discovered roots is legitimately empty:\n // there is nothing to traverse, and tx.resourceTree([]) would throw \"at least one seed\n // must be provided\". Skip the backend load and leave the (empty) state as-is — discovery\n // adds roots later via setRoots(), which schedules the next refresh. Explicit-root trees\n // never hit this (their root set is non-empty by construction).\n if (request.seedResources.length === 0 && request.finalResources.size === 0) return;\n const data = await this.pl.withReadTx(\n \"ReadingTree\",\n async (tx) => {\n return await loadTreeState(\n tx,\n request,\n stats,\n this.pl.serverInfo.capabilities ?? [],\n this.traversalMode,\n this.logger,\n );\n },\n txOps,\n );\n this.state.updateFromResourceData(data, { allowOrphanInputs: true, stat: stats });\n }\n\n /** Discovery sync for shared-type seeds: re-polls `ListUserResources` (gRPC-only) and\n * reconciles the discovered root set against the heap. A longer poll result adds roots; a\n * shorter one removes them (grant revoked/expired) — the removed roots' subtrees cascade\n * to collection via the ordinary refcount GC ({@link PlTreeState.setRoots}). No-op when the\n * tree has no shared-type seeds, or silently no-op on a REST client where `ListUserResources`\n * is unavailable. */\n private async discover(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n if (this.sharedSeeds.length === 0) return;\n\n const discovered = new Set<SignedResourceId>();\n for (const seed of this.sharedSeeds) {\n let ids: SignedResourceId[];\n try {\n // match by name only (permissive; ignores version, so it survives schema bumps)\n ids = await this.pl.userResources.listSharedResourcesByType(seed.resourceType.name);\n } catch (e: unknown) {\n if (isUnimplementedError(e)) continue;\n throw e;\n }\n for (const id of ids) discovered.add(id);\n }\n\n this.discoveredRoots = [...discovered];\n this.state.setRoots(this.currentRootSet());\n }\n\n /** If true this tree state is permanently terminaed. */\n private terminated = false;\n\n private async mainLoop() {\n // Always collected, even when not logging: the change counters drive the idle backoff\n // below. Counter bumps are cheap next to the round trip they describe.\n let stat = initialTreeLoadingStat();\n\n let lastUpdate = Date.now();\n\n // paces the discovery poll for shared-type seeds; 0 forces discovery on the first pass.\n let lastDiscovery = 0;\n\n while (true) {\n if (!this.keepRunning || this.terminated) break;\n\n // saving those who want to be notified about new state here\n // because those who will be added during the tree retrieval\n // should be notified only on the next round\n let toNotify: ScheduledRefresh[] | undefined = undefined;\n if (this.scheduledOnNextState.length > 0) {\n toNotify = this.scheduledOnNextState;\n this.scheduledOnNextState = [];\n }\n\n try {\n // resetting stats if we were asked to collect non-cumulative stats\n if (this.logStat === \"per-request\") stat = initialTreeLoadingStat();\n\n // discovery sync for shared-type seeds: reconcile the discovered root set before\n // refreshing, so newly discovered roots are materialized in this same iteration.\n if (this.sharedSeeds.length > 0 && Date.now() - lastDiscovery >= DISCOVERY_INTERVAL_MS) {\n await this.discover();\n lastDiscovery = Date.now();\n }\n\n // Change counters before the refresh, so the delta tells us whether this single cycle\n // brought anything new. Works in both stat modes: \"per-request\" resets to 0 above.\n const changesBefore = countedChanges(stat);\n\n // actual tree synchronization\n await this.refresh(stat);\n\n this.updatePollingInterval(countedChanges(stat) > changesBefore);\n\n // logging stats if we were asked to\n if (this.logStat && this.logger)\n this.logger.info(\n `Tree stat (success, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we got new state\n if (toNotify !== undefined) for (const n of toNotify) n.resolve();\n } catch (e: any) {\n // logging stats if we were asked to (even if error occured)\n if (this.logStat && this.logger)\n this.logger.info(\n `Tree stat (error, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we failed to refresh the state\n if (toNotify !== undefined) for (const n of toNotify) n.reject(e);\n\n // catching tree update errors, as they may leave our tree in inconsistent state\n if (e instanceof TreeStateUpdateError) {\n // important error logging, this should never happen\n this.logger?.error(e);\n\n // marking everybody who used previous state as changed\n this.state.invalidateTree(\"stat update error\");\n // creating new tree with the full current root set (re-discovered on next iteration)\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n\n // scheduling state update without delay\n continue;\n\n // unfortunately external observer may still see tree in its default\n // empty state, though this is best we can do in this exceptional\n // situation, and hope on caching layers inside computables to present\n // some stale state until we reconstruct the tree again\n } else this.logger?.warn(e);\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 1: mandatory floor — always wait at least MIN_POLLING_INTERVAL_MS.\n // Not interruptible by scheduleOnNextState; only termination aborts it.\n try {\n await tp.setTimeout(MIN_POLLING_INTERVAL_MS, undefined, {\n signal: this.abortController.signal,\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 2: optional remainder up to pollingInterval — interruptible by\n // scheduleOnNextState so that an external nudge wakes the loop promptly.\n if (this.scheduledOnNextState.length === 0) {\n const remaining = Math.max(0, this.effectivePollingInterval - MIN_POLLING_INTERVAL_MS);\n if (remaining > 0) {\n try {\n this.currentLoopDelayInterrupt = new AbortController();\n await tp.setTimeout(remaining, undefined, {\n signal: AbortSignal.any([\n this.abortController.signal,\n this.currentLoopDelayInterrupt.signal,\n ]),\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n // Otherwise it was just the loop delay interrupt (scheduleOnNextState),\n // continue to the next iteration\n } finally {\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n }\n\n // reset only as a very last line\n this.currentLoop = undefined;\n }\n\n /**\n * Dumps the current state of the tree.\n * @returns An array of ExtendedResourceData objects representing the current state of the tree.\n */\n public dumpState(): ExtendedResourceData[] {\n return this.state.dumpState();\n }\n\n /**\n * Terminates the internal loop, and permanently destoys all internal state, so\n * all computables using this state will resolve to errors.\n * */\n public async terminate(): Promise<void> {\n this.keepRunning = false;\n this.terminated = true;\n this.abortController.abort();\n\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n\n this.state.invalidateTree(\"synchronization terminated for the tree\");\n }\n\n /** @deprecated */\n public async awaitSyncLoopTermination(): Promise<void> {\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n }\n\n /**\n * Initializes a synchronized tree from one or more seeds.\n *\n * @param seeds a bare {@link SignedResourceId} (the original single-root contract), a\n * single {@link TreeSeed}, or an array of seeds. Bare ids and explicit-resource seeds\n * become roots immediately; shared-type seeds discover their roots via `ListUserResources`.\n */\n public static async init(\n pl: PlClient,\n seeds: SignedResourceId | TreeSeed | TreeSeed[],\n ops: SynchronizedTreeOps,\n logger?: MiLogger,\n ) {\n const tree = new SynchronizedTreeState(pl, normalizeSeeds(seeds), ops, logger);\n\n const stat = ops.logStat ? initialTreeLoadingStat() : undefined;\n\n let ok = false;\n\n try {\n // resolve shared-type seeds before the first refresh so discovered roots load now\n await tree.discover();\n await tree.refresh(stat, {\n timeout: ops.initialTreeLoadingTimeout,\n });\n ok = true;\n } finally {\n // logging stats if we were asked to (even if error occured)\n if (stat && logger)\n logger.info(\n `Tree stat (initial load, ${ok ? \"success\" : \"failure\"}): ${JSON.stringify(stat)}`,\n );\n }\n\n return tree;\n }\n}\n"],"mappings":"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"}
@@ -30,6 +30,18 @@ type SharedTypeSeed = {
30
30
  resourceType: ResourceType;
31
31
  };
32
32
  type TreeSeed = ExplicitRootSeed | SharedTypeSeed;
33
+ /** The poll-cadence policy, as a pure function of the last cycle's outcome.
34
+ *
35
+ * `configuredMs` is the tree's static `pollingInterval` and acts as the lower bound, so no
36
+ * link can be polled faster than configured. `rttMs` raises that bound on a slow link.
37
+ * `currentMs` is the interval in force for the cycle that just finished, which is what the
38
+ * idle backoff compounds on. */
39
+ declare function derivePollingInterval(opts: {
40
+ configuredMs: number;
41
+ currentMs: number;
42
+ rttMs: number | undefined;
43
+ changed: boolean;
44
+ }): number;
33
45
  declare class SynchronizedTreeState {
34
46
  private readonly pl;
35
47
  private readonly logger?;
@@ -69,6 +81,17 @@ declare class SynchronizedTreeState {
69
81
  refreshState(): Promise<void>;
70
82
  private currentLoopDelayInterrupt;
71
83
  private scheduledOnNextState;
84
+ /** Interval actually used for the current wait. Starts at the configured `pollingInterval`
85
+ * and is re-derived after every cycle by {@link updatePollingInterval}. */
86
+ private effectivePollingInterval;
87
+ /** Re-derives {@link effectivePollingInterval} after a cycle.
88
+ *
89
+ * Two independent effects. The floor scales with the client's measured RTT, so a
90
+ * high-latency link stops queueing round-trips it cannot service. On top of that, a cycle
91
+ * that changed nothing multiplies the interval out towards
92
+ * {@link MAX_POLLING_INTERVAL_MS}, while any change snaps it straight back to the floor so
93
+ * an active tree stays responsive. */
94
+ private updatePollingInterval;
72
95
  /** Called from computable hooks when external observer asks for state refresh */
73
96
  private scheduleOnNextState;
74
97
  /** Called from observer */
@@ -114,5 +137,5 @@ declare class SynchronizedTreeState {
114
137
  static init(pl: PlClient, seeds: SignedResourceId | TreeSeed | TreeSeed[], ops: SynchronizedTreeOps, logger?: MiLogger): Promise<SynchronizedTreeState>;
115
138
  }
116
139
  //#endregion
117
- export { ExplicitRootSeed, SharedTypeSeed, SynchronizedTreeOps, SynchronizedTreeState, TreeSeed };
140
+ export { ExplicitRootSeed, SharedTypeSeed, SynchronizedTreeOps, SynchronizedTreeState, TreeSeed, derivePollingInterval };
118
141
  //# sourceMappingURL=synchronized_tree.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"synchronized_tree.d.ts","names":[],"sources":["../src/synchronized_tree.ts"],"mappings":";;;;;;;KA2BK,eAAA;AAAA,KAEO,mBAAA;EAFQ,iDAIlB,sBAAA,GAAyB,0BAAA,EAJP;EAOlB,OAAA,GAAU,eAAA,EALA;EAQV,WAAA,GAAc,MAAA;EAGd,iBAAA,GAAoB,MAAA,EANV;EASV,eAAA,UAHoB;EAKpB,gBAAA,UASgB;EANhB,OAAA,GAAU,eAAA,EAMmB;EAH7B,yBAAA,WApByB;EAuBzB,aAAA,GAAgB,aAAA;AAAA;;KAIN,gBAAA;EAAqB,IAAA;EAAkB,IAAA,EAAM,gBAAgB;AAAA;;;;KAK7D,cAAA;EAAmB,IAAA;EAAgB,YAAA,EAAc,YAAY;AAAA;AAAA,KAE7D,QAAA,GAAW,gBAAA,GAAmB,cAAc;AAAA,cA+B3C,qBAAA;EAAA,iBAoBQ,EAAA;EAAA,iBAGA,MAAA;EAAA,iBAtBF,cAAA;EAAA,QACT,KAAA;EAAA,iBACS,eAAA;EAAA,iBACA,OAAA;EAAA,iBACA,WAAA;EAAA,iBACA,iBAAA;EAAA,iBACA,aAAA;EAAA,iBACA,OAAA;EAAA,iBACA,KAAA;EAAA,iBACA,eAAA;EA3CY;EAAA,iBA8CZ,aAAA;EA9C0C;EAAA,iBAgD1C,WAAA;EAhDsD;EAAA,QAkD/D,eAAA;EAAA,QAED,WAAA;;UAuCC,cAAA;EAzF8C;AA+BxD;EA/BwD,QA+F9C,QAAA;;EAUD,QAAA,CAAS,GAAA,GAAM,gBAAA,GAAmB,WAAA;EAAA;;;EAQlC,KAAA,CAAM,GAAA,GAAM,gBAAA,GAAmB,WAAA;EAiBT;;EAPtB,UAAA,IAAc,gBAAA;EA0QoB;;EAnQ5B,YAAA,IAAgB,OAAA;EAAA,QAKrB,yBAAA;EAAA,QACA,oBAAA;EA4QD;EAAA,QAzQC,mBAAA;EA0QW;EAAA,QA9PX,aAAA;EA8PW;EAAA,QAvPX,YAAA;EA3GW;EAAA,QAgHX,WAAA;EAnIS;EAAA,QAqIT,WAAA;EAnIS;EAAA,QAsIH,OAAA;EAAA,QAmBA,YAAA;EAtJG;;;;;;EAAA,QAyLH,QAAA;EA9KN;EAAA,QAoMA,UAAA;EAAA,QAEM,QAAA;EAvJN;;;;EAkRD,SAAA,IAAa,oBAAA;EAhQb;;;;EAwQM,SAAA,IAAa,OAAA;EA9PL;EA0QR,wBAAA,IAA4B,OAAA;EAnQZ;;;;;;;EAAA,OA+QT,IAAA,CAClB,EAAA,EAAI,QAAA,EACJ,KAAA,EAAO,gBAAA,GAAmB,QAAA,GAAW,QAAA,IACrC,GAAA,EAAK,mBAAA,EACL,MAAA,GAAS,QAAA,GAAQ,OAAA,CAAA,qBAAA;AAAA"}
1
+ {"version":3,"file":"synchronized_tree.d.ts","names":[],"sources":["../src/synchronized_tree.ts"],"mappings":";;;;;;;KAgDK,eAAA;AAAA,KAEO,mBAAA;EAFQ,iDAIlB,sBAAA,GAAyB,0BAAA,EAJP;EAOlB,OAAA,GAAU,eAAA,EALA;EAQV,WAAA,GAAc,MAAA;EAGd,iBAAA,GAAoB,MAAA,EANV;EASV,eAAA,UAHoB;EAKpB,gBAAA,UASgB;EANhB,OAAA,GAAU,eAAA,EAMmB;EAH7B,yBAAA,WApByB;EAuBzB,aAAA,GAAgB,aAAA;AAAA;;KAIN,gBAAA;EAAqB,IAAA;EAAkB,IAAA,EAAM,gBAAgB;AAAA;;;;KAK7D,cAAA;EAAmB,IAAA;EAAgB,YAAA,EAAc,YAAY;AAAA;AAAA,KAE7D,QAAA,GAAW,gBAAA,GAAmB,cAAc;AAPxD;;;;;;AAAA,iBAgDgB,qBAAA,CAAsB,IAAA;EACpC,YAAA;EACA,SAAA;EACA,KAAA;EACA,OAAA;AAAA;AAAA,cA0BW,qBAAA;EAAA,iBAoBQ,EAAA;EAAA,iBAGA,MAAA;EAAA,iBAtBF,cAAA;EAAA,QACT,KAAA;EAAA,iBACS,eAAA;EAAA,iBACA,OAAA;EAAA,iBACA,WAAA;EAAA,iBACA,iBAAA;EAAA,iBACA,aAAA;EAAA,iBACA,OAAA;EAAA,iBACA,KAAA;EAAA,iBACA,eAAA;;mBAGA,aAAA;EA1CjB;EAAA,iBA4CiB,WAAA;EA1CjB;EAAA,QA4CQ,eAAA;EAAA,QAED,WAAA;EA5CR;EAAA,QAoFS,cAAA;EA3DG;;EAAA,QAiEH,QAAA;EAUc;EAAf,QAAA,CAAS,GAAA,GAAM,gBAAA,GAAmB,WAAA;EAQtB;;;EAAZ,KAAA,CAAM,GAAA,GAAM,gBAAA,GAAmB,WAAA;EAmSlB;;EAzRb,UAAA,IAAc,gBAAA;EA0Tf;;EAnTO,YAAA,IAAgB,OAAA;EAAA,QAKrB,yBAAA;EAAA,QACA,oBAAA;EAgTG;;EAAA,QA5SH,wBAAA;EA4SW;;;;;;;EAAA,QAnSX,qBAAA;EAlHS;EAAA,QA4HT,mBAAA;EA1HS;EAAA,QA8IT,aAAA;EA5IS;EAAA,QAmJT,YAAA;EA/IS;EAAA,QAoJT,WAAA;EAhJA;EAAA,QAkJA,WAAA;EAxGA;EAAA,QA2GM,OAAA;EAAA,QAmBA,YAAA;EA9GQ;;;;;;EAAA,QAiJR,QAAA;EA/HP;EAAA,QAqJC,UAAA;EAAA,QAEM,QAAA;EAhJe;;;;EAkRtB,SAAA,IAAa,oBAAA;EArPZ;;;;EA6PK,SAAA,IAAa,OAAA;EAxNZ;EAoOD,wBAAA,IAA4B,OAAA;EA9K3B;;;;;;;EAAA,OA0LM,IAAA,CAClB,EAAA,EAAI,QAAA,EACJ,KAAA,EAAO,gBAAA,GAAmB,QAAA,GAAW,QAAA,IACrC,GAAA,EAAK,mBAAA,EACL,MAAA,GAAS,QAAA,GAAQ,OAAA,CAAA,qBAAA;AAAA"}
@@ -9,6 +9,23 @@ import * as tp from "node:timers/promises";
9
9
  * Applies even when {@link scheduleOnNextState} has woken the loop early,
10
10
  * preventing tight polling loops during rapid state transitions. */
11
11
  const MIN_POLLING_INTERVAL_MS = 100;
12
+ /** Ceiling for the adaptive poll interval. Caps how stale an idle tree can get before the
13
+ * next look, and bounds how far the idle backoff can push the interval out. */
14
+ const MAX_POLLING_INTERVAL_MS = 5e3;
15
+ /** Applied to the interval after a cycle that changed nothing. An idle tree walks its
16
+ * interval out towards {@link MAX_POLLING_INTERVAL_MS} instead of re-polling at full rate;
17
+ * the first cycle that changes anything resets it. */
18
+ const IDLE_BACKOFF_MULTIPLIER = 1.5;
19
+ /** The client's measured RTT becomes an interval floor, scaled by this. Every refresh costs
20
+ * at least one round trip, so polling faster than a small multiple of the RTT only queues
21
+ * round-trips the link cannot service. This is what replaces the fixed interval on a
22
+ * high-latency link; on a fast link the configured `pollingInterval` still dominates. */
23
+ const RTT_POLL_FACTOR = 2;
24
+ /** Ceiling for the RTT-derived floor. The estimate is sampled at connect time and never
25
+ * re-sampled, so without a bound one slow ping pins the cadence high for the whole session.
26
+ * Mirrors `MAX_ADAPTIVE_REQUEST_TIMEOUT` on the deadline side: past this the link is stuck
27
+ * rather than slow, and spacing polls further only delays noticing it recovered. */
28
+ const MAX_RTT_POLL_INTERVAL_MS = 3e4;
12
29
  /** Normalizes the {@link SynchronizedTreeState.init} seed argument — a bare
13
30
  * {@link SignedResourceId}, a single {@link TreeSeed}, or an array — to `TreeSeed[]`, so
14
31
  * every existing single-root caller is unchanged. */
@@ -20,19 +37,39 @@ function normalizeSeeds(seeds) {
20
37
  root: seeds
21
38
  }];
22
39
  }
23
- /** How often, in main-loop iterations, a tree with shared-type seeds re-polls
24
- * ListUserResources to reconcile its discovered roots. 1 would mean every iteration.
40
+ /** How often a tree with shared-type seeds re-polls ListUserResources to reconcile its
41
+ * discovered roots.
25
42
  *
26
43
  * Discovery (a full ListUserResources stream) is far heavier than an ordinary incremental
27
- * refresh, so it must NOT run on every fast refresh tick. The refresh cadence is the tree's
28
- * `pollingInterval` floored by {@link MIN_POLLING_INTERVAL_MS} (~200ms-1s in practice — the
29
- * shared-seed discovery tree runs at the 200ms default). At N = 15 discovery fires roughly
30
- * every 15 × 200ms ≈ 3s, decoupling it from the fast refresh loop while keeping the latency
31
- * of noticing a new/removed share to a few seconds — acceptable for a human-driven share flow.
44
+ * refresh, so it must NOT run on every refresh tick. This is a wall-clock interval rather
45
+ * than a count of iterations: the refresh cadence is adaptive (it stretches towards
46
+ * {@link MAX_POLLING_INTERVAL_MS} on an idle tree and scales with RTT on a slow link),
47
+ * so a fixed iteration count would let discovery latency drift out with it. Keeping it in
48
+ * milliseconds pins the latency of noticing a new/removed share regardless of cadence, which
49
+ * is what a human-driven share flow cares about.
32
50
  *
33
51
  * Only trees with shared-type seeds gate on this; {@link discover} is a no-op for single-root
34
52
  * and explicit-seed trees (empty `sharedSeeds`), so the value never affects them. */
35
- const DISCOVERY_EVERY_N_REFRESHES = 15;
53
+ const DISCOVERY_INTERVAL_MS = 3e3;
54
+ /** Counters that mean "this cycle brought something new". Deliberately not the full change
55
+ * breakdown: those fields are sub-counts of `resourcesChanged` and would double-count.
56
+ * `resourcesUnchanged` is excluded by design, since a cycle that only re-fetched unchanged
57
+ * state is exactly the idle case the backoff exists for. */
58
+ function countedChanges(stat) {
59
+ return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal;
60
+ }
61
+ /** The poll-cadence policy, as a pure function of the last cycle's outcome.
62
+ *
63
+ * `configuredMs` is the tree's static `pollingInterval` and acts as the lower bound, so no
64
+ * link can be polled faster than configured. `rttMs` raises that bound on a slow link.
65
+ * `currentMs` is the interval in force for the cycle that just finished, which is what the
66
+ * idle backoff compounds on. */
67
+ function derivePollingInterval(opts) {
68
+ const { configuredMs, currentMs, rttMs, changed } = opts;
69
+ const floor = rttMs === void 0 ? configuredMs : Math.max(configuredMs, Math.min(MAX_RTT_POLL_INTERVAL_MS, Math.ceil(rttMs * RTT_POLL_FACTOR)));
70
+ const next = changed ? floor : Math.max(floor, currentMs * IDLE_BACKOFF_MULTIPLIER);
71
+ return Math.max(floor, Math.min(MAX_POLLING_INTERVAL_MS, next));
72
+ }
36
73
  var SynchronizedTreeState = class SynchronizedTreeState {
37
74
  pl;
38
75
  logger;
@@ -61,6 +98,7 @@ var SynchronizedTreeState = class SynchronizedTreeState {
61
98
  this.traverseStopRules = traverseStopRules;
62
99
  this.traversalMode = traversalMode ?? "auto";
63
100
  this.pollingInterval = pollingInterval;
101
+ this.effectivePollingInterval = pollingInterval;
64
102
  this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;
65
103
  this.logStat = logStat;
66
104
  this.explicitRoots = seeds.filter((s) => s.kind === "resource").map((s) => s.root);
@@ -111,6 +149,24 @@ var SynchronizedTreeState = class SynchronizedTreeState {
111
149
  }
112
150
  currentLoopDelayInterrupt = void 0;
113
151
  scheduledOnNextState = [];
152
+ /** Interval actually used for the current wait. Starts at the configured `pollingInterval`
153
+ * and is re-derived after every cycle by {@link updatePollingInterval}. */
154
+ effectivePollingInterval;
155
+ /** Re-derives {@link effectivePollingInterval} after a cycle.
156
+ *
157
+ * Two independent effects. The floor scales with the client's measured RTT, so a
158
+ * high-latency link stops queueing round-trips it cannot service. On top of that, a cycle
159
+ * that changed nothing multiplies the interval out towards
160
+ * {@link MAX_POLLING_INTERVAL_MS}, while any change snaps it straight back to the floor so
161
+ * an active tree stays responsive. */
162
+ updatePollingInterval(changed) {
163
+ this.effectivePollingInterval = derivePollingInterval({
164
+ configuredMs: this.pollingInterval,
165
+ currentMs: this.effectivePollingInterval,
166
+ rttMs: this.pl.rttEstimateMs,
167
+ changed
168
+ });
169
+ }
114
170
  /** Called from computable hooks when external observer asks for state refresh */
115
171
  scheduleOnNextState(resolve, reject) {
116
172
  if (this.terminated) reject(/* @__PURE__ */ new Error("tree synchronization is terminated"));
@@ -119,6 +175,7 @@ var SynchronizedTreeState = class SynchronizedTreeState {
119
175
  resolve,
120
176
  reject
121
177
  });
178
+ this.updatePollingInterval(true);
122
179
  if (this.currentLoopDelayInterrupt) {
123
180
  this.currentLoopDelayInterrupt.abort();
124
181
  this.currentLoopDelayInterrupt = void 0;
@@ -193,9 +250,9 @@ var SynchronizedTreeState = class SynchronizedTreeState {
193
250
  /** If true this tree state is permanently terminaed. */
194
251
  terminated = false;
195
252
  async mainLoop() {
196
- let stat = this.logStat ? initialTreeLoadingStat() : void 0;
253
+ let stat = initialTreeLoadingStat();
197
254
  let lastUpdate = Date.now();
198
- let iteration = 0;
255
+ let lastDiscovery = 0;
199
256
  while (true) {
200
257
  if (!this.keepRunning || this.terminated) break;
201
258
  let toNotify = void 0;
@@ -205,14 +262,18 @@ var SynchronizedTreeState = class SynchronizedTreeState {
205
262
  }
206
263
  try {
207
264
  if (this.logStat === "per-request") stat = initialTreeLoadingStat();
208
- if (this.sharedSeeds.length > 0 && iteration % DISCOVERY_EVERY_N_REFRESHES === 0) await this.discover();
209
- iteration++;
265
+ if (this.sharedSeeds.length > 0 && Date.now() - lastDiscovery >= DISCOVERY_INTERVAL_MS) {
266
+ await this.discover();
267
+ lastDiscovery = Date.now();
268
+ }
269
+ const changesBefore = countedChanges(stat);
210
270
  await this.refresh(stat);
211
- if (stat && this.logger) this.logger.info(`Tree stat (success, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`);
271
+ this.updatePollingInterval(countedChanges(stat) > changesBefore);
272
+ if (this.logStat && this.logger) this.logger.info(`Tree stat (success, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`);
212
273
  lastUpdate = Date.now();
213
274
  if (toNotify !== void 0) for (const n of toNotify) n.resolve();
214
275
  } catch (e) {
215
- if (stat && this.logger) this.logger.info(`Tree stat (error, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`);
276
+ if (this.logStat && this.logger) this.logger.info(`Tree stat (error, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`);
216
277
  lastUpdate = Date.now();
217
278
  if (toNotify !== void 0) for (const n of toNotify) n.reject(e);
218
279
  if (e instanceof TreeStateUpdateError) {
@@ -231,7 +292,7 @@ var SynchronizedTreeState = class SynchronizedTreeState {
231
292
  }
232
293
  if (!this.keepRunning || this.terminated) break;
233
294
  if (this.scheduledOnNextState.length === 0) {
234
- const remaining = Math.max(0, this.pollingInterval - MIN_POLLING_INTERVAL_MS);
295
+ const remaining = Math.max(0, this.effectivePollingInterval - MIN_POLLING_INTERVAL_MS);
235
296
  if (remaining > 0) try {
236
297
  this.currentLoopDelayInterrupt = new AbortController();
237
298
  await tp.setTimeout(remaining, void 0, { signal: AbortSignal.any([this.abortController.signal, this.currentLoopDelayInterrupt.signal]) });
@@ -291,6 +352,6 @@ var SynchronizedTreeState = class SynchronizedTreeState {
291
352
  }
292
353
  };
293
354
  //#endregion
294
- export { SynchronizedTreeState };
355
+ export { SynchronizedTreeState, derivePollingInterval };
295
356
 
296
357
  //# sourceMappingURL=synchronized_tree.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"synchronized_tree.js","names":[],"sources":["../src/synchronized_tree.ts"],"sourcesContent":["import { PollingComputableHooks } from \"@milaboratories/computable\";\nimport { PlTreeEntry, PlTreeRootsEntry } from \"./accessors\";\nimport type {\n FinalResourceDataPredicate,\n PlClient,\n ResourceType,\n SignedResourceId,\n TxOps,\n} from \"@milaboratories/pl-client\";\nimport type { Filter } from \"@milaboratories/pl-client\";\nimport {\n isUnauthenticated,\n isTimeoutOrCancelError,\n isUnimplementedError,\n} from \"@milaboratories/pl-client\";\nimport type { ExtendedResourceData } from \"./state\";\nimport { PlTreeState, TreeStateUpdateError } from \"./state\";\nimport type { PruningFunction, TraversalMode, TreeLoadingStat } from \"./sync\";\nimport { constructTreeLoadingRequest, initialTreeLoadingStat, loadTreeState } from \"./sync\";\nimport * as tp from \"node:timers/promises\";\nimport type { MiLogger } from \"@milaboratories/ts-helpers\";\n\n/** Hard floor between consecutive tree-refresh calls.\n * Applies even when {@link scheduleOnNextState} has woken the loop early,\n * preventing tight polling loops during rapid state transitions. */\nconst MIN_POLLING_INTERVAL_MS = 100;\n\ntype StatLoggingMode = \"cumulative\" | \"per-request\";\n\nexport type SynchronizedTreeOps = {\n /** Override final predicate from the PlClient */\n finalPredicateOverride?: FinalResourceDataPredicate;\n\n /** Pruning function for legacy fallback path. */\n pruning?: PruningFunction;\n\n /** ResourceTree field filter for modern backend path. */\n fieldFilter?: Filter;\n\n /** ResourceTree traversal stop rules for modern backend path. */\n traverseStopRules?: Filter;\n\n /** Interval after last sync to sleep before the next one */\n pollingInterval: number;\n /** For how long to continue polling after the last derived value access */\n stopPollingDelay: number;\n\n /** If one of the values, tree will log stats of each polling request */\n logStat?: StatLoggingMode;\n\n /** Timeout for initial tree loading. If not specified, will use default for RO tx from pl-client. */\n initialTreeLoadingTimeout?: number;\n\n /** Controls which tree-loading path to use. Default `\"auto\"`. */\n traversalMode?: TraversalMode;\n};\n\n/** An explicit resource to serve as a tree root. Several explicit seeds may be passed. */\nexport type ExplicitRootSeed = { kind: \"resource\"; root: SignedResourceId };\n\n/** Discovers, as roots, every resource of this type shared with the current user.\n * Matched against SharedResource.resourceType by NAME (version optional/ignored). The\n * discovered set is DYNAMIC — roots appear/disappear as grants are added/revoked/expire. */\nexport type SharedTypeSeed = { kind: \"shared\"; resourceType: ResourceType };\n\nexport type TreeSeed = ExplicitRootSeed | SharedTypeSeed;\n\n/** Normalizes the {@link SynchronizedTreeState.init} seed argument — a bare\n * {@link SignedResourceId}, a single {@link TreeSeed}, or an array — to `TreeSeed[]`, so\n * every existing single-root caller is unchanged. */\nfunction normalizeSeeds(seeds: SignedResourceId | TreeSeed | TreeSeed[]): TreeSeed[] {\n if (Array.isArray(seeds)) return seeds;\n if (typeof seeds === \"object\" && seeds !== null && \"kind\" in seeds) return [seeds];\n // bare SignedResourceId\n return [{ kind: \"resource\", root: seeds }];\n}\n\n/** How often, in main-loop iterations, a tree with shared-type seeds re-polls\n * ListUserResources to reconcile its discovered roots. 1 would mean every iteration.\n *\n * Discovery (a full ListUserResources stream) is far heavier than an ordinary incremental\n * refresh, so it must NOT run on every fast refresh tick. The refresh cadence is the tree's\n * `pollingInterval` floored by {@link MIN_POLLING_INTERVAL_MS} (~200ms-1s in practice — the\n * shared-seed discovery tree runs at the 200ms default). At N = 15 discovery fires roughly\n * every 15 × 200ms ≈ 3s, decoupling it from the fast refresh loop while keeping the latency\n * of noticing a new/removed share to a few seconds — acceptable for a human-driven share flow.\n *\n * Only trees with shared-type seeds gate on this; {@link discover} is a no-op for single-root\n * and explicit-seed trees (empty `sharedSeeds`), so the value never affects them. */\nconst DISCOVERY_EVERY_N_REFRESHES = 15;\n\ntype ScheduledRefresh = {\n resolve: () => void;\n reject: (err: any) => void;\n};\n\nexport class SynchronizedTreeState {\n private readonly finalPredicate: FinalResourceDataPredicate;\n private state: PlTreeState;\n private readonly pollingInterval: number;\n private readonly pruning?: PruningFunction;\n private readonly fieldFilter?: Filter;\n private readonly traverseStopRules?: Filter;\n private readonly traversalMode: TraversalMode;\n private readonly logStat?: StatLoggingMode;\n private readonly hooks: PollingComputableHooks;\n private readonly abortController = new AbortController();\n\n /** Explicit-resource seeds: fixed roots, present from construction. */\n private readonly explicitRoots: SignedResourceId[];\n /** Shared-type seeds (discovered roots), if any. */\n private readonly sharedSeeds: SharedTypeSeed[];\n /** Roots discovered for shared-type seeds on the last discovery poll. */\n private discoveredRoots: SignedResourceId[] = [];\n\n private constructor(\n private readonly pl: PlClient,\n seeds: TreeSeed[],\n ops: SynchronizedTreeOps,\n private readonly logger?: MiLogger,\n ) {\n const {\n finalPredicateOverride,\n pruning,\n fieldFilter,\n traverseStopRules,\n traversalMode,\n pollingInterval,\n stopPollingDelay,\n logStat,\n } = ops;\n this.pruning = pruning;\n this.fieldFilter = fieldFilter;\n this.traverseStopRules = traverseStopRules;\n this.traversalMode = traversalMode ?? \"auto\";\n this.pollingInterval = pollingInterval;\n this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;\n this.logStat = logStat;\n\n this.explicitRoots = seeds\n .filter((s): s is ExplicitRootSeed => s.kind === \"resource\")\n .map((s) => s.root);\n this.sharedSeeds = seeds.filter((s): s is SharedTypeSeed => s.kind === \"shared\");\n\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n this.hooks = new PollingComputableHooks(\n () => this.startUpdating(),\n () => this.stopUpdating(),\n { stopDebounce: stopPollingDelay },\n (resolve, reject) => this.scheduleOnNextState(resolve, reject),\n );\n }\n\n /** The current protected root set: explicit roots plus the latest discovered roots. */\n private currentRootSet(): Set<SignedResourceId> {\n return new Set([...this.explicitRoots, ...this.discoveredRoots]);\n }\n\n /** Resolves the single root for the backward-compatible single-root accessors, throwing\n * if the tree does not have exactly one root (guards legacy callers against multi-root). */\n private soleRoot(): SignedResourceId {\n const roots = this.currentRootSet();\n if (roots.size !== 1)\n throw new Error(\n `single-root accessor used on a tree with ${roots.size} roots; use rootsEntry() instead`,\n );\n return roots.values().next().value!;\n }\n\n /** @deprecated use \"entry\" instead */\n public accessor(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return this.entry(rid);\n }\n\n /** Backward-compatible single-root entry. With no `rid` it returns the sole root's entry\n * and THROWS if the tree has zero or more than one root. An explicit `rid` addresses any\n * resource in the heap, as today. */\n public entry(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeEntry(\n { treeProvider: () => this.state, hooks: this.hooks },\n rid ?? this.soleRoot(),\n );\n }\n\n /** Reactive provider for the current root SET. Reading it inside a Computable tracks the\n * set as a dependency, so the Computable recomputes when discovered roots appear/disappear. */\n public rootsEntry(): PlTreeRootsEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeRootsEntry({ treeProvider: () => this.state, hooks: this.hooks });\n }\n\n /** Can be used to externally kick off the synchronization polling loop, and\n * await for the first synchronization to happen. */\n public async refreshState(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n await this.hooks.refreshState();\n }\n\n private currentLoopDelayInterrupt: AbortController | undefined = undefined;\n private scheduledOnNextState: ScheduledRefresh[] = [];\n\n /** Called from computable hooks when external observer asks for state refresh */\n private scheduleOnNextState(resolve: () => void, reject: (err: any) => void): void {\n if (this.terminated) reject(new Error(\"tree synchronization is terminated\"));\n else {\n this.scheduledOnNextState.push({ resolve, reject });\n if (this.currentLoopDelayInterrupt) {\n this.currentLoopDelayInterrupt.abort();\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n\n /** Called from observer */\n private startUpdating(): void {\n if (this.terminated) return;\n this.keepRunning = true;\n if (this.currentLoop === undefined) this.currentLoop = this.mainLoop();\n }\n\n /** Called from observer */\n private stopUpdating(): void {\n this.keepRunning = false;\n }\n\n /** If true, main loop will continue polling pl state. */\n private keepRunning = false;\n /** Actual state of main loop. */\n private currentLoop: Promise<void> | undefined = undefined;\n\n /** Executed from the main loop, and initialization procedure. */\n private async refresh(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n try {\n await this.loadAndApply(stats, txOps);\n } catch (e) {\n // Discovery-tree self-heal: a discovered root whose grant was revoked/expired fails the whole\n // ResourceTree poll with Unauthenticated. Re-discover (drops dead roots) and retry once. This is\n // self-discriminating — a genuinely dead session also fails discover()'s own call, so real auth\n // loss still propagates. Only for discovery trees; explicit-root trees propagate as-is.\n if (this.sharedSeeds.length > 0 && isUnauthenticated(e)) {\n this.logger?.warn(\n \"discovery tree: Unauthenticated on ResourceTree (likely revoked/expired root); re-discovering and retrying\",\n );\n await this.discover();\n await this.loadAndApply(stats, txOps);\n } else throw e;\n }\n }\n\n private async loadAndApply(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n const request = constructTreeLoadingRequest(this.state, {\n pruningFunction: this.pruning,\n fieldFilter: this.fieldFilter,\n traverseStopRules: this.traverseStopRules,\n });\n // A shared-type-seed tree with no currently-discovered roots is legitimately empty:\n // there is nothing to traverse, and tx.resourceTree([]) would throw \"at least one seed\n // must be provided\". Skip the backend load and leave the (empty) state as-is — discovery\n // adds roots later via setRoots(), which schedules the next refresh. Explicit-root trees\n // never hit this (their root set is non-empty by construction).\n if (request.seedResources.length === 0 && request.finalResources.size === 0) return;\n const data = await this.pl.withReadTx(\n \"ReadingTree\",\n async (tx) => {\n return await loadTreeState(\n tx,\n request,\n stats,\n this.pl.serverInfo.capabilities ?? [],\n this.traversalMode,\n this.logger,\n );\n },\n txOps,\n );\n this.state.updateFromResourceData(data, { allowOrphanInputs: true, stat: stats });\n }\n\n /** Discovery sync for shared-type seeds: re-polls `ListUserResources` (gRPC-only) and\n * reconciles the discovered root set against the heap. A longer poll result adds roots; a\n * shorter one removes them (grant revoked/expired) — the removed roots' subtrees cascade\n * to collection via the ordinary refcount GC ({@link PlTreeState.setRoots}). No-op when the\n * tree has no shared-type seeds, or silently no-op on a REST client where `ListUserResources`\n * is unavailable. */\n private async discover(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n if (this.sharedSeeds.length === 0) return;\n\n const discovered = new Set<SignedResourceId>();\n for (const seed of this.sharedSeeds) {\n let ids: SignedResourceId[];\n try {\n // match by name only (permissive; ignores version, so it survives schema bumps)\n ids = await this.pl.userResources.listSharedResourcesByType(seed.resourceType.name);\n } catch (e: unknown) {\n if (isUnimplementedError(e)) continue;\n throw e;\n }\n for (const id of ids) discovered.add(id);\n }\n\n this.discoveredRoots = [...discovered];\n this.state.setRoots(this.currentRootSet());\n }\n\n /** If true this tree state is permanently terminaed. */\n private terminated = false;\n\n private async mainLoop() {\n // will hold request stats\n let stat = this.logStat ? initialTreeLoadingStat() : undefined;\n\n let lastUpdate = Date.now();\n\n // counts refresh iterations to pace the discovery poll for shared-type seeds.\n let iteration = 0;\n\n while (true) {\n if (!this.keepRunning || this.terminated) break;\n\n // saving those who want to be notified about new state here\n // because those who will be added during the tree retrieval\n // should be notified only on the next round\n let toNotify: ScheduledRefresh[] | undefined = undefined;\n if (this.scheduledOnNextState.length > 0) {\n toNotify = this.scheduledOnNextState;\n this.scheduledOnNextState = [];\n }\n\n try {\n // resetting stats if we were asked to collect non-cumulative stats\n if (this.logStat === \"per-request\") stat = initialTreeLoadingStat();\n\n // discovery sync for shared-type seeds: reconcile the discovered root set before\n // refreshing, so newly discovered roots are materialized in this same iteration.\n if (this.sharedSeeds.length > 0 && iteration % DISCOVERY_EVERY_N_REFRESHES === 0) {\n await this.discover();\n }\n iteration++;\n\n // actual tree synchronization\n await this.refresh(stat);\n\n // logging stats if we were asked to\n if (stat && this.logger)\n this.logger.info(\n `Tree stat (success, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we got new state\n if (toNotify !== undefined) for (const n of toNotify) n.resolve();\n } catch (e: any) {\n // logging stats if we were asked to (even if error occured)\n if (stat && this.logger)\n this.logger.info(\n `Tree stat (error, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we failed to refresh the state\n if (toNotify !== undefined) for (const n of toNotify) n.reject(e);\n\n // catching tree update errors, as they may leave our tree in inconsistent state\n if (e instanceof TreeStateUpdateError) {\n // important error logging, this should never happen\n this.logger?.error(e);\n\n // marking everybody who used previous state as changed\n this.state.invalidateTree(\"stat update error\");\n // creating new tree with the full current root set (re-discovered on next iteration)\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n\n // scheduling state update without delay\n continue;\n\n // unfortunately external observer may still see tree in its default\n // empty state, though this is best we can do in this exceptional\n // situation, and hope on caching layers inside computables to present\n // some stale state until we reconstruct the tree again\n } else this.logger?.warn(e);\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 1: mandatory floor — always wait at least MIN_POLLING_INTERVAL_MS.\n // Not interruptible by scheduleOnNextState; only termination aborts it.\n try {\n await tp.setTimeout(MIN_POLLING_INTERVAL_MS, undefined, {\n signal: this.abortController.signal,\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 2: optional remainder up to pollingInterval — interruptible by\n // scheduleOnNextState so that an external nudge wakes the loop promptly.\n if (this.scheduledOnNextState.length === 0) {\n const remaining = Math.max(0, this.pollingInterval - MIN_POLLING_INTERVAL_MS);\n if (remaining > 0) {\n try {\n this.currentLoopDelayInterrupt = new AbortController();\n await tp.setTimeout(remaining, undefined, {\n signal: AbortSignal.any([\n this.abortController.signal,\n this.currentLoopDelayInterrupt.signal,\n ]),\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n // Otherwise it was just the loop delay interrupt (scheduleOnNextState),\n // continue to the next iteration\n } finally {\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n }\n\n // reset only as a very last line\n this.currentLoop = undefined;\n }\n\n /**\n * Dumps the current state of the tree.\n * @returns An array of ExtendedResourceData objects representing the current state of the tree.\n */\n public dumpState(): ExtendedResourceData[] {\n return this.state.dumpState();\n }\n\n /**\n * Terminates the internal loop, and permanently destoys all internal state, so\n * all computables using this state will resolve to errors.\n * */\n public async terminate(): Promise<void> {\n this.keepRunning = false;\n this.terminated = true;\n this.abortController.abort();\n\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n\n this.state.invalidateTree(\"synchronization terminated for the tree\");\n }\n\n /** @deprecated */\n public async awaitSyncLoopTermination(): Promise<void> {\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n }\n\n /**\n * Initializes a synchronized tree from one or more seeds.\n *\n * @param seeds a bare {@link SignedResourceId} (the original single-root contract), a\n * single {@link TreeSeed}, or an array of seeds. Bare ids and explicit-resource seeds\n * become roots immediately; shared-type seeds discover their roots via `ListUserResources`.\n */\n public static async init(\n pl: PlClient,\n seeds: SignedResourceId | TreeSeed | TreeSeed[],\n ops: SynchronizedTreeOps,\n logger?: MiLogger,\n ) {\n const tree = new SynchronizedTreeState(pl, normalizeSeeds(seeds), ops, logger);\n\n const stat = ops.logStat ? initialTreeLoadingStat() : undefined;\n\n let ok = false;\n\n try {\n // resolve shared-type seeds before the first refresh so discovered roots load now\n await tree.discover();\n await tree.refresh(stat, {\n timeout: ops.initialTreeLoadingTimeout,\n });\n ok = true;\n } finally {\n // logging stats if we were asked to (even if error occured)\n if (stat && logger)\n logger.info(\n `Tree stat (initial load, ${ok ? \"success\" : \"failure\"}): ${JSON.stringify(stat)}`,\n );\n }\n\n return tree;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"synchronized_tree.js","names":[],"sources":["../src/synchronized_tree.ts"],"sourcesContent":["import { PollingComputableHooks } from \"@milaboratories/computable\";\nimport { PlTreeEntry, PlTreeRootsEntry } from \"./accessors\";\nimport type {\n FinalResourceDataPredicate,\n PlClient,\n ResourceType,\n SignedResourceId,\n TxOps,\n} from \"@milaboratories/pl-client\";\nimport type { Filter } from \"@milaboratories/pl-client\";\nimport {\n isUnauthenticated,\n isTimeoutOrCancelError,\n isUnimplementedError,\n} from \"@milaboratories/pl-client\";\nimport type { ExtendedResourceData } from \"./state\";\nimport { PlTreeState, TreeStateUpdateError } from \"./state\";\nimport type { PruningFunction, TraversalMode, TreeLoadingStat } from \"./sync\";\nimport { constructTreeLoadingRequest, initialTreeLoadingStat, loadTreeState } from \"./sync\";\nimport * as tp from \"node:timers/promises\";\nimport type { MiLogger } from \"@milaboratories/ts-helpers\";\n\n/** Hard floor between consecutive tree-refresh calls.\n * Applies even when {@link scheduleOnNextState} has woken the loop early,\n * preventing tight polling loops during rapid state transitions. */\nconst MIN_POLLING_INTERVAL_MS = 100;\n\n/** Ceiling for the adaptive poll interval. Caps how stale an idle tree can get before the\n * next look, and bounds how far the idle backoff can push the interval out. */\nconst MAX_POLLING_INTERVAL_MS = 5_000;\n\n/** Applied to the interval after a cycle that changed nothing. An idle tree walks its\n * interval out towards {@link MAX_POLLING_INTERVAL_MS} instead of re-polling at full rate;\n * the first cycle that changes anything resets it. */\nconst IDLE_BACKOFF_MULTIPLIER = 1.5;\n\n/** The client's measured RTT becomes an interval floor, scaled by this. Every refresh costs\n * at least one round trip, so polling faster than a small multiple of the RTT only queues\n * round-trips the link cannot service. This is what replaces the fixed interval on a\n * high-latency link; on a fast link the configured `pollingInterval` still dominates. */\nconst RTT_POLL_FACTOR = 2;\n\n/** Ceiling for the RTT-derived floor. The estimate is sampled at connect time and never\n * re-sampled, so without a bound one slow ping pins the cadence high for the whole session.\n * Mirrors `MAX_ADAPTIVE_REQUEST_TIMEOUT` on the deadline side: past this the link is stuck\n * rather than slow, and spacing polls further only delays noticing it recovered. */\nconst MAX_RTT_POLL_INTERVAL_MS = 30_000;\n\ntype StatLoggingMode = \"cumulative\" | \"per-request\";\n\nexport type SynchronizedTreeOps = {\n /** Override final predicate from the PlClient */\n finalPredicateOverride?: FinalResourceDataPredicate;\n\n /** Pruning function for legacy fallback path. */\n pruning?: PruningFunction;\n\n /** ResourceTree field filter for modern backend path. */\n fieldFilter?: Filter;\n\n /** ResourceTree traversal stop rules for modern backend path. */\n traverseStopRules?: Filter;\n\n /** Interval after last sync to sleep before the next one */\n pollingInterval: number;\n /** For how long to continue polling after the last derived value access */\n stopPollingDelay: number;\n\n /** If one of the values, tree will log stats of each polling request */\n logStat?: StatLoggingMode;\n\n /** Timeout for initial tree loading. If not specified, will use default for RO tx from pl-client. */\n initialTreeLoadingTimeout?: number;\n\n /** Controls which tree-loading path to use. Default `\"auto\"`. */\n traversalMode?: TraversalMode;\n};\n\n/** An explicit resource to serve as a tree root. Several explicit seeds may be passed. */\nexport type ExplicitRootSeed = { kind: \"resource\"; root: SignedResourceId };\n\n/** Discovers, as roots, every resource of this type shared with the current user.\n * Matched against SharedResource.resourceType by NAME (version optional/ignored). The\n * discovered set is DYNAMIC — roots appear/disappear as grants are added/revoked/expire. */\nexport type SharedTypeSeed = { kind: \"shared\"; resourceType: ResourceType };\n\nexport type TreeSeed = ExplicitRootSeed | SharedTypeSeed;\n\n/** Normalizes the {@link SynchronizedTreeState.init} seed argument — a bare\n * {@link SignedResourceId}, a single {@link TreeSeed}, or an array — to `TreeSeed[]`, so\n * every existing single-root caller is unchanged. */\nfunction normalizeSeeds(seeds: SignedResourceId | TreeSeed | TreeSeed[]): TreeSeed[] {\n if (Array.isArray(seeds)) return seeds;\n if (typeof seeds === \"object\" && seeds !== null && \"kind\" in seeds) return [seeds];\n // bare SignedResourceId\n return [{ kind: \"resource\", root: seeds }];\n}\n\n/** How often a tree with shared-type seeds re-polls ListUserResources to reconcile its\n * discovered roots.\n *\n * Discovery (a full ListUserResources stream) is far heavier than an ordinary incremental\n * refresh, so it must NOT run on every refresh tick. This is a wall-clock interval rather\n * than a count of iterations: the refresh cadence is adaptive (it stretches towards\n * {@link MAX_POLLING_INTERVAL_MS} on an idle tree and scales with RTT on a slow link),\n * so a fixed iteration count would let discovery latency drift out with it. Keeping it in\n * milliseconds pins the latency of noticing a new/removed share regardless of cadence, which\n * is what a human-driven share flow cares about.\n *\n * Only trees with shared-type seeds gate on this; {@link discover} is a no-op for single-root\n * and explicit-seed trees (empty `sharedSeeds`), so the value never affects them. */\nconst DISCOVERY_INTERVAL_MS = 3_000;\n\n/** Counters that mean \"this cycle brought something new\". Deliberately not the full change\n * breakdown: those fields are sub-counts of `resourcesChanged` and would double-count.\n * `resourcesUnchanged` is excluded by design, since a cycle that only re-fetched unchanged\n * state is exactly the idle case the backoff exists for. */\nfunction countedChanges(stat: TreeLoadingStat): number {\n return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal;\n}\n\n/** The poll-cadence policy, as a pure function of the last cycle's outcome.\n *\n * `configuredMs` is the tree's static `pollingInterval` and acts as the lower bound, so no\n * link can be polled faster than configured. `rttMs` raises that bound on a slow link.\n * `currentMs` is the interval in force for the cycle that just finished, which is what the\n * idle backoff compounds on. */\nexport function derivePollingInterval(opts: {\n configuredMs: number;\n currentMs: number;\n rttMs: number | undefined;\n changed: boolean;\n}): number {\n const { configuredMs, currentMs, rttMs, changed } = opts;\n\n // The cap applies to the RTT-derived part only, so `configuredMs` stays an absolute lower\n // bound even if it is ever set above the cap.\n const floor =\n rttMs === undefined\n ? configuredMs\n : Math.max(\n configuredMs,\n Math.min(MAX_RTT_POLL_INTERVAL_MS, Math.ceil(rttMs * RTT_POLL_FACTOR)),\n );\n\n const next = changed ? floor : Math.max(floor, currentMs * IDLE_BACKOFF_MULTIPLIER);\n\n // The ceiling never cuts below the floor: a link slower than MAX_POLLING_INTERVAL_MS still\n // gets its RTT-derived spacing rather than being forced to re-poll early.\n return Math.max(floor, Math.min(MAX_POLLING_INTERVAL_MS, next));\n}\n\ntype ScheduledRefresh = {\n resolve: () => void;\n reject: (err: any) => void;\n};\n\nexport class SynchronizedTreeState {\n private readonly finalPredicate: FinalResourceDataPredicate;\n private state: PlTreeState;\n private readonly pollingInterval: number;\n private readonly pruning?: PruningFunction;\n private readonly fieldFilter?: Filter;\n private readonly traverseStopRules?: Filter;\n private readonly traversalMode: TraversalMode;\n private readonly logStat?: StatLoggingMode;\n private readonly hooks: PollingComputableHooks;\n private readonly abortController = new AbortController();\n\n /** Explicit-resource seeds: fixed roots, present from construction. */\n private readonly explicitRoots: SignedResourceId[];\n /** Shared-type seeds (discovered roots), if any. */\n private readonly sharedSeeds: SharedTypeSeed[];\n /** Roots discovered for shared-type seeds on the last discovery poll. */\n private discoveredRoots: SignedResourceId[] = [];\n\n private constructor(\n private readonly pl: PlClient,\n seeds: TreeSeed[],\n ops: SynchronizedTreeOps,\n private readonly logger?: MiLogger,\n ) {\n const {\n finalPredicateOverride,\n pruning,\n fieldFilter,\n traverseStopRules,\n traversalMode,\n pollingInterval,\n stopPollingDelay,\n logStat,\n } = ops;\n this.pruning = pruning;\n this.fieldFilter = fieldFilter;\n this.traverseStopRules = traverseStopRules;\n this.traversalMode = traversalMode ?? \"auto\";\n this.pollingInterval = pollingInterval;\n this.effectivePollingInterval = pollingInterval;\n this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;\n this.logStat = logStat;\n\n this.explicitRoots = seeds\n .filter((s): s is ExplicitRootSeed => s.kind === \"resource\")\n .map((s) => s.root);\n this.sharedSeeds = seeds.filter((s): s is SharedTypeSeed => s.kind === \"shared\");\n\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n this.hooks = new PollingComputableHooks(\n () => this.startUpdating(),\n () => this.stopUpdating(),\n { stopDebounce: stopPollingDelay },\n (resolve, reject) => this.scheduleOnNextState(resolve, reject),\n );\n }\n\n /** The current protected root set: explicit roots plus the latest discovered roots. */\n private currentRootSet(): Set<SignedResourceId> {\n return new Set([...this.explicitRoots, ...this.discoveredRoots]);\n }\n\n /** Resolves the single root for the backward-compatible single-root accessors, throwing\n * if the tree does not have exactly one root (guards legacy callers against multi-root). */\n private soleRoot(): SignedResourceId {\n const roots = this.currentRootSet();\n if (roots.size !== 1)\n throw new Error(\n `single-root accessor used on a tree with ${roots.size} roots; use rootsEntry() instead`,\n );\n return roots.values().next().value!;\n }\n\n /** @deprecated use \"entry\" instead */\n public accessor(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return this.entry(rid);\n }\n\n /** Backward-compatible single-root entry. With no `rid` it returns the sole root's entry\n * and THROWS if the tree has zero or more than one root. An explicit `rid` addresses any\n * resource in the heap, as today. */\n public entry(rid?: SignedResourceId): PlTreeEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeEntry(\n { treeProvider: () => this.state, hooks: this.hooks },\n rid ?? this.soleRoot(),\n );\n }\n\n /** Reactive provider for the current root SET. Reading it inside a Computable tracks the\n * set as a dependency, so the Computable recomputes when discovered roots appear/disappear. */\n public rootsEntry(): PlTreeRootsEntry {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n return new PlTreeRootsEntry({ treeProvider: () => this.state, hooks: this.hooks });\n }\n\n /** Can be used to externally kick off the synchronization polling loop, and\n * await for the first synchronization to happen. */\n public async refreshState(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n await this.hooks.refreshState();\n }\n\n private currentLoopDelayInterrupt: AbortController | undefined = undefined;\n private scheduledOnNextState: ScheduledRefresh[] = [];\n\n /** Interval actually used for the current wait. Starts at the configured `pollingInterval`\n * and is re-derived after every cycle by {@link updatePollingInterval}. */\n private effectivePollingInterval: number;\n\n /** Re-derives {@link effectivePollingInterval} after a cycle.\n *\n * Two independent effects. The floor scales with the client's measured RTT, so a\n * high-latency link stops queueing round-trips it cannot service. On top of that, a cycle\n * that changed nothing multiplies the interval out towards\n * {@link MAX_POLLING_INTERVAL_MS}, while any change snaps it straight back to the floor so\n * an active tree stays responsive. */\n private updatePollingInterval(changed: boolean): void {\n this.effectivePollingInterval = derivePollingInterval({\n configuredMs: this.pollingInterval,\n currentMs: this.effectivePollingInterval,\n rttMs: this.pl.rttEstimateMs,\n changed,\n });\n }\n\n /** Called from computable hooks when external observer asks for state refresh */\n private scheduleOnNextState(resolve: () => void, reject: (err: any) => void): void {\n if (this.terminated) reject(new Error(\"tree synchronization is terminated\"));\n else {\n this.scheduledOnNextState.push({ resolve, reject });\n // Someone is waiting on fresh state, so this tree is not idle after all: drop any\n // accumulated backoff, otherwise the cycles right after a nudge stay slow. Routed\n // through the policy rather than assigning the configured value directly, so the RTT\n // floor survives the reset. Assigning it raw would poll a high-latency link faster than\n // it can answer, and the interval would stay there until the next cycle that completes:\n // the error path never reaches updatePollingInterval, so a failing nudged refresh would\n // keep retrying at the un-floored rate.\n this.updatePollingInterval(true);\n if (this.currentLoopDelayInterrupt) {\n this.currentLoopDelayInterrupt.abort();\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n\n /** Called from observer */\n private startUpdating(): void {\n if (this.terminated) return;\n this.keepRunning = true;\n if (this.currentLoop === undefined) this.currentLoop = this.mainLoop();\n }\n\n /** Called from observer */\n private stopUpdating(): void {\n this.keepRunning = false;\n }\n\n /** If true, main loop will continue polling pl state. */\n private keepRunning = false;\n /** Actual state of main loop. */\n private currentLoop: Promise<void> | undefined = undefined;\n\n /** Executed from the main loop, and initialization procedure. */\n private async refresh(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n try {\n await this.loadAndApply(stats, txOps);\n } catch (e) {\n // Discovery-tree self-heal: a discovered root whose grant was revoked/expired fails the whole\n // ResourceTree poll with Unauthenticated. Re-discover (drops dead roots) and retry once. This is\n // self-discriminating — a genuinely dead session also fails discover()'s own call, so real auth\n // loss still propagates. Only for discovery trees; explicit-root trees propagate as-is.\n if (this.sharedSeeds.length > 0 && isUnauthenticated(e)) {\n this.logger?.warn(\n \"discovery tree: Unauthenticated on ResourceTree (likely revoked/expired root); re-discovering and retrying\",\n );\n await this.discover();\n await this.loadAndApply(stats, txOps);\n } else throw e;\n }\n }\n\n private async loadAndApply(stats?: TreeLoadingStat, txOps?: TxOps): Promise<void> {\n const request = constructTreeLoadingRequest(this.state, {\n pruningFunction: this.pruning,\n fieldFilter: this.fieldFilter,\n traverseStopRules: this.traverseStopRules,\n });\n // A shared-type-seed tree with no currently-discovered roots is legitimately empty:\n // there is nothing to traverse, and tx.resourceTree([]) would throw \"at least one seed\n // must be provided\". Skip the backend load and leave the (empty) state as-is — discovery\n // adds roots later via setRoots(), which schedules the next refresh. Explicit-root trees\n // never hit this (their root set is non-empty by construction).\n if (request.seedResources.length === 0 && request.finalResources.size === 0) return;\n const data = await this.pl.withReadTx(\n \"ReadingTree\",\n async (tx) => {\n return await loadTreeState(\n tx,\n request,\n stats,\n this.pl.serverInfo.capabilities ?? [],\n this.traversalMode,\n this.logger,\n );\n },\n txOps,\n );\n this.state.updateFromResourceData(data, { allowOrphanInputs: true, stat: stats });\n }\n\n /** Discovery sync for shared-type seeds: re-polls `ListUserResources` (gRPC-only) and\n * reconciles the discovered root set against the heap. A longer poll result adds roots; a\n * shorter one removes them (grant revoked/expired) — the removed roots' subtrees cascade\n * to collection via the ordinary refcount GC ({@link PlTreeState.setRoots}). No-op when the\n * tree has no shared-type seeds, or silently no-op on a REST client where `ListUserResources`\n * is unavailable. */\n private async discover(): Promise<void> {\n if (this.terminated) throw new Error(\"tree synchronization is terminated\");\n if (this.sharedSeeds.length === 0) return;\n\n const discovered = new Set<SignedResourceId>();\n for (const seed of this.sharedSeeds) {\n let ids: SignedResourceId[];\n try {\n // match by name only (permissive; ignores version, so it survives schema bumps)\n ids = await this.pl.userResources.listSharedResourcesByType(seed.resourceType.name);\n } catch (e: unknown) {\n if (isUnimplementedError(e)) continue;\n throw e;\n }\n for (const id of ids) discovered.add(id);\n }\n\n this.discoveredRoots = [...discovered];\n this.state.setRoots(this.currentRootSet());\n }\n\n /** If true this tree state is permanently terminaed. */\n private terminated = false;\n\n private async mainLoop() {\n // Always collected, even when not logging: the change counters drive the idle backoff\n // below. Counter bumps are cheap next to the round trip they describe.\n let stat = initialTreeLoadingStat();\n\n let lastUpdate = Date.now();\n\n // paces the discovery poll for shared-type seeds; 0 forces discovery on the first pass.\n let lastDiscovery = 0;\n\n while (true) {\n if (!this.keepRunning || this.terminated) break;\n\n // saving those who want to be notified about new state here\n // because those who will be added during the tree retrieval\n // should be notified only on the next round\n let toNotify: ScheduledRefresh[] | undefined = undefined;\n if (this.scheduledOnNextState.length > 0) {\n toNotify = this.scheduledOnNextState;\n this.scheduledOnNextState = [];\n }\n\n try {\n // resetting stats if we were asked to collect non-cumulative stats\n if (this.logStat === \"per-request\") stat = initialTreeLoadingStat();\n\n // discovery sync for shared-type seeds: reconcile the discovered root set before\n // refreshing, so newly discovered roots are materialized in this same iteration.\n if (this.sharedSeeds.length > 0 && Date.now() - lastDiscovery >= DISCOVERY_INTERVAL_MS) {\n await this.discover();\n lastDiscovery = Date.now();\n }\n\n // Change counters before the refresh, so the delta tells us whether this single cycle\n // brought anything new. Works in both stat modes: \"per-request\" resets to 0 above.\n const changesBefore = countedChanges(stat);\n\n // actual tree synchronization\n await this.refresh(stat);\n\n this.updatePollingInterval(countedChanges(stat) > changesBefore);\n\n // logging stats if we were asked to\n if (this.logStat && this.logger)\n this.logger.info(\n `Tree stat (success, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we got new state\n if (toNotify !== undefined) for (const n of toNotify) n.resolve();\n } catch (e: any) {\n // logging stats if we were asked to (even if error occured)\n if (this.logStat && this.logger)\n this.logger.info(\n `Tree stat (error, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,\n );\n lastUpdate = Date.now();\n\n // notifying that we failed to refresh the state\n if (toNotify !== undefined) for (const n of toNotify) n.reject(e);\n\n // catching tree update errors, as they may leave our tree in inconsistent state\n if (e instanceof TreeStateUpdateError) {\n // important error logging, this should never happen\n this.logger?.error(e);\n\n // marking everybody who used previous state as changed\n this.state.invalidateTree(\"stat update error\");\n // creating new tree with the full current root set (re-discovered on next iteration)\n this.state = new PlTreeState(this.currentRootSet(), this.finalPredicate);\n\n // scheduling state update without delay\n continue;\n\n // unfortunately external observer may still see tree in its default\n // empty state, though this is best we can do in this exceptional\n // situation, and hope on caching layers inside computables to present\n // some stale state until we reconstruct the tree again\n } else this.logger?.warn(e);\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 1: mandatory floor — always wait at least MIN_POLLING_INTERVAL_MS.\n // Not interruptible by scheduleOnNextState; only termination aborts it.\n try {\n await tp.setTimeout(MIN_POLLING_INTERVAL_MS, undefined, {\n signal: this.abortController.signal,\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n }\n\n if (!this.keepRunning || this.terminated) break;\n\n // Phase 2: optional remainder up to pollingInterval — interruptible by\n // scheduleOnNextState so that an external nudge wakes the loop promptly.\n if (this.scheduledOnNextState.length === 0) {\n const remaining = Math.max(0, this.effectivePollingInterval - MIN_POLLING_INTERVAL_MS);\n if (remaining > 0) {\n try {\n this.currentLoopDelayInterrupt = new AbortController();\n await tp.setTimeout(remaining, undefined, {\n signal: AbortSignal.any([\n this.abortController.signal,\n this.currentLoopDelayInterrupt.signal,\n ]),\n });\n } catch (e: unknown) {\n if (!isTimeoutOrCancelError(e)) throw new Error(\"Unexpected error\", { cause: e });\n if (this.abortController.signal.aborted) break;\n // Otherwise it was just the loop delay interrupt (scheduleOnNextState),\n // continue to the next iteration\n } finally {\n this.currentLoopDelayInterrupt = undefined;\n }\n }\n }\n }\n\n // reset only as a very last line\n this.currentLoop = undefined;\n }\n\n /**\n * Dumps the current state of the tree.\n * @returns An array of ExtendedResourceData objects representing the current state of the tree.\n */\n public dumpState(): ExtendedResourceData[] {\n return this.state.dumpState();\n }\n\n /**\n * Terminates the internal loop, and permanently destoys all internal state, so\n * all computables using this state will resolve to errors.\n * */\n public async terminate(): Promise<void> {\n this.keepRunning = false;\n this.terminated = true;\n this.abortController.abort();\n\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n\n this.state.invalidateTree(\"synchronization terminated for the tree\");\n }\n\n /** @deprecated */\n public async awaitSyncLoopTermination(): Promise<void> {\n if (this.currentLoop === undefined) return;\n await this.currentLoop;\n }\n\n /**\n * Initializes a synchronized tree from one or more seeds.\n *\n * @param seeds a bare {@link SignedResourceId} (the original single-root contract), a\n * single {@link TreeSeed}, or an array of seeds. Bare ids and explicit-resource seeds\n * become roots immediately; shared-type seeds discover their roots via `ListUserResources`.\n */\n public static async init(\n pl: PlClient,\n seeds: SignedResourceId | TreeSeed | TreeSeed[],\n ops: SynchronizedTreeOps,\n logger?: MiLogger,\n ) {\n const tree = new SynchronizedTreeState(pl, normalizeSeeds(seeds), ops, logger);\n\n const stat = ops.logStat ? initialTreeLoadingStat() : undefined;\n\n let ok = false;\n\n try {\n // resolve shared-type seeds before the first refresh so discovered roots load now\n await tree.discover();\n await tree.refresh(stat, {\n timeout: ops.initialTreeLoadingTimeout,\n });\n ok = true;\n } finally {\n // logging stats if we were asked to (even if error occured)\n if (stat && logger)\n logger.info(\n `Tree stat (initial load, ${ok ? \"success\" : \"failure\"}): ${JSON.stringify(stat)}`,\n );\n }\n\n return tree;\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milaboratories/pl-tree",
3
- "version": "1.13.5",
3
+ "version": "1.13.6",
4
4
  "description": "Reactive pl tree state",
5
5
  "files": [
6
6
  "./dist/**/*",
@@ -21,10 +21,10 @@
21
21
  "denque": "^2.1.0",
22
22
  "utility-types": "^3.11.0",
23
23
  "zod": "~3.25.76",
24
+ "@milaboratories/computable": "2.9.8",
25
+ "@milaboratories/pl-errors": "1.4.35",
24
26
  "@milaboratories/ts-helpers": "1.8.6",
25
- "@milaboratories/pl-errors": "1.4.34",
26
- "@milaboratories/pl-client": "3.14.5",
27
- "@milaboratories/computable": "2.9.8"
27
+ "@milaboratories/pl-client": "3.14.6"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/node": "~24.5.2",
@@ -32,8 +32,8 @@
32
32
  "typescript": "~5.9.3",
33
33
  "vitest": "^4.1.3",
34
34
  "@milaboratories/build-configs": "2.0.0",
35
- "@milaboratories/ts-builder": "1.6.1",
36
- "@milaboratories/ts-configs": "1.3.1"
35
+ "@milaboratories/ts-configs": "1.4.0",
36
+ "@milaboratories/ts-builder": "1.6.2"
37
37
  },
38
38
  "engines": {
39
39
  "node": ">=22.19.0"
@@ -0,0 +1,90 @@
1
+ import { test, expect } from "vitest";
2
+ import { derivePollingInterval } from "./synchronized_tree";
3
+
4
+ /** The project-list / sharing singleton trees run at 200ms; per-project trees at 1000ms. */
5
+ const FAST = 200;
6
+
7
+ test("no rtt sample yet leaves the configured interval alone", () => {
8
+ expect(
9
+ derivePollingInterval({ configuredMs: FAST, currentMs: FAST, rttMs: undefined, changed: true }),
10
+ ).toEqual(FAST);
11
+ });
12
+
13
+ test("a fast link keeps the configured interval", () => {
14
+ // 10ms RTT would imply a 20ms floor, well under the configured value.
15
+ expect(
16
+ derivePollingInterval({ configuredMs: FAST, currentMs: FAST, rttMs: 10, changed: true }),
17
+ ).toEqual(FAST);
18
+ });
19
+
20
+ test("a slow link spaces polls by rtt", () => {
21
+ // The spec's target envelope: 1.4s RTT against a 200ms fixed interval, which is the
22
+ // regime where unconditional polling outran what the link could deliver.
23
+ expect(
24
+ derivePollingInterval({ configuredMs: FAST, currentMs: FAST, rttMs: 1400, changed: true }),
25
+ ).toEqual(2800);
26
+ });
27
+
28
+ test("idle cycles back off, and a change snaps straight back", () => {
29
+ const step = (currentMs: number, changed: boolean) =>
30
+ derivePollingInterval({ configuredMs: FAST, currentMs, rttMs: 10, changed });
31
+
32
+ // Nothing changed: the interval walks out, strictly increasing each time.
33
+ const first = step(FAST, false);
34
+ const second = step(first, false);
35
+ const third = step(second, false);
36
+ expect(first).toBeGreaterThan(FAST);
37
+ expect(second).toBeGreaterThan(first);
38
+ expect(third).toBeGreaterThan(second);
39
+
40
+ // One changed cycle returns to the floor immediately, no gradual recovery.
41
+ expect(step(third, true)).toEqual(FAST);
42
+ });
43
+
44
+ test("idle backoff is bounded", () => {
45
+ let interval = FAST;
46
+ for (let i = 0; i < 100; i++) {
47
+ interval = derivePollingInterval({
48
+ configuredMs: FAST,
49
+ currentMs: interval,
50
+ rttMs: 10,
51
+ changed: false,
52
+ });
53
+ }
54
+ // Must converge on the ceiling rather than growing without limit.
55
+ expect(interval).toEqual(5_000);
56
+ });
57
+
58
+ test("resetting backoff keeps the rtt floor", () => {
59
+ // What an observer nudge does: reset to the floor, from a fully backed-off interval.
60
+ // The floor must stay RTT-derived, not drop to the raw configured value, or a nudge on a
61
+ // slow link would poll faster than the link can answer.
62
+ expect(
63
+ derivePollingInterval({ configuredMs: FAST, currentMs: 5_000, rttMs: 1400, changed: true }),
64
+ ).toEqual(2800);
65
+ });
66
+
67
+ test("the rtt floor is itself bounded", () => {
68
+ // A ping sampled during connect can legitimately take tens of seconds, and the estimate is
69
+ // never re-sampled. Unbounded, a 40s sample would pin the interval at 80s for the session.
70
+ expect(
71
+ derivePollingInterval({ configuredMs: FAST, currentMs: FAST, rttMs: 40_000, changed: true }),
72
+ ).toEqual(30_000);
73
+
74
+ // The bound holds against the idle backoff too, which compounds on the previous interval.
75
+ expect(
76
+ derivePollingInterval({ configuredMs: FAST, currentMs: 30_000, rttMs: 40_000, changed: false }),
77
+ ).toEqual(30_000);
78
+ });
79
+
80
+ test("the rtt floor outranks the ceiling on a very slow link", () => {
81
+ // 4s RTT implies an 8s floor, past the 5s ceiling. Clamping to 5s would poll faster than
82
+ // the link can answer, so the floor has to win.
83
+ const interval = derivePollingInterval({
84
+ configuredMs: FAST,
85
+ currentMs: FAST,
86
+ rttMs: 4000,
87
+ changed: false,
88
+ });
89
+ expect(interval).toEqual(8_000);
90
+ });
@@ -25,6 +25,27 @@ import type { MiLogger } from "@milaboratories/ts-helpers";
25
25
  * preventing tight polling loops during rapid state transitions. */
26
26
  const MIN_POLLING_INTERVAL_MS = 100;
27
27
 
28
+ /** Ceiling for the adaptive poll interval. Caps how stale an idle tree can get before the
29
+ * next look, and bounds how far the idle backoff can push the interval out. */
30
+ const MAX_POLLING_INTERVAL_MS = 5_000;
31
+
32
+ /** Applied to the interval after a cycle that changed nothing. An idle tree walks its
33
+ * interval out towards {@link MAX_POLLING_INTERVAL_MS} instead of re-polling at full rate;
34
+ * the first cycle that changes anything resets it. */
35
+ const IDLE_BACKOFF_MULTIPLIER = 1.5;
36
+
37
+ /** The client's measured RTT becomes an interval floor, scaled by this. Every refresh costs
38
+ * at least one round trip, so polling faster than a small multiple of the RTT only queues
39
+ * round-trips the link cannot service. This is what replaces the fixed interval on a
40
+ * high-latency link; on a fast link the configured `pollingInterval` still dominates. */
41
+ const RTT_POLL_FACTOR = 2;
42
+
43
+ /** Ceiling for the RTT-derived floor. The estimate is sampled at connect time and never
44
+ * re-sampled, so without a bound one slow ping pins the cadence high for the whole session.
45
+ * Mirrors `MAX_ADAPTIVE_REQUEST_TIMEOUT` on the deadline side: past this the link is stuck
46
+ * rather than slow, and spacing polls further only delays noticing it recovered. */
47
+ const MAX_RTT_POLL_INTERVAL_MS = 30_000;
48
+
28
49
  type StatLoggingMode = "cumulative" | "per-request";
29
50
 
30
51
  export type SynchronizedTreeOps = {
@@ -75,19 +96,59 @@ function normalizeSeeds(seeds: SignedResourceId | TreeSeed | TreeSeed[]): TreeSe
75
96
  return [{ kind: "resource", root: seeds }];
76
97
  }
77
98
 
78
- /** How often, in main-loop iterations, a tree with shared-type seeds re-polls
79
- * ListUserResources to reconcile its discovered roots. 1 would mean every iteration.
99
+ /** How often a tree with shared-type seeds re-polls ListUserResources to reconcile its
100
+ * discovered roots.
80
101
  *
81
102
  * Discovery (a full ListUserResources stream) is far heavier than an ordinary incremental
82
- * refresh, so it must NOT run on every fast refresh tick. The refresh cadence is the tree's
83
- * `pollingInterval` floored by {@link MIN_POLLING_INTERVAL_MS} (~200ms-1s in practice — the
84
- * shared-seed discovery tree runs at the 200ms default). At N = 15 discovery fires roughly
85
- * every 15 × 200ms ≈ 3s, decoupling it from the fast refresh loop while keeping the latency
86
- * of noticing a new/removed share to a few seconds — acceptable for a human-driven share flow.
103
+ * refresh, so it must NOT run on every refresh tick. This is a wall-clock interval rather
104
+ * than a count of iterations: the refresh cadence is adaptive (it stretches towards
105
+ * {@link MAX_POLLING_INTERVAL_MS} on an idle tree and scales with RTT on a slow link),
106
+ * so a fixed iteration count would let discovery latency drift out with it. Keeping it in
107
+ * milliseconds pins the latency of noticing a new/removed share regardless of cadence, which
108
+ * is what a human-driven share flow cares about.
87
109
  *
88
110
  * Only trees with shared-type seeds gate on this; {@link discover} is a no-op for single-root
89
111
  * and explicit-seed trees (empty `sharedSeeds`), so the value never affects them. */
90
- const DISCOVERY_EVERY_N_REFRESHES = 15;
112
+ const DISCOVERY_INTERVAL_MS = 3_000;
113
+
114
+ /** Counters that mean "this cycle brought something new". Deliberately not the full change
115
+ * breakdown: those fields are sub-counts of `resourcesChanged` and would double-count.
116
+ * `resourcesUnchanged` is excluded by design, since a cycle that only re-fetched unchanged
117
+ * state is exactly the idle case the backoff exists for. */
118
+ function countedChanges(stat: TreeLoadingStat): number {
119
+ return stat.resourcesNew + stat.resourcesChanged + stat.resourcesMarkedFinal;
120
+ }
121
+
122
+ /** The poll-cadence policy, as a pure function of the last cycle's outcome.
123
+ *
124
+ * `configuredMs` is the tree's static `pollingInterval` and acts as the lower bound, so no
125
+ * link can be polled faster than configured. `rttMs` raises that bound on a slow link.
126
+ * `currentMs` is the interval in force for the cycle that just finished, which is what the
127
+ * idle backoff compounds on. */
128
+ export function derivePollingInterval(opts: {
129
+ configuredMs: number;
130
+ currentMs: number;
131
+ rttMs: number | undefined;
132
+ changed: boolean;
133
+ }): number {
134
+ const { configuredMs, currentMs, rttMs, changed } = opts;
135
+
136
+ // The cap applies to the RTT-derived part only, so `configuredMs` stays an absolute lower
137
+ // bound even if it is ever set above the cap.
138
+ const floor =
139
+ rttMs === undefined
140
+ ? configuredMs
141
+ : Math.max(
142
+ configuredMs,
143
+ Math.min(MAX_RTT_POLL_INTERVAL_MS, Math.ceil(rttMs * RTT_POLL_FACTOR)),
144
+ );
145
+
146
+ const next = changed ? floor : Math.max(floor, currentMs * IDLE_BACKOFF_MULTIPLIER);
147
+
148
+ // The ceiling never cuts below the floor: a link slower than MAX_POLLING_INTERVAL_MS still
149
+ // gets its RTT-derived spacing rather than being forced to re-poll early.
150
+ return Math.max(floor, Math.min(MAX_POLLING_INTERVAL_MS, next));
151
+ }
91
152
 
92
153
  type ScheduledRefresh = {
93
154
  resolve: () => void;
@@ -134,6 +195,7 @@ export class SynchronizedTreeState {
134
195
  this.traverseStopRules = traverseStopRules;
135
196
  this.traversalMode = traversalMode ?? "auto";
136
197
  this.pollingInterval = pollingInterval;
198
+ this.effectivePollingInterval = pollingInterval;
137
199
  this.finalPredicate = finalPredicateOverride ?? pl.finalPredicate;
138
200
  this.logStat = logStat;
139
201
 
@@ -201,11 +263,39 @@ export class SynchronizedTreeState {
201
263
  private currentLoopDelayInterrupt: AbortController | undefined = undefined;
202
264
  private scheduledOnNextState: ScheduledRefresh[] = [];
203
265
 
266
+ /** Interval actually used for the current wait. Starts at the configured `pollingInterval`
267
+ * and is re-derived after every cycle by {@link updatePollingInterval}. */
268
+ private effectivePollingInterval: number;
269
+
270
+ /** Re-derives {@link effectivePollingInterval} after a cycle.
271
+ *
272
+ * Two independent effects. The floor scales with the client's measured RTT, so a
273
+ * high-latency link stops queueing round-trips it cannot service. On top of that, a cycle
274
+ * that changed nothing multiplies the interval out towards
275
+ * {@link MAX_POLLING_INTERVAL_MS}, while any change snaps it straight back to the floor so
276
+ * an active tree stays responsive. */
277
+ private updatePollingInterval(changed: boolean): void {
278
+ this.effectivePollingInterval = derivePollingInterval({
279
+ configuredMs: this.pollingInterval,
280
+ currentMs: this.effectivePollingInterval,
281
+ rttMs: this.pl.rttEstimateMs,
282
+ changed,
283
+ });
284
+ }
285
+
204
286
  /** Called from computable hooks when external observer asks for state refresh */
205
287
  private scheduleOnNextState(resolve: () => void, reject: (err: any) => void): void {
206
288
  if (this.terminated) reject(new Error("tree synchronization is terminated"));
207
289
  else {
208
290
  this.scheduledOnNextState.push({ resolve, reject });
291
+ // Someone is waiting on fresh state, so this tree is not idle after all: drop any
292
+ // accumulated backoff, otherwise the cycles right after a nudge stay slow. Routed
293
+ // through the policy rather than assigning the configured value directly, so the RTT
294
+ // floor survives the reset. Assigning it raw would poll a high-latency link faster than
295
+ // it can answer, and the interval would stay there until the next cycle that completes:
296
+ // the error path never reaches updatePollingInterval, so a failing nudged refresh would
297
+ // keep retrying at the un-floored rate.
298
+ this.updatePollingInterval(true);
209
299
  if (this.currentLoopDelayInterrupt) {
210
300
  this.currentLoopDelayInterrupt.abort();
211
301
  this.currentLoopDelayInterrupt = undefined;
@@ -310,13 +400,14 @@ export class SynchronizedTreeState {
310
400
  private terminated = false;
311
401
 
312
402
  private async mainLoop() {
313
- // will hold request stats
314
- let stat = this.logStat ? initialTreeLoadingStat() : undefined;
403
+ // Always collected, even when not logging: the change counters drive the idle backoff
404
+ // below. Counter bumps are cheap next to the round trip they describe.
405
+ let stat = initialTreeLoadingStat();
315
406
 
316
407
  let lastUpdate = Date.now();
317
408
 
318
- // counts refresh iterations to pace the discovery poll for shared-type seeds.
319
- let iteration = 0;
409
+ // paces the discovery poll for shared-type seeds; 0 forces discovery on the first pass.
410
+ let lastDiscovery = 0;
320
411
 
321
412
  while (true) {
322
413
  if (!this.keepRunning || this.terminated) break;
@@ -336,16 +427,22 @@ export class SynchronizedTreeState {
336
427
 
337
428
  // discovery sync for shared-type seeds: reconcile the discovered root set before
338
429
  // refreshing, so newly discovered roots are materialized in this same iteration.
339
- if (this.sharedSeeds.length > 0 && iteration % DISCOVERY_EVERY_N_REFRESHES === 0) {
430
+ if (this.sharedSeeds.length > 0 && Date.now() - lastDiscovery >= DISCOVERY_INTERVAL_MS) {
340
431
  await this.discover();
432
+ lastDiscovery = Date.now();
341
433
  }
342
- iteration++;
434
+
435
+ // Change counters before the refresh, so the delta tells us whether this single cycle
436
+ // brought anything new. Works in both stat modes: "per-request" resets to 0 above.
437
+ const changesBefore = countedChanges(stat);
343
438
 
344
439
  // actual tree synchronization
345
440
  await this.refresh(stat);
346
441
 
442
+ this.updatePollingInterval(countedChanges(stat) > changesBefore);
443
+
347
444
  // logging stats if we were asked to
348
- if (stat && this.logger)
445
+ if (this.logStat && this.logger)
349
446
  this.logger.info(
350
447
  `Tree stat (success, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,
351
448
  );
@@ -355,7 +452,7 @@ export class SynchronizedTreeState {
355
452
  if (toNotify !== undefined) for (const n of toNotify) n.resolve();
356
453
  } catch (e: any) {
357
454
  // logging stats if we were asked to (even if error occured)
358
- if (stat && this.logger)
455
+ if (this.logStat && this.logger)
359
456
  this.logger.info(
360
457
  `Tree stat (error, after ${Date.now() - lastUpdate}ms): ${JSON.stringify(stat)}`,
361
458
  );
@@ -402,7 +499,7 @@ export class SynchronizedTreeState {
402
499
  // Phase 2: optional remainder up to pollingInterval — interruptible by
403
500
  // scheduleOnNextState so that an external nudge wakes the loop promptly.
404
501
  if (this.scheduledOnNextState.length === 0) {
405
- const remaining = Math.max(0, this.pollingInterval - MIN_POLLING_INTERVAL_MS);
502
+ const remaining = Math.max(0, this.effectivePollingInterval - MIN_POLLING_INTERVAL_MS);
406
503
  if (remaining > 0) {
407
504
  try {
408
505
  this.currentLoopDelayInterrupt = new AbortController();