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
@@ -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))
@@ -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, "running")
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, "running")
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, "stopping")
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()
@@ -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")