rollbridge 0.1.28 → 0.1.30

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.
Files changed (37) hide show
  1. package/AGENTS.md +14 -0
  2. package/README.md +69 -14
  3. package/TODO.md +5 -2
  4. package/changelog.d/20260828-atomic-owner-replacement.md +22 -0
  5. package/changelog.d/20260828-durable-owner-recovery.md +7 -0
  6. package/changelog.d/20260828-same-owner-jobs-generations.md +6 -0
  7. package/docs/cli.md +43 -21
  8. package/docs/config.md +71 -18
  9. package/docs/logging.md +8 -3
  10. package/docs/tensorbuzz-runbook.md +7 -6
  11. package/docs/troubleshooting.md +28 -12
  12. package/docs/velocious.md +11 -4
  13. package/docs/workers.md +8 -2
  14. package/examples/tensorbuzz.com.js +12 -4
  15. package/package.json +1 -1
  16. package/src/cli.js +209 -36
  17. package/src/config.js +8 -2
  18. package/src/control-client.js +118 -1
  19. package/src/daemon.js +939 -53
  20. package/src/guardian-client.js +434 -0
  21. package/src/managed-process.js +45 -15
  22. package/src/process-guardian.js +601 -0
  23. package/src/release-group.js +190 -15
  24. package/src/state-store.js +1 -1
  25. package/test/config-validation.test.js +22 -0
  26. package/test/fixtures/pre-split3-daemon-runner.js +30 -0
  27. package/test/fixtures/pre-split3-daemon.js +1336 -0
  28. package/test/fixtures/pre-split3-guardian-client.js +293 -0
  29. package/test/fixtures/pre-split3-process-guardian.js +292 -0
  30. package/test/fixtures/service-app.js +32 -2
  31. package/test/guardian-client.test.js +304 -0
  32. package/test/owner-recovery.test.js +950 -0
  33. package/test/owner-replacement.test.js +772 -0
  34. package/test/release-runtime-retention.test.js +1 -1
  35. package/test/rollbridge.test.js +178 -5
  36. package/test/shutdown-completion.test.js +1 -1
  37. package/test/state-store.test.js +12 -0
