rollbridge 0.1.37 → 0.1.38
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/package.json +1 -1
- package/src/daemon.js +28 -6
- package/test/owner-replacement.test.js +83 -1
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -340,7 +340,7 @@ export default class RollbridgeDaemon {
|
|
|
340
340
|
if (!transfer?.config || !transfer.snapshot) throw new Error("Committed owner published incomplete replacement state")
|
|
341
341
|
const registeredProcesses = new Map((await this.guardian.inventory()).map(({key, provenance}) => [key, provenance]))
|
|
342
342
|
|
|
343
|
-
reservedProcessKey = legacyBridge ? undefined :
|
|
343
|
+
reservedProcessKey = legacyBridge ? undefined : reconstructableOwnerSnapshotProcessKeys(transfer.snapshot, transfer.singletonReleaseIds)
|
|
344
344
|
.find((key) => registeredProcesses.has(key))
|
|
345
345
|
if (reservedProcessKey) this.guardian.reserveProcessRecovery(reservedProcessKey, /** @type {string} */ (registeredProcesses.get(reservedProcessKey)))
|
|
346
346
|
await this.restoreOwnerState(transfer.snapshot, {config: transfer.config, releaseConfigs: transfer.releaseConfigs, resumeDrains: false, singletonReleaseIds: transfer.singletonReleaseIds, synchronizeLifecycleRoles: false})
|
|
@@ -547,13 +547,14 @@ export default class RollbridgeDaemon {
|
|
|
547
547
|
if (!releaseId || !connections || typeof connections !== "object" || Array.isArray(connections)) {
|
|
548
548
|
throw new Error("Incumbent listener sent invalid connection state")
|
|
549
549
|
}
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
if (!release) throw new Error(`Incumbent listener reported unknown release ${releaseId}`)
|
|
553
|
-
release.setTransferredConnections({
|
|
550
|
+
const transferredConnections = {
|
|
554
551
|
http: requiredNonNegativeInteger(connections.http, "connections.http"),
|
|
555
552
|
websocket: requiredNonNegativeInteger(connections.websocket, "connections.websocket")
|
|
556
|
-
}
|
|
553
|
+
}
|
|
554
|
+
const release = this.releases.get(releaseId)
|
|
555
|
+
|
|
556
|
+
if (!release && (transferredConnections.http > 0 || transferredConnections.websocket > 0)) throw new Error(`Incumbent listener reported unknown release ${releaseId}`)
|
|
557
|
+
if (release) release.setTransferredConnections(transferredConnections)
|
|
557
558
|
if (this.incumbentListenerControl === session && ![...this.releases.values()].some((candidate) => candidate.hasTransferredConnections())) {
|
|
558
559
|
this.incumbentListenerControl = undefined
|
|
559
560
|
session.close()
|
|
@@ -2121,6 +2122,27 @@ function ownerSnapshotProcessKeys(snapshot, singletonReleaseIds = {}) {
|
|
|
2121
2122
|
return keys
|
|
2122
2123
|
}
|
|
2123
2124
|
|
|
2125
|
+
/**
|
|
2126
|
+
* Selects only committed guardian registrations that restoreOwnerState will reconstruct.
|
|
2127
|
+
* @param {OwnerRecoverySnapshot} snapshot - Serialized owner process snapshot.
|
|
2128
|
+
* @param {Record<string, string>} [singletonReleaseIds] - Exact singleton owner releases.
|
|
2129
|
+
* @returns {string[]} Exact reconstructable guardian registration keys.
|
|
2130
|
+
*/
|
|
2131
|
+
function reconstructableOwnerSnapshotProcessKeys(snapshot, singletonReleaseIds = {}) {
|
|
2132
|
+
const singletonOwnerReleaseIds = new Set(Object.values(singletonReleaseIds))
|
|
2133
|
+
const releaseProcessKeys = new Set()
|
|
2134
|
+
|
|
2135
|
+
for (const release of snapshot.releases) {
|
|
2136
|
+
const transitionCandidate = snapshot.generationTransition?.candidateReleaseId === release.releaseId && snapshot.generationTransition.phase !== "committed"
|
|
2137
|
+
const singletonOwner = singletonOwnerReleaseIds.has(release.releaseId)
|
|
2138
|
+
|
|
2139
|
+
if (release.state !== "active" && release.state !== "draining" && !transitionCandidate && !singletonOwner) continue
|
|
2140
|
+
for (const processStatus of release.processes) releaseProcessKeys.add(`release:${release.releaseId}:${processStatus.id}`)
|
|
2141
|
+
}
|
|
2142
|
+
return ownerSnapshotProcessKeys(snapshot, singletonReleaseIds)
|
|
2143
|
+
.filter((key) => !key.startsWith("release:") || releaseProcessKeys.has(key))
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2124
2146
|
/**
|
|
2125
2147
|
* Canonicalizes the authenticated guardian inventory without relying on map insertion order.
|
|
2126
2148
|
* @param {{key: string, provenance: string}[]} inventory - Guardian inventory response.
|
|
@@ -10,7 +10,7 @@ import path from "node:path"
|
|
|
10
10
|
import test from "node:test"
|
|
11
11
|
import {fileURLToPath} from "node:url"
|
|
12
12
|
import {normalizeConfig} from "../src/config.js"
|
|
13
|
-
import {sendControlCommand} from "../src/control-client.js"
|
|
13
|
+
import {openControlSession, sendControlCommand} from "../src/control-client.js"
|
|
14
14
|
import RollbridgeDaemon, {isLegacyGuardianPrepareDiagnostic} from "../src/daemon.js"
|
|
15
15
|
import GuardianClient from "../src/guardian-client.js"
|
|
16
16
|
import {findAvailablePort} from "../src/port-allocator.js"
|
|
@@ -1188,6 +1188,88 @@ test("owner replacement preserves committed generation metadata without firing l
|
|
|
1188
1188
|
}
|
|
1189
1189
|
})
|
|
1190
1190
|
|
|
1191
|
+
test("owner replacement excludes stopped retained releases from reserved process recovery", async () => {
|
|
1192
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-stopped-proof-"))
|
|
1193
|
+
const oldSocketPath = path.join(root, "old.sock")
|
|
1194
|
+
const newSocketPath = path.join(root, "new.sock")
|
|
1195
|
+
const statePath = path.join(root, "state.json")
|
|
1196
|
+
const configPath = path.join(root, "rollbridge.cjs")
|
|
1197
|
+
const v1Path = path.join(root, "v1")
|
|
1198
|
+
const v2Path = path.join(root, "v2")
|
|
1199
|
+
let owner
|
|
1200
|
+
let candidate
|
|
1201
|
+
|
|
1202
|
+
try {
|
|
1203
|
+
await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
|
|
1204
|
+
await Promise.all([makeFifo(path.join(v1Path, "worker.fifo")), makeFifo(path.join(v2Path, "worker.fifo"))])
|
|
1205
|
+
await writeConfig(configPath, config({controlPath: oldSocketPath, extraCompanion: false, statePath}))
|
|
1206
|
+
owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1207
|
+
await waitForLog(owner, "control socket listening")
|
|
1208
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldSocketPath})
|
|
1209
|
+
const stopped = sendControlCommand({command: {command: "stop", releaseId: "v1"}, path: oldSocketPath})
|
|
1210
|
+
|
|
1211
|
+
await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
|
|
1212
|
+
await stopped
|
|
1213
|
+
await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: oldSocketPath})
|
|
1214
|
+
const before = await sendControlCommand({command: {command: "status"}, path: oldSocketPath})
|
|
1215
|
+
const activeWorkerPid = releaseProcessPid(before, "v2", "worker")
|
|
1216
|
+
const retained = /** @type {{releaseId: string, state: string}[]} */ (before.releases)
|
|
1217
|
+
|
|
1218
|
+
assert.equal(retained.find(({releaseId}) => releaseId === "v1")?.state, "stopped")
|
|
1219
|
+
const persisted = JSON.parse(await fs.readFile(statePath, "utf8"))
|
|
1220
|
+
const guardian = new GuardianClient(persisted.recovery.guardian)
|
|
1221
|
+
|
|
1222
|
+
await guardian.connect()
|
|
1223
|
+
assert.ok((await guardian.inventory()).some(({key}) => key === "release:v1:worker"), "stopped release registration remains in authenticated guardian inventory")
|
|
1224
|
+
guardian.disconnect()
|
|
1225
|
+
await writeConfig(configPath, config({controlPath: newSocketPath, extraCompanion: true, statePath}))
|
|
1226
|
+
candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
1227
|
+
const output = await collectUntilExitOrLog(candidate, "owner replacement committed")
|
|
1228
|
+
|
|
1229
|
+
assert.equal(output.message, "owner replacement committed", output.output)
|
|
1230
|
+
const recovered = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
|
|
1231
|
+
|
|
1232
|
+
assert.equal(recovered.activeReleaseId, "v2")
|
|
1233
|
+
assert.equal(releaseProcessPid(recovered, "v2", "worker"), activeWorkerPid)
|
|
1234
|
+
assert.equal(/** @type {{releaseId: string}[]} */ (recovered.releases).some(({releaseId}) => releaseId === "v1"), false)
|
|
1235
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
|
|
1236
|
+
|
|
1237
|
+
await fs.writeFile(path.join(v2Path, "worker.fifo"), "drained\n")
|
|
1238
|
+
await shutdown
|
|
1239
|
+
} finally {
|
|
1240
|
+
for (const child of [owner, candidate]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
|
|
1241
|
+
await stopGuardian(statePath)
|
|
1242
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
1243
|
+
}
|
|
1244
|
+
})
|
|
1245
|
+
|
|
1246
|
+
test("pruned release connection completion closes the incumbent listener session", () => {
|
|
1247
|
+
const daemon = new RollbridgeDaemon({
|
|
1248
|
+
config: normalizeConfig(config({controlPath: "/unused/control.sock", extraCompanion: false, statePath: "/unused/state.json"})),
|
|
1249
|
+
logger: () => {}
|
|
1250
|
+
})
|
|
1251
|
+
let closeCount = 0
|
|
1252
|
+
const session = {close: () => { closeCount += 1 }}
|
|
1253
|
+
const controlSession = /** @type {Awaited<ReturnType<typeof openControlSession>>} */ (session)
|
|
1254
|
+
|
|
1255
|
+
daemon.releases = /** @type {Map<string, import("../src/release-group.js").default>} */ (new Map([
|
|
1256
|
+
["active", /** @type {import("../src/release-group.js").default} */ ({hasTransferredConnections: () => false})]
|
|
1257
|
+
]))
|
|
1258
|
+
daemon.incumbentListenerControl = controlSession
|
|
1259
|
+
daemon.handleIncumbentListenerEvent({connections: {http: 0, websocket: 0}, event: "owner-connection-state", releaseId: "pruned"}, session)
|
|
1260
|
+
|
|
1261
|
+
assert.equal(closeCount, 1)
|
|
1262
|
+
assert.equal(daemon.incumbentListenerControl, undefined)
|
|
1263
|
+
|
|
1264
|
+
daemon.incumbentListenerControl = controlSession
|
|
1265
|
+
assert.throws(
|
|
1266
|
+
() => daemon.handleIncumbentListenerEvent({connections: {http: 1, websocket: 0}, event: "owner-connection-state", releaseId: "pruned"}, session),
|
|
1267
|
+
/unknown release pruned/
|
|
1268
|
+
)
|
|
1269
|
+
assert.equal(closeCount, 1)
|
|
1270
|
+
assert.equal(daemon.incumbentListenerControl, session)
|
|
1271
|
+
})
|
|
1272
|
+
|
|
1191
1273
|
test("owner replacement preserves a failed generation transition without retrying its hook", async () => {
|
|
1192
1274
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-failed-generation-"))
|
|
1193
1275
|
const oldSocketPath = path.join(root, "old.sock")
|