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
@@ -10,8 +10,8 @@ import {waitForHealth} from "./health.js"
10
10
  * @typedef {import("./json.js").JsonValue} JsonValue
11
11
  * @typedef {"starting" | "active" | "draining" | "stopped" | "failed"} ReleaseState
12
12
  * @typedef {{http: number, websocket: number}} ReleaseConnections
13
- * @typedef {{activatedAt: string | undefined, connectionCount: number, connections: ReleaseConnections, drainStartedAt: string | undefined, ports: Record<string, number>, processes: import("./managed-process.js").ManagedProcessStatus[], releaseId: string, releasePath: string, revision: string, state: ReleaseState, stoppedAt: string | undefined}} ReleaseStatus
14
- * @typedef {{count?: number, index?: number, instanceId?: string, shouldRestart?: () => boolean}} BuildProcessOptions
13
+ * @typedef {{activatedAt: string | undefined, connectionCount: number, connections: ReleaseConnections, drainStartedAt: string | undefined, ports: Record<string, number>, processes: import("./managed-process.js").ManagedProcessStatus[], releaseId: string, releasePath: string, retirementError: string | undefined, revision: string, state: ReleaseState, stoppedAt: string | undefined}} ReleaseStatus
14
+ * @typedef {{count?: number, guardianKey?: string, index?: number, instanceId?: string, shouldRestart?: () => boolean}} BuildProcessOptions
15
15
  */
16
16
 
17
17
  /**
@@ -36,13 +36,15 @@ export default class ReleaseGroup extends EventEmitter {
36
36
  * @param {object} args - Options.
37
37
  * @param {import("./config.js").RollbridgeConfig} args.config - Rollbridge config.
38
38
  * @param {(message: string, data?: Record<string, JsonValue>) => void} args.logger - Logger.
39
+ * @param {Set<number>} [args.portReservations] - Ports owned by live generations and daemon services.
39
40
  * @param {string} args.releaseId - Release id.
40
41
  * @param {string} args.releasePath - Release path.
41
42
  * @param {string | undefined} args.revision - Revision.
42
43
  * @param {Record<string, number>} [args.servicePorts] - Ports already owned by daemon-wide services.
43
44
  * @param {() => boolean} [args.shouldStart] - Whether bootstrap may create another process.
45
+ * @param {(key: string, definition: ConstructorParameters<typeof ManagedProcess>[0]) => ManagedProcess} [args.processFactory] - Durable process factory.
44
46
  */
