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 +1 -1
- package/src/daemon.js +12 -7
- package/test/rollbridge.test.js +11 -2
package/package.json
CHANGED
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
|
|
1926
|
-
|
|
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 =
|
|
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
|
|
package/test/rollbridge.test.js
CHANGED
|
@@ -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(), /
|
|
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", "
|
|
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})
|