rollbridge 0.1.8 → 0.1.10
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/docs/config.md +35 -7
- package/docs/velocious.md +35 -31
- package/docs/workers.md +7 -5
- package/package.json +1 -1
- package/src/config.js +46 -3
- package/src/daemon.js +1 -1
- package/src/managed-process.js +19 -5
- package/src/release-group.js +9 -4
- package/test/config-validation.test.js +75 -0
- package/test/fixtures/dependent-app.js +33 -0
- package/test/managed-process.test.js +33 -0
- package/test/rollbridge.test.js +79 -4
package/docs/config.md
CHANGED
|
@@ -127,6 +127,7 @@ legacyTakeover: {
|
|
|
127
127
|
| --- | --- | --- | --- |
|
|
128
128
|
| `id` | string | **required** | Unique identifier. Appears in `status`, logs, and `ROLLBRIDGE_*` env vars. |
|
|
129
129
|
| `policy` | `"proxied"` \| `"companion"` \| `"singleton"` \| `"service"` | `"companion"` | Lifecycle policy (see [README → Process Policies](../README.md#process-policies)). Exactly one process must be `proxied`. |
|
|
130
|
+
| `deployStrategy` | `"persistent"` \| `"handoff"` | `"persistent"` | Service deploy behavior. Only valid on `policy: "service"`; see below. |
|
|
130
131
|
| `command` | string | **required** | Shell command to run (templated). |
|
|
131
132
|
| `cwd` | string | the release path | Working directory (templated). |
|
|
132
133
|
| `env` | object of string → string | `{}` | Extra environment variables (values templated). Merged over the injected `ROLLBRIDGE_*` vars. |
|
|
@@ -135,7 +136,7 @@ legacyTakeover: {
|
|
|
135
136
|
| `stopSignal` | signal name (e.g. `"SIGTERM"`, `"SIGINT"`, `"SIGQUIT"`) | `"SIGTERM"` | Signal sent to gracefully stop the process; after `gracefulStopMs` it is `SIGKILL`ed. Use a worker's quit signal so it finishes in-flight work before exiting. |
|
|
136
137
|
| `nonBlockingDrain` | boolean | `false` | When a release is retired, drain this process **immediately** (in parallel with the proxied connection drain) instead of after it. Companion processes only — typically background workers (see below). |
|
|
137
138
|
| `lifecycle` | object | no hooks | Command hooks run when gracefully stopping the process (see below). |
|
|
138
|
-
| `gracefulStopMs` | number | `proxy.forceStopTimeoutMs` | Graceful-stop window: time between `stopSignal`/`stopCommand` and `SIGKILL` for this process. |
|
|
139
|
+
| `gracefulStopMs` | number or `"indefinite"` | `proxy.forceStopTimeoutMs` | Graceful-stop window: time between `stopSignal`/`stopCommand` and `SIGKILL` for this process. Use `"indefinite"` to wait for process exit without sending `SIGKILL`. |
|
|
139
140
|
| `restartDelayMs` | number | `1000` | Base delay before restarting this process after a crash (the backoff base; see `restart`). |
|
|
140
141
|
| `restart` | object | unlimited restarts, constant delay | Automatic-restart policy: cap, rolling window, and backoff (see below). |
|
|
141
142
|
| `memory` | object | unset (no monitoring) | Memory supervision: restart the process when its RSS exceeds a limit (see below). |
|
|
@@ -161,6 +162,32 @@ restart every replica, or `worker#0` for one). Replicas get `replicaIndex`/
|
|
|
161
162
|
environment, so each instance can pick a distinct shard, queue, or lock. A single
|
|
162
163
|
process (`replicas: 1`) keeps its plain id and is replica `0` of `1`.
|
|
163
164
|
|
|
165
|
+
### `processes[].deployStrategy`
|
|
166
|
+
|
|
167
|
+
`deployStrategy` controls how `policy: "service"` processes behave across deploys:
|
|
168
|
+
|
|
169
|
+
| Value | Behavior |
|
|
170
|
+
| --- | --- |
|
|
171
|
+
| `"persistent"` | The default. Rollbridge runs one daemon-wide service instance on a stable port. Deploys update the stored template for future restarts but do not replace a healthy service. |
|
|
172
|
+
| `"handoff"` | Rollbridge starts a new release-scoped service instance before the new release's companions and proxied process start. The old release keeps its old service port while it drains, then that old service stops with the old release. |
|
|
173
|
+
|
|
174
|
+
Use `"handoff"` when release-scoped processes must talk to a same-release service
|
|
175
|
+
while old and new releases overlap. A handoff service must define a **multi-port
|
|
176
|
+
range** so old and new instances can run at the same time:
|
|
177
|
+
|
|
178
|
+
```js
|
|
179
|
+
{
|
|
180
|
+
id: "background-jobs-main",
|
|
181
|
+
policy: "service",
|
|
182
|
+
deployStrategy: "handoff",
|
|
183
|
+
command: "npx velocious background-jobs-main",
|
|
184
|
+
port: {from: 7331, to: 7399}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Reference it from same-release processes with `{{ports.background-jobs-main}}`.
|
|
189
|
+
During a deploy, old workers keep the old port and new workers get the new port.
|
|
190
|
+
|
|
164
191
|
### `processes[].lifecycle`
|
|
165
192
|
|
|
166
193
|
Command hooks run when Rollbridge **gracefully stops** the process — during a
|
|
@@ -181,12 +208,13 @@ ignored. Use one or the other.
|
|
|
181
208
|
|
|
182
209
|
The full stop sequence is: run `quietCommand` → drain (`drainCommand`, or wait
|
|
183
210
|
`drainTimeoutMs` for the process to exit) → if still running, run `stopCommand`
|
|
184
|
-
or send `stopSignal` → `SIGKILL` after `gracefulStopMs
|
|
185
|
-
|
|
186
|
-
process
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
211
|
+
or send `stopSignal` → `SIGKILL` after `gracefulStopMs` unless
|
|
212
|
+
`gracefulStopMs: "indefinite"` is set. Each hook command is run through a shell
|
|
213
|
+
with the process's environment plus `ROLLBRIDGE_PID` (the process-group leader's
|
|
214
|
+
pid, so a hook can `kill -TSTP -$ROLLBRIDGE_PID`). Every hook is **bounded by a
|
|
215
|
+
timeout** (its drain timeout, `gracefulStopMs`, or 30 seconds when the graceful
|
|
216
|
+
stop window is indefinite) and its failure is non-fatal — the sequence proceeds
|
|
217
|
+
to the stop signal/command so a slow or broken hook can't wedge a stop.
|
|
190
218
|
|
|
191
219
|
```js
|
|
192
220
|
{id: "worker", policy: "companion", command: "…", lifecycle: {quietCommand: "kill -TSTP -$ROLLBRIDGE_PID", drainTimeoutMs: 60000}}
|
package/docs/velocious.md
CHANGED
|
@@ -14,7 +14,7 @@ A production version of this config lives at
|
|
|
14
14
|
| Velocious process | Policy | Why |
|
|
15
15
|
| --- | --- | --- |
|
|
16
16
|
| `beacon` | `service` | A shared broker the other processes connect to. It should survive deploys and keep a **stable port**, so workers and the web process always reach the same Beacon. |
|
|
17
|
-
| `background-jobs-main` | `service`
|
|
17
|
+
| `background-jobs-main` | `service` with `deployStrategy: "handoff"` | The job coordinator. Run it as a handoff service so each release's workers and web process use a same-release coordinator while old releases drain (see [Choosing the jobs-main policy](#choosing-the-jobs-main-policy)). |
|
|
18
18
|
| `background-jobs-worker` | `companion` | Release-scoped: one set of workers per active release, started before the web process and running that release's code. |
|
|
19
19
|
| `web` | `proxied` | Receives external HTTP/WebSocket traffic, is health-checked before traffic switches, and is drained on the next deploy. Exactly one process is `proxied`. |
|
|
20
20
|
|
|
@@ -49,10 +49,11 @@ export default {
|
|
|
49
49
|
port: 7330
|
|
50
50
|
},
|
|
51
51
|
|
|
52
|
-
// Job coordinator —
|
|
52
|
+
// Job coordinator — one release-scoped service instance per deploy.
|
|
53
53
|
{
|
|
54
54
|
id: "background-jobs-main",
|
|
55
55
|
policy: "service",
|
|
56
|
+
deployStrategy: "handoff",
|
|
56
57
|
cwd: "{{releasePath}}/backend",
|
|
57
58
|
env: {
|
|
58
59
|
NODE_ENV: "production",
|
|
@@ -60,7 +61,7 @@ export default {
|
|
|
60
61
|
VELOCIOUS_BACKGROUND_JOBS_PORT: "{{port}}"
|
|
61
62
|
},
|
|
62
63
|
command: "wait-for-it 127.0.0.1:{{ports.beacon}} --strict -- npx velocious background-jobs-main",
|
|
63
|
-
port: 7331
|
|
64
|
+
port: {from: 7331, to: 7399}
|
|
64
65
|
},
|
|
65
66
|
|
|
66
67
|
// Workers — one set per release; raise gracefulStopMs to let in-flight
|
|
@@ -75,7 +76,7 @@ export default {
|
|
|
75
76
|
VELOCIOUS_BACKGROUND_JOBS_PORT: "{{ports.background-jobs-main}}"
|
|
76
77
|
},
|
|
77
78
|
command: "wait-for-it 127.0.0.1:{{ports.beacon}} --strict -- wait-for-it 127.0.0.1:{{ports.background-jobs-main}} --strict -- npx velocious background-jobs-worker",
|
|
78
|
-
gracefulStopMs:
|
|
79
|
+
gracefulStopMs: "indefinite"
|
|
79
80
|
},
|
|
80
81
|
|
|
81
82
|
// Web/API — the one proxied process.
|
|
@@ -98,11 +99,13 @@ export default {
|
|
|
98
99
|
|
|
99
100
|
## Wiring processes together
|
|
100
101
|
|
|
101
|
-
Beacon
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
Beacon gets a **fixed** port (`7330`) because it is a persistent `service` — a
|
|
103
|
+
stable port lets every release's processes find the shared broker.
|
|
104
|
+
`background-jobs-main` gets a **range** (`{from: 7331, to: 7399}`) because it is a
|
|
105
|
+
handoff service: Rollbridge allocates a new port per release so old workers keep
|
|
106
|
+
talking to the old coordinator while new workers and web use the new one. The
|
|
107
|
+
proxied `web` process also gets a **range** (`{from: 14500, to: 14599}`) so old
|
|
108
|
+
and new web releases can run side by side during the drain.
|
|
106
109
|
|
|
107
110
|
Cross-reference ports with `{{ports.<id>}}` and pass them to Velocious through
|
|
108
111
|
`env`. Rollbridge also injects `ROLLBRIDGE_<ID>_PORT` for every process (e.g.
|
|
@@ -115,8 +118,8 @@ environment instead of templating if you prefer — see
|
|
|
115
118
|
Only the `proxied` process is health-checked, so dependent processes must wait
|
|
116
119
|
for their dependencies themselves. Two mechanisms combine:
|
|
117
120
|
|
|
118
|
-
1. **Policy ordering.** On each deploy Rollbridge starts `service`s
|
|
119
|
-
the release's `companion`s, then the `proxied` process (see
|
|
121
|
+
1. **Policy ordering.** On each deploy Rollbridge starts handoff `service`s
|
|
122
|
+
first, then the release's `companion`s, then the `proxied` process (see
|
|
120
123
|
[README → Deploy ordering](../README.md#deploy-ordering)).
|
|
121
124
|
2. **Readiness gating.** `wait-for-it 127.0.0.1:{{ports.beacon}} --strict -- …`
|
|
122
125
|
blocks the command until Beacon's port accepts connections, so
|
|
@@ -159,8 +162,9 @@ The worker is a `companion`, so each release runs its own workers:
|
|
|
159
162
|
is drained and retired — the worker's `stopSignal`, then `SIGKILL` after
|
|
160
163
|
`gracefulStopMs`.
|
|
161
164
|
- Set `stopSignal` to the signal your worker drains on and `gracefulStopMs` to at
|
|
162
|
-
least your longest in-flight job
|
|
163
|
-
|
|
165
|
+
least your longest in-flight job. Use `gracefulStopMs: "indefinite"` when the
|
|
166
|
+
worker can safely drain until it exits on its own. Set `replicas` to run a pool
|
|
167
|
+
of workers.
|
|
164
168
|
|
|
165
169
|
See [`docs/workers.md`](workers.md) for the full safe background-job deployment
|
|
166
170
|
pattern (companion + `replicas` + `stopSignal`/`lifecycle` hooks +
|
|
@@ -185,7 +189,7 @@ jobs across a deploy:
|
|
|
185
189
|
},
|
|
186
190
|
command: "wait-for-it 127.0.0.1:{{ports.beacon}} --strict -- wait-for-it 127.0.0.1:{{ports.background-jobs-main}} --strict -- npx velocious background-jobs-worker",
|
|
187
191
|
replicas: 4,
|
|
188
|
-
gracefulStopMs:
|
|
192
|
+
gracefulStopMs: "indefinite"
|
|
189
193
|
}
|
|
190
194
|
```
|
|
191
195
|
|
|
@@ -193,9 +197,8 @@ jobs across a deploy:
|
|
|
193
197
|
each with `ROLLBRIDGE_REPLICA_INDEX`/`ROLLBRIDGE_REPLICA_COUNT` if you shard work.
|
|
194
198
|
- On deploy the new release's workers start before traffic switches; the old
|
|
195
199
|
release's workers receive `SIGTERM` (the default `stopSignal`) when the old
|
|
196
|
-
release is retired, then
|
|
197
|
-
|
|
198
|
-
shared queue, so keep job code backwards-compatible and jobs idempotent.
|
|
200
|
+
release is retired, then wait to exit. With `gracefulStopMs: "indefinite"`,
|
|
201
|
+
Rollbridge does not send a `SIGKILL` fallback.
|
|
199
202
|
|
|
200
203
|
If your worker quiesces on a command or a non-default signal, add a `lifecycle`
|
|
201
204
|
block — Rollbridge runs `quietCommand`, drains for up to `drainTimeoutMs`, then
|
|
@@ -208,27 +211,28 @@ lifecycle: {quietCommand: "kill -TSTP -$ROLLBRIDGE_PID", drainTimeoutMs: 60000}
|
|
|
208
211
|
|
|
209
212
|
### Choosing the jobs-main policy
|
|
210
213
|
|
|
211
|
-
`background-jobs-main`
|
|
212
|
-
it is either a `service` or a `singleton` — never a `companion`:
|
|
214
|
+
`background-jobs-main` coordinates workers, so choose its lifecycle deliberately:
|
|
213
215
|
|
|
214
|
-
- **`service
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
216
|
+
- **`service` with `deployStrategy: "handoff"`** — starts one coordinator per
|
|
217
|
+
release on a port from a range. New workers and web get the new release's port;
|
|
218
|
+
old workers keep the old release's port while they drain. This is the safest
|
|
219
|
+
default when the coordinator should run the same code version as its workers.
|
|
220
|
+
- **`service` with the default `deployStrategy: "persistent"`** — keeps one
|
|
221
|
+
daemon-wide coordinator on a stable port. Workers from every release talk to the
|
|
222
|
+
same coordinator, but it keeps running the release it was started from and only
|
|
223
|
+
adopts the latest template if it restarts later.
|
|
224
|
+
- **`singleton`** — stops the old instance and then starts the new one on each
|
|
225
|
+
deploy, so it always runs the latest release's code and two copies never
|
|
226
|
+
overlap. The trade-off: a brief coordination gap while it restarts.
|
|
223
227
|
|
|
224
228
|
Beacon is a broker rather than code that changes per release, so `service` is
|
|
225
229
|
almost always right for it.
|
|
226
230
|
|
|
227
231
|
## Verifying
|
|
228
232
|
|
|
229
|
-
After a deploy, `rollbridge status` should show `beacon`
|
|
230
|
-
`background-jobs-main` as
|
|
231
|
-
|
|
233
|
+
After a deploy, `rollbridge status` should show `beacon` as a long-lived service
|
|
234
|
+
with an unchanged port, `background-jobs-main` as the active release's handoff
|
|
235
|
+
service, one `background-jobs-worker` for the active release, and the `web`
|
|
232
236
|
process `proxied` with its connection counts. Use
|
|
233
237
|
[`rollbridge logs --process <id>`](cli.md) to read recent output from any
|
|
234
238
|
process, and [`docs/troubleshooting.md`](troubleshooting.md) for health-check,
|
package/docs/workers.md
CHANGED
|
@@ -47,7 +47,8 @@ the worker's chance to finish its current job and exit cleanly.
|
|
|
47
47
|
finish the current job and exit on `SIGTERM` (the default); some use `SIGINT`
|
|
48
48
|
or `SIGQUIT`. Use the one your worker treats as "drain and exit".
|
|
49
49
|
- Set `gracefulStopMs` to at least your longest job's duration, so a job in
|
|
50
|
-
progress is not cut off by the `SIGKILL` fallback.
|
|
50
|
+
progress is not cut off by the `SIGKILL` fallback. Use `"indefinite"` only for
|
|
51
|
+
workers that are safe to leave draining until they exit on their own.
|
|
51
52
|
|
|
52
53
|
```js
|
|
53
54
|
{
|
|
@@ -56,7 +57,7 @@ the worker's chance to finish its current job and exit cleanly.
|
|
|
56
57
|
command: "npx velocious background-jobs-worker",
|
|
57
58
|
replicas: 4,
|
|
58
59
|
stopSignal: "SIGTERM",
|
|
59
|
-
gracefulStopMs:
|
|
60
|
+
gracefulStopMs: "indefinite"
|
|
60
61
|
}
|
|
61
62
|
```
|
|
62
63
|
|
|
@@ -73,8 +74,9 @@ the worker's chance to finish its current job and exit cleanly.
|
|
|
73
74
|
|
|
74
75
|
Because old workers are retired on the release's **connection** drain (not on
|
|
75
76
|
their own job queue draining), a job still running when the release is retired
|
|
76
|
-
gets only the `gracefulStopMs` window to finish
|
|
77
|
-
safe to retry** so a job interrupted
|
|
77
|
+
gets only the `gracefulStopMs` window to finish, unless `gracefulStopMs` is
|
|
78
|
+
`"indefinite"`. Keep jobs **idempotent and safe to retry** so a job interrupted
|
|
79
|
+
at a finite `SIGKILL` fallback can run again.
|
|
78
80
|
|
|
79
81
|
## Command-based lifecycle hooks
|
|
80
82
|
|
|
@@ -107,7 +109,7 @@ in parallel with the connection drain. The new release's workers handle new work
|
|
|
107
109
|
while the old workers finish their in-flight jobs:
|
|
108
110
|
|
|
109
111
|
```js
|
|
110
|
-
{id: "worker", policy: "companion", command: "…", nonBlockingDrain: true, gracefulStopMs:
|
|
112
|
+
{id: "worker", policy: "companion", command: "…", nonBlockingDrain: true, gracefulStopMs: "indefinite"}
|
|
111
113
|
```
|
|
112
114
|
|
|
113
115
|
See [`docs/config.md`](config.md) for `stopSignal`, `replicas`, and
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -13,7 +13,9 @@ import {pathToFileURL} from "node:url"
|
|
|
13
13
|
* @typedef {{backoffFactor: number, maxDelayMs: number, maxRestarts: number | undefined, windowMs: number}} RestartConfig
|
|
14
14
|
* @typedef {{checkIntervalMs: number, limitBytes: number, warnBytes: number}} MemoryConfig
|
|
15
15
|
* @typedef {{drainCommand?: string, drainTimeoutMs: number, quietCommand?: string, stopCommand?: string}} LifecycleConfig
|
|
16
|
-
* @typedef {
|
|
16
|
+
* @typedef {number | "indefinite"} StopTimeoutMs
|
|
17
|
+
* @typedef {"persistent" | "handoff"} ServiceDeployStrategy
|
|
18
|
+
* @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
|
|
17
19
|
* @typedef {{group?: number | string, mode?: number, owner?: number | string, path: string}} ControlConfig
|
|
18
20
|
* @typedef {{drainTimeoutMs: number, forceStopTimeoutMs: number, healthPath: string, healthTimeoutMs: number, host: string, port: number, upstreamHost: string}} ProxyConfig
|
|
19
21
|
* @typedef {{includes: string[], name: string}} LegacyTakeoverProcessConfig
|
|
@@ -185,7 +187,7 @@ function normalizeProcess(value, index, proxy, issues) {
|
|
|
185
187
|
if (!isPlainObject(value)) {
|
|
186
188
|
issues.push({fix: `Define processes[${index}] as a mapping with id, policy, and command.`, message: `processes[${index}] must be an object`})
|
|
187
189
|
|
|
188
|
-
return {command: "", cwd: undefined, env: {}, gracefulStopMs: proxy.forceStopTimeoutMs, health: undefined, id: "", lifecycle: {drainTimeoutMs: 0}, memory: undefined, nonBlockingDrain: false, outputLines: 50, policy: "companion", port: undefined, replicas: 1, restart: defaultRestartConfig(), restartDelayMs: 1000, stopSignal: "SIGTERM"}
|
|
190
|
+
return {command: "", cwd: undefined, deployStrategy: "persistent", env: {}, gracefulStopMs: proxy.forceStopTimeoutMs, health: undefined, id: "", lifecycle: {drainTimeoutMs: 0}, memory: undefined, nonBlockingDrain: false, outputLines: 50, policy: "companion", port: undefined, replicas: 1, restart: defaultRestartConfig(), restartDelayMs: 1000, stopSignal: "SIGTERM"}
|
|
189
191
|
}
|
|
190
192
|
|
|
191
193
|
const source = value
|
|
@@ -193,8 +195,9 @@ function normalizeProcess(value, index, proxy, issues) {
|
|
|
193
195
|
return {
|
|
194
196
|
command: normalizeString(source.command, `processes[${index}].command`, issues),
|
|
195
197
|
cwd: source.cwd === undefined ? undefined : normalizeString(source.cwd, `processes[${index}].cwd`, issues),
|
|
198
|
+
deployStrategy: normalizeDeployStrategy(source.deployStrategy, `processes[${index}].deployStrategy`, issues),
|
|
196
199
|
env: normalizeEnv(source.env, `processes[${index}].env`, issues),
|
|
197
|
-
gracefulStopMs:
|
|
200
|
+
gracefulStopMs: normalizeStopTimeout(source.gracefulStopMs, `processes[${index}].gracefulStopMs`, proxy.forceStopTimeoutMs, issues),
|
|
198
201
|
health: normalizeHealth(source.health, `processes[${index}].health`, proxy, issues),
|
|
199
202
|
id: normalizeString(source.id, `processes[${index}].id`, issues),
|
|
200
203
|
lifecycle: normalizeLifecycle(source.lifecycle, `processes[${index}].lifecycle`, issues),
|
|
@@ -457,6 +460,36 @@ function normalizeStopSignal(value, key, issues) {
|
|
|
457
460
|
return "SIGTERM"
|
|
458
461
|
}
|
|
459
462
|
|
|
463
|
+
/**
|
|
464
|
+
* @param {JsonValue} value - Raw graceful stop timeout.
|
|
465
|
+
* @param {string} key - Config key.
|
|
466
|
+
* @param {number} fallback - Default timeout in milliseconds.
|
|
467
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
468
|
+
* @returns {StopTimeoutMs} Normalized stop timeout.
|
|
469
|
+
*/
|
|
470
|
+
function normalizeStopTimeout(value, key, fallback, issues) {
|
|
471
|
+
if (value === "indefinite") return "indefinite"
|
|
472
|
+
|
|
473
|
+
const timeoutMs = normalizeNumber(value, key, issues, {default: fallback})
|
|
474
|
+
|
|
475
|
+
return nonNegativeOrDefault(timeoutMs, key, issues, fallback, false)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* @param {JsonValue} value - Raw service deploy strategy.
|
|
480
|
+
* @param {string} key - Config key.
|
|
481
|
+
* @param {ConfigIssue[]} issues - Issue collector.
|
|
482
|
+
* @returns {ServiceDeployStrategy} Normalized service deploy strategy.
|
|
483
|
+
*/
|
|
484
|
+
function normalizeDeployStrategy(value, key, issues) {
|
|
485
|
+
if (value === undefined || value === null) return "persistent"
|
|
486
|
+
if (value === "persistent" || value === "handoff") return value
|
|
487
|
+
|
|
488
|
+
issues.push({fix: `Set ${key} to "persistent" or "handoff".`, message: `${key} must be one of: persistent, handoff`})
|
|
489
|
+
|
|
490
|
+
return "persistent"
|
|
491
|
+
}
|
|
492
|
+
|
|
460
493
|
/**
|
|
461
494
|
* @param {JsonValue} value - Raw output retention value.
|
|
462
495
|
* @param {string} key - Config key.
|
|
@@ -615,6 +648,16 @@ function validateProcessSet(processes, issues) {
|
|
|
615
648
|
issues.push({fix: `Set nonBlockingDrain only on a companion process; "${processConfig.id}" is ${processConfig.policy}.`, message: `Process "${processConfig.id}" can only set nonBlockingDrain on a companion process`})
|
|
616
649
|
}
|
|
617
650
|
|
|
651
|
+
if (processConfig.deployStrategy === "handoff") {
|
|
652
|
+
if (processConfig.policy !== "service") {
|
|
653
|
+
issues.push({fix: `Set deployStrategy: "handoff" only on service processes; "${processConfig.id}" is ${processConfig.policy}.`, message: `Process "${processConfig.id}" can only set deployStrategy: "handoff" on a service process`})
|
|
654
|
+
} else if (!processConfig.port) {
|
|
655
|
+
issues.push({fix: `Add a port range to hand off service "${processConfig.id}" so old and new instances can overlap.`, message: `Handoff service "${processConfig.id}" must define a port range`})
|
|
656
|
+
} else if (processConfig.port.from === processConfig.port.to) {
|
|
657
|
+
issues.push({fix: `Set service "${processConfig.id}" to a multi-port range, e.g. port: {from: ${processConfig.port.from}, to: ${processConfig.port.from + 99}}.`, message: `Handoff service "${processConfig.id}" must use a multi-port range`})
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
618
661
|
if (processConfig.lifecycle.stopCommand && processConfig.stopSignal !== "SIGTERM") {
|
|
619
662
|
issues.push({fix: `Drop the custom stopSignal "${processConfig.stopSignal}" or lifecycle.stopCommand from "${processConfig.id}": with a stopCommand set, Rollbridge runs it to stop the process instead of sending stopSignal, so the signal is never used.`, message: `Process "${processConfig.id}" sets both lifecycle.stopCommand and a custom stopSignal, but stopCommand replaces stopSignal`})
|
|
620
663
|
}
|
package/src/daemon.js
CHANGED
|
@@ -419,7 +419,7 @@ export default class RollbridgeDaemon {
|
|
|
419
419
|
await release.allocatePorts()
|
|
420
420
|
|
|
421
421
|
for (const processConfig of this.config.processes) {
|
|
422
|
-
if (processConfig.policy !== "service") continue
|
|
422
|
+
if (processConfig.policy !== "service" || processConfig.deployStrategy === "handoff") continue
|
|
423
423
|
if (this.services.has(processConfig.id)) continue
|
|
424
424
|
|
|
425
425
|
const service = release.buildProcess(processConfig, {shouldRestart: () => !this.stopping})
|
package/src/managed-process.js
CHANGED
|
@@ -10,7 +10,8 @@ import {processGroupMembers} from "./process-memory.js"
|
|
|
10
10
|
* @typedef {"deploy" | "crash" | "manual" | "memory"} ManagedProcessStartReason
|
|
11
11
|
* @typedef {import("node:child_process").ChildProcess["signalCode"]} ProcessExitSignal
|
|
12
12
|
* @typedef {{at: string, line: string, stream: "stdout" | "stderr"}} ManagedProcessLog
|
|
13
|
-
* @typedef {
|
|
13
|
+
* @typedef {import("./config.js").StopTimeoutMs} StopTimeoutMs
|
|
14
|
+
* @typedef {{command: string, cwd: string | undefined, env: Record<string, string | undefined>, lifecycle: import("./config.js").LifecycleConfig, logger: (message: string, data?: Record<string, import("./json.js").JsonValue>) => void, memory: import("./config.js").MemoryConfig | undefined, outputLines: number, restart: import("./config.js").RestartConfig, restartDelayMs: number, shouldRestart: () => boolean, stopSignal: string, stopTimeoutMs: StopTimeoutMs}} ManagedProcessDefinition
|
|
14
15
|
* @typedef {{children: import("./process-memory.js").ProcessGroupMember[], command: string, cwd: string | undefined, exitCode: number | null | undefined, exitSignal: ProcessExitSignal | undefined, id: string, lastMemoryRestartAt: string | undefined, lastStartReason: ManagedProcessStartReason | undefined, logs: ManagedProcessLog[], memoryRestarts: number, pid: number | undefined, restarts: number, rssBytes: number | undefined, startedAt: string | undefined, state: ManagedProcessState, uptimeMs: number | undefined}} ManagedProcessStatus
|
|
15
16
|
*/
|
|
16
17
|
|
|
@@ -29,7 +30,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
29
30
|
* @param {number} args.restartDelayMs - Restart delay.
|
|
30
31
|
* @param {() => boolean} args.shouldRestart - Restart policy callback.
|
|
31
32
|
* @param {string} [args.stopSignal] - Signal sent to gracefully stop the process (default "SIGTERM").
|
|
32
|
-
* @param {
|
|
33
|
+
* @param {StopTimeoutMs} args.stopTimeoutMs - Stop timeout.
|
|
33
34
|
*/
|
|
34
35
|
constructor({command, cwd, env, id, lifecycle = {drainTimeoutMs: 0}, logger, memory, outputLines, restart = {backoffFactor: 1, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0}, restartDelayMs, shouldRestart, stopSignal = "SIGTERM", stopTimeoutMs}) {
|
|
35
36
|
super()
|
|
@@ -356,8 +357,10 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
356
357
|
|
|
357
358
|
const {drainCommand, drainTimeoutMs, quietCommand, stopCommand} = this.lifecycle
|
|
358
359
|
|
|
360
|
+
const hookTimeoutMs = this.hookTimeoutMs()
|
|
361
|
+
|
|
359
362
|
// 1. Quiesce: tell the process to stop accepting new work.
|
|
360
|
-
if (quietCommand) await this.runHook(quietCommand,
|
|
363
|
+
if (quietCommand) await this.runHook(quietCommand, hookTimeoutMs, "quiet command")
|
|
361
364
|
|
|
362
365
|
// 2. Drain: let in-flight work finish, bounded by drainTimeoutMs (0 skips the step). A
|
|
363
366
|
// drainCommand blocks until drained; otherwise wait for the process to exit on its own.
|
|
@@ -368,7 +371,7 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
368
371
|
|
|
369
372
|
// 3. Stop whatever is still running, then SIGKILL if it outlasts the graceful window.
|
|
370
373
|
if (this.child) {
|
|
371
|
-
if (stopCommand) await this.runHook(stopCommand,
|
|
374
|
+
if (stopCommand) await this.runHook(stopCommand, hookTimeoutMs, "stop command")
|
|
372
375
|
else this.killProcessGroup(this.stopSignal)
|
|
373
376
|
|
|
374
377
|
const timeoutMs = options.timeoutMs ?? this.stopTimeoutMs
|
|
@@ -383,6 +386,13 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
383
386
|
this.state = "stopped"
|
|
384
387
|
}
|
|
385
388
|
|
|
389
|
+
/** @returns {number} Timeout used for lifecycle hooks. */
|
|
390
|
+
hookTimeoutMs() {
|
|
391
|
+
if (this.stopTimeoutMs === "indefinite") return 30000
|
|
392
|
+
|
|
393
|
+
return this.stopTimeoutMs
|
|
394
|
+
}
|
|
395
|
+
|
|
386
396
|
/**
|
|
387
397
|
* Runs a lifecycle hook command, bounded by a timeout so a hung hook can never block stop().
|
|
388
398
|
* Failures are logged and swallowed — the graceful-stop sequence proceeds (and SIGKILL is the
|
|
@@ -465,11 +475,15 @@ export default class ManagedProcess extends EventEmitter {
|
|
|
465
475
|
}
|
|
466
476
|
|
|
467
477
|
/**
|
|
468
|
-
* @param {
|
|
478
|
+
* @param {StopTimeoutMs} timeoutMs - Timeout.
|
|
469
479
|
* @returns {Promise<boolean>} True when the process exited before timeout.
|
|
470
480
|
*/
|
|
471
481
|
async waitForExit(timeoutMs) {
|
|
472
482
|
if (!this.exitPromise) return true
|
|
483
|
+
if (timeoutMs === "indefinite") {
|
|
484
|
+
await this.exitPromise
|
|
485
|
+
return true
|
|
486
|
+
}
|
|
473
487
|
|
|
474
488
|
let timer = /** @type {ReturnType<typeof setTimeout> | undefined} */ (undefined)
|
|
475
489
|
const timeoutPromise = new Promise((resolve) => {
|
package/src/release-group.js
CHANGED
|
@@ -53,6 +53,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
53
53
|
this.connectionCount = 0
|
|
54
54
|
this.connections = /** @type {ReleaseConnections} */ ({http: 0, websocket: 0})
|
|
55
55
|
this.processes = /** @type {Map<string, ManagedProcess>} */ (new Map())
|
|
56
|
+
this.handoffServiceIds = /** @type {Set<string>} */ (new Set())
|
|
56
57
|
this.nonBlockingDrainIds = /** @type {Set<string>} */ (new Set())
|
|
57
58
|
this.ports = /** @type {Record<string, number>} */ ({})
|
|
58
59
|
this.servicePorts = servicePorts
|
|
@@ -75,6 +76,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
75
76
|
const processInstance = this.buildProcess(processConfig, {count: processConfig.replicas, index, instanceId})
|
|
76
77
|
|
|
77
78
|
this.processes.set(instanceId, processInstance)
|
|
79
|
+
if (processConfig.policy === "service" && processConfig.deployStrategy === "handoff") this.handoffServiceIds.add(instanceId)
|
|
78
80
|
if (processConfig.nonBlockingDrain) this.nonBlockingDrainIds.add(instanceId)
|
|
79
81
|
await processInstance.start("deploy")
|
|
80
82
|
}
|
|
@@ -154,11 +156,12 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
154
156
|
* @returns {import("./config.js").ProcessConfig[]} Ordered process configs.
|
|
155
157
|
*/
|
|
156
158
|
releaseProcessStartOrder() {
|
|
157
|
-
const releaseProcesses = this.config.processes.filter((processConfig) =>
|
|
159
|
+
const releaseProcesses = this.config.processes.filter((processConfig) => processConfig.policy !== "singleton" && (processConfig.policy !== "service" || processConfig.deployStrategy === "handoff"))
|
|
160
|
+
const serviceProcesses = releaseProcesses.filter((processConfig) => processConfig.policy === "service")
|
|
158
161
|
const companionProcesses = releaseProcesses.filter((processConfig) => processConfig.policy === "companion")
|
|
159
162
|
const proxiedProcesses = releaseProcesses.filter((processConfig) => processConfig.policy === "proxied")
|
|
160
163
|
|
|
161
|
-
return [...companionProcesses, ...proxiedProcesses]
|
|
164
|
+
return [...serviceProcesses, ...companionProcesses, ...proxiedProcesses]
|
|
162
165
|
}
|
|
163
166
|
|
|
164
167
|
/** @returns {void} Marks this release active. */
|
|
@@ -175,7 +178,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
175
178
|
|
|
176
179
|
for (const processConfig of this.config.processes) {
|
|
177
180
|
if (!processConfig.port) continue
|
|
178
|
-
if (processConfig.policy === "service" && this.servicePorts[processConfig.id] !== undefined) {
|
|
181
|
+
if (processConfig.policy === "service" && processConfig.deployStrategy !== "handoff" && this.servicePorts[processConfig.id] !== undefined) {
|
|
179
182
|
this.ports[processConfig.id] = this.servicePorts[processConfig.id]
|
|
180
183
|
usedPorts.add(this.servicePorts[processConfig.id])
|
|
181
184
|
continue
|
|
@@ -331,7 +334,8 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
331
334
|
// connection drain, not held until after it. The rest stop once connections have closed.
|
|
332
335
|
const entries = [...this.processes.entries()]
|
|
333
336
|
const nonBlockingStops = entries.filter(([id]) => this.nonBlockingDrainIds.has(id)).map(([, processInstance]) => processInstance.stop())
|
|
334
|
-
const
|
|
337
|
+
const handoffServices = entries.filter(([id]) => this.handoffServiceIds.has(id)).map(([, processInstance]) => processInstance)
|
|
338
|
+
const connectionDependent = entries.filter(([id]) => !this.nonBlockingDrainIds.has(id) && !this.handoffServiceIds.has(id)).map(([, processInstance]) => processInstance)
|
|
335
339
|
|
|
336
340
|
if (this.connectionCount > 0) {
|
|
337
341
|
await new Promise((resolve) => {
|
|
@@ -345,6 +349,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
345
349
|
|
|
346
350
|
await Promise.allSettled(connectionDependent.map((processInstance) => processInstance.stop()))
|
|
347
351
|
await Promise.allSettled(nonBlockingStops)
|
|
352
|
+
await Promise.allSettled(handoffServices.map((processInstance) => processInstance.stop()))
|
|
348
353
|
this.state = "stopped"
|
|
349
354
|
this.stoppedAt = new Date().toISOString()
|
|
350
355
|
}
|
|
@@ -204,6 +204,81 @@ test("validateConfig defaults lifecycle, accepts hooks, and rejects bad values",
|
|
|
204
204
|
assert.deepEqual(validateLifecycle({stopCommand: "kill -TERM $ROLLBRIDGE_PID"}).issues, [])
|
|
205
205
|
})
|
|
206
206
|
|
|
207
|
+
test("validateConfig accepts indefinite graceful stop windows", () => {
|
|
208
|
+
const {config, issues} = validateConfig({
|
|
209
|
+
application: "demo",
|
|
210
|
+
control: {path: "/tmp/demo.sock"},
|
|
211
|
+
processes: [
|
|
212
|
+
{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}},
|
|
213
|
+
{command: "run worker", gracefulStopMs: "indefinite", id: "worker", policy: "companion"}
|
|
214
|
+
],
|
|
215
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
assert.deepEqual(issues, [])
|
|
219
|
+
assert.equal(config.processes[1].gracefulStopMs, "indefinite")
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test("validateConfig accepts handoff services only with a multi-port service range", () => {
|
|
223
|
+
const valid = validateConfig({
|
|
224
|
+
application: "demo",
|
|
225
|
+
control: {path: "/tmp/demo.sock"},
|
|
226
|
+
processes: [
|
|
227
|
+
{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}},
|
|
228
|
+
{command: "run service", deployStrategy: "handoff", id: "beacon", policy: "service", port: {from: 18100, to: 18199}}
|
|
229
|
+
],
|
|
230
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
assert.deepEqual(valid.issues, [])
|
|
234
|
+
assert.equal(valid.config.processes[1].deployStrategy, "handoff")
|
|
235
|
+
|
|
236
|
+
const defaulted = validateConfig({
|
|
237
|
+
application: "demo",
|
|
238
|
+
control: {path: "/tmp/demo.sock"},
|
|
239
|
+
processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
|
|
240
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
assert.equal(defaulted.config.processes[0].deployStrategy, "persistent")
|
|
244
|
+
|
|
245
|
+
const invalidProcess = validateConfig({
|
|
246
|
+
application: "demo",
|
|
247
|
+
control: {path: "/tmp/demo.sock"},
|
|
248
|
+
processes: [
|
|
249
|
+
{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}},
|
|
250
|
+
{command: "run worker", deployStrategy: "handoff", id: "worker", policy: "companion"}
|
|
251
|
+
],
|
|
252
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
assert.ok(invalidProcess.issues.some((issue) => issue.message === "Process \"worker\" can only set deployStrategy: \"handoff\" on a service process"), JSON.stringify(invalidProcess.issues))
|
|
256
|
+
|
|
257
|
+
const missingPort = validateConfig({
|
|
258
|
+
application: "demo",
|
|
259
|
+
control: {path: "/tmp/demo.sock"},
|
|
260
|
+
processes: [
|
|
261
|
+
{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}},
|
|
262
|
+
{command: "run service", deployStrategy: "handoff", id: "beacon", policy: "service"}
|
|
263
|
+
],
|
|
264
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
assert.ok(missingPort.issues.some((issue) => issue.message === "Handoff service \"beacon\" must define a port range"), JSON.stringify(missingPort.issues))
|
|
268
|
+
|
|
269
|
+
const fixedPort = validateConfig({
|
|
270
|
+
application: "demo",
|
|
271
|
+
control: {path: "/tmp/demo.sock"},
|
|
272
|
+
processes: [
|
|
273
|
+
{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}},
|
|
274
|
+
{command: "run service", deployStrategy: "handoff", id: "beacon", policy: "service", port: 18100}
|
|
275
|
+
],
|
|
276
|
+
proxy: {host: "127.0.0.1", port: 8182}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
assert.ok(fixedPort.issues.some((issue) => issue.message === "Handoff service \"beacon\" must use a multi-port range"), JSON.stringify(fixedPort.issues))
|
|
280
|
+
})
|
|
281
|
+
|
|
207
282
|
test("validateConfig rejects a custom stopSignal alongside a stopCommand that would ignore it", () => {
|
|
208
283
|
/**
|
|
209
284
|
* @param {Record<string, import("../src/json.js").JsonValue>} overrides - Extra fields merged onto the worker process.
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import http from "node:http"
|
|
4
|
+
import crypto from "node:crypto"
|
|
4
5
|
|
|
5
6
|
const port = Number(requiredEnv("ROLLBRIDGE_PORT"))
|
|
6
7
|
const servicePort = Number(requiredEnv("ROLLBRIDGE_BEACON_PORT"))
|
|
7
8
|
const releaseId = process.env.ROLLBRIDGE_RELEASE_ID || "unknown"
|
|
9
|
+
const sockets = new Set()
|
|
8
10
|
|
|
9
11
|
await waitForService()
|
|
10
12
|
|
|
@@ -21,6 +23,37 @@ const server = http.createServer((request, response) => {
|
|
|
21
23
|
|
|
22
24
|
process.on("SIGTERM", () => {
|
|
23
25
|
server.close(() => process.exit(0))
|
|
26
|
+
|
|
27
|
+
if (sockets.size === 0) {
|
|
28
|
+
setTimeout(() => process.exit(0), 10)
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
server.on("upgrade", (request, socket) => {
|
|
33
|
+
const key = request.headers["sec-websocket-key"]
|
|
34
|
+
|
|
35
|
+
if (typeof key !== "string") {
|
|
36
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n")
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const accept = crypto
|
|
41
|
+
.createHash("sha1")
|
|
42
|
+
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
|
43
|
+
.digest("base64")
|
|
44
|
+
|
|
45
|
+
sockets.add(socket)
|
|
46
|
+
socket.once("close", () => sockets.delete(socket))
|
|
47
|
+
socket.on("data", () => {
|
|
48
|
+
socket.end()
|
|
49
|
+
})
|
|
50
|
+
socket.write([
|
|
51
|
+
"HTTP/1.1 101 Switching Protocols",
|
|
52
|
+
"Upgrade: websocket",
|
|
53
|
+
"Connection: Upgrade",
|
|
54
|
+
`Sec-WebSocket-Accept: ${accept}`,
|
|
55
|
+
"\r\n"
|
|
56
|
+
].join("\r\n"))
|
|
24
57
|
})
|
|
25
58
|
|
|
26
59
|
server.listen(port, "127.0.0.1")
|
|
@@ -381,6 +381,39 @@ test("sends the configured stopSignal as the graceful stop signal", async () =>
|
|
|
381
381
|
assert.equal(managed.status().state, "stopped")
|
|
382
382
|
})
|
|
383
383
|
|
|
384
|
+
test("indefinite stop waits for the process to exit without SIGKILL", async () => {
|
|
385
|
+
const managed = new ManagedProcess({
|
|
386
|
+
command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("process.on('SIGTERM', () => setTimeout(() => process.exit(0), 150)); setInterval(() => {}, 1000)")}`,
|
|
387
|
+
cwd: undefined,
|
|
388
|
+
env: {},
|
|
389
|
+
id: "worker",
|
|
390
|
+
logger: () => {},
|
|
391
|
+
outputLines: 50,
|
|
392
|
+
restartDelayMs: 10,
|
|
393
|
+
shouldRestart: () => false,
|
|
394
|
+
stopSignal: "SIGTERM",
|
|
395
|
+
stopTimeoutMs: "indefinite"
|
|
396
|
+
})
|
|
397
|
+
/** @type {string[]} */
|
|
398
|
+
const signals = []
|
|
399
|
+
const killProcessGroup = managed.killProcessGroup.bind(managed)
|
|
400
|
+
|
|
401
|
+
managed.killProcessGroup = (signal) => {
|
|
402
|
+
signals.push(signal)
|
|
403
|
+
killProcessGroup(signal)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
await managed.start()
|
|
408
|
+
await managed.stop()
|
|
409
|
+
|
|
410
|
+
assert.equal(managed.status().state, "stopped")
|
|
411
|
+
assert.deepEqual(signals, ["SIGTERM"])
|
|
412
|
+
} finally {
|
|
413
|
+
await managed.stop()
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
|
|
384
417
|
test("a memory restart respawns and is counted when the supervisor still wants the process", async () => {
|
|
385
418
|
const managed = buildLongLived(() => true)
|
|
386
419
|
|
package/test/rollbridge.test.js
CHANGED
|
@@ -243,6 +243,80 @@ test("service processes start before releases and restart with the latest deploy
|
|
|
243
243
|
}
|
|
244
244
|
})
|
|
245
245
|
|
|
246
|
+
test("handoff services start per release and drain with their release", async () => {
|
|
247
|
+
const fixture = await createFixture({handoffService: true, webDependsOnService: true})
|
|
248
|
+
const daemon = await startDaemon(fixture.config)
|
|
249
|
+
/** @type {WebSocket | undefined} */
|
|
250
|
+
let socket
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
254
|
+
socket = await openWebSocket(daemon)
|
|
255
|
+
const v1 = statusRelease(daemon, "v1")
|
|
256
|
+
const v1Service = v1.processes.find((processStatus) => processStatus.id === "beacon")
|
|
257
|
+
|
|
258
|
+
assert.ok(v1Service?.pid, "v1 service should be running")
|
|
259
|
+
assert.equal(v1.ports.beacon > 0, true)
|
|
260
|
+
|
|
261
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
262
|
+
const v2 = statusRelease(daemon, "v2")
|
|
263
|
+
const v2Service = v2.processes.find((processStatus) => processStatus.id === "beacon")
|
|
264
|
+
|
|
265
|
+
assert.ok(v2Service?.pid, "v2 service should be running")
|
|
266
|
+
assert.notEqual(v2.ports.beacon, v1.ports.beacon)
|
|
267
|
+
assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.state, "running")
|
|
268
|
+
|
|
269
|
+
socket.close()
|
|
270
|
+
socket = undefined
|
|
271
|
+
|
|
272
|
+
await waitFor(() => statusRelease(daemon, "v1").state === "stopped")
|
|
273
|
+
assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.state, "stopped")
|
|
274
|
+
|
|
275
|
+
const events = await processEvents(fixture.serviceLogPath)
|
|
276
|
+
|
|
277
|
+
assert.ok(events.some((event) => event.event === "start" && event.releaseId === "v1"), "v1 service should start")
|
|
278
|
+
assert.ok(events.some((event) => event.event === "start" && event.releaseId === "v2"), "v2 service should start")
|
|
279
|
+
assert.ok(events.some((event) => event.event === "stop" && event.releaseId === "v1"), "v1 service should stop after drain")
|
|
280
|
+
} finally {
|
|
281
|
+
if (socket) socket.close()
|
|
282
|
+
await daemon.shutdown()
|
|
283
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test("handoff services stop after release-local dependents finish draining", async () => {
|
|
288
|
+
const fixture = await createFixture({handoffService: true, nonBlockingDrainWorker: true, webDependsOnService: true, workerStopDelayMs: 200})
|
|
289
|
+
const daemon = await startDaemon(fixture.config)
|
|
290
|
+
/** @type {WebSocket | undefined} */
|
|
291
|
+
let socket
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
295
|
+
socket = await openWebSocket(daemon)
|
|
296
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
297
|
+
|
|
298
|
+
await waitFor(() => statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state === "stopped")
|
|
299
|
+
|
|
300
|
+
const drainingRelease = statusRelease(daemon, "v1")
|
|
301
|
+
|
|
302
|
+
assert.equal(drainingRelease.state, "draining")
|
|
303
|
+
assert.equal(drainingRelease.processes.find((processStatus) => processStatus.id === "beacon")?.state, "running")
|
|
304
|
+
|
|
305
|
+
socket.close()
|
|
306
|
+
socket = undefined
|
|
307
|
+
await waitFor(() => statusRelease(daemon, "v1").state === "stopped")
|
|
308
|
+
|
|
309
|
+
const events = await processEvents(fixture.serviceLogPath)
|
|
310
|
+
const v1ServiceStop = events.find((event) => event.event === "stop" && event.releaseId === "v1")
|
|
311
|
+
|
|
312
|
+
assert.ok(v1ServiceStop, "v1 handoff service should stop after release drain")
|
|
313
|
+
} finally {
|
|
314
|
+
if (socket) socket.close()
|
|
315
|
+
await daemon.shutdown()
|
|
316
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
|
|
246
320
|
test("a replicated companion starts one instance per replica, and restart targets one or all", async () => {
|
|
247
321
|
const fixture = await createFixture({companionReplicas: 3})
|
|
248
322
|
const daemon = await startDaemon(fixture.config)
|
|
@@ -972,7 +1046,7 @@ test("deploy can ensure the daemon before sending the release command", async ()
|
|
|
972
1046
|
})
|
|
973
1047
|
|
|
974
1048
|
/**
|
|
975
|
-
* @param {{companionReplicas?: number, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number}} [options] - Fixture options.
|
|
1049
|
+
* @param {{companionReplicas?: number, handoffService?: boolean, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number, workerStopDelayMs?: number}} [options] - Fixture options.
|
|
976
1050
|
* @returns {Promise<{config: import("../src/config.js").RollbridgeConfig, root: string, serviceLogPath: string, singletonLogPath: string, statePath: string}>} Fixture data.
|
|
977
1051
|
*/
|
|
978
1052
|
async function createFixture(options = {}) {
|
|
@@ -983,15 +1057,16 @@ async function createFixture(options = {}) {
|
|
|
983
1057
|
/** @type {Array<Record<string, import("../src/json.js").JsonValue>>} */
|
|
984
1058
|
const processes = []
|
|
985
1059
|
|
|
986
|
-
if (options.includeService) {
|
|
1060
|
+
if (options.includeService || options.handoffService) {
|
|
987
1061
|
processes.push({
|
|
988
1062
|
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(serviceAppPath)} --release={{releaseId}}`,
|
|
1063
|
+
...(options.handoffService ? {deployStrategy: "handoff"} : {}),
|
|
989
1064
|
env: {
|
|
990
1065
|
ROLLBRIDGE_SERVICE_LOG: serviceLogPath
|
|
991
1066
|
},
|
|
992
1067
|
id: "beacon",
|
|
993
1068
|
policy: "service",
|
|
994
|
-
port: {from: 0, to: 0},
|
|
1069
|
+
port: options.handoffService ? {from: 15000, to: 15099} : {from: 0, to: 0},
|
|
995
1070
|
restartDelayMs: 50
|
|
996
1071
|
})
|
|
997
1072
|
}
|
|
@@ -1015,7 +1090,7 @@ async function createFixture(options = {}) {
|
|
|
1015
1090
|
|
|
1016
1091
|
if (options.nonBlockingDrainWorker) {
|
|
1017
1092
|
processes.push({
|
|
1018
|
-
command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(
|
|
1093
|
+
command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`process.on('SIGTERM', () => setTimeout(() => process.exit(0), ${options.workerStopDelayMs || 0})); setInterval(() => {}, 1000)`)}`,
|
|
1019
1094
|
id: "worker",
|
|
1020
1095
|
nonBlockingDrain: true,
|
|
1021
1096
|
policy: "companion"
|