rollbridge 0.1.43 → 0.1.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/daemon.js +12 -2
- package/src/managed-process.js +1 -1
- package/src/release-group.js +11 -2
- package/test/managed-process.test.js +25 -0
- package/test/rollbridge.test.js +5 -1
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -505,7 +505,10 @@ export default class RollbridgeDaemon {
|
|
|
505
505
|
if (!transfer?.config || !transfer.snapshot) throw new Error("Committed owner published incomplete replacement state")
|
|
506
506
|
const unresolvedTransition = transfer.snapshot.generationTransition
|
|
507
507
|
|
|
508
|
-
|
|
508
|
+
const transferredTransitionConfigDigest = transfer.config ? ownerConfigDigest(transfer.config) : undefined
|
|
509
|
+
if (unresolvedTransition && unresolvedTransition.phase !== "committed" &&
|
|
510
|
+
unresolvedTransition.configDigest !== this.ownerRecoveryConfigDigest() &&
|
|
511
|
+
unresolvedTransition.configDigest !== transferredTransitionConfigDigest) {
|
|
509
512
|
throw new Error(`Owner replacement cannot change config authority while unresolved generation transition ${unresolvedTransition.candidateReleaseId} remains at ${unresolvedTransition.phase}`)
|
|
510
513
|
}
|
|
511
514
|
const registeredProcesses = new Map((await this.guardian.inventory()).map(({key, provenance}) => [key, provenance]))
|
|
@@ -1914,7 +1917,14 @@ export default class RollbridgeDaemon {
|
|
|
1914
1917
|
* @param {{config: import("./config.js").RollbridgeConfig, releaseId: string, releasePath: string, revision: string}} candidate - Requested identity.
|
|
1915
1918
|
*/
|
|
1916
1919
|
assertExactGenerationTransition(transition, candidate) {
|
|
1917
|
-
|
|
1920
|
+
const retainedCandidate = this.releases.get(transition.candidateReleaseId)
|
|
1921
|
+
const retainedAuthorityMatches = transition.phase !== "committed" &&
|
|
1922
|
+
retainedCandidate?.releasePath === transition.candidateReleasePath &&
|
|
1923
|
+
retainedCandidate.revision === transition.candidateRevision &&
|
|
1924
|
+
ownerConfigDigest(retainedCandidate.config) === transition.configDigest
|
|
1925
|
+
const requestedAuthorityMatches = ownerConfigDigest(candidate.config) === transition.configDigest
|
|
1926
|
+
|
|
1927
|
+
if (transition.candidateReleaseId !== candidate.releaseId || transition.candidateReleasePath !== candidate.releasePath || transition.candidateRevision !== candidate.revision || (!requestedAuthorityMatches && !retainedAuthorityMatches)) {
|
|
1918
1928
|
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`)
|
|
1919
1929
|
}
|
|
1920
1930
|
}
|
package/src/managed-process.js
CHANGED
|
@@ -531,7 +531,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
531
531
|
const child = this.child
|
|
532
532
|
const pid = this.pid
|
|
533
533
|
|
|
534
|
-
if (!child?.pid || child.pid !== pid || (this.state !== "quiesced" && this.state !== "running")) {
|
|
534
|
+
if (!child?.pid || child.pid !== pid || (this.state !== "failed" && this.state !== "quiesced" && this.state !== "running")) {
|
|
535
535
|
throw new Error(`Process ${this.id} is not retained for reactivation`)
|
|
536
536
|
}
|
|
537
537
|
await this.runReactivationHook(pid)
|
package/src/release-group.js
CHANGED
|
@@ -290,10 +290,19 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
290
290
|
if (!coordinator) throw new Error(`Generation activation process ${processConfig.id} is not retained for release ${this.releaseId}`)
|
|
291
291
|
const generationIds = new Set([...this.handoffServiceIds, ...this.nonBlockingDrainIds])
|
|
292
292
|
|
|
293
|
+
/** @param {import("./managed-process.js").default} processInstance - Retained generation process. */
|
|
294
|
+
const reactivate = async (processInstance) => {
|
|
295
|
+
const {pid, state} = processInstance.status()
|
|
296
|
+
|
|
297
|
+
if (pid !== undefined) await processInstance.reactivateStrict()
|
|
298
|
+
else if (state === "stopped" || state === "failed") await processInstance.start("crash", "active")
|
|
299
|
+
else throw new Error(`Generation process is ${state}; refusing active-role restoration`)
|
|
300
|
+
}
|
|
301
|
+
|
|
293
302
|
for (const [id, processInstance] of this.processes) {
|
|
294
|
-
if (generationIds.has(id) && processInstance !== coordinator.process) await processInstance
|
|
303
|
+
if (generationIds.has(id) && processInstance !== coordinator.process) await reactivate(processInstance)
|
|
295
304
|
}
|
|
296
|
-
await coordinator.process
|
|
305
|
+
await reactivate(coordinator.process)
|
|
297
306
|
this.state = "active"
|
|
298
307
|
this.activatedAt = new Date().toISOString()
|
|
299
308
|
this.drainStartedAt = undefined
|
|
@@ -532,6 +532,31 @@ test("reactivateStrict restores a retained quiesced process only after activatio
|
|
|
532
532
|
}
|
|
533
533
|
})
|
|
534
534
|
|
|
535
|
+
test("reactivateStrict retries a failed active-role startup against the retained child", async () => {
|
|
536
|
+
const managed = buildLongLived(() => false)
|
|
537
|
+
let attempts = 0
|
|
538
|
+
|
|
539
|
+
managed.lifecycle = {activateCommand: "jobs activate", drainTimeoutMs: 0}
|
|
540
|
+
managed.runHook = async () => {
|
|
541
|
+
attempts += 1
|
|
542
|
+
return attempts === 1 ? new Error("startup activation raced readiness") : undefined
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
await assert.rejects(() => managed.start("deploy", "active"), /startup activation raced readiness/)
|
|
547
|
+
const failed = managed.status()
|
|
548
|
+
|
|
549
|
+
assert.equal(failed.state, "failed")
|
|
550
|
+
assert.ok(failed.pid)
|
|
551
|
+
await managed.reactivateStrict()
|
|
552
|
+
assert.equal(managed.status().state, "running")
|
|
553
|
+
assert.equal(managed.status().lifecycleRole, "active")
|
|
554
|
+
assert.equal(managed.status().pid, failed.pid)
|
|
555
|
+
} finally {
|
|
556
|
+
await managed.stop()
|
|
557
|
+
}
|
|
558
|
+
})
|
|
559
|
+
|
|
535
560
|
test("quiesce waits for active-role restoration before retiring a restarted process", async () => {
|
|
536
561
|
const managed = buildLongLived(() => false)
|
|
537
562
|
const hooks = /** @type {string[]} */ ([])
|
package/test/rollbridge.test.js
CHANGED
|
@@ -700,8 +700,12 @@ test("candidate activation failure reports restoration failure and exact recover
|
|
|
700
700
|
|
|
701
701
|
assert.ok(failedCandidate)
|
|
702
702
|
await failedCandidate.stop()
|
|
703
|
-
assert.equal(failedCandidate.state, "stopped")
|
|
704
703
|
incumbentCoordinator.reactivateStrict = reactivate
|
|
704
|
+
await incumbentCoordinator.stop()
|
|
705
|
+
assert.equal(failedCandidate.state, "stopped")
|
|
706
|
+
assert.equal(incumbentCoordinator.status().state, "stopped")
|
|
707
|
+
daemon.config = structuredClone(daemon.config)
|
|
708
|
+
daemon.config.processes[0].lifecycle.activateTimeoutMs = (daemon.config.processes[0].lifecycle.activateTimeoutMs ?? 30000) + 1
|
|
705
709
|
const recovery = await sendControlCommand({
|
|
706
710
|
command: {
|
|
707
711
|
command: "recover-generation-transition",
|