45
- constructor({config, logger, releaseId, releasePath, revision, servicePorts = {}, shouldStart = () => true}) {
47
+ constructor({config, logger, portReservations = new Set(), processFactory, releaseId, releasePath, revision, servicePorts = {}, shouldStart = () => true}) {
46
48
  super()
47
49
 
48
50
  this.config = config
@@ -53,16 +55,23 @@ export default class ReleaseGroup extends EventEmitter {
53
55
  this.state = /** @type {ReleaseState} */ ("starting")
54
56
  this.connectionCount = 0
55
57
  this.connections = /** @type {ReleaseConnections} */ ({http: 0, websocket: 0})
58
+ this.transferredConnections = /** @type {ReleaseConnections} */ ({http: 0, websocket: 0})
59
+ this.ownerHandoffPaused = false
56
60
  this.processes = /** @type {Map<string, ManagedProcess>} */ (new Map())
57
61
  this.handoffServiceIds = /** @type {Set<string>} */ (new Set())
58
62
  this.nonBlockingDrainIds = /** @type {Set<string>} */ (new Set())
59
63
  this.ports = /** @type {Record<string, number>} */ ({})
64
+ this.portReservations = portReservations
65
+ this.ownedPortReservations = /** @type {Set<number>} */ (new Set())
60
66
  this.servicePorts = servicePorts
61
67
  this.shouldStart = shouldStart
68
+ this.processFactory = processFactory
62
69
  this.portsAllocated = false
63
70
  this.drainStartedAt = /** @type {string | undefined} */ (undefined)
64
71
  this.activatedAt = /** @type {string | undefined} */ (undefined)
65
72
  this.stoppedAt = /** @type {string | undefined} */ (undefined)
73
+ this.retirementError = /** @type {string | undefined} */ (undefined)
74
+ this.preserveConfigOnRetirement = false
66
75
  }
67
76
 
68
77
  /** @returns {Promise<void>} Starts release-owned processes and health checks the proxied process. */
@@ -104,6 +113,70 @@ export default class ReleaseGroup extends EventEmitter {
104
113
  }
105
114
  }
106
115
 
116
+ /**
117
+ * Reconstructs this release around processes still owned by the durable guardian.
118
+ * @param {ReleaseStatus} snapshot - Persisted release snapshot.
119
+ */
120
+ async restore(snapshot) {
121
+ if (!snapshot || snapshot.releaseId !== this.releaseId || snapshot.releasePath !== this.releasePath || snapshot.revision !== this.revision) {
122
+ throw new Error(`Persisted release identity mismatch for ${this.releaseId}`)
123
+ }
124
+ if (!snapshot.ports || !Array.isArray(snapshot.processes)) throw new Error(`Persisted release ${this.releaseId} is missing ports or processes`)
125
+
126
+ const expectedProcessIds = this.config.processes
127
+ .filter((processConfig) => processConfig.policy !== "singleton" && (processConfig.policy !== "service" || processConfig.deployStrategy === "handoff"))
128
+ .flatMap((processConfig) => Array.from({length: processConfig.replicas}, (_, index) => replicaInstanceId(processConfig, index)))
129
+ .sort()
130
+ const persistedProcessIds = snapshot.processes.map((processStatus) => processStatus.id).sort()
131
+
132
+ if (JSON.stringify(persistedProcessIds) !== JSON.stringify(expectedProcessIds)) {
133
+ throw new Error(`Persisted release ${this.releaseId} process set does not match the configured generation`)
134
+ }
135
+ for (const processConfig of this.config.processes) {
136
+ if (processConfig.port && typeof snapshot.ports[processConfig.id] !== "number") throw new Error(`Persisted release ${this.releaseId} is missing port ${processConfig.id}`)
137
+ }
138
+
139
+ const generationPorts = this.config.processes
140
+ .filter((processConfig) => processConfig.port && (processConfig.policy !== "service" || processConfig.deployStrategy === "handoff"))
141
+ .map((processConfig) => snapshot.ports[processConfig.id])
142
+ const distinctGenerationPorts = new Set(generationPorts)
143
+
144
+ if (distinctGenerationPorts.size !== generationPorts.length) throw new Error(`Persisted release ${this.releaseId} reuses a port within one live generation`)
145
+ for (const port of distinctGenerationPorts) {
146
+ if (this.portReservations.has(port)) throw new Error(`Persisted release ${this.releaseId} port ${port} is already reserved by another live generation`)
147
+ }
148
+ for (const port of distinctGenerationPorts) {
149
+ this.portReservations.add(port)
150
+ this.ownedPortReservations.add(port)
151
+ }
152
+
153
+ this.ports = {...snapshot.ports}
154
+ this.portsAllocated = true
155
+ this.state = snapshot.state
156
+ this.activatedAt = snapshot.activatedAt
157
+ this.drainStartedAt = snapshot.drainStartedAt
158
+ this.retirementError = snapshot.retirementError
159
+ this.stoppedAt = snapshot.stoppedAt
160
+ this.setTransferredConnections(snapshot.connections)
161
+
162
+ for (const processStatus of snapshot.processes) {
163
+ const baseId = processStatus.id.replace(/#\d+$/, "")
164
+ const processConfig = this.config.processes.find((candidate) => candidate.id === baseId)
165
+
166
+ if (!processConfig || processConfig.policy === "singleton" || (processConfig.policy === "service" && processConfig.deployStrategy !== "handoff")) {
167
+ throw new Error(`Persisted release ${this.releaseId} contains unknown process ${processStatus.id}`)
168
+ }
169
+ const replicaMatch = processStatus.id.match(/#(\d+)$/)
170
+ const index = replicaMatch ? Number(replicaMatch[1]) : 0
171
+ const processInstance = this.buildProcess(processConfig, {count: processConfig.replicas, index, instanceId: processStatus.id})
172
+
173
+ this.processes.set(processStatus.id, processInstance)
174
+ if (processConfig.policy === "service" && processConfig.deployStrategy === "handoff") this.handoffServiceIds.add(processStatus.id)
175
+ if (processConfig.nonBlockingDrain) this.nonBlockingDrainIds.add(processStatus.id)
176
+ if ("recover" in processInstance && typeof processInstance.recover === "function") await processInstance.recover()
177
+ }
178
+ }
179
+
107
180
  /**
108
181
  * @param {string} id - Process id.
109
182
  * @returns {ManagedProcess | undefined} This release's managed process with the given id, if present.
@@ -184,26 +257,43 @@ export default class ReleaseGroup extends EventEmitter {
184
257
  async allocatePorts() {
185
258
  if (this.portsAllocated) return
186
259
 
187
- const usedPorts = /** @type {Set<number>} */ (new Set())
188
-
189
260
  for (const processConfig of this.config.processes) {
190
261
  if (!processConfig.port) continue
191
262
  if (processConfig.policy === "service" && processConfig.deployStrategy !== "handoff" && this.servicePorts[processConfig.id] !== undefined) {
192
263
  this.ports[processConfig.id] = this.servicePorts[processConfig.id]
193
- usedPorts.add(this.servicePorts[processConfig.id])
194
264
  continue
195
265
  }
196
266
 
197
- this.ports[processConfig.id] = await findAvailablePort({
267
+ const port = await findAvailablePort({
198
268
  host: this.config.proxy.upstreamHost,
199
269
  range: processConfig.port,
200
- usedPorts
270
+ usedPorts: this.portReservations
201
271
  })
272
+
273
+ this.ports[processConfig.id] = port
274
+ this.ownedPortReservations.add(port)
202
275
  }
203
276
 
204
277
  this.portsAllocated = true
205
278
  }
206
279
 
280
+ /**
281
+ * Transfers a newly allocated daemon-wide service port out of this generation's cleanup scope.
282
+ * @param {string} processId - Daemon-wide service process id.
283
+ * @returns {void}
284
+ */
285
+ transferPortReservation(processId) {
286
+ const port = this.ports[processId]
287
+
288
+ if (port !== undefined) this.ownedPortReservations.delete(port)
289
+ }
290
+
291
+ /** @returns {void} Releases ports only after this generation has truly stopped. */
292
+ releasePortReservations() {
293
+ for (const port of this.ownedPortReservations) this.portReservations.delete(port)
294
+ this.ownedPortReservations.clear()
295
+ }
296
+
207
297
  /**
208
298
  * Builds a managed process from config.
209
299
  * @param {import("./config.js").ProcessConfig} processConfig - Process config.
@@ -221,7 +311,7 @@ export default class ReleaseGroup extends EventEmitter {
221
311
  ...renderedEnv
222
312
  }
223
313
 
224
- return new ManagedProcess({
314
+ const definition = /** @type {ConstructorParameters<typeof ManagedProcess>[0]} */ ({
225
315
  command: renderTemplate(processConfig.command, context),
226
316
  cwd: processConfig.cwd ? renderTemplate(processConfig.cwd, context) : this.releasePath,
227
317
  env: processEnv,
@@ -236,6 +326,10 @@ export default class ReleaseGroup extends EventEmitter {
236
326
  stopSignal: processConfig.stopSignal,
237
327
  stopTimeoutMs: processConfig.gracefulStopMs
238
328
  })
329
+
330
+ return this.processFactory
331
+ ? this.processFactory(options.guardianKey || `release:${this.releaseId}:${instanceId}`, definition)
332
+ : new ManagedProcess(definition)
239
333
  }
240
334
 
241
335
  /**
@@ -356,6 +450,48 @@ export default class ReleaseGroup extends EventEmitter {
356
450
  }
357
451
  }
358
452
 
453
+ /**
454
+ * Reconciles connections still owned by a prior daemon listener.
455
+ * @param {ReleaseConnections} connections - Exact incumbent listener counts.
456
+ */
457
+ setTransferredConnections(connections) {
458
+ const http = connections?.http
459
+ const websocket = connections?.websocket
460
+
461
+ if (!Number.isSafeInteger(http) || http < 0 || !Number.isSafeInteger(websocket) || websocket < 0) {
462
+ throw new Error(`Persisted release ${this.releaseId} has invalid listener connection counts`)
463
+ }
464
+ const previous = this.transferredConnections
465
+
466
+ this.connectionCount += http + websocket - previous.http - previous.websocket
467
+ this.connections.http += http - previous.http
468
+ this.connections.websocket += websocket - previous.websocket
469
+ this.transferredConnections = {http, websocket}
470
+ if (this.connectionCount === 0) this.emit("drained")
471
+ }
472
+
473
+ /** @returns {boolean} Whether a prior daemon still owns live connections for this release. */
474
+ hasTransferredConnections() {
475
+ return this.transferredConnections.http + this.transferredConnections.websocket > 0
476
+ }
477
+
478
+ /** Pauses only daemon-local connection-dependent retirement at owner handoff. */
479
+ pauseDrainForOwnerHandoff() {
480
+ if (this.state !== "draining") return
481
+ this.ownerHandoffPaused = true
482
+ this.emit("ownerHandoff")
483
+ }
484
+
485
+ /** @returns {boolean} Whether retirement completion belongs to the prepared successor. */
486
+ isDrainPausedForOwnerHandoff() {
487
+ return this.ownerHandoffPaused
488
+ }
489
+
490
+ /** Resumes a drain after its prepared owner handoff aborts. */
491
+ resumeDrainAfterOwnerHandoff() {
492
+ this.ownerHandoffPaused = false
493
+ }
494
+
359
495
  /**
360
496
  * Starts draining and stops once existing connections close or timeout.
361
497
  * @param {number} timeoutMs - Drain timeout.
@@ -365,9 +501,7 @@ export default class ReleaseGroup extends EventEmitter {
365
501
  async drainAndStop(timeoutMs, config = this.config) {
366
502
  if (this.state === "stopped") return
367
503
 
368
- this.state = "draining"
369
- this.drainStartedAt = new Date().toISOString()
370
- this.refreshProcessDefinitions(config)
504
+ if (this.state !== "draining") await this.beginRetirement(config)
371
505
 
372
506
  // Stop nonBlockingDrain processes (e.g. job workers) immediately and in the background, so
373
507
  // their lifecycle drain runs as soon as the release is retired — in parallel with the
@@ -379,19 +513,58 @@ export default class ReleaseGroup extends EventEmitter {
379
513
 
380
514
  if (this.connectionCount > 0) {
381
515
  await new Promise((resolve) => {
382
- const timer = setTimeout(resolve, timeoutMs)
383
- this.once("drained", () => {
516
+ let timer = /** @type {ReturnType<typeof setTimeout> | undefined} */ (undefined)
517
+ const completed = () => {
384
518
  clearTimeout(timer)
519
+ this.off("drained", completed)
520
+ this.off("ownerHandoff", completed)
385
521
  resolve(undefined)
386
- })
522
+ }
523
+
524
+ this.once("drained", completed)
525
+ this.once("ownerHandoff", completed)
526
+ timer = setTimeout(completed, timeoutMs)
387
527
  })
