@powerhousedao/switchboard 6.2.3-dev.50 → 6.2.3-dev.51

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.
@@ -16,7 +16,10 @@ import {
16
16
  type GraphQLManager,
17
17
  type PackagePieceEntry,
18
18
  } from "@powerhousedao/reactor-api";
19
- import { PieceRegistry } from "@powerhousedao/reactor-workflow";
19
+ import {
20
+ PieceRegistry,
21
+ type WorkflowRuntimeService,
22
+ } from "@powerhousedao/reactor-workflow";
20
23
  import {
21
24
  createRelationalDb,
22
25
  type IRelationalDb,
@@ -36,6 +39,9 @@ import {
36
39
  composeWorkflowRuntime,
37
40
  assertWorkflowPackageLoadable,
38
41
  hostPrincipalOf,
42
+ isWorkflowSingletonConflict,
43
+ retryWorkflowSingleton,
44
+ workflowSingletonLossHandler,
39
45
  reactorAccessOf,
40
46
  resolveWorkflowsEnabled,
41
47
  type BooleanFlagSource,
@@ -353,13 +359,16 @@ describe("composeWorkflowRuntime", () => {
353
359
 
354
360
  it("arms the webhooks and the supervisor on start, and stops them once", async () => {
355
361
  const webhooks = memoryWebhooks();
356
- const workflows = await compose(await buildReactorModule(), {
357
- webhooks: webhooks.scope,
358
- });
359
-
360
- // Intervals only, from here on: the supervisor's tick is the runtime's one.
362
+ const clientModule = await buildReactorModule();
363
+ // Intervals only, from here on: the singleton lease's heartbeat and the
364
+ // run-retention sweep from compose, then the supervisor's tick.
361
365
  vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
362
366
  try {
367
+ const workflows = await compose(clientModule, {
368
+ webhooks: webhooks.scope,
369
+ });
370
+ expect(vi.getTimerCount()).toBe(2);
371
+
363
372
  await workflows.start();
364
373
 
365
374
  expect(webhooks.families.map(({ name }) => name)).toEqual(["trigger"]);
@@ -367,7 +376,7 @@ describe("composeWorkflowRuntime", () => {
367
376
  await expect(
368
377
  webhooks.families[0]!.policyFor?.("wf-unknown"),
369
378
  ).resolves.toBeUndefined();
370
- expect(vi.getTimerCount()).toBe(1);
379
+ expect(vi.getTimerCount()).toBe(3);
371
380
 
372
381
  await workflows.stop();
373
382
  await workflows.stop();
@@ -387,6 +396,485 @@ describe("composeWorkflowRuntime", () => {
387
396
  await expect(failing).rejects.toThrow("@powerhousedao/reactor-workflow");
388
397
  await expect(failing).rejects.toMatchObject({ cause });
389
398
  });
399
+
400
+ // Placement (plan agreed decision 3): the claim is taken before the runtime
401
+ // exists and released on the way out. Distinct storage ids give the two
402
+ // composes distinct owner names, as two hosts would have.
403
+ function composeSecond(
404
+ clientModule: InProcessReactorClientModule,
405
+ relationalDb: IRelationalDb,
406
+ ): Promise<ComposedWorkflowRuntime> {
407
+ return composeWorkflowRuntime({
408
+ reactorClient: clientModule.client,
409
+ clientModule: {} as InProcessReactorClientModule,
410
+ relationalDb,
411
+ attachments: {} as never,
412
+ authorizationService: {} as never,
413
+ logger: stubLogger(),
414
+ storageId: "/srv/slot-b",
415
+ });
416
+ }
417
+
418
+ it("claims the workflow singleton and refuses a second owner until it is released", async () => {
419
+ const clientModule = await buildReactorModule();
420
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
421
+ const first = await compose(clientModule, {
422
+ relationalDb,
423
+ storageId: "/srv/slot-a",
424
+ });
425
+ expect(first.singletonOwner).toBeDefined();
426
+
427
+ const refused = await composeSecond(clientModule, relationalDb).catch(
428
+ (error: unknown) => error,
429
+ );
430
+ expect(isWorkflowSingletonConflict(refused)).toBe(true);
431
+
432
+ await first.stop();
433
+ const second = await composeSecond(clientModule, relationalDb);
434
+ try {
435
+ expect(second.singletonOwner).toBeDefined();
436
+ expect(second.singletonOwner).not.toBe(first.singletonOwner);
437
+ } finally {
438
+ await second.stop();
439
+ }
440
+ });
441
+
442
+ it("hands the claim back when composing fails after it", async () => {
443
+ const clientModule = await buildReactorModule();
444
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
445
+ const engine = await import("@powerhousedao/reactor-workflow");
446
+
447
+ await expect(
448
+ compose(clientModule, {
449
+ relationalDb,
450
+ storageId: "/srv/slot-a",
451
+ load: () =>
452
+ Promise.resolve({
453
+ ...engine,
454
+ createWorkflowRuntime: () => {
455
+ throw new Error("the runtime could not be built");
456
+ },
457
+ }),
458
+ }),
459
+ ).rejects.toThrow("the runtime could not be built");
460
+
461
+ const next = await composeSecond(clientModule, relationalDb);
462
+ try {
463
+ expect(next.singletonOwner).toBeDefined();
464
+ } finally {
465
+ await next.stop();
466
+ }
467
+ });
468
+
469
+ // A rolling deploy under one stable owner name: the next pod's claim wins,
470
+ // and this one must stop running workflows rather than become a second
471
+ // writer. There is no re-claim; it stays down until restarted.
472
+ it("shuts the runtime down when a newer claim takes the singleton", async () => {
473
+ const clientModule = await buildReactorModule();
474
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
475
+ const engine = await import("@powerhousedao/reactor-workflow");
476
+ const lost = vi.fn();
477
+ let newer:
478
+ | Awaited<ReturnType<typeof engine.acquireWorkflowSingletonLease>>
479
+ | undefined;
480
+ // The lease measures its silence on performance.now().
481
+ vi.useFakeTimers({
482
+ toFake: ["setInterval", "clearInterval", "performance"],
483
+ });
484
+ try {
485
+ const workflows = await compose(clientModule, {
486
+ relationalDb,
487
+ storageId: "/srv/slot-a",
488
+ onSingletonLost: lost,
489
+ });
490
+ await workflows.start();
491
+ expect(workflows.triggers).toEqual({ status: "available" });
492
+
493
+ // The older holder went quiet, so its own slot may take over.
494
+ const leaseDb = await relationalDb.createNamespace<{
495
+ workflow_singleton: { heartbeat_at: Date };
496
+ }>("workflow_runtime");
497
+ await leaseDb
498
+ .updateTable("workflow_singleton")
499
+ .set({ heartbeat_at: new Date(Date.now() - 3_600_000) })
500
+ .execute();
501
+ newer = await engine.acquireWorkflowSingletonLease({
502
+ relationalDb,
503
+ storageId: "/srv/slot-a",
504
+ logger: stubLogger(),
505
+ });
506
+ expect(newer.owner).toBe(workflows.singletonOwner);
507
+
508
+ await vi.advanceTimersByTimeAsync(engine.SINGLETON_HEARTBEAT_MS);
509
+ await vi.waitFor(() =>
510
+ expect(lost).toHaveBeenCalledWith({
511
+ reason: "taken",
512
+ heldBy: newer!.owner,
513
+ }),
514
+ );
515
+ expect(lost).toHaveBeenCalledTimes(1);
516
+ expect(workflows.triggers).toEqual({
517
+ status: "unavailable",
518
+ reason: "workflow-singleton-lost",
519
+ });
520
+ // Only the newer claim's heartbeat is left running.
521
+ expect(vi.getTimerCount()).toBe(1);
522
+
523
+ await workflows.start();
524
+ expect(vi.getTimerCount()).toBe(1);
525
+
526
+ await workflows.stop();
527
+ expect(await newer.heartbeat()).toBe(true);
528
+ } finally {
529
+ await newer?.release();
530
+ vi.useRealTimers();
531
+ }
532
+ });
533
+
534
+ it("shuts the runtime down before handing the claim back when composing fails", async () => {
535
+ const clientModule = await buildReactorModule();
536
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
537
+ const engine = await import("@powerhousedao/reactor-workflow");
538
+ const shutdowns: ReturnType<typeof vi.fn>[] = [];
539
+
540
+ await expect(
541
+ compose(clientModule, {
542
+ relationalDb,
543
+ storageId: "/srv/slot-a",
544
+ load: () =>
545
+ Promise.resolve({
546
+ ...engine,
547
+ createWorkflowRuntime: (host) => {
548
+ const runtime = engine.createWorkflowRuntime(host);
549
+ shutdowns.push(vi.spyOn(runtime, "shutdown"));
550
+ return runtime;
551
+ },
552
+ WorkflowTriggersReadModel: class {
553
+ constructor() {
554
+ throw new Error("the read model could not be built");
555
+ }
556
+ } as never,
557
+ }),
558
+ }),
559
+ ).rejects.toThrow("the read model could not be built");
560
+
561
+ expect(shutdowns).toHaveLength(1);
562
+ expect(shutdowns[0]).toHaveBeenCalledOnce();
563
+ const next = await composeSecond(clientModule, relationalDb);
564
+ await next.stop();
565
+ });
566
+
567
+ const triggerModels = (clientModule: InProcessReactorClientModule) =>
568
+ clientModule.reactorModule!.readModelCoordinator.readModels.filter(
569
+ ({ name }) => name === "workflow-triggers",
570
+ );
571
+
572
+ // The retry path composes again in the same process, against the same
573
+ // coordinator: a registration the failed attempt left behind blocks it.
574
+ it("composes again after a compose that failed past registering the trigger read model", async () => {
575
+ const clientModule = await buildReactorModule();
576
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
577
+
578
+ await expect(
579
+ compose(clientModule, {
580
+ relationalDb,
581
+ http: {
582
+ baseUrl: "https://host.test/workflow",
583
+ get: () => {
584
+ throw new Error("the callback route could not be mounted");
585
+ },
586
+ } as never,
587
+ }),
588
+ ).rejects.toThrow("the callback route could not be mounted");
589
+ expect(triggerModels(clientModule)).toHaveLength(0);
590
+
591
+ const again = await compose(clientModule, { relationalDb });
592
+ expect(again.triggers).toEqual({ status: "available" });
593
+ expect(triggerModels(clientModule)).toHaveLength(1);
594
+ });
595
+
596
+ it("registers no trigger read model when the lease is lost while it initialises", async () => {
597
+ const clientModule = await buildReactorModule();
598
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
599
+ const engine = await import("@powerhousedao/reactor-workflow");
600
+ let onLost:
601
+ | ((loss: { reason: "unrenewable"; silentMs: number }) => void)
602
+ | undefined;
603
+ class LosingReadModel extends engine.WorkflowTriggersReadModel {
604
+ override async init(): Promise<void> {
605
+ onLost?.({ reason: "unrenewable", silentMs: 30_000 });
606
+ await super.init();
607
+ }
608
+ }
609
+
610
+ await expect(
611
+ compose(clientModule, {
612
+ relationalDb,
613
+ load: () =>
614
+ Promise.resolve({
615
+ ...engine,
616
+ WorkflowTriggersReadModel: LosingReadModel,
617
+ acquireWorkflowSingletonLease: (options) => {
618
+ onLost = options.onLost as typeof onLost;
619
+ return engine.acquireWorkflowSingletonLease(options);
620
+ },
621
+ }),
622
+ }),
623
+ ).rejects.toMatchObject({ name: "WorkflowSingletonConflictError" });
624
+ expect(triggerModels(clientModule)).toHaveLength(0);
625
+
626
+ const again = await compose(clientModule, { relationalDb });
627
+ expect(triggerModels(clientModule)).toHaveLength(1);
628
+ await again.stop();
629
+ expect(triggerModels(clientModule)).toHaveLength(0);
630
+ });
631
+
632
+ // Embedded PGlite: no other process can open the journal, so the lease must
633
+ // not turn workflows off over a stalled renewal.
634
+ it("lets the lease fence itself only over a journal others can open", async () => {
635
+ const engine = await import("@powerhousedao/reactor-workflow");
636
+ const selfFence: (boolean | undefined)[] = [];
637
+ const load = () =>
638
+ Promise.resolve({
639
+ ...engine,
640
+ acquireWorkflowSingletonLease: (
641
+ options: Parameters<typeof engine.acquireWorkflowSingletonLease>[0],
642
+ ) => {
643
+ selfFence.push(options.selfFence);
644
+ return engine.acquireWorkflowSingletonLease(options);
645
+ },
646
+ });
647
+
648
+ for (const exclusiveJournal of [true, false]) {
649
+ const composed = await compose(await buildReactorModule(), {
650
+ relationalDb: createRelationalDb(pglite()) as IRelationalDb,
651
+ exclusiveJournal,
652
+ load,
653
+ });
654
+ await composed.stop();
655
+ }
656
+
657
+ expect(selfFence).toEqual([false, true]);
658
+ // Two reactors and two PGlite stores: past 5s on a Windows runner
659
+ }, 30_000);
660
+
661
+ it("opens no journal when the lease is lost before the runtime exists", async () => {
662
+ const clientModule = await buildReactorModule();
663
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
664
+ const engine = await import("@powerhousedao/reactor-workflow");
665
+ const createWorkflowRuntime = vi.fn(engine.createWorkflowRuntime);
666
+
667
+ const composing = compose(clientModule, {
668
+ relationalDb,
669
+ load: () =>
670
+ Promise.resolve({
671
+ ...engine,
672
+ createWorkflowRuntime,
673
+ acquireWorkflowSingletonLease: async (options) => {
674
+ const lease = await engine.acquireWorkflowSingletonLease(options);
675
+ options.onLost?.({ reason: "taken", heldBy: "thief" });
676
+ return lease;
677
+ },
678
+ }),
679
+ });
680
+
681
+ await expect(composing).rejects.toMatchObject({
682
+ name: "WorkflowSingletonConflictError",
683
+ owner: "thief",
684
+ });
685
+ expect(createWorkflowRuntime).not.toHaveBeenCalled();
686
+ });
687
+
688
+ // A rolling deploy whose slots have different owner names: the new pod is
689
+ // refused while the old one holds the lease, and must pick workflows up
690
+ // once the old pod releases it rather than leave nobody running them.
691
+ it("composes a refused host once the holder releases the singleton", async () => {
692
+ const clientModule = await buildReactorModule();
693
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
694
+ const holder = await compose(clientModule, {
695
+ relationalDb,
696
+ storageId: "/srv/slot-a",
697
+ });
698
+ const onComposed = vi.fn(() => Promise.resolve());
699
+ const retry = retryWorkflowSingleton({
700
+ compose: () => composeSecond(clientModule, relationalDb),
701
+ onComposed,
702
+ logger: stubLogger(),
703
+ intervalMs: 20,
704
+ });
705
+ try {
706
+ await new Promise((resolve) => setTimeout(resolve, 150));
707
+ expect(onComposed).not.toHaveBeenCalled();
708
+
709
+ await holder.stop();
710
+
711
+ await vi.waitFor(() => expect(onComposed).toHaveBeenCalledOnce());
712
+ const [taken] = onComposed.mock.calls[0] as unknown as [
713
+ ComposedWorkflowRuntime,
714
+ ];
715
+ expect(taken.singletonOwner).not.toBe(holder.singletonOwner);
716
+ await taken.stop();
717
+ } finally {
718
+ await retry.stop();
719
+ }
720
+ });
721
+
722
+ // Each claim builds its own runtime, so park state seeded under an earlier
723
+ // claim never outlives the gap in which another holder wrote.
724
+ it("reads the parks another holder wrote when it claims again", async () => {
725
+ const clientModule = await buildReactorModule();
726
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
727
+ const engine = await import("@powerhousedao/reactor-workflow");
728
+ const runtimes: WorkflowRuntimeService[] = [];
729
+ const claim = (storageId: string) =>
730
+ composeWorkflowRuntime({
731
+ reactorClient: clientModule.client,
732
+ clientModule: {} as InProcessReactorClientModule,
733
+ relationalDb,
734
+ attachments: {} as never,
735
+ authorizationService: {} as never,
736
+ logger: stubLogger(),
737
+ storageId,
738
+ load: () =>
739
+ Promise.resolve({
740
+ ...engine,
741
+ createWorkflowRuntime: (host) => {
742
+ const runtime = engine.createWorkflowRuntime(host);
743
+ runtimes.push(runtime);
744
+ return runtime;
745
+ },
746
+ }),
747
+ });
748
+ const parkOf = (runtime: WorkflowRuntimeService) =>
749
+ (
750
+ runtime as unknown as {
751
+ parks: { get(id: string): Promise<unknown> };
752
+ }
753
+ ).parks.get("wf-gap");
754
+
755
+ const first = await claim("/srv/slot-a");
756
+ expect(await parkOf(runtimes[0]!)).toBeUndefined();
757
+ await first.stop();
758
+
759
+ const other = await claim("/srv/slot-b");
760
+ await (await runtimes[1]!.store())!.parkWorkflow(
761
+ "wf-gap",
762
+ 1,
763
+ "parked by the other holder",
764
+ );
765
+ await other.stop();
766
+
767
+ const again = await claim("/srv/slot-a");
768
+ try {
769
+ expect(runtimes[2]).not.toBe(runtimes[0]);
770
+ expect(await parkOf(runtimes[2]!)).toMatchObject({
771
+ reason: "parked by the other holder",
772
+ });
773
+ } finally {
774
+ await again.stop();
775
+ }
776
+ });
777
+
778
+ it("stops retrying the claim when the host stops", async () => {
779
+ const clientModule = await buildReactorModule();
780
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
781
+ const holder = await compose(clientModule, {
782
+ relationalDb,
783
+ storageId: "/srv/slot-a",
784
+ });
785
+ const onComposed = vi.fn(() => Promise.resolve());
786
+ const retry = retryWorkflowSingleton({
787
+ compose: () => composeSecond(clientModule, relationalDb),
788
+ onComposed,
789
+ logger: stubLogger(),
790
+ intervalMs: 20,
791
+ });
792
+ await new Promise((resolve) => setTimeout(resolve, 60));
793
+
794
+ await retry.stop();
795
+ await holder.stop();
796
+ await new Promise((resolve) => setTimeout(resolve, 150));
797
+
798
+ expect(onComposed).not.toHaveBeenCalled();
799
+ });
800
+
801
+ it("composes without a claim only when the host opts out", async () => {
802
+ const clientModule = await buildReactorModule();
803
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
804
+
805
+ const unclaimed = await compose(clientModule, {
806
+ relationalDb,
807
+ singletonLease: false,
808
+ });
809
+ expect(unclaimed.singletonOwner).toBeUndefined();
810
+
811
+ // Nothing was claimed, so a host that does claim takes it at once.
812
+ const claimed = await composeSecond(clientModule, relationalDb);
813
+ try {
814
+ expect(claimed.singletonOwner).toBeDefined();
815
+ } finally {
816
+ await claimed.stop();
817
+ }
818
+ });
819
+ });
820
+
821
+ describe("the host's answer to losing the workflow singleton", () => {
822
+ it("stays without workflows, naming the holder, when another claim took it", () => {
823
+ const logger = stubLogger();
824
+ const fatal = vi.fn(() => true);
825
+
826
+ workflowSingletonLossHandler(
827
+ logger,
828
+ fatal,
829
+ )({
830
+ reason: "taken",
831
+ heldBy: "switchboard-1",
832
+ });
833
+
834
+ expect(fatal).not.toHaveBeenCalled();
835
+ const logged = vi
836
+ .mocked(logger.error)
837
+ .mock.calls.map(([line]) => String(line));
838
+ expect(logged.join("\n")).toContain('Another process ("switchboard-1")');
839
+ });
840
+
841
+ // Nobody else runs workflows after a database blip: going down lets the
842
+ // supervisor restart the process, which re-claims at boot.
843
+ it("goes through the fatal shutdown when no takeover was seen", () => {
844
+ const logger = stubLogger();
845
+ const fatal = vi.fn(() => true);
846
+
847
+ workflowSingletonLossHandler(
848
+ logger,
849
+ fatal,
850
+ )({
851
+ reason: "unrenewable",
852
+ silentMs: 30_000,
853
+ });
854
+
855
+ expect(fatal).toHaveBeenCalledOnce();
856
+ const [, error] = fatal.mock.calls[0] as unknown as [string, Error];
857
+ expect(error.message).toContain("no other process was seen");
858
+ expect(error.message).not.toContain("Another process");
859
+ });
860
+
861
+ it("says a restart is needed when no fatal shutdown is installed", () => {
862
+ const logger = stubLogger();
863
+
864
+ workflowSingletonLossHandler(
865
+ logger,
866
+ () => false,
867
+ )({
868
+ reason: "unrenewable",
869
+ silentMs: 30_000,
870
+ });
871
+
872
+ const logged = vi
873
+ .mocked(logger.error)
874
+ .mock.calls.map(([line]) => String(line));
875
+ expect(logged.join("\n")).toContain("until it is restarted");
876
+ expect(logged.join("\n")).not.toContain("Another process");
877
+ });
390
878
  });
391
879
 
392
880
  describe("the host's reactor access", () => {
@@ -486,6 +974,13 @@ describe("booting Switchboard with workflows on", () => {
486
974
  await expect(pollWorkflowSubgraph(switchboard)).resolves.toMatchObject({
487
975
  name: "workflow-runtime",
488
976
  });
977
+ // The boot type does not publish `api`; the facts sink lives on it.
978
+ const { api } = switchboard as unknown as {
979
+ api: { inspection?: { inspector: { info(): Promise<unknown> } } };
980
+ };
981
+ await expect(api.inspection?.inspector.info()).resolves.toMatchObject({
982
+ workflows: true,
983
+ });
489
984
 
490
985
  await switchboard.shutdown();
491
986
  switchboard = undefined;