rollbridge 0.1.46 → 0.1.48

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
package/src/daemon.js CHANGED
@@ -25,7 +25,7 @@ const STATE_PERSIST_INTERVAL_MS = 5000
25
25
  * @typedef {{id: string, process: import("./managed-process.js").ManagedProcessStatus}} ProcessStatus
26
26
  * @typedef {{disruptive: true, mode: "legacy-first-upgrade", reason: string}} OwnerTransition
27
27
  * @typedef {"candidate_ready" | "retiring_previous" | "previous_retired" | "activating_candidate" | "restoring_previous" | "retiring_failed_candidate" | "degraded_active" | "committed_pending" | "committed" | "restoring_committed"} GenerationTransitionPhase
28
- * @typedef {{activationError?: string, activationLifecycle?: boolean, candidateReleaseId: string, candidateReleasePath: string, candidateRevision: string, compensationError?: string, configDigest: string, error?: string, journalRevision?: number, phase: GenerationTransitionPhase, previousReleaseId: string | null, startedAt: string, updatedAt: string}} GenerationTransition
28
+ * @typedef {{activationError?: string, activationLifecycle?: boolean, candidateReleaseId: string, candidateReleasePath: string, candidateRevision: string, compensationError?: string, configDigest: string, degradedIncumbent?: boolean, error?: string, journalRevision?: number, phase: GenerationTransitionPhase, previousReleaseId: string | null, startedAt: string, updatedAt: string}} GenerationTransition
29
29
  * @typedef {{activeReleaseId: string | null, application: string, bootstrap: BootstrapIdentity | undefined, control: import("./config.js").ControlConfig, daemonPid: number, daemonRuntime: import("./daemon-runtime.js").DaemonRuntimeIdentity | undefined, generationTransition?: GenerationTransition, ownerRecovery: {configDigest: string, ready: boolean} | undefined, ownerTransition?: OwnerTransition, orphans: {id: string, pid: number, releaseId: string | null}[], proxy: {host: string, port: number | undefined, upstreamHost: string}, releaseReferences: {releaseId: string, releasePath: string}[], releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
30
30
  * @typedef {{configDigest: string, format: number, guardian: {pid?: number, socketPath: string, token: string}, reconnectGraceMs: number}} OwnerRecoveryMetadata
31
31
  * @typedef {DaemonStatus & {recovery: OwnerRecoveryMetadata, serviceReleaseIds?: Record<string, string>, singletonReleaseIds?: Record<string, string>}} OwnerRecoverySnapshot
@@ -1620,6 +1620,7 @@ export default class RollbridgeDaemon {
1620
1620
  candidateReleasePath: release.releasePath,
1621
1621
  candidateRevision: release.revision,
1622
1622
  configDigest: ownerConfigDigest(nextConfig),
1623
+ degradedIncumbent: transition?.phase === "degraded_active",
1623
1624
  journalRevision: 0,
1624
1625
  phase: activationLifecycle ? "candidate_ready" : "activating_candidate",
1625
1626
  previousReleaseId: previousRelease?.releaseId ?? null,
@@ -1651,7 +1652,7 @@ export default class RollbridgeDaemon {
1651
1652
  let retirementFailure = activationLifecycle ? undefined : previousRelease?.retirementError
1652
1653
 
1653
1654
  if (transition.phase === "candidate_ready") {
1654
- if (previousRelease) await this.updateGenerationTransition("retiring_previous")
1655
+ if (previousRelease && !transition.degradedIncumbent) await this.updateGenerationTransition("retiring_previous")
1655
1656
  else await this.updateGenerationTransition("previous_retired")
1656
1657
  }
1657
1658
 
@@ -1894,7 +1895,7 @@ export default class RollbridgeDaemon {
1894
1895
  if (transition.previousReleaseId !== identity.previousReleaseId) {
1895
1896
  throw new Error(`Generation transition previous release is ${transition.previousReleaseId}; refusing stale recovery for ${identity.previousReleaseId}`)
1896
1897
  }
1897
- if (transition.phase !== "activating_candidate" && transition.phase !== "restoring_previous" && transition.phase !== "retiring_failed_candidate" && transition.phase !== "degraded_active") {
1898
+ if (transition.phase !== "activating_candidate" && transition.phase !== "retiring_previous" && transition.phase !== "restoring_previous" && transition.phase !== "retiring_failed_candidate" && transition.phase !== "degraded_active") {
1898
1899
  throw new Error(`Generation transition ${transition.candidateReleaseId} at ${transition.phase} is not a safe failed pre-commit transition`)
1899
1900
  }
1900
1901
  if (transition.phase === "degraded_active" && !identity.acceptRetiredIncumbent) {
@@ -1922,8 +1923,10 @@ export default class RollbridgeDaemon {
1922
1923
  * @returns {Promise<Record<string, JsonValue>>} Explicit degraded recovery result.
1923
1924
  */
1924
1925
  async acceptRetiredIncumbentTransition(transition) {
1925
- if (transition.phase !== "restoring_previous" && transition.phase !== "degraded_active") throw new Error(`Retired-incumbent recovery requires exactly restoring_previous; transition ${transition.candidateReleaseId} is at ${transition.phase}`)
1926
- if (!terminalRetirementFailure(transition.compensationError)) throw new Error("Retired-incumbent recovery requires a recorded restoration failure compatible with terminal retirement")
1926
+ if (transition.phase !== "retiring_previous" && transition.phase !== "restoring_previous" && transition.phase !== "degraded_active") throw new Error(`Retired-incumbent recovery requires retiring_previous or restoring_previous; transition ${transition.candidateReleaseId} is at ${transition.phase}`)
1927
+ const retirementFailure = transition.phase === "retiring_previous" ? transition.error : transition.compensationError
1928
+
1929
+ if (!terminalRetirementFailure(retirementFailure)) throw new Error("Retired-incumbent recovery requires a recorded restoration failure compatible with terminal retirement")
1927
1930
  if (!transition.previousReleaseId) throw new Error("Retired-incumbent recovery requires a retained previous release")
1928
1931
  const candidate = this.releases.get(transition.candidateReleaseId)
1929
1932
  const previous = this.releases.get(transition.previousReleaseId)
@@ -1957,6 +1960,7 @@ export default class RollbridgeDaemon {
1957
1960
 
1958
1961
  if (transition.phase === "degraded_active") return result
1959
1962
  const previousError = transition.error
1963
+ const previousPhase = transition.phase
1960
1964
  const previousJournalRevision = transition.journalRevision
1961
1965
  const previousUpdatedAt = transition.updatedAt
1962
1966
 
@@ -1967,7 +1971,7 @@ export default class RollbridgeDaemon {
1967
1971
  try {
1968
1972
  await this.checkpointGenerationTransition()
1969
1973
  } catch (error) {
1970
- transition.phase = "restoring_previous"
1974
+ transition.phase = previousPhase
1971
1975
  transition.error = previousError
1972
1976
  transition.journalRevision = previousJournalRevision
1973
1977
  transition.updatedAt = previousUpdatedAt
@@ -2961,7 +2965,8 @@ export function ownerReplacementTransitionAuthorityMatches(transfer, transition,
2961
2965
  function terminalRetirementFailure(failure) {
2962
2966
  return typeof failure === "string" && (
2963
2967
  /^Cannot activate .+ generation from retired$/iu.test(failure) ||
2964
- /^activate command exited non-zero with status \d+$/u.test(failure)
2968
+ /^activate command exited non-zero with status \d+$/u.test(failure) ||
2969
+ /^Release .+ retirement quiescence failed: quiet command exited non-zero with status \d+$/u.test(failure)
2965
2970
  )
2966
2971
  }
2967
2972
 
@@ -799,7 +799,7 @@ test("explicit recovery stops the exact failed candidate and fences degraded inc
799
799
  await assert.rejects(() => exactRecovery({previousReleaseId: "wrong-v1"}), /refusing stale recovery/i)
800
800
  await assert.rejects(() => exactRecovery({revision: "wrong-v2"}), /exact same release, path, revision, and config authority/i)
801
801
  transition.phase = "retiring_failed_candidate"
802
- await assert.rejects(() => exactRecovery(), /exactly restoring_previous/i)
802
+ await assert.rejects(() => exactRecovery(), /requires retiring_previous or restoring_previous/i)
803
803
  transition.phase = "restoring_previous"
804
804
  const terminalFailure = transition.compensationError
805
805
 
@@ -826,6 +826,15 @@ test("explicit recovery stops the exact failed candidate and fences degraded inc
826
826
  const persisted = /** @type {{generationTransition?: import("../src/json.js").JsonValue} | undefined} */ (await readState(fixture.statePath))
827
827
 
828
828
  assert.equal(/** @type {{phase?: string} | undefined} */ (persisted?.generationTransition)?.phase, "degraded_active")
829
+
830
+ transition.phase = "retiring_previous"
831
+ transition.error = "Release v1 retirement quiescence failed: quiet command exited non-zero with status 1"
832
+ transition.compensationError = undefined
833
+ await daemon.checkpointGenerationTransition()
834
+ const legacyRecovery = await exactRecovery()
835
+
836
+ assert.equal(legacyRecovery.recoveryStatus, "retired_incumbent_accepted")
837
+ assert.equal(daemon.status().generationTransition?.phase, "degraded_active", "guarded recovery migrates a legacy terminal retirement fence")
829
838
  await assert.rejects(() => daemon.deploy({releaseId: "bad-v3", releasePath: fixture.root, revision: "bad-v3"}), /health check failed/i)
830
839
  assert.equal(daemon.status().generationTransition?.phase, "degraded_active")
831
840
  assert.equal(statusRelease(daemon, "v1").processes.find(({id}) => id === "web")?.pid, incumbentWebPid)
@@ -834,7 +843,7 @@ test("explicit recovery stops the exact failed candidate and fences degraded inc
834
843
  assert.equal(daemon.status().activeReleaseId, "v3")
835
844
  assert.equal(daemon.status().generationTransition?.phase, "committed")
836
845
  assert.equal(await fetchText(daemon, "/release"), "v3")
837
- assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "retire:bad-v3", "retire:v1", "activate:v3"])
846
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "retire:bad-v3", "activate:v3"], "fresh deployment must not re-retire a degraded incumbent generation")
838
847
  } finally {
839
848
  await daemon.shutdown()
840
849
  await fs.rm(fixture.root, {force: true, recursive: true})