@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.
@@ -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);