@milaboratories/pl-tree 1.13.5 → 1.13.7
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 +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/synchronized_tree.cjs +77 -15
- package/dist/synchronized_tree.cjs.map +1 -1
- package/dist/synchronized_tree.d.ts +24 -1
- package/dist/synchronized_tree.d.ts.map +1 -1
- package/dist/synchronized_tree.js +77 -16
- package/dist/synchronized_tree.js.map +1 -1
- package/package.json +6 -6
- package/src/polling_interval.test.ts +90 -0
- package/src/synchronized_tree.ts +114 -17
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
|
|
26
|
-
*
|
|
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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* of noticing a new/removed share
|
|
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
|
|
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 =
|
|
255
|
+
let stat = require_sync.initialTreeLoadingStat();
|
|
199
256
|
let lastUpdate = Date.now();
|
|
200
|
-
let
|
|
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 &&
|
|
211
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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":";;;;;;;
|
|
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
|
|
24
|
-
*
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* of noticing a new/removed share
|
|
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
|
|
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 =
|
|
253
|
+
let stat = initialTreeLoadingStat();
|
|
197
254
|
let lastUpdate = Date.now();
|
|
198
|
-
let
|
|
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 &&
|
|
209
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
3
|
+
"version": "1.13.7",
|
|
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",
|
|
24
25
|
"@milaboratories/ts-helpers": "1.8.6",
|
|
25
|
-
"@milaboratories/pl-
|
|
26
|
-
"@milaboratories/pl-
|
|
27
|
-
"@milaboratories/computable": "2.9.8"
|
|
26
|
+
"@milaboratories/pl-client": "3.14.7",
|
|
27
|
+
"@milaboratories/pl-errors": "1.4.36"
|
|
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.
|
|
36
|
-
"@milaboratories/ts-configs": "1.
|
|
35
|
+
"@milaboratories/ts-builder": "1.7.0",
|
|
36
|
+
"@milaboratories/ts-configs": "1.4.0"
|
|
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
|
+
});
|
package/src/synchronized_tree.ts
CHANGED
|
@@ -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
|
|
79
|
-
*
|
|
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
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* of noticing a new/removed share
|
|
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
|
|
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
|
-
//
|
|
314
|
-
|
|
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
|
-
//
|
|
319
|
-
let
|
|
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 &&
|
|
430
|
+
if (this.sharedSeeds.length > 0 && Date.now() - lastDiscovery >= DISCOVERY_INTERVAL_MS) {
|
|
340
431
|
await this.discover();
|
|
432
|
+
lastDiscovery = Date.now();
|
|
341
433
|
}
|
|
342
|
-
|
|
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 (
|
|
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 (
|
|
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.
|
|
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();
|