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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.43",
3
+ "version": "0.1.45",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
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
- if (unresolvedTransition && unresolvedTransition.phase !== "committed" && unresolvedTransition.configDigest !== this.ownerRecoveryConfigDigest()) {
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
- if (transition.candidateReleaseId !== candidate.releaseId || transition.candidateReleasePath !== candidate.releasePath || transition.candidateRevision !== candidate.revision || transition.configDigest !== ownerConfigDigest(candidate.config)) {
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
  }
@@ -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)
@@ -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.reactivateStrict()
303
+ if (generationIds.has(id) && processInstance !== coordinator.process) await reactivate(processInstance)
295
304
  }
296
- await coordinator.process.reactivateStrict()
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[]} */ ([])
@@ -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",