rollbridge 0.1.23 → 0.1.25

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/workers.md CHANGED
@@ -1,117 +1,95 @@
1
- # Background-job worker deployment
1
+ # Background-job generation deployment
2
2
 
3
- This guide covers deploying background-job workers (or any non-HTTP worker pool)
4
- with Rollbridge so that in-flight jobs finish across a deploy. It uses features
5
- that exist today; the command-based lifecycle hooks mentioned at the end are
6
- still on the roadmap.
3
+ This is the required Rollbridge lifecycle for a background-jobs runtime. A
4
+ generation is release-scoped and contains its own `background-jobs-main` plus
5
+ its worker pool. It is not just a set of workers attached to one persistent
6
+ coordinator.
7
7
 
8
- ## Run workers as a `companion`
8
+ ## Process topology
9
9
 
10
- Give each worker the `companion` policy. Companions are **release-scoped**: every
11
- release starts its own workers running that release's code, and a release's
12
- workers are stopped only when that release is retired (drained) after a newer
13
- release takes over. They start **before** the `proxied` web process, so they're
14
- ready before traffic switches.
10
+ Configure jobs-main as a handoff `service` on a multi-port range and the workers
11
+ as same-release `companion`s. Beacon may remain a shared persistent service on a
12
+ fixed port such as `7330`.
15
13
 
16
14
  ```js
17
15
  {
18
- id: "worker",
19
- policy: "companion",
20
- cwd: "{{releasePath}}",
21
- command: "npx velocious background-jobs-worker"
22
- }
23
- ```
24
-
25
- ## Scale the pool with `replicas`
26
-
27
- Set `replicas` to run several identical workers (a port-less companion only).
28
- Each instance runs as `worker#0`, `worker#1`, … and gets
29
- `ROLLBRIDGE_REPLICA_INDEX` / `ROLLBRIDGE_REPLICA_COUNT` (and `{{replicaIndex}}` /
30
- `{{replicaCount}}`), so an instance can claim a distinct shard, queue, or lock:
31
-
32
- ```js
33
- {id: "worker", policy: "companion", command: "npx velocious background-jobs-worker", replicas: 4}
34
- ```
35
-
36
- Restart the pool with `rollbridge restart --process worker` (all replicas) or a
37
- single instance with `rollbridge restart --process worker#0`.
38
-
39
- ## Finish in-flight jobs on stop (`stopSignal` + `gracefulStopMs`)
40
-
41
- When Rollbridge stops a worker — during a deploy's drain, a `rollbridge restart`,
42
- or shutdown — it sends the worker's **`stopSignal`** (default `SIGTERM`), waits up
43
- to **`gracefulStopMs`**, then `SIGKILL`s it if it hasn't exited. That window is
44
- the worker's chance to finish its current job and exit cleanly.
45
-
46
- - Set `stopSignal` to the signal your worker quiets/drains on. Many job runners
47
- finish the current job and exit on `SIGTERM` (the default); some use `SIGINT`
48
- or `SIGQUIT`. Use the one your worker treats as "drain and exit".
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. Use `"indefinite"` only for
51
- workers that are safe to leave draining until they exit on their own.
52
-
53
- ```js
16
+ id: "background-jobs-main",
17
+ policy: "service",
18
+ deployStrategy: "handoff",
19
+ command: "npx velocious background-jobs-main",
20
+ port: {from: 7331, to: 7399}
21
+ },
54
22
  {
55
- id: "worker",
23
+ id: "background-jobs-worker",
56
24
  policy: "companion",
25
+ env: {VELOCIOUS_BACKGROUND_JOBS_PORT: "{{ports.background-jobs-main}}"},
57
26
  command: "npx velocious background-jobs-worker",
58
27
  replicas: 4,
59
- stopSignal: "SIGTERM",
28
+ nonBlockingDrain: true,
60
29
  gracefulStopMs: "indefinite"
61
30
  }
62
31
  ```
63
32
 
