rollbridge 0.1.31 → 0.1.33
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/AGENTS.md +11 -6
- package/README.md +28 -12
- package/changelog.d/20260830-guardian-retired-replacement-process-key.md +5 -0
- package/changelog.d/20260830-release-generation-activation-lifecycle.md +9 -0
- package/docs/cli.md +10 -7
- package/docs/config.md +41 -13
- package/docs/tensorbuzz-runbook.md +4 -2
- package/docs/velocious.md +19 -7
- package/docs/workers.md +18 -9
- package/examples/tensorbuzz.com.js +9 -5
- package/package.json +1 -1
- package/src/config.js +32 -2
- package/src/daemon.js +268 -21
- package/src/guardian-client.js +47 -6
- package/src/managed-process.js +85 -13
- package/src/process-guardian.js +27 -5
- package/src/release-group.js +53 -14
- package/test/config-validation.test.js +44 -0
- package/test/guardian-client.test.js +78 -3
- package/test/managed-process.test.js +33 -0
- package/test/owner-recovery.test.js +396 -6
- package/test/owner-replacement.test.js +107 -2
- package/test/rollbridge.test.js +333 -6
package/src/daemon.js
CHANGED
|
@@ -23,9 +23,12 @@ const STATE_PERSIST_INTERVAL_MS = 5000
|
|
|
23
23
|
* @typedef {{attestation?: string, releaseId: string, releasePath: string, revision: string}} BootstrapIdentity
|
|
24
24
|
* @typedef {{id: string, process: import("./managed-process.js").ManagedProcessStatus}} ProcessStatus
|
|
25
25
|
* @typedef {{disruptive: true, mode: "legacy-first-upgrade", reason: string}} OwnerTransition
|
|
26
|
-
* @typedef {
|
|
26
|
+
* @typedef {"candidate_ready" | "retiring_previous" | "previous_retired" | "activating_candidate" | "committed_pending" | "committed"} GenerationTransitionPhase
|
|
27
|
+
* @typedef {{candidateReleaseId: string, candidateReleasePath: string, candidateRevision: string, configDigest: string, error?: string, phase: GenerationTransitionPhase, previousReleaseId: string | null, startedAt: string, updatedAt: string}} GenerationTransition
|
|
28
|
+
* @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} | 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
|
|
27
29
|
* @typedef {{configDigest: string, format: number, guardian: {pid?: number, socketPath: string, token: string}, reconnectGraceMs: number}} OwnerRecoveryMetadata
|
|
28
|
-
* @typedef {DaemonStatus & {recovery: OwnerRecoveryMetadata}} OwnerRecoverySnapshot
|
|
30
|
+
* @typedef {DaemonStatus & {recovery: OwnerRecoveryMetadata, singletonReleaseIds?: Record<string, string>}} OwnerRecoverySnapshot
|
|
31
|
+
* @typedef {{authority: JsonValue, config: import("./config.js").RollbridgeConfig, releaseConfigs?: Record<string, import("./config.js").RollbridgeConfig>, singletonReleaseIds?: Record<string, string>, snapshot: OwnerRecoverySnapshot}} PrivateOwnerState
|
|
29
32
|
* @typedef {{boundaryCrossed: boolean, incumbentControl: Awaited<ReturnType<typeof openControlSession>>, incumbentStartTime: string, prepared: {ownerState: JsonValue, replacementId: string}, recoverySnapshot: OwnerRecoverySnapshot}} LegacyOwnerBridge
|
|
30
33
|
*/
|
|
31
34
|
|
|
@@ -62,7 +65,9 @@ export default class RollbridgeDaemon {
|
|
|
62
65
|
this.servicePorts = /** @type {Record<string, number>} */ ({})
|
|
63
66
|
this.portReservations = /** @type {Set<number>} */ (new Set())
|
|
64
67
|
this.singletons = /** @type {Map<string, import("./managed-process.js").default>} */ (new Map())
|
|
68
|
+
this.singletonReleaseIds = /** @type {Map<string, string>} */ (new Map())
|
|
65
69
|
this.activeRelease = /** @type {ReleaseGroup | undefined} */ (undefined)
|
|
70
|
+
this.generationTransition = /** @type {GenerationTransition | undefined} */ (undefined)
|
|
66
71
|
this.proxy = httpProxy.createProxyServer({ws: true, xfwd: true})
|
|
67
72
|
this.proxyServer = /** @type {http.Server | undefined} */ (undefined)
|
|
68
73
|
this.controlServer = /** @type {net.Server | undefined} */ (undefined)
|
|
@@ -137,10 +142,42 @@ export default class RollbridgeDaemon {
|
|
|
137
142
|
this.watchOwnerReplacementEvents()
|
|
138
143
|
|
|
139
144
|
if (snapshot) {
|
|
140
|
-
|
|
145
|
+
if (!Array.isArray(snapshot.releases) || (snapshot.activeReleaseId !== null && typeof snapshot.activeReleaseId !== "string")) {
|
|
146
|
+
throw new Error("Owner recovery state is partial or corrupt; active release metadata is required.")
|
|
147
|
+
}
|
|
148
|
+
let recoveryConfig = this.config
|
|
149
|
+
let releaseConfigs = /** @type {Record<string, import("./config.js").RollbridgeConfig>} */ ({})
|
|
150
|
+
let singletonReleaseIds = snapshot.singletonReleaseIds || /** @type {Record<string, string>} */ ({})
|
|
151
|
+
let recoverySnapshot = snapshot
|
|
152
|
+
|
|
153
|
+
if ((recovery?.format ?? 0) >= 2) {
|
|
154
|
+
const ownerState = /** @type {PrivateOwnerState} */ (await this.guardian.ownerState())
|
|
155
|
+
|
|
156
|
+
if (!ownerState?.config || !ownerState.releaseConfigs || !ownerState.snapshot) {
|
|
157
|
+
throw new Error("Durable guardian state is missing exact release definitions; refusing partial owner recovery.")
|
|
158
|
+
}
|
|
159
|
+
recoveryConfig = ownerState.config
|
|
160
|
+
releaseConfigs = ownerState.releaseConfigs
|
|
161
|
+
singletonReleaseIds = ownerState.singletonReleaseIds || singletonReleaseIds
|
|
162
|
+
const guardianTransitionAt = Date.parse(ownerState.snapshot.generationTransition?.updatedAt || "")
|
|
163
|
+
const persistedTransitionAt = Date.parse(snapshot.generationTransition?.updatedAt || "")
|
|
164
|
+
|
|
165
|
+
if (Number.isFinite(guardianTransitionAt) && (!Number.isFinite(persistedTransitionAt) || guardianTransitionAt > persistedTransitionAt)) {
|
|
166
|
+
recoverySnapshot = ownerState.snapshot
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
await this.restoreOwnerState(recoverySnapshot, {config: recoveryConfig, releaseConfigs, resumeDrains: false, singletonReleaseIds})
|
|
170
|
+
this.persistenceEnabled = true
|
|
141
171
|
await this.guardian.reconcileInventory()
|
|
172
|
+
if (this.generationTransition && this.generationTransition.phase !== "committed" && !this.generationTransition.error) {
|
|
173
|
+
try {
|
|
174
|
+
await this.resumeGenerationTransition()
|
|
175
|
+
} catch (error) {
|
|
176
|
+
this.logger("release generation transition recovery failed", {error: error instanceof Error ? error.message : String(error), releaseId: this.generationTransition.candidateReleaseId})
|
|
177
|
+
}
|
|
178
|
+
}
|
|
142
179
|
for (const release of this.releases.values()) {
|
|
143
|
-
if (release.state === "draining") void this.drainAndPrune(release, release.config)
|
|
180
|
+
if (release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
|
|
144
181
|
}
|
|
145
182
|
}
|
|
146
183
|
else {
|
|
@@ -166,7 +203,7 @@ export default class RollbridgeDaemon {
|
|
|
166
203
|
if (this.listenerHandoff || this.ownerRetired) return
|
|
167
204
|
for (const release of this.releases.values()) {
|
|
168
205
|
release.resumeDrainAfterOwnerHandoff()
|
|
169
|
-
if (release.state === "draining") void this.drainAndPrune(release, release.config)
|
|
206
|
+
if (release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
|
|
170
207
|
}
|
|
171
208
|
})
|
|
172
209
|
}
|
|
@@ -182,17 +219,24 @@ export default class RollbridgeDaemon {
|
|
|
182
219
|
* @param {import("./config.js").RollbridgeConfig} [options.config] - Owner config for daemon-wide processes.
|
|
183
220
|
* @param {Record<string, import("./config.js").RollbridgeConfig>} [options.releaseConfigs] - Exact generation configs.
|
|
184
221
|
* @param {boolean} [options.resumeDrains] - Whether to resume draining generations immediately.
|
|
222
|
+
* @param {Record<string, string>} [options.singletonReleaseIds] - Exact release owner for each singleton registration.
|
|
223
|
+
* @param {boolean} [options.synchronizeLifecycleRoles] - Whether the caller owns guardian role mutation authority.
|
|
185
224
|
*/
|
|
186
|
-
async restoreOwnerState(snapshot, {config = this.config, releaseConfigs = /** @type {Record<string, import("./config.js").RollbridgeConfig>} */ ({}), resumeDrains = true} = {}) {
|
|
225
|
+
async restoreOwnerState(snapshot, {config = this.config, releaseConfigs = /** @type {Record<string, import("./config.js").RollbridgeConfig>} */ ({}), resumeDrains = true, singletonReleaseIds = snapshot.singletonReleaseIds || /** @type {Record<string, string>} */ ({}), synchronizeLifecycleRoles = true} = {}) {
|
|
187
226
|
if (!Array.isArray(snapshot.releases) || (snapshot.activeReleaseId !== null && typeof snapshot.activeReleaseId !== "string")) {
|
|
188
227
|
throw new Error("Owner recovery state is partial or corrupt; active release metadata is required.")
|
|
189
228
|
}
|
|
229
|
+
this.generationTransition = snapshot.generationTransition ? {...snapshot.generationTransition} : undefined
|
|
190
230
|
if (snapshot.activeReleaseId === null && snapshot.releases.length === 0) return
|
|
191
231
|
this.bootstrap = snapshot.bootstrap ? {...snapshot.bootstrap} : undefined
|
|
192
232
|
this.ownerTransition = snapshot.ownerTransition ? {...snapshot.ownerTransition} : undefined
|
|
233
|
+
const singletonOwnerReleaseIds = new Set(Object.values(singletonReleaseIds))
|
|
193
234
|
|
|
194
235
|
for (const releaseStatus of snapshot.releases) {
|
|
195
|
-
|
|
236
|
+
const transitionCandidate = this.generationTransition?.candidateReleaseId === releaseStatus.releaseId && this.generationTransition.phase !== "committed"
|
|
237
|
+
const singletonOwner = singletonOwnerReleaseIds.has(releaseStatus.releaseId)
|
|
238
|
+
|
|
239
|
+
if (releaseStatus.state !== "active" && releaseStatus.state !== "draining" && !transitionCandidate && !singletonOwner) continue
|
|
196
240
|
const release = new ReleaseGroup({
|
|
197
241
|
config: releaseConfigs[releaseStatus.releaseId] || config,
|
|
198
242
|
logger: this.logger,
|
|
@@ -205,7 +249,7 @@ export default class RollbridgeDaemon {
|
|
|
205
249
|
shouldStart: () => !this.stopping
|
|
206
250
|
})
|
|
207
251
|
|
|
208
|
-
await release.restore(releaseStatus)
|
|
252
|
+
await release.restore(releaseStatus, {synchronizeLifecycleRole: synchronizeLifecycleRoles})
|
|
209
253
|
this.releases.set(release.releaseId, release)
|
|
210
254
|
if (release.releaseId === snapshot.activeReleaseId) this.activeRelease = release
|
|
211
255
|
}
|
|
@@ -231,16 +275,21 @@ export default class RollbridgeDaemon {
|
|
|
231
275
|
}
|
|
232
276
|
}
|
|
233
277
|
for (const singletonStatus of snapshot.singletons) {
|
|
234
|
-
const
|
|
278
|
+
const singletonReleaseId = singletonReleaseIds[singletonStatus.id] || definitionRelease.releaseId
|
|
279
|
+
const singletonRelease = this.releases.get(singletonReleaseId)
|
|
280
|
+
|
|
281
|
+
if (!singletonRelease) throw new Error(`Owner recovery state contains singleton ${singletonStatus.id} for unknown release ${singletonReleaseId}.`)
|
|
282
|
+
const processConfig = singletonRelease.config.processes.find((candidate) => candidate.id === singletonStatus.id && candidate.policy === "singleton")
|
|
235
283
|
|
|
236
|
-
if (!processConfig) throw new Error(`Owner recovery state contains unknown singleton ${singletonStatus.id}.`)
|
|
237
|
-
const singleton =
|
|
284
|
+
if (!processConfig) throw new Error(`Owner recovery state contains unknown singleton ${singletonStatus.id} for release ${singletonReleaseId}.`)
|
|
285
|
+
const singleton = singletonRelease.buildProcess(processConfig, {guardianKey: `singleton:${singletonReleaseId}:${singletonStatus.id}`})
|
|
238
286
|
|
|
239
287
|
await this.recoverGuardianProcess(singleton)
|
|
240
288
|
this.singletons.set(singletonStatus.id, singleton)
|
|
289
|
+
this.singletonReleaseIds.set(singletonStatus.id, singletonReleaseId)
|
|
241
290
|
}
|
|
242
291
|
for (const release of this.releases.values()) {
|
|
243
|
-
if (resumeDrains && release.state === "draining") void this.drainAndPrune(release, release.config)
|
|
292
|
+
if (resumeDrains && release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
|
|
244
293
|
}
|
|
245
294
|
this.logger("owner state recovered", {activeReleaseId: this.activeRelease?.releaseId ?? null, releases: this.releases.size})
|
|
246
295
|
}
|
|
@@ -252,6 +301,7 @@ export default class RollbridgeDaemon {
|
|
|
252
301
|
authority: this.ownerAuthority(),
|
|
253
302
|
config: this.config,
|
|
254
303
|
releaseConfigs: Object.fromEntries([...this.releases].map(([releaseId, release]) => [releaseId, release.config])),
|
|
304
|
+
singletonReleaseIds: Object.fromEntries(this.singletonReleaseIds),
|
|
255
305
|
snapshot: this.status()
|
|
256
306
|
})
|
|
257
307
|
}
|
|
@@ -289,10 +339,10 @@ export default class RollbridgeDaemon {
|
|
|
289
339
|
prepared = legacyBridge.prepared
|
|
290
340
|
}
|
|
291
341
|
const preparedStatus = await this.guardian.replacementStatus()
|
|
292
|
-
const transfer = /** @type {
|
|
342
|
+
const transfer = /** @type {PrivateOwnerState} */ (prepared.ownerState)
|
|
293
343
|
|
|
294
344
|
if (!transfer?.config || !transfer.snapshot) throw new Error("Committed owner published incomplete replacement state")
|
|
295
|
-
await this.restoreOwnerState(transfer.snapshot, {config: transfer.config, releaseConfigs: transfer.releaseConfigs, resumeDrains: false})
|
|
345
|
+
await this.restoreOwnerState(transfer.snapshot, {config: transfer.config, releaseConfigs: transfer.releaseConfigs, resumeDrains: false, singletonReleaseIds: transfer.singletonReleaseIds, synchronizeLifecycleRoles: false})
|
|
296
346
|
for (const release of this.releases.values()) release.preserveConfigOnRetirement = true
|
|
297
347
|
this.logger("owner replacement candidate prepared", {activeReleaseId: this.activeRelease?.releaseId ?? null, replacementId: prepared.replacementId})
|
|
298
348
|
|
|
@@ -356,12 +406,16 @@ export default class RollbridgeDaemon {
|
|
|
356
406
|
authority: this.ownerAuthority(),
|
|
357
407
|
config: this.config,
|
|
358
408
|
releaseConfigs: Object.fromEntries([...this.releases].map(([releaseId, release]) => [releaseId, release.config])),
|
|
409
|
+
singletonReleaseIds: Object.fromEntries(this.singletonReleaseIds),
|
|
359
410
|
snapshot: this.status()
|
|
360
411
|
})
|
|
361
412
|
|
|
362
413
|
if (!staged.committed) {
|
|
363
414
|
if (retiredIncumbentControl) {
|
|
364
|
-
|
|
415
|
+
const processKey = this.guardian.processes.keys().next().value
|
|
416
|
+
|
|
417
|
+
if (!processKey) throw new Error("Retired owner replacement requires an exact recovered guardian process registration")
|
|
418
|
+
await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId, processKey)
|
|
365
419
|
} else {
|
|
366
420
|
try {
|
|
367
421
|
if (!incumbentControl) throw new Error("Owner replacement incumbent control session is unavailable")
|
|
@@ -381,12 +435,13 @@ export default class RollbridgeDaemon {
|
|
|
381
435
|
finalControlPublished = true
|
|
382
436
|
this.boundControlPath = this.config.control.path
|
|
383
437
|
}
|
|
438
|
+
await Promise.all([...this.releases.values()].map((release) => release.synchronizeLifecycleRoles()))
|
|
384
439
|
if (!legacyBridge && incumbentControl && [...this.releases.values()].some((release) => release.hasTransferredConnections())) {
|
|
385
440
|
this.incumbentListenerControl = incumbentControl
|
|
386
441
|
retainIncumbentControl = true
|
|
387
442
|
}
|
|
388
443
|
for (const release of this.releases.values()) {
|
|
389
|
-
if (release.state === "draining") void this.drainAndPrune(release, release.config)
|
|
444
|
+
if (release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
|
|
390
445
|
}
|
|
391
446
|
this.startStatePersistence()
|
|
392
447
|
await this.persistState({throwOnError: true})
|
|
@@ -509,6 +564,7 @@ export default class RollbridgeDaemon {
|
|
|
509
564
|
authority: persistedAuthority,
|
|
510
565
|
config: this.config,
|
|
511
566
|
releaseConfigs: Object.fromEntries(persisted.releases.map((release) => [release.releaseId, this.config])),
|
|
567
|
+
singletonReleaseIds: Object.fromEntries(persisted.singletons.map((singleton) => [singleton.id, persisted.activeReleaseId]).filter((entry) => entry[1] !== null)),
|
|
512
568
|
snapshot: persisted
|
|
513
569
|
}
|
|
514
570
|
const upgradedIdentity = /** @type {{pid?: number, socketPath: string, token: string}} */ ({
|
|
@@ -1022,7 +1078,7 @@ export default class RollbridgeDaemon {
|
|
|
1022
1078
|
if (!handoff || this.ownerRetired) return
|
|
1023
1079
|
for (const release of this.releases.values()) {
|
|
1024
1080
|
release.resumeDrainAfterOwnerHandoff()
|
|
1025
|
-
if (release.state === "draining") void this.drainAndPrune(release, release.config)
|
|
1081
|
+
if (release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
|
|
1026
1082
|
}
|
|
1027
1083
|
if (handoff.proxy) {
|
|
1028
1084
|
await this.startProxy()
|
|
@@ -1048,6 +1104,16 @@ export default class RollbridgeDaemon {
|
|
|
1048
1104
|
this.assertReloadCompatible(nextConfig)
|
|
1049
1105
|
|
|
1050
1106
|
const newReleaseId = releaseId || revision || new Date().toISOString().replace(/[^0-9]/g, "")
|
|
1107
|
+
const transition = this.generationTransition
|
|
1108
|
+
|
|
1109
|
+
if (transition && transition.phase !== "committed") {
|
|
1110
|
+
this.assertExactGenerationTransition(transition, {config: nextConfig, releaseId: newReleaseId, releasePath, revision: revision || newReleaseId})
|
|
1111
|
+
return await this.resumeGenerationTransition()
|
|
1112
|
+
}
|
|
1113
|
+
if (transition?.phase === "committed" && transition.candidateReleaseId === newReleaseId && this.activeRelease?.releaseId === newReleaseId) {
|
|
1114
|
+
this.assertExactGenerationTransition(transition, {config: nextConfig, releaseId: newReleaseId, releasePath, revision: revision || newReleaseId})
|
|
1115
|
+
return {activeReleaseId: newReleaseId, previousReleaseId: transition.previousReleaseId}
|
|
1116
|
+
}
|
|
1051
1117
|
const release = new ReleaseGroup({
|
|
1052
1118
|
config: nextConfig,
|
|
1053
1119
|
logger: this.logger,
|
|
@@ -1080,6 +1146,25 @@ export default class RollbridgeDaemon {
|
|
|
1080
1146
|
|
|
1081
1147
|
const previousRelease = this.activeRelease
|
|
1082
1148
|
|
|
1149
|
+
if (nextConfig.processes.some((processConfig) => processConfig.lifecycle.activateCommand !== undefined)) {
|
|
1150
|
+
this.config = nextConfig
|
|
1151
|
+
this.releases.set(release.releaseId, release)
|
|
1152
|
+
const now = new Date().toISOString()
|
|
1153
|
+
|
|
1154
|
+
this.generationTransition = /** @type {GenerationTransition} */ ({
|
|
1155
|
+
candidateReleaseId: release.releaseId,
|
|
1156
|
+
candidateReleasePath: release.releasePath,
|
|
1157
|
+
candidateRevision: release.revision,
|
|
1158
|
+
configDigest: ownerConfigDigest(nextConfig),
|
|
1159
|
+
phase: "candidate_ready",
|
|
1160
|
+
previousReleaseId: previousRelease?.releaseId ?? null,
|
|
1161
|
+
startedAt: now,
|
|
1162
|
+
updatedAt: now
|
|
1163
|
+
})
|
|
1164
|
+
await this.checkpointGenerationTransition()
|
|
1165
|
+
return await this.resumeGenerationTransition()
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1083
1168
|
this.config = nextConfig
|
|
1084
1169
|
this.releases.set(release.releaseId, release)
|
|
1085
1170
|
release.activate()
|
|
@@ -1113,6 +1198,138 @@ export default class RollbridgeDaemon {
|
|
|
1113
1198
|
}
|
|
1114
1199
|
}
|
|
1115
1200
|
|
|
1201
|
+
/**
|
|
1202
|
+
* Continues one exact durable generation transition without an internal retry loop.
|
|
1203
|
+
* @returns {Promise<Record<string, JsonValue>>} Deploy result after commit.
|
|
1204
|
+
*/
|
|
1205
|
+
async resumeGenerationTransition() {
|
|
1206
|
+
const transition = this.generationTransition
|
|
1207
|
+
|
|
1208
|
+
if (!transition) throw new Error("No release generation transition to resume")
|
|
1209
|
+
const release = this.releases.get(transition.candidateReleaseId)
|
|
1210
|
+
const previousRelease = transition.previousReleaseId ? this.releases.get(transition.previousReleaseId) : undefined
|
|
1211
|
+
|
|
1212
|
+
if (!release) throw new Error(`Generation transition candidate ${transition.candidateReleaseId} is not retained`)
|
|
1213
|
+
if (transition.previousReleaseId && !previousRelease) throw new Error(`Generation transition previous release ${transition.previousReleaseId} is not retained`)
|
|
1214
|
+
|
|
1215
|
+
if (transition.phase === "candidate_ready") {
|
|
1216
|
+
if (previousRelease) await this.updateGenerationTransition("retiring_previous")
|
|
1217
|
+
else await this.updateGenerationTransition("previous_retired")
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (transition.phase === "retiring_previous") {
|
|
1221
|
+
try {
|
|
1222
|
+
const retirementConfig = previousRelease?.config
|
|
1223
|
+
|
|
1224
|
+
// Every entry into this journaled phase represents one explicit attempt. Reset the
|
|
1225
|
+
// process-local acknowledgement cache so exact resume can replay an ambiguous or
|
|
1226
|
+
// failed idempotent retirement once; there is no internal retry loop.
|
|
1227
|
+
await previousRelease?.beginRetirement(retirementConfig, {retry: true})
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
const failure = previousRelease?.retirementError ?? (error instanceof Error ? error.message : String(error))
|
|
1230
|
+
|
|
1231
|
+
await this.failGenerationTransition(failure)
|
|
1232
|
+
this.logger("release retirement quiescence failed", {error: failure, releaseId: previousRelease?.releaseId ?? null})
|
|
1233
|
+
throw error
|
|
1234
|
+
}
|
|
1235
|
+
await this.updateGenerationTransition("previous_retired")
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
if (transition.phase === "previous_retired") await this.updateGenerationTransition("activating_candidate")
|
|
1239
|
+
|
|
1240
|
+
if (transition.phase === "activating_candidate") {
|
|
1241
|
+
try {
|
|
1242
|
+
await release.activateGeneration()
|
|
1243
|
+
} catch (error) {
|
|
1244
|
+
const failure = error instanceof Error ? error.message : String(error)
|
|
1245
|
+
|
|
1246
|
+
await this.failGenerationTransition(failure)
|
|
1247
|
+
this.logger("release generation activation failed", {error: failure, releaseId: release.releaseId})
|
|
1248
|
+
throw error
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// Activation acknowledgement and the logical proxy commit deliberately share one
|
|
1252
|
+
// synchronous continuation: no awaited failure boundary may leave jobs active while
|
|
1253
|
+
// Rollbridge still points new traffic at the retired generation.
|
|
1254
|
+
release.activate()
|
|
1255
|
+
this.activeRelease = release
|
|
1256
|
+
transition.phase = "committed_pending"
|
|
1257
|
+
transition.error = undefined
|
|
1258
|
+
transition.updatedAt = new Date().toISOString()
|
|
1259
|
+
this.logger("traffic switched", {previousReleaseId: previousRelease?.releaseId ?? null, releaseId: release.releaseId})
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
if (transition.phase === "committed_pending") {
|
|
1263
|
+
await this.checkpointGenerationTransition()
|
|
1264
|
+
this.refreshServiceDefinitions(release)
|
|
1265
|
+
if (previousRelease) {
|
|
1266
|
+
const retirementConfig = previousRelease.config
|
|
1267
|
+
|
|
1268
|
+
void this.drainAndPrune(previousRelease, retirementConfig)
|
|
1269
|
+
}
|
|
1270
|
+
try {
|
|
1271
|
+
await this.replaceSingletons(release)
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
await this.failGenerationTransition(error instanceof Error ? error.message : String(error))
|
|
1274
|
+
throw error
|
|
1275
|
+
}
|
|
1276
|
+
transition.phase = "committed"
|
|
1277
|
+
transition.error = undefined
|
|
1278
|
+
transition.updatedAt = new Date().toISOString()
|
|
1279
|
+
await this.checkpointGenerationTransition()
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
return {activeReleaseId: release.releaseId, previousReleaseId: previousRelease?.releaseId ?? null}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* @param {GenerationTransition} transition - Pending or committed exact transition.
|
|
1287
|
+
* @param {{config: import("./config.js").RollbridgeConfig, releaseId: string, releasePath: string, revision: string}} candidate - Requested identity.
|
|
1288
|
+
*/
|
|
1289
|
+
assertExactGenerationTransition(transition, candidate) {
|
|
1290
|
+
if (transition.candidateReleaseId !== candidate.releaseId || transition.candidateReleasePath !== candidate.releasePath || transition.candidateRevision !== candidate.revision || transition.configDigest !== ownerConfigDigest(candidate.config)) {
|
|
1291
|
+
throw new Error(`Release generation transition for ${transition.candidateReleaseId} is unresolved at ${transition.phase}; only the exact same release, path, revision, and config authority may resume it`)
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/** @param {GenerationTransitionPhase} phase - Durable phase to enter. */
|
|
1296
|
+
async updateGenerationTransition(phase) {
|
|
1297
|
+
if (!this.generationTransition) throw new Error("No release generation transition to update")
|
|
1298
|
+
this.generationTransition.phase = phase
|
|
1299
|
+
this.generationTransition.error = undefined
|
|
1300
|
+
this.generationTransition.updatedAt = new Date().toISOString()
|
|
1301
|
+
await this.checkpointGenerationTransition()
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/** @param {string} error - Exact failed phase diagnostic. */
|
|
1305
|
+
async failGenerationTransition(error) {
|
|
1306
|
+
if (!this.generationTransition) throw new Error("No release generation transition to fail")
|
|
1307
|
+
this.generationTransition.error = error
|
|
1308
|
+
this.generationTransition.updatedAt = new Date().toISOString()
|
|
1309
|
+
await this.checkpointGenerationTransition()
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/** Persists and publishes the exact transition boundary before another external effect. */
|
|
1313
|
+
async checkpointGenerationTransition() {
|
|
1314
|
+
// Publish private exact definitions before the public secret-safe state can expose
|
|
1315
|
+
// a newer transition authority. A replacement can then reconstruct every guardian
|
|
1316
|
+
// registration without reading commands or environment values from statePath.
|
|
1317
|
+
await this.publishOwnerState()
|
|
1318
|
+
const write = this.persistState({throwOnError: true})
|
|
1319
|
+
|
|
1320
|
+
if (write) await write
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* @param {ReleaseGroup} release - Retained release.
|
|
1325
|
+
* @returns {boolean} Whether its stop drain may run now.
|
|
1326
|
+
*/
|
|
1327
|
+
shouldResumeDrain(release) {
|
|
1328
|
+
const transition = this.generationTransition
|
|
1329
|
+
|
|
1330
|
+
return !transition || transition.phase === "committed_pending" || transition.phase === "committed" || transition.previousReleaseId !== release.releaseId
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1116
1333
|
/**
|
|
1117
1334
|
* Relinquishes only daemon authority/listeners after the guardian has committed a
|
|
1118
1335
|
* prepared replacement. Guardian-owned processes and drains are never stopped.
|
|
@@ -1181,6 +1398,7 @@ export default class RollbridgeDaemon {
|
|
|
1181
1398
|
* @returns {Promise<Record<string, JsonValue>>} The rollback result.
|
|
1182
1399
|
*/
|
|
1183
1400
|
async rollback({releaseId} = {}) {
|
|
1401
|
+
this.assertNoUnresolvedGenerationTransition("rollback")
|
|
1184
1402
|
const target = releaseId ? this.releases.get(releaseId) : this.previousRelease()
|
|
1185
1403
|
|
|
1186
1404
|
if (!target) {
|
|
@@ -1304,6 +1522,9 @@ export default class RollbridgeDaemon {
|
|
|
1304
1522
|
if (processConfig.policy !== "singleton") continue
|
|
1305
1523
|
|
|
1306
1524
|
const previous = this.singletons.get(processConfig.id)
|
|
1525
|
+
const previousReleaseId = this.singletonReleaseIds.get(processConfig.id)
|
|
1526
|
+
|
|
1527
|
+
if (previous && previousReleaseId === release.releaseId && previous.status().state === "running") continue
|
|
1307
1528
|
|
|
1308
1529
|
if (previous) {
|
|
1309
1530
|
await previous.stop()
|
|
@@ -1313,8 +1534,10 @@ export default class RollbridgeDaemon {
|
|
|
1313
1534
|
const singleton = release.buildProcess(processConfig, {guardianKey: `singleton:${release.releaseId}:${processConfig.id}`})
|
|
1314
1535
|
|
|
1315
1536
|
this.singletons.set(processConfig.id, singleton)
|
|
1537
|
+
this.singletonReleaseIds.set(processConfig.id, release.releaseId)
|
|
1316
1538
|
await singleton.start("deploy")
|
|
1317
1539
|
}
|
|
1540
|
+
this.pruneStoppedReleases()
|
|
1318
1541
|
}
|
|
1319
1542
|
|
|
1320
1543
|
/**
|
|
@@ -1328,6 +1551,7 @@ export default class RollbridgeDaemon {
|
|
|
1328
1551
|
* @returns {Promise<Record<string, JsonValue>>} The ids that were restarted.
|
|
1329
1552
|
*/
|
|
1330
1553
|
async restartProcesses({policy, processId} = {}) {
|
|
1554
|
+
this.assertNoUnresolvedGenerationTransition("restart")
|
|
1331
1555
|
if (policy === "proxied" || (processId !== undefined && this.isProxiedId(processId))) {
|
|
1332
1556
|
throw new Error('The proxied process cannot be restarted in place; use "rollbridge deploy" for a zero-downtime replacement.')
|
|
1333
1557
|
}
|
|
@@ -1341,7 +1565,7 @@ export default class RollbridgeDaemon {
|
|
|
1341
1565
|
for (const target of targets) {
|
|
1342
1566
|
this.logger("process restart requested", {processId: target.id})
|
|
1343
1567
|
await target.process.stop()
|
|
1344
|
-
await target.process.start("manual")
|
|
1568
|
+
await target.process.start("manual", target.process.lifecycle.activateCommand ? "active" : undefined)
|
|
1345
1569
|
}
|
|
1346
1570
|
|
|
1347
1571
|
return {restarted: targets.map((target) => target.id)}
|
|
@@ -1402,6 +1626,7 @@ export default class RollbridgeDaemon {
|
|
|
1402
1626
|
* @returns {Promise<void>} Resolves when stopped.
|
|
1403
1627
|
*/
|
|
1404
1628
|
async stopRelease(releaseId) {
|
|
1629
|
+
this.assertNoUnresolvedGenerationTransition("stop")
|
|
1405
1630
|
const release = releaseId ? this.releases.get(releaseId) : this.activeRelease
|
|
1406
1631
|
|
|
1407
1632
|
if (!release) throw new Error(`Release not found: ${releaseId || "active"}`)
|
|
@@ -1413,6 +1638,15 @@ export default class RollbridgeDaemon {
|
|
|
1413
1638
|
this.persistState()
|
|
1414
1639
|
}
|
|
1415
1640
|
|
|
1641
|
+
/** @param {string} operation - Mutating control operation. */
|
|
1642
|
+
assertNoUnresolvedGenerationTransition(operation) {
|
|
1643
|
+
const transition = this.generationTransition
|
|
1644
|
+
|
|
1645
|
+
if (transition && transition.phase !== "committed") {
|
|
1646
|
+
throw new Error(`Cannot ${operation} while release generation transition ${transition.candidateReleaseId} is unresolved at ${transition.phase}; resume the exact deploy first`)
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1416
1650
|
/**
|
|
1417
1651
|
* Drains and stops a retired release in the background, then prunes stopped releases.
|
|
1418
1652
|
* @param {ReleaseGroup} release - Release to drain and stop.
|
|
@@ -1435,9 +1669,13 @@ export default class RollbridgeDaemon {
|
|
|
1435
1669
|
|
|
1436
1670
|
/** @returns {void} Removes stopped releases beyond the retention policy. */
|
|
1437
1671
|
pruneStoppedReleases() {
|
|
1438
|
-
const
|
|
1672
|
+
const singletonOwnerReleaseIds = new Set(this.singletonReleaseIds.values())
|
|
1673
|
+
const statuses = [...this.releases.values()]
|
|
1674
|
+
.filter((release) => !singletonOwnerReleaseIds.has(release.releaseId))
|
|
1675
|
+
.map((release) => release.status())
|
|
1439
1676
|
|
|
1440
1677
|
for (const releaseId of releasesToPrune(statuses, this.config.releaseRetention, Date.now())) {
|
|
1678
|
+
this.releases.get(releaseId)?.releasePortReservations()
|
|
1441
1679
|
this.releases.delete(releaseId)
|
|
1442
1680
|
}
|
|
1443
1681
|
}
|
|
@@ -1469,9 +1707,10 @@ export default class RollbridgeDaemon {
|
|
|
1469
1707
|
...status,
|
|
1470
1708
|
events,
|
|
1471
1709
|
persistedAt: new Date().toISOString(),
|
|
1710
|
+
...(this.hasActivationLifecycle() ? {singletonReleaseIds: Object.fromEntries(this.singletonReleaseIds)} : {}),
|
|
1472
1711
|
...(this.guardianIdentity ? {recovery: {
|
|
1473
1712
|
configDigest: this.ownerRecoveryConfigDigest(),
|
|
1474
|
-
format: 1,
|
|
1713
|
+
format: this.hasActivationLifecycle() ? 2 : 1,
|
|
1475
1714
|
guardian: this.guardianIdentity,
|
|
1476
1715
|
reconnectGraceMs: this.config.ownerRecovery?.reconnectGraceMs
|
|
1477
1716
|
}} : {})
|
|
@@ -1491,6 +1730,11 @@ export default class RollbridgeDaemon {
|
|
|
1491
1730
|
return this.pendingWrite
|
|
1492
1731
|
}
|
|
1493
1732
|
|
|
1733
|
+
/** @returns {boolean} Whether the current authority uses explicit generation activation. */
|
|
1734
|
+
hasActivationLifecycle() {
|
|
1735
|
+
return Boolean(this.generationTransition) || this.config.processes.some((processConfig) => processConfig.lifecycle?.activateCommand !== undefined) || [...this.releases.values()].some((release) => release.config.processes.some((processConfig) => processConfig.lifecycle?.activateCommand !== undefined))
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1494
1738
|
/**
|
|
1495
1739
|
* On startup, reads any state left by a previous daemon and reports managed processes whose
|
|
1496
1740
|
* pids are still alive — likely orphans from a daemon that did not shut down cleanly. This is
|
|
@@ -1678,6 +1922,8 @@ export default class RollbridgeDaemon {
|
|
|
1678
1922
|
// a cleared orphan must not reappear if the OS later recycles its pid for an unrelated process.
|
|
1679
1923
|
this.orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
|
|
1680
1924
|
|
|
1925
|
+
const singletonOwnerReleaseIds = new Set(this.singletonReleaseIds.values())
|
|
1926
|
+
|
|
1681
1927
|
return {
|
|
1682
1928
|
activeReleaseId: this.activeRelease ? this.activeRelease.releaseId : null,
|
|
1683
1929
|
application: this.config.application,
|
|
@@ -1685,6 +1931,7 @@ export default class RollbridgeDaemon {
|
|
|
1685
1931
|
control: {...this.config.control},
|
|
1686
1932
|
daemonPid: process.pid,
|
|
1687
1933
|
daemonRuntime: this.runtime ? {...this.runtime} : undefined,
|
|
1934
|
+
generationTransition: this.generationTransition ? {...this.generationTransition} : undefined,
|
|
1688
1935
|
ownerRecovery: this.guardian ? {configDigest: this.ownerRecoveryConfigDigest()} : undefined,
|
|
1689
1936
|
ownerTransition: this.ownerTransition ? {...this.ownerTransition} : undefined,
|
|
1690
1937
|
orphans: [...this.orphans],
|
|
@@ -1694,7 +1941,7 @@ export default class RollbridgeDaemon {
|
|
|
1694
1941
|
upstreamHost: this.config.proxy.upstreamHost
|
|
1695
1942
|
},
|
|
1696
1943
|
releaseReferences: [...this.releases.values()]
|
|
1697
|
-
.filter((release) => release.state === "active" || release.state === "draining")
|
|
1944
|
+
.filter((release) => release.state === "active" || release.state === "draining" || singletonOwnerReleaseIds.has(release.releaseId) || (this.generationTransition?.phase !== "committed" && this.generationTransition?.candidateReleaseId === release.releaseId))
|
|
1698
1945
|
.map((release) => ({releaseId: release.releaseId, releasePath: release.releasePath})),
|
|
1699
1946
|
releases: [...this.releases.values()].map((release) => release.status()),
|
|
1700
1947
|
services: [...this.services.entries()].map(([id, processInstance]) => ({
|
package/src/guardian-client.js
CHANGED
|
@@ -194,9 +194,12 @@ export default class GuardianClient {
|
|
|
194
194
|
await this.request({command: "commit-owner-replacement", replacementId})
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
/**
|
|
198
|
-
|
|
199
|
-
|
|
197
|
+
/**
|
|
198
|
+
* @param {string} replacementId - Same-authority transaction whose incumbent listener is absent.
|
|
199
|
+
* @param {string} key - Exact recovered guardian process proving candidate reconstruction.
|
|
200
|
+
*/
|
|
201
|
+
async commitRetiredOwnerReplacement(replacementId, key) {
|
|
202
|
+
await this.request({command: "commit-retired-owner-replacement", key, replacementId})
|
|
200
203
|
}
|
|
201
204
|
|
|
202
205
|
/** @param {string} replacementId - Committed transaction awaiting incumbent retirement. */
|
|
@@ -310,7 +313,7 @@ export default class GuardianClient {
|
|
|
310
313
|
|
|
311
314
|
this.buffer = this.buffer.slice(newline + 1)
|
|
312
315
|
if (message.event) {
|
|
313
|
-
if (message.event === "process" || message.event === "status") this.processes.get(message.key)?.onGuardianEvent(message)
|
|
316
|
+
if (message.event === "process" || message.event === "process-log" || message.event === "status") this.processes.get(message.key)?.onGuardianEvent(message)
|
|
314
317
|
for (const handler of this.eventHandlers.get(message.event) || []) handler(message)
|
|
315
318
|
const waiter = this.events.get(message.event)?.shift()
|
|
316
319
|
|
|
@@ -354,10 +357,15 @@ class GuardianProcess extends ManagedProcess {
|
|
|
354
357
|
await this.ensureRegistered()
|
|
355
358
|
}
|
|
356
359
|
|
|
357
|
-
|
|
360
|
+
/**
|
|
361
|
+
* @param {import("./managed-process.js").ManagedProcessStartReason} [reason] - Start reason.
|
|
362
|
+
* @param {import("./managed-process.js").LifecycleRole} [lifecycleRole] - Desired role restored before running.
|
|
363
|
+
*/
|
|
364
|
+
async start(reason = "deploy", lifecycleRole) {
|
|
358
365
|
await this.ensureRegistered()
|
|
359
366
|
await this.pendingUpdate
|
|
360
|
-
|
|
367
|
+
if (lifecycleRole) this.lifecycleRole = lifecycleRole
|
|
368
|
+
this.cachedStatus = asProcessStatus(await this.client.request({command: "start", key: this.key, lifecycleRole, reason}))
|
|
361
369
|
}
|
|
362
370
|
|
|
363
371
|
/** @param {import("./managed-process.js").ManagedProcessDefinition} definition - Updated definition. */
|
|
@@ -383,6 +391,27 @@ class GuardianProcess extends ManagedProcess {
|
|
|
383
391
|
await this.quiesce()
|
|
384
392
|
}
|
|
385
393
|
|
|
394
|
+
async requiesceStrict() {
|
|
395
|
+
await this.ensureRegistered()
|
|
396
|
+
await this.pendingUpdate
|
|
397
|
+
this.cachedStatus = asProcessStatus(await this.client.request({command: "requiesce", key: this.key}))
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async activateStrict() {
|
|
401
|
+
await this.ensureRegistered()
|
|
402
|
+
await this.pendingUpdate
|
|
403
|
+
this.cachedStatus = asProcessStatus(await this.client.request({command: "activate", key: this.key}))
|
|
404
|
+
this.lifecycleRole = "active"
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** @param {import("./managed-process.js").LifecycleRole} role - Exact generation role. */
|
|
408
|
+
async setLifecycleRole(role) {
|
|
409
|
+
await this.ensureRegistered()
|
|
410
|
+
await this.pendingUpdate
|
|
411
|
+
this.cachedStatus = asProcessStatus(await this.client.request({command: "set-lifecycle-role", key: this.key, lifecycleRole: role}))
|
|
412
|
+
this.lifecycleRole = role
|
|
413
|
+
}
|
|
414
|
+
|
|
386
415
|
async stop(options = {}) {
|
|
387
416
|
await this.ensureRegistered()
|
|
388
417
|
await this.pendingUpdate
|
|
@@ -396,6 +425,10 @@ class GuardianProcess extends ManagedProcess {
|
|
|
396
425
|
/** @param {Record<string, import("./json.js").JsonValue>} event - Guardian event. */
|
|
397
426
|
onGuardianEvent(event) {
|
|
398
427
|
if (event.status) this.cachedStatus = asProcessStatus(event.status)
|
|
428
|
+
if (event.event === "process-log") {
|
|
429
|
+
this.emit("log", asProcessLog(event.entry))
|
|
430
|
+
return
|
|
431
|
+
}
|
|
399
432
|
if (event.message === "process started") this.emit("started")
|
|
400
433
|
if (event.message === "process exited") this.emit("exit", event.data)
|
|
401
434
|
this.logger(typeof event.message === "string" ? event.message : "guardian process status", event.data && typeof event.data === "object" && !Array.isArray(event.data) ? event.data : {})
|
|
@@ -430,6 +463,14 @@ function asProcessStatus(value) {
|
|
|
430
463
|
return JSON.parse(JSON.stringify(value))
|
|
431
464
|
}
|
|
432
465
|
|
|
466
|
+
/**
|
|
467
|
+
* @param {import("./json.js").JsonValue} value - Protocol value.
|
|
468
|
+
* @returns {import("./managed-process.js").ManagedProcessLog} Process output entry.
|
|
469
|
+
*/
|
|
470
|
+
function asProcessLog(value) {
|
|
471
|
+
return JSON.parse(JSON.stringify(value))
|
|
472
|
+
}
|
|
473
|
+
|
|
433
474
|
/**
|
|
434
475
|
* @param {Error | string} error - Error-like value.
|
|
435
476
|
* @returns {string} Error message.
|