rollbridge 0.1.40 → 0.1.42

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.
@@ -660,25 +660,248 @@ test("retirement failure retains the exact transition, blocks other deploys, and
660
660
  }
661
661
  })
662
662
 
663
- test("candidate activation failure never reactivates old and exact resume activates only the candidate", async () => {
663
+ test("candidate activation failure reports restoration failure and exact recovery clears the fence", async () => {
664
664
  const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
665
665
  const daemon = await startDaemon(fixture.config)
666
666
 
667
667
  try {
668
668
  await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
669
- await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /activate command exited non-zero/)
669
+ const incumbentCoordinator = daemon.releases.get("v1")?.getProcess("beacon")
670
+ const reactivate = incumbentCoordinator?.reactivateStrict.bind(incumbentCoordinator)
671
+
672
+ assert.ok(incumbentCoordinator && reactivate)
673
+ incumbentCoordinator.reactivateStrict = async () => { throw new Error("incumbent restoration rejected") }
674
+ await assert.rejects(
675
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
676
+ error => {
677
+ assert.ok(error instanceof AggregateError)
678
+ assert.match(error.message, /activate command exited non-zero/)
679
+ assert.match(error.message, /incumbent v1 restoration failed: incumbent restoration rejected/i)
680
+ return true
681
+ }
682
+ )
670
683
 
671
684
  const failed = daemon.status()
672
685
 
673
686
  assert.equal(failed.activeReleaseId, "v1")
674
- assert.equal(failed.generationTransition?.phase, "activating_candidate")
675
- assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"])
687
+ assert.equal(failed.generationTransition?.phase, "restoring_previous")
688
+ assert.match(String(failed.generationTransition?.activationError), /activate command exited non-zero/)
689
+ assert.match(String(failed.generationTransition?.compensationError), /incumbent restoration rejected/)
690
+ const failedEvents = daemon.eventLog.recent()
691
+ const activationEvent = failedEvents.find((event) => event.message === "release generation activation failed")
692
+ const restorationEvent = failedEvents.find((event) => event.message === "release generation compensation restoration failed")
693
+
694
+ assert.match(String(activationEvent?.data.error), /activate command exited non-zero/)
695
+ assert.match(String(restorationEvent?.data.activationError), /activate command exited non-zero/)
696
+ assert.match(String(restorationEvent?.data.error), /incumbent restoration rejected/)
697
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2"])
698
+ await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
676
699
 
677
- await fs.writeFile(fixture.activationGatePath, "allow\n")
678
- await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
700
+ incumbentCoordinator.reactivateStrict = reactivate
701
+ const recovery = await sendControlCommand({
702
+ command: {
703
+ command: "recover-generation-transition",
704
+ previousReleaseId: "v1",
705
+ releaseId: "v2",
706
+ releasePath: fixture.root,
707
+ revision: "v2"
708
+ },
709
+ path: fixture.config.control.path
710
+ })
679
711
 
680
- assert.equal(daemon.status().activeReleaseId, "v2")
681
- assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
712
+ assert.equal(recovery.recoveryStatus, "recovered")
713
+ assert.equal(daemon.status().activeReleaseId, "v1")
714
+ assert.equal(daemon.status().generationTransition, undefined)
715
+ const persisted = /** @type {{generationTransition?: import("../src/json.js").JsonValue} | undefined} */ (await readState(fixture.statePath))
716
+
717
+ assert.equal(persisted?.generationTransition, undefined)
718
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"])
719
+
720
+ const idempotent = await sendControlCommand({
721
+ command: {
722
+ command: "recover-generation-transition",
723
+ previousReleaseId: "v1",
724
+ releaseId: "v2",
725
+ releasePath: fixture.root,
726
+ revision: "v2"
727
+ },
728
+ path: fixture.config.control.path
729
+ })
730
+
731
+ assert.equal(idempotent.recoveryStatus, "already_recovered")
732
+ await daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})
733
+ await assert.rejects(
734
+ () => sendControlCommand({
735
+ command: {
736
+ command: "recover-generation-transition",
737
+ previousReleaseId: "v1",
738
+ releaseId: "v3",
739
+ releasePath: fixture.root,
740
+ revision: "v3"
741
+ },
742
+ path: fixture.config.control.path
743
+ }),
744
+ /not a safe failed pre-commit transition/i
745
+ )
746
+ } finally {
747
+ await daemon.shutdown()
748
+ await fs.rm(fixture.root, {force: true, recursive: true})
749
+ }
750
+ })
751
+
752
+ test("candidate activation failure compensates to the incumbent and admits a different later release", async () => {
753
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
754
+ const daemon = await startDaemon(fixture.config)
755
+
756
+ try {
757
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
758
+ await assert.rejects(
759
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
760
+ /activate command exited non-zero.*compensation restored incumbent v1 as authoritative and retired failed candidate v2/i
761
+ )
762
+
763
+ const compensated = daemon.status()
764
+
765
+ assert.equal(compensated.activeReleaseId, "v1")
766
+ assert.equal(compensated.generationTransition, undefined)
767
+ assert.equal(await fetchText(daemon, "/release"), "v1")
768
+ assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state, "running")
769
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"])
770
+
771
+ await daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})
772
+
773
+ assert.equal(daemon.status().activeReleaseId, "v3")
774
+ assert.equal(await fetchText(daemon, "/release"), "v3")
775
+ } finally {
776
+ await daemon.shutdown()
777
+ await fs.rm(fixture.root, {force: true, recursive: true})
778
+ }
779
+ })
780
+
781
+ test("ambiguous candidate activation retires the candidate before reactivating the incumbent", async () => {
782
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateAmbiguousFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
783
+ const daemon = await startDaemon(fixture.config)
784
+
785
+ try {
786
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
787
+ await assert.rejects(
788
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
789
+ /activate command exited non-zero.*compensation restored incumbent v1 as authoritative and retired failed candidate v2/i
790
+ )
791
+
792
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), [
793
+ "activate:v1",
794
+ "retire:v1",
795
+ "activate:v2",
796
+ "retire:v2",
797
+ "activate:v1"
798
+ ])
799
+ assert.equal(daemon.status().activeReleaseId, "v1")
800
+ assert.equal(daemon.status().generationTransition, undefined)
801
+ } finally {
802
+ await daemon.shutdown()
803
+ await fs.rm(fixture.root, {force: true, recursive: true})
804
+ }
805
+ })
806
+
807
+ test("candidate activation recovery reverses a worker-specific quiet hook before reporting active", async () => {
808
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true, workerReactivationLifecycle: true})
809
+ const daemon = await startDaemon(fixture.config)
810
+
811
+ try {
812
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
813
+ await assert.rejects(
814
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
815
+ /compensation restored incumbent v1 as authoritative/i
816
+ )
817
+
818
+ const events = await lifecycleEvents(fixture.lifecycleLogPath)
819
+ const candidateRetired = events.indexOf("worker-retire:v2")
820
+ const workerReactivated = events.indexOf("worker-reactivate:v1")
821
+
822
+ assert.ok(candidateRetired >= 0, JSON.stringify(events))
823
+ assert.ok(workerReactivated > candidateRetired, JSON.stringify(events))
824
+ assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state, "running")
825
+ assert.equal(daemon.status().activeReleaseId, "v1")
826
+ assert.equal(daemon.status().generationTransition, undefined)
827
+ } finally {
828
+ await daemon.shutdown()
829
+ await fs.rm(fixture.root, {force: true, recursive: true})
830
+ }
831
+ })
832
+
833
+ test("candidate activation recovery keeps the fence when a worker-specific resume hook fails", async () => {
834
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true, workerReactivationFailure: true, workerReactivationLifecycle: true})
835
+ const daemon = await startDaemon(fixture.config)
836
+
837
+ try {
838
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
839
+ await assert.rejects(
840
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
841
+ error => {
842
+ const failure = /** @type {Error} */ (error)
843
+
844
+ assert.match(failure.message, /activate command exited non-zero/)
845
+ assert.match(failure.message, /reactivate command exited non-zero/)
846
+ return true
847
+ }
848
+ )
849
+
850
+ const status = daemon.status()
851
+ const restorationEvent = daemon.eventLog.recent().find((event) => event.message === "release generation compensation restoration failed")
852
+
853
+ assert.equal(status.activeReleaseId, "v1")
854
+ assert.equal(status.generationTransition?.phase, "restoring_previous")
855
+ assert.match(String(status.generationTransition?.activationError), /activate command exited non-zero/)
856
+ assert.match(String(status.generationTransition?.compensationError), /reactivate command exited non-zero/)
857
+ assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state, "quiesced")
858
+ assert.match(String(restorationEvent?.data.activationError), /activate command exited non-zero/)
859
+ assert.match(String(restorationEvent?.data.error), /reactivate command exited non-zero/)
860
+ await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
861
+ } finally {
862
+ await daemon.shutdown()
863
+ await fs.rm(fixture.root, {force: true, recursive: true})
864
+ }
865
+ })
866
+
867
+ test("compensation keeps the fence when the cleared checkpoint cannot be persisted", async () => {
868
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
869
+ const daemon = await startDaemon(fixture.config)
870
+
871
+ try {
872
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
873
+ const checkpoint = daemon.checkpointGenerationTransition.bind(daemon)
874
+
875
+ daemon.checkpointGenerationTransition = async () => {
876
+ if (!daemon.generationTransition) throw new Error("cleared checkpoint unavailable")
877
+ await checkpoint()
878
+ }
879
+ await assert.rejects(
880
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
881
+ /activate command exited non-zero.*compensation checkpoint clear failed: cleared checkpoint unavailable/i
882
+ )
883
+
884
+ assert.equal(daemon.status().activeReleaseId, "v1")
885
+ assert.equal(daemon.status().generationTransition?.phase, "restoring_previous")
886
+ const persisted = /** @type {{generationTransition?: {phase?: string}} | undefined} */ (await readState(fixture.statePath))
887
+
888
+ assert.equal(persisted?.generationTransition?.phase, "restoring_previous")
889
+ await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
890
+
891
+ daemon.checkpointGenerationTransition = checkpoint
892
+ const recovery = await sendControlCommand({
893
+ command: {
894
+ command: "recover-generation-transition",
895
+ previousReleaseId: "v1",
896
+ releaseId: "v2",
897
+ releasePath: fixture.root,
898
+ revision: "v2"
899
+ },
900
+ path: fixture.config.control.path
901
+ })
902
+
903
+ assert.equal(recovery.recoveryStatus, "recovered")
904
+ assert.equal(daemon.status().generationTransition, undefined)
682
905
  } finally {
683
906
  await daemon.shutdown()
684
907
  await fs.rm(fixture.root, {force: true, recursive: true})
@@ -691,6 +914,10 @@ test("unresolved generation transition fences stop, restart, and rollback mutati
691
914
 
692
915
  try {
693
916
  await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
917
+ const incumbentCoordinator = daemon.releases.get("v1")?.getProcess("beacon")
918
+
919
+ assert.ok(incumbentCoordinator)
920
+ incumbentCoordinator.reactivateStrict = async () => { throw new Error("incumbent restoration rejected") }
694
921
  await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /activate command exited non-zero/)
695
922
 
696
923
  await assert.rejects(() => daemon.stopRelease("v2"), /cannot stop.*generation transition.*unresolved/i)
@@ -698,9 +925,7 @@ test("unresolved generation transition fences stop, restart, and rollback mutati
698
925
  await assert.rejects(() => daemon.rollback({releaseId: "v2"}), /cannot rollback.*generation transition.*unresolved/i)
699
926
 
700
927
  assert.notEqual(statusRelease(daemon, "v2").processes.find((entry) => entry.id === "web")?.state, "stopped")
701
- await fs.writeFile(fixture.activationGatePath, "allow\n")
702
- await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
703
- assert.equal(await fetchText(daemon, "/release"), "v2")
928
+ assert.equal(await fetchText(daemon, "/release"), "v1")
704
929
  } finally {
705
930
  await daemon.shutdown()
706
931
  await fs.rm(fixture.root, {force: true, recursive: true})
@@ -1654,7 +1879,7 @@ test("deploy can ensure the daemon before sending the release command", async ()
1654
1879
  })
1655
1880
 
1656
1881
  /**
1657
- * @param {{companionReplicas?: number, handoffService?: boolean, handoffServiceActivate?: boolean, handoffServiceActivateFailure?: boolean | string, handoffServiceQuiet?: boolean, handoffServiceQuietFailure?: boolean, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number, workerStopDelayMs?: number}} [options] - Fixture options.
1882
+ * @param {{companionReplicas?: number, handoffService?: boolean, handoffServiceActivate?: boolean, handoffServiceActivateAmbiguousFailure?: boolean, handoffServiceActivateFailure?: boolean | string, handoffServiceQuiet?: boolean, handoffServiceQuietFailure?: boolean, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number, workerReactivationFailure?: boolean, workerReactivationLifecycle?: boolean, workerStopDelayMs?: number}} [options] - Fixture options.
1658
1883
  * @returns {Promise<{activationGatePath: string, config: import("../src/config.js").RollbridgeConfig, lifecycleLogPath: string, retirementGatePath: string, root: string, serviceLogPath: string, serviceQuietPath: string, singletonLogPath: string, statePath: string}>} Fixture data.
1659
1884
  */
1660
1885
  async function createFixture(options = {}) {
@@ -1672,7 +1897,7 @@ async function createFixture(options = {}) {
1672
1897
  if (options.includeService || options.handoffService) {
1673
1898
  const activationFailureRelease = typeof options.handoffServiceActivateFailure === "string" ? options.handoffServiceActivateFailure : "v2"
1674
1899
  const lifecycle = options.handoffServiceActivate ? {
1675
- activateCommand: `${options.handoffServiceActivateFailure ? `[ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24; ` : ""}printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`,
1900
+ activateCommand: `${options.handoffServiceActivateFailure && !options.handoffServiceActivateAmbiguousFailure ? `[ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24; ` : ""}printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}${options.handoffServiceActivateAmbiguousFailure ? `; [ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24` : ""}`,
1676
1901
  quietCommand: `${options.handoffServiceQuietFailure ? `[ -f ${JSON.stringify(retirementGatePath)} ] || exit 23; ` : ""}printf 'retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
1677
1902
  } : options.handoffServiceQuiet || options.handoffServiceQuietFailure ? {
1678
1903
  quietCommand: options.handoffServiceQuietFailure ? "exit 23" : `printf '%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(serviceQuietPath)}`
@@ -1713,6 +1938,10 @@ async function createFixture(options = {}) {
1713
1938
  processes.push({
1714
1939
  command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`process.on('SIGTERM', () => setTimeout(() => process.exit(0), ${options.workerStopDelayMs || 0})); setInterval(() => {}, 1000)`)}`,
1715
1940
  id: "worker",
1941
+ ...(options.workerReactivationLifecycle ? {lifecycle: {
1942
+ quietCommand: `printf 'worker-retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`,
1943
+ reactivateCommand: `${options.workerReactivationFailure ? "exit 25; " : ""}printf 'worker-reactivate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
1944
+ }} : {}),
1716
1945
  nonBlockingDrain: true,
1717
1946
  policy: "companion"
1718
1947
  })