@@ -0,0 +1,304 @@
1
+ // @ts-check
2
+
3
+ import assert from "node:assert/strict"
4
+ import {spawn} from "node:child_process"
5
+ import fs from "node:fs/promises"
6
+ import os from "node:os"
7
+ import path from "node:path"
8
+ import test from "node:test"
9
+ import {fileURLToPath} from "node:url"
10
+ import GuardianClient from "../src/guardian-client.js"
11
+
12
+ const legacyGuardianPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "pre-split3-process-guardian.js")
13
+
14
+ test("guardian bootstrap capability is absent from process argv", async () => {
15
+ const fixture = await createGuardian()
16
+
17
+ try {
18
+ const commandLine = await fs.readFile(`/proc/${fixture.client.pid}/cmdline`, "utf8")
19
+ const environment = await fs.readFile(`/proc/${fixture.client.pid}/environ`, "utf8")
20
+ const status = await fs.readFile(`/proc/${fixture.client.pid}/status`, "utf8")
21
+ const inventory = JSON.stringify(await fixture.client.inventory())
22
+
23
+ assert.ok(commandLine.includes("process-guardian.js"))
24
+ assert.ok(!commandLine.includes(fixture.token), "guardian capability must not be exposed through argv")
25
+ assert.ok(!environment.includes(fixture.token), "guardian capability must not be exposed through env")
26
+ assert.ok(!status.includes(fixture.token), "guardian capability must not be exposed through process status/title")
27
+ assert.ok(!inventory.includes(fixture.token), "guardian capability must not be exposed through guardian status")
28
+ } finally {
29
+ await cleanupGuardian(fixture)
30
+ }
31
+ })
32
+
33
+ test("guardian inventory removes only an exact owned provenance", async () => {
34
+ const fixture = await createGuardian()
35
+ const processInstance = fixture.client.process("candidate", definition("candidate"))
36
+
37
+ try {
38
+ await processInstance.start()
39
+ const inventory = await fixture.client.inventory()
40
+ const candidate = inventory.find((entry) => entry.key === "candidate")
41
+
42
+ assert.ok(candidate)
43
+ await assert.rejects(() => fixture.client.remove("candidate", `${candidate.provenance}-wrong`), /provenance mismatch/)
44
+ assert.equal((await fixture.client.inventory()).length, 1)
45
+ await fixture.client.remove("candidate", candidate.provenance)
46
+ assert.deepEqual(await fixture.client.inventory(), [])
47
+ } finally {
48
+ await cleanupGuardian(fixture)
49
+ }
50
+ })
51
+
52
+ test("guardian shutdown reports an exact owned process stop failure", async () => {
53
+ const fixture = await createGuardian()
54
+ const processInstance = fixture.client.process("broken-stop", {...definition("broken-stop"), stopSignal: "NOT_A_SIGNAL"})
55
+ const replacement = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
56
+
57
+ try {
58
+ await processInstance.start()
59
+ const [entry] = await fixture.client.inventory()
60
+
61
+ assert.ok(entry)
62
+ fixture.client.disconnect()
63
+ await replacement.connect()
64
+ await replacement.claimOwner(0, null)
65
+ await assert.rejects(() => replacement.reconcileInventory(), /NOT_A_SIGNAL|Unknown signal/)
66
+ assert.deepEqual((await replacement.inventory()).map(({key, provenance}) => ({key, provenance})), [{key: entry.key, provenance: entry.provenance}], "failed reconciliation must retain the exact registration")
67
+ await assert.rejects(() => replacement.shutdown(), /NOT_A_SIGNAL|Unknown signal/)
68
+ } finally {
69
+ const inventory = await replacement.inventory().catch(() => [])
70
+ const entry = inventory[0]
71
+ if (entry?.status.pid) {
72
+ try { process.kill(-entry.status.pid, "SIGKILL") } catch (_error) { /* Exact fixture process already exited. */ }
73
+ }
74
+ if (fixture.client.pid) {
75
+ try { process.kill(fixture.client.pid, "SIGKILL") } catch (_error) { /* Guardian already exited. */ }
76
+ }
77
+ replacement.disconnect()
78
+ fixture.client.disconnect()
79
+ await fixture.client.guardianExit().catch(() => {})
80
+ await fs.rm(fixture.root, {force: true, recursive: true})
81
+ }
82
+ })
83
+
84
+ test("successful guardian shutdown closes an authenticated waiting contender before exit", async () => {
85
+ const fixture = await createGuardian()
86
+ const contender = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
87
+
88
+ try {
89
+ await contender.connect()
90
+ assert.ok(fixture.client.socket)
91
+ assert.ok(contender.socket)
92
+ const shutdownOrder = /** @type {string[]} */ ([])
93
+ const onData = fixture.client.onData.bind(fixture.client)
94
+
95
+ fixture.client.onData = (chunk) => {
96
+ onData(chunk)
97
+ if (chunk.includes('"stopped":true')) shutdownOrder.push("response")
98
+ }
99
+ fixture.client.socket.once("close", () => shutdownOrder.push("caller-close"))
100
+ const contenderClosed = new Promise((resolve) => contender.socket?.once("close", () => resolve(undefined)))
101
+ const waitingClaim = contender.claimOwner(250, null)
102
+ const rejectedClaim = assert.rejects(waitingClaim, /connection closed/)
103
+
104
+ await fixture.client.shutdown()
105
+ await rejectedClaim
106
+ await contenderClosed
107
+ assert.deepEqual(shutdownOrder, ["response", "caller-close"], "shutdown success must be received before the caller connection closes")
108
+ await assert.rejects(() => contender.inventory(), /not connected/)
109
+ await assert.rejects(fs.access(fixture.client.socketPath), {code: "ENOENT"})
110
+ await fixture.client.guardianExit()
111
+ } finally {
112
+ contender.disconnect()
113
+ await cleanupGuardian(fixture)
114
+ }
115
+ })
116
+
117
+ test("replacement commit notification waits for incumbent listener retirement", async () => {
118
+ const fixture = await createGuardian()
119
+ const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
120
+ const authority = {configDigest: "incumbent", runtime: null}
121
+ const nextAuthority = {configDigest: "candidate", runtime: null}
122
+ let committed = false
123
+
124
+ try {
125
+ await fixture.client.publishOwnerState({authority, snapshot: {activeReleaseId: "v1"}})
126
+ await candidate.connect()
127
+ const prepared = await candidate.prepareOwnerReplacement(authority, nextAuthority)
128
+ await candidate.stageOwnerReplacement(prepared.replacementId, {authority: nextAuthority, snapshot: {activeReleaseId: "v1"}})
129
+ const notification = candidate.waitForEvent("replacement-committed").then(() => { committed = true })
130
+
131
+ await fixture.client.commitOwnerReplacement(prepared.replacementId)
132
+ await new Promise((resolve) => setImmediate(resolve))
133
+ assert.equal(committed, false, "candidate publication must remain fenced while incumbent listener retirement is delayed")
134
+ await fixture.client.request({command: "finalize-owner-replacement", replacementId: prepared.replacementId})
135
+ await notification
136
+ assert.equal(committed, true)
137
+ await candidate.shutdown()
138
+ await fixture.client.guardianExit()
139
+ } finally {
140
+ candidate.disconnect()
141
+ await cleanupGuardian(fixture)
142
+ }
143
+ })
144
+
145
+ test("replacement staging rejects owner state published after prepare", async () => {
146
+ const fixture = await createGuardian()
147
+ const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
148
+ const authority = {configDigest: "incumbent", runtime: null}
149
+ const nextAuthority = {configDigest: "candidate", runtime: null}
150
+
151
+ try {
152
+ await fixture.client.publishOwnerState({authority, snapshot: {activeReleaseId: "v1"}})
153
+ await candidate.connect()
154
+ const prepared = await candidate.prepareOwnerReplacement(authority, nextAuthority)
155
+
156
+ await fixture.client.publishOwnerState({authority, snapshot: {activeReleaseId: "v2"}})
157
+ await assert.rejects(
158
+ () => candidate.stageOwnerReplacement(prepared.replacementId, {authority: nextAuthority, snapshot: {activeReleaseId: "v1"}}),
159
+ /owner state changed after prepare/i
160
+ )
161
+ await candidate.abortOwnerReplacement(prepared.replacementId)
162
+ const fresh = await candidate.prepareOwnerReplacement(authority, nextAuthority)
163
+
164
+ assert.deepEqual(fresh.ownerState, {authority, snapshot: {activeReleaseId: "v2"}})
165
+ await candidate.abortOwnerReplacement(fresh.replacementId)
166
+ await fixture.client.shutdown()
167
+ await fixture.client.guardianExit()
168
+ } finally {
169
+ candidate.disconnect()
170
+ await cleanupGuardian(fixture)
171
+ }
172
+ })
173
+
174
+ test("first upgrade migrates a real pre-split guardian without replacing its owned process", async () => {
175
+ const fixture = await createLegacyGuardian()
176
+ const processDefinition = definition("legacy-worker")
177
+ const legacyProcess = fixture.client.process("release:v1:legacy-worker", processDefinition)
178
+ let upgraded
179
+ let legacyPid
180
+
181
+ try {
182
+ await legacyProcess.start()
183
+ legacyPid = legacyProcess.status().pid
184
+ const authority = {configDigest: "legacy-config", runtime: {digest: "legacy-runtime", format: 1, path: "/legacy", version: "0.1.28"}}
185
+ const nextAuthority = {...authority, runtime: {...authority.runtime, digest: "candidate-runtime", path: "/candidate"}}
186
+ const ownerState = {
187
+ authority,
188
+ snapshot: {
189
+ activeReleaseId: "v1",
190
+ releases: [{processes: [{id: "legacy-worker"}], releaseId: "v1"}],
191
+ services: [],
192
+ singletons: []
193
+ }
194
+ }
195
+
196
+ assert.ok(legacyPid)
197
+ upgraded = await fixture.client.upgradeLegacyGuardian({
198
+ ownerState,
199
+ socketPath: path.join(fixture.root, "guardian-v2.sock"),
200
+ token: "candidate-guardian-capability"
201
+ })
202
+ const prepared = await upgraded.prepareOwnerReplacement(authority, nextAuthority)
203
+ const committed = upgraded.waitForEvent("replacement-committed")
204
+ const restored = upgraded.process("release:v1:legacy-worker", processDefinition)
205
+
206
+ await restored.recover()
207
+ assert.equal(restored.status().pid, legacyPid)
208
+ assert.deepEqual(prepared.ownerState, ownerState)
209
+ assert.deepEqual(await upgraded.stageOwnerReplacement(prepared.replacementId, {authority: nextAuthority, snapshot: ownerState.snapshot}), {committed: true})
210
+ await committed
211
+ assert.equal(restored.status().pid, legacyPid)
212
+ await upgraded.shutdown()
213
+ await upgraded.guardianExit()
214
+ } finally {
215
+ upgraded?.disconnect()
216
+ fixture.client.disconnect()
217
+ if (legacyPid) killExactProcessGroup(legacyPid)
218
+ if (fixture.child.exitCode === null && fixture.child.signalCode === null) fixture.child.kill("SIGKILL")
219
+ await fs.rm(fixture.root, {force: true, recursive: true})
220
+ }
221
+ })
222
+
223
+ /** @returns {Promise<{client: GuardianClient, root: string, token: string}>} Started guardian fixture. */
224
+ async function createGuardian() {
225
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-guardian-client-"))
226
+ const token = "capability-must-not-appear-in-argv"
227
+ const client = new GuardianClient({socketPath: path.join(root, "guardian.sock"), token})
228
+
229
+ await client.launch()
230
+ await client.claimOwner(0, null)
231
+ return {client, root, token}
232
+ }
233
+
234
+ /** @returns {Promise<{child: import("node:child_process").ChildProcess, client: GuardianClient, root: string}>} Real pre-split guardian fixture. */
235
+ async function createLegacyGuardian() {
236
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-legacy-guardian-"))
237
+ const token = "legacy-guardian-capability"
238
+ const socketPath = path.join(root, "guardian.sock")
239
+ const child = spawn(process.execPath, [legacyGuardianPath, socketPath], {detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"]})
240
+
241
+ await new Promise((resolve, reject) => {
242
+ child.once("error", reject)
243
+ child.once("exit", (code) => reject(new Error(`Legacy guardian exited before readiness with status ${code}`)))
244
+ child.once("message", resolve)
245
+ child.send({token}, (error) => {
246
+ if (error) reject(error)
247
+ })
248
+ })
249
+ if (child.connected) await new Promise((resolve) => child.once("disconnect", resolve))
250
+ child.unref()
251
+ const client = new GuardianClient({pid: child.pid, socketPath, token})
252
+
253
+ await client.connect()
254
+ await client.claimOwner(0, null)
255
+ return {child, client, root}
256
+ }
257
+
258
+ /** @param {{client: GuardianClient, root: string}} fixture - Exact guardian fixture. */
259
+ async function cleanupGuardian(fixture) {
260
+ let stopped = false
261
+
262
+ try {
263
+ await fixture.client.shutdown()
264
+ stopped = true
265
+ } catch (_error) {
266
+ // Exact fixture cleanup continues below.
267
+ }
268
+ fixture.client.disconnect()
269
+ if (!stopped && fixture.client.pid) {
270
+ try { process.kill(fixture.client.pid, "SIGKILL") } catch (error) {
271
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ESRCH") throw error
272
+ }
273
+ }
274
+ await fixture.client.guardianExit().catch(() => {})
275
+ await fs.rm(fixture.root, {force: true, recursive: true})
276
+ }
277
+
278
+ /**
279
+ * @param {string} id - Process id.
280
+ * @returns {Parameters<GuardianClient["process"]>[1]} Managed process definition.
281
+ */
282
+ function definition(id) {
283
+ return {
284
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
285
+ cwd: undefined,
286
+ env: {},
287
+ id,
288
+ lifecycle: {drainTimeoutMs: 0},
289
+ logger: () => {},
290
+ outputLines: 10,
291
+ restart: {backoffFactor: 1, maxDelayMs: 0, maxRestarts: 0, windowMs: 0},
292
+ restartDelayMs: 0,
293
+ shouldRestart: () => false,
294
+ stopSignal: "SIGTERM",
295
+ stopTimeoutMs: 100
296
+ }
297
+ }
298
+
299
+ /** @param {number} pid - Exact fixture process group leader. */
300
+ function killExactProcessGroup(pid) {
301
+ try { process.kill(-pid, "SIGKILL") } catch (error) {
302
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ESRCH") throw error
303
+ }
304
+ }