@botiverse/k-carrier 0.1.8 → 0.2.0
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/NOTICE +5 -2
- package/README.md +72 -26
- package/core/src/artifact/download.ts +23 -1
- package/core/src/artifact/gzip.ts +26 -0
- package/core/src/artifact/source.ts +2 -0
- package/core/src/{createUpgrader.ts → createRunner.ts} +13 -15
- package/core/src/index.ts +11 -3
- package/core/src/launcher/launch.ts +15 -0
- package/core/src/launcher/supervise.ts +170 -0
- package/core/src/lifecycle/commandHost.ts +111 -0
- package/core/src/lifecycle/hostAdapter.ts +28 -16
- package/core/src/operation.ts +47 -25
- package/core/src/operationLifecycle.ts +2 -7
- package/core/src/platform/ops.ts +7 -0
- package/core/src/platform/posix.ts +26 -6
- package/core/src/platform/windows.ts +8 -2
- package/core/src/protocol/runner.ts +81 -0
- package/core/src/provenance/journal.ts +1 -1
- package/core/src/runner/cli.ts +27 -0
- package/core/src/runner/execute.ts +68 -0
- package/core/src/txn/engine.ts +41 -85
- package/core/src/txn/fileEffects.ts +15 -1
- package/core/src/txn/hostCallBudget.ts +4 -1
- package/core/src/txn/hostCallUncertain.ts +2 -0
- package/core/src/txn/lock.ts +81 -37
- package/core/src/txn/state.ts +1 -1
- package/core/src/upgrade/drive.ts +31 -2
- package/core/src/upgrade/outcome.ts +1 -1
- package/core/src/upgrade/recover.ts +22 -1
- package/core/src/upgrade/retire.ts +1 -1
- package/core/src/upgrader.ts +4 -9
- package/docs/design.md +173 -0
- package/docs/guide.md +196 -0
- package/docs/harness-design.md +75 -170
- package/docs/integration.md +221 -364
- package/docs/prior-art/design-influences.md +26 -0
- package/docs/prior-art/external-runner-research.md +49 -0
- package/docs/reference.md +209 -0
- package/docs/test-plan.md +89 -92
- package/harness/src/adapter/releaseKnob.ts +1 -1
- package/harness/src/adapter/serviceChecks.ts +5 -5
- package/harness/src/artifact/m1.ts +8 -8
- package/harness/src/artifact/m1Resume.ts +2 -2
- package/harness/src/artifact/m3.ts +25 -104
- package/harness/src/artifact/m3Hosts.ts +9 -61
- package/harness/src/artifact/m4.ts +3 -3
- package/harness/src/artifact/m5.ts +5 -5
- package/harness/src/artifact/m6.ts +6 -6
- package/harness/src/artifact/m6Status.ts +1 -1
- package/harness/src/examples/checks.ts +10 -13
- package/harness/src/fixtures/cliToolSource.ts +166 -0
- package/harness/src/fixtures/externalCrashAdapter.ts +19 -0
- package/harness/src/fixtures/managedHost.ts +100 -0
- package/harness/src/fixtures/serviceSource.ts +181 -0
- package/harness/src/fixtures/supervisedAdapter.ts +57 -0
- package/harness/src/scenario/processScan.ts +3 -1
- package/harness/src/scenario/sandbox.ts +2 -2
- package/harness/src/teeth/artifact.ts +3 -3
- package/harness/src/teeth/examples.ts +1 -1
- package/package.json +5 -3
- package/docs/design-v1.md +0 -246
- package/docs/prior-art.md +0 -150
package/core/src/txn/engine.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* - Crash anywhere -> recover() lands on stable-running or completes the
|
|
8
8
|
* transition, decided by journal replay. Never dual-run, never bricked.
|
|
9
9
|
* - Promote only after the caller-supplied predicate evaluation passed.
|
|
10
|
+
* - Readback is bound to a NEW incarnation: the startId probed before
|
|
11
|
+
* handover is journaled, and evidence carrying it again is refused.
|
|
10
12
|
* - Rollback is always available until promote; its reason is journaled.
|
|
11
13
|
*/
|
|
12
14
|
import type { HostAdapter, ProcessEvidence } from "../lifecycle/hostAdapter.ts";
|
|
@@ -14,7 +16,7 @@ import type { TxnEffects } from "./effects.ts";
|
|
|
14
16
|
import type { JournalEntry, TxnPhase } from "./state.ts";
|
|
15
17
|
import { STATE_FORMAT_VERSION } from "./state.ts";
|
|
16
18
|
import type { Clock } from "../clock.ts";
|
|
17
|
-
import { HostCallTimeout, DEFAULT_HOST_CALL_BUDGET_MS } from "./hostCallBudget.ts";
|
|
19
|
+
import { HostCallTimeout, HostCallUncertain, DEFAULT_HOST_CALL_BUDGET_MS } from "./hostCallBudget.ts";
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
export interface EngineDeps {
|
|
@@ -53,6 +55,8 @@ export class UpgradeEngine {
|
|
|
53
55
|
private seq = 0;
|
|
54
56
|
|
|
55
57
|
constructor(deps: EngineDeps) {
|
|
58
|
+
const budget = deps.hostCallBudgetMs ?? DEFAULT_HOST_CALL_BUDGET_MS;
|
|
59
|
+
if (!Number.isSafeInteger(budget) || budget <= 0 || budget > 2_147_483_647) throw new Error("invalid host call budget");
|
|
56
60
|
this.deps = deps;
|
|
57
61
|
}
|
|
58
62
|
|
|
@@ -71,6 +75,7 @@ export class UpgradeEngine {
|
|
|
71
75
|
* Must be called before upgrade() on every process start.
|
|
72
76
|
*/
|
|
73
77
|
async recover(): Promise<void> {
|
|
78
|
+
if (this.deps.host.fence) await this.withBudget("fence", () => this.deps.host.fence!());
|
|
74
79
|
const entries = await this.deps.effects.journal.readAll();
|
|
75
80
|
const last = entries.at(-1);
|
|
76
81
|
this.seq = (last?.seq ?? -1) + 1;
|
|
@@ -89,16 +94,16 @@ export class UpgradeEngine {
|
|
|
89
94
|
// resume is part of the terminal action too. A crash after promote()
|
|
90
95
|
// but before resume() used to leave the service alive and its hosted
|
|
91
96
|
// work permanently parked; DST found this exact effect boundary.
|
|
92
|
-
await this.deps.host.resume();
|
|
97
|
+
await this.withBudget("resume", () => this.deps.host.resume());
|
|
93
98
|
return;
|
|
94
99
|
case "rolled-back":
|
|
95
100
|
// The terminal journal entry is WAL intent, not proof that the host
|
|
96
101
|
// restore ran. Redo the whole idempotent rollback action: a crash
|
|
97
102
|
// immediately after journaling `rolled-back` may still have the
|
|
98
103
|
// experiment process live and workloads parked.
|
|
99
|
-
await this.deps.host.stop("experiment");
|
|
100
|
-
await this.deps.host.start("stable");
|
|
101
|
-
await this.deps.host.resume();
|
|
104
|
+
await this.withBudget("stop", () => this.deps.host.stop("experiment"));
|
|
105
|
+
await this.withBudget("start", () => this.deps.host.start("stable"));
|
|
106
|
+
await this.withBudget("resume", () => this.deps.host.resume());
|
|
102
107
|
await this.deps.effects.slots.clearExperiment();
|
|
103
108
|
return;
|
|
104
109
|
case "staged":
|
|
@@ -108,15 +113,8 @@ export class UpgradeEngine {
|
|
|
108
113
|
case "handing-over":
|
|
109
114
|
case "running-experiment":
|
|
110
115
|
case "readback": {
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// EVIDENCE decides, never a "this restart was planned" flag: a flag is
|
|
114
|
-
// a claim the crash path could make just as easily.
|
|
115
|
-
if (version !== null && (await this.handoverSucceeded(last, version))) {
|
|
116
|
-
await this.finishHandover(version);
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
// Fail closed: stop whatever runs, restore stable, resume workloads.
|
|
116
|
+
// Runner death before the commit intent always undoes the attempt.
|
|
117
|
+
// A healthy candidate does not authorize a successor to commit it.
|
|
120
118
|
await this.rollbackTo(`crash during ${last.intent}` + (version ? ` (experiment ${version})` : ""));
|
|
121
119
|
return;
|
|
122
120
|
}
|
|
@@ -134,20 +132,23 @@ export class UpgradeEngine {
|
|
|
134
132
|
const versions = await this.deps.effects.slots.slotVersions();
|
|
135
133
|
if (versions.stable === target.version) return { result: "up-to-date" };
|
|
136
134
|
|
|
135
|
+
// Record the incarnation this upgrade replaces, before anything on disk
|
|
136
|
+
// changes. A failed probe provides no usable baseline; it does not prove
|
|
137
|
+
// the service is stopped. The controller must still confirm stop before
|
|
138
|
+
// start. An uncertain probe is let out, as for the readback probe below.
|
|
139
|
+
let prior: ProcessEvidence | null = null;
|
|
140
|
+
try {
|
|
141
|
+
prior = await this.withBudget("healthProbe", () => this.deps.host.healthProbe());
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (err instanceof HostCallUncertain) throw err;
|
|
144
|
+
prior = null;
|
|
145
|
+
}
|
|
146
|
+
|
|
137
147
|
await this.journal("staged", { version: target.version });
|
|
138
148
|
await this.deps.effects.slots.stageExperiment(target);
|
|
139
149
|
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
// exiting (its supervisor respawns it from the new bytes) dies here on the
|
|
143
|
-
// SUCCESS path, and the successor -- not us -- finishes the transaction.
|
|
144
|
-
// Without this identity the successor cannot tell "the handover worked"
|
|
145
|
-
// from "we crashed mid-handover", because both leave the same journal.
|
|
146
|
-
const priorStartId = await this.probeStartId();
|
|
147
|
-
await this.journal("handing-over", {
|
|
148
|
-
version: target.version,
|
|
149
|
-
...(priorStartId === null ? {} : { priorStartId }),
|
|
150
|
-
});
|
|
150
|
+
// The external runner survives normal service replacement.
|
|
151
|
+
await this.journal("handing-over", { version: target.version, ...(prior ? { priorStartId: prior.startId } : {}) });
|
|
151
152
|
await this.withBudget("quiesce", () => this.deps.host.quiesce());
|
|
152
153
|
await this.withBudget("stop", () => this.deps.host.stop("stable"));
|
|
153
154
|
await this.withBudget("start", () => this.deps.host.start("experiment"));
|
|
@@ -164,69 +165,36 @@ export class UpgradeEngine {
|
|
|
164
165
|
// how a stuck upgrade becomes two live incarnations. Let it out; the
|
|
165
166
|
// journal keeps the in-flight phase and the next start resolves it from
|
|
166
167
|
// evidence.
|
|
167
|
-
if (err instanceof
|
|
168
|
+
if (err instanceof HostCallUncertain) throw err;
|
|
168
169
|
return this.rollbackOutcome(`experiment probe failed: ${(err as Error).message}`);
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
await this.journal("readback", { version: target.version });
|
|
172
|
-
|
|
173
|
+
// The same startId after stop/start means the old process answered: it
|
|
174
|
+
// was never stopped, or the probe served cached evidence. A version
|
|
175
|
+
// string alone cannot tell those apart; the incarnation identity can.
|
|
176
|
+
if (prior !== null && evidence.startId === prior.startId) {
|
|
177
|
+
return this.rollbackOutcome(
|
|
178
|
+
`live process is still the pre-upgrade incarnation (startId ${evidence.startId}); the old service was not replaced`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const refusal = await this.withBudget("readback", () => this.deps.evaluatePredicates(evidence, target.version));
|
|
173
182
|
if (refusal !== null) {
|
|
174
183
|
return this.rollbackOutcome(`predicates refused: ${refusal}`);
|
|
175
184
|
}
|
|
176
185
|
|
|
177
186
|
await this.journal("promoted", { version: target.version });
|
|
178
187
|
await this.deps.effects.slots.promoteExperiment();
|
|
179
|
-
await this.deps.host.resume();
|
|
188
|
+
await this.withBudget("resume", () => this.deps.host.resume());
|
|
180
189
|
return { result: "promoted", version: target.version };
|
|
181
190
|
}
|
|
182
191
|
|
|
183
|
-
/**
|
|
184
|
-
* Did the handover actually happen? True only when a live process reports
|
|
185
|
-
* the EXPERIMENT version from a DIFFERENT incarnation than the one that
|
|
186
|
-
* journaled the handover. Same incarnation answering => nothing was
|
|
187
|
-
* replaced, whatever it claims about its version.
|
|
188
|
-
*/
|
|
189
|
-
private async handoverSucceeded(last: JournalEntry, experimentVersion: string): Promise<boolean> {
|
|
190
|
-
const priorStartId = last.detail.priorStartId;
|
|
191
|
-
// No recorded identity (older journal, or a host whose probe was
|
|
192
|
-
// unavailable) => we cannot prove a successor exists => fail closed.
|
|
193
|
-
if (priorStartId === undefined) return false;
|
|
194
|
-
let evidence: ProcessEvidence;
|
|
195
|
-
try {
|
|
196
|
-
evidence = await this.withBudget("healthProbe", () => this.deps.host.healthProbe());
|
|
197
|
-
} catch {
|
|
198
|
-
return false; // nothing alive to vouch for the handover
|
|
199
|
-
}
|
|
200
|
-
return evidence.version === experimentVersion && evidence.startId !== priorStartId;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Finish a transaction whose handover outlived the process that began it. */
|
|
204
|
-
private async finishHandover(experimentVersion: string): Promise<void> {
|
|
205
|
-
let evidence: ProcessEvidence;
|
|
206
|
-
try {
|
|
207
|
-
evidence = await this.withBudget("healthProbe", () => this.deps.host.healthProbe());
|
|
208
|
-
} catch (err) {
|
|
209
|
-
if (err instanceof HostCallTimeout) throw err; // same rule: do not act on a wedged host
|
|
210
|
-
await this.rollbackTo(`successor probe failed: ${(err as Error).message}`);
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
await this.journal("readback", { version: experimentVersion });
|
|
214
|
-
const refusal = await this.deps.evaluatePredicates(evidence, experimentVersion);
|
|
215
|
-
if (refusal !== null) {
|
|
216
|
-
await this.rollbackTo(`predicates refused after handover: ${refusal}`);
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
await this.journal("promoted", { version: experimentVersion });
|
|
220
|
-
await this.deps.effects.slots.promoteExperiment();
|
|
221
|
-
await this.deps.host.resume();
|
|
222
|
-
}
|
|
223
|
-
|
|
224
192
|
/**
|
|
225
193
|
* Bound a host call. On expiry we do NOT pretend the call failed cleanly:
|
|
226
194
|
* a pending promise cannot be cancelled in JS, so the host may still be
|
|
227
195
|
* mid-operation. The transaction gives up instead of issuing further host
|
|
228
196
|
* calls it cannot reason about, and the journal records why. Recovery is
|
|
229
|
-
* the next process start, which
|
|
197
|
+
* the next process start, which replays the durable commit or restores stable.
|
|
230
198
|
*/
|
|
231
199
|
private async withBudget<T>(label: string, call: () => Promise<T>): Promise<T> {
|
|
232
200
|
const budgetMs = this.deps.hostCallBudgetMs ?? DEFAULT_HOST_CALL_BUDGET_MS;
|
|
@@ -245,18 +213,6 @@ export class UpgradeEngine {
|
|
|
245
213
|
}
|
|
246
214
|
}
|
|
247
215
|
|
|
248
|
-
/** Best-effort identity of the live process; null when nothing answers. */
|
|
249
|
-
private async probeStartId(): Promise<string | null> {
|
|
250
|
-
try {
|
|
251
|
-
// Budgeted like every other host call: found by the wedged-host test,
|
|
252
|
-
// which hung HERE -- an unbounded probe before the handover is the same
|
|
253
|
-
// trap one line earlier.
|
|
254
|
-
return (await this.withBudget("healthProbe", () => this.deps.host.healthProbe())).startId;
|
|
255
|
-
} catch {
|
|
256
|
-
return null;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
216
|
private async rollbackOutcome(reason: string): Promise<EngineOutcome> {
|
|
261
217
|
await this.rollbackTo(reason);
|
|
262
218
|
return { result: "rolled-back", reason };
|
|
@@ -266,9 +222,9 @@ export class UpgradeEngine {
|
|
|
266
222
|
await this.journal("rolled-back", { reason });
|
|
267
223
|
if (!opts.skipHostRestart) {
|
|
268
224
|
// Stop whatever may be running (either slot), restore stable, resume.
|
|
269
|
-
await this.deps.host.stop("experiment");
|
|
270
|
-
await this.deps.host.start("stable");
|
|
271
|
-
await this.deps.host.resume();
|
|
225
|
+
await this.withBudget("stop", () => this.deps.host.stop("experiment"));
|
|
226
|
+
await this.withBudget("start", () => this.deps.host.start("stable"));
|
|
227
|
+
await this.withBudget("resume", () => this.deps.host.resume());
|
|
272
228
|
}
|
|
273
229
|
await this.deps.effects.slots.clearExperiment();
|
|
274
230
|
}
|
|
@@ -38,6 +38,9 @@ export function fileJournalStore(stateDir: string): JournalStore {
|
|
|
38
38
|
} finally {
|
|
39
39
|
await fh.close();
|
|
40
40
|
}
|
|
41
|
+
// The first append creates the file; its directory entry is only
|
|
42
|
+
// durable once the parent is fsync'd. Cheap enough to do every time.
|
|
43
|
+
await platformOpsFor().syncDirectory(stateDir);
|
|
41
44
|
},
|
|
42
45
|
async readAll(): Promise<JournalEntry[]> {
|
|
43
46
|
let text: string;
|
|
@@ -80,6 +83,13 @@ export function fileSlotStore(stateDir: string): SlotStore {
|
|
|
80
83
|
// bytesRef is a path to the verified bytes the caller downloaded.
|
|
81
84
|
await fs.copyFile(artifact.bytesRef, path.join(staging, ARTIFACT_FILE));
|
|
82
85
|
await fs.writeFile(path.join(staging, VERSION_FILE), artifact.version);
|
|
86
|
+
// Flush copied bytes, version metadata and their names before publication.
|
|
87
|
+
// Syncing only the parent after rename does not persist file contents.
|
|
88
|
+
for (const name of [ARTIFACT_FILE, VERSION_FILE]) {
|
|
89
|
+
const handle = await fs.open(path.join(staging, name), "r+");
|
|
90
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
91
|
+
}
|
|
92
|
+
await platformOpsFor().syncDirectory(staging);
|
|
83
93
|
// Publish the slot atomically: a half-written experiment must never be
|
|
84
94
|
// visible as a stageable slot.
|
|
85
95
|
await fs.rm(dir, { recursive: true, force: true });
|
|
@@ -95,7 +105,11 @@ export function fileSlotStore(stateDir: string): SlotStore {
|
|
|
95
105
|
const stable = slotDir(stateDir, "stable");
|
|
96
106
|
if ((await readVersion("experiment")) === null) return; // idempotent redo
|
|
97
107
|
await fs.rm(`${stable}.old`, { recursive: true, force: true });
|
|
98
|
-
await
|
|
108
|
+
const stableExists = await fs.stat(stable).then(() => true, (error: NodeJS.ErrnoException) => {
|
|
109
|
+
if (error.code === "ENOENT") return false; // replay after the first rename
|
|
110
|
+
throw error;
|
|
111
|
+
});
|
|
112
|
+
if (stableExists) await platformOpsFor().renamePath(stable, `${stable}.old`);
|
|
99
113
|
await platformOpsFor().renamePath(experiment, stable);
|
|
100
114
|
await fs.rm(`${stable}.old`, { recursive: true, force: true });
|
|
101
115
|
},
|
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
*/
|
|
16
16
|
export const DEFAULT_HOST_CALL_BUDGET_MS = 120_000;
|
|
17
17
|
|
|
18
|
+
import { HostCallUncertain } from "./hostCallUncertain.ts";
|
|
19
|
+
export { HostCallUncertain } from "./hostCallUncertain.ts";
|
|
20
|
+
|
|
18
21
|
/** A host call exceeded its budget: the host is wedged, not merely failing. */
|
|
19
|
-
export class HostCallTimeout extends
|
|
22
|
+
export class HostCallTimeout extends HostCallUncertain {
|
|
20
23
|
readonly call: string;
|
|
21
24
|
readonly budgetMs: number;
|
|
22
25
|
constructor(call: string, budgetMs: number) {
|
package/core/src/txn/lock.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { promises as fs } from "node:fs";
|
|
19
19
|
import path from "node:path";
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
20
21
|
import { platformOpsFor } from "../platform/index.ts";
|
|
21
22
|
|
|
22
23
|
/** Bounded retries: an unclaimable lock is a typed failure, never a hang. */
|
|
@@ -53,46 +54,89 @@ export async function acquireUpgradeLock(stateDir: string, nowMs: number): Promi
|
|
|
53
54
|
await fs.mkdir(stateDir, { recursive: true });
|
|
54
55
|
const ops = platformOpsFor();
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
// Unique contender directories are visible atomically, before inspecting the
|
|
58
|
+
// legacy lock file. Never unlink a live contender or reuse its path. This
|
|
59
|
+
// closes both the empty-file publication window and double stale-unlink race.
|
|
60
|
+
const claims = path.join(stateDir, "upgrade.lock.claims");
|
|
61
|
+
await fs.mkdir(claims, { recursive: true });
|
|
62
|
+
const name = `${process.pid}-${randomUUID()}`;
|
|
63
|
+
const claim = path.join(claims, name);
|
|
64
|
+
await fs.mkdir(claim);
|
|
65
|
+
const removeClaim = async (): Promise<void> => {
|
|
66
|
+
try { await fs.rmdir(claim); }
|
|
67
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
for (const other of await fs.readdir(claims)) {
|
|
71
|
+
if (other === name) continue;
|
|
72
|
+
const pid = Number(other.split("-")[0]);
|
|
73
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("UPGRADE_LOCK_UNREADABLE");
|
|
74
|
+
if (ops.isProcessAlive(pid)) throw new UpgradeLockError(pid);
|
|
75
|
+
await fs.rmdir(path.join(claims, other)).catch((error: NodeJS.ErrnoException) => {
|
|
76
|
+
if (error.code !== "ENOENT") throw error;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
await removeClaim();
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
let acquired = false;
|
|
84
|
+
try {
|
|
85
|
+
const record: LockRecord = { pid: process.pid, acquiredAtMs: nowMs };
|
|
86
|
+
let attempts = 0;
|
|
87
|
+
for (;;) {
|
|
62
88
|
try {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
// wx: fails if the file exists — the atomic "claim it" primitive.
|
|
90
|
+
const fh = await fs.open(lockPath, "wx");
|
|
91
|
+
try {
|
|
92
|
+
await fh.writeFile(JSON.stringify(record));
|
|
93
|
+
} finally {
|
|
94
|
+
await fh.close();
|
|
95
|
+
}
|
|
96
|
+
acquired = true;
|
|
97
|
+
let released = false;
|
|
98
|
+
return {
|
|
99
|
+
async release() {
|
|
100
|
+
if (released) return;
|
|
101
|
+
// Quarantine can move this entire state directory. A replacement
|
|
102
|
+
// directory at the same pathname belongs to a different owner.
|
|
103
|
+
try { await fs.stat(claim); }
|
|
104
|
+
catch (error) {
|
|
105
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
106
|
+
released = true;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
await fs.rm(lockPath, { force: true });
|
|
110
|
+
await removeClaim();
|
|
111
|
+
released = true;
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
116
|
+
attempts += 1;
|
|
117
|
+
if (attempts > MAX_ATTEMPTS) {
|
|
118
|
+
// Never spin forever: a lock we can neither claim nor clear is a
|
|
119
|
+
// typed failure, not a hang. (Hangs hide bugs; failures report them.)
|
|
120
|
+
throw new Error(
|
|
121
|
+
`[UPGRADE_LOCK_UNRESOLVABLE] could not acquire or clear ${lockPath} after ${MAX_ATTEMPTS} attempts`,
|
|
122
|
+
{ cause: err },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const holder = await readHolder(lockPath);
|
|
126
|
+
if (holder === "vanished") continue; // gone between open and read: retry
|
|
127
|
+
// A non-positive pid is never a real holder — and must NEVER reach
|
|
128
|
+
// process.kill, where pid<=0 addresses process GROUPS or every process.
|
|
129
|
+
if (holder !== "unreadable" && holder.pid > 0 && ops.isProcessAlive(holder.pid)) {
|
|
130
|
+
throw new UpgradeLockError(holder.pid);
|
|
131
|
+
}
|
|
132
|
+
// Holder is gone: its transaction died mid-flight. Recovery (journal
|
|
133
|
+
// replay) will decide what to do with the state; clear the lock and
|
|
134
|
+
// take it. The contender guard above serializes stale-file removal.
|
|
135
|
+
await fs.rm(lockPath, { force: true });
|
|
89
136
|
}
|
|
90
|
-
// Holder is gone: its transaction died mid-flight. Recovery (journal
|
|
91
|
-
// replay) will decide what to do with the state; clear the lock and
|
|
92
|
-
// take it. Removing a specific stale file is safe to race — whoever
|
|
93
|
-
// wins the next `wx` owns the lock.
|
|
94
|
-
await fs.rm(lockPath, { force: true });
|
|
95
137
|
}
|
|
138
|
+
} finally {
|
|
139
|
+
if (!acquired) await removeClaim();
|
|
96
140
|
}
|
|
97
141
|
}
|
|
98
142
|
|
package/core/src/txn/state.ts
CHANGED
|
@@ -124,7 +124,7 @@ export type TxnStateInput =
|
|
|
124
124
|
*
|
|
125
125
|
* The one place an inconsistent persisted world can slip in is the runtime
|
|
126
126
|
* boundary that reads the journal and slots but only knows the phase at
|
|
127
|
-
* runtime (see `
|
|
127
|
+
* runtime (see `createRunner.readState`); that boundary must decide the
|
|
128
128
|
* phase before calling this, failing closed if a phase is in flight but the
|
|
129
129
|
* experiment slot is empty.
|
|
130
130
|
*/
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HostCallUncertain } from "../txn/hostCallBudget.ts";
|
|
1
2
|
import * as path from "node:path";
|
|
2
3
|
import type { Release } from "../artifact/source.ts";
|
|
3
4
|
import { downloadVerified } from "../artifact/download.ts";
|
|
@@ -14,7 +15,7 @@ import type { Clock } from "../clock.ts";
|
|
|
14
15
|
import type { UpgradeProgress } from "../progress.ts";
|
|
15
16
|
import type { ProcessEvidence } from "../lifecycle/hostAdapter.ts";
|
|
16
17
|
import type { PredicateResult, ConvergenceReport } from "../converge/predicates.ts";
|
|
17
|
-
import type
|
|
18
|
+
import { OperationReplay, loadArchivedOperation, type OperationDescriptor } from "../operation.ts";
|
|
18
19
|
import type { OperationLifecycle } from "../operationLifecycle.ts";
|
|
19
20
|
import type {
|
|
20
21
|
NotificationEvent,
|
|
@@ -69,9 +70,28 @@ export async function driveUpgrade(
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
const lock = await acquireUpgradeLock(deps.stateDir, deps.clock.nowMs());
|
|
73
|
+
let releaseLock = true;
|
|
72
74
|
try {
|
|
75
|
+
deps.operation.reset();
|
|
76
|
+
// Inspect under the transaction lock, before recovery or any new host action.
|
|
77
|
+
const latest = await deps.operation.read();
|
|
78
|
+
if (latest.kind === "unreadable") throw new Error(latest.reason);
|
|
79
|
+
const archived = request.operation ? await loadArchivedOperation(deps.stateDir, request.operation.id) : { kind: "genesis" as const };
|
|
80
|
+
if (archived.kind === "unreadable") throw new Error(archived.reason);
|
|
81
|
+
const prior = archived.kind === "observed" ? archived : latest;
|
|
82
|
+
if (request.operation && prior.kind === "observed" && prior.operation.id === request.operation.id) {
|
|
83
|
+
if (prior.operation.targetVersion !== request.targetVersionHint) {
|
|
84
|
+
throw new Error("OPERATION_ID_CONFLICT: request id is already bound to another version");
|
|
85
|
+
}
|
|
86
|
+
if (prior.operation.outcome !== null) throw new OperationReplay(prior.operation);
|
|
87
|
+
}
|
|
73
88
|
await deps.engine.recover();
|
|
74
89
|
await deps.operation.settleRecovery();
|
|
90
|
+
const recovered = await deps.operation.read();
|
|
91
|
+
if (request.operation && recovered.kind === "observed" &&
|
|
92
|
+
recovered.operation.id === request.operation.id && recovered.operation.outcome !== null) {
|
|
93
|
+
throw new OperationReplay(recovered.operation);
|
|
94
|
+
}
|
|
75
95
|
const current = await deps.readStableVersion();
|
|
76
96
|
await deps.operation.begin(
|
|
77
97
|
request.operation ?? null,
|
|
@@ -97,6 +117,10 @@ export async function driveUpgrade(
|
|
|
97
117
|
});
|
|
98
118
|
throw error;
|
|
99
119
|
}
|
|
120
|
+
if (release !== null && request.targetVersionHint !== undefined && release.version !== request.targetVersionHint) {
|
|
121
|
+
await deps.operation.transition({ phase: "failed", outcome: "failed", reason: "target-version-mismatch" });
|
|
122
|
+
throw new Error("PINNED_VERSION_MISMATCH: release source returned a different target");
|
|
123
|
+
}
|
|
100
124
|
if (release === null) {
|
|
101
125
|
await deps.operation.transition({ phase: "up-to-date", outcome: "up-to-date" });
|
|
102
126
|
return { result: "up-to-date" };
|
|
@@ -182,7 +206,12 @@ export async function driveUpgrade(
|
|
|
182
206
|
break;
|
|
183
207
|
}
|
|
184
208
|
return finished.outcome;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
// A timed-out promise may still execute. Keep ownership until this worker
|
|
211
|
+
// exits; its successor must fence external controller effects as well.
|
|
212
|
+
if (error instanceof HostCallUncertain) releaseLock = false;
|
|
213
|
+
throw error;
|
|
185
214
|
} finally {
|
|
186
|
-
await lock.release();
|
|
215
|
+
if (releaseLock) await lock.release();
|
|
187
216
|
}
|
|
188
217
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* finishUpgradeOutcome — the engine outcome becomes the facade outcome:
|
|
3
3
|
* report the stage, notify the sink, build the convergence report
|
|
4
|
-
* (promoted only), and return. Split out of
|
|
4
|
+
* (promoted only), and return. Split out of createRunner so that file
|
|
5
5
|
* stays under the line budget; the semantics are unchanged from the inline
|
|
6
6
|
* version.
|
|
7
7
|
*/
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { HostCallUncertain } from "../txn/hostCallBudget.ts";
|
|
2
|
+
import { loadOperation, loadArchivedOperation, OperationReplay } from "../operation.ts";
|
|
1
3
|
import type { Clock } from "../clock.ts";
|
|
2
4
|
import type { UpgradeEngine } from "../txn/engine.ts";
|
|
3
5
|
import { acquireUpgradeLock } from "../txn/lock.ts";
|
|
@@ -8,12 +10,31 @@ export async function recoverUpgrade(
|
|
|
8
10
|
clock: Clock,
|
|
9
11
|
engine: UpgradeEngine,
|
|
10
12
|
afterRecover?: () => Promise<void>,
|
|
13
|
+
expected?: { id: string; targetVersion: string },
|
|
11
14
|
): Promise<void> {
|
|
12
15
|
const lock = await acquireUpgradeLock(stateDir, clock.nowMs());
|
|
16
|
+
let release = true;
|
|
13
17
|
try {
|
|
18
|
+
const current = await loadOperation(stateDir);
|
|
19
|
+
if (current.kind === "unreadable") throw new Error(current.reason);
|
|
20
|
+
if (expected) {
|
|
21
|
+
const archived = await loadArchivedOperation(stateDir, expected.id);
|
|
22
|
+
if (archived.kind === "unreadable") throw new Error(archived.reason);
|
|
23
|
+
const prior = archived.kind === "observed" ? archived : current;
|
|
24
|
+
if (prior.kind !== "observed" || prior.operation.id !== expected.id) {
|
|
25
|
+
throw new Error("RECOVERY_OPERATION_NOT_FOUND");
|
|
26
|
+
}
|
|
27
|
+
if (prior.operation.targetVersion !== expected.targetVersion) throw new Error("OPERATION_ID_CONFLICT");
|
|
28
|
+
if (prior.operation.outcome !== null) throw new OperationReplay(prior.operation);
|
|
29
|
+
}
|
|
14
30
|
await engine.recover();
|
|
15
31
|
await afterRecover?.();
|
|
32
|
+
} catch (error) {
|
|
33
|
+
// A timed-out promise may still execute. Keep ownership until this worker
|
|
34
|
+
// exits; its successor must fence external controller effects as well.
|
|
35
|
+
if (error instanceof HostCallUncertain) release = false;
|
|
36
|
+
throw error;
|
|
16
37
|
} finally {
|
|
17
|
-
await lock.release();
|
|
38
|
+
if (release) await lock.release();
|
|
18
39
|
}
|
|
19
40
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* retireReason — the fail-closed retirement gate (M5, extracted from
|
|
3
|
-
*
|
|
3
|
+
* createRunner for the line budget). The legacy lifecycle manager may be
|
|
4
4
|
* retired ONLY after host_lifecycle_converged passed on the last promote;
|
|
5
5
|
* before that, retirement is refused with a typed HOLD — removing the old
|
|
6
6
|
* supervisor without a converged replacement leaves the machine with
|
package/core/src/upgrader.ts
CHANGED
|
@@ -19,11 +19,9 @@ export interface ProvenanceIdentity {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Upgrader is the
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* executor, so there is no entrypoint that "swaps bytes but skips
|
|
26
|
-
* convergence" (the class of bug this framework exists to kill).
|
|
22
|
+
* Upgrader is the transaction facade constructed by createRunner
|
|
23
|
+
* inside the disposable runner. Application entry points submit runner
|
|
24
|
+
* requests; they do not construct an in-process upgrade engine.
|
|
27
25
|
*/
|
|
28
26
|
export interface Upgrader {
|
|
29
27
|
/**
|
|
@@ -35,7 +33,7 @@ export interface Upgrader {
|
|
|
35
33
|
* recorded by K. Hosts should run this from a coordinator that survives
|
|
36
34
|
* service replacement, because recovery may stop and restart the service.
|
|
37
35
|
*/
|
|
38
|
-
recover(): Promise<void>;
|
|
36
|
+
recover(expected?: { id: string; targetVersion: string }): Promise<void>;
|
|
39
37
|
|
|
40
38
|
/**
|
|
41
39
|
* Ask the release source whether this install should move, without moving
|
|
@@ -105,9 +103,6 @@ export interface Upgrader {
|
|
|
105
103
|
/** K's single durable operation receipt; hosts project it, never mirror it. */
|
|
106
104
|
operation(): Promise<OperationRead>;
|
|
107
105
|
|
|
108
|
-
/** Mark one exact terminal operation delivered by the host transport. */
|
|
109
|
-
acknowledgeOperation(operationId: string): Promise<"acknowledged" | "not-terminal" | "not-found" | "changed">;
|
|
110
|
-
|
|
111
106
|
/** Atomically move quiesced K state to an audit-only fresh-install backup. */
|
|
112
107
|
quarantineState(options: QuarantineOptions): Promise<QuarantineResult>;
|
|
113
108
|
}
|