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
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs/promises"
|
|
4
|
+
import net from "node:net"
|
|
5
|
+
import crypto from "node:crypto"
|
|
6
|
+
import {isDeepStrictEqual} from "node:util"
|
|
7
|
+
import GuardianClient from "./guardian-client.js"
|
|
8
|
+
import ManagedProcess from "./managed-process.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} GuardianRequest
|
|
12
|
+
* @property {string} command - Operation name.
|
|
13
|
+
* @property {ConstructorParameters<typeof ManagedProcess>[0]} [definition] - Managed process definition.
|
|
14
|
+
* @property {number} [graceMs] - Owner reconnection grace.
|
|
15
|
+
* @property {number} id - Request id.
|
|
16
|
+
* @property {string} [key] - Stable process key.
|
|
17
|
+
* @property {{timeoutMs?: number}} [options] - Stop options.
|
|
18
|
+
* @property {string} [previousProvenance] - Expected current provenance.
|
|
19
|
+
* @property {string} [provenance] - New or registered provenance.
|
|
20
|
+
* @property {import("./json.js").JsonValue} [ownerState] - Private owner transfer state.
|
|
21
|
+
* @property {string} [replacementId] - Prepared replacement transaction id.
|
|
22
|
+
* @property {string} [mutationId] - Owner mutation lease id.
|
|
23
|
+
* @property {string} [operation] - Owner mutation diagnostic name.
|
|
24
|
+
* @property {import("./json.js").JsonValue} [authority] - Expected current authority.
|
|
25
|
+
* @property {import("./json.js").JsonValue} [nextAuthority] - Requested replacement authority.
|
|
26
|
+
* @property {import("./managed-process.js").ManagedProcessStartReason} [reason] - Start reason.
|
|
27
|
+
* @property {string} token - Authentication token.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const [socketPath] = process.argv.slice(2)
|
|
31
|
+
|
|
32
|
+
if (!socketPath || !process.send) throw new Error("process-guardian requires socket path and a private bootstrap channel")
|
|
33
|
+
|
|
34
|
+
const bootstrap = await new Promise((resolve, reject) => {
|
|
35
|
+
process.once("disconnect", () => reject(new Error("Guardian bootstrap channel closed before authentication capability arrived")))
|
|
36
|
+
process.once("message", (message) => {
|
|
37
|
+
if (!message || typeof message !== "object" || !("token" in message) || typeof message.token !== "string" || !message.token) {
|
|
38
|
+
reject(new Error("Guardian bootstrap authentication capability is invalid"))
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
resolve(message)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
const token = bootstrap.token
|
|
45
|
+
const legacyGuardian = bootstrap.legacyGuardian ? new GuardianClient(bootstrap.legacyGuardian) : undefined
|
|
46
|
+
|
|
47
|
+
if (legacyGuardian) await legacyGuardian.connect()
|
|
48
|
+
|
|
49
|
+
/** @type {Map<string, {desired: boolean, process: ManagedProcess, provenance: string}>} */
|
|
50
|
+
const processes = new Map()
|
|
51
|
+
/** @type {Set<net.Socket>} */
|
|
52
|
+
const clients = new Set()
|
|
53
|
+
/** @type {net.Socket | undefined} */
|
|
54
|
+
let ownerClient
|
|
55
|
+
/** @type {net.Socket | undefined} */
|
|
56
|
+
let replacementClient
|
|
57
|
+
/** @type {string | undefined} */
|
|
58
|
+
let replacementId
|
|
59
|
+
/** @type {string | undefined} */
|
|
60
|
+
let committedReplacementId
|
|
61
|
+
/** @type {import("./json.js").JsonValue | undefined} */
|
|
62
|
+
let replacementAuthority
|
|
63
|
+
/** @type {import("./json.js").JsonValue | undefined} */
|
|
64
|
+
let replacementOwnerState
|
|
65
|
+
/** @type {import("./json.js").JsonValue | undefined} */
|
|
66
|
+
let ownerState = bootstrap.ownerState
|
|
67
|
+
let ownerRevision = ownerState === undefined ? 0 : 1
|
|
68
|
+
/** @type {number | undefined} */
|
|
69
|
+
let replacementRevision
|
|
70
|
+
const legacyKeys = legacyGuardian ? legacyOwnerKeys(ownerState) : new Set()
|
|
71
|
+
/** @type {net.Socket | undefined} */
|
|
72
|
+
let ownerMutationClient
|
|
73
|
+
/** @type {string | undefined} */
|
|
74
|
+
let ownerMutationId
|
|
75
|
+
/** @type {net.Socket | undefined} */
|
|
76
|
+
let retiringClient
|
|
77
|
+
/** @type {string | undefined} */
|
|
78
|
+
let retiringReplacementId
|
|
79
|
+
let shuttingDown = false
|
|
80
|
+
/** @type {net.Socket | undefined} */
|
|
81
|
+
let shutdownClient
|
|
82
|
+
let shutdownFinalizing = false
|
|
83
|
+
/** @type {Promise<void> | undefined} */
|
|
84
|
+
let serverClosed
|
|
85
|
+
/** @type {{reject: (error: Error) => void, resolve: (value: {claimed: boolean}) => void, socket: net.Socket, timer: ReturnType<typeof setTimeout>}[]} */
|
|
86
|
+
const claimWaiters = []
|
|
87
|
+
|
|
88
|
+
const server = net.createServer((socket) => {
|
|
89
|
+
clients.add(socket)
|
|
90
|
+
socket.setEncoding("utf8")
|
|
91
|
+
// An abruptly killed daemon can reset its private guardian connection. Keep the
|
|
92
|
+
// durable guardian alive; the close handler below releases only that owner claim.
|
|
93
|
+
socket.on("error", () => socket.destroy())
|
|
94
|
+
let buffer = ""
|
|
95
|
+
|
|
96
|
+
socket.once("close", () => {
|
|
97
|
+
clients.delete(socket)
|
|
98
|
+
const waiterIndex = claimWaiters.findIndex((waiter) => waiter.socket === socket)
|
|
99
|
+
|
|
100
|
+
if (waiterIndex >= 0) {
|
|
101
|
+
const [waiter] = claimWaiters.splice(waiterIndex, 1)
|
|
102
|
+
clearTimeout(waiter.timer)
|
|
103
|
+
waiter.reject(new Error("Owner claimant disconnected"))
|
|
104
|
+
}
|
|
105
|
+
if (ownerClient === socket) {
|
|
106
|
+
ownerClient = undefined
|
|
107
|
+
if (replacementClient && replacementOwnerState) commitReplacement()
|
|
108
|
+
else {
|
|
109
|
+
if (replacementClient) abortReplacement("Committed owner disconnected before replacement candidate was ready")
|
|
110
|
+
if (!shuttingDown) grantNextOwner()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (ownerMutationClient === socket) {
|
|
114
|
+
ownerMutationClient = undefined
|
|
115
|
+
ownerMutationId = undefined
|
|
116
|
+
}
|
|
117
|
+
if (retiringClient === socket) finalizeReplacementRetirement()
|
|
118
|
+
if (replacementClient === socket) {
|
|
119
|
+
replacementClient = undefined
|
|
120
|
+
replacementId = undefined
|
|
121
|
+
replacementAuthority = undefined
|
|
122
|
+
replacementOwnerState = undefined
|
|
123
|
+
if (ownerClient && !ownerClient.destroyed) ownerClient.write(`${JSON.stringify({event: "replacement-aborted"})}\n`)
|
|
124
|
+
}
|
|
125
|
+
if (shutdownClient === socket) void finishShutdown()
|
|
126
|
+
})
|
|
127
|
+
socket.on("data", (chunk) => {
|
|
128
|
+
buffer += chunk
|
|
129
|
+
let newline = buffer.indexOf("\n")
|
|
130
|
+
|
|
131
|
+
while (newline >= 0) {
|
|
132
|
+
const line = buffer.slice(0, newline)
|
|
133
|
+
|
|
134
|
+
buffer = buffer.slice(newline + 1)
|
|
135
|
+
void handleLine(socket, line)
|
|
136
|
+
newline = buffer.indexOf("\n")
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
server.on("error", (error) => {
|
|
142
|
+
if (process.send) process.send({error: error.message})
|
|
143
|
+
else throw error
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
server.listen(socketPath, async () => {
|
|
147
|
+
await fs.chmod(socketPath, 0o600)
|
|
148
|
+
if (process.send) {
|
|
149
|
+
process.send({ready: true})
|
|
150
|
+
process.disconnect?.()
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {net.Socket} socket - Client.
|
|
156
|
+
* @param {string} line - JSON request.
|
|
157
|
+
* @returns {Promise<void>} Request completion.
|
|
158
|
+
*/
|
|
159
|
+
async function handleLine(socket, line) {
|
|
160
|
+
/** @type {GuardianRequest | undefined} */
|
|
161
|
+
let request
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
request = /** @type {GuardianRequest} */ (JSON.parse(line))
|
|
165
|
+
if (request.token !== token) throw new Error("Guardian authentication failed")
|
|
166
|
+
const result = await execute(request, socket)
|
|
167
|
+
|
|
168
|
+
socket.write(`${JSON.stringify({id: request.id, result})}\n`)
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (!socket.destroyed) socket.write(`${JSON.stringify({error: error instanceof Error ? error.message : String(error), id: request?.id})}\n`)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {GuardianRequest} request - Authenticated request.
|
|
176
|
+
* @param {net.Socket} socket - Requesting client.
|
|
177
|
+
* @returns {Promise<import("./json.js").JsonValue>} Command result.
|
|
178
|
+
*/
|
|
179
|
+
async function execute(request, socket) {
|
|
180
|
+
if (shuttingDown) throw new Error("Process guardian is shutting down")
|
|
181
|
+
|
|
182
|
+
if (request.command === "claim-owner") {
|
|
183
|
+
if (!ownerClient) {
|
|
184
|
+
if (ownerState !== undefined && !isDeepStrictEqual(request.authority, ownerAuthority(ownerState))) {
|
|
185
|
+
throw new Error("Owner recovery authority does not match the guardian's committed authority")
|
|
186
|
+
}
|
|
187
|
+
ownerClient = socket
|
|
188
|
+
return {claimed: true}
|
|
189
|
+
}
|
|
190
|
+
if (ownerClient === socket) return {claimed: true}
|
|
191
|
+
|
|
192
|
+
return await new Promise((resolve, reject) => {
|
|
193
|
+
const timer = setTimeout(() => {
|
|
194
|
+
const index = claimWaiters.findIndex((waiter) => waiter.socket === socket)
|
|
195
|
+
if (index >= 0) claimWaiters.splice(index, 1)
|
|
196
|
+
reject(new Error("Durable owner is already claimed by another matching daemon"))
|
|
197
|
+
}, request.graceMs ?? 30000)
|
|
198
|
+
|
|
199
|
+
claimWaiters.push({reject, resolve, socket, timer})
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (request.command === "retire-owner") {
|
|
204
|
+
requireOwner(socket, request.command)
|
|
205
|
+
if (replacementClient) throw new Error("Committed owner cannot retire while an owner replacement is prepared")
|
|
206
|
+
for (const entry of processes.values()) entry.desired = false
|
|
207
|
+
void Promise.allSettled([...processes.values()].map((entry) => entry.process.stop()))
|
|
208
|
+
ownerClient = undefined
|
|
209
|
+
ownerMutationClient = undefined
|
|
210
|
+
ownerMutationId = undefined
|
|
211
|
+
ownerRevision += 1
|
|
212
|
+
grantNextOwner()
|
|
213
|
+
return {retired: true}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (request.command === "abandon-legacy-upgrade") {
|
|
217
|
+
if (!legacyGuardian) throw new Error("Guardian is not a legacy upgrade coordinator")
|
|
218
|
+
if (ownerClient || committedReplacementId) throw new Error("Committed guardian authority cannot abandon its legacy backend")
|
|
219
|
+
if (replacementClient && replacementClient !== socket) throw new Error("Only the prepared replacement can abandon the legacy upgrade")
|
|
220
|
+
if (replacementClient) abortReplacement("Legacy guardian upgrade candidate abandoned before the disruptive boundary")
|
|
221
|
+
legacyGuardian.disconnect()
|
|
222
|
+
shuttingDown = true
|
|
223
|
+
beginSuccessfulShutdown(socket)
|
|
224
|
+
return {abandoned: true}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (request.command === "publish-owner-state") {
|
|
228
|
+
requireOwner(socket, request.command)
|
|
229
|
+
if (request.ownerState === undefined) throw new Error("Guardian owner publication requires ownerState")
|
|
230
|
+
ownerState = request.ownerState
|
|
231
|
+
ownerRevision += 1
|
|
232
|
+
committedReplacementId = undefined
|
|
233
|
+
return {published: true}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (request.command === "owner-state") {
|
|
237
|
+
requireOwner(socket, request.command)
|
|
238
|
+
if (!ownerState) throw new Error("Committed owner has not published transferable state")
|
|
239
|
+
return {ownerState}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (request.command === "replacement-status") {
|
|
243
|
+
return {committedReplacementId: committedReplacementId ?? null, ownerClaimed: Boolean(ownerClient), retirementPending: Boolean(retiringClient)}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (request.command === "begin-owner-mutation") {
|
|
247
|
+
requireOwner(socket, request.command)
|
|
248
|
+
if (replacementClient) throw new Error(`Owner mutation ${request.operation || "operation"} is fenced while an owner replacement is prepared`)
|
|
249
|
+
if (ownerMutationClient) throw new Error("Another owner mutation is already in progress")
|
|
250
|
+
ownerMutationClient = socket
|
|
251
|
+
ownerMutationId = crypto.randomUUID()
|
|
252
|
+
return {mutationId: ownerMutationId}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (request.command === "end-owner-mutation") {
|
|
256
|
+
requireOwner(socket, request.command)
|
|
257
|
+
if (ownerMutationClient !== socket || request.mutationId !== ownerMutationId) throw new Error("Owner mutation lease does not match the active mutation")
|
|
258
|
+
ownerMutationClient = undefined
|
|
259
|
+
ownerMutationId = undefined
|
|
260
|
+
return {ended: true}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (request.command === "prepare-owner-replacement") {
|
|
264
|
+
if (socket === ownerClient) throw new Error("Committed owner cannot prepare itself as a replacement")
|
|
265
|
+
if (replacementClient && replacementClient !== socket) throw new Error("Another owner replacement candidate is already prepared")
|
|
266
|
+
if (ownerMutationClient) throw new Error("Owner replacement cannot prepare while an owner mutation is in progress")
|
|
267
|
+
if (!ownerState) throw new Error("Committed owner has not published transferable state")
|
|
268
|
+
const currentAuthority = ownerAuthority(ownerState)
|
|
269
|
+
const resumesCommittedAuthority = !ownerClient && isDeepStrictEqual(currentAuthority, request.nextAuthority)
|
|
270
|
+
|
|
271
|
+
if (!resumesCommittedAuthority && !isDeepStrictEqual(currentAuthority, request.authority)) {
|
|
272
|
+
throw new Error("Owner replacement authority fence does not match the guardian's committed authority")
|
|
273
|
+
}
|
|
274
|
+
if (request.nextAuthority === undefined) throw new Error("Owner replacement requires the requested authority")
|
|
275
|
+
if (!replacementId) replacementId = crypto.randomUUID()
|
|
276
|
+
replacementClient = socket
|
|
277
|
+
replacementAuthority = request.nextAuthority
|
|
278
|
+
replacementRevision = ownerRevision
|
|
279
|
+
if (ownerClient && !ownerClient.destroyed) ownerClient.write(`${JSON.stringify({event: "replacement-prepared", replacementId})}\n`)
|
|
280
|
+
return {ownerState, replacementId}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (request.command === "stage-owner-replacement") {
|
|
284
|
+
requireReplacement(socket, request)
|
|
285
|
+
if (replacementRevision !== ownerRevision) throw new Error("Guardian owner state changed after prepare; abort the stale candidate before preparing again")
|
|
286
|
+
if (request.ownerState === undefined || !isDeepStrictEqual(ownerAuthority(request.ownerState), replacementAuthority)) {
|
|
287
|
+
throw new Error("Prepared replacement state does not match the requested authority")
|
|
288
|
+
}
|
|
289
|
+
replacementOwnerState = request.ownerState
|
|
290
|
+
if (!ownerClient) commitReplacement()
|
|
291
|
+
return {committed: ownerClient === socket}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (request.command === "abort-owner-replacement") {
|
|
295
|
+
requireReplacement(socket, request)
|
|
296
|
+
abortReplacement("Replacement candidate aborted the prepared transaction")
|
|
297
|
+
return {aborted: true}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (request.command === "commit-owner-replacement") {
|
|
301
|
+
requireOwner(socket, request.command)
|
|
302
|
+
if (!replacementClient || request.replacementId !== replacementId || !replacementOwnerState) throw new Error("Owner replacement transaction is not the prepared ready candidate")
|
|
303
|
+
commitReplacement()
|
|
304
|
+
return {committed: true}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (request.command === "validate-owner-replacement") {
|
|
308
|
+
requireOwner(socket, request.command)
|
|
309
|
+
if (!replacementClient || request.replacementId !== replacementId) throw new Error("Owner replacement transaction is not the prepared candidate")
|
|
310
|
+
return {valid: true}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (request.command === "finalize-owner-replacement") {
|
|
314
|
+
if (retiringClient !== socket || request.replacementId !== retiringReplacementId) throw new Error("Owner replacement retirement finalization requires the committed incumbent transaction")
|
|
315
|
+
finalizeReplacementRetirement()
|
|
316
|
+
return {finalized: true}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (request.command === "shutdown") {
|
|
320
|
+
requireOwner(socket, request.command)
|
|
321
|
+
shuttingDown = true
|
|
322
|
+
try {
|
|
323
|
+
const results = await Promise.allSettled([...processes.values()].map((entry) => entry.process.stop()))
|
|
324
|
+
const errors = results.filter((result) => result.status === "rejected").map((result) => result.reason)
|
|
325
|
+
|
|
326
|
+
if (errors.length > 0) throw new AggregateError(errors, `Guardian failed to stop ${errors.length} owned process${errors.length === 1 ? "" : "es"}: ${errors.map((error) => errorMessage(error instanceof Error ? error : String(error))).join("; ")}`)
|
|
327
|
+
if (legacyGuardian) await legacyGuardian.shutdown()
|
|
328
|
+
beginSuccessfulShutdown(socket)
|
|
329
|
+
return {stopped: true}
|
|
330
|
+
} catch (error) {
|
|
331
|
+
shutdownClient = undefined
|
|
332
|
+
shuttingDown = false
|
|
333
|
+
throw error
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (request.command === "inventory") {
|
|
338
|
+
return [...processes.entries()].map(([key, entry]) => ({key, provenance: entry.provenance, status: entry.process.status()}))
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (request.command === "remove") {
|
|
342
|
+
requireOwner(socket, request.command)
|
|
343
|
+
if (!request.key || !request.provenance) throw new Error("Guardian remove requires key and provenance")
|
|
344
|
+
const existing = processes.get(request.key)
|
|
345
|
+
|
|
346
|
+
if (!existing) throw new Error(`Guardian process ${request.key} is not registered`)
|
|
347
|
+
if (existing.provenance !== request.provenance) throw new Error(`Guardian provenance mismatch for ${request.key}`)
|
|
348
|
+
existing.desired = false
|
|
349
|
+
await existing.process.stop()
|
|
350
|
+
processes.delete(request.key)
|
|
351
|
+
ownerRevision += 1
|
|
352
|
+
return {removed: true}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (request.command === "register") {
|
|
356
|
+
if (!request.key || !request.definition || !request.provenance) throw new Error("Guardian register requires key, definition, and provenance")
|
|
357
|
+
const existing = processes.get(request.key)
|
|
358
|
+
|
|
359
|
+
if (existing) {
|
|
360
|
+
if (existing.provenance !== request.provenance) throw new Error(`Guardian provenance mismatch for ${request.key}`)
|
|
361
|
+
return existing.process.status()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const recoversLegacyProcess = Boolean(legacyGuardian && legacyKeys.has(request.key))
|
|
365
|
+
|
|
366
|
+
if (!recoversLegacyProcess) requireOwner(socket, request.command)
|
|
367
|
+
|
|
368
|
+
const record = /** @type {{desired: boolean, process?: ManagedProcess, provenance: string}} */ ({desired: true, provenance: request.provenance})
|
|
369
|
+
const definition = request.definition
|
|
370
|
+
const managedDefinition = {
|
|
371
|
+
...definition,
|
|
372
|
+
logger: (/** @type {string} */ message, /** @type {Record<string, import("./json.js").JsonValue>} */ data = {}) => {
|
|
373
|
+
ownerRevision += 1
|
|
374
|
+
broadcast({event: "process", key: request.key, message, data, status: managedProcess.status()})
|
|
375
|
+
},
|
|
376
|
+
shouldRestart: () => record.desired
|
|
377
|
+
}
|
|
378
|
+
const managedProcess = legacyGuardian
|
|
379
|
+
? legacyGuardian.process(request.key, managedDefinition)
|
|
380
|
+
: new ManagedProcess(managedDefinition)
|
|
381
|
+
|
|
382
|
+
if (recoversLegacyProcess && "recover" in managedProcess && typeof managedProcess.recover === "function") await managedProcess.recover()
|
|
383
|
+
|
|
384
|
+
record.process = managedProcess
|
|
385
|
+
processes.set(request.key, /** @type {{desired: boolean, process: ManagedProcess, provenance: string}} */ (record))
|
|
386
|
+
if (!recoversLegacyProcess) ownerRevision += 1
|
|
387
|
+
return managedProcess.status()
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (!request.key) throw new Error(`Guardian ${request.command} requires a process key`)
|
|
391
|
+
const record = processes.get(request.key)
|
|
392
|
+
|
|
393
|
+
if (!record) throw new Error(`Guardian process ${request.key} is not registered`)
|
|
394
|
+
|
|
395
|
+
if (request.command !== "status") requireOwner(socket, request.command)
|
|
396
|
+
|
|
397
|
+
if (request.command === "start") {
|
|
398
|
+
record.desired = true
|
|
399
|
+
await record.process.start(request.reason)
|
|
400
|
+
} else if (request.command === "quiesce") {
|
|
401
|
+
record.desired = false
|
|
402
|
+
await record.process.quiesceStrict()
|
|
403
|
+
} else if (request.command === "stop") {
|
|
404
|
+
record.desired = false
|
|
405
|
+
await record.process.stop(request.options)
|
|
406
|
+
} else if (request.command === "update") {
|
|
407
|
+
if (!request.definition || !request.provenance) throw new Error("Guardian update requires definition and provenance")
|
|
408
|
+
if (record.provenance !== request.previousProvenance) throw new Error(`Guardian provenance mismatch for ${request.key}`)
|
|
409
|
+
record.process.updateDefinition({
|
|
410
|
+
...request.definition,
|
|
411
|
+
lifecycle: request.definition.lifecycle || {drainTimeoutMs: 0},
|
|
412
|
+
logger: record.process.logger,
|
|
413
|
+
memory: request.definition.memory,
|
|
414
|
+
restart: request.definition.restart || {backoffFactor: 1, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0},
|
|
415
|
+
shouldRestart: () => record.desired,
|
|
416
|
+
stopSignal: request.definition.stopSignal || "SIGTERM"
|
|
417
|
+
})
|
|
418
|
+
record.provenance = request.provenance
|
|
419
|
+
} else if (request.command === "status") {
|
|
420
|
+
return record.process.status()
|
|
421
|
+
} else {
|
|
422
|
+
throw new Error(`Unknown guardian command: ${request.command}`)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
ownerRevision += 1
|
|
426
|
+
|
|
427
|
+
const status = record.process.status()
|
|
428
|
+
|
|
429
|
+
broadcast({event: "status", key: request.key, status})
|
|
430
|
+
return status
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* @param {net.Socket} socket - Requesting client.
|
|
435
|
+
* @param {string} command - Command name.
|
|
436
|
+
*/
|
|
437
|
+
function requireOwner(socket, command) {
|
|
438
|
+
if (ownerClient !== socket) throw new Error(`Guardian ${command} requires the committed owner`)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** @param {string} reason - Abort diagnostic. */
|
|
442
|
+
function abortReplacement(reason) {
|
|
443
|
+
if (replacementClient && !replacementClient.destroyed) replacementClient.write(`${JSON.stringify({event: "replacement-aborted", reason})}\n`)
|
|
444
|
+
replacementClient = undefined
|
|
445
|
+
replacementId = undefined
|
|
446
|
+
replacementAuthority = undefined
|
|
447
|
+
replacementOwnerState = undefined
|
|
448
|
+
replacementRevision = undefined
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Atomically promotes the prepared client and its complete transferable state. */
|
|
452
|
+
function commitReplacement() {
|
|
453
|
+
if (!replacementClient || !replacementId || !replacementOwnerState) throw new Error("Owner replacement candidate is not ready")
|
|
454
|
+
const previousOwner = ownerClient
|
|
455
|
+
const committedClient = replacementClient
|
|
456
|
+
const committedId = replacementId
|
|
457
|
+
|
|
458
|
+
ownerClient = committedClient
|
|
459
|
+
ownerState = replacementOwnerState
|
|
460
|
+
ownerRevision += 1
|
|
461
|
+
committedReplacementId = committedId
|
|
462
|
+
replacementClient = undefined
|
|
463
|
+
replacementId = undefined
|
|
464
|
+
replacementAuthority = undefined
|
|
465
|
+
replacementOwnerState = undefined
|
|
466
|
+
replacementRevision = undefined
|
|
467
|
+
if (previousOwner && !previousOwner.destroyed) {
|
|
468
|
+
retiringClient = previousOwner
|
|
469
|
+
retiringReplacementId = committedId
|
|
470
|
+
} else {
|
|
471
|
+
publishReplacementCommitted(committedClient, committedId)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Completes publication only after the incumbent has retired its listeners. */
|
|
476
|
+
function finalizeReplacementRetirement() {
|
|
477
|
+
const committedClient = ownerClient
|
|
478
|
+
const committedId = retiringReplacementId
|
|
479
|
+
const previousOwner = retiringClient
|
|
480
|
+
|
|
481
|
+
retiringClient = undefined
|
|
482
|
+
retiringReplacementId = undefined
|
|
483
|
+
if (!committedClient || !committedId) return
|
|
484
|
+
publishReplacementCommitted(committedClient, committedId)
|
|
485
|
+
if (previousOwner && !previousOwner.destroyed) previousOwner.write(`${JSON.stringify({event: "replacement-retired", replacementId: committedId})}\n`)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* @param {net.Socket} committedClient - New owner channel.
|
|
490
|
+
* @param {string} committedId - Replacement transaction id.
|
|
491
|
+
*/
|
|
492
|
+
function publishReplacementCommitted(committedClient, committedId) {
|
|
493
|
+
if (!committedClient.destroyed) committedClient.write(`${JSON.stringify({event: "replacement-committed", replacementId: committedId})}\n`)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* @param {net.Socket} socket - Requesting client.
|
|
498
|
+
* @param {GuardianRequest} request - Request.
|
|
499
|
+
*/
|
|
500
|
+
function requireReplacement(socket, request) {
|
|
501
|
+
if (replacementClient !== socket || request.replacementId !== replacementId) throw new Error("Owner replacement transaction is not the prepared candidate")
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* @param {import("./json.js").JsonValue} state - Transfer state.
|
|
506
|
+
* @returns {import("./json.js").JsonValue} Embedded authority fence.
|
|
507
|
+
*/
|
|
508
|
+
function ownerAuthority(state) {
|
|
509
|
+
if (!state || typeof state !== "object" || Array.isArray(state) || !("authority" in state)) throw new Error("Guardian owner state is missing its authority fence")
|
|
510
|
+
return state.authority
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Stops accepting connections and closes every authority channel except the response caller.
|
|
515
|
+
* @param {net.Socket} caller - Shutdown requester retained until it receives the response.
|
|
516
|
+
*/
|
|
517
|
+
function beginSuccessfulShutdown(caller) {
|
|
518
|
+
shutdownClient = caller
|
|
519
|
+
serverClosed = new Promise((resolve, reject) => {
|
|
520
|
+
server.close((error) => {
|
|
521
|
+
if (error) reject(error)
|
|
522
|
+
else resolve(undefined)
|
|
523
|
+
})
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
for (const client of clients) {
|
|
527
|
+
if (client !== caller) client.destroy()
|
|
528
|
+
}
|
|
529
|
+
if (caller.destroyed) void finishShutdown()
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Completes shutdown after the caller has received success and closed its side. */
|
|
533
|
+
async function finishShutdown() {
|
|
534
|
+
if (shutdownFinalizing) return
|
|
535
|
+
shutdownFinalizing = true
|
|
536
|
+
for (const client of clients) client.destroy()
|
|
537
|
+
await serverClosed
|
|
538
|
+
await fs.rm(socketPath, {force: true})
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** @param {Record<string, import("./json.js").JsonValue>} event - Event payload. */
|
|
542
|
+
function broadcast(event) {
|
|
543
|
+
const line = `${JSON.stringify(event)}\n`
|
|
544
|
+
|
|
545
|
+
for (const client of clients) if (!client.destroyed) client.write(line)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** @returns {void} Grants the next queued owner claim. */
|
|
549
|
+
function grantNextOwner() {
|
|
550
|
+
const next = claimWaiters.shift()
|
|
551
|
+
|
|
552
|
+
if (!next) return
|
|
553
|
+
clearTimeout(next.timer)
|
|
554
|
+
ownerClient = next.socket
|
|
555
|
+
next.resolve({claimed: true})
|
|
556
|
+
for (const waiter of claimWaiters.splice(0)) {
|
|
557
|
+
clearTimeout(waiter.timer)
|
|
558
|
+
waiter.reject(new Error("Durable owner was claimed by another matching daemon"))
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* @param {Error | string} error - Error-like value.
|
|
564
|
+
* @returns {string} Error message.
|
|
565
|
+
*/
|
|
566
|
+
function errorMessage(error) {
|
|
567
|
+
return error instanceof Error ? error.message : String(error)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Derives only exact process keys present in a committed pre-split durable snapshot.
|
|
572
|
+
* @param {import("./json.js").JsonValue | undefined} state - Seeded owner state.
|
|
573
|
+
* @returns {Set<string>} Exact legacy registrations eligible for recovery.
|
|
574
|
+
*/
|
|
575
|
+
function legacyOwnerKeys(state) {
|
|
576
|
+
const keys = new Set()
|
|
577
|
+
|
|
578
|
+
if (!state || typeof state !== "object" || Array.isArray(state) || !("snapshot" in state)) return keys
|
|
579
|
+
const snapshot = state.snapshot
|
|
580
|
+
|
|
581
|
+
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return keys
|
|
582
|
+
if (Array.isArray(snapshot.releases)) {
|
|
583
|
+
for (const release of snapshot.releases) {
|
|
584
|
+
if (!release || typeof release !== "object" || Array.isArray(release) || typeof release.releaseId !== "string" || !Array.isArray(release.processes)) continue
|
|
585
|
+
for (const processStatus of release.processes) {
|
|
586
|
+
if (processStatus && typeof processStatus === "object" && !Array.isArray(processStatus) && typeof processStatus.id === "string") keys.add(`release:${release.releaseId}:${processStatus.id}`)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (Array.isArray(snapshot.services)) {
|
|
591
|
+
for (const service of snapshot.services) {
|
|
592
|
+
if (service && typeof service === "object" && !Array.isArray(service) && typeof service.id === "string") keys.add(`service:${service.id}`)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (typeof snapshot.activeReleaseId === "string" && Array.isArray(snapshot.singletons)) {
|
|
596
|
+
for (const singleton of snapshot.singletons) {
|
|
597
|
+
if (singleton && typeof singleton === "object" && !Array.isArray(singleton) && typeof singleton.id === "string") keys.add(`singleton:${snapshot.activeReleaseId}:${singleton.id}`)
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return keys
|
|
601
|
+
}
|