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