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
package/src/release-group.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
383
|
-
|
|
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
|
package/src/state-store.js
CHANGED
|
@@ -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() })
|