rollbridge 0.1.30 → 0.1.32
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-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 +305 -42
- package/src/guardian-client.js +46 -3
- package/src/managed-process.js +85 -13
- package/src/process-guardian.js +45 -1
- package/src/release-group.js +53 -14
- package/test/config-validation.test.js +44 -0
- package/test/guardian-client.test.js +70 -0
- package/test/managed-process.test.js +33 -0
- package/test/owner-recovery.test.js +396 -6
- package/test/owner-replacement.test.js +153 -3
- 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)
|
|
235
280
|
|
|
236
|
-
if (!
|
|
237
|
-
const
|
|
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")
|
|
283
|
+
|
|
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
|
|
|
@@ -300,6 +350,7 @@ export default class RollbridgeDaemon {
|
|
|
300
350
|
let stagingControlPath
|
|
301
351
|
let finalControlPublished = false
|
|
302
352
|
let listenersYielded = false
|
|
353
|
+
let retiredIncumbentControl = false
|
|
303
354
|
let retainIncumbentControl = false
|
|
304
355
|
let incumbentControl = legacyBridge?.incumbentControl
|
|
305
356
|
|
|
@@ -325,49 +376,69 @@ export default class RollbridgeDaemon {
|
|
|
325
376
|
if (legacyBridge) {
|
|
326
377
|
await this.crossLegacyDisruptiveBoundary(legacyBridge)
|
|
327
378
|
} else if (preparedStatus.ownerClaimed) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
379
|
+
try {
|
|
380
|
+
incumbentControl = await openControlSession(transfer.snapshot.control.path)
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
|
|
383
|
+
retiredIncumbentControl = true
|
|
384
|
+
}
|
|
385
|
+
if (incumbentControl) {
|
|
386
|
+
const listenerSession = incumbentControl
|
|
387
|
+
|
|
388
|
+
incumbentControl.onEvent((event) => this.handleIncumbentListenerEvent(event, listenerSession))
|
|
389
|
+
await incumbentControl.request({
|
|
390
|
+
command: "yield-owner-listeners",
|
|
391
|
+
control: transfer.snapshot.control.path === this.config.control.path,
|
|
392
|
+
proxy: true,
|
|
393
|
+
replacementId: prepared.replacementId
|
|
394
|
+
})
|
|
395
|
+
listenersYielded = true
|
|
396
|
+
}
|
|
339
397
|
}
|
|
340
398
|
if (sharedFixedProxy) await this.startProxy()
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
399
|
+
if (!retiredIncumbentControl) {
|
|
400
|
+
await fs.rename(stagingControlPath, this.config.control.path)
|
|
401
|
+
finalControlPublished = true
|
|
402
|
+
this.boundControlPath = this.config.control.path
|
|
403
|
+
}
|
|
344
404
|
const committed = this.guardian.waitForEvent("replacement-committed")
|
|
345
405
|
const staged = await this.guardian.stageOwnerReplacement(prepared.replacementId, {
|
|
346
406
|
authority: this.ownerAuthority(),
|
|
347
407
|
config: this.config,
|
|
348
408
|
releaseConfigs: Object.fromEntries([...this.releases].map(([releaseId, release]) => [releaseId, release.config])),
|
|
409
|
+
singletonReleaseIds: Object.fromEntries(this.singletonReleaseIds),
|
|
349
410
|
snapshot: this.status()
|
|
350
411
|
})
|
|
351
412
|
|
|
352
413
|
if (!staged.committed) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
414
|
+
if (retiredIncumbentControl) {
|
|
415
|
+
await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId)
|
|
416
|
+
} else {
|
|
417
|
+
try {
|
|
418
|
+
if (!incumbentControl) throw new Error("Owner replacement incumbent control session is unavailable")
|
|
419
|
+
await incumbentControl.request({command: "commit-owner-replacement", replacementId: prepared.replacementId})
|
|
420
|
+
} catch (error) {
|
|
421
|
+
const status = await this.guardian.replacementStatus()
|
|
422
|
+
|
|
423
|
+
if (status.committedReplacementId !== prepared.replacementId || !status.ownerClaimed) throw error
|
|
424
|
+
this.logger("owner replacement commit response lost; guardian commit confirmed", {replacementId: prepared.replacementId})
|
|
425
|
+
}
|
|
361
426
|
}
|
|
362
427
|
}
|
|
363
428
|
await committed
|
|
364
429
|
committedAuthority = true
|
|
430
|
+
if (retiredIncumbentControl) {
|
|
431
|
+
await fs.rename(stagingControlPath, this.config.control.path)
|
|
432
|
+
finalControlPublished = true
|
|
433
|
+
this.boundControlPath = this.config.control.path
|
|
434
|
+
}
|
|
435
|
+
await Promise.all([...this.releases.values()].map((release) => release.synchronizeLifecycleRoles()))
|
|
365
436
|
if (!legacyBridge && incumbentControl && [...this.releases.values()].some((release) => release.hasTransferredConnections())) {
|
|
366
437
|
this.incumbentListenerControl = incumbentControl
|
|
367
438
|
retainIncumbentControl = true
|
|
368
439
|
}
|
|
369
440
|
for (const release of this.releases.values()) {
|
|
370
|
-
if (release.state === "draining") void this.drainAndPrune(release, release.config)
|
|
441
|
+
if (release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
|
|
371
442
|
}
|
|
372
443
|
this.startStatePersistence()
|
|
373
444
|
await this.persistState({throwOnError: true})
|
|
@@ -490,6 +561,7 @@ export default class RollbridgeDaemon {
|
|
|
490
561
|
authority: persistedAuthority,
|
|
491
562
|
config: this.config,
|
|
492
563
|
releaseConfigs: Object.fromEntries(persisted.releases.map((release) => [release.releaseId, this.config])),
|
|
564
|
+
singletonReleaseIds: Object.fromEntries(persisted.singletons.map((singleton) => [singleton.id, persisted.activeReleaseId]).filter((entry) => entry[1] !== null)),
|
|
493
565
|
snapshot: persisted
|
|
494
566
|
}
|
|
495
567
|
const upgradedIdentity = /** @type {{pid?: number, socketPath: string, token: string}} */ ({
|
|
@@ -1003,7 +1075,7 @@ export default class RollbridgeDaemon {
|
|
|
1003
1075
|
if (!handoff || this.ownerRetired) return
|
|
1004
1076
|
for (const release of this.releases.values()) {
|
|
1005
1077
|
release.resumeDrainAfterOwnerHandoff()
|
|
1006
|
-
if (release.state === "draining") void this.drainAndPrune(release, release.config)
|
|
1078
|
+
if (release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
|
|
1007
1079
|
}
|
|
1008
1080
|
if (handoff.proxy) {
|
|
1009
1081
|
await this.startProxy()
|
|
@@ -1029,6 +1101,16 @@ export default class RollbridgeDaemon {
|
|
|
1029
1101
|
this.assertReloadCompatible(nextConfig)
|
|
1030
1102
|
|
|
1031
1103
|
const newReleaseId = releaseId || revision || new Date().toISOString().replace(/[^0-9]/g, "")
|
|
1104
|
+
const transition = this.generationTransition
|
|
1105
|
+
|
|
1106
|
+
if (transition && transition.phase !== "committed") {
|
|
1107
|
+
this.assertExactGenerationTransition(transition, {config: nextConfig, releaseId: newReleaseId, releasePath, revision: revision || newReleaseId})
|
|
1108
|
+
return await this.resumeGenerationTransition()
|
|
1109
|
+
}
|
|
1110
|
+
if (transition?.phase === "committed" && transition.candidateReleaseId === newReleaseId && this.activeRelease?.releaseId === newReleaseId) {
|
|
1111
|
+
this.assertExactGenerationTransition(transition, {config: nextConfig, releaseId: newReleaseId, releasePath, revision: revision || newReleaseId})
|
|
1112
|
+
return {activeReleaseId: newReleaseId, previousReleaseId: transition.previousReleaseId}
|
|
1113
|
+
}
|
|
1032
1114
|
const release = new ReleaseGroup({
|
|
1033
1115
|
config: nextConfig,
|
|
1034
1116
|
logger: this.logger,
|
|
@@ -1061,6 +1143,25 @@ export default class RollbridgeDaemon {
|
|
|
1061
1143
|
|
|
1062
1144
|
const previousRelease = this.activeRelease
|
|
1063
1145
|
|
|
1146
|
+
if (nextConfig.processes.some((processConfig) => processConfig.lifecycle.activateCommand !== undefined)) {
|
|
1147
|
+
this.config = nextConfig
|
|
1148
|
+
this.releases.set(release.releaseId, release)
|
|
1149
|
+
const now = new Date().toISOString()
|
|
1150
|
+
|
|
1151
|
+
this.generationTransition = /** @type {GenerationTransition} */ ({
|
|
1152
|
+
candidateReleaseId: release.releaseId,
|
|
1153
|
+
candidateReleasePath: release.releasePath,
|
|
1154
|
+
candidateRevision: release.revision,
|
|
1155
|
+
configDigest: ownerConfigDigest(nextConfig),
|
|
1156
|
+
phase: "candidate_ready",
|
|
1157
|
+
previousReleaseId: previousRelease?.releaseId ?? null,
|
|
1158
|
+
startedAt: now,
|
|
1159
|
+
updatedAt: now
|
|
1160
|
+
})
|
|
1161
|
+
await this.checkpointGenerationTransition()
|
|
1162
|
+
return await this.resumeGenerationTransition()
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1064
1165
|
this.config = nextConfig
|
|
1065
1166
|
this.releases.set(release.releaseId, release)
|
|
1066
1167
|
release.activate()
|
|
@@ -1094,6 +1195,138 @@ export default class RollbridgeDaemon {
|
|
|
1094
1195
|
}
|
|
1095
1196
|
}
|
|
1096
1197
|
|
|
1198
|
+
/**
|
|
1199
|
+
* Continues one exact durable generation transition without an internal retry loop.
|
|
1200
|
+
* @returns {Promise<Record<string, JsonValue>>} Deploy result after commit.
|
|
1201
|
+
*/
|
|
1202
|
+
async resumeGenerationTransition() {
|
|
1203
|
+
const transition = this.generationTransition
|
|
1204
|
+
|
|
1205
|
+
if (!transition) throw new Error("No release generation transition to resume")
|
|
1206
|
+
const release = this.releases.get(transition.candidateReleaseId)
|
|
1207
|
+
const previousRelease = transition.previousReleaseId ? this.releases.get(transition.previousReleaseId) : undefined
|
|
1208
|
+
|
|
1209
|
+
if (!release) throw new Error(`Generation transition candidate ${transition.candidateReleaseId} is not retained`)
|
|
1210
|
+
if (transition.previousReleaseId && !previousRelease) throw new Error(`Generation transition previous release ${transition.previousReleaseId} is not retained`)
|
|
1211
|
+
|
|
1212
|
+
if (transition.phase === "candidate_ready") {
|
|
1213
|
+
if (previousRelease) await this.updateGenerationTransition("retiring_previous")
|
|
1214
|
+
else await this.updateGenerationTransition("previous_retired")
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (transition.phase === "retiring_previous") {
|
|
1218
|
+
try {
|
|
1219
|
+
const retirementConfig = previousRelease?.config
|
|
1220
|
+
|
|
1221
|
+
// Every entry into this journaled phase represents one explicit attempt. Reset the
|
|
1222
|
+
// process-local acknowledgement cache so exact resume can replay an ambiguous or
|
|
1223
|
+
// failed idempotent retirement once; there is no internal retry loop.
|
|
1224
|
+
await previousRelease?.beginRetirement(retirementConfig, {retry: true})
|
|
1225
|
+
} catch (error) {
|
|
1226
|
+
const failure = previousRelease?.retirementError ?? (error instanceof Error ? error.message : String(error))
|
|
1227
|
+
|
|
1228
|
+
await this.failGenerationTransition(failure)
|
|
1229
|
+
this.logger("release retirement quiescence failed", {error: failure, releaseId: previousRelease?.releaseId ?? null})
|
|
1230
|
+
throw error
|
|
1231
|
+
}
|
|
1232
|
+
await this.updateGenerationTransition("previous_retired")
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
if (transition.phase === "previous_retired") await this.updateGenerationTransition("activating_candidate")
|
|
1236
|
+
|
|
1237
|
+
if (transition.phase === "activating_candidate") {
|
|
1238
|
+
try {
|
|
1239
|
+
await release.activateGeneration()
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
const failure = error instanceof Error ? error.message : String(error)
|
|
1242
|
+
|
|
1243
|
+
await this.failGenerationTransition(failure)
|
|
1244
|
+
this.logger("release generation activation failed", {error: failure, releaseId: release.releaseId})
|
|
1245
|
+
throw error
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Activation acknowledgement and the logical proxy commit deliberately share one
|
|
1249
|
+
// synchronous continuation: no awaited failure boundary may leave jobs active while
|
|
1250
|
+
// Rollbridge still points new traffic at the retired generation.
|
|
1251
|
+
release.activate()
|
|
1252
|
+
this.activeRelease = release
|
|
1253
|
+
transition.phase = "committed_pending"
|
|
1254
|
+
transition.error = undefined
|
|
1255
|
+
transition.updatedAt = new Date().toISOString()
|
|
1256
|
+
this.logger("traffic switched", {previousReleaseId: previousRelease?.releaseId ?? null, releaseId: release.releaseId})
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
if (transition.phase === "committed_pending") {
|
|
1260
|
+
await this.checkpointGenerationTransition()
|
|
1261
|
+
this.refreshServiceDefinitions(release)
|
|
1262
|
+
if (previousRelease) {
|
|
1263
|
+
const retirementConfig = previousRelease.config
|
|
1264
|
+
|
|
1265
|
+
void this.drainAndPrune(previousRelease, retirementConfig)
|
|
1266
|
+
}
|
|
1267
|
+
try {
|
|
1268
|
+
await this.replaceSingletons(release)
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
await this.failGenerationTransition(error instanceof Error ? error.message : String(error))
|
|
1271
|
+
throw error
|
|
1272
|
+
}
|
|
1273
|
+
transition.phase = "committed"
|
|
1274
|
+
transition.error = undefined
|
|
1275
|
+
transition.updatedAt = new Date().toISOString()
|
|
1276
|
+
await this.checkpointGenerationTransition()
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
return {activeReleaseId: release.releaseId, previousReleaseId: previousRelease?.releaseId ?? null}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* @param {GenerationTransition} transition - Pending or committed exact transition.
|
|
1284
|
+
* @param {{config: import("./config.js").RollbridgeConfig, releaseId: string, releasePath: string, revision: string}} candidate - Requested identity.
|
|
1285
|
+
*/
|
|
1286
|
+
assertExactGenerationTransition(transition, candidate) {
|
|
1287
|
+
if (transition.candidateReleaseId !== candidate.releaseId || transition.candidateReleasePath !== candidate.releasePath || transition.candidateRevision !== candidate.revision || transition.configDigest !== ownerConfigDigest(candidate.config)) {
|
|
1288
|
+
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`)
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/** @param {GenerationTransitionPhase} phase - Durable phase to enter. */
|
|
1293
|
+
async updateGenerationTransition(phase) {
|
|
1294
|
+
if (!this.generationTransition) throw new Error("No release generation transition to update")
|
|
1295
|
+
this.generationTransition.phase = phase
|
|
1296
|
+
this.generationTransition.error = undefined
|
|
1297
|
+
this.generationTransition.updatedAt = new Date().toISOString()
|
|
1298
|
+
await this.checkpointGenerationTransition()
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/** @param {string} error - Exact failed phase diagnostic. */
|
|
1302
|
+
async failGenerationTransition(error) {
|
|
1303
|
+
if (!this.generationTransition) throw new Error("No release generation transition to fail")
|
|
1304
|
+
this.generationTransition.error = error
|
|
1305
|
+
this.generationTransition.updatedAt = new Date().toISOString()
|
|
1306
|
+
await this.checkpointGenerationTransition()
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/** Persists and publishes the exact transition boundary before another external effect. */
|
|
1310
|
+
async checkpointGenerationTransition() {
|
|
1311
|
+
// Publish private exact definitions before the public secret-safe state can expose
|
|
1312
|
+
// a newer transition authority. A replacement can then reconstruct every guardian
|
|
1313
|
+
// registration without reading commands or environment values from statePath.
|
|
1314
|
+
await this.publishOwnerState()
|
|
1315
|
+
const write = this.persistState({throwOnError: true})
|
|
1316
|
+
|
|
1317
|
+
if (write) await write
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* @param {ReleaseGroup} release - Retained release.
|
|
1322
|
+
* @returns {boolean} Whether its stop drain may run now.
|
|
1323
|
+
*/
|
|
1324
|
+
shouldResumeDrain(release) {
|
|
1325
|
+
const transition = this.generationTransition
|
|
1326
|
+
|
|
1327
|
+
return !transition || transition.phase === "committed_pending" || transition.phase === "committed" || transition.previousReleaseId !== release.releaseId
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1097
1330
|
/**
|
|
1098
1331
|
* Relinquishes only daemon authority/listeners after the guardian has committed a
|
|
1099
1332
|
* prepared replacement. Guardian-owned processes and drains are never stopped.
|
|
@@ -1162,6 +1395,7 @@ export default class RollbridgeDaemon {
|
|
|
1162
1395
|
* @returns {Promise<Record<string, JsonValue>>} The rollback result.
|
|
1163
1396
|
*/
|
|
1164
1397
|
async rollback({releaseId} = {}) {
|
|
1398
|
+
this.assertNoUnresolvedGenerationTransition("rollback")
|
|
1165
1399
|
const target = releaseId ? this.releases.get(releaseId) : this.previousRelease()
|
|
1166
1400
|
|
|
1167
1401
|
if (!target) {
|
|
@@ -1285,6 +1519,9 @@ export default class RollbridgeDaemon {
|
|
|
1285
1519
|
if (processConfig.policy !== "singleton") continue
|
|
1286
1520
|
|
|
1287
1521
|
const previous = this.singletons.get(processConfig.id)
|
|
1522
|
+
const previousReleaseId = this.singletonReleaseIds.get(processConfig.id)
|
|
1523
|
+
|
|
1524
|
+
if (previous && previousReleaseId === release.releaseId && previous.status().state === "running") continue
|
|
1288
1525
|
|
|
1289
1526
|
if (previous) {
|
|
1290
1527
|
await previous.stop()
|
|
@@ -1294,8 +1531,10 @@ export default class RollbridgeDaemon {
|
|
|
1294
1531
|
const singleton = release.buildProcess(processConfig, {guardianKey: `singleton:${release.releaseId}:${processConfig.id}`})
|
|
1295
1532
|
|
|
1296
1533
|
this.singletons.set(processConfig.id, singleton)
|
|
1534
|
+
this.singletonReleaseIds.set(processConfig.id, release.releaseId)
|
|
1297
1535
|
await singleton.start("deploy")
|
|
1298
1536
|
}
|
|
1537
|
+
this.pruneStoppedReleases()
|
|
1299
1538
|
}
|
|
1300
1539
|
|
|
1301
1540
|
/**
|
|
@@ -1309,6 +1548,7 @@ export default class RollbridgeDaemon {
|
|
|
1309
1548
|
* @returns {Promise<Record<string, JsonValue>>} The ids that were restarted.
|
|
1310
1549
|
*/
|
|
1311
1550
|
async restartProcesses({policy, processId} = {}) {
|
|
1551
|
+
this.assertNoUnresolvedGenerationTransition("restart")
|
|
1312
1552
|
if (policy === "proxied" || (processId !== undefined && this.isProxiedId(processId))) {
|
|
1313
1553
|
throw new Error('The proxied process cannot be restarted in place; use "rollbridge deploy" for a zero-downtime replacement.')
|
|
1314
1554
|
}
|
|
@@ -1322,7 +1562,7 @@ export default class RollbridgeDaemon {
|
|
|
1322
1562
|
for (const target of targets) {
|
|
1323
1563
|
this.logger("process restart requested", {processId: target.id})
|
|
1324
1564
|
await target.process.stop()
|
|
1325
|
-
await target.process.start("manual")
|
|
1565
|
+
await target.process.start("manual", target.process.lifecycle.activateCommand ? "active" : undefined)
|
|
1326
1566
|
}
|
|
1327
1567
|
|
|
1328
1568
|
return {restarted: targets.map((target) => target.id)}
|
|
@@ -1383,6 +1623,7 @@ export default class RollbridgeDaemon {
|
|
|
1383
1623
|
* @returns {Promise<void>} Resolves when stopped.
|
|
1384
1624
|
*/
|
|
1385
1625
|
async stopRelease(releaseId) {
|
|
1626
|
+
this.assertNoUnresolvedGenerationTransition("stop")
|
|
1386
1627
|
const release = releaseId ? this.releases.get(releaseId) : this.activeRelease
|
|
1387
1628
|
|
|
1388
1629
|
if (!release) throw new Error(`Release not found: ${releaseId || "active"}`)
|
|
@@ -1394,6 +1635,15 @@ export default class RollbridgeDaemon {
|
|
|
1394
1635
|
this.persistState()
|
|
1395
1636
|
}
|
|
1396
1637
|
|
|
1638
|
+
/** @param {string} operation - Mutating control operation. */
|
|
1639
|
+
assertNoUnresolvedGenerationTransition(operation) {
|
|
1640
|
+
const transition = this.generationTransition
|
|
1641
|
+
|
|
1642
|
+
if (transition && transition.phase !== "committed") {
|
|
1643
|
+
throw new Error(`Cannot ${operation} while release generation transition ${transition.candidateReleaseId} is unresolved at ${transition.phase}; resume the exact deploy first`)
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1397
1647
|
/**
|
|
1398
1648
|
* Drains and stops a retired release in the background, then prunes stopped releases.
|
|
1399
1649
|
* @param {ReleaseGroup} release - Release to drain and stop.
|
|
@@ -1416,9 +1666,13 @@ export default class RollbridgeDaemon {
|
|
|
1416
1666
|
|
|
1417
1667
|
/** @returns {void} Removes stopped releases beyond the retention policy. */
|
|
1418
1668
|
pruneStoppedReleases() {
|
|
1419
|
-
const
|
|
1669
|
+
const singletonOwnerReleaseIds = new Set(this.singletonReleaseIds.values())
|
|
1670
|
+
const statuses = [...this.releases.values()]
|
|
1671
|
+
.filter((release) => !singletonOwnerReleaseIds.has(release.releaseId))
|
|
1672
|
+
.map((release) => release.status())
|
|
1420
1673
|
|
|
1421
1674
|
for (const releaseId of releasesToPrune(statuses, this.config.releaseRetention, Date.now())) {
|
|
1675
|
+
this.releases.get(releaseId)?.releasePortReservations()
|
|
1422
1676
|
this.releases.delete(releaseId)
|
|
1423
1677
|
}
|
|
1424
1678
|
}
|
|
@@ -1450,9 +1704,10 @@ export default class RollbridgeDaemon {
|
|
|
1450
1704
|
...status,
|
|
1451
1705
|
events,
|
|
1452
1706
|
persistedAt: new Date().toISOString(),
|
|
1707
|
+
...(this.hasActivationLifecycle() ? {singletonReleaseIds: Object.fromEntries(this.singletonReleaseIds)} : {}),
|
|
1453
1708
|
...(this.guardianIdentity ? {recovery: {
|
|
1454
1709
|
configDigest: this.ownerRecoveryConfigDigest(),
|
|
1455
|
-
format: 1,
|
|
1710
|
+
format: this.hasActivationLifecycle() ? 2 : 1,
|
|
1456
1711
|
guardian: this.guardianIdentity,
|
|
1457
1712
|
reconnectGraceMs: this.config.ownerRecovery?.reconnectGraceMs
|
|
1458
1713
|
}} : {})
|
|
@@ -1472,6 +1727,11 @@ export default class RollbridgeDaemon {
|
|
|
1472
1727
|
return this.pendingWrite
|
|
1473
1728
|
}
|
|
1474
1729
|
|
|
1730
|
+
/** @returns {boolean} Whether the current authority uses explicit generation activation. */
|
|
1731
|
+
hasActivationLifecycle() {
|
|
1732
|
+
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))
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1475
1735
|
/**
|
|
1476
1736
|
* On startup, reads any state left by a previous daemon and reports managed processes whose
|
|
1477
1737
|
* pids are still alive — likely orphans from a daemon that did not shut down cleanly. This is
|
|
@@ -1659,6 +1919,8 @@ export default class RollbridgeDaemon {
|
|
|
1659
1919
|
// a cleared orphan must not reappear if the OS later recycles its pid for an unrelated process.
|
|
1660
1920
|
this.orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
|
|
1661
1921
|
|
|
1922
|
+
const singletonOwnerReleaseIds = new Set(this.singletonReleaseIds.values())
|
|
1923
|
+
|
|
1662
1924
|
return {
|
|
1663
1925
|
activeReleaseId: this.activeRelease ? this.activeRelease.releaseId : null,
|
|
1664
1926
|
application: this.config.application,
|
|
@@ -1666,6 +1928,7 @@ export default class RollbridgeDaemon {
|
|
|
1666
1928
|
control: {...this.config.control},
|
|
1667
1929
|
daemonPid: process.pid,
|
|
1668
1930
|
daemonRuntime: this.runtime ? {...this.runtime} : undefined,
|
|
1931
|
+
generationTransition: this.generationTransition ? {...this.generationTransition} : undefined,
|
|
1669
1932
|
ownerRecovery: this.guardian ? {configDigest: this.ownerRecoveryConfigDigest()} : undefined,
|
|
1670
1933
|
ownerTransition: this.ownerTransition ? {...this.ownerTransition} : undefined,
|
|
1671
1934
|
orphans: [...this.orphans],
|
|
@@ -1675,7 +1938,7 @@ export default class RollbridgeDaemon {
|
|
|
1675
1938
|
upstreamHost: this.config.proxy.upstreamHost
|
|
1676
1939
|
},
|
|
1677
1940
|
releaseReferences: [...this.releases.values()]
|
|
1678
|
-
.filter((release) => release.state === "active" || release.state === "draining")
|
|
1941
|
+
.filter((release) => release.state === "active" || release.state === "draining" || singletonOwnerReleaseIds.has(release.releaseId) || (this.generationTransition?.phase !== "committed" && this.generationTransition?.candidateReleaseId === release.releaseId))
|
|
1679
1942
|
.map((release) => ({releaseId: release.releaseId, releasePath: release.releasePath})),
|
|
1680
1943
|
releases: [...this.releases.values()].map((release) => release.status()),
|
|
1681
1944
|
services: [...this.services.entries()].map(([id, processInstance]) => ({
|