@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.
- package/.tsbuild/test/boot-workflow-singleton-conflict.test.d.ts +2 -0
- package/.tsbuild/test/boot-workflow-singleton-conflict.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-workflow-singleton-retry.test.d.ts +2 -0
- package/.tsbuild/test/boot-workflow-singleton-retry.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/CHANGELOG.md +32 -0
- package/dist/index.mjs +1 -1
- package/dist/{server-Zz7SqTwO.mjs → server-J9fpd42v.mjs} +262 -48
- package/dist/server-J9fpd42v.mjs.map +1 -0
- package/dist/server.d.mts +3 -2
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -3
- package/package.json +15 -15
- package/test/boot-workflow-singleton-conflict.test.ts +107 -0
- package/test/boot-workflow-singleton-retry.test.ts +133 -0
- package/test/pglite-dialect.test.ts +58 -0
- package/test/workflow/resolvers.test.ts +35 -0
- package/test/workflow-reactor-enforcement.test.ts +3 -0
- package/test/workflow-read-gate.test.ts +3 -0
- package/test/workflow-runtime.test.ts +502 -7
- package/dist/server-Zz7SqTwO.mjs.map +0 -1
|
@@ -16,7 +16,10 @@ import {
|
|
|
16
16
|
type GraphQLManager,
|
|
17
17
|
type PackagePieceEntry,
|
|
18
18
|
} from "@powerhousedao/reactor-api";
|
|
19
|
-
import {
|
|
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
|
|
357
|
-
|
|
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(
|
|
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;
|