388
528
  }
389
529
 
530
+ if (this.ownerHandoffPaused) {
531
+ void Promise.allSettled(nonBlockingStops)
532
+ return
533
+ }
534
+
390
535
  await Promise.allSettled(connectionDependent.map((processInstance) => processInstance.stop()))
391
536
  await Promise.allSettled(nonBlockingStops)
392
537
  await Promise.allSettled(handoffServices.map((processInstance) => processInstance.stop()))
393
538
  this.state = "stopped"
394
539
  this.stoppedAt = new Date().toISOString()
540
+ this.releasePortReservations()
541
+ }
542
+
543
+ /**
544
+ * Marks the generation retired and quiesces its jobs-main and non-blocking workers as one unit.
545
+ * @param {import("./config.js").RollbridgeConfig} [config] - Refreshed retirement config.
546
+ * @returns {Promise<void>} Resolves when retirement quiescence succeeds.
547
+ */
548
+ async beginRetirement(config = this.config) {
549
+ if (this.state === "draining") {
550
+ if (this.retirementError) throw new Error(this.retirementError)
551
+ return
552
+ }
553
+
554
+ this.state = "draining"
555
+ this.drainStartedAt = new Date().toISOString()
556
+ this.refreshProcessDefinitions(config)
557
+ const generationIds = new Set([...this.handoffServiceIds, ...this.nonBlockingDrainIds])
558
+ const results = await Promise.allSettled([...this.processes.entries()]
559
+ .filter(([id]) => generationIds.has(id))
560
+ .map(([, processInstance]) => processInstance.quiesceStrict()))
561
+ const errors = results.filter((result) => result.status === "rejected").map((result) => result.reason)
562
+
563
+ if (errors.length > 0) {
564
+ const failure = new AggregateError(errors, `Release ${this.releaseId} retirement quiescence failed`)
565
+ this.retirementError = `${failure.message}: ${errors.map((error) => error instanceof Error ? error.message : String(error)).join("; ")}`
566
+ throw failure
567
+ }
395
568
  }
