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/AGENTS.md +55 -0
- package/README.md +63 -39
- package/TODO.md +5 -4
- package/docs/cli.md +26 -6
- package/docs/config.md +28 -8
- package/docs/nginx.md +5 -4
- package/docs/tensorbuzz-runbook.md +91 -125
- package/docs/troubleshooting.md +18 -9
- package/docs/velocious.md +93 -170
- package/docs/workers.md +81 -103
- package/package.json +1 -1
- package/src/daemon.js +3 -2
- package/test/shutdown-completion.test.js +57 -3
package/docs/workers.md
CHANGED
|
@@ -1,117 +1,95 @@
|
|
|
1
|
-
# Background-job
|
|
1
|
+
# Background-job generation deployment
|
|
2
2
|
|
|
3
|
-
This
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
##
|
|
8
|
+
## Process topology
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
release
|
|
12
|
-
|
|
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: "
|
|
19
|
-
policy: "
|
|
20
|
-
|
|
21
|
-
command: "npx velocious background-jobs-
|
|
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
|
-
|
|
28
|
+
nonBlockingDrain: true,
|
|
60
29
|
gracefulStopMs: "indefinite"
|
|
61
30
|
}
|
|
62
31
|
```
|
|
63
32
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
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
|
|
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
|
|
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
|
|