rollbridge 0.1.40 → 0.1.41
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/examples/tensorbuzz.com.js +5 -2
- package/package.json +1 -1
- package/src/cli.js +25 -0
- package/src/config.js +21 -2
- package/src/daemon.js +180 -3
- package/src/guardian-client.js +55 -2
- package/src/managed-process.js +63 -8
- package/src/process-guardian.js +4 -1
- package/src/release-group.js +22 -0
- package/test/completion.test.js +4 -2
- package/test/config-examples.test.js +1 -0
- package/test/config-validation.test.js +18 -0
- package/test/guardian-client.test.js +82 -1
- package/test/managed-process.test.js +27 -0
- package/test/owner-recovery.test.js +11 -18
- package/test/owner-replacement.test.js +13 -19
- package/test/rollbridge.test.js +242 -13
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// binds that stable HTTP port, forwards to the active release's internal web
|
|
5
5
|
// port and keeps Beacon daemon-wide. The lifecycle socket path must match the
|
|
6
6
|
// reviewed release-local Velocious jobs-main configuration; the worker appctl
|
|
7
|
-
//
|
|
7
|
+
// commands remain illustrative application-specific quiescence/reactivation controls.
|
|
8
8
|
|
|
9
9
|
export default {
|
|
10
10
|
application: "tensorbuzz",
|
|
@@ -68,7 +68,10 @@ export default {
|
|
|
68
68
|
VELOCIOUS_BACKGROUND_JOBS_PORT: "{{ports.background-jobs-main}}"
|
|
69
69
|
},
|
|
70
70
|
command: "wait-for-it 127.0.0.1:{{ports.beacon}} --strict -- wait-for-it 127.0.0.1:{{ports.background-jobs-main}} --strict -- npx velocious background-jobs-worker",
|
|
71
|
-
lifecycle: {
|
|
71
|
+
lifecycle: {
|
|
72
|
+
quietCommand: "appctl jobs-worker-retire --pid $ROLLBRIDGE_PID",
|
|
73
|
+
reactivateCommand: "appctl jobs-worker-reactivate --pid $ROLLBRIDGE_PID"
|
|
74
|
+
},
|
|
72
75
|
nonBlockingDrain: true,
|
|
73
76
|
gracefulStopMs: "indefinite"
|
|
74
77
|
},
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -213,6 +213,31 @@ export async function runCli(argv) {
|
|
|
213
213
|
console.log(JSON.stringify(response, null, 2))
|
|
214
214
|
})
|
|
215
215
|
|
|
216
|
+
program
|
|
217
|
+
.command("recover-generation-transition")
|
|
218
|
+
.description("Restore the exact incumbent and retire a failed pre-commit generation candidate.")
|
|
219
|
+
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
220
|
+
.requiredOption("--release-path <path>", "Failed candidate release path")
|
|
221
|
+
.requiredOption("--release-id <id>", "Failed candidate release id")
|
|
222
|
+
.requiredOption("--revision <sha>", "Failed candidate revision")
|
|
223
|
+
.requiredOption("--previous-release-id <id>", "Expected authoritative incumbent release id")
|
|
224
|
+
.action(async (options) => {
|
|
225
|
+
const configPath = await resolveConfigPath(options.config)
|
|
226
|
+
const config = await loadConfig(configPath)
|
|
227
|
+
const response = await sendControlCommand({
|
|
228
|
+
command: {
|
|
229
|
+
command: "recover-generation-transition",
|
|
230
|
+
previousReleaseId: options.previousReleaseId,
|
|
231
|
+
releaseId: options.releaseId,
|
|
232
|
+
releasePath: options.releasePath,
|
|
233
|
+
revision: options.revision
|
|
234
|
+
},
|
|
235
|
+
path: config.control.path
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
console.log(JSON.stringify(response, null, 2))
|
|
239
|
+
})
|
|
240
|
+
|
|
216
241
|
program
|
|
217
242
|
.command("ensure-daemon")
|
|
218
243
|
.description("Start the daemon if the control socket is not already accepting commands.")
|
package/src/config.js
CHANGED
|
@@ -14,7 +14,7 @@ import {pathToFileURL} from "node:url"
|
|
|
14
14
|
* @typedef {"proxied" | "companion" | "singleton" | "service"} ProcessPolicy
|
|
15
15
|
* @typedef {{backoffFactor: number, maxDelayMs: number, maxRestarts: number | undefined, windowMs: number}} RestartConfig
|
|
16
16
|
* @typedef {{checkIntervalMs: number, limitBytes: number, warnBytes: number}} MemoryConfig
|
|
17
|
-
* @typedef {{activateCommand?: string, drainCommand?: string, drainTimeoutMs: number, quietCommand?: string, stopCommand?: string}} LifecycleConfig
|
|
17
|
+
* @typedef {{activateCommand?: string, drainCommand?: string, drainTimeoutMs: number, quietCommand?: string, reactivateCommand?: string, stopCommand?: string}} LifecycleConfig
|
|
18
18
|
* @typedef {number | "indefinite"} StopTimeoutMs
|
|
19
19
|
* @typedef {"persistent" | "handoff"} ServiceDeployStrategy
|
|
20
20
|
* @typedef {{cwd?: string, deployStrategy: ServiceDeployStrategy, env: Record<string, string>, gracefulStopMs: StopTimeoutMs, health?: HealthConfig, id: string, lifecycle: LifecycleConfig, memory?: MemoryConfig, nonBlockingDrain: boolean, outputLines: number, policy: ProcessPolicy, port?: PortRange, replicas: number, restart: RestartConfig, restartDelayMs: number, stopSignal: string, command: string}} ProcessConfig
|
|
@@ -346,7 +346,7 @@ function normalizeLifecycle(value, key, issues) {
|
|
|
346
346
|
if (value === undefined || value === null) return {drainTimeoutMs: 0}
|
|
347
347
|
|
|
348
348
|
if (!isPlainObject(value)) {
|
|
349
|
-
issues.push({fix: `Set ${key} to a mapping with optional activateCommand, quietCommand, drainCommand, stopCommand, and drainTimeoutMs.`, message: `${key} must be an object`})
|
|
349
|
+
issues.push({fix: `Set ${key} to a mapping with optional activateCommand, quietCommand, reactivateCommand, drainCommand, stopCommand, and drainTimeoutMs.`, message: `${key} must be an object`})
|
|
350
350
|
|
|
351
351
|
return {drainTimeoutMs: 0}
|
|
352
352
|
}
|
|
@@ -357,6 +357,7 @@ function normalizeLifecycle(value, key, issues) {
|
|
|
357
357
|
|
|
358
358
|
if (value.activateCommand !== undefined) lifecycle.activateCommand = normalizeString(value.activateCommand, `${key}.activateCommand`, issues, {nonEmpty: true})
|
|
359
359
|
if (value.quietCommand !== undefined) lifecycle.quietCommand = normalizeString(value.quietCommand, `${key}.quietCommand`, issues, {nonEmpty: true})
|
|
360
|
+
if (value.reactivateCommand !== undefined) lifecycle.reactivateCommand = normalizeString(value.reactivateCommand, `${key}.reactivateCommand`, issues, {nonEmpty: true})
|
|
360
361
|
if (value.drainCommand !== undefined) lifecycle.drainCommand = normalizeString(value.drainCommand, `${key}.drainCommand`, issues, {nonEmpty: true})
|
|
361
362
|
if (value.stopCommand !== undefined) lifecycle.stopCommand = normalizeString(value.stopCommand, `${key}.stopCommand`, issues, {nonEmpty: true})
|
|
362
363
|
|
|
@@ -376,6 +377,7 @@ function normalizeLifecycle(value, key, issues) {
|
|
|
376
377
|
*/
|
|
377
378
|
function validateActivationLifecycle(processes, ownerRecovery, statePath, issues) {
|
|
378
379
|
const activated = processes.filter((processConfig) => processConfig.lifecycle.activateCommand !== undefined)
|
|
380
|
+
const reactivated = processes.filter((processConfig) => processConfig.lifecycle.reactivateCommand !== undefined)
|
|
379
381
|
|
|
380
382
|
if (activated.length > 1) {
|
|
381
383
|
issues.push({fix: "Configure lifecycle.activateCommand on only one release-generation coordinator.", message: "Config may define at most one lifecycle.activateCommand"})
|
|
@@ -390,6 +392,23 @@ function validateActivationLifecycle(processes, ownerRecovery, statePath, issues
|
|
|
390
392
|
}
|
|
391
393
|
}
|
|
392
394
|
|
|
395
|
+
for (const processConfig of reactivated) {
|
|
396
|
+
if (processConfig.policy !== "companion" || !processConfig.nonBlockingDrain) {
|
|
397
|
+
issues.push({fix: `Set lifecycle.reactivateCommand only on a nonBlockingDrain companion; "${processConfig.id}" is ${processConfig.policy} with nonBlockingDrain: ${processConfig.nonBlockingDrain}.`, message: `Process "${processConfig.id}" can only set lifecycle.reactivateCommand on a nonBlockingDrain companion`})
|
|
398
|
+
}
|
|
399
|
+
if (!processConfig.lifecycle.quietCommand) {
|
|
400
|
+
issues.push({fix: `Add lifecycle.quietCommand to "${processConfig.id}" so worker reactivation has a paired retirement command.`, message: `Process "${processConfig.id}" lifecycle.reactivateCommand requires lifecycle.quietCommand`})
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (activated.length > 0) {
|
|
405
|
+
for (const processConfig of processes) {
|
|
406
|
+
if (processConfig.nonBlockingDrain && processConfig.lifecycle.quietCommand && !processConfig.lifecycle.reactivateCommand) {
|
|
407
|
+
issues.push({fix: `Add lifecycle.reactivateCommand to "${processConfig.id}" so failed candidate recovery reverses its external quiet hook.`, message: `Process "${processConfig.id}" lifecycle.quietCommand requires lifecycle.reactivateCommand during generation activation recovery`})
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
393
412
|
if (activated.length > 0 && (!ownerRecovery || !statePath)) {
|
|
394
413
|
issues.push({fix: "Configure statePath and ownerRecovery for durable release-generation transition recovery.", message: "lifecycle.activateCommand requires ownerRecovery and statePath"})
|
|
395
414
|
}
|
package/src/daemon.js
CHANGED
|
@@ -24,8 +24,8 @@ const STATE_PERSIST_INTERVAL_MS = 5000
|
|
|
24
24
|
* @typedef {{attestation?: string, releaseId: string, releasePath: string, revision: string}} BootstrapIdentity
|
|
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
|
-
* @typedef {"candidate_ready" | "retiring_previous" | "previous_retired" | "activating_candidate" | "committed_pending" | "committed" | "restoring_committed"} GenerationTransitionPhase
|
|
28
|
-
* @typedef {{activationLifecycle?: boolean, candidateReleaseId: string, candidateReleasePath: string, candidateRevision: string, configDigest: string, error?: string, journalRevision?: number, phase: GenerationTransitionPhase, previousReleaseId: string | null, startedAt: string, updatedAt: string}} GenerationTransition
|
|
27
|
+
* @typedef {"candidate_ready" | "retiring_previous" | "previous_retired" | "activating_candidate" | "restoring_previous" | "retiring_failed_candidate" | "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
|
|
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
|
|
@@ -1337,6 +1337,15 @@ export default class RollbridgeDaemon {
|
|
|
1337
1337
|
}))
|
|
1338
1338
|
}
|
|
1339
1339
|
|
|
1340
|
+
if (commandName === "recover-generation-transition") {
|
|
1341
|
+
return await this.executeOwnerMutation("recover generation transition", async () => await this.recoverGenerationTransition({
|
|
1342
|
+
previousReleaseId: requiredString(data.previousReleaseId, "previousReleaseId"),
|
|
1343
|
+
releaseId: requiredString(data.releaseId, "releaseId"),
|
|
1344
|
+
releasePath: requiredString(data.releasePath, "releasePath"),
|
|
1345
|
+
revision: requiredString(data.revision, "revision")
|
|
1346
|
+
}))
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1340
1349
|
if (commandName === "status") {
|
|
1341
1350
|
return this.status()
|
|
1342
1351
|
}
|
|
@@ -1627,6 +1636,9 @@ export default class RollbridgeDaemon {
|
|
|
1627
1636
|
const transition = this.generationTransition
|
|
1628
1637
|
|
|
1629
1638
|
if (!transition) throw new Error("No release generation transition to resume")
|
|
1639
|
+
if (transition.phase === "restoring_previous" || transition.phase === "retiring_failed_candidate") {
|
|
1640
|
+
return await this.compensatePreCommitActivationFailure()
|
|
1641
|
+
}
|
|
1630
1642
|
const release = this.releases.get(transition.candidateReleaseId)
|
|
1631
1643
|
const previousRelease = transition.previousReleaseId ? this.releases.get(transition.previousReleaseId) : undefined
|
|
1632
1644
|
|
|
@@ -1668,7 +1680,23 @@ export default class RollbridgeDaemon {
|
|
|
1668
1680
|
|
|
1669
1681
|
await this.failGenerationTransition(failure)
|
|
1670
1682
|
this.logger("release generation activation failed", {error: failure, releaseId: release.releaseId})
|
|
1671
|
-
throw error
|
|
1683
|
+
if (!previousRelease || !activationLifecycle) throw error
|
|
1684
|
+
|
|
1685
|
+
let compensation
|
|
1686
|
+
|
|
1687
|
+
try {
|
|
1688
|
+
compensation = await this.compensatePreCommitActivationFailure()
|
|
1689
|
+
} catch (compensationError) {
|
|
1690
|
+
throw new AggregateError(
|
|
1691
|
+
[error, compensationError],
|
|
1692
|
+
`${failure}; pre-commit compensation failed: ${compensationError instanceof Error ? compensationError.message : String(compensationError)}`,
|
|
1693
|
+
{cause: compensationError}
|
|
1694
|
+
)
|
|
1695
|
+
}
|
|
1696
|
+
const compensatedError = /** @type {Error & {compensation?: Record<string, JsonValue>}} */ (new Error(`${failure}; compensation restored incumbent ${compensation.previousReleaseId} as authoritative and retired failed candidate ${compensation.candidateReleaseId}`, {cause: error}))
|
|
1697
|
+
|
|
1698
|
+
compensatedError.compensation = compensation
|
|
1699
|
+
throw compensatedError
|
|
1672
1700
|
}
|
|
1673
1701
|
|
|
1674
1702
|
// Activation acknowledgement and the logical proxy commit deliberately share one
|
|
@@ -1732,6 +1760,155 @@ export default class RollbridgeDaemon {
|
|
|
1732
1760
|
}
|
|
1733
1761
|
}
|
|
1734
1762
|
|
|
1763
|
+
/**
|
|
1764
|
+
* Restores the authoritative proxy target and retires only the failed candidate.
|
|
1765
|
+
* Every external effect is preceded by a durable phase checkpoint so recovery can
|
|
1766
|
+
* safely replay the paired lifecycle hooks, which are already idempotent contracts.
|
|
1767
|
+
* @returns {Promise<Record<string, JsonValue>>} Structured recovery result.
|
|
1768
|
+
*/
|
|
1769
|
+
async compensatePreCommitActivationFailure() {
|
|
1770
|
+
const transition = this.generationTransition
|
|
1771
|
+
|
|
1772
|
+
if (!transition) throw new Error("No release generation transition to compensate")
|
|
1773
|
+
if (transition.phase !== "activating_candidate" && transition.phase !== "restoring_previous" && transition.phase !== "retiring_failed_candidate") {
|
|
1774
|
+
throw new Error(`Generation transition ${transition.candidateReleaseId} at ${transition.phase} is not safe for pre-commit compensation`)
|
|
1775
|
+
}
|
|
1776
|
+
if (!transition.previousReleaseId) throw new Error("Pre-commit compensation requires a retained previous release")
|
|
1777
|
+
const candidate = this.releases.get(transition.candidateReleaseId)
|
|
1778
|
+
const previous = this.releases.get(transition.previousReleaseId)
|
|
1779
|
+
|
|
1780
|
+
if (!candidate) throw new Error(`Generation transition candidate ${transition.candidateReleaseId} is not retained`)
|
|
1781
|
+
if (!previous) throw new Error(`Generation transition previous release ${transition.previousReleaseId} is not retained`)
|
|
1782
|
+
if (this.activeRelease !== previous) throw new Error(`Previous release ${previous.releaseId} is not the authoritative proxy target`)
|
|
1783
|
+
|
|
1784
|
+
if (transition.phase === "activating_candidate") {
|
|
1785
|
+
if (!transition.error && !transition.activationError) throw new Error("Candidate activation has no recorded failure to compensate")
|
|
1786
|
+
transition.activationError = transition.activationError || transition.error
|
|
1787
|
+
transition.compensationError = undefined
|
|
1788
|
+
await this.updateGenerationTransition("retiring_failed_candidate")
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
if (transition.phase === "retiring_failed_candidate") {
|
|
1792
|
+
try {
|
|
1793
|
+
await candidate.beginRetirement(candidate.config, {retry: true})
|
|
1794
|
+
const candidateServiceIds = [...this.serviceReleaseIds.entries()]
|
|
1795
|
+
.filter(([, releaseId]) => releaseId === candidate.releaseId)
|
|
1796
|
+
.map(([serviceId]) => serviceId)
|
|
1797
|
+
|
|
1798
|
+
await this.stopStartedServices(candidateServiceIds)
|
|
1799
|
+
} catch (error) {
|
|
1800
|
+
const failure = error instanceof Error ? error.message : String(error)
|
|
1801
|
+
|
|
1802
|
+
transition.compensationError = failure
|
|
1803
|
+
await this.failGenerationTransition(`Failed candidate ${candidate.releaseId} retirement failed: ${failure}`)
|
|
1804
|
+
this.logger("release generation compensation candidate retirement failed", {
|
|
1805
|
+
activationError: transition.activationError,
|
|
1806
|
+
error: failure,
|
|
1807
|
+
releaseId: candidate.releaseId
|
|
1808
|
+
})
|
|
1809
|
+
throw new Error(`failed candidate ${candidate.releaseId} retirement failed: ${failure}`, {cause: error})
|
|
1810
|
+
}
|
|
1811
|
+
transition.compensationError = undefined
|
|
1812
|
+
await this.updateGenerationTransition("restoring_previous")
|
|
1813
|
+
this.logger("release generation compensation candidate retired", {releaseId: candidate.releaseId})
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
if (transition.phase === "restoring_previous") {
|
|
1817
|
+
if (candidate.state !== "draining") throw new Error(`Failed candidate ${candidate.releaseId} is not retired before incumbent restoration`)
|
|
1818
|
+
try {
|
|
1819
|
+
await previous.reactivateGeneration()
|
|
1820
|
+
} catch (error) {
|
|
1821
|
+
const failure = error instanceof Error ? error.message : String(error)
|
|
1822
|
+
|
|
1823
|
+
transition.compensationError = failure
|
|
1824
|
+
await this.failGenerationTransition(`Incumbent ${previous.releaseId} restoration failed: ${failure}`)
|
|
1825
|
+
this.logger("release generation compensation restoration failed", {
|
|
1826
|
+
activationError: transition.activationError,
|
|
1827
|
+
error: failure,
|
|
1828
|
+
releaseId: previous.releaseId
|
|
1829
|
+
})
|
|
1830
|
+
throw new Error(`incumbent ${previous.releaseId} restoration failed: ${failure}`, {cause: error})
|
|
1831
|
+
}
|
|
1832
|
+
this.activeRelease = previous
|
|
1833
|
+
transition.compensationError = undefined
|
|
1834
|
+
this.logger("release generation compensation incumbent restored", {releaseId: previous.releaseId})
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
const result = /** @type {Record<string, JsonValue>} */ ({
|
|
1838
|
+
activeReleaseId: previous.releaseId,
|
|
1839
|
+
activationError: transition.activationError,
|
|
1840
|
+
candidateReleaseId: candidate.releaseId,
|
|
1841
|
+
failedCandidateStatus: candidate.state,
|
|
1842
|
+
previousReleaseId: previous.releaseId,
|
|
1843
|
+
recoveryStatus: "recovered"
|
|
1844
|
+
})
|
|
1845
|
+
|
|
1846
|
+
this.config = previous.config
|
|
1847
|
+
this.generationTransition = undefined
|
|
1848
|
+
try {
|
|
1849
|
+
await this.checkpointGenerationTransition()
|
|
1850
|
+
} catch (error) {
|
|
1851
|
+
const failure = error instanceof Error ? error.message : String(error)
|
|
1852
|
+
|
|
1853
|
+
this.generationTransition = transition
|
|
1854
|
+
transition.compensationError = failure
|
|
1855
|
+
transition.error = `Compensation checkpoint clear failed: ${failure}`
|
|
1856
|
+
transition.journalRevision = (transition.journalRevision ?? 0) + 1
|
|
1857
|
+
transition.updatedAt = new Date().toISOString()
|
|
1858
|
+
await this.publishOwnerState().catch((publishError) => {
|
|
1859
|
+
this.logger("release generation compensation fence republish failed", {
|
|
1860
|
+
error: publishError instanceof Error ? publishError.message : String(publishError),
|
|
1861
|
+
releaseId: candidate.releaseId
|
|
1862
|
+
})
|
|
1863
|
+
})
|
|
1864
|
+
this.logger("release generation compensation checkpoint failed", {error: failure, releaseId: candidate.releaseId})
|
|
1865
|
+
throw new Error(`compensation checkpoint clear failed: ${failure}`, {cause: error})
|
|
1866
|
+
}
|
|
1867
|
+
void this.drainAndPrune(candidate, candidate.config)
|
|
1868
|
+
this.logger("release generation compensation completed", result)
|
|
1869
|
+
return result
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
/**
|
|
1873
|
+
* Authenticated control recovery for one exact failed pre-commit transition.
|
|
1874
|
+
* @param {{previousReleaseId: string, releaseId: string, releasePath: string, revision: string}} identity - Exact transition fence.
|
|
1875
|
+
* @returns {Promise<Record<string, JsonValue>>} Structured recovery result.
|
|
1876
|
+
*/
|
|
1877
|
+
async recoverGenerationTransition(identity) {
|
|
1878
|
+
const transition = this.generationTransition
|
|
1879
|
+
|
|
1880
|
+
if (!transition) {
|
|
1881
|
+
if (this.activeRelease?.releaseId !== identity.previousReleaseId || this.activeRelease.releaseId === identity.releaseId) {
|
|
1882
|
+
throw new Error("No matching recovered generation transition; the expected incumbent is not authoritative")
|
|
1883
|
+
}
|
|
1884
|
+
return {
|
|
1885
|
+
activeReleaseId: this.activeRelease.releaseId,
|
|
1886
|
+
candidateReleaseId: identity.releaseId,
|
|
1887
|
+
previousReleaseId: identity.previousReleaseId,
|
|
1888
|
+
recoveryStatus: "already_recovered"
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
if (transition.previousReleaseId !== identity.previousReleaseId) {
|
|
1892
|
+
throw new Error(`Generation transition previous release is ${transition.previousReleaseId}; refusing stale recovery for ${identity.previousReleaseId}`)
|
|
1893
|
+
}
|
|
1894
|
+
if (transition.phase !== "activating_candidate" && transition.phase !== "restoring_previous" && transition.phase !== "retiring_failed_candidate") {
|
|
1895
|
+
throw new Error(`Generation transition ${transition.candidateReleaseId} at ${transition.phase} is not a safe failed pre-commit transition`)
|
|
1896
|
+
}
|
|
1897
|
+
const nextConfig = this.configPath ? await loadConfig(this.configPath) : this.config
|
|
1898
|
+
|
|
1899
|
+
this.assertReloadCompatible(nextConfig)
|
|
1900
|
+
this.assertExactGenerationTransition(transition, {
|
|
1901
|
+
config: nextConfig,
|
|
1902
|
+
releaseId: identity.releaseId,
|
|
1903
|
+
releasePath: identity.releasePath,
|
|
1904
|
+
revision: identity.revision
|
|
1905
|
+
})
|
|
1906
|
+
if (this.activeRelease?.releaseId !== transition.previousReleaseId) {
|
|
1907
|
+
throw new Error(`Previous release ${transition.previousReleaseId} is not the authoritative proxy target`)
|
|
1908
|
+
}
|
|
1909
|
+
return await this.compensatePreCommitActivationFailure()
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1735
1912
|
/**
|
|
1736
1913
|
* @param {GenerationTransition} transition - Pending or committed exact transition.
|
|
1737
1914
|
* @param {{config: import("./config.js").RollbridgeConfig, releaseId: string, releasePath: string, revision: string}} candidate - Requested identity.
|
package/src/guardian-client.js
CHANGED
|
@@ -23,6 +23,7 @@ export default class GuardianClient {
|
|
|
23
23
|
this.processes = /** @type {Map<string, GuardianProcess>} */ (new Map())
|
|
24
24
|
this.reservedProcessKey = /** @type {string | undefined} */ (undefined)
|
|
25
25
|
this.reservedProcessProvenance = /** @type {string | undefined} */ (undefined)
|
|
26
|
+
this.generationReactivation = /** @type {boolean | undefined} */ (undefined)
|
|
26
27
|
this.events = /** @type {Map<string, {reject: (error: Error) => void, resolve: (value: Record<string, import("./json.js").JsonValue>) => void}[]>} */ (new Map())
|
|
27
28
|
this.eventHandlers = /** @type {Map<string, ((event: Record<string, import("./json.js").JsonValue>) => void)[]>} */ (new Map())
|
|
28
29
|
}
|
|
@@ -51,6 +52,7 @@ export default class GuardianClient {
|
|
|
51
52
|
if (child.connected) await new Promise((resolve) => child.once("disconnect", () => resolve(undefined)))
|
|
52
53
|
child.unref()
|
|
53
54
|
await this.connect()
|
|
55
|
+
this.generationReactivation = true
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/**
|
|
@@ -184,9 +186,12 @@ export default class GuardianClient {
|
|
|
184
186
|
await this.request({command: "owner-ready", ownerPid: process.pid})
|
|
185
187
|
}
|
|
186
188
|
|
|
187
|
-
/** @returns {Promise<{daemonRecovery: number}>} Guardian protocol capabilities. */
|
|
189
|
+
/** @returns {Promise<{daemonRecovery: number, generationReactivation?: number}>} Guardian protocol capabilities. */
|
|
188
190
|
async capabilities() {
|
|
189
|
-
|
|
191
|
+
const capabilities = /** @type {{daemonRecovery: number, generationReactivation?: number}} */ (await this.request({command: "capabilities"}))
|
|
192
|
+
|
|
193
|
+
this.generationReactivation = capabilities.generationReactivation === 1
|
|
194
|
+
return capabilities
|
|
190
195
|
}
|
|
191
196
|
|
|
192
197
|
/** Starts graceful process retirement and relinquishes committed owner authority. */
|
|
@@ -445,6 +450,7 @@ class GuardianProcess extends ManagedProcess {
|
|
|
445
450
|
this.cachedStatus = super.status()
|
|
446
451
|
this.registration = /** @type {Promise<void> | undefined} */ (undefined)
|
|
447
452
|
this.pendingUpdate = Promise.resolve()
|
|
453
|
+
this.compatibilityReactivated = false
|
|
448
454
|
}
|
|
449
455
|
|
|
450
456
|
async ensureRegistered() {
|
|
@@ -479,6 +485,7 @@ class GuardianProcess extends ManagedProcess {
|
|
|
479
485
|
await this.pendingUpdate
|
|
480
486
|
if (lifecycleRole) this.lifecycleRole = lifecycleRole
|
|
481
487
|
this.cachedStatus = asProcessStatus(await this.client.request({command: "start", key: this.key, lifecycleRole, reason}))
|
|
488
|
+
if (this.cachedStatus.state === "running") this.compatibilityReactivated = false
|
|
482
489
|
}
|
|
483
490
|
|
|
484
491
|
/**
|
|
@@ -529,12 +536,52 @@ class GuardianProcess extends ManagedProcess {
|
|
|
529
536
|
this.lifecycleRole = "active"
|
|
530
537
|
}
|
|
531
538
|
|
|
539
|
+
async reactivateStrict() {
|
|
540
|
+
await this.ensureRegistered()
|
|
541
|
+
await this.pendingUpdate
|
|
542
|
+
const command = this.lifecycle.reactivateCommand ? "reactivate-with-command" : "reactivate"
|
|
543
|
+
try {
|
|
544
|
+
this.cachedStatus = asProcessStatus(await this.client.request({command, key: this.key}))
|
|
545
|
+
this.client.generationReactivation = true
|
|
546
|
+
this.compatibilityReactivated = false
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if (!(error instanceof Error) || error.message !== `Unknown guardian command: ${command}`) throw error
|
|
549
|
+
this.client.generationReactivation = false
|
|
550
|
+
await this.adoptRetainedActive({activate: true})
|
|
551
|
+
}
|
|
552
|
+
this.lifecycleRole = "active"
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Bridges a retained quiesced process owned by a pre-reactivation guardian.
|
|
557
|
+
* @param {{activate: boolean}} options - Whether to run the external activation acknowledgement.
|
|
558
|
+
*/
|
|
559
|
+
async adoptRetainedActive({activate}) {
|
|
560
|
+
const before = this.cachedStatus
|
|
561
|
+
|
|
562
|
+
if (!before.pid || (before.state !== "quiesced" && before.state !== "running")) throw new Error(`Process ${this.id} is not retained for compatible reactivation`)
|
|
563
|
+
if (activate) await this.runReactivationHook(before.pid)
|
|
564
|
+
const verified = asProcessStatus(await this.client.request({command: "status", key: this.key}))
|
|
565
|
+
|
|
566
|
+
if (verified.pid !== before.pid || (verified.state !== "quiesced" && verified.state !== "running")) {
|
|
567
|
+
throw new Error(`Process ${this.id} exited before compatible reactivation completed`)
|
|
568
|
+
}
|
|
569
|
+
const adopted = asProcessStatus(await this.client.request({command: "start", key: this.key, lifecycleRole: "active", reason: "deploy"}))
|
|
570
|
+
|
|
571
|
+
if (adopted.pid !== before.pid) throw new Error(`Process ${this.id} changed before compatible reactivation was adopted`)
|
|
572
|
+
this.cachedStatus = asProcessStatus({...adopted, lifecycleRole: "active", state: "running"})
|
|
573
|
+
this.compatibilityReactivated = true
|
|
574
|
+
}
|
|
575
|
+
|
|
532
576
|
/** @param {import("./managed-process.js").LifecycleRole} role - Exact generation role. */
|
|
533
577
|
async setLifecycleRole(role) {
|
|
534
578
|
await this.ensureRegistered()
|
|
535
579
|
await this.pendingUpdate
|
|
536
580
|
this.cachedStatus = asProcessStatus(await this.client.request({command: "set-lifecycle-role", key: this.key, lifecycleRole: role}))
|
|
537
581
|
this.lifecycleRole = role
|
|
582
|
+
if (role === "active" && this.client.generationReactivation === false && this.cachedStatus.state === "quiesced") {
|
|
583
|
+
await this.adoptRetainedActive({activate: false})
|
|
584
|
+
}
|
|
538
585
|
}
|
|
539
586
|
|
|
540
587
|
async stop(options = {}) {
|
|
@@ -550,6 +597,12 @@ class GuardianProcess extends ManagedProcess {
|
|
|
550
597
|
/** @param {Record<string, import("./json.js").JsonValue>} event - Guardian event. */
|
|
551
598
|
onGuardianEvent(event) {
|
|
552
599
|
if (event.status) this.cachedStatus = asProcessStatus(event.status)
|
|
600
|
+
if (this.compatibilityReactivated && (this.cachedStatus.state === "failed" || this.cachedStatus.state === "stopped") && this.shouldRestart()) {
|
|
601
|
+
this.compatibilityReactivated = false
|
|
602
|
+
void this.start("crash", this.lifecycleRole).catch((error) => {
|
|
603
|
+
this.logger("compatible reactivated process restart failed", {error: error instanceof Error ? error.message : String(error), id: this.id})
|
|
604
|
+
})
|
|
605
|
+
}
|
|
553
606
|
if (event.event === "process-log") {
|
|
554
607
|
const entry = asProcessLog(event.entry)
|
|
555
608
|
|
package/src/managed-process.js
CHANGED
|
@@ -486,14 +486,14 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
486
486
|
if (this.lifecycleRestoreBarrier) await this.lifecycleRestoreBarrier
|
|
487
487
|
if (!this.child?.pid) {
|
|
488
488
|
this.state = "stopped"
|
|
489
|
-
if (this.
|
|
489
|
+
if (this.hasRestorableLifecycle()) this.lifecycleRole = "retired"
|
|
490
490
|
return
|
|
491
491
|
}
|
|
492
492
|
this.state = "stopping"
|
|
493
493
|
if (this.lifecycle.quietCommand) this.quiesceError = await this.runHook(this.lifecycle.quietCommand, this.hookTimeoutMs(), "quiet command")
|
|
494
494
|
if (!this.quiesceError) {
|
|
495
495
|
this.state = "quiesced"
|
|
496
|
-
if (this.
|
|
496
|
+
if (this.hasRestorableLifecycle()) this.lifecycleRole = "retired"
|
|
497
497
|
}
|
|
498
498
|
})()
|
|
499
499
|
return await this.quiescePromise
|
|
@@ -521,11 +521,63 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
521
521
|
const pid = this.pid
|
|
522
522
|
|
|
523
523
|
if (!child?.pid || child.pid !== pid || this.state !== "running") throw new Error(`Process ${this.id} is not running for activation`)
|
|
524
|
+
await this.runActivationHook(pid)
|
|
525
|
+
if (this.child !== child || this.pid !== pid || this.state !== "running") throw new Error(`Process ${this.id} exited before activation completed`)
|
|
526
|
+
this.lifecycleRole = "active"
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Reactivates an explicitly retired process without replacing its release-scoped process. */
|
|
530
|
+
async reactivateStrict() {
|
|
531
|
+
const child = this.child
|
|
532
|
+
const pid = this.pid
|
|
533
|
+
|
|
534
|
+
if (!child?.pid || child.pid !== pid || (this.state !== "quiesced" && this.state !== "running")) {
|
|
535
|
+
throw new Error(`Process ${this.id} is not retained for reactivation`)
|
|
536
|
+
}
|
|
537
|
+
await this.runReactivationHook(pid)
|
|
538
|
+
if (this.child !== child || this.pid !== pid) throw new Error(`Process ${this.id} exited before reactivation completed`)
|
|
539
|
+
this.intentionalStop = false
|
|
540
|
+
this.intentionalStopSignal = undefined
|
|
541
|
+
this.quiescePromise = undefined
|
|
542
|
+
this.quiesceError = undefined
|
|
543
|
+
this.state = "running"
|
|
544
|
+
this.lifecycleRole = "active"
|
|
545
|
+
this.startMemoryMonitor()
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Runs the generation activation hook against one exact retained process.
|
|
550
|
+
* @param {number | undefined} pid - Exact process group leader.
|
|
551
|
+
* @returns {Promise<void>} Resolves after acknowledgement.
|
|
552
|
+
*/
|
|
553
|
+
async runActivationHook(pid) {
|
|
554
|
+
const command = this.lifecycle.activateCommand
|
|
555
|
+
|
|
556
|
+
if (!command) return
|
|
524
557
|
const error = await this.runHook(command, ACTIVATION_HOOK_TIMEOUT_MS, "activate command", pid)
|
|
525
558
|
|
|
526
559
|
if (error) throw error
|
|
527
|
-
|
|
528
|
-
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Runs the process-specific reactivation hook, falling back to the generation
|
|
564
|
+
* coordinator's activation hook for the original lifecycle contract.
|
|
565
|
+
* @param {number | undefined} pid - Exact process group leader.
|
|
566
|
+
* @returns {Promise<void>} Resolves after acknowledgement.
|
|
567
|
+
*/
|
|
568
|
+
async runReactivationHook(pid) {
|
|
569
|
+
const command = this.lifecycle.reactivateCommand ?? this.lifecycle.activateCommand
|
|
570
|
+
|
|
571
|
+
if (!command) return
|
|
572
|
+
const label = this.lifecycle.reactivateCommand ? "reactivate command" : "activate command"
|
|
573
|
+
const error = await this.runHook(command, ACTIVATION_HOOK_TIMEOUT_MS, label, pid)
|
|
574
|
+
|
|
575
|
+
if (error) throw error
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** @returns {boolean} Whether this process owns a durable external lifecycle role. */
|
|
579
|
+
hasRestorableLifecycle() {
|
|
580
|
+
return Boolean(this.lifecycle.activateCommand || this.lifecycle.reactivateCommand)
|
|
529
581
|
}
|
|
530
582
|
|
|
531
583
|
/**
|
|
@@ -538,12 +590,15 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
538
590
|
|
|
539
591
|
/** Restores an active or retired role after this exact process starts. */
|
|
540
592
|
async restoreLifecycleRole() {
|
|
541
|
-
if (!this.
|
|
542
|
-
const command = this.lifecycleRole === "active"
|
|
593
|
+
if (!this.hasRestorableLifecycle() || this.lifecycleRole === "candidate") return
|
|
594
|
+
const command = this.lifecycleRole === "active"
|
|
595
|
+
? this.lifecycle.reactivateCommand ?? this.lifecycle.activateCommand
|
|
596
|
+
: this.lifecycle.quietCommand
|
|
543
597
|
|
|
544
598
|
if (!command) throw new Error(`Process ${this.id} cannot restore lifecycle role ${this.lifecycleRole} without its paired command`)
|
|
545
599
|
const timeoutMs = this.lifecycleRole === "active" ? ACTIVATION_HOOK_TIMEOUT_MS : this.hookTimeoutMs()
|
|
546
|
-
const
|
|
600
|
+
const activeLabel = this.lifecycle.reactivateCommand ? "reactivate" : "activate"
|
|
601
|
+
const error = await this.runHook(command, timeoutMs, `${this.lifecycleRole === "active" ? activeLabel : "quiet"} command`, this.pid)
|
|
547
602
|
|
|
548
603
|
if (error) throw error
|
|
549
604
|
}
|
|
@@ -760,7 +815,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
760
815
|
id: this.id,
|
|
761
816
|
lastMemoryRestartAt: this.lastMemoryRestartAtMs === undefined ? undefined : new Date(this.lastMemoryRestartAtMs).toISOString(),
|
|
762
817
|
lastStartReason: this.lastStartReason,
|
|
763
|
-
...(this.
|
|
818
|
+
...(this.hasRestorableLifecycle() ? {lifecycleRole: this.lifecycleRole} : {}),
|
|
764
819
|
logs: this.logs.slice(-this.outputLines),
|
|
765
820
|
memoryRestarts: this.memoryRestarts,
|
|
766
821
|
pid: this.pid,
|
package/src/process-guardian.js
CHANGED
|
@@ -259,7 +259,7 @@ async function handleLine(socket, line) {
|
|
|
259
259
|
async function execute(request, socket) {
|
|
260
260
|
if (shuttingDown) throw new Error("Process guardian is shutting down")
|
|
261
261
|
|
|
262
|
-
if (request.command === "capabilities") return {daemonRecovery: 1}
|
|
262
|
+
if (request.command === "capabilities") return {daemonRecovery: 1, generationReactivation: 1}
|
|
263
263
|
|
|
264
264
|
if (request.command === "owner-replacement-capabilities") {
|
|
265
265
|
return {commands: ["commit-retired-owner-replacement"], protocol: "owner-replacement", version: 1}
|
|
@@ -655,6 +655,9 @@ async function execute(request, socket) {
|
|
|
655
655
|
await record.process.start(request.reason, request.lifecycleRole)
|
|
656
656
|
} else if (request.command === "activate") {
|
|
657
657
|
await record.process.activateStrict()
|
|
658
|
+
} else if (request.command === "reactivate" || request.command === "reactivate-with-command") {
|
|
659
|
+
await record.process.reactivateStrict()
|
|
660
|
+
record.desired = true
|
|
658
661
|
} else if (request.command === "quiesce") {
|
|
659
662
|
record.desired = false
|
|
660
663
|
await record.process.quiesceStrict()
|
package/src/release-group.js
CHANGED
|
@@ -279,6 +279,28 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
279
279
|
await instance.process.activateStrict()
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
/** Restores the retained generation coordinator to its active role in place. */
|
|
283
|
+
async reactivateGeneration() {
|
|
284
|
+
if (this.state !== "draining" && this.state !== "active") throw new Error(`Generation ${this.releaseId} is not retained for reactivation`)
|
|
285
|
+
const processConfig = this.config.processes.find((candidate) => candidate.lifecycle.activateCommand !== undefined)
|
|
286
|
+
|
|
287
|
+
if (!processConfig) throw new Error(`Generation ${this.releaseId} has no activation lifecycle`)
|
|
288
|
+
const [coordinator] = this.getProcesses(processConfig.id)
|
|
289
|
+
|
|
290
|
+
if (!coordinator) throw new Error(`Generation activation process ${processConfig.id} is not retained for release ${this.releaseId}`)
|
|
291
|
+
const generationIds = new Set([...this.handoffServiceIds, ...this.nonBlockingDrainIds])
|
|
292
|
+
|
|
293
|
+
for (const [id, processInstance] of this.processes) {
|
|
294
|
+
if (generationIds.has(id) && processInstance !== coordinator.process) await processInstance.reactivateStrict()
|
|
295
|
+
}
|
|
296
|
+
await coordinator.process.reactivateStrict()
|
|
297
|
+
this.state = "active"
|
|
298
|
+
this.activatedAt = new Date().toISOString()
|
|
299
|
+
this.drainStartedAt = undefined
|
|
300
|
+
this.retirementError = undefined
|
|
301
|
+
this.stoppedAt = undefined
|
|
302
|
+
}
|
|
303
|
+
|
|
282
304
|
/**
|
|
283
305
|
* Restarts only the exact processes reconstructed for a committed generation.
|
|
284
306
|
* The caller must prove the durable transition identity before using this path.
|
package/test/completion.test.js
CHANGED
|
@@ -41,9 +41,10 @@ test("completion bash prints a sourceable script with commands and option flags"
|
|
|
41
41
|
|
|
42
42
|
assert.notEqual(code, 1)
|
|
43
43
|
assert.match(output, /complete -F _rollbridge rollbridge/)
|
|
44
|
-
assert.match(output, /compgen -W "daemon deploy rollback ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion"/)
|
|
44
|
+
assert.match(output, /compgen -W "daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion"/)
|
|
45
45
|
// A command's own options are completed after the command.
|
|
46
46
|
assert.match(output, /deploy\)\n\s+opts="[^"]*--release-path[^"]*"/)
|
|
47
|
+
assert.match(output, /recover-generation-transition\)\n\s+opts="--config --release-path --release-id --revision --previous-release-id"/)
|
|
47
48
|
assert.match(output, /ensure-daemon\)\n\s+opts="[^"]*--daemon-runtime-path[^"]*"/)
|
|
48
49
|
assert.match(output, /restart\)\n\s+opts="[^"]*--policy[^"]*"/)
|
|
49
50
|
})
|
|
@@ -53,7 +54,8 @@ test("completion zsh prints a #compdef script with per-command options", async (
|
|
|
53
54
|
|
|
54
55
|
assert.match(output, /^#compdef rollbridge/)
|
|
55
56
|
assert.match(output, /compdef _rollbridge rollbridge/)
|
|
56
|
-
assert.match(output, /commands=\(daemon deploy rollback ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion\)/)
|
|
57
|
+
assert.match(output, /commands=\(daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion\)/)
|
|
58
|
+
assert.match(output, /recover-generation-transition\) compadd -- --config --release-path --release-id --revision --previous-release-id/)
|
|
57
59
|
assert.match(output, /events\) compadd -- [^\n]*--limit/)
|
|
58
60
|
})
|
|
59
61
|
|
|
@@ -27,6 +27,7 @@ test("TensorBuzz example config loads", async () => {
|
|
|
27
27
|
["web", "proxied"]
|
|
28
28
|
]
|
|
29
29
|
)
|
|
30
|
+
assert.equal(config.processes[2].lifecycle.reactivateCommand, "appctl jobs-worker-reactivate --pid $ROLLBRIDGE_PID")
|
|
30
31
|
assert.equal(config.processes[3].env.VELOCIOUS_BACKGROUND_JOBS_PORT, "{{ports.background-jobs-main}}")
|
|
31
32
|
})
|
|
32
33
|
|
|
@@ -250,6 +250,24 @@ test("validateConfig accepts one durable handoff activation lifecycle and reject
|
|
|
250
250
|
{...base.processes[1], id: "jobs-secondary", port: {from: 18200, to: 18299}}
|
|
251
251
|
]})
|
|
252
252
|
assert.ok(duplicate.issues.some((issue) => /at most one lifecycle\.activateCommand/.test(issue.message)))
|
|
253
|
+
|
|
254
|
+
const worker = {
|
|
255
|
+
command: "run worker",
|
|
256
|
+
id: "worker",
|
|
257
|
+
lifecycle: {quietCommand: "worker quiet", reactivateCommand: "worker resume"},
|
|
258
|
+
nonBlockingDrain: true,
|
|
259
|
+
policy: "companion"
|
|
260
|
+
}
|
|
261
|
+
const pairedWorker = validateConfig({...base, processes: [...base.processes, worker]})
|
|
262
|
+
|
|
263
|
+
assert.deepEqual(pairedWorker.issues, [])
|
|
264
|
+
assert.equal(pairedWorker.config.processes[2].lifecycle.reactivateCommand, "worker resume")
|
|
265
|
+
|
|
266
|
+
const unpairedWorker = validateConfig({...base, processes: [...base.processes, {...worker, lifecycle: {quietCommand: "worker quiet"}}]})
|
|
267
|
+
assert.ok(unpairedWorker.issues.some((issue) => /quietCommand requires lifecycle\.reactivateCommand/.test(issue.message)))
|
|
268
|
+
|
|
269
|
+
const unsupportedPlacement = validateConfig({...base, processes: [...base.processes, {...worker, nonBlockingDrain: false}]})
|
|
270
|
+
assert.ok(unsupportedPlacement.issues.some((issue) => /reactivateCommand.*nonBlockingDrain companion/.test(issue.message)))
|
|
253
271
|
})
|
|
254
272
|
|
|
255
273
|
test("validateConfig accepts indefinite graceful stop windows", () => {
|
|
@@ -18,7 +18,7 @@ test("guardian bootstrap capability is absent from process argv", async () => {
|
|
|
18
18
|
const fixture = await createGuardian()
|
|
19
19
|
|
|
20
20
|
try {
|
|
21
|
-
assert.deepEqual(await fixture.client.capabilities(), {daemonRecovery: 1})
|
|
21
|
+
assert.deepEqual(await fixture.client.capabilities(), {daemonRecovery: 1, generationReactivation: 1})
|
|
22
22
|
const commandLine = await fs.readFile(`/proc/${fixture.client.pid}/cmdline`, "utf8")
|
|
23
23
|
const environment = await fs.readFile(`/proc/${fixture.client.pid}/environ`, "utf8")
|
|
24
24
|
const status = await fs.readFile(`/proc/${fixture.client.pid}/status`, "utf8")
|
|
@@ -70,6 +70,87 @@ test("guardian runs a strict activation lifecycle command for the exact register
|
|
|
70
70
|
}
|
|
71
71
|
})
|
|
72
72
|
|
|
73
|
+
test("client reactivates a retained process through a guardian without the reactivation command", async () => {
|
|
74
|
+
const fixture = await createGuardian()
|
|
75
|
+
const lifecyclePath = path.join(fixture.root, "lifecycle.log")
|
|
76
|
+
const processInstance = fixture.client.process("compatible-reactivation", {
|
|
77
|
+
...definition("compatible-reactivation"),
|
|
78
|
+
lifecycle: {
|
|
79
|
+
activateCommand: `printf 'activate\n' >> ${JSON.stringify(lifecyclePath)}`,
|
|
80
|
+
drainTimeoutMs: 0,
|
|
81
|
+
quietCommand: `printf 'retire\n' >> ${JSON.stringify(lifecyclePath)}`
|
|
82
|
+
},
|
|
83
|
+
shouldRestart: () => true
|
|
84
|
+
})
|
|
85
|
+
const request = fixture.client.request.bind(fixture.client)
|
|
86
|
+
|
|
87
|
+
fixture.client.request = async command => {
|
|
88
|
+
if (command.command === "reactivate") throw new Error("Unknown guardian command: reactivate")
|
|
89
|
+
return await request(command)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await processInstance.start()
|
|
94
|
+
await processInstance.activateStrict()
|
|
95
|
+
const pid = processInstance.status().pid
|
|
96
|
+
|
|
97
|
+
await processInstance.quiesceStrict()
|
|
98
|
+
await processInstance.reactivateStrict()
|
|
99
|
+
|
|
100
|
+
assert.equal(processInstance.status().pid, pid)
|
|
101
|
+
assert.equal(processInstance.status().state, "running")
|
|
102
|
+
assert.equal(processInstance.status().lifecycleRole, "active")
|
|
103
|
+
assert.equal(await fs.readFile(lifecyclePath, "utf8"), "activate\nretire\nactivate\n")
|
|
104
|
+
|
|
105
|
+
const restarted = once(processInstance, "started")
|
|
106
|
+
|
|
107
|
+
assert.ok(pid)
|
|
108
|
+
process.kill(-pid, "SIGKILL")
|
|
109
|
+
await restarted
|
|
110
|
+
assert.notEqual(processInstance.status().pid, pid)
|
|
111
|
+
assert.equal(processInstance.status().state, "running")
|
|
112
|
+
assert.equal(processInstance.status().lifecycleRole, "active")
|
|
113
|
+
assert.equal(await fs.readFile(lifecyclePath, "utf8"), "activate\nretire\nactivate\nactivate\n")
|
|
114
|
+
} finally {
|
|
115
|
+
await cleanupGuardian(fixture)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test("client reverses a worker quiet hook through a pre-reactivation guardian", async () => {
|
|
120
|
+
const fixture = await createGuardian()
|
|
121
|
+
const lifecyclePath = path.join(fixture.root, "worker-lifecycle.log")
|
|
122
|
+
const processInstance = fixture.client.process("compatible-worker-reactivation", {
|
|
123
|
+
...definition("compatible-worker-reactivation"),
|
|
124
|
+
lifecycle: {
|
|
125
|
+
drainTimeoutMs: 0,
|
|
126
|
+
quietCommand: `printf 'quiet\n' >> ${JSON.stringify(lifecyclePath)}`,
|
|
127
|
+
reactivateCommand: `printf 'resume\n' >> ${JSON.stringify(lifecyclePath)}`
|
|
128
|
+
},
|
|
129
|
+
shouldRestart: () => true
|
|
130
|
+
})
|
|
131
|
+
const request = fixture.client.request.bind(fixture.client)
|
|
132
|
+
|
|
133
|
+
fixture.client.request = async command => {
|
|
134
|
+
if (command.command === "reactivate-with-command") throw new Error("Unknown guardian command: reactivate-with-command")
|
|
135
|
+
return await request(command)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await processInstance.start()
|
|
140
|
+
const pid = processInstance.status().pid
|
|
141
|
+
|
|
142
|
+
await processInstance.quiesceStrict()
|
|
143
|
+
await processInstance.reactivateStrict()
|
|
144
|
+
|
|
145
|
+
assert.equal(processInstance.status().pid, pid)
|
|
146
|
+
assert.equal(processInstance.status().state, "running")
|
|
147
|
+
assert.equal(processInstance.status().lifecycleRole, "active")
|
|
148
|
+
assert.equal(await fs.readFile(lifecyclePath, "utf8"), "quiet\nresume\n")
|
|
149
|
+
} finally {
|
|
150
|
+
await cleanupGuardian(fixture)
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
73
154
|
test("guardian atomically updates process provenance with private owner state", async () => {
|
|
74
155
|
const fixture = await createGuardian()
|
|
75
156
|
const processInstance = fixture.client.process("service", definition("service"))
|
|
@@ -505,6 +505,33 @@ test("activateStrict rejects an activation request when its process is not runni
|
|
|
505
505
|
assert.equal(hookRan, false)
|
|
506
506
|
})
|
|
507
507
|
|
|
508
|
+
test("reactivateStrict restores a retained quiesced process only after activation succeeds", async () => {
|
|
509
|
+
const managed = buildLongLived(() => false)
|
|
510
|
+
const hooks = /** @type {string[]} */ ([])
|
|
511
|
+
|
|
512
|
+
managed.lifecycle = {activateCommand: "jobs activate", drainTimeoutMs: 0, quietCommand: "jobs retire"}
|
|
513
|
+
managed.runHook = async (_command, _timeoutMs, label) => {
|
|
514
|
+
hooks.push(label)
|
|
515
|
+
if (label === "activate command" && hooks.length === 2) return new Error("restoration rejected")
|
|
516
|
+
return undefined
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
await managed.start()
|
|
521
|
+
await managed.quiesceStrict()
|
|
522
|
+
await assert.rejects(() => managed.reactivateStrict(), /restoration rejected/)
|
|
523
|
+
assert.equal(managed.status().state, "quiesced")
|
|
524
|
+
assert.equal(managed.status().lifecycleRole, "retired")
|
|
525
|
+
|
|
526
|
+
await managed.reactivateStrict()
|
|
527
|
+
assert.equal(managed.status().state, "running")
|
|
528
|
+
assert.equal(managed.status().lifecycleRole, "active")
|
|
529
|
+
assert.deepEqual(hooks, ["quiet command", "activate command", "activate command"])
|
|
530
|
+
} finally {
|
|
531
|
+
await managed.stop()
|
|
532
|
+
}
|
|
533
|
+
})
|
|
534
|
+
|
|
508
535
|
test("quiesce waits for active-role restoration before retiring a restarted process", async () => {
|
|
509
536
|
const managed = buildLongLived(() => false)
|
|
510
537
|
const hooks = /** @type {string[]} */ ([])
|
|
@@ -748,7 +748,7 @@ test("guardian recovery becomes ready before replaying a gated generation hook",
|
|
|
748
748
|
}
|
|
749
749
|
})
|
|
750
750
|
|
|
751
|
-
test("owner recovery preserves a
|
|
751
|
+
test("owner recovery preserves a completed activation compensation without replaying hooks", async () => {
|
|
752
752
|
const fixture = await createFixture({activationFailureRelease: "v2"})
|
|
753
753
|
let owner = spawnDaemon(fixture.configPath)
|
|
754
754
|
|
|
@@ -762,7 +762,7 @@ test("owner recovery preserves a failed generation transition without firing hoo
|
|
|
762
762
|
sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}),
|
|
763
763
|
/activate command exited non-zero/
|
|
764
764
|
)
|
|
765
|
-
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"])
|
|
765
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"])
|
|
766
766
|
|
|
767
767
|
owner.kill("SIGKILL")
|
|
768
768
|
await once(owner, "exit")
|
|
@@ -771,13 +771,9 @@ test("owner recovery preserves a failed generation transition without firing hoo
|
|
|
771
771
|
|
|
772
772
|
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
773
773
|
|
|
774
|
-
assert.equal(recovered.
|
|
775
|
-
assert.
|
|
776
|
-
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"], "owner recovery
|
|
777
|
-
|
|
778
|
-
await fs.writeFile(fixture.activationGatePath, "allow\n")
|
|
779
|
-
await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
|
|
780
|
-
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
|
|
774
|
+
assert.equal(recovered.activeReleaseId, "v1")
|
|
775
|
+
assert.equal(recovered.generationTransition, undefined)
|
|
776
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"], "owner recovery must not replay completed compensation hooks")
|
|
781
777
|
|
|
782
778
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
783
779
|
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
|
|
@@ -789,17 +785,14 @@ test("owner recovery preserves a failed generation transition without firing hoo
|
|
|
789
785
|
}
|
|
790
786
|
})
|
|
791
787
|
|
|
792
|
-
test("owner recovery replays one journaled ambiguous activation by exact
|
|
793
|
-
const fixture = await createFixture({activationFailureRelease: "
|
|
788
|
+
test("owner recovery replays one journaled ambiguous first-generation activation by exact identity", async () => {
|
|
789
|
+
const fixture = await createFixture({activationFailureRelease: "v1"})
|
|
794
790
|
let owner = spawnDaemon(fixture.configPath)
|
|
795
791
|
|
|
796
792
|
try {
|
|
797
793
|
await waitForLog(owner, "control socket listening")
|
|
798
794
|
const v1Path = await prepareRelease(fixture.root, "v1")
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
|
|
802
|
-
await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}))
|
|
795
|
+
await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath}))
|
|
803
796
|
owner.kill("SIGKILL")
|
|
804
797
|
await once(owner, "exit")
|
|
805
798
|
|
|
@@ -818,12 +811,12 @@ test("owner recovery replays one journaled ambiguous activation by exact generat
|
|
|
818
811
|
|
|
819
812
|
const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
|
|
820
813
|
|
|
821
|
-
assert.equal(recovered.activeReleaseId, "
|
|
814
|
+
assert.equal(recovered.activeReleaseId, "v1")
|
|
822
815
|
assert.equal(recovered.generationTransition?.phase, "committed")
|
|
823
|
-
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"
|
|
816
|
+
assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
|
|
824
817
|
|
|
825
818
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
|
|
826
|
-
await
|
|
819
|
+
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
|
|
827
820
|
await shutdown
|
|
828
821
|
} finally {
|
|
829
822
|
await killChild(owner)
|
|
@@ -1649,7 +1649,7 @@ test("pruned release connection completion closes the incumbent listener session
|
|
|
1649
1649
|
assert.equal(daemon.incumbentListenerControl, session)
|
|
1650
1650
|
})
|
|
1651
1651
|
|
|
1652
|
-
test("same-authority owner replacement preserves
|
|
1652
|
+
test("same-authority owner replacement preserves completed activation compensation without replaying hooks", async () => {
|
|
1653
1653
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-failed-generation-"))
|
|
1654
1654
|
const oldSocketPath = path.join(root, "old.sock")
|
|
1655
1655
|
const statePath = path.join(root, "state.json")
|
|
@@ -1677,18 +1677,15 @@ test("same-authority owner replacement preserves a failed generation transition
|
|
|
1677
1677
|
await waitForLog(owner, "control socket listening")
|
|
1678
1678
|
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldSocketPath})
|
|
1679
1679
|
await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: oldSocketPath}), /activate command exited non-zero/)
|
|
1680
|
-
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n")
|
|
1680
|
+
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\nretire:v2\nactivate:v1\n")
|
|
1681
1681
|
|
|
1682
1682
|
await writeConfig(configPath, failedConfig(oldSocketPath, false))
|
|
1683
1683
|
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1684
1684
|
await waitForLog(candidate, "owner replacement committed")
|
|
1685
1685
|
const status = await sendControlCommand({command: {command: "status"}, path: oldSocketPath})
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
assert.
|
|
1689
|
-
assert.equal(generationTransition.phase, "activating_candidate")
|
|
1690
|
-
assert.match(String(generationTransition.error), /activate command exited non-zero/)
|
|
1691
|
-
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n", "replacement must preserve, not retry, the failed activation")
|
|
1686
|
+
assert.equal(status.activeReleaseId, "v1")
|
|
1687
|
+
assert.equal(status.generationTransition, undefined)
|
|
1688
|
+
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\nretire:v2\nactivate:v1\n", "replacement must not replay completed compensation hooks")
|
|
1692
1689
|
|
|
1693
1690
|
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: oldSocketPath})
|
|
1694
1691
|
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
|
|
@@ -1700,7 +1697,7 @@ test("same-authority owner replacement preserves a failed generation transition
|
|
|
1700
1697
|
}
|
|
1701
1698
|
})
|
|
1702
1699
|
|
|
1703
|
-
test("config-changing owner replacement
|
|
1700
|
+
test("config-changing owner replacement proceeds after activation compensation clears the transition", async () => {
|
|
1704
1701
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-unresolved-config-"))
|
|
1705
1702
|
const oldSocketPath = path.join(root, "old.sock")
|
|
1706
1703
|
const newSocketPath = path.join(root, "new.sock")
|
|
@@ -1734,16 +1731,13 @@ test("config-changing owner replacement rejects an unresolved generation transit
|
|
|
1734
1731
|
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1735
1732
|
const result = await collectUntilExitOrLog(candidate, "owner replacement committed")
|
|
1736
1733
|
|
|
1737
|
-
assert.equal(result.message,
|
|
1738
|
-
|
|
1739
|
-
const status = await sendControlCommand({command: {command: "status"}, path: oldSocketPath})
|
|
1740
|
-
const generationTransition = status.generationTransition
|
|
1734
|
+
assert.equal(result.message, "owner replacement committed", result.output)
|
|
1735
|
+
const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
|
|
1741
1736
|
|
|
1742
|
-
assert.
|
|
1743
|
-
assert.equal(generationTransition
|
|
1744
|
-
assert.
|
|
1745
|
-
|
|
1746
|
-
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: oldSocketPath})
|
|
1737
|
+
assert.equal(status.activeReleaseId, "v1")
|
|
1738
|
+
assert.equal(status.generationTransition, undefined)
|
|
1739
|
+
assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\nretire:v2\nactivate:v1\n")
|
|
1740
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
|
|
1747
1741
|
|
|
1748
1742
|
await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
|
|
1749
1743
|
await shutdown
|
|
@@ -1928,7 +1922,7 @@ async function removeDaemonRecoveryCapability(packagePath, {abortedPath, prepare
|
|
|
1928
1922
|
const guardianPath = path.join(packagePath, "src", "process-guardian.js")
|
|
1929
1923
|
const daemonPath = path.join(packagePath, "src", "daemon.js")
|
|
1930
1924
|
const source = await fs.readFile(guardianPath, "utf8")
|
|
1931
|
-
const capability = " if (request.command === \"capabilities\") return {daemonRecovery: 1}\n\n"
|
|
1925
|
+
const capability = " if (request.command === \"capabilities\") return {daemonRecovery: 1, generationReactivation: 1}\n\n"
|
|
1932
1926
|
const incumbentAbortNotification = " if (ownerClient && !ownerClient.destroyed) ownerClient.write(`${JSON.stringify({event: \"replacement-aborted\", reason})}\\n`)\n"
|
|
1933
1927
|
const legacyCapability = ` if (request.command === "capabilities") {
|
|
1934
1928
|
while (!fsSync.existsSync(${JSON.stringify(preparedPath)})) await new Promise((resolve) => setTimeout(resolve, 5))
|
package/test/rollbridge.test.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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, "
|
|
675
|
-
assert.
|
|
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
|
-
|
|
678
|
-
await
|
|
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(
|
|
681
|
-
assert.
|
|
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
|
|
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
|
})
|