rollbridge 0.1.35 → 0.1.36
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.
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
### Fixed
|
|
2
2
|
|
|
3
3
|
- Select retired-owner commit proof from the guardian-published committed owner
|
|
4
|
-
snapshot rather than candidate-local process order,
|
|
5
|
-
|
|
6
|
-
authority, and control-path fences.
|
|
4
|
+
snapshot rather than candidate-local process order, reserve its incumbent-owned
|
|
5
|
+
registration through commit, then attach it to the committed candidate while
|
|
6
|
+
retaining the replacement transaction, authority, and control-path fences.
|
|
7
7
|
- Fail closed when an older retained guardian cannot commit that replacement
|
|
8
8
|
atomically after the incumbent control socket disappears, preserving the
|
|
9
9
|
incumbent owner, retained connections, and guardian-managed release processes.
|
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -342,6 +342,11 @@ export default class RollbridgeDaemon {
|
|
|
342
342
|
const transfer = /** @type {PrivateOwnerState} */ (prepared.ownerState)
|
|
343
343
|
|
|
344
344
|
if (!transfer?.config || !transfer.snapshot) throw new Error("Committed owner published incomplete replacement state")
|
|
345
|
+
const registeredProcesses = new Map((await this.guardian.inventory()).map(({key, provenance}) => [key, provenance]))
|
|
346
|
+
const reservedProcessKey = legacyBridge ? undefined : ownerSnapshotProcessKeys(transfer.snapshot, transfer.singletonReleaseIds)
|
|
347
|
+
.find((key) => registeredProcesses.has(key))
|
|
348
|
+
|
|
349
|
+
if (reservedProcessKey) this.guardian.reserveProcessRecovery(reservedProcessKey, /** @type {string} */ (registeredProcesses.get(reservedProcessKey)))
|
|
345
350
|
await this.restoreOwnerState(transfer.snapshot, {config: transfer.config, releaseConfigs: transfer.releaseConfigs, resumeDrains: false, singletonReleaseIds: transfer.singletonReleaseIds, synchronizeLifecycleRoles: false})
|
|
346
351
|
for (const release of this.releases.values()) release.preserveConfigOnRetirement = true
|
|
347
352
|
this.logger("owner replacement candidate prepared", {activeReleaseId: this.activeRelease?.releaseId ?? null, replacementId: prepared.replacementId})
|
|
@@ -386,6 +391,7 @@ export default class RollbridgeDaemon {
|
|
|
386
391
|
if (incumbentControl) {
|
|
387
392
|
const listenerSession = incumbentControl
|
|
388
393
|
|
|
394
|
+
if (reservedProcessKey) await this.guardian.recoverReservedProcess(reservedProcessKey)
|
|
389
395
|
incumbentControl.onEvent((event) => this.handleIncumbentListenerEvent(event, listenerSession))
|
|
390
396
|
await incumbentControl.request({
|
|
391
397
|
command: "yield-owner-listeners",
|
|
@@ -414,15 +420,20 @@ export default class RollbridgeDaemon {
|
|
|
414
420
|
snapshot: this.status()
|
|
415
421
|
})
|
|
416
422
|
|
|
423
|
+
if (staged.committed && reservedProcessKey) {
|
|
424
|
+
committedAuthority = true
|
|
425
|
+
await this.guardian.recoverReservedProcess(reservedProcessKey)
|
|
426
|
+
}
|
|
427
|
+
|
|
417
428
|
if (!staged.committed) {
|
|
418
429
|
if (retiredIncumbentControl) {
|
|
419
|
-
const
|
|
420
|
-
const processKey = ownerSnapshotProcessKeys(transfer.snapshot, transfer.singletonReleaseIds)
|
|
421
|
-
.find((key) => recoveredProcesses.has(key))
|
|
430
|
+
const processKey = reservedProcessKey
|
|
422
431
|
|
|
423
|
-
if (!processKey) throw new Error("Retired owner replacement requires an exact
|
|
432
|
+
if (!processKey || !this.guardian.processes.has(processKey)) throw new Error("Retired owner replacement requires an exact reserved process from committed owner state")
|
|
424
433
|
try {
|
|
425
434
|
await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId, processKey)
|
|
435
|
+
committedAuthority = true
|
|
436
|
+
await this.guardian.recoverReservedProcess(processKey)
|
|
426
437
|
} catch (error) {
|
|
427
438
|
if (!(error instanceof Error) || error.message !== "Guardian commit-retired-owner-replacement requires the committed owner") throw error
|
|
428
439
|
throw new Error(
|
package/src/guardian-client.js
CHANGED
|
@@ -21,6 +21,8 @@ export default class GuardianClient {
|
|
|
21
21
|
this.idleWaiters = /** @type {(() => void)[]} */ ([])
|
|
22
22
|
this.guardianExitPromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
23
23
|
this.processes = /** @type {Map<string, GuardianProcess>} */ (new Map())
|
|
24
|
+
this.reservedProcessKey = /** @type {string | undefined} */ (undefined)
|
|
25
|
+
this.reservedProcessProvenance = /** @type {string | undefined} */ (undefined)
|
|
24
26
|
this.events = /** @type {Map<string, {reject: (error: Error) => void, resolve: (value: Record<string, import("./json.js").JsonValue>) => void}[]>} */ (new Map())
|
|
25
27
|
this.eventHandlers = /** @type {Map<string, ((event: Record<string, import("./json.js").JsonValue>) => void)[]>} */ (new Map())
|
|
26
28
|
}
|
|
@@ -111,6 +113,27 @@ export default class GuardianClient {
|
|
|
111
113
|
return processInstance
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
/**
|
|
117
|
+
* @param {string} key - Exact committed-owner registration reserved until replacement commit.
|
|
118
|
+
* @param {string} provenance - Guardian-inventoried definition fence.
|
|
119
|
+
*/
|
|
120
|
+
reserveProcessRecovery(key, provenance) {
|
|
121
|
+
if (this.reservedProcessKey) throw new Error(`Guardian process recovery ${this.reservedProcessKey} is already reserved`)
|
|
122
|
+
this.reservedProcessKey = key
|
|
123
|
+
this.reservedProcessProvenance = provenance
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @param {string} key - Exact reserved registration to attach after authority commits. */
|
|
127
|
+
async recoverReservedProcess(key) {
|
|
128
|
+
if (this.reservedProcessKey !== key) throw new Error(`Guardian process recovery ${key} is not reserved`)
|
|
129
|
+
const processInstance = this.processes.get(key)
|
|
130
|
+
|
|
131
|
+
if (!processInstance) throw new Error(`Reserved guardian process ${key} was not reconstructed`)
|
|
132
|
+
await processInstance.attachReserved()
|
|
133
|
+
this.reservedProcessKey = undefined
|
|
134
|
+
this.reservedProcessProvenance = undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
114
137
|
/**
|
|
115
138
|
* @param {Record<string, import("./json.js").JsonValue>} command - Command.
|
|
116
139
|
* @returns {Promise<import("./json.js").JsonValue>} Guardian response.
|
|
@@ -354,6 +377,16 @@ class GuardianProcess extends ManagedProcess {
|
|
|
354
377
|
|
|
355
378
|
/** Reconnects to an already registered guardian process without changing its desired state. */
|
|
356
379
|
async recover() {
|
|
380
|
+
if (this.client.reservedProcessKey === this.key) {
|
|
381
|
+
if (this.client.reservedProcessProvenance !== this.provenance) throw new Error(`Guardian provenance mismatch for reserved process ${this.key}`)
|
|
382
|
+
this.cachedStatus = asProcessStatus(await this.client.request({command: "status", key: this.key}))
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
await this.ensureRegistered()
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Attaches a reconstructed process whose incumbent-owned registration was reserved through commit. */
|
|
389
|
+
async attachReserved() {
|
|
357
390
|
await this.ensureRegistered()
|
|
358
391
|
}
|
|
359
392
|
|
|
@@ -224,6 +224,29 @@ test("retired owner replacement commit carries its exact recovered process key",
|
|
|
224
224
|
await client.commitRetiredOwnerReplacement(replacementId, processKey)
|
|
225
225
|
})
|
|
226
226
|
|
|
227
|
+
test("reserved process recovery rejects a reconstructed definition with different provenance", async () => {
|
|
228
|
+
const fixture = await createGuardian()
|
|
229
|
+
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
230
|
+
const processKey = "release:v1:worker"
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
await fixture.client.process(processKey, definition("worker")).recover()
|
|
234
|
+
const [registration] = await fixture.client.inventory()
|
|
235
|
+
|
|
236
|
+
assert.ok(registration)
|
|
237
|
+
await candidate.connect()
|
|
238
|
+
candidate.reserveProcessRecovery(processKey, registration.provenance)
|
|
239
|
+
await assert.rejects(
|
|
240
|
+
() => candidate.process(processKey, definition("different-worker")).recover(),
|
|
241
|
+
/provenance mismatch for reserved process/
|
|
242
|
+
)
|
|
243
|
+
assert.deepEqual((await fixture.client.inventory()).map(({key}) => key), [processKey])
|
|
244
|
+
} finally {
|
|
245
|
+
candidate.disconnect()
|
|
246
|
+
await cleanupGuardian(fixture)
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
227
250
|
test("retired owner replacement rejects a registered process absent from committed owner state", async () => {
|
|
228
251
|
const fixture = await createGuardian()
|
|
229
252
|
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
@@ -393,16 +393,24 @@ test("cross-version replacement fails closed without dropping a retained WebSock
|
|
|
393
393
|
}
|
|
394
394
|
})
|
|
395
395
|
|
|
396
|
-
test("
|
|
396
|
+
test("cross-version replacement preserves committed-owner proof until commit then recovers every process", async () => {
|
|
397
397
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-committed-proof-"))
|
|
398
398
|
const socketPath = path.join(root, "rollbridge.sock")
|
|
399
399
|
const statePath = path.join(root, "state.json")
|
|
400
|
+
const compatibilitySocketPath = path.join(root, "retained-guardian.sock")
|
|
400
401
|
const releasePath = path.join(root, "v1")
|
|
401
402
|
const daemonConfig = normalizeConfig(config({controlPath: socketPath, extraCompanion: false, statePath}))
|
|
402
403
|
const owner = new RollbridgeDaemon({config: daemonConfig, logger: () => {}})
|
|
404
|
+
const compatibilitySockets = new Set()
|
|
403
405
|
const candidateProcessKey = "release:candidate:worker"
|
|
404
406
|
const committedOwnerProcessKey = "release:v1:worker"
|
|
407
|
+
const candidateRecoveredKeys = new Set()
|
|
408
|
+
let compatibilityGuardian
|
|
405
409
|
let committedProcessKey
|
|
410
|
+
let recoveredKeysAtCommit = /** @type {Set<string> | undefined} */ (undefined)
|
|
411
|
+
let retainedConnection
|
|
412
|
+
let retainedConnectionClosed = false
|
|
413
|
+
let retainedGuardianSocketPath = /** @type {string | undefined} */ (undefined)
|
|
406
414
|
/** @type {RollbridgeDaemon | undefined} */
|
|
407
415
|
let replacement
|
|
408
416
|
|
|
@@ -421,14 +429,66 @@ test("same-authority replacement commits with committed-owner proof after the in
|
|
|
421
429
|
key: candidateProcessKey,
|
|
422
430
|
provenance: ownerProcess.provenance
|
|
423
431
|
})
|
|
424
|
-
|
|
432
|
+
const running = owner.status()
|
|
433
|
+
const processState = running.releases[0]?.processes.map(({id, pid, state}) => ({id, pid, state}))
|
|
434
|
+
const expectedProcessKeys = new Set(running.releases[0]?.processes.map(({id}) => `release:v1:${id}`))
|
|
435
|
+
const proxyPort = /** @type {{port?: number}} */ (running.proxy).port
|
|
436
|
+
|
|
437
|
+
assert.deepEqual(processState?.map(({state}) => state), ["running", "running"])
|
|
438
|
+
if (typeof proxyPort !== "number") throw new Error("Retained owner proxy is missing its port")
|
|
439
|
+
retainedConnection = await openWebSocket(proxyPort)
|
|
440
|
+
retainedConnection.once("close", () => { retainedConnectionClosed = true })
|
|
425
441
|
await owner.closeServer(owner.controlServer)
|
|
426
442
|
await owner.removeControlSocket()
|
|
427
|
-
await
|
|
428
|
-
const
|
|
429
|
-
|
|
443
|
+
const state = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
444
|
+
const guardianSocketPath = state.recovery.guardian.socketPath
|
|
445
|
+
|
|
446
|
+
if (typeof guardianSocketPath !== "string") throw new Error("Retained guardian state is missing its socket path")
|
|
447
|
+
retainedGuardianSocketPath = guardianSocketPath
|
|
448
|
+
compatibilityGuardian = net.createServer((candidateSocket) => {
|
|
449
|
+
const guardianSocket = net.createConnection(guardianSocketPath)
|
|
450
|
+
let buffer = ""
|
|
451
|
+
|
|
452
|
+
compatibilitySockets.add(candidateSocket)
|
|
453
|
+
compatibilitySockets.add(guardianSocket)
|
|
454
|
+
candidateSocket.setEncoding("utf8")
|
|
455
|
+
candidateSocket.once("close", () => {
|
|
456
|
+
compatibilitySockets.delete(candidateSocket)
|
|
457
|
+
guardianSocket.destroy()
|
|
458
|
+
})
|
|
459
|
+
guardianSocket.once("close", () => {
|
|
460
|
+
compatibilitySockets.delete(guardianSocket)
|
|
461
|
+
candidateSocket.destroy()
|
|
462
|
+
})
|
|
463
|
+
guardianSocket.on("data", (chunk) => candidateSocket.write(chunk))
|
|
464
|
+
candidateSocket.on("data", (chunk) => {
|
|
465
|
+
buffer += chunk
|
|
466
|
+
let newline = buffer.indexOf("\n")
|
|
467
|
+
|
|
468
|
+
while (newline >= 0) {
|
|
469
|
+
const line = buffer.slice(0, newline)
|
|
470
|
+
const request = JSON.parse(line)
|
|
471
|
+
|
|
472
|
+
buffer = buffer.slice(newline + 1)
|
|
473
|
+
if (request.command === "register") candidateRecoveredKeys.add(request.key)
|
|
474
|
+
if (request.command === "commit-retired-owner-replacement") {
|
|
475
|
+
committedProcessKey = request.key
|
|
476
|
+
recoveredKeysAtCommit = new Set(candidateRecoveredKeys)
|
|
477
|
+
if (candidateRecoveredKeys.has(request.key)) {
|
|
478
|
+
candidateSocket.write(`${JSON.stringify({error: `Guardian ${request.command} requires the committed owner`, id: request.id})}\n`)
|
|
479
|
+
newline = buffer.indexOf("\n")
|
|
480
|
+
continue
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
guardianSocket.write(`${line}\n`)
|
|
484
|
+
newline = buffer.indexOf("\n")
|
|
485
|
+
}
|
|
486
|
+
})
|
|
487
|
+
})
|
|
488
|
+
await listenUnix(compatibilityGuardian, compatibilitySocketPath)
|
|
489
|
+
state.recovery.guardian.socketPath = compatibilitySocketPath
|
|
490
|
+
await fs.writeFile(statePath, `${JSON.stringify(state)}\n`)
|
|
430
491
|
|
|
431
|
-
assert.deepEqual(processState?.map(({state}) => state), ["quiesced", "quiesced"])
|
|
432
492
|
assert.equal((await owner.guardian?.replacementStatus())?.ownerClaimed, true)
|
|
433
493
|
await assert.rejects(fs.access(socketPath), {code: "ENOENT"})
|
|
434
494
|
|
|
@@ -455,14 +515,33 @@ test("same-authority replacement commits with committed-owner proof after the in
|
|
|
455
515
|
|
|
456
516
|
assert.equal(recovered.activeReleaseId, "v1")
|
|
457
517
|
assert.equal(committedProcessKey, committedOwnerProcessKey)
|
|
518
|
+
assert.equal(recoveredKeysAtCommit?.has(committedOwnerProcessKey), false)
|
|
519
|
+
assert.deepEqual(candidateRecoveredKeys, expectedProcessKeys)
|
|
520
|
+
assert.equal([...replacement.guardian?.processes.keys() || []][0], candidateProcessKey)
|
|
521
|
+
assert.equal(retainedConnectionClosed, false, "successful compatibility handoff must preserve retained connections")
|
|
522
|
+
assert.equal(retainedConnection.destroyed, false, "successful compatibility handoff must leave the retained listener serving")
|
|
458
523
|
assert.deepEqual(recovered.releaseReferences, [{releaseId: "v1", releasePath}])
|
|
459
524
|
assert.deepEqual(recoveredReleases[0]?.processes.map(({id, pid, state}) => ({id, pid, state})), processState)
|
|
525
|
+
for (const {pid} of processState || []) {
|
|
526
|
+
if (typeof pid !== "number") throw new Error("Retained process is missing its PID")
|
|
527
|
+
assert.doesNotThrow(() => process.kill(pid, 0))
|
|
528
|
+
}
|
|
460
529
|
} finally {
|
|
530
|
+
if (retainedGuardianSocketPath) {
|
|
531
|
+
const cleanupState = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
532
|
+
|
|
533
|
+
cleanupState.recovery.guardian.socketPath = retainedGuardianSocketPath
|
|
534
|
+
await fs.writeFile(statePath, `${JSON.stringify(cleanupState)}\n`)
|
|
535
|
+
}
|
|
461
536
|
const shutdown = replacement?.controlCommandsReady ? replacement.shutdown().catch(() => {}) : owner.shutdown().catch(() => {})
|
|
462
537
|
|
|
538
|
+
retainedConnection?.destroy()
|
|
539
|
+
await owner.closeServer(owner.proxyServer)
|
|
463
540
|
await Promise.all([fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n").catch(() => {}), shutdown])
|
|
464
541
|
owner.guardian?.disconnect()
|
|
465
542
|
replacement?.guardian?.disconnect()
|
|
543
|
+
for (const socket of compatibilitySockets) socket.destroy()
|
|
544
|
+
if (compatibilityGuardian?.listening) await closeServer(compatibilityGuardian)
|
|
466
545
|
await stopGuardian(statePath)
|
|
467
546
|
await fs.rm(root, {force: true, recursive: true})
|
|
468
547
|
}
|