396
569
 
397
570
  /** @returns {Promise<void>} Stops all release-owned processes. */
@@ -401,6 +574,7 @@ export default class ReleaseGroup extends EventEmitter {
401
574
  await Promise.allSettled(stopTasks)
402
575
  this.state = "stopped"
403
576
  this.stoppedAt = new Date().toISOString()
577
+ this.releasePortReservations()
404
578
  }
405
579
 
406
580
  /** @returns {Promise<void>} Quiesces every release process without waiting for its drain. */
@@ -419,6 +593,7 @@ export default class ReleaseGroup extends EventEmitter {
419
593
  processes: [...this.processes.values()].map((processInstance) => processInstance.status()),
420
594
  releaseId: this.releaseId,
421
595
  releasePath: this.releasePath,
596
+ retirementError: this.retirementError,
422
597
  revision: this.revision,
423
598
  state: this.state,
424
599
  stoppedAt: this.stoppedAt
@@ -21,7 +21,7 @@ export async function writeState(path, state) {
21
21
 
22
22
  const tempPath = `${path}.${process.pid}.${tempCounter}.tmp`
23
23
 
24
- await fs.writeFile(tempPath, `${JSON.stringify(state, null, 2)}\n`)
24
+ await fs.writeFile(tempPath, `${JSON.stringify(state, null, 2)}\n`, {mode: 0o600})
25
25
  await fs.rename(tempPath, path)
26
26
  }
27
27
 
@@ -571,6 +571,28 @@ test("validateConfig leaves statePath unset by default, accepts a string, and re
571
571
  assert.ok(validateStatePath(123).issues.some((issue) => issue.message === "statePath must be a string"))
572
572
  })
