@milaboratories/pl-tree 1.15.6 → 1.16.0
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/accessors.cjs +6 -2
- package/dist/accessors.cjs.map +1 -1
- package/dist/accessors.d.ts.map +1 -1
- package/dist/accessors.js +6 -2
- package/dist/accessors.js.map +1 -1
- package/dist/delta_sync.cjs +18 -8
- package/dist/delta_sync.cjs.map +1 -1
- package/dist/delta_sync.js +18 -8
- package/dist/delta_sync.js.map +1 -1
- package/dist/state.cjs +108 -30
- package/dist/state.cjs.map +1 -1
- package/dist/state.d.ts +27 -2
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +108 -30
- package/dist/state.js.map +1 -1
- package/dist/sync.cjs +2 -1
- package/dist/sync.cjs.map +1 -1
- package/dist/sync.d.ts +2 -0
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +2 -1
- package/dist/sync.js.map +1 -1
- package/dist/synchronized_tree.cjs +46 -9
- package/dist/synchronized_tree.cjs.map +1 -1
- package/dist/synchronized_tree.d.ts +6 -0
- package/dist/synchronized_tree.d.ts.map +1 -1
- package/dist/synchronized_tree.js +47 -10
- package/dist/synchronized_tree.js.map +1 -1
- package/package.json +6 -6
- package/src/accessors.test.ts +108 -0
- package/src/accessors.ts +8 -3
- package/src/delta_sync.test.ts +88 -3
- package/src/delta_sync.ts +19 -8
- package/src/delta_sync_live.test.ts +3 -4
- package/src/state.test.ts +397 -2
- package/src/state.ts +137 -46
- package/src/state_notifications.model.test.ts +264 -0
- package/src/sync.ts +6 -3
- package/src/synchronized_tree.test.ts +550 -1
- package/src/synchronized_tree.ts +102 -20
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { test, expect } from "vitest";
|
|
2
|
-
import { field, TestHelpers } from "@milaboratories/pl-client";
|
|
2
|
+
import { DefaultFinalResourceDataPredicate, field, TestHelpers } from "@milaboratories/pl-client";
|
|
3
|
+
import type {
|
|
4
|
+
FinalResourceDataPredicate,
|
|
5
|
+
PlClient,
|
|
6
|
+
SignedResourceId,
|
|
7
|
+
} from "@milaboratories/pl-client";
|
|
3
8
|
import { TestStructuralResourceType1 } from "./test_utils";
|
|
4
9
|
import { Computable } from "@milaboratories/computable";
|
|
5
10
|
import { SynchronizedTreeState } from "./synchronized_tree";
|
|
@@ -235,3 +240,547 @@ test("termination test", async () => {
|
|
|
235
240
|
expect((resultAfterTermination as any).errors[0].message).toMatch("terminated");
|
|
236
241
|
});
|
|
237
242
|
});
|
|
243
|
+
|
|
244
|
+
/** A fresh resource (a test struct, or a unique json/object value per `make`) hung under the
|
|
245
|
+
* client root; removing that field makes the backend collect it. */
|
|
246
|
+
async function createRootUnderClientRoot(
|
|
247
|
+
pl: PlClient,
|
|
248
|
+
fieldName: string,
|
|
249
|
+
make: "struct" | "jsonValue" = "struct",
|
|
250
|
+
): Promise<SignedResourceId> {
|
|
251
|
+
return await pl.withWriteTx(
|
|
252
|
+
"CreatingRoot",
|
|
253
|
+
async (tx) => {
|
|
254
|
+
const r =
|
|
255
|
+
make === "struct"
|
|
256
|
+
? tx.createStruct(TestStructuralResourceType1)
|
|
257
|
+
: tx.createValue(
|
|
258
|
+
{ name: "json/object", version: "1" },
|
|
259
|
+
// unique content: Values are deduplicated by content
|
|
260
|
+
Buffer.from(JSON.stringify({ nonce: `${Date.now()}-${Math.random()}` })),
|
|
261
|
+
);
|
|
262
|
+
const f = field(tx.clientRoot, fieldName);
|
|
263
|
+
tx.createField(f, "Dynamic");
|
|
264
|
+
tx.setField(f, r);
|
|
265
|
+
await tx.commit();
|
|
266
|
+
return await r.globalId;
|
|
267
|
+
},
|
|
268
|
+
{ sync: true },
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function deleteAndAwaitGone(pl: PlClient, fieldName: string, rid: SignedResourceId) {
|
|
273
|
+
await pl.withWriteTx(
|
|
274
|
+
"DeletingRoot",
|
|
275
|
+
async (tx) => {
|
|
276
|
+
tx.removeField(field(tx.clientRoot, fieldName));
|
|
277
|
+
await tx.commit();
|
|
278
|
+
},
|
|
279
|
+
{ sync: true },
|
|
280
|
+
);
|
|
281
|
+
// the backend collects the resource asynchronously once its last reference is gone
|
|
282
|
+
for (let i = 0; i < 100; i++) {
|
|
283
|
+
const gone = await pl.withReadTx(
|
|
284
|
+
"CheckingRoot",
|
|
285
|
+
async (tx) => (await tx.getResourceDataIfExists(rid, false)) === undefined,
|
|
286
|
+
);
|
|
287
|
+
if (gone) return;
|
|
288
|
+
await tp.setTimeout(100);
|
|
289
|
+
}
|
|
290
|
+
throw new Error("root was not deleted by the backend");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
test("a deleted root leaves the tree and its readers see it gone", async () => {
|
|
294
|
+
await TestHelpers.withTempRoot(async (pl) => {
|
|
295
|
+
const root = await createRootUnderClientRoot(pl, "deletedRoot");
|
|
296
|
+
const tree = await SynchronizedTreeState.init(pl, root, {
|
|
297
|
+
stopPollingDelay: 50,
|
|
298
|
+
pollingInterval: 10,
|
|
299
|
+
});
|
|
300
|
+
try {
|
|
301
|
+
const reader = Computable.make((c) => c.accessor(tree.entry()).node().resourceType.name);
|
|
302
|
+
expect(await reader.getValue()).toBe(TestStructuralResourceType1.name);
|
|
303
|
+
|
|
304
|
+
const generation = tree.changeGeneration;
|
|
305
|
+
await deleteAndAwaitGone(pl, "deletedRoot", root);
|
|
306
|
+
await tree.refreshState();
|
|
307
|
+
|
|
308
|
+
expect(tree.dumpState().some((r) => r.id === root)).toBe(false);
|
|
309
|
+
// the tree moved, so a holder persisting it on change writes the emptied mirror
|
|
310
|
+
expect(tree.changeGeneration).toBeGreaterThan(generation);
|
|
311
|
+
expect(reader.isChanged()).toBe(true);
|
|
312
|
+
await expect(reader.getValue()).rejects.toThrow(/not found/);
|
|
313
|
+
|
|
314
|
+
// and the tree keeps polling without error
|
|
315
|
+
await tree.refreshState();
|
|
316
|
+
} finally {
|
|
317
|
+
await tree.terminate();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}, 60_000);
|
|
321
|
+
|
|
322
|
+
test("with rootsNeverFinal, a deleted root the predicate calls final leaves the tree", async () => {
|
|
323
|
+
await TestHelpers.withTempRoot(async (pl) => {
|
|
324
|
+
// json/object is always final in the default predicate
|
|
325
|
+
const root = await createRootUnderClientRoot(pl, "deletedFinalRoot", "jsonValue");
|
|
326
|
+
const tree = await SynchronizedTreeState.init(pl, root, {
|
|
327
|
+
stopPollingDelay: 50,
|
|
328
|
+
pollingInterval: 10,
|
|
329
|
+
rootsNeverFinal: true,
|
|
330
|
+
});
|
|
331
|
+
try {
|
|
332
|
+
const isFinal = Computable.make((c) => c.accessor(tree.entry()).node().getIsFinal());
|
|
333
|
+
expect(await isFinal.getValue()).toBe(false);
|
|
334
|
+
|
|
335
|
+
await deleteAndAwaitGone(pl, "deletedFinalRoot", root);
|
|
336
|
+
await tree.refreshState();
|
|
337
|
+
expect(tree.dumpState().some((r) => r.id === root)).toBe(false);
|
|
338
|
+
} finally {
|
|
339
|
+
await tree.terminate();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
}, 60_000);
|
|
343
|
+
|
|
344
|
+
test("a persistent update error rebuilds with backoff, and a successful read resets the backoff", async () => {
|
|
345
|
+
await TestHelpers.withTempRoot(async (pl) => {
|
|
346
|
+
const root = await createRootUnderClientRoot(pl, "rebuildBackoffRoot");
|
|
347
|
+
|
|
348
|
+
// Throws on every resource while `failing`, so every rebuild's full read fails the same way.
|
|
349
|
+
let failing = false;
|
|
350
|
+
const failures: number[] = [];
|
|
351
|
+
const predicate: FinalResourceDataPredicate = (r) => {
|
|
352
|
+
if (failing) {
|
|
353
|
+
failures.push(Date.now());
|
|
354
|
+
throw new Error("predicate failure");
|
|
355
|
+
}
|
|
356
|
+
return DefaultFinalResourceDataPredicate(r);
|
|
357
|
+
};
|
|
358
|
+
const tree = await SynchronizedTreeState.init(pl, root, {
|
|
359
|
+
stopPollingDelay: 60_000,
|
|
360
|
+
pollingInterval: 10,
|
|
361
|
+
finalPredicateOverride: predicate,
|
|
362
|
+
});
|
|
363
|
+
const touch = async (value: string) =>
|
|
364
|
+
await pl.withWriteTx(
|
|
365
|
+
"Touch",
|
|
366
|
+
async (tx) => {
|
|
367
|
+
tx.setKValue(root, "k", Buffer.from(value));
|
|
368
|
+
await tx.commit();
|
|
369
|
+
},
|
|
370
|
+
{ sync: true },
|
|
371
|
+
);
|
|
372
|
+
try {
|
|
373
|
+
await tree.refreshState();
|
|
374
|
+
|
|
375
|
+
failing = true;
|
|
376
|
+
await touch("1");
|
|
377
|
+
await tree.refreshState().catch(() => {});
|
|
378
|
+
await tp.setTimeout(1500);
|
|
379
|
+
// ~100, 200, 400, 800 ms apart (±20% jitter): about 5 reads in 1.5 s, 6 allowed for
|
|
380
|
+
// timing slack. Without the wait the loop re-reads at the speed of the link.
|
|
381
|
+
const persistent = failures.length;
|
|
382
|
+
expect(persistent).toBeGreaterThanOrEqual(2);
|
|
383
|
+
expect(persistent).toBeLessThanOrEqual(6);
|
|
384
|
+
|
|
385
|
+
// recovers once the error goes away
|
|
386
|
+
failing = false;
|
|
387
|
+
await tree.refreshState();
|
|
388
|
+
expect(tree.dumpState().some((r) => r.id === root)).toBe(true);
|
|
389
|
+
|
|
390
|
+
// and a new error starts from the initial delay again, not from where the last one left
|
|
391
|
+
const start = failures.length;
|
|
392
|
+
failing = true;
|
|
393
|
+
await touch("2");
|
|
394
|
+
await tree.refreshState().catch(() => {});
|
|
395
|
+
await tp.setTimeout(500);
|
|
396
|
+
expect(failures.length - start).toBeGreaterThanOrEqual(2);
|
|
397
|
+
failing = false;
|
|
398
|
+
} finally {
|
|
399
|
+
await tree.terminate();
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}, 60_000);
|
|
403
|
+
|
|
404
|
+
test("an MTW field deleted and recreated as Input between polls is applied", async () => {
|
|
405
|
+
await TestHelpers.withTempRoot(async (pl) => {
|
|
406
|
+
const root = await pl.withWriteTx(
|
|
407
|
+
"SeedingMtwField",
|
|
408
|
+
async (tx) => {
|
|
409
|
+
const r = tx.createStruct(TestStructuralResourceType1);
|
|
410
|
+
const rf = field(tx.clientRoot, "recreatedFieldRoot");
|
|
411
|
+
tx.createField(rf, "Dynamic");
|
|
412
|
+
tx.setField(rf, r);
|
|
413
|
+
tx.createField(field(r, "g"), "MTW");
|
|
414
|
+
await tx.commit();
|
|
415
|
+
return await r.globalId;
|
|
416
|
+
},
|
|
417
|
+
{ sync: true },
|
|
418
|
+
);
|
|
419
|
+
const tree = await SynchronizedTreeState.init(pl, root, {
|
|
420
|
+
stopPollingDelay: 50,
|
|
421
|
+
pollingInterval: 10,
|
|
422
|
+
});
|
|
423
|
+
try {
|
|
424
|
+
const inputs = Computable.make((c) => c.accessor(tree.entry()).node().listInputFields());
|
|
425
|
+
expect(await inputs.getValue()).toEqual([]);
|
|
426
|
+
|
|
427
|
+
await pl.withWriteTx(
|
|
428
|
+
"RecreatingField",
|
|
429
|
+
async (tx) => {
|
|
430
|
+
tx.removeField(field(root, "g"));
|
|
431
|
+
tx.createField(field(root, "g"), "Input");
|
|
432
|
+
await tx.commit();
|
|
433
|
+
},
|
|
434
|
+
{ sync: true },
|
|
435
|
+
);
|
|
436
|
+
await tree.refreshState();
|
|
437
|
+
|
|
438
|
+
const g = tree
|
|
439
|
+
.dumpState()
|
|
440
|
+
.find((r) => r.id === root)
|
|
441
|
+
?.fields.find((f) => f.name === "g");
|
|
442
|
+
expect(g?.type).toBe("Input");
|
|
443
|
+
expect(inputs.isChanged()).toBe(true);
|
|
444
|
+
expect(await inputs.getValue()).toEqual(["g"]);
|
|
445
|
+
} finally {
|
|
446
|
+
await tree.terminate();
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
}, 60_000);
|
|
450
|
+
|
|
451
|
+
async function touch(pl: PlClient, rid: SignedResourceId) {
|
|
452
|
+
await pl.withWriteTx(
|
|
453
|
+
"Touch",
|
|
454
|
+
async (tx) => {
|
|
455
|
+
tx.setKValue(rid, "touched", Buffer.from(`${Date.now()}-${Math.random()}`));
|
|
456
|
+
await tx.commit();
|
|
457
|
+
},
|
|
458
|
+
{ sync: true },
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function removeClientRootField(pl: PlClient, fieldName: string) {
|
|
463
|
+
await pl.withWriteTx(
|
|
464
|
+
"Removing",
|
|
465
|
+
async (tx) => {
|
|
466
|
+
tx.removeField(field(tx.clientRoot, fieldName));
|
|
467
|
+
await tx.commit();
|
|
468
|
+
},
|
|
469
|
+
{ sync: true },
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function awaitGone(pl: PlClient, rid: SignedResourceId) {
|
|
474
|
+
for (let i = 0; i < 100; i++) {
|
|
475
|
+
const gone = await pl.withReadTx(
|
|
476
|
+
"Checking",
|
|
477
|
+
async (tx) => (await tx.getResourceDataIfExists(rid, false)) === undefined,
|
|
478
|
+
);
|
|
479
|
+
if (gone) return;
|
|
480
|
+
await tp.setTimeout(100);
|
|
481
|
+
}
|
|
482
|
+
throw new Error("resource was not deleted by the backend");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const traversalModes: TraversalMode[] = ["backend-delta", "backend-streaming", "client-bfs"];
|
|
486
|
+
for (const mode of traversalModes)
|
|
487
|
+
test(`a deleted root is dropped under ${mode}`, async () => {
|
|
488
|
+
// under every loading algorithm a walk seeded at a deleted resource yields nothing and no
|
|
489
|
+
// error, so only the existence check can notice the root is gone
|
|
490
|
+
await TestHelpers.withTempRoot(async (pl) => {
|
|
491
|
+
const name = `deletedRoot_${mode}`;
|
|
492
|
+
const root = await createRootUnderClientRoot(pl, name);
|
|
493
|
+
const tree = await SynchronizedTreeState.init(pl, root, {
|
|
494
|
+
stopPollingDelay: 50,
|
|
495
|
+
pollingInterval: 10,
|
|
496
|
+
traversalMode: mode,
|
|
497
|
+
});
|
|
498
|
+
try {
|
|
499
|
+
await removeClientRootField(pl, name);
|
|
500
|
+
await awaitGone(pl, root);
|
|
501
|
+
await tree.refreshState();
|
|
502
|
+
expect(tree.dumpState().some((r) => r.id === root)).toBe(false);
|
|
503
|
+
await tree.refreshState();
|
|
504
|
+
} finally {
|
|
505
|
+
await tree.terminate();
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
}, 60_000);
|
|
509
|
+
|
|
510
|
+
for (const holderFirst of [false, true])
|
|
511
|
+
test(`a deleted root held by another deleted root leaves in the same poll (${holderFirst ? "holder" : "held"} first)`, async () => {
|
|
512
|
+
await TestHelpers.withTempRoot(async (pl) => {
|
|
513
|
+
const { a, b } = await pl.withWriteTx(
|
|
514
|
+
"Chain",
|
|
515
|
+
async (tx) => {
|
|
516
|
+
const ra = tx.createStruct(TestStructuralResourceType1);
|
|
517
|
+
const rb = tx.createStruct(TestStructuralResourceType1);
|
|
518
|
+
const f = field(tx.clientRoot, "deletedChain");
|
|
519
|
+
tx.createField(f, "Dynamic");
|
|
520
|
+
tx.setField(f, ra);
|
|
521
|
+
tx.createField(field(ra, "child"), "Dynamic");
|
|
522
|
+
tx.setField(field(ra, "child"), rb);
|
|
523
|
+
await tx.commit();
|
|
524
|
+
return { a: await ra.globalId, b: await rb.globalId };
|
|
525
|
+
},
|
|
526
|
+
{ sync: true },
|
|
527
|
+
);
|
|
528
|
+
const roots = [a, b].map((root) => ({ kind: "resource" as const, root }));
|
|
529
|
+
const tree = await SynchronizedTreeState.init(pl, holderFirst ? roots : roots.reverse(), {
|
|
530
|
+
stopPollingDelay: 50,
|
|
531
|
+
pollingInterval: 10,
|
|
532
|
+
});
|
|
533
|
+
try {
|
|
534
|
+
await removeClientRootField(pl, "deletedChain");
|
|
535
|
+
await awaitGone(pl, a);
|
|
536
|
+
await awaitGone(pl, b);
|
|
537
|
+
await tree.refreshState();
|
|
538
|
+
const ids = tree.dumpState().map((r) => r.id);
|
|
539
|
+
expect(ids).not.toContain(a);
|
|
540
|
+
expect(ids).not.toContain(b);
|
|
541
|
+
} finally {
|
|
542
|
+
await tree.terminate();
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
}, 60_000);
|
|
546
|
+
|
|
547
|
+
for (const rebuiltFirst of [true, false])
|
|
548
|
+
test(`a plain error ${rebuiltFirst ? "after a rebuild" : "with no rebuild before it"} is retried at the polling interval`, async () => {
|
|
549
|
+
await TestHelpers.withTempRoot(async (pl) => {
|
|
550
|
+
const root = await createRootUnderClientRoot(pl, "plainErrorRoot");
|
|
551
|
+
// with rebuiltFirst, one predicate throw forces a rebuild first; in both variants the
|
|
552
|
+
// pruning function then throws a plain Error (outside the update, so not a
|
|
553
|
+
// TreeStateUpdateError) on every poll
|
|
554
|
+
let predicateThrowsOnce = false;
|
|
555
|
+
let pruneFailing = false;
|
|
556
|
+
const pruneFailures: number[] = [];
|
|
557
|
+
const predicate: FinalResourceDataPredicate = (r) => {
|
|
558
|
+
if (predicateThrowsOnce) {
|
|
559
|
+
predicateThrowsOnce = false;
|
|
560
|
+
pruneFailing = true;
|
|
561
|
+
throw new Error("one-off predicate failure");
|
|
562
|
+
}
|
|
563
|
+
return DefaultFinalResourceDataPredicate(r);
|
|
564
|
+
};
|
|
565
|
+
const pruning = (r: ExtendedResourceData): FieldData[] => {
|
|
566
|
+
if (pruneFailing) {
|
|
567
|
+
pruneFailures.push(Date.now());
|
|
568
|
+
throw new Error("walk failure");
|
|
569
|
+
}
|
|
570
|
+
return r.fields;
|
|
571
|
+
};
|
|
572
|
+
const tree = await SynchronizedTreeState.init(pl, root, {
|
|
573
|
+
stopPollingDelay: 60_000,
|
|
574
|
+
pollingInterval: 1000,
|
|
575
|
+
finalPredicateOverride: predicate,
|
|
576
|
+
pruning,
|
|
577
|
+
});
|
|
578
|
+
try {
|
|
579
|
+
await tree.refreshState();
|
|
580
|
+
if (rebuiltFirst) predicateThrowsOnce = true;
|
|
581
|
+
else pruneFailing = true;
|
|
582
|
+
await touch(pl, root);
|
|
583
|
+
await tree.refreshState().catch(() => {});
|
|
584
|
+
const start = pruneFailures.length;
|
|
585
|
+
await tp.setTimeout(2000);
|
|
586
|
+
// one attempt per polling interval, give or take one
|
|
587
|
+
expect(new Set(pruneFailures.slice(start)).size).toBeLessThanOrEqual(3);
|
|
588
|
+
} finally {
|
|
589
|
+
pruneFailing = false;
|
|
590
|
+
await tree.terminate();
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
}, 60_000);
|
|
594
|
+
|
|
595
|
+
test("a rebuild re-runs a reader waiting for a resource the tree had not loaded", async () => {
|
|
596
|
+
await TestHelpers.withTempRoot(async (pl) => {
|
|
597
|
+
const root = await createRootUnderClientRoot(pl, "rebuildRoot");
|
|
598
|
+
const c = await createRootUnderClientRoot(pl, "rebuildC");
|
|
599
|
+
let throwOnce = false;
|
|
600
|
+
const predicate: FinalResourceDataPredicate = (r) => {
|
|
601
|
+
if (throwOnce) {
|
|
602
|
+
throwOnce = false;
|
|
603
|
+
throw new Error("one-off predicate failure");
|
|
604
|
+
}
|
|
605
|
+
return DefaultFinalResourceDataPredicate(r);
|
|
606
|
+
};
|
|
607
|
+
const tree = await SynchronizedTreeState.init(pl, root, {
|
|
608
|
+
stopPollingDelay: 60_000,
|
|
609
|
+
pollingInterval: 10,
|
|
610
|
+
finalPredicateOverride: predicate,
|
|
611
|
+
});
|
|
612
|
+
try {
|
|
613
|
+
const reader = Computable.make((ctx) => ctx.accessor(tree.entry(c)).node().resourceType.name);
|
|
614
|
+
await expect(reader.getValue()).rejects.toThrow(/not found/);
|
|
615
|
+
|
|
616
|
+
throwOnce = true;
|
|
617
|
+
await touch(pl, root);
|
|
618
|
+
await tree.refreshState().catch(() => {});
|
|
619
|
+
expect(throwOnce).toBe(false);
|
|
620
|
+
|
|
621
|
+
await pl.withWriteTx(
|
|
622
|
+
"Attaching",
|
|
623
|
+
async (tx) => {
|
|
624
|
+
tx.createField(field(root, "c"), "Dynamic");
|
|
625
|
+
tx.setField(field(root, "c"), c);
|
|
626
|
+
await tx.commit();
|
|
627
|
+
},
|
|
628
|
+
{ sync: true },
|
|
629
|
+
);
|
|
630
|
+
await tree.refreshState();
|
|
631
|
+
await tree.refreshState();
|
|
632
|
+
expect(reader.isChanged()).toBe(true);
|
|
633
|
+
expect(await reader.getValue()).toBe(TestStructuralResourceType1.name);
|
|
634
|
+
} finally {
|
|
635
|
+
await tree.terminate();
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
}, 60_000);
|
|
639
|
+
|
|
640
|
+
test("terminate rejects a refresh still waiting for its poll", async () => {
|
|
641
|
+
await TestHelpers.withTempRoot(async (pl) => {
|
|
642
|
+
const root = await createRootUnderClientRoot(pl, "terminateRoot");
|
|
643
|
+
let failing = false;
|
|
644
|
+
const predicate: FinalResourceDataPredicate = (r) => {
|
|
645
|
+
if (failing) throw new Error("predicate failure");
|
|
646
|
+
return DefaultFinalResourceDataPredicate(r);
|
|
647
|
+
};
|
|
648
|
+
const tree = await SynchronizedTreeState.init(pl, root, {
|
|
649
|
+
stopPollingDelay: 60_000,
|
|
650
|
+
pollingInterval: 10,
|
|
651
|
+
finalPredicateOverride: predicate,
|
|
652
|
+
});
|
|
653
|
+
await tree.refreshState();
|
|
654
|
+
failing = true;
|
|
655
|
+
await touch(pl, root);
|
|
656
|
+
await tree.refreshState().catch(() => {});
|
|
657
|
+
// after the failed read the loop waits out the floor, which a refresh does not cut
|
|
658
|
+
// short, so this refresh is still queued when terminate runs
|
|
659
|
+
let outcome = "pending";
|
|
660
|
+
void tree.refreshState().then(
|
|
661
|
+
() => (outcome = "resolved"),
|
|
662
|
+
() => (outcome = "rejected"),
|
|
663
|
+
);
|
|
664
|
+
await tree.terminate();
|
|
665
|
+
await tp.setTimeout(10);
|
|
666
|
+
expect(outcome).toBe("rejected");
|
|
667
|
+
});
|
|
668
|
+
}, 60_000);
|
|
669
|
+
|
|
670
|
+
test("a refresh request cuts a rebuild backoff short", async () => {
|
|
671
|
+
await TestHelpers.withTempRoot(async (pl) => {
|
|
672
|
+
const root = await createRootUnderClientRoot(pl, "rebuildNudgeRoot");
|
|
673
|
+
let failing = false;
|
|
674
|
+
const failures: number[] = [];
|
|
675
|
+
const predicate: FinalResourceDataPredicate = (r) => {
|
|
676
|
+
if (failing) {
|
|
677
|
+
failures.push(Date.now());
|
|
678
|
+
throw new Error("predicate failure");
|
|
679
|
+
}
|
|
680
|
+
return DefaultFinalResourceDataPredicate(r);
|
|
681
|
+
};
|
|
682
|
+
const tree = await SynchronizedTreeState.init(pl, root, {
|
|
683
|
+
stopPollingDelay: 60_000,
|
|
684
|
+
pollingInterval: 10,
|
|
685
|
+
finalPredicateOverride: predicate,
|
|
686
|
+
});
|
|
687
|
+
try {
|
|
688
|
+
await tree.refreshState();
|
|
689
|
+
failing = true;
|
|
690
|
+
await touch(pl, root);
|
|
691
|
+
await tree.refreshState().catch(() => {});
|
|
692
|
+
// ~4 s in, the backoff has grown past 1.6 s: the loop's own next read is far away
|
|
693
|
+
await tp.setTimeout(4000);
|
|
694
|
+
const nudgedAt = Date.now();
|
|
695
|
+
await tree.refreshState().catch(() => {});
|
|
696
|
+
const next = failures.find((t) => t >= nudgedAt);
|
|
697
|
+
expect(next).toBeDefined();
|
|
698
|
+
// the 100 ms floor still holds, the rest of the backoff does not
|
|
699
|
+
expect(next! - nudgedAt).toBeLessThan(800);
|
|
700
|
+
} finally {
|
|
701
|
+
failing = false;
|
|
702
|
+
await tree.terminate();
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
}, 60_000);
|
|
706
|
+
|
|
707
|
+
/** A tree whose reads fail while `fail.on`, with the time of each failed read in `reads`.
|
|
708
|
+
* `capBackoff` fails enough consecutive rebuilds for the retry delay to reach its 5 s cap. */
|
|
709
|
+
async function treeWithFailingReads(pl: PlClient, fieldName: string) {
|
|
710
|
+
const root = await createRootUnderClientRoot(pl, fieldName);
|
|
711
|
+
const fail = { on: false };
|
|
712
|
+
const reads: number[] = [];
|
|
713
|
+
const predicate: FinalResourceDataPredicate = (r) => {
|
|
714
|
+
if (fail.on) {
|
|
715
|
+
reads.push(performance.now());
|
|
716
|
+
throw new Error("predicate failure");
|
|
717
|
+
}
|
|
718
|
+
return DefaultFinalResourceDataPredicate(r);
|
|
719
|
+
};
|
|
720
|
+
const tree = await SynchronizedTreeState.init(pl, root, {
|
|
721
|
+
stopPollingDelay: 60_000,
|
|
722
|
+
pollingInterval: 10,
|
|
723
|
+
finalPredicateOverride: predicate,
|
|
724
|
+
});
|
|
725
|
+
const capBackoff = async () => {
|
|
726
|
+
await tree.refreshState();
|
|
727
|
+
fail.on = true;
|
|
728
|
+
await touch(pl, root);
|
|
729
|
+
// Each refresh cuts the wait short but the retry state keeps growing: at minimum jitter
|
|
730
|
+
// the eighth consecutive failure sits at the 5 s cap.
|
|
731
|
+
for (let i = 0; i < 8; i++) await tree.refreshState().catch(() => {});
|
|
732
|
+
};
|
|
733
|
+
return { tree, fail, reads, capBackoff };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
test("terminate rejects every queued refresh and starts no read, after a refresh cut the backoff short", async () => {
|
|
737
|
+
await TestHelpers.withTempRoot(async (pl) => {
|
|
738
|
+
const { tree, fail, reads, capBackoff } = await treeWithFailingReads(pl, "terminateNudgedRoot");
|
|
739
|
+
try {
|
|
740
|
+
await capBackoff();
|
|
741
|
+
// past the floor, inside the 5 s backoff: the loop is waiting on the interruptible part
|
|
742
|
+
await tp.setTimeout(150);
|
|
743
|
+
const readsBefore = reads.length;
|
|
744
|
+
const queued = Array.from({ length: 16 }, () =>
|
|
745
|
+
expect(tree.refreshState()).rejects.toThrow(/terminated/),
|
|
746
|
+
);
|
|
747
|
+
const started = performance.now();
|
|
748
|
+
await tree.terminate();
|
|
749
|
+
await Promise.all(queued);
|
|
750
|
+
// termination does not wait out the backoff
|
|
751
|
+
expect(performance.now() - started).toBeLessThan(800);
|
|
752
|
+
await tp.setTimeout(150);
|
|
753
|
+
expect(reads.length).toBe(readsBefore);
|
|
754
|
+
await expect(tree.refreshState()).rejects.toThrow(/terminated/);
|
|
755
|
+
} finally {
|
|
756
|
+
fail.on = false;
|
|
757
|
+
await tree.terminate();
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
}, 60_000);
|
|
761
|
+
|
|
762
|
+
test("bursts of concurrent refreshes read once each, at least the floor apart, and leave no hot loop", async () => {
|
|
763
|
+
await TestHelpers.withTempRoot(async (pl) => {
|
|
764
|
+
const { tree, fail, reads, capBackoff } = await treeWithFailingReads(pl, "refreshBurstRoot");
|
|
765
|
+
try {
|
|
766
|
+
await capBackoff();
|
|
767
|
+
for (let burst = 0; burst < 6; burst++) {
|
|
768
|
+
const before = reads.length;
|
|
769
|
+
const previous = reads[before - 1]!;
|
|
770
|
+
await Promise.all(
|
|
771
|
+
Array.from({ length: 32 }, () => expect(tree.refreshState()).rejects.toThrow()),
|
|
772
|
+
);
|
|
773
|
+
expect(reads.length - before).toBe(1);
|
|
774
|
+
// the 100 ms floor, less timer rounding
|
|
775
|
+
expect(reads[before]! - previous).toBeGreaterThanOrEqual(90);
|
|
776
|
+
}
|
|
777
|
+
// once callers stop, the loop returns to its backoff rather than spinning
|
|
778
|
+
const settled = reads.length;
|
|
779
|
+
await tp.setTimeout(900);
|
|
780
|
+
expect(reads.length).toBe(settled);
|
|
781
|
+
} finally {
|
|
782
|
+
fail.on = false;
|
|
783
|
+
await tree.terminate();
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
}, 60_000);
|