rollbridge 0.1.41 → 0.1.43

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/README.md CHANGED
@@ -89,6 +89,7 @@ export default {
89
89
  command: "env VELOCIOUS_BACKGROUND_JOBS_PORT={{port}} npx velocious background-jobs-main",
90
90
  lifecycle: {
91
91
  activateCommand: 'npx velocious background-jobs:activate --generation "$ROLLBRIDGE_RELEASE_ID" --socket "$VELOCIOUS_BACKGROUND_JOBS_LIFECYCLE_SOCKET"',
92
+ activateTimeoutMs: 60000,
92
93
  quietCommand: 'npx velocious background-jobs:retire --generation "$ROLLBRIDGE_RELEASE_ID" --socket "$VELOCIOUS_BACKGROUND_JOBS_LIFECYCLE_SOCKET"'
93
94
  },
94
95
  port: {from: 7331, to: 7399}
@@ -188,7 +189,8 @@ generation-scoped and resumable; failures remain visible and block unrelated
188
189
  deploys. Post-commit singleton replacement is also journaled and must complete
189
190
  before an exact retry reports success. If the active coordinator restarts,
190
191
  Rollbridge restores its active role with the same bounded, generation-scoped
191
- activation command before reporting it running. Omit `activateCommand` to
192
+ activation command before reporting it running. Set `activateTimeoutMs` when the
193
+ activation acknowledgement can exceed its 30-second default. Omit `activateCommand` to
192
194
  preserve the existing hook-free ordering.
193
195
 
194
196
  See [`docs/workers.md`](docs/workers.md) for the full release-generation
package/docs/config.md CHANGED
@@ -303,8 +303,8 @@ service when the service starts as a quiescent candidate and requires an explici
303
303
  generation transition. Rollbridge starts and health-checks the complete candidate,
304
304
  waits for the old generation's strict retirement acknowledgement, waits for the
305
305
  candidate's strict activation acknowledgement, then commits the active release and
306
- proxy target synchronously. Activation is always bounded to 30 seconds;
307
- retirement uses the process's `gracefulStopMs` bound (or 30 seconds when that
306
+ proxy target synchronously. Activation is bounded by `lifecycle.activateTimeoutMs`,
307
+ which defaults to 30 seconds; retirement uses the process's `gracefulStopMs` bound (or 30 seconds when that
308
308
  window is `"indefinite"`). Both run with the process environment plus
309
309
  `ROLLBRIDGE_PID`.
310
310
 
@@ -347,7 +347,8 @@ legitimate hours-long generation drains are valid.
347
347
 
348
348
  | Field | Type | Default | Description |
349
349
  | --- | --- | --- | --- |
350
- | `lifecycle.activateCommand` | string | unset | For one handoff service, acknowledge activation of its already-started candidate generation after the previous generation has acknowledged retirement. Requires `quietCommand`, `statePath`, and `ownerRecovery`; bounded to 30 seconds. |
350
+ | `lifecycle.activateCommand` | string | unset | For one handoff service, acknowledge activation of its already-started candidate generation after the previous generation has acknowledged retirement. Requires `quietCommand`, `statePath`, and `ownerRecovery`; bounded by `activateTimeoutMs`. |
351
+ | `lifecycle.activateTimeoutMs` | positive number | `30000` | Bounds activation, reactivation, and active-role restoration commands. |
351
352
  | `lifecycle.quietCommand` | string | unset | Run first to tell the process to stop accepting new work. Bounded by `gracefulStopMs`, or 30 seconds when that window is `"indefinite"`. |
352
353
  | `lifecycle.drainCommand` | string | unset | Run after quieting to wait until the process has drained (it blocks until done). When unset, Rollbridge instead waits up to `drainTimeoutMs` for the process to exit on its own. Requires a positive `drainTimeoutMs` (which bounds it). |
353
354
  | `lifecycle.drainTimeoutMs` | non-negative number | `0` | Bounds the drain step. `0` **skips the drain step entirely** (no `drainCommand`, no wait). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
