@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
package/dist/run.js
ADDED
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
// The run lifecycle in two layers. `openRunLease` is the claim's lifecycle without a store or a Harness: the
|
|
2
|
+
// claim, the owner lock, `run.json` with its heartbeat, the self-fence (a timer and a watchdog thread), the seal, the
|
|
3
|
+
// fence and the release; an app that owns its own Harness and store connections uses it directly. `openDurableRun`
|
|
4
|
+
// builds the whole run on it: the store, the seal check, pi's Harness, resume. A fence reported by the store, the claim,
|
|
5
|
+
// a write of the run's own files or the lapsed lease kills the run's commands and ends the instance (exit 75). Nothing
|
|
6
|
+
// is retried. The lease writes its own files through the claim directory (`ClaimDir`), never by path.
|
|
7
|
+
import { mkdir } from "node:fs/promises";
|
|
8
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
9
|
+
import { DatabaseSync } from "node:sqlite";
|
|
10
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
11
|
+
import { Harness } from "@earendil-works/pi-durable";
|
|
12
|
+
import { acquire } from "./claim.js";
|
|
13
|
+
import { reapOrRefuse } from "./commands.js";
|
|
14
|
+
import { archilEnv } from "./env.js";
|
|
15
|
+
import { EXIT_FENCED, FencedError, HeldError, ownWriteFenced, PdaError } from "./errors.js";
|
|
16
|
+
import { openArchilStore } from "./store.js";
|
|
17
|
+
import { LeaseWatchdog } from "./watchdog.js";
|
|
18
|
+
import { currentHolder, DEFAULT_HEARTBEAT_MS, DEFAULT_LEASE_EXPIRY_MS, DEFAULT_LEASE_MARGIN_MS, openClaimDir, persistRecord, readRunRecord, RUN_JSON, RunRecordError, writeRunRecord, } from "./status.js";
|
|
19
|
+
/** The same-host owner lock under the run's root. */
|
|
20
|
+
export const OWNER_LOCK = "owner.lock";
|
|
21
|
+
/** pi's store under the claim's `store/` directory. */
|
|
22
|
+
export const STORE_FILE = "run.sqlite";
|
|
23
|
+
/** EX_DATAERR: the run's data refuses to open (a store behind its seal, an unreadable `run.json`); a restart cannot help. */
|
|
24
|
+
export const EXIT_DATAERR = 65;
|
|
25
|
+
/** EX_SOFTWARE: this package cannot read the head of pi's store (pi's schema moved); a restart cannot help. */
|
|
26
|
+
export const EXIT_SOFTWARE = 70;
|
|
27
|
+
/**
|
|
28
|
+
* Bad options, a call on a released run, or a store whose head cannot be read (STORE_HEAD_UNREADABLE: the run is marked
|
|
29
|
+
* `failed` and released, exit 70).
|
|
30
|
+
*/
|
|
31
|
+
export class RunError extends PdaError {
|
|
32
|
+
constructor(code, message, options = {}) {
|
|
33
|
+
super(code, message, { cause: options.cause, exitCode: code === "STORE_HEAD_UNREADABLE" ? EXIT_SOFTWARE : 1 });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The store's last committed sequence is below the `sealedSeq` the last clean release wrote: an acknowledged commit is
|
|
38
|
+
* missing. The run is refused before pi commits anything, and marked `failed` in `run.json`. Exit 65.
|
|
39
|
+
*/
|
|
40
|
+
export class StoreBehindSealError extends PdaError {
|
|
41
|
+
sealedSeq;
|
|
42
|
+
/** The store's last committed sequence. */
|
|
43
|
+
head;
|
|
44
|
+
constructor(file, sealedSeq, head) {
|
|
45
|
+
super("STORE_BEHIND_SEAL", `store ${file} is at sequence ${head}, behind its seal at ${sealedSeq}`, { exitCode: EXIT_DATAERR });
|
|
46
|
+
this.sealedSeq = sealedSeq;
|
|
47
|
+
this.head = head;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The self-fence rule: the last heartbeat that succeeded started longer ago than the lease expiry minus its margin. `by`
|
|
52
|
+
* names who saw it: the main thread's timer, or the watchdog thread (which had already killed the commands).
|
|
53
|
+
*/
|
|
54
|
+
export class LeaseLapsedError extends FencedError {
|
|
55
|
+
sinceMs;
|
|
56
|
+
by;
|
|
57
|
+
constructor(sinceMs, limitMs, by = "timer") {
|
|
58
|
+
super(`the last successful heartbeat started ${Math.round(sinceMs)} ms ago, past the ${limitMs} ms self-fence (seen by the ${by})`, { code: "LEASE_LAPSED" });
|
|
59
|
+
this.sinceMs = sinceMs;
|
|
60
|
+
this.by = by;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* `storage`'s four reads on a frozen object that holds nothing else: no `commit` or `mintId` on it, and no reference to
|
|
65
|
+
* `storage` a cast could reach.
|
|
66
|
+
*/
|
|
67
|
+
function storeReads(storage) {
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
scanConversations: (...args) => storage.scanConversations(...args),
|
|
70
|
+
scanTasks: (...args) => storage.scanTasks(...args),
|
|
71
|
+
scanDocuments: (...args) => storage.scanDocuments(...args),
|
|
72
|
+
document: (...args) => storage.document(...args),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** Locks this process holds. A collected DatabaseSync closes its file, which would drop the lock without a word. */
|
|
76
|
+
const heldLocks = new Set();
|
|
77
|
+
const SQLITE_BUSY = 5;
|
|
78
|
+
const SQLITE_LOCKED = 6;
|
|
79
|
+
const isBusy = (error) => {
|
|
80
|
+
const errcode = error.errcode;
|
|
81
|
+
return typeof errcode === "number" && [SQLITE_BUSY, SQLITE_LOCKED].includes(errcode & 0xff);
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Take the run's same-host owner lock for the life of this process. Node has no flock(2), so the lock is
|
|
85
|
+
* SQLite's exclusive POSIX lock on `<root>/owner.lock` (`locking_mode = EXCLUSIVE`, then `BEGIN EXCLUSIVE`), which the
|
|
86
|
+
* kernel drops when the process dies, however it dies. The journal stays in memory, so `owner.lock` is the only file
|
|
87
|
+
* the lock makes. Another process holding it is HeldError("owner-lock") (76); any other error is a fence, since the
|
|
88
|
+
* file is the claim's.
|
|
89
|
+
*
|
|
90
|
+
* Only this function may open `owner.lock` in the process: a POSIX lock is dropped when any descriptor the process holds
|
|
91
|
+
* on the file is closed, so a stray open and close elsewhere would silently release it.
|
|
92
|
+
*/
|
|
93
|
+
export function takeOwnerLock(root) {
|
|
94
|
+
const file = join(root, OWNER_LOCK);
|
|
95
|
+
let db;
|
|
96
|
+
try {
|
|
97
|
+
db = new DatabaseSync(file, { timeout: 0 });
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
throw ownWriteFenced(file, error);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
db.exec("PRAGMA locking_mode = EXCLUSIVE");
|
|
104
|
+
db.exec("PRAGMA journal_mode = MEMORY");
|
|
105
|
+
db.exec("BEGIN EXCLUSIVE");
|
|
106
|
+
db.exec("COMMIT");
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
try {
|
|
110
|
+
db.close();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// The lock was never ours; the refusal is the error to report.
|
|
114
|
+
}
|
|
115
|
+
if (isBusy(error))
|
|
116
|
+
throw new HeldError("owner-lock", `${file} is held by another process on this host`, { cause: error });
|
|
117
|
+
throw ownWriteFenced(file, error);
|
|
118
|
+
}
|
|
119
|
+
heldLocks.add(db);
|
|
120
|
+
return {
|
|
121
|
+
file,
|
|
122
|
+
release() {
|
|
123
|
+
if (!heldLocks.delete(db))
|
|
124
|
+
return;
|
|
125
|
+
try {
|
|
126
|
+
db.close();
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
throw ownWriteFenced(file, error);
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable's public
|
|
136
|
+
* `Storage` has no head query (1.0.4 and 1.1.0), and the seal check must run before `Harness.open` can commit (its
|
|
137
|
+
* recovery commit would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq`
|
|
138
|
+
* and stores the successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store
|
|
139
|
+
* without that table, row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the
|
|
140
|
+
* check silently; a unit test pins the read to the sequence pi's commits return.
|
|
141
|
+
*
|
|
142
|
+
* `database` is the store's fence-aware facade (`FencedDatabase`), which turns an I/O-class error into a fence; through
|
|
143
|
+
* a raw connection such an error would read as STORE_HEAD_UNREADABLE.
|
|
144
|
+
*/
|
|
145
|
+
export async function storeHead(store) {
|
|
146
|
+
let row;
|
|
147
|
+
try {
|
|
148
|
+
row = await store.database.get("SELECT next_seq FROM durable_metadata WHERE singleton = 1");
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (error instanceof FencedError)
|
|
152
|
+
throw error;
|
|
153
|
+
throw new RunError("STORE_HEAD_UNREADABLE", `cannot read the head of ${store.file}`, { cause: error });
|
|
154
|
+
}
|
|
155
|
+
const next = typeof row?.next_seq === "bigint" ? Number(row.next_seq) : row?.next_seq;
|
|
156
|
+
if (typeof next !== "number" || !Number.isSafeInteger(next) || next < 1) {
|
|
157
|
+
throw new RunError("STORE_HEAD_UNREADABLE", `${store.file} has no usable durable_metadata.next_seq (${JSON.stringify(row ?? null)})`);
|
|
158
|
+
}
|
|
159
|
+
return next - 1;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* `storage` with `before` run ahead of every commit (it may throw to refuse it), every resolved commit's sequence
|
|
163
|
+
* reported to `after` and every rejected commit's error to `failed`; everything else delegates unchanged.
|
|
164
|
+
*/
|
|
165
|
+
function observeCommits(storage, hooks) {
|
|
166
|
+
return new Proxy(storage, {
|
|
167
|
+
get(target, key) {
|
|
168
|
+
if (key === "commit") {
|
|
169
|
+
return async (writes, context) => {
|
|
170
|
+
hooks.before();
|
|
171
|
+
let seq;
|
|
172
|
+
try {
|
|
173
|
+
seq = await target.commit(writes, context);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
hooks.failed(error);
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
hooks.after(seq);
|
|
180
|
+
return seq;
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const value = Reflect.get(target, key, target);
|
|
184
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
// ---- the lease --------------------------------------------------------------------------------------------------------
|
|
189
|
+
const asFenced = (error, what) => error instanceof FencedError ? error : new FencedError(`${what} failed, so the claim cannot be trusted`, { cause: error });
|
|
190
|
+
function exitFenced(error) {
|
|
191
|
+
process.stderr.write(`pi-durable-disk: fenced (${error.code}): ${error.message}\n`);
|
|
192
|
+
process.exit(EXIT_FENCED);
|
|
193
|
+
}
|
|
194
|
+
function stepper(onStep) {
|
|
195
|
+
return async (name, run) => {
|
|
196
|
+
const t0 = performance.now();
|
|
197
|
+
const value = await run();
|
|
198
|
+
onStep?.(name, performance.now() - t0);
|
|
199
|
+
return value;
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function leaseOf(options = {}) {
|
|
203
|
+
const lease = {
|
|
204
|
+
heartbeatMs: options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS,
|
|
205
|
+
expiryMs: options.expiryMs ?? DEFAULT_LEASE_EXPIRY_MS,
|
|
206
|
+
marginMs: options.marginMs ?? DEFAULT_LEASE_MARGIN_MS,
|
|
207
|
+
checkMs: options.checkMs ?? 1000,
|
|
208
|
+
};
|
|
209
|
+
const limit = lease.expiryMs - lease.marginMs;
|
|
210
|
+
if (!(lease.heartbeatMs > 0 && lease.checkMs > 0 && lease.marginMs >= 0 && limit > lease.heartbeatMs)) {
|
|
211
|
+
throw new RunError("INVALID_ARGUMENT", `lease ${JSON.stringify(lease)}: the self-fence (expiry minus margin) must exceed the heartbeat period`);
|
|
212
|
+
}
|
|
213
|
+
return lease;
|
|
214
|
+
}
|
|
215
|
+
const noCleanup = async () => undefined;
|
|
216
|
+
class Lease {
|
|
217
|
+
ref;
|
|
218
|
+
claim;
|
|
219
|
+
/** The claim itself, for a layer that builds on the lease (`openDurableRun` builds the env on it). */
|
|
220
|
+
rawClaim;
|
|
221
|
+
#options;
|
|
222
|
+
#context;
|
|
223
|
+
#lease;
|
|
224
|
+
#clock;
|
|
225
|
+
#persist;
|
|
226
|
+
#cleanup;
|
|
227
|
+
#onFenced;
|
|
228
|
+
#openDir;
|
|
229
|
+
/** The claim's root held open: every write of the lease's own files goes through it. */
|
|
230
|
+
#dir;
|
|
231
|
+
#dirClosed;
|
|
232
|
+
#lock;
|
|
233
|
+
#previous;
|
|
234
|
+
#record;
|
|
235
|
+
#lastSeq = 0;
|
|
236
|
+
#fence;
|
|
237
|
+
/** Aborted by the fence, synchronously: a `run.json` write still in flight then refuses to rename. */
|
|
238
|
+
#fencing = new AbortController();
|
|
239
|
+
#releasing;
|
|
240
|
+
#released = false;
|
|
241
|
+
#writes = Promise.resolve();
|
|
242
|
+
#beating = false;
|
|
243
|
+
#lastBeatStart = 0;
|
|
244
|
+
#lastBeatNs = 0n;
|
|
245
|
+
#heartbeat;
|
|
246
|
+
#leaseCheck;
|
|
247
|
+
#watchdog;
|
|
248
|
+
constructor(ref, claim, options, context) {
|
|
249
|
+
this.ref = ref;
|
|
250
|
+
this.rawClaim = claim;
|
|
251
|
+
this.#options = options;
|
|
252
|
+
this.#context = context;
|
|
253
|
+
this.#lease = leaseOf(options.lease);
|
|
254
|
+
this.#clock = options.clock ?? (() => performance.now());
|
|
255
|
+
this.#persist = options.persist ?? persistRecord;
|
|
256
|
+
this.#cleanup = options.cleanup ?? noCleanup;
|
|
257
|
+
this.#onFenced = options.onFenced ?? exitFenced;
|
|
258
|
+
this.#openDir = options.claimDir ?? ((root) => openClaimDir(root));
|
|
259
|
+
const lease = this;
|
|
260
|
+
this.claim = {
|
|
261
|
+
ref: claim.ref,
|
|
262
|
+
disk: claim.disk,
|
|
263
|
+
root: claim.root,
|
|
264
|
+
work: claim.work,
|
|
265
|
+
store: claim.store,
|
|
266
|
+
get fenced() {
|
|
267
|
+
return lease.fenced;
|
|
268
|
+
},
|
|
269
|
+
barrier: () => lease.barrier(),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
get record() {
|
|
273
|
+
return this.#record;
|
|
274
|
+
}
|
|
275
|
+
get generation() {
|
|
276
|
+
return this.#record.generation;
|
|
277
|
+
}
|
|
278
|
+
get sealedSeq() {
|
|
279
|
+
return this.#previous?.sealedSeq ?? null;
|
|
280
|
+
}
|
|
281
|
+
get fenced() {
|
|
282
|
+
return this.#fence !== undefined;
|
|
283
|
+
}
|
|
284
|
+
/** The first fence, once fenced. */
|
|
285
|
+
get fencedBy() {
|
|
286
|
+
return this.#fence?.error;
|
|
287
|
+
}
|
|
288
|
+
/** True once `release()` began. */
|
|
289
|
+
get releasing() {
|
|
290
|
+
return this.#releasing !== undefined || this.#released;
|
|
291
|
+
}
|
|
292
|
+
fence(error) {
|
|
293
|
+
if (this.#fence)
|
|
294
|
+
return this.#fence.error;
|
|
295
|
+
const fence = { error, done: Promise.resolve() };
|
|
296
|
+
this.#fence = fence;
|
|
297
|
+
this.#fencing.abort(error);
|
|
298
|
+
this.#stopTimers();
|
|
299
|
+
this.rawClaim.markFenced(error);
|
|
300
|
+
fence.done = this.#cleanup(this.#context)
|
|
301
|
+
.catch(() => undefined)
|
|
302
|
+
.then(() => this.#onFenced(error))
|
|
303
|
+
.catch(() => exitFenced(error));
|
|
304
|
+
void fence.done.then(() => this.#closeDir());
|
|
305
|
+
return error;
|
|
306
|
+
}
|
|
307
|
+
fenceSettled() {
|
|
308
|
+
return this.#fence?.done ?? Promise.resolve();
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* The order after the claim. `run.json` is written (`running`, generation + 1, this holder, a heartbeat) and the
|
|
312
|
+
* heartbeat starts right after the owner lock, before the store and the Harness open, so a supervisor never sees the
|
|
313
|
+
* previous incarnation's stale heartbeat on a live claim. That first write keeps the previous `sealedSeq`, which the
|
|
314
|
+
* seal check still needs; `live()` clears it once the store is open.
|
|
315
|
+
*/
|
|
316
|
+
async open(step, holder) {
|
|
317
|
+
const claim = this.rawClaim;
|
|
318
|
+
// SQLite resolves symbolic links in a file name, `/proc/self/fd/<n>` included, so `owner.lock` is the one file opened
|
|
319
|
+
// by path: right after the mount check, and the file it names must then be the one in the claim directory.
|
|
320
|
+
this.#lock = await step("owner-lock", async () => {
|
|
321
|
+
const dir = (this.#dir = await this.#openDir(claim.root));
|
|
322
|
+
await dir.assertMounted();
|
|
323
|
+
const lock = takeOwnerLock(claim.root);
|
|
324
|
+
try {
|
|
325
|
+
await dir.assertSame(OWNER_LOCK);
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
try {
|
|
329
|
+
lock.release();
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
// The lock is not the claim's; the failed check is the fence to report.
|
|
333
|
+
}
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
return lock;
|
|
337
|
+
});
|
|
338
|
+
const previous = await this.#readPrevious();
|
|
339
|
+
this.#previous = previous;
|
|
340
|
+
this.#lastSeq = previous?.sealedSeq ?? 0;
|
|
341
|
+
await step("run.json", async () => {
|
|
342
|
+
const now = new Date().toISOString();
|
|
343
|
+
this.#noteBeat(await this.#write(() => ({
|
|
344
|
+
run: this.ref.id,
|
|
345
|
+
status: "running",
|
|
346
|
+
generation: (previous?.generation ?? 0) + 1,
|
|
347
|
+
sealedSeq: previous?.sealedSeq ?? null,
|
|
348
|
+
wakeAt: null,
|
|
349
|
+
holder,
|
|
350
|
+
heartbeatAt: now,
|
|
351
|
+
updatedAt: now,
|
|
352
|
+
detail: null,
|
|
353
|
+
})));
|
|
354
|
+
});
|
|
355
|
+
// Two self-fences: a timer on this thread, and a watchdog thread for when this thread is stuck in a FUSE request.
|
|
356
|
+
await step("heartbeat", () => {
|
|
357
|
+
this.#heartbeat = setInterval(() => this.#beat(), this.#lease.heartbeatMs).unref();
|
|
358
|
+
this.#leaseCheck = setInterval(() => this.#checkLease(), this.#lease.checkMs).unref();
|
|
359
|
+
this.#watchdog = new LeaseWatchdog({
|
|
360
|
+
limitMs: this.#lease.expiryMs - this.#lease.marginMs,
|
|
361
|
+
checkMs: this.#lease.checkMs,
|
|
362
|
+
beatNs: this.#lastBeatNs,
|
|
363
|
+
ownCgroup: this.#options.ownCgroup === true,
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
await step("layout", async () => {
|
|
367
|
+
for (const dir of [claim.store, claim.work, join(claim.root, "tmp")]) {
|
|
368
|
+
const under = relative(claim.root, dir);
|
|
369
|
+
if (under === "" || under.startsWith("..") || isAbsolute(under))
|
|
370
|
+
throw new RunError("INVALID_ARGUMENT", `${dir} is not under the claim root ${claim.root}`);
|
|
371
|
+
try {
|
|
372
|
+
await mkdir(join(this.#dir.at, under), { recursive: true });
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
throw ownWriteFenced(dir, error);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
async checkSeal(store) {
|
|
381
|
+
const sealed = this.sealedSeq;
|
|
382
|
+
let head;
|
|
383
|
+
try {
|
|
384
|
+
head = await storeHead(store);
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
if (error instanceof RunError)
|
|
388
|
+
await this.#markFailed({ code: error.code, sealedSeq: sealed, reason: error.message });
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
this.#lastSeq = Math.max(this.#lastSeq, head);
|
|
392
|
+
if (sealed === null || head >= sealed)
|
|
393
|
+
return;
|
|
394
|
+
const refusal = new StoreBehindSealError(store.file, sealed, head);
|
|
395
|
+
await this.#markFailed({ code: refusal.code, sealedSeq: sealed, head });
|
|
396
|
+
throw refusal;
|
|
397
|
+
}
|
|
398
|
+
observe(storage) {
|
|
399
|
+
return observeCommits(storage, {
|
|
400
|
+
before: () => {
|
|
401
|
+
const lapse = this.#watchdogLapse();
|
|
402
|
+
if (lapse)
|
|
403
|
+
throw this.fence(lapse);
|
|
404
|
+
},
|
|
405
|
+
after: (seq) => {
|
|
406
|
+
this.#lastSeq = Math.max(this.#lastSeq, seq);
|
|
407
|
+
const lapse = this.#watchdogLapse();
|
|
408
|
+
if (lapse)
|
|
409
|
+
this.fence(lapse);
|
|
410
|
+
},
|
|
411
|
+
failed: (error) => {
|
|
412
|
+
if (error instanceof FencedError)
|
|
413
|
+
this.fence(error);
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
async live() {
|
|
418
|
+
this.#assertUsable();
|
|
419
|
+
const now = new Date().toISOString();
|
|
420
|
+
this.#noteBeat(await this.#write((current) => ({ ...current, sealedSeq: null, heartbeatAt: now })));
|
|
421
|
+
}
|
|
422
|
+
async abandon(error, close) {
|
|
423
|
+
this.#stopTimers();
|
|
424
|
+
if (this.#fence || error instanceof FencedError) {
|
|
425
|
+
this.fence(this.#fence?.error ?? error);
|
|
426
|
+
await this.fenceSettled();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
await this.#writes;
|
|
431
|
+
await close?.();
|
|
432
|
+
this.#lock?.release();
|
|
433
|
+
await this.#closeDir();
|
|
434
|
+
if (!(error instanceof HeldError))
|
|
435
|
+
await this.rawClaim.release();
|
|
436
|
+
}
|
|
437
|
+
catch (cleanupError) {
|
|
438
|
+
if (cleanupError instanceof FencedError) {
|
|
439
|
+
this.fence(cleanupError);
|
|
440
|
+
await this.fenceSettled();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
async setStatus(status, detail = null, options = {}) {
|
|
445
|
+
this.#assertUsable();
|
|
446
|
+
if (status === "done" || status === "failed")
|
|
447
|
+
await this.barrier();
|
|
448
|
+
const now = new Date().toISOString();
|
|
449
|
+
await this.#write((current) => ({ ...current, status, detail, wakeAt: options.wakeAt ?? null, updatedAt: now }));
|
|
450
|
+
}
|
|
451
|
+
async barrier() {
|
|
452
|
+
if (this.#fence)
|
|
453
|
+
throw this.#fence.error;
|
|
454
|
+
try {
|
|
455
|
+
await this.rawClaim.barrier();
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
if (error instanceof FencedError)
|
|
459
|
+
throw this.fence(error);
|
|
460
|
+
throw error;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
release() {
|
|
464
|
+
if (this.#released)
|
|
465
|
+
return Promise.resolve();
|
|
466
|
+
if (this.#fence)
|
|
467
|
+
return Promise.reject(this.#fence.error);
|
|
468
|
+
this.#releasing ??= this.#release();
|
|
469
|
+
return this.#releasing;
|
|
470
|
+
}
|
|
471
|
+
async #release() {
|
|
472
|
+
const step = stepper(this.#options.onStep);
|
|
473
|
+
await step("cleanup", () => this.#cleanup(this.#context));
|
|
474
|
+
await step("barrier", () => this.barrier());
|
|
475
|
+
clearInterval(this.#heartbeat);
|
|
476
|
+
await step("seal", () => {
|
|
477
|
+
const now = new Date().toISOString();
|
|
478
|
+
return this.#write((current) => ({
|
|
479
|
+
...current,
|
|
480
|
+
status: current.status === "running" ? "paused" : current.status,
|
|
481
|
+
sealedSeq: this.#lastSeq,
|
|
482
|
+
updatedAt: now,
|
|
483
|
+
}));
|
|
484
|
+
});
|
|
485
|
+
this.#stopTimers();
|
|
486
|
+
await step("unlock", () => {
|
|
487
|
+
try {
|
|
488
|
+
this.#lock.release();
|
|
489
|
+
}
|
|
490
|
+
catch (error) {
|
|
491
|
+
throw this.fence(asFenced(error, `releasing ${OWNER_LOCK}`));
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
await step("unmount", async () => {
|
|
495
|
+
await this.#closeDir();
|
|
496
|
+
try {
|
|
497
|
+
await this.rawClaim.release();
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
if (error instanceof FencedError)
|
|
501
|
+
throw this.fence(error);
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
this.#released = true;
|
|
506
|
+
}
|
|
507
|
+
async #readPrevious() {
|
|
508
|
+
let previous;
|
|
509
|
+
try {
|
|
510
|
+
previous = await readRunRecord(this.#dir);
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
if (error instanceof RunRecordError)
|
|
514
|
+
throw error;
|
|
515
|
+
throw asFenced(error, `reading ${join(this.rawClaim.root, RUN_JSON)}`);
|
|
516
|
+
}
|
|
517
|
+
if (previous !== undefined && previous.run !== this.ref.id) {
|
|
518
|
+
throw new RunRecordError(`${join(this.rawClaim.root, RUN_JSON)} names run ${JSON.stringify(previous.run)}, not ${JSON.stringify(this.ref.id)}`);
|
|
519
|
+
}
|
|
520
|
+
return previous;
|
|
521
|
+
}
|
|
522
|
+
/** `run.json` becomes `failed` with `detail`; the seal the record carries is kept as evidence. */
|
|
523
|
+
async #markFailed(detail) {
|
|
524
|
+
const now = new Date().toISOString();
|
|
525
|
+
await this.#write((current) => ({ ...current, status: "failed", updatedAt: now, detail }));
|
|
526
|
+
}
|
|
527
|
+
/** Serialize writes of `run.json`. Resolves with the clock readings when this write started; a failure fences. */
|
|
528
|
+
#write(change) {
|
|
529
|
+
const next = this.#writes.then(async () => {
|
|
530
|
+
if (this.#fence)
|
|
531
|
+
throw this.#fence.error;
|
|
532
|
+
const record = change(this.#record);
|
|
533
|
+
const started = this.#clock();
|
|
534
|
+
const startedNs = process.hrtime.bigint();
|
|
535
|
+
try {
|
|
536
|
+
const dir = this.#dir;
|
|
537
|
+
if (dir === undefined)
|
|
538
|
+
throw new FencedError(`the claim directory of ${this.rawClaim.root} is closed`);
|
|
539
|
+
await writeRunRecord(dir, record, this.#persist, this.#fencing.signal);
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
throw this.fence(asFenced(error, `writing ${RUN_JSON}`));
|
|
543
|
+
}
|
|
544
|
+
this.#record = record;
|
|
545
|
+
return { started, startedNs };
|
|
546
|
+
});
|
|
547
|
+
this.#writes = next.catch(() => undefined);
|
|
548
|
+
return next;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Close the claim directory once no write can still address it: a closed descriptor's number may be reused, and
|
|
552
|
+
* `/proc/self/fd/<n>` would then name another file. An open directory also keeps the mount busy, so this runs before
|
|
553
|
+
* the unmount.
|
|
554
|
+
*/
|
|
555
|
+
#closeDir() {
|
|
556
|
+
this.#dirClosed ??= (async () => {
|
|
557
|
+
await this.#writes;
|
|
558
|
+
const dir = this.#dir;
|
|
559
|
+
this.#dir = undefined;
|
|
560
|
+
await dir?.close().catch(() => undefined);
|
|
561
|
+
})();
|
|
562
|
+
return this.#dirClosed;
|
|
563
|
+
}
|
|
564
|
+
#beat() {
|
|
565
|
+
if (this.#beating || this.#fence || this.#released)
|
|
566
|
+
return;
|
|
567
|
+
this.#beating = true;
|
|
568
|
+
this.#write((current) => ({ ...current, heartbeatAt: new Date().toISOString() }))
|
|
569
|
+
.then((started) => this.#noteBeat(started))
|
|
570
|
+
.catch(() => undefined)
|
|
571
|
+
.finally(() => (this.#beating = false));
|
|
572
|
+
}
|
|
573
|
+
/** A heartbeat-class write (the first, the one in `live()`, each heartbeat) succeeded. */
|
|
574
|
+
#noteBeat(write) {
|
|
575
|
+
this.#lastBeatStart = Math.max(this.#lastBeatStart, write.started);
|
|
576
|
+
if (write.startedNs > this.#lastBeatNs)
|
|
577
|
+
this.#lastBeatNs = write.startedNs;
|
|
578
|
+
this.#watchdog?.beat(write.startedNs);
|
|
579
|
+
}
|
|
580
|
+
#watchdogLapse() {
|
|
581
|
+
const since = this.#watchdog?.lapsedAfterMs;
|
|
582
|
+
return since === undefined ? undefined : new LeaseLapsedError(since, this.#lease.expiryMs - this.#lease.marginMs, "watchdog");
|
|
583
|
+
}
|
|
584
|
+
#checkLease() {
|
|
585
|
+
if (this.#fence)
|
|
586
|
+
return;
|
|
587
|
+
const lapse = this.#watchdogLapse();
|
|
588
|
+
if (lapse) {
|
|
589
|
+
this.fence(lapse);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const since = this.#clock() - this.#lastBeatStart;
|
|
593
|
+
const limit = this.#lease.expiryMs - this.#lease.marginMs;
|
|
594
|
+
if (since > limit)
|
|
595
|
+
this.fence(new LeaseLapsedError(since, limit));
|
|
596
|
+
}
|
|
597
|
+
#stopTimers() {
|
|
598
|
+
clearInterval(this.#heartbeat);
|
|
599
|
+
clearInterval(this.#leaseCheck);
|
|
600
|
+
this.#heartbeat = this.#leaseCheck = undefined;
|
|
601
|
+
this.#watchdog?.stop();
|
|
602
|
+
this.#watchdog = undefined;
|
|
603
|
+
}
|
|
604
|
+
#assertUsable() {
|
|
605
|
+
if (this.#fence)
|
|
606
|
+
throw this.#fence.error;
|
|
607
|
+
if (this.#released || this.#releasing)
|
|
608
|
+
throw new RunError("RUN_RELEASED", `run ${this.ref.id} is released`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
async function openLease(ref, options, step) {
|
|
612
|
+
const context = options.context ?? BACKGROUND_CONTEXT;
|
|
613
|
+
leaseOf(options.lease);
|
|
614
|
+
let holder;
|
|
615
|
+
try {
|
|
616
|
+
holder = currentHolder(options.holder);
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
throw new RunError("INVALID_ARGUMENT", `holder ${JSON.stringify(options.holder)}: ${error.message}`, { cause: error });
|
|
620
|
+
}
|
|
621
|
+
if (options.acquire === undefined && !options.mountToken) {
|
|
622
|
+
throw new RunError("INVALID_ARGUMENT", "mountToken is required to mount an Archil disk; a run on a local directory passes localRun(root) instead");
|
|
623
|
+
}
|
|
624
|
+
const claim = await step("acquire", () => (options.acquire ?? acquire)({
|
|
625
|
+
ref,
|
|
626
|
+
token: options.mountToken ?? "",
|
|
627
|
+
...(options.mountRoot === undefined ? {} : { mountRoot: options.mountRoot }),
|
|
628
|
+
...(options.force === undefined ? {} : { force: options.force }),
|
|
629
|
+
...(options.host === undefined ? {} : { host: options.host }),
|
|
630
|
+
}));
|
|
631
|
+
const lease = new Lease(ref, claim, options, context);
|
|
632
|
+
try {
|
|
633
|
+
await lease.open(step, holder);
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
await lease.abandon(error);
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
return lease;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Open the claim's lifecycle for run `ref` (see `RunLease`): acquire and verify the claim, take the owner lock, write
|
|
643
|
+
* `run.json` and start the heartbeat and the self-fence, create `store/`, `work/` and `tmp/`. Errors are typed as for
|
|
644
|
+
* `openDurableRun`: HeldError (76), FencedError (75, also passed to `onFenced`), RunRecordError (65), RunError
|
|
645
|
+
* INVALID_ARGUMENT before any claim.
|
|
646
|
+
*/
|
|
647
|
+
export async function openRunLease(ref, options) {
|
|
648
|
+
return openLease(ref, options, stepper(options.onStep));
|
|
649
|
+
}
|
|
650
|
+
// ---- the run ----------------------------------------------------------------------------------------------------------
|
|
651
|
+
class Run {
|
|
652
|
+
ref;
|
|
653
|
+
env;
|
|
654
|
+
#lease;
|
|
655
|
+
#options;
|
|
656
|
+
#context;
|
|
657
|
+
#store;
|
|
658
|
+
#harness;
|
|
659
|
+
#releasing;
|
|
660
|
+
#released = false;
|
|
661
|
+
constructor(lease, env, options, context) {
|
|
662
|
+
this.ref = lease.ref;
|
|
663
|
+
this.#lease = lease;
|
|
664
|
+
this.env = env;
|
|
665
|
+
this.#options = options;
|
|
666
|
+
this.#context = context;
|
|
667
|
+
}
|
|
668
|
+
get harness() {
|
|
669
|
+
return this.#harness;
|
|
670
|
+
}
|
|
671
|
+
get store() {
|
|
672
|
+
return this.#store;
|
|
673
|
+
}
|
|
674
|
+
get claim() {
|
|
675
|
+
return this.#lease.claim;
|
|
676
|
+
}
|
|
677
|
+
get record() {
|
|
678
|
+
return this.#lease.record;
|
|
679
|
+
}
|
|
680
|
+
get generation() {
|
|
681
|
+
return this.#lease.generation;
|
|
682
|
+
}
|
|
683
|
+
get fenced() {
|
|
684
|
+
return this.#lease.fenced;
|
|
685
|
+
}
|
|
686
|
+
/** The store, the seal check, pi's Harness over the observed storage, the seal cleared, resume. */
|
|
687
|
+
async open(step) {
|
|
688
|
+
const lease = this.#lease;
|
|
689
|
+
const file = join(lease.rawClaim.store, STORE_FILE);
|
|
690
|
+
this.#store = await step("store", () => openArchilStore(file, this.#options.profile ?? "exclusive", {
|
|
691
|
+
onFenced: (error) => void lease.fence(error),
|
|
692
|
+
...(this.#options.decorateStore === undefined ? {} : { decorate: this.#options.decorateStore }),
|
|
693
|
+
}));
|
|
694
|
+
// Both refusals here are terminal: the run is marked `failed`, keeping its seal, so the supervisor stops on it.
|
|
695
|
+
await step("seal", () => lease.checkSeal(this.#store));
|
|
696
|
+
const storage = lease.observe(this.#store.storage);
|
|
697
|
+
this.#harness = await step("harness", () => Harness.open(storage, { ...this.#options.harness, env: this.env }, this.#context));
|
|
698
|
+
await step("live", () => lease.live());
|
|
699
|
+
const before = this.#options.beforeResume;
|
|
700
|
+
if (before) {
|
|
701
|
+
// The storage the Harness was opened over (the lease observes its commits), and its head now: nothing commits
|
|
702
|
+
// between the open and the hook, since the Harness has not resumed.
|
|
703
|
+
await step("before-resume", async () => {
|
|
704
|
+
const head = (await storeHead(this.#store));
|
|
705
|
+
await before({ harness: this.#harness, context: this.#context, generation: lease.generation, record: lease.record, claim: lease.claim, storage: storeReads(storage), head });
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
await step("resume", () => this.#harness.resume());
|
|
709
|
+
}
|
|
710
|
+
/** Undo a failed open: close what this layer opened, then the lease's own undo. */
|
|
711
|
+
abandon(error) {
|
|
712
|
+
return this.#lease.abandon(error, async () => {
|
|
713
|
+
if (this.#harness)
|
|
714
|
+
await this.#harness.close(this.#context);
|
|
715
|
+
else if (this.#store)
|
|
716
|
+
await this.#store.storage.close(this.#context);
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
async setStatus(status, detail = null, options = {}) {
|
|
720
|
+
if (!this.#lease.fenced && (this.#released || this.#releasing))
|
|
721
|
+
throw new RunError("RUN_RELEASED", `run ${this.ref.id} is released`);
|
|
722
|
+
await this.#lease.setStatus(status, detail, options);
|
|
723
|
+
}
|
|
724
|
+
release() {
|
|
725
|
+
if (this.#released)
|
|
726
|
+
return Promise.resolve();
|
|
727
|
+
if (this.#lease.fenced)
|
|
728
|
+
return Promise.reject(this.#lease.fencedBy);
|
|
729
|
+
this.#releasing ??= this.#release();
|
|
730
|
+
return this.#releasing;
|
|
731
|
+
}
|
|
732
|
+
async #release() {
|
|
733
|
+
const step = stepper(this.#options.onStep);
|
|
734
|
+
// Closing first aborts running work without recording an outcome, so the next open reports it interrupted; the
|
|
735
|
+
// lease then kills the commands, barriers, seals and unmounts.
|
|
736
|
+
await step("close", async () => {
|
|
737
|
+
try {
|
|
738
|
+
await this.#harness.close(this.#context);
|
|
739
|
+
}
|
|
740
|
+
catch (error) {
|
|
741
|
+
throw this.#lease.fence(asFenced(error, "closing the Harness"));
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
await this.#lease.release();
|
|
745
|
+
this.#released = true;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Open run `ref` on this host: the lease (`openRunLease`: claim, owner lock, `run.json`, heartbeat and
|
|
750
|
+
* self-fence, layout), then the store, the seal check before anything commits, pi's Harness with the app's registry,
|
|
751
|
+
* the seal cleared in `run.json`, resume.
|
|
752
|
+
*
|
|
753
|
+
* Errors are typed: HeldError (76) when another client, another process on this host or another connection holds the
|
|
754
|
+
* run; FencedError (75) when the claim cannot be trusted (also passed to `onFenced`); StoreBehindSealError (65) and
|
|
755
|
+
* RunError STORE_HEAD_UNREADABLE (70), each after marking the run `failed` and releasing the claim.
|
|
756
|
+
*/
|
|
757
|
+
export async function openDurableRun(ref, options) {
|
|
758
|
+
const context = options.context ?? BACKGROUND_CONTEXT;
|
|
759
|
+
const step = stepper(options.onStep);
|
|
760
|
+
let env;
|
|
761
|
+
const lease = await openLease(ref, { ...options, context, cleanup: (c) => env?.cleanup(c) ?? Promise.resolve() }, step);
|
|
762
|
+
try {
|
|
763
|
+
// A claim that records its commands (a local run's): end what a killed incarnation left running, now that the owner
|
|
764
|
+
// lock is held and before the Harness opens, so no command of it still writes in work/ once the run resumes.
|
|
765
|
+
const commands = lease.rawClaim.commands;
|
|
766
|
+
// A process of a killed incarnation that outlives its SIGKILL refuses the open (HeldError, 76): the run never resumes
|
|
767
|
+
// beside it, and its record stays for the next open.
|
|
768
|
+
if (commands !== undefined)
|
|
769
|
+
await step("commands", () => reapOrRefuse(commands));
|
|
770
|
+
env = (options.env ?? ((c) => archilEnv(c)))(lease.rawClaim);
|
|
771
|
+
}
|
|
772
|
+
catch (error) {
|
|
773
|
+
await lease.abandon(error);
|
|
774
|
+
throw error;
|
|
775
|
+
}
|
|
776
|
+
const run = new Run(lease, env, options, context);
|
|
777
|
+
try {
|
|
778
|
+
await run.open(step);
|
|
779
|
+
}
|
|
780
|
+
catch (error) {
|
|
781
|
+
await run.abandon(error);
|
|
782
|
+
throw error;
|
|
783
|
+
}
|
|
784
|
+
return run;
|
|
785
|
+
}
|