573
573
 
574
+ test("ownerRecovery requires durable state and a non-negative integer reconnection grace", () => {
575
+ const raw = {
576
+ application: "demo",
577
+ control: {path: "/tmp/demo.sock"},
578
+ ownerRecovery: {reconnectGraceMs: 45000},
579
+ processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
580
+ proxy: {host: "127.0.0.1", port: 8182}
581
+ }
582
+ const missingState = validateConfig(raw)
583
+
584
+ assert.ok(missingState.issues.some((issue) => issue.message === "ownerRecovery requires statePath"))
585
+
586
+ const valid = validateConfig({...raw, statePath: "/var/lib/rollbridge/demo.state.json"})
587
+
588
+ assert.deepEqual(valid.issues, [])
589
+ assert.deepEqual(valid.config.ownerRecovery, {reconnectGraceMs: 45000})
590
+
591
+ const invalidGrace = validateConfig({...raw, ownerRecovery: {reconnectGraceMs: -1}, statePath: "/var/lib/rollbridge/demo.state.json"})
592
+
593
+ assert.ok(invalidGrace.issues.some((issue) => issue.message === "ownerRecovery.reconnectGraceMs must be a non-negative integer"))
594
+ })
595
+
574
596
  test("normalizeConfig throws an aggregated error listing every issue", () => {
575
597
  assert.throws(
576
598
  () => normalizeConfig({
@@ -0,0 +1,30 @@
1
+ // @ts-check
2
+
3
+ import {loadConfig} from "../../src/config.js"
4
+ import {currentPackageIdentity} from "../../src/daemon-runtime.js"
5
+ import RollbridgeDaemon from "./pre-split3-daemon.js"
6
+
7
+ const [command, configFlag, configPath] = process.argv.slice(2)
8
+
9
+ if (command !== "daemon" || configFlag !== "--config" || !configPath) throw new Error("pre-split3-daemon-runner requires daemon --config <path>")
10
+
11
+ const daemon = new RollbridgeDaemon({
12
+ config: await loadConfig(configPath),
13
+ configPath,
14
+ runtime: await currentPackageIdentity()
15
+ })
16
+
17
+ await daemon.start()
18
+
19
+ const shutdown = async () => {
20
+ try {
21
+ await daemon.shutdown()
22
+ process.exit(0)
23
+ } catch (error) {
24
+ console.error(error)
25
+ process.exit(1)
26
+ }
27
+ }
28
+
29
+ process.once("SIGINT", () => { void shutdown() })
30
+ process.once("SIGTERM", () => { void shutdown() })