package/src/config.js CHANGED
@@ -14,7 +14,7 @@ import {pathToFileURL} from "node:url"
14
14
  * @typedef {"proxied" | "companion" | "singleton" | "service"} ProcessPolicy
15
15
  * @typedef {{backoffFactor: number, maxDelayMs: number, maxRestarts: number | undefined, windowMs: number}} RestartConfig
16
16
  * @typedef {{checkIntervalMs: number, limitBytes: number, warnBytes: number}} MemoryConfig
17
- * @typedef {{activateCommand?: string, drainCommand?: string, drainTimeoutMs: number, quietCommand?: string, reactivateCommand?: string, stopCommand?: string}} LifecycleConfig
17
+ * @typedef {{activateCommand?: string, activateTimeoutMs?: number, drainCommand?: string, drainTimeoutMs: number, quietCommand?: string, reactivateCommand?: string, stopCommand?: string}} LifecycleConfig
18
18
  * @typedef {number | "indefinite"} StopTimeoutMs
19
19
  * @typedef {"persistent" | "handoff"} ServiceDeployStrategy
20
20
  * @typedef {{cwd?: string, deployStrategy: ServiceDeployStrategy, env: Record<string, string>, gracefulStopMs: StopTimeoutMs, health?: HealthConfig, id: string, lifecycle: LifecycleConfig, memory?: MemoryConfig, nonBlockingDrain: boolean, outputLines: number, policy: ProcessPolicy, port?: PortRange, replicas: number, restart: RestartConfig, restartDelayMs: number, stopSignal: string, command: string}} ProcessConfig