64
- ## What happens across a deploy
65
-
66
- 1. The new release's workers start (running the **new** code) before traffic
67
- switches to the new web process.
68
- 2. Both old and new workers run while the previous release drains, so **both
69
- code versions consume the shared queue at once.** Keep job code
70
- backwards-compatible across a deploy — the same rule as database migrations.
71
- 3. When the previous release is retired (its HTTP/WebSocket connections close or
72
- `proxy.drainTimeoutMs` elapses), its workers are stopped: `stopSignal`, then
73
- `SIGKILL` after `gracefulStopMs`.
74
-
75
- Because old workers are retired on the release's **connection** drain (not on
76
- their own job queue draining), a job still running when the release is retired
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.
80
-
81
- ## Command-based lifecycle hooks
82
-
83
- For workers that quiesce or drain via a command rather than a single signal, set
84
- a `lifecycle` block. When Rollbridge gracefully stops the worker it runs
85
- `quietCommand` (stop accepting new work), then drains (`drainCommand`, or waits up
86
- to `drainTimeoutMs` for the worker to exit), then `stopCommand` or `stopSignal`,
87
- then `SIGKILL` after `gracefulStopMs`. Each hook gets `ROLLBRIDGE_PID` and is
88
- bounded by a timeout, so a slow hook can't wedge a deploy.
89
-
90
- ```js
91
- {
92
- id: "worker",
93
- policy: "companion",
94
- command: "npx velocious background-jobs-worker",
95
- replicas: 4,
96
- lifecycle: {quietCommand: "kill -TSTP -$ROLLBRIDGE_PID", drainTimeoutMs: 60000}
97
- }
98
- ```
99
-
100
- See [`docs/config.md`](config.md#processeslifecycle) for the hook reference.
101
-
102
- ## Non-blocking drain
103
-
104
- By default a retired release's workers are stopped only after the proxied
105
- process's connections have drained. Set `nonBlockingDrain: true` on a worker
106
- companion whose work is independent of the web process (a job worker on a shared
107
- queue) to start its graceful stop **immediately** when the release is retired
108
- in parallel with the connection drain. The new release's workers handle new work
109
- while the old workers finish their in-flight jobs:
110
-
111
- ```js
112
- {id: "worker", policy: "companion", command: "…", nonBlockingDrain: true, gracefulStopMs: "indefinite"}
113
- ```
114
-
115
- See [`docs/config.md`](config.md) for `stopSignal`, `replicas`, and
116
- `gracefulStopMs`, and [`docs/velocious.md`](velocious.md) for a full Velocious
117
- deployment (Beacon, jobs-main, workers, web) example.
33
+ Each worker receives its generation's jobs-main port. Old workers keep that port
34
+ for their entire lifetime; normal deploy draining never hands them to, or lets
35
+ them reconnect to, the new jobs-main. `replicas` scales the pool as
36
+ `background-jobs-worker#0`, `#1`, and so on.
37
+
38
+ ## Deploy and retirement sequence
39
+
40
+ 1. Before activation, Rollbridge starts the candidate release's jobs-main and
41
+ complete worker pool, then starts and health-checks the candidate web process.
42
+ 2. Activation switches new web traffic and makes the candidate jobs generation
43
+ active.
44
+ 3. The previous jobs generation retires as one unit. Its jobs-main stops schedule
45
+ ownership, new queue dispatch, and new ordinary worker handoffs. Its workers
46
+ stop accepting handoffs.
47
+ 4. The old jobs-main stays running with its old workers. It continues owning
48
+ their connections and heartbeats, lease fencing, terminal-report acceptance
49
+ and acknowledgement, and durable store transitions. The old worker/reporting
50
+ side durably retries terminal reports, tracks outstanding report promises,
51
+ enforces per-job execution timeouts, and owns and reaps child runners.
52
+ 5. Work returned or retried to the shared queue becomes eligible for the new
53
+ active generation. The retired main never dispatches it again.
54
+ 6. The old main and workers remain one release generation until every accepted
55
+ handoff settles. Only then, after every old worker drains and exits, may the
56
+ old jobs-main exit. Rollbridge then reaps the generation and reports that its
57
+ release reference ended so Rampway can release the retention pin.
58
+
59
+ Old and new generations may overlap for hours, each running its own release code
60
+ and jobs-main endpoint. Multiple retired generations may drain concurrently.
61
+
62
+ ## Deploy completion is independent
63
+
64
+ The deploy succeeds when the candidate release is activated and healthy. The
65
+ command and deploy lock do not wait for old jobs generations, workers, jobs,
66
+ HTTP/WebSocket connections, or other retained services to finish. The required
67
+ supervisor contract durably retains generations after the command returns and
68
+ across later deploys and supervisor/host recovery. Every referenced release
69
+ directory must be reported to Rampway and stays pinned against cleanup until the
70
+ last retained process exits. Current Rollbridge provides asynchronous draining
71
+ only for the lifetime of the current daemon; see the current-behavior notes in
72
+ [`docs/config.md`](config.md#processesdeploystrategy) and
73
+ [`docs/cli.md`](cli.md#deploy).
74
+
75
+ HTTP/WebSocket drain and jobs drain are independent. Set `nonBlockingDrain: true`
76
+ so workers stop accepting new handoffs when retirement starts rather than after
77
+ the connection drain. Closing or timing out HTTP connections must never kill a
78
+ still-draining jobs-main or worker pool.
79
+
80
+ ## Timeouts and failures
81
+
82
+ `stopSignal`, `lifecycle`, and `gracefulStopMs` remain useful process-stop tools,
83
+ and the jobs framework should enforce per-job timeouts for genuinely hung work.
84
+ They are not the primary deployment solution. A legitimate multi-hour job makes
85
+ a multi-hour generation drain valid; do not turn a normal worker-shutdown timeout
86
+ into the deploy deadline.
87
+
88
+ If a worker connection is actually lost, its old jobs-main applies lease fencing
89
+ and the durable store transitions that make work eligible to return or retry.
90
+ Returned work may then run in the active generation. Normal retirement does not
91
+ simulate a disconnect and does not make a new jobs-main adopt the old worker's
92
+ handoffs.
93
+
94
+ See [`docs/velocious.md`](velocious.md) for the complete topology and
95
+ [`docs/config.md`](config.md#processesdeploystrategy) for handoff-service fields.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
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
@@ -882,12 +882,13 @@ export default class RollbridgeDaemon {
882
882
  }
883
883
 
884
884
  await captureShutdownError(cleanupErrors, "proxy close", async () => this.proxy.close())
885
- const stopResults = await Promise.allSettled([
886
- ...[...this.services.values()].map((processInstance) => processInstance.stop()),
885
+ const dependentStopResults = await Promise.allSettled([
887
886
  ...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
888
887
  ...[...this.startingReleases].map((release) => release.stop()),
889
888
  ...[...this.releases.values()].map((release) => release.stop())
890
889
  ])
890
+ const serviceStopResults = await Promise.allSettled([...this.services.values()].map((processInstance) => processInstance.stop()))
891
+ const stopResults = [...dependentStopResults, ...serviceStopResults]
891
892
  await captureShutdownError(cleanupErrors, "proxy server close", () => this.closeServer(this.proxyServer))
892
893
 
893
894
  // Wait for any in-flight write first so it can't recreate or overwrite the final state (no
@@ -115,6 +115,60 @@ test("shutdown response waits for endpoint and owned-process cleanup before imme
115
115
  }
116
116
  })
117
117
 
118
+ test("shutdown keeps daemon services alive until release-owned dependents stop", async () => {
119
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-service-order-"))
120
+ const socketPath = path.join(root, "control.sock")
121
+ const processCommand = `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`
122
+ const config = buildConfig(socketPath, {
123
+ companion: {command: processCommand, gracefulStopMs: "indefinite", id: "worker", policy: "companion"},
124
+ service: {command: processCommand, id: "coordinator", policy: "service"}
125
+ })
126
+ const daemon = new RollbridgeDaemon({config, logger: () => {}})
127
+ /** @type {() => void} */
128
+ let releaseWorker = () => {}
129
+ const workerGate = new Promise((resolve) => { releaseWorker = () => resolve(undefined) })
130
+ let serviceStopStarted = false
131
+
132
+ try {
133
+ await daemon.start()
134
+ await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
135
+
136
+ const release = daemon.activeRelease
137
+ const coordinator = daemon.services.get("coordinator")
138
+
139
+ assert.ok(release)
140
+ assert.ok(coordinator)
141
+
142
+ const originalReleaseStop = release.stop.bind(release)
143
+ const originalCoordinatorStop = coordinator.stop.bind(coordinator)
144
+ let signalReleaseStopStarted = () => {}
145
+ const releaseStopStarted = new Promise((resolve) => { signalReleaseStopStarted = () => resolve(undefined) })
146
+
147
+ release.stop = async () => {
148
+ signalReleaseStopStarted()
149
+ await workerGate
150
+ await originalReleaseStop()
151
+ }
152
+ coordinator.stop = async () => {
153
+ serviceStopStarted = true
154
+ await originalCoordinatorStop()
155
+ }
156
+
157
+ const shutdown = daemon.shutdown()
158
+
159
+ await releaseStopStarted
160
+ const serviceStoppedWhileWorkerWasDraining = serviceStopStarted
161
+ releaseWorker()
162
+ await shutdown
163
+
164
+ assert.equal(serviceStoppedWhileWorkerWasDraining, false, "a worker must retain access to daemon services throughout its drain")
165
+ } finally {
166
+ releaseWorker()
167
+ await daemon.shutdown()
168
+ await fs.rm(root, {force: true, recursive: true})
169
+ }
170
+ })
171
+
118
172
  test("external-owner retirement releases listeners before a long-draining companion exits", async () => {
119
173
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-retirement-"))
120
174
  const socketPath = path.join(root, "rollbridge.sock")
@@ -314,13 +368,13 @@ test("shutdown of an already-stopped endpoint fails explicitly", async () => {
314
368
 
315
369
  /**
316
370
  * @param {string} socketPath - Control socket path.
317
- * @param {{companion?: Record<string, import("../src/json.js").JsonValue>}} [options] - Optional companion process.
371
+ * @param {{companion?: Record<string, import("../src/json.js").JsonValue>, service?: Record<string, import("../src/json.js").JsonValue>}} [options] - Optional dependent processes.
318
372
  * @returns {import("../src/config.js").RollbridgeConfig} Normalized config.
319
373
  */
320
- function buildConfig(socketPath, {companion} = {}) {
374
+ function buildConfig(socketPath, {companion, service} = {}) {
321
375
  return normalizeConfig({
322
376
  ...rawConfig(socketPath),
323
- ...(companion ? {processes: [companion, ...rawConfig(socketPath).processes]} : {})
377
+ ...((companion || service) ? {processes: [...(service ? [service] : []), ...(companion ? [companion] : []), ...rawConfig(socketPath).processes]} : {})
324
378
  })
325
379
  }
326
380