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.
- package/AGENTS.md +14 -0
- package/README.md +69 -14
- package/TODO.md +5 -2
- package/changelog.d/20260828-atomic-owner-replacement.md +22 -0
- package/changelog.d/20260828-durable-owner-recovery.md +7 -0
- package/changelog.d/20260828-same-owner-jobs-generations.md +6 -0
- package/docs/cli.md +43 -21
- package/docs/config.md +71 -18
- package/docs/logging.md +8 -3
- package/docs/tensorbuzz-runbook.md +7 -6
- package/docs/troubleshooting.md +28 -12
- package/docs/velocious.md +11 -4
- package/docs/workers.md +8 -2
- package/examples/tensorbuzz.com.js +12 -4
- package/package.json +1 -1
- package/src/cli.js +209 -36
- package/src/config.js +8 -2
- package/src/control-client.js +118 -1
- package/src/daemon.js +939 -53
- package/src/guardian-client.js +434 -0
- package/src/managed-process.js +45 -15
- package/src/process-guardian.js +601 -0
- package/src/release-group.js +190 -15
- package/src/state-store.js +1 -1
- package/test/config-validation.test.js +22 -0
- package/test/fixtures/pre-split3-daemon-runner.js +30 -0
- package/test/fixtures/pre-split3-daemon.js +1336 -0
- package/test/fixtures/pre-split3-guardian-client.js +293 -0
- package/test/fixtures/pre-split3-process-guardian.js +292 -0
- package/test/fixtures/service-app.js +32 -2
- package/test/guardian-client.test.js +304 -0
- package/test/owner-recovery.test.js +950 -0
- package/test/owner-replacement.test.js +772 -0
- package/test/release-runtime-retention.test.js +1 -1
- package/test/rollbridge.test.js +178 -5
- package/test/shutdown-completion.test.js +1 -1
- package/test/state-store.test.js +12 -0
|
@@ -300,7 +300,7 @@ async function prepareRelease(releasePath, deferredImport) {
|
|
|
300
300
|
async function installStartupPause(releasePath, pausedPath) {
|
|
301
301
|
const cliPath = path.join(releasePath, "node_modules", "rollbridge", "src", "cli.js")
|
|
302
302
|
const source = await fs.readFile(cliPath, "utf8")
|
|
303
|
-
const marker = " await startDaemonProcess({\n"
|
|
303
|
+
const marker = " const candidate = await startDaemonProcess({\n"
|
|
304
304
|
const pause = ` await fsPromises.writeFile(${JSON.stringify(pausedPath)}, "paused\\n")\n await new Promise((resolve) => setTimeout(resolve, 750))\n\n${marker}`
|
|
305
305
|
|
|
306
306
|
assert.ok(source.includes(marker))
|
package/test/rollbridge.test.js
CHANGED
|
@@ -278,6 +278,65 @@ test("singleton processes restart without overlap during deploy", async () => {
|
|
|
278
278
|
}
|
|
279
279
|
})
|
|
280
280
|
|
|
281
|
+
test("candidate activation quiesces the old jobs generation before a blocked singleton replacement completes", async () => {
|
|
282
|
+
const fixture = await createFixture({handoffService: true, handoffServiceQuiet: true, includeSingleton: true, webDependsOnService: true})
|
|
283
|
+
const singletonGatePath = path.join(fixture.root, "singleton-replacement.gate")
|
|
284
|
+
const singletonQuietPath = path.join(fixture.root, "singleton-replacement.quiet")
|
|
285
|
+
const fifo = spawn("mkfifo", [singletonGatePath])
|
|
286
|
+
|
|
287
|
+
await once(fifo, "exit")
|
|
288
|
+
|
|
289
|
+
const config = normalizeConfig({
|
|
290
|
+
...fixture.config,
|
|
291
|
+
processes: fixture.config.processes.map((processConfig) => processConfig.id === "jobs-main"
|
|
292
|
+
? {...processConfig, lifecycle: {quietCommand: `printf 'quiet\\n' > ${JSON.stringify(singletonQuietPath)}; read -r _ < ${JSON.stringify(singletonGatePath)}`}}
|
|
293
|
+
: processConfig)
|
|
294
|
+
})
|
|
295
|
+
const daemon = await startDaemon(config)
|
|
296
|
+
/** @type {Promise<Record<string, import("../src/json.js").JsonValue>> | undefined} */
|
|
297
|
+
let deployPromise
|
|
298
|
+
let singletonGateReleased = false
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
302
|
+
|
|
303
|
+
const abortController = new AbortController()
|
|
304
|
+
const changes = fs.watch(fixture.root, {signal: abortController.signal})
|
|
305
|
+
const singletonReplacementBlocked = (async () => {
|
|
306
|
+
try {
|
|
307
|
+
for await (const change of changes) {
|
|
308
|
+
if (change.filename === path.basename(singletonQuietPath)) return
|
|
309
|
+
}
|
|
310
|
+
} finally {
|
|
311
|
+
abortController.abort()
|
|
312
|
+
}
|
|
313
|
+
})()
|
|
314
|
+
|
|
315
|
+
deployPromise = daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
316
|
+
let deploySettled = false
|
|
317
|
+
|
|
318
|
+
void deployPromise.then(() => { deploySettled = true }, () => { deploySettled = true })
|
|
319
|
+
await singletonReplacementBlocked
|
|
320
|
+
await Promise.resolve()
|
|
321
|
+
|
|
322
|
+
assert.equal(daemon.status().activeReleaseId, "v2", "candidate traffic must already be active")
|
|
323
|
+
assert.equal(deploySettled, false, "deploy must remain pending on singleton replacement")
|
|
324
|
+
assert.equal(await fs.readFile(fixture.serviceQuietPath, "utf8"), "v1\n", "old jobs-main must quiesce before singleton replacement completes")
|
|
325
|
+
|
|
326
|
+
await fs.writeFile(singletonGatePath, "continue\n")
|
|
327
|
+
singletonGateReleased = true
|
|
328
|
+
await deployPromise
|
|
329
|
+
} finally {
|
|
330
|
+
if (deployPromise && !singletonGateReleased) {
|
|
331
|
+
await fs.writeFile(singletonGatePath, "continue\n")
|
|
332
|
+
await deployPromise.catch(() => {})
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await daemon.shutdown()
|
|
336
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
|
|
281
340
|
test("a failed singleton replacement surfaces the error after stopping the old singleton", async () => {
|
|
282
341
|
// The singleton's working directory is per-release; only the v1 directory exists, so
|
|
283
342
|
// the v2 replacement cannot spawn (ENOENT on cwd) and its start() rejects.
|
|
@@ -363,7 +422,7 @@ test("handoff services start per release and drain with their release", async ()
|
|
|
363
422
|
|
|
364
423
|
assert.ok(v2Service?.pid, "v2 service should be running")
|
|
365
424
|
assert.notEqual(v2.ports.beacon, v1.ports.beacon)
|
|
366
|
-
assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.state, "
|
|
425
|
+
assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.state, "quiesced")
|
|
367
426
|
|
|
368
427
|
socket.close()
|
|
369
428
|
socket = undefined
|
|
@@ -399,7 +458,7 @@ test("handoff services stop after release-local dependents finish draining", asy
|
|
|
399
458
|
const drainingRelease = statusRelease(daemon, "v1")
|
|
400
459
|
|
|
401
460
|
assert.equal(drainingRelease.state, "draining")
|
|
402
|
-
assert.equal(drainingRelease.processes.find((processStatus) => processStatus.id === "beacon")?.state, "
|
|
461
|
+
assert.equal(drainingRelease.processes.find((processStatus) => processStatus.id === "beacon")?.state, "quiesced")
|
|
403
462
|
|
|
404
463
|
socket.close()
|
|
405
464
|
socket = undefined
|
|
@@ -416,6 +475,118 @@ test("handoff services stop after release-local dependents finish draining", asy
|
|
|
416
475
|
}
|
|
417
476
|
})
|
|
418
477
|
|
|
478
|
+
test("candidate activation retires jobs-main with its workers without waiting for the generation to drain", async () => {
|
|
479
|
+
const fixture = await createFixture({handoffService: true, handoffServiceQuiet: true, nonBlockingDrainWorker: true, webDependsOnService: true, workerStopDelayMs: 1000})
|
|
480
|
+
const daemon = await startDaemon(fixture.config)
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
484
|
+
const oldRelease = statusRelease(daemon, "v1")
|
|
485
|
+
const oldService = oldRelease.processes.find((processStatus) => processStatus.id === "beacon")
|
|
486
|
+
const oldWorker = oldRelease.processes.find((processStatus) => processStatus.id === "worker")
|
|
487
|
+
|
|
488
|
+
assert.ok(oldService?.pid)
|
|
489
|
+
assert.ok(oldWorker?.pid)
|
|
490
|
+
|
|
491
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
492
|
+
|
|
493
|
+
assert.equal(daemon.status().activeReleaseId, "v2", "traffic must switch only after the complete candidate is healthy")
|
|
494
|
+
assert.equal(await fs.readFile(fixture.serviceQuietPath, "utf8"), "v1\n", "old jobs-main must quiesce immediately after candidate activation")
|
|
495
|
+
|
|
496
|
+
const retired = statusRelease(daemon, "v1")
|
|
497
|
+
|
|
498
|
+
assert.equal(retired.state, "draining", "deployment completion must not wait for the old jobs generation")
|
|
499
|
+
assert.equal(retired.processes.find((processStatus) => processStatus.id === "beacon")?.state, "quiesced", "old jobs-main must remain alive and quiesced with its draining workers")
|
|
500
|
+
assert.notEqual(retired.processes.find((processStatus) => processStatus.id === "worker")?.state, "stopped", "old worker must remain in its original generation until accepted work settles")
|
|
501
|
+
assert.notEqual(statusRelease(daemon, "v2").ports.beacon, retired.ports.beacon, "old and new workers must retain distinct jobs-main endpoints")
|
|
502
|
+
} finally {
|
|
503
|
+
await daemon.shutdown()
|
|
504
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
505
|
+
}
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
test("multiple retired jobs generations keep distinct endpoints and live references until completion", async () => {
|
|
509
|
+
const fixture = await createFixture({handoffService: true, handoffServiceQuiet: true, webDependsOnService: true})
|
|
510
|
+
const daemon = await startDaemon(fixture.config)
|
|
511
|
+
/** @type {WebSocket[]} */
|
|
512
|
+
const sockets = []
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
await Promise.all(["v1", "v2", "v3"].map((releaseId) => fs.mkdir(path.join(fixture.root, releaseId))))
|
|
516
|
+
await daemon.deploy({releaseId: "v1", releasePath: path.join(fixture.root, "v1"), revision: "v1"})
|
|
517
|
+
sockets.push(await openWebSocket(daemon))
|
|
518
|
+
await daemon.deploy({releaseId: "v2", releasePath: path.join(fixture.root, "v2"), revision: "v2"})
|
|
519
|
+
sockets.push(await openWebSocket(daemon))
|
|
520
|
+
await daemon.deploy({releaseId: "v3", releasePath: path.join(fixture.root, "v3"), revision: "v3"})
|
|
521
|
+
|
|
522
|
+
const status = daemon.status()
|
|
523
|
+
const generations = ["v1", "v2", "v3"].map((releaseId) => statusRelease(daemon, releaseId))
|
|
524
|
+
|
|
525
|
+
assert.deepEqual(generations.map((release) => release.state), ["draining", "draining", "active"])
|
|
526
|
+
assert.equal(new Set(generations.map((release) => release.ports.beacon)).size, 3)
|
|
527
|
+
assert.deepEqual(status.releaseReferences.map((reference) => reference.releaseId), ["v1", "v2", "v3"])
|
|
528
|
+
assert.deepEqual(status.releaseReferences.map((reference) => reference.releasePath), ["v1", "v2", "v3"].map((releaseId) => path.join(fixture.root, releaseId)))
|
|
529
|
+
|
|
530
|
+
for (const socket of sockets.splice(0)) socket.close()
|
|
531
|
+
await waitFor(() => statusRelease(daemon, "v1").state === "stopped" && statusRelease(daemon, "v2").state === "stopped")
|
|
532
|
+
assert.deepEqual(daemon.status().releaseReferences.map((reference) => reference.releaseId), ["v3"])
|
|
533
|
+
} finally {
|
|
534
|
+
for (const socket of sockets) socket.close()
|
|
535
|
+
await daemon.shutdown()
|
|
536
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
537
|
+
}
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
test("candidate failure preserves old traffic, jobs generation, endpoint, and reference without quiescing it", async () => {
|
|
541
|
+
const fixture = await createFixture({handoffService: true, handoffServiceQuiet: true, nonBlockingDrainWorker: true})
|
|
542
|
+
const daemon = await startDaemon(fixture.config)
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
await daemon.deploy({releaseId: "good", releasePath: fixture.root, revision: "good"})
|
|
546
|
+
const before = statusRelease(daemon, "good")
|
|
547
|
+
const beforeService = before.processes.find((processStatus) => processStatus.id === "beacon")
|
|
548
|
+
|
|
549
|
+
await assert.rejects(() => daemon.deploy({releaseId: "bad", releasePath: fixture.root, revision: "bad"}), /Health check failed/)
|
|
550
|
+
|
|
551
|
+
const after = statusRelease(daemon, "good")
|
|
552
|
+
|
|
553
|
+
assert.equal(await fetchText(daemon, "/release"), "good")
|
|
554
|
+
assert.equal(after.state, "active")
|
|
555
|
+
assert.equal(after.ports.beacon, before.ports.beacon)
|
|
556
|
+
assert.equal(after.processes.find((processStatus) => processStatus.id === "beacon")?.pid, beforeService?.pid)
|
|
557
|
+
assert.equal((await fs.readFile(fixture.serviceQuietPath, "utf8")).includes("good\n"), false, "candidate cleanup must not quiesce the active generation")
|
|
558
|
+
assert.deepEqual(daemon.status().releaseReferences.map((reference) => reference.releaseId), ["good"])
|
|
559
|
+
} finally {
|
|
560
|
+
await daemon.shutdown()
|
|
561
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
562
|
+
}
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
test("handoff-service quiescence failure is visible and leaves the generation alive", async () => {
|
|
566
|
+
const fixture = await createFixture({handoffService: true, handoffServiceQuietFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
|
|
567
|
+
/** @type {{data?: Record<string, import("../src/json.js").JsonValue>, message: string}[]} */
|
|
568
|
+
const logs = []
|
|
569
|
+
const daemon = new RollbridgeDaemon({config: fixture.config, logger: (message, data) => logs.push({data, message})})
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
await daemon.start()
|
|
573
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
574
|
+
const result = await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
575
|
+
|
|
576
|
+
const retired = statusRelease(daemon, "v1")
|
|
577
|
+
|
|
578
|
+
assert.equal(retired.state, "draining")
|
|
579
|
+
assert.match(String(retired.retirementError), /quiet command exited non-zero.*23/)
|
|
580
|
+
assert.equal(retired.processes.find((processStatus) => processStatus.id === "beacon")?.state, "stopping")
|
|
581
|
+
assert.notEqual(retired.processes.find((processStatus) => processStatus.id === "worker")?.state, "stopped")
|
|
582
|
+
assert.ok(logs.some((entry) => entry.message === "release retirement quiescence failed" && entry.data?.releaseId === "v1"))
|
|
583
|
+
assert.deepEqual(result.retirement, {error: retired.retirementError, releaseId: "v1", status: "quiescence_failed"})
|
|
584
|
+
} finally {
|
|
585
|
+
await daemon.shutdown()
|
|
586
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
587
|
+
}
|
|
588
|
+
})
|
|
589
|
+
|
|
419
590
|
test("a replicated companion starts one instance per replica, and restart targets one or all", async () => {
|
|
420
591
|
const fixture = await createFixture({companionReplicas: 3})
|
|
421
592
|
const daemon = await startDaemon(fixture.config)
|
|
@@ -1187,12 +1358,13 @@ test("deploy can ensure the daemon before sending the release command", async ()
|
|
|
1187
1358
|
})
|
|
1188
1359
|
|
|
1189
1360
|
/**
|
|
1190
|
-
* @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.
|
|
1191
|
-
* @returns {Promise<{config: import("../src/config.js").RollbridgeConfig, root: string, serviceLogPath: string, singletonLogPath: string, statePath: string}>} Fixture data.
|
|
1361
|
+
* @param {{companionReplicas?: number, handoffService?: boolean, handoffServiceQuiet?: boolean, handoffServiceQuietFailure?: 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.
|
|
1362
|
+
* @returns {Promise<{config: import("../src/config.js").RollbridgeConfig, root: string, serviceLogPath: string, serviceQuietPath: string, singletonLogPath: string, statePath: string}>} Fixture data.
|
|
1192
1363
|
*/
|
|
1193
1364
|
async function createFixture(options = {}) {
|
|
1194
1365
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-test-"))
|
|
1195
1366
|
const serviceLogPath = path.join(root, "service.log")
|
|
1367
|
+
const serviceQuietPath = path.join(root, "service.quiet")
|
|
1196
1368
|
const singletonLogPath = path.join(root, "singleton.log")
|
|
1197
1369
|
const statePath = path.join(root, "rollbridge.state.json")
|
|
1198
1370
|
/** @type {Array<Record<string, import("../src/json.js").JsonValue>>} */
|
|
@@ -1206,6 +1378,7 @@ async function createFixture(options = {}) {
|
|
|
1206
1378
|
ROLLBRIDGE_SERVICE_LOG: serviceLogPath
|
|
1207
1379
|
},
|
|
1208
1380
|
id: "beacon",
|
|
1381
|
+
...(options.handoffServiceQuiet || options.handoffServiceQuietFailure ? {lifecycle: {quietCommand: options.handoffServiceQuietFailure ? "exit 23" : `printf '%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(serviceQuietPath)}`}} : {}),
|
|
1209
1382
|
policy: "service",
|
|
1210
1383
|
port: options.handoffService ? {from: 15000, to: 15099} : {from: 0, to: 0},
|
|
1211
1384
|
restartDelayMs: 50
|
|
@@ -1293,7 +1466,7 @@ async function createFixture(options = {}) {
|
|
|
1293
1466
|
...(options.persistState ? {statePath} : {})
|
|
1294
1467
|
})
|
|
1295
1468
|
|
|
1296
|
-
return {config, root, serviceLogPath, singletonLogPath, statePath}
|
|
1469
|
+
return {config, root, serviceLogPath, serviceQuietPath, singletonLogPath, statePath}
|
|
1297
1470
|
}
|
|
1298
1471
|
|
|
1299
1472
|
/**
|
|
@@ -197,7 +197,7 @@ test("external-owner retirement releases listeners before a long-draining compan
|
|
|
197
197
|
await replacement.start({reportOrphans: false})
|
|
198
198
|
assert.equal((await sendControlCommand({command: {command: "status"}, path: socketPath})).application, "shutdown-target")
|
|
199
199
|
assert.deepEqual(replacement.status().orphans, [], "intentional retired companions are not replacement orphans")
|
|
200
|
-
assert.equal(daemon.status().releases[0].processes[0].state, "
|
|
200
|
+
assert.equal(daemon.status().releases[0].processes[0].state, "quiesced")
|
|
201
201
|
} finally {
|
|
202
202
|
await fs.writeFile(gatePath, "done\n").catch(() => {})
|
|
203
203
|
if (replacement) await replacement.shutdown()
|
package/test/state-store.test.js
CHANGED
|
@@ -22,6 +22,18 @@ test("writeState then readState round-trips a snapshot", async () => {
|
|
|
22
22
|
}
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
+
test("writeState keeps durable guardian capabilities private", async () => {
|
|
26
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-state-mode-"))
|
|
27
|
+
const statePath = path.join(dir, "state.json")
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await writeState(statePath, {recovery: {guardian: {token: "private"}}})
|
|
31
|
+
assert.equal((await fs.stat(statePath)).mode & 0o777, 0o600)
|
|
32
|
+
} finally {
|
|
33
|
+
await fs.rm(dir, {force: true, recursive: true})
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
25
37
|
test("readState returns undefined for a missing or unparseable file", async () => {
|
|
26
38
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-state-"))
|
|
27
39
|
const statePath = path.join(dir, "state.json")
|