@@ -343,17 +343,25 @@ function normalizeMemory(value, key, issues) {
343
343
  * @returns {LifecycleConfig} Normalized lifecycle hooks (no commands and a 0 drain by default).
344
344
  */
345
345
  function normalizeLifecycle(value, key, issues) {
346
- if (value === undefined || value === null) return {drainTimeoutMs: 0}
346
+ if (value === undefined || value === null) return {activateTimeoutMs: 30000, drainTimeoutMs: 0}
347
347
 
348
348
  if (!isPlainObject(value)) {
349
349
  issues.push({fix: `Set ${key} to a mapping with optional activateCommand, quietCommand, reactivateCommand, drainCommand, stopCommand, and drainTimeoutMs.`, message: `${key} must be an object`})
350
350
 
351
- return {drainTimeoutMs: 0}
351
+ return {activateTimeoutMs: 30000, drainTimeoutMs: 0}
352
352
  }
353
353
 
354
+ const activateTimeoutMs = normalizeNumber(value.activateTimeoutMs, `${key}.activateTimeoutMs`, issues, {default: 30000})
354
355
  const drainTimeoutMs = normalizeNumber(value.drainTimeoutMs, `${key}.drainTimeoutMs`, issues, {default: 0})
355
356
  /** @type {LifecycleConfig} */
356
- const lifecycle = {drainTimeoutMs: nonNegativeOrDefault(drainTimeoutMs, `${key}.drainTimeoutMs`, issues, 0, false)}
357
+ const lifecycle = {
358
+ activateTimeoutMs: activateTimeoutMs > 0 ? activateTimeoutMs : 30000,
359
+ drainTimeoutMs: nonNegativeOrDefault(drainTimeoutMs, `${key}.drainTimeoutMs`, issues, 0, false)
360
+ }
361
+
362
+ if (activateTimeoutMs <= 0) {
363
+ issues.push({fix: `Set ${key}.activateTimeoutMs to a positive number of milliseconds, e.g. 30000.`, message: `${key}.activateTimeoutMs must be a positive number`})
364
+ }
357
365
 
358
366
  if (value.activateCommand !== undefined) lifecycle.activateCommand = normalizeString(value.activateCommand, `${key}.activateCommand`, issues, {nonEmpty: true})
359
367
  if (value.quietCommand !== undefined) lifecycle.quietCommand = normalizeString(value.quietCommand, `${key}.quietCommand`, issues, {nonEmpty: true})
package/src/daemon.js CHANGED
@@ -1814,7 +1814,7 @@ export default class RollbridgeDaemon {
1814
1814
  }
1815
1815
 
1816
1816
  if (transition.phase === "restoring_previous") {
1817
- if (candidate.state !== "draining") throw new Error(`Failed candidate ${candidate.releaseId} is not retired before incumbent restoration`)
1817
+ if (candidate.state !== "draining" && candidate.state !== "stopped") throw new Error(`Failed candidate ${candidate.releaseId} is not retired before incumbent restoration`)
1818
1818
  try {
1819
1819
  await previous.reactivateGeneration()
1820
1820
  } catch (error) {
@@ -4,7 +4,7 @@ import {EventEmitter} from "node:events"
4
4
  import {spawn} from "node:child_process"
5
5
  import {processGroupHasLiveMembers, processGroupMembers} from "./process-memory.js"
6
6
 
7
- const ACTIVATION_HOOK_TIMEOUT_MS = 30000
7
+ const DEFAULT_ACTIVATION_HOOK_TIMEOUT_MS = 30000
8
8
  const MAX_BUFFERED_OUTPUT_CHARACTERS = 64 * 1024
9
9
 
10
10
  /**
@@ -554,7 +554,7 @@ export default class ManagedProcess extends EventEmitter {
554
554
  const command = this.lifecycle.activateCommand
555
555
 
556
556
  if (!command) return
557
- const error = await this.runHook(command, ACTIVATION_HOOK_TIMEOUT_MS, "activate command", pid)
557
+ const error = await this.runHook(command, this.activationHookTimeoutMs(), "activate command", pid)
558
558
 
559
559
  if (error) throw error
560
560
  }
@@ -570,7 +570,7 @@ export default class ManagedProcess extends EventEmitter {
570
570
 
571
571
  if (!command) return
572
572
  const label = this.lifecycle.reactivateCommand ? "reactivate command" : "activate command"
573
- const error = await this.runHook(command, ACTIVATION_HOOK_TIMEOUT_MS, label, pid)
573
+ const error = await this.runHook(command, this.activationHookTimeoutMs(), label, pid)
574
574
 
575
575
  if (error) throw error
576
576
  }
@@ -596,13 +596,18 @@ export default class ManagedProcess extends EventEmitter {
596
596
  : this.lifecycle.quietCommand
597
597
 
598
598
  if (!command) throw new Error(`Process ${this.id} cannot restore lifecycle role ${this.lifecycleRole} without its paired command`)
599
- const timeoutMs = this.lifecycleRole === "active" ? ACTIVATION_HOOK_TIMEOUT_MS : this.hookTimeoutMs()
599
+ const timeoutMs = this.lifecycleRole === "active" ? this.activationHookTimeoutMs() : this.hookTimeoutMs()
600
600
  const activeLabel = this.lifecycle.reactivateCommand ? "reactivate" : "activate"
601
601
  const error = await this.runHook(command, timeoutMs, `${this.lifecycleRole === "active" ? activeLabel : "quiet"} command`, this.pid)
602
602
 
603
603
  if (error) throw error
604
604
  }
605
605
 
606
+ /** @returns {number} Timeout used for activation/reactivation hooks. */
607
+ activationHookTimeoutMs() {
608
+ return this.lifecycle.activateTimeoutMs ?? DEFAULT_ACTIVATION_HOOK_TIMEOUT_MS
609
+ }
610
+
606
611
  /** @returns {number} Timeout used for lifecycle hooks. */
607
612
  hookTimeoutMs() {
608
613
  if (this.stopTimeoutMs === "indefinite") return 30000
@@ -180,18 +180,20 @@ test("validateConfig defaults lifecycle, accepts hooks, and rejects bad values",
180
180
  })
181
181
 
182
182
  // Omitted → no commands, zero drain.
183
- assert.deepEqual(validateLifecycle(undefined).config.processes[0].lifecycle, {drainTimeoutMs: 0})
183
+ assert.deepEqual(validateLifecycle(undefined).config.processes[0].lifecycle, {activateTimeoutMs: 30000, drainTimeoutMs: 0})
184
184
 
185
- const custom = validateLifecycle({drainTimeoutMs: 30000, quietCommand: "kill -TSTP $ROLLBRIDGE_PID", stopCommand: "kill -TERM $ROLLBRIDGE_PID"})
185
+ const custom = validateLifecycle({activateTimeoutMs: 60000, drainTimeoutMs: 30000, quietCommand: "kill -TSTP $ROLLBRIDGE_PID", stopCommand: "kill -TERM $ROLLBRIDGE_PID"})
186
186
 
187
187
  assert.deepEqual(custom.issues, [])
188
188
  assert.equal(custom.config.processes[0].lifecycle.quietCommand, "kill -TSTP $ROLLBRIDGE_PID")
189
189
  assert.equal(custom.config.processes[0].lifecycle.stopCommand, "kill -TERM $ROLLBRIDGE_PID")
190
+ assert.equal(custom.config.processes[0].lifecycle.activateTimeoutMs, 60000)
190
191
  assert.equal(custom.config.processes[0].lifecycle.drainTimeoutMs, 30000)
191
192
 
192
- const invalid = validateLifecycle({drainTimeoutMs: -1, quietCommand: 5})
193
+ const invalid = validateLifecycle({activateTimeoutMs: 0, drainTimeoutMs: -1, quietCommand: 5})
193
194
  const messages = invalid.issues.map((issue) => issue.message)
194
195
 
196
+ assert.ok(messages.includes("processes[0].lifecycle.activateTimeoutMs must be a positive number"), JSON.stringify(messages))
195
197
  assert.ok(messages.includes("processes[0].lifecycle.drainTimeoutMs must be a non-negative number"), JSON.stringify(messages))
196
198
  assert.ok(messages.includes("processes[0].lifecycle.quietCommand must be a string"), JSON.stringify(messages))
197
199
 
@@ -450,14 +450,14 @@ test("activateStrict runs the configured activation command once per call and re
450
450
  const pid = managed.pid
451
451
 
452
452
  assert.ok(pid)
453
- managed.lifecycle = {activateCommand: "jobs activate", drainTimeoutMs: 0}
453
+ managed.lifecycle = {activateCommand: "jobs activate", activateTimeoutMs: 60000, drainTimeoutMs: 0}
454
454
  managed.runHook = async (command, timeoutMs, label, hookPid) => {
455
455
  commands.push({command, label, pid: hookPid, timeoutMs})
456
456
  return undefined
457
457
  }
458
458
 
459
459
  await managed.activateStrict()
460
- assert.deepEqual(commands, [{command: "jobs activate", label: "activate command", pid, timeoutMs: 30000}])
460
+ assert.deepEqual(commands, [{command: "jobs activate", label: "activate command", pid, timeoutMs: 60000}])
461
461
 
462
462
  managed.runHook = async () => new Error("activation rejected")
463
463
  await assert.rejects(() => managed.activateStrict(), /activation rejected/)
@@ -696,7 +696,11 @@ test("candidate activation failure reports restoration failure and exact recover
696
696
  assert.match(String(restorationEvent?.data.error), /incumbent restoration rejected/)
697
697
  assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2"])
698
698
  await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
699
+ const failedCandidate = daemon.releases.get("v2")
699
700
 
701
+ assert.ok(failedCandidate)
702
+ await failedCandidate.stop()
703
+ assert.equal(failedCandidate.state, "stopped")
700
704
  incumbentCoordinator.reactivateStrict = reactivate
701
705
  const recovery = await sendControlCommand({
702
706
  command: {