@parcha/pi-durable-disk 0.0.0-stage → 0.1.0-beta.13
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/CHANGELOG.md +329 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +695 -2
- package/bin/archil-scoped +165 -0
- package/dist/app.d.ts +40 -0
- package/dist/app.js +47 -0
- package/dist/claim.d.ts +222 -0
- package/dist/claim.js +722 -0
- package/dist/cli.d.ts +64 -0
- package/dist/cli.js +664 -0
- package/dist/commands.d.ts +67 -0
- package/dist/commands.js +299 -0
- package/dist/confine.d.ts +91 -0
- package/dist/confine.js +341 -0
- package/dist/env.d.ts +129 -0
- package/dist/env.js +608 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.js +55 -0
- package/dist/fork.d.ts +41 -0
- package/dist/fork.js +181 -0
- package/dist/hosts/daytona-launch.d.ts +52 -0
- package/dist/hosts/daytona-launch.js +270 -0
- package/dist/hosts/daytona.d.ts +147 -0
- package/dist/hosts/daytona.js +368 -0
- package/dist/hosts/docker.d.ts +103 -0
- package/dist/hosts/docker.js +362 -0
- package/dist/hosts/local-host.d.ts +71 -0
- package/dist/hosts/local-host.js +356 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +17 -0
- package/dist/lease.d.ts +7 -0
- package/dist/lease.js +6 -0
- package/dist/local-run.d.ts +10 -0
- package/dist/local-run.js +63 -0
- package/dist/park.d.ts +92 -0
- package/dist/park.js +229 -0
- package/dist/run.d.ts +290 -0
- package/dist/run.js +785 -0
- package/dist/serve.d.ts +101 -0
- package/dist/serve.js +393 -0
- package/dist/status.d.ts +123 -0
- package/dist/status.js +264 -0
- package/dist/store.d.ts +103 -0
- package/dist/store.js +217 -0
- package/dist/supervise.d.ts +340 -0
- package/dist/supervise.js +528 -0
- package/dist/watchdog.d.ts +39 -0
- package/dist/watchdog.js +137 -0
- package/package.json +87 -4
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
// The supervisor: a stateless function that reads a run's `run.json` over the S3 API and its delegation over
|
|
2
|
+
// the control API, decides whether the run needs a new instance, and starts one through a host driver. It holds the
|
|
3
|
+
// API key; hosts and instances never do. It must run in a different process from any instance (a process with files
|
|
4
|
+
// on a hung mount cannot fork), and it never touches a run's mount itself: every mount operation happens in an
|
|
5
|
+
// instance or in a host driver's child processes.
|
|
6
|
+
import { open } from "node:fs/promises";
|
|
7
|
+
import { hostname } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { acquire, createRunDir, findDelegations, matchDelegations, pathlessResolver, revokeBestEffort, revokeCompanions, mintMountToken, MOUNT_TOKEN_TTL, parseTokenNickname, removeMountToken, runPath, unmountClaim, } from "./claim.js";
|
|
10
|
+
import { FencedError, HeldError, PdaError } from "./errors.js";
|
|
11
|
+
import { parseRunRecord, RUN_JSON } from "./status.js";
|
|
12
|
+
export const CONTROL_TIMEOUT_MS = 10_000;
|
|
13
|
+
/** The same control API with every call bounded: one that does not settle in `ms` fails as CONTROL_API_FAILED. */
|
|
14
|
+
export function withTimeouts(control, ms) {
|
|
15
|
+
return new Proxy(control, {
|
|
16
|
+
get(target, prop, receiver) {
|
|
17
|
+
const value = Reflect.get(target, prop, receiver);
|
|
18
|
+
if (typeof value !== "function")
|
|
19
|
+
return value;
|
|
20
|
+
return (...args) => new Promise((resolve, reject) => {
|
|
21
|
+
const timer = setTimeout(() => reject(new SuperviseError("CONTROL_API_FAILED", `${String(prop)} did not answer in ${ms} ms`)), ms);
|
|
22
|
+
Promise.resolve(value.apply(target, args)).then((v) => (clearTimeout(timer), resolve(v)), (e) => (clearTimeout(timer), reject(e)));
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** A supervisor step failed; nothing was started. Exit 1; the next tick tries again. */
|
|
28
|
+
export class SuperviseError extends PdaError {
|
|
29
|
+
constructor(code, message, options = {}) {
|
|
30
|
+
super(code, message, options);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// ---- run.json (status.ts) -------------------------------------------------------------------------------------
|
|
34
|
+
const notFound = (err) => {
|
|
35
|
+
const e = err;
|
|
36
|
+
return e.status === 404 || e.statusCode === 404 || e.code === "NoSuchKey";
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* `run.json` over S3 GetObject, strongly consistent once the writer fsynced the file and its directory. Absent: null.
|
|
40
|
+
* status.ts's strict reader: an unknown status or a malformed field is RunRecordError (RUN_JSON_INVALID), which fails
|
|
41
|
+
* this run's decision and nothing else.
|
|
42
|
+
*/
|
|
43
|
+
export async function readRunStatus(control, id) {
|
|
44
|
+
const key = `${runPath(id)}/${RUN_JSON}`;
|
|
45
|
+
let bytes;
|
|
46
|
+
try {
|
|
47
|
+
bytes = await control.getObject(key);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (notFound(err))
|
|
51
|
+
return null;
|
|
52
|
+
throw new SuperviseError("CONTROL_API_FAILED", `GetObject ${key} failed`, { cause: err });
|
|
53
|
+
}
|
|
54
|
+
return parseRunRecord(new TextDecoder().decode(bytes));
|
|
55
|
+
}
|
|
56
|
+
// ---- ensureRunning ------------------------------------------------------------------------------------------
|
|
57
|
+
export const LEASE_EXPIRY_MS = 90_000;
|
|
58
|
+
export const STONITH_TIMEOUT_MS = 30_000;
|
|
59
|
+
/** Each start attempt's mount token lives one day (reusable: a single-use token dies at the client's 5 min refresh). */
|
|
60
|
+
export const TOKEN_TTL = MOUNT_TOKEN_TTL;
|
|
61
|
+
/** A token user younger than this may belong to a start still in flight (minted, not yet mounted): never removed. */
|
|
62
|
+
export const TOKEN_GRACE_MS = 15 * 60_000;
|
|
63
|
+
/** The longest start grace a run whose starts keep failing gets. */
|
|
64
|
+
export const START_BACKOFF_MAX_MS = 10 * 60_000;
|
|
65
|
+
/**
|
|
66
|
+
* Make sure the run has a live instance, or has no business having one:
|
|
67
|
+
* - `run.json` done or failed (a store behind its seal is failed, never restarted into): nothing. Paused, or sleeping
|
|
68
|
+
* with `wakeAt` later or null: nothing unless `demand`.
|
|
69
|
+
* - No delegation on `runs/<id>`: start. Every delegation orphaned (the holder's client is gone): revoke, start.
|
|
70
|
+
* - A delegation still checking out (`isPending`): someone is mounting; nothing.
|
|
71
|
+
* - Held and the lease fresh: healthy. Held and the lease expired (or no heartbeat at all): stop the holder if the
|
|
72
|
+
* driver can reach it (bounded), revoke, start. Revoking a live holder is safe: its next fsync fails.
|
|
73
|
+
* - The start grace: every start first writes `runs/<id>/start.json` (the generation the new instance will write, and
|
|
74
|
+
* when). While run.json is below that generation and the mark is younger than `startGraceMs`, a run with no
|
|
75
|
+
* delegation or with a lease that looks expired is `starting`: nothing is revoked or started, so a tick between a
|
|
76
|
+
* start and the instance's first run.json write never fences the instance it started. Past the grace, the start has
|
|
77
|
+
* failed and the rules above apply. Every delegation orphaned inside the grace waits too: the start's client is gone,
|
|
78
|
+
* which is a failed start like any other.
|
|
79
|
+
* - The backoff: a start whose generation run.json never reached failed (it ended before its first run.json write, 76
|
|
80
|
+
* or on MOUNT_FAILED, or ran out its grace; 65 and 70 mark run.json failed, which is terminal). The next start counts
|
|
81
|
+
* it in its mark (`failures`, `lastExit`) and gets the base grace doubled per consecutive failure, capped by
|
|
82
|
+
* `startBackoffMaxMs`, so a run whose starts keep failing is started a handful of times an hour, not once a tick. A
|
|
83
|
+
* start that writes run.json at its generation spends its mark, and the next start counts from 0.
|
|
84
|
+
* No wait follows a revoke: the fence is Archil's, enforced by the server at the old holder's next fsync. Two racing
|
|
85
|
+
* supervisors are safe: the mount admits one claimant and the other instance exits 76.
|
|
86
|
+
*/
|
|
87
|
+
export async function ensureRunning(ref, host, options) {
|
|
88
|
+
const opts = { ...options, control: withTimeouts(options.control, options.controlTimeoutMs ?? CONTROL_TIMEOUT_MS) };
|
|
89
|
+
const now = opts.now ?? Date.now;
|
|
90
|
+
const leaseExpiryMs = opts.leaseExpiryMs ?? LEASE_EXPIRY_MS;
|
|
91
|
+
const status = await readRunStatus(opts.control, ref.id);
|
|
92
|
+
const generation = status?.generation ?? 0;
|
|
93
|
+
let woke = false;
|
|
94
|
+
if (status) {
|
|
95
|
+
if (status.status === "done" || status.status === "failed")
|
|
96
|
+
return { action: "terminal", status: status.status, generation, detail: status.detail };
|
|
97
|
+
if (status.status === "paused" && !opts.demand)
|
|
98
|
+
return { action: "paused", generation };
|
|
99
|
+
if (status.status === "sleeping") {
|
|
100
|
+
// null is an idle release, woken by a request; the reader has already refused an unreadable time.
|
|
101
|
+
const at = status.wakeAt === null ? Number.POSITIVE_INFINITY : Date.parse(status.wakeAt);
|
|
102
|
+
if (!opts.demand && at > now())
|
|
103
|
+
return { action: "sleeping", wakeAt: status.wakeAt, generation };
|
|
104
|
+
woke = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// A due wake still goes through the delegation check: an instance that wrote `sleeping` and died before releasing
|
|
108
|
+
// left a held or orphaned delegation, which a plain start would hit (76) on every tick.
|
|
109
|
+
const { held, all } = await listHeld(opts.control, ref.id, opts.pathless);
|
|
110
|
+
// A start in flight: the instance has not written run.json at its generation yet (it may not even have mounted).
|
|
111
|
+
// Revoking it or starting a second one would fence what was just started.
|
|
112
|
+
const baseGraceMs = opts.startGraceMs ?? leaseExpiryMs;
|
|
113
|
+
const maxGraceMs = opts.startBackoffMaxMs ?? START_BACKOFF_MAX_MS;
|
|
114
|
+
let lastMark;
|
|
115
|
+
const lastStart = () => (lastMark ??= baseGraceMs > 0 ? readStartMark(opts.control, ref.id) : Promise.resolve(null));
|
|
116
|
+
const starting = async () => {
|
|
117
|
+
const mark = await lastStart();
|
|
118
|
+
if (!mark || mark.generation <= generation)
|
|
119
|
+
return null;
|
|
120
|
+
const graceMs = startGrace(baseGraceMs, mark.failures, maxGraceMs);
|
|
121
|
+
const sinceMs = now() - Date.parse(mark.at);
|
|
122
|
+
// A mark more than a grace ahead of this clock is treated as spent, so a clock step cannot hold a run indefinitely.
|
|
123
|
+
if (Math.abs(sinceMs) >= graceMs)
|
|
124
|
+
return null;
|
|
125
|
+
return { action: "starting", generation: mark.generation, sinceMs, graceMs, failures: mark.failures, lastExit: mark.lastExit, by: mark.by, delegations: held.length };
|
|
126
|
+
};
|
|
127
|
+
const start = async (reason, revoked = [], stonith, created = false) => {
|
|
128
|
+
const prev = await lastStart();
|
|
129
|
+
// The last start never wrote its generation, so it failed: this one backs off.
|
|
130
|
+
const failed = prev !== null && prev.generation > generation;
|
|
131
|
+
const failures = failed ? prev.failures + 1 : 0;
|
|
132
|
+
const lastExit = failed ? seenOfFailedStart(held) : null;
|
|
133
|
+
const backoff = { prev, failures, lastExit, graceMs: startGrace(baseGraceMs, failures, maxGraceMs) };
|
|
134
|
+
return startInstance(ref, host, opts, { reason, woke, revoked, stonith, created, generation, backoff });
|
|
135
|
+
};
|
|
136
|
+
if (held.length === 0) {
|
|
137
|
+
const inFlight = await starting();
|
|
138
|
+
if (inFlight)
|
|
139
|
+
return inFlight;
|
|
140
|
+
const created = status === null && opts.create ? await ensureRunDir(opts.control, ref.id, opts.create) : false;
|
|
141
|
+
return start("none", [], undefined, created);
|
|
142
|
+
}
|
|
143
|
+
if (held.every((d) => d.isOrphaned)) {
|
|
144
|
+
const inFlight = await starting();
|
|
145
|
+
if (inFlight)
|
|
146
|
+
return inFlight;
|
|
147
|
+
await revokeListed(opts.control, ref.id, held, all);
|
|
148
|
+
return start("orphaned", held);
|
|
149
|
+
}
|
|
150
|
+
if (held.some((d) => d.isPending && !d.isOrphaned))
|
|
151
|
+
return { action: "pending", delegations: held.length };
|
|
152
|
+
const beat = status?.heartbeatAt ? Date.parse(status.heartbeatAt) : Number.NaN;
|
|
153
|
+
const ageMs = now() - beat;
|
|
154
|
+
if (Number.isFinite(ageMs) && ageMs <= leaseExpiryMs)
|
|
155
|
+
return { action: "healthy", heartbeatAt: status.heartbeatAt, ageMs, generation };
|
|
156
|
+
const inFlight = await starting();
|
|
157
|
+
if (inFlight)
|
|
158
|
+
return inFlight;
|
|
159
|
+
const stonith = status?.holder ? await stopHolder(host, status.holder, opts.stonithTimeoutMs ?? STONITH_TIMEOUT_MS) : { outcome: "no-holder" };
|
|
160
|
+
await revokeListed(opts.control, ref.id, held, all);
|
|
161
|
+
return start("lease-expired", held, stonith);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The supervisor's record of its last start of a run: the generation the instance will write, when, by whom, and the
|
|
165
|
+
* failed starts right before it (`failures`; `lastExit`, what the decision saw of the last one).
|
|
166
|
+
*/
|
|
167
|
+
export const START_MARK = "start.json";
|
|
168
|
+
/** The grace of a start that follows `failures` failed ones: the base doubled per failure, capped, never below the base. */
|
|
169
|
+
export function startGrace(baseMs, failures, maxMs = START_BACKOFF_MAX_MS) {
|
|
170
|
+
if (baseMs <= 0)
|
|
171
|
+
return 0;
|
|
172
|
+
return Math.max(baseMs, Math.min(maxMs, baseMs * 2 ** failures));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* What the decision saw of a start that never wrote its generation (a diagnostic, not a trigger): no delegation (it
|
|
176
|
+
* never mounted, or let go), its client gone (orphaned), or a delegation still held (mounted, never wrote).
|
|
177
|
+
*/
|
|
178
|
+
function seenOfFailedStart(held) {
|
|
179
|
+
return held.length === 0 ? "no-delegation" : held.every((d) => d.isOrphaned) ? "orphaned" : "held";
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The start mark over S3, or null when absent or unreadable (an unreadable mark gives no grace). It lives in the run's
|
|
183
|
+
* directory, so it goes with the run (deleteRunTree removes it), and a later start overwrites it; a mark whose
|
|
184
|
+
* generation run.json has reached is spent.
|
|
185
|
+
*/
|
|
186
|
+
export async function readStartMark(control, id) {
|
|
187
|
+
let text;
|
|
188
|
+
try {
|
|
189
|
+
text = new TextDecoder().decode(await control.getObject(`${runPath(id)}/${START_MARK}`));
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
if (notFound(err))
|
|
193
|
+
return null;
|
|
194
|
+
throw new SuperviseError("CONTROL_API_FAILED", `GetObject ${runPath(id)}/${START_MARK} failed`, { cause: err });
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const v = JSON.parse(text);
|
|
198
|
+
if (!Number.isSafeInteger(v.generation) || v.generation < 1 || typeof v.at !== "string" || !Number.isFinite(Date.parse(v.at)))
|
|
199
|
+
return null;
|
|
200
|
+
const failures = Number.isSafeInteger(v.failures) && v.failures >= 0 ? v.failures : 0;
|
|
201
|
+
const lastExit = typeof v.lastExit === "string" ? v.lastExit : null;
|
|
202
|
+
return { generation: v.generation, at: v.at, by: typeof v.by === "string" ? v.by : null, failures, lastExit };
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** Only a run with no run.json and no holder gets here, so the directory is either absent or idle. */
|
|
209
|
+
async function ensureRunDir(control, id, owner) {
|
|
210
|
+
let exists;
|
|
211
|
+
try {
|
|
212
|
+
exists = await control.headObject(`${runPath(id)}/`);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
throw new SuperviseError("CONTROL_API_FAILED", `HeadObject ${runPath(id)}/ failed`, { cause: err });
|
|
216
|
+
}
|
|
217
|
+
if (exists)
|
|
218
|
+
return false;
|
|
219
|
+
await createRunDir(control, id, owner);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
/** The run's delegations (`matchDelegations`), and the listing they came from. */
|
|
223
|
+
async function listHeld(control, id, resolve) {
|
|
224
|
+
try {
|
|
225
|
+
const all = await control.listDelegations();
|
|
226
|
+
return { held: await matchDelegations(all, id, resolve ?? pathlessResolver(control)), all };
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
throw new SuperviseError("CONTROL_API_FAILED", `listing delegations on ${runPath(id)} failed`, { cause: err });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const sameDelegation = (a, b) => a.clientId === b.clientId && a.inodeId === b.inodeId;
|
|
233
|
+
/**
|
|
234
|
+
* Revoke exactly the delegations the decision was made on, never a fresh listing: a racing supervisor may have started
|
|
235
|
+
* a new holder since, and that one is not ours to judge. A revoke that fails is fine only if the delegation is gone.
|
|
236
|
+
* Then the same clients' private directories (`revokeCompanions`): those clients are gone or fenced.
|
|
237
|
+
*/
|
|
238
|
+
async function revokeListed(control, id, held, all) {
|
|
239
|
+
for (const d of held) {
|
|
240
|
+
try {
|
|
241
|
+
await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId });
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
const still = await listHeld(control, id).then((now) => now.held.some((n) => sameDelegation(n, d)));
|
|
245
|
+
if (still)
|
|
246
|
+
throw new SuperviseError("CONTROL_API_FAILED", `revoking ${runPath(id)} (client ${d.clientId}) failed`, { cause: err });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
await revokeCompanions(control, held, all);
|
|
250
|
+
}
|
|
251
|
+
function settleWithin(promise, ms) {
|
|
252
|
+
return new Promise((resolve) => {
|
|
253
|
+
const timer = setTimeout(() => resolve("timeout"), ms);
|
|
254
|
+
promise.then((value) => (clearTimeout(timer), resolve({ value })), (error) => (clearTimeout(timer), resolve({ error })));
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* STONITH, best effort: ask the driver about the holder and stop it unless the driver cannot reach it. A holder that
|
|
259
|
+
* already stopped or failed is stopped too, so the driver cleans what it left on its host (a revoked mount the next
|
|
260
|
+
* instance there would otherwise find). Never waits past `ms`.
|
|
261
|
+
*/
|
|
262
|
+
async function stopHolder(host, holder, ms) {
|
|
263
|
+
const t0 = performance.now();
|
|
264
|
+
const r = await settleWithin((async () => {
|
|
265
|
+
const status = await host.status(holder);
|
|
266
|
+
if (status === "unknown" || status === "gone")
|
|
267
|
+
return { status, stopped: false };
|
|
268
|
+
await host.stop(holder);
|
|
269
|
+
return { status, stopped: true };
|
|
270
|
+
})(), ms);
|
|
271
|
+
const elapsed = Math.round(performance.now() - t0);
|
|
272
|
+
if (r === "timeout")
|
|
273
|
+
return { outcome: "timed-out", ms: elapsed };
|
|
274
|
+
if ("error" in r)
|
|
275
|
+
return { outcome: "failed", ms: elapsed, error: r.error instanceof Error ? r.error.message : String(r.error) };
|
|
276
|
+
const { status, stopped } = r.value;
|
|
277
|
+
return stopped ? { outcome: "stopped", status, ms: elapsed } : { outcome: "unreachable", status: status };
|
|
278
|
+
}
|
|
279
|
+
/** A mount token per start attempt, then the driver. A driver that throws used no token, so its user is removed. */
|
|
280
|
+
async function startInstance(ref, host, opts, why) {
|
|
281
|
+
const t0 = performance.now();
|
|
282
|
+
let token;
|
|
283
|
+
try {
|
|
284
|
+
// The attempt is the generation the instance will write, so the token user names the incarnation it served.
|
|
285
|
+
token = await mintMountToken(opts.control, { run: { id: ref.id, attempt: why.generation + 1 }, prefix: opts.tokenPrefix, ttl: opts.tokenTtl ?? TOKEN_TTL, now: opts.now });
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
throw new SuperviseError("CONTROL_API_FAILED", `minting a mount token for ${runPath(ref.id)} failed`, { cause: err });
|
|
289
|
+
}
|
|
290
|
+
// The mark goes before the driver, so a tick that runs while this start is in flight (in this process or another)
|
|
291
|
+
// sees it. The run holds no delegation here (none, or revoked just now), so S3 accepts the write. A mark that cannot be
|
|
292
|
+
// written costs only the grace.
|
|
293
|
+
const { prev, failures, lastExit, graceMs } = why.backoff;
|
|
294
|
+
const writeMark = (mark) => opts.control.putObject(`${runPath(ref.id)}/${START_MARK}`, JSON.stringify(mark), { uid: 0, gid: 0, mode: 0o644 }).then(() => true, () => false);
|
|
295
|
+
const at = new Date((opts.now ?? Date.now)()).toISOString();
|
|
296
|
+
const by = opts.supervisorId ?? `${hostname()}:${process.pid}`;
|
|
297
|
+
const startMark = await writeMark({ generation: why.generation + 1, at, by, failures, lastExit });
|
|
298
|
+
let handle;
|
|
299
|
+
try {
|
|
300
|
+
handle = await host.start(ref, token.token, { attempt: why.generation + 1 });
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
// A driver that throws started nothing: its token goes, and the mark goes back to the last start's (whose grace is
|
|
304
|
+
// over, or there would be no start), so the next tick starts again and this attempt adds no failure. With no last
|
|
305
|
+
// start, the mark falls back to run.json's generation (spent).
|
|
306
|
+
await removeMountToken(opts.control, token.identifier).catch(() => { });
|
|
307
|
+
if (startMark)
|
|
308
|
+
await writeMark(prev ?? { generation: why.generation, at, by, failures: 0, lastExit: null });
|
|
309
|
+
throw new SuperviseError("HOST_START_FAILED", `starting an instance of ${runPath(ref.id)} failed: ${err?.message ?? err}`, { cause: err });
|
|
310
|
+
}
|
|
311
|
+
// An adopted instance mounts with the token of the start that made it; this one's goes now, not at a later sweep, which
|
|
312
|
+
// keeps a run's users while it holds a delegation.
|
|
313
|
+
const adopted = handle.adopted === true;
|
|
314
|
+
if (adopted)
|
|
315
|
+
await removeMountToken(opts.control, token.identifier).catch(() => { });
|
|
316
|
+
return {
|
|
317
|
+
action: "started",
|
|
318
|
+
reason: why.reason,
|
|
319
|
+
woke: why.woke,
|
|
320
|
+
revoked: why.revoked.map(({ clientId, inodeId, path, isOrphaned }) => ({ clientId, inodeId, path, isOrphaned })),
|
|
321
|
+
...(why.stonith ? { stonith: why.stonith } : {}),
|
|
322
|
+
created: why.created,
|
|
323
|
+
handle,
|
|
324
|
+
token: { identifier: token.identifier, nickname: token.nickname },
|
|
325
|
+
...(adopted ? { adopted: true } : {}),
|
|
326
|
+
startMark,
|
|
327
|
+
failures,
|
|
328
|
+
lastExit,
|
|
329
|
+
graceMs,
|
|
330
|
+
startMs: Math.round(performance.now() - t0),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
/** One pass over many runs: each decision on its own, so a run whose calls fail or time out costs only its own line. */
|
|
334
|
+
export async function superviseRuns(refs, host, opts) {
|
|
335
|
+
const out = [];
|
|
336
|
+
const pass = { ...opts, pathless: opts.pathless ?? pathlessResolver(opts.control) };
|
|
337
|
+
for (const ref of refs) {
|
|
338
|
+
const t0 = performance.now();
|
|
339
|
+
try {
|
|
340
|
+
const r = await ensureRunning(ref, host, pass);
|
|
341
|
+
out.push({ run: ref.id, ms: Math.round(performance.now() - t0), ...r });
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
const e = err;
|
|
345
|
+
out.push({ run: ref.id, ms: Math.round(performance.now() - t0), action: "error", error: e.code ?? "ERROR", message: e.message, ...(e.cause instanceof Error ? { cause: e.cause.message } : {}) });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
const RELEASED = ["paused", "sleeping", "done", "failed"];
|
|
351
|
+
/**
|
|
352
|
+
* Remove mount-token users that no live mount needs. Removing a token user under a live mount makes that mount lose its
|
|
353
|
+
* claim at the client's next re-authentication, so a run's users go only when the run holds no delegation, its
|
|
354
|
+
* run.json says it is released (paused, sleeping, done, failed; never running, absent or unreadable), and the user is
|
|
355
|
+
* older than `graceMs` (a start mints before it mounts). With `expired`, users whose TTL has passed go too: an expired
|
|
356
|
+
* token already fences its mount at the next refresh.
|
|
357
|
+
*/
|
|
358
|
+
export async function sweepTokens(opts) {
|
|
359
|
+
if (!opts.prefix)
|
|
360
|
+
throw new SuperviseError("CONTROL_API_FAILED", "a token sweep needs a nickname prefix");
|
|
361
|
+
const now = (opts.now ?? Date.now)();
|
|
362
|
+
const grace = opts.graceMs ?? TOKEN_GRACE_MS;
|
|
363
|
+
const users = (await opts.listUsers()).filter((u) => u.identifier && u.nickname?.startsWith(opts.prefix));
|
|
364
|
+
const sweep = { removed: [], failed: [] };
|
|
365
|
+
if (!users.length)
|
|
366
|
+
return sweep;
|
|
367
|
+
const delegations = await opts.control.listDelegations();
|
|
368
|
+
// A run whose pathless delegations cannot be attributed counts as held: its users stay.
|
|
369
|
+
const heldBy = new Map();
|
|
370
|
+
const resolve = pathlessResolver(opts.control);
|
|
371
|
+
const held = (id) => {
|
|
372
|
+
if (!heldBy.has(id))
|
|
373
|
+
heldBy.set(id, matchDelegations(delegations, id, resolve).then((d) => d.length > 0, () => true));
|
|
374
|
+
return heldBy.get(id);
|
|
375
|
+
};
|
|
376
|
+
const released = new Map();
|
|
377
|
+
const isReleased = (id) => {
|
|
378
|
+
if (!released.has(id)) {
|
|
379
|
+
released.set(id, readRunStatus(opts.control, id).then((r) => r !== null && RELEASED.includes(r.status), () => false));
|
|
380
|
+
}
|
|
381
|
+
return released.get(id);
|
|
382
|
+
};
|
|
383
|
+
for (const u of users) {
|
|
384
|
+
const parsed = parseTokenNickname(u.nickname, opts.prefix);
|
|
385
|
+
const created = u.createdAt ? Date.parse(u.createdAt) : (parsed?.at ?? Number.NaN);
|
|
386
|
+
const expiry = u.expiresAt ? Date.parse(u.expiresAt) : Number.NaN;
|
|
387
|
+
let why = null;
|
|
388
|
+
if (opts.expired && (u.status === "expired" || expiry <= now))
|
|
389
|
+
why = "expired";
|
|
390
|
+
else if (parsed && (!opts.runs || opts.runs.includes(parsed.runId)) && now - created > grace && !(await held(parsed.runId)) && (await isReleased(parsed.runId))) {
|
|
391
|
+
why = "released";
|
|
392
|
+
}
|
|
393
|
+
if (!why)
|
|
394
|
+
continue;
|
|
395
|
+
await opts.control.removeUser("token", u.identifier).then(() => sweep.removed.push({ identifier: u.identifier, run: parsed?.runId ?? null, why: why }), (e) => sweep.failed.push({ identifier: u.identifier, error: e.message }));
|
|
396
|
+
}
|
|
397
|
+
return sweep;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Prove on this host and client version that the claim's two server-side guarantees hold before serving runs: a second
|
|
401
|
+
* exclusive mount of a probe directory is refused while the first holds it, and a revoked mount's fsync fails (and its
|
|
402
|
+
* barrier fences). Then a third mount takes over. Every token, mount and the probe directory are removed.
|
|
403
|
+
*/
|
|
404
|
+
export async function checkHost(options) {
|
|
405
|
+
const opts = { ...options, control: withTimeouts(options.control, options.controlTimeoutMs ?? CONTROL_TIMEOUT_MS) };
|
|
406
|
+
const id = `${opts.idPrefix ?? "pda-check-"}${hostname().replace(/[^A-Za-z0-9._-]/g, "-")}-${Date.now().toString(36)}`.slice(0, 120);
|
|
407
|
+
const ref = { disk: opts.disk, region: opts.region, id };
|
|
408
|
+
const note = opts.onResource ?? (() => { });
|
|
409
|
+
const steps = [];
|
|
410
|
+
const cleanup = [];
|
|
411
|
+
const tokens = [];
|
|
412
|
+
const claims = [];
|
|
413
|
+
let created = false;
|
|
414
|
+
let first = null;
|
|
415
|
+
const mount = async (mountRoot, tag) => {
|
|
416
|
+
const nickname = `${opts.tokenPrefix ?? "pda-"}check-${tag}-${Date.now().toString(36)}`.slice(0, 64);
|
|
417
|
+
const t = await mintMountToken(opts.control, { nickname, ttl: "1h" });
|
|
418
|
+
tokens.push(t.identifier);
|
|
419
|
+
note("token", t.identifier, nickname);
|
|
420
|
+
const c = await acquire({ ref, token: t.token, mountRoot, host: opts.host });
|
|
421
|
+
claims.push(c);
|
|
422
|
+
note("mount", c.root, `${ref.disk}:/${runPath(id)}`);
|
|
423
|
+
return c;
|
|
424
|
+
};
|
|
425
|
+
const plan = [
|
|
426
|
+
["create probe directory", async () => {
|
|
427
|
+
await createRunDir(opts.control, id, opts.owner);
|
|
428
|
+
created = true;
|
|
429
|
+
note("subdir", `${runPath(id)}/`);
|
|
430
|
+
return undefined;
|
|
431
|
+
}],
|
|
432
|
+
["first exclusive mount", async () => {
|
|
433
|
+
first = await mount(opts.mountRoot, "a");
|
|
434
|
+
return `mount ${Math.round(first.timings.mountMs)} ms, verify ${Math.round(first.timings.verifyMs)} ms`;
|
|
435
|
+
}],
|
|
436
|
+
["second exclusive mount is refused", async () => {
|
|
437
|
+
const err = await mount(join(opts.mountRoot, ".check-b"), "b").then(() => null, (e) => e);
|
|
438
|
+
if (err === null)
|
|
439
|
+
throw new Error("a second client mounted the held directory");
|
|
440
|
+
if (!(err instanceof HeldError))
|
|
441
|
+
throw err;
|
|
442
|
+
return "HeldError (76)";
|
|
443
|
+
}],
|
|
444
|
+
["revoke the first mount", async () => {
|
|
445
|
+
const held = await findDelegations(opts.control, id);
|
|
446
|
+
if (held.length !== 1)
|
|
447
|
+
throw new Error(`expected one delegation, found ${held.length}`);
|
|
448
|
+
await opts.control.revokeDelegation(held[0]);
|
|
449
|
+
return undefined;
|
|
450
|
+
}],
|
|
451
|
+
["revoked mount's fsync fails", async () => {
|
|
452
|
+
const fh = await open(join(first.root, ".check-revoked"), "w");
|
|
453
|
+
const err = await fh.writeFile("revoked\n").then(() => fh.sync()).then(() => null, (e) => e);
|
|
454
|
+
await fh.close().catch(() => { });
|
|
455
|
+
if (err === null)
|
|
456
|
+
throw new Error("write and fsync succeeded on a revoked mount");
|
|
457
|
+
return err.code ?? String(err);
|
|
458
|
+
}],
|
|
459
|
+
["revoked mount's barrier fences", async () => {
|
|
460
|
+
const err = await first.barrier().then(() => null, (e) => e);
|
|
461
|
+
if (err === null)
|
|
462
|
+
throw new Error("archil sync succeeded on a revoked mount");
|
|
463
|
+
if (!(err instanceof FencedError))
|
|
464
|
+
throw err;
|
|
465
|
+
return "FencedError (75)";
|
|
466
|
+
}],
|
|
467
|
+
["a new mount takes over", async () => {
|
|
468
|
+
const c = await mount(join(opts.mountRoot, ".check-b"), "c");
|
|
469
|
+
return `mount ${Math.round(c.timings.mountMs)} ms`;
|
|
470
|
+
}],
|
|
471
|
+
];
|
|
472
|
+
try {
|
|
473
|
+
for (const [name, fn] of plan) {
|
|
474
|
+
const t0 = performance.now();
|
|
475
|
+
const r = await fn().then((detail) => ({ ok: true, detail }), (e) => ({ ok: false, detail: e instanceof Error ? `${e.name}: ${e.message}` : String(e) }));
|
|
476
|
+
steps.push({ step: name, ok: r.ok, ms: Math.round(performance.now() - t0), ...(r.detail ? { detail: r.detail } : {}) });
|
|
477
|
+
if (!r.ok)
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
// release() unmounts even when its barrier finds the claim fenced; unmountClaim then confirms against the mount table.
|
|
483
|
+
for (const c of claims.reverse()) {
|
|
484
|
+
await c.release().catch(() => { });
|
|
485
|
+
const via = await unmountClaim(c.root, opts.host).then((v) => (v === "none" ? "released" : v), (e) => `failed: ${e.message}`);
|
|
486
|
+
cleanup.push(`unmount ${c.root}: ${via}`);
|
|
487
|
+
note("unmount", c.root, via);
|
|
488
|
+
}
|
|
489
|
+
if (created) {
|
|
490
|
+
const r = await deleteRunTree(opts.control, id).then(() => "deleted", (e) => `failed: ${e.message}`);
|
|
491
|
+
cleanup.push(`delete ${runPath(id)}/: ${r}`);
|
|
492
|
+
if (r === "deleted")
|
|
493
|
+
note("subdir-deleted", `${runPath(id)}/`);
|
|
494
|
+
}
|
|
495
|
+
for (const t of tokens) {
|
|
496
|
+
const r = await removeMountToken(opts.control, t).then(() => "removed", (e) => `failed: ${e.message}`);
|
|
497
|
+
cleanup.push(`token ${t}: ${r}`);
|
|
498
|
+
if (r === "removed")
|
|
499
|
+
note("token-removed", t);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const ok = steps.length === plan.length && steps.every((s) => s.ok) && cleanup.every((c) => !c.includes("failed"));
|
|
503
|
+
return { ok, host: hostname(), run: id, steps, cleanup };
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Delete a run's directory tree over S3. A tree with a delegation on it, even an orphaned one, is not deleted: S3
|
|
507
|
+
* DeleteObjects leaves its objects in place and reports no error, and the run id would later reopen with its old
|
|
508
|
+
* store. So: revoke every delegation on the subtree and its holders' private directories, delete the files, then the
|
|
509
|
+
* directory markers deepest first (a directory with children refuses), then list the prefix. Anything left is
|
|
510
|
+
* RUN_TREE_NOT_DELETED; a non-empty prefix is never reported as deleted.
|
|
511
|
+
*/
|
|
512
|
+
export async function deleteRunTree(control, id) {
|
|
513
|
+
const prefix = `${runPath(id)}/`;
|
|
514
|
+
const held = await revokeBestEffort(control, id);
|
|
515
|
+
const keys = (await control.listObjects(prefix, { recursive: true })).objects.map((o) => o.key);
|
|
516
|
+
const dirs = [...new Set([...keys.filter((k) => k.endsWith("/")), prefix])];
|
|
517
|
+
const depth = (k) => k.split("/").length;
|
|
518
|
+
let errors = (await control.deleteObjects(keys.filter((k) => !k.endsWith("/")), { quiet: true })).errors.length;
|
|
519
|
+
for (const d of [...new Set(dirs.map(depth))].sort((x, y) => y - x)) {
|
|
520
|
+
errors += (await control.deleteObjects(dirs.filter((k) => depth(k) === d), { quiet: true })).errors.length;
|
|
521
|
+
}
|
|
522
|
+
const left = (await control.listObjects(prefix, { recursive: true })).objects.length;
|
|
523
|
+
if (left > 0) {
|
|
524
|
+
const still = (await findDelegations(control, id).catch(() => [])).length;
|
|
525
|
+
throw new SuperviseError("RUN_TREE_NOT_DELETED", `${prefix} still holds ${left} objects (${errors} delete errors, ${still} delegations after revoking ${held.length})`);
|
|
526
|
+
}
|
|
527
|
+
return { objects: keys.length, revoked: held.length };
|
|
528
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Slots of the shared BigInt64Array. Clock readings are `process.hrtime.bigint()`, monotonic and the same on every thread. */
|
|
2
|
+
export declare const SLOT_BEAT = 0;
|
|
3
|
+
export declare const SLOT_LIMIT = 1;
|
|
4
|
+
export declare const SLOT_LAPSED = 2;
|
|
5
|
+
export declare const SLOT_KILLED = 3;
|
|
6
|
+
export interface SweepOptions {
|
|
7
|
+
/** The instance runs in a cgroup of its own (a systemd unit, a container): every other process in it is killed too. */
|
|
8
|
+
readonly ownCgroup?: boolean;
|
|
9
|
+
readonly proc?: string;
|
|
10
|
+
readonly sys?: string;
|
|
11
|
+
readonly kill?: (pid: number, signal: NodeJS.Signals) => void;
|
|
12
|
+
}
|
|
13
|
+
/** One look at the shared state at `now`. True once the lease has lapsed, for good; the first look records the age. */
|
|
14
|
+
export declare const lapsed: (shared: BigInt64Array, now: bigint) => boolean;
|
|
15
|
+
/**
|
|
16
|
+
* SIGKILL the instance's commands: the process group of each direct child (pi starts each command as its own group's
|
|
17
|
+
* leader), or the child alone when it shares the instance's group; with `ownCgroup`, every other process of the cgroup.
|
|
18
|
+
* Never the instance or its group. A process this user may not signal (sudo's archil helpers) is skipped. Returns the
|
|
19
|
+
* number of signals sent. `stat` lines are `pid (comm) state ppid pgrp ...`, read after the last `)` since comm may hold
|
|
20
|
+
* spaces and parentheses.
|
|
21
|
+
*/
|
|
22
|
+
export declare const sweep: (instance: number, options?: SweepOptions) => number;
|
|
23
|
+
/** The main thread's handle on the watchdog thread. */
|
|
24
|
+
export declare class LeaseWatchdog {
|
|
25
|
+
#private;
|
|
26
|
+
/** Starts the thread; `beatNs` is when the last successful heartbeat write started. */
|
|
27
|
+
constructor(options: {
|
|
28
|
+
limitMs: number;
|
|
29
|
+
checkMs: number;
|
|
30
|
+
beatNs: bigint;
|
|
31
|
+
ownCgroup?: boolean;
|
|
32
|
+
});
|
|
33
|
+
/** A heartbeat write that started at `startNs` succeeded. */
|
|
34
|
+
beat(startNs: bigint): void;
|
|
35
|
+
/** Once the watchdog found the lease lapsed: how long before that the last successful heartbeat started, in ms. */
|
|
36
|
+
get lapsedAfterMs(): number | undefined;
|
|
37
|
+
get killed(): number;
|
|
38
|
+
stop(): void;
|
|
39
|
+
}
|