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 +3 -1
- package/docs/config.md +4 -3
- package/package.json +1 -1
- package/src/config.js +12 -4
- package/src/daemon.js +1 -1
- package/src/managed-process.js +9 -4
- package/test/config-validation.test.js +5 -3
- package/test/managed-process.test.js +2 -2
- package/test/rollbridge.test.js +4 -0
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.
|
|
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
|
|
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
|
|
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
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 = {
|
|
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) {
|
package/src/managed-process.js
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
|
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" ?
|
|
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:
|
|
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/)
|
package/test/rollbridge.test.js
CHANGED
|
@@ -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: {
|