@parcha/pi-durable-disk 0.1.0-beta.12 → 0.1.0-beta.14
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 +241 -0
- package/README.md +268 -28
- package/dist/app.d.ts +5 -0
- package/dist/app.js +3 -2
- package/dist/claim.d.ts +34 -2
- package/dist/claim.js +86 -5
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +98 -33
- package/dist/commands.d.ts +70 -0
- package/dist/commands.js +257 -0
- package/dist/confine.d.ts +47 -7
- package/dist/confine.js +148 -19
- package/dist/env.d.ts +17 -26
- package/dist/env.js +73 -68
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +1 -1
- package/dist/fork.d.ts +23 -0
- package/dist/fork.js +103 -3
- package/dist/hosts/daytona-launch.d.ts +3 -1
- package/dist/hosts/daytona-launch.js +30 -6
- package/dist/hosts/daytona.js +15 -3
- package/dist/hosts/local-host.d.ts +7 -1
- package/dist/hosts/local-host.js +30 -16
- package/dist/index.d.ts +31 -15
- package/dist/index.js +23 -15
- package/dist/internals.d.ts +3 -0
- package/dist/internals.js +6 -0
- package/dist/lease.d.ts +5 -2
- package/dist/lease.js +3 -1
- package/dist/local-run.d.ts +10 -0
- package/dist/local-run.js +64 -0
- package/dist/park.d.ts +4 -3
- package/dist/park.js +4 -3
- package/dist/process-table.d.ts +38 -0
- package/dist/process-table.js +158 -0
- package/dist/run.d.ts +52 -10
- package/dist/run.js +42 -7
- package/dist/status.d.ts +11 -3
- package/dist/status.js +15 -13
- package/dist/store.d.ts +14 -2
- package/dist/store.js +16 -4
- package/dist/supervise.d.ts +22 -3
- package/dist/supervise.js +40 -13
- package/dist/watchdog.d.ts +3 -1
- package/dist/watchdog.js +46 -13
- package/package.json +12 -7
package/dist/run.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Context, JsonValue } from "@earendil-works/chord";
|
|
2
2
|
import { Harness } from "@earendil-works/pi-durable";
|
|
3
|
-
import type { HarnessOptions, Storage, ToolRegistration } from "@earendil-works/pi-durable";
|
|
3
|
+
import type { HarnessOptions, Seq, Storage, ToolRegistration } from "@earendil-works/pi-durable";
|
|
4
4
|
import type { SqliteDatabase, SqliteExecutor } from "@earendil-works/pi-durable/storage/sqlite";
|
|
5
5
|
import { type AcquireOptions, type ArchilHost, type Claim, type RunRef } from "./claim.js";
|
|
6
6
|
import { FencedError, PdaError } from "./errors.js";
|
|
@@ -57,11 +57,15 @@ export interface LeaseOptions {
|
|
|
57
57
|
/** How often the self-fence timer looks; default 1 s. */
|
|
58
58
|
readonly checkMs?: number;
|
|
59
59
|
}
|
|
60
|
-
export type OpenStep = "acquire" | "owner-lock" | "run.json" | "heartbeat" | "layout" | "store" | "seal" | "harness" | "live" | "resume";
|
|
60
|
+
export type OpenStep = "acquire" | "owner-lock" | "run.json" | "heartbeat" | "layout" | "commands" | "store" | "seal" | "harness" | "live" | "before-resume" | "resume";
|
|
61
61
|
export type ReleaseStep = "close" | "cleanup" | "barrier" | "seal" | "unlock" | "unmount";
|
|
62
62
|
export interface OpenRunLeaseOptions {
|
|
63
|
-
/**
|
|
64
|
-
|
|
63
|
+
/**
|
|
64
|
+
* The mount token from the host driver (reusable, 24 h); travels only on the archil wrapper's stdin, never in an
|
|
65
|
+
* environment. Required to mount an Archil disk (the default `acquire`); a run whose `acquire` is replaced (a local
|
|
66
|
+
* directory: `localRun`) needs none, and its `acquire` is handed an empty token.
|
|
67
|
+
*/
|
|
68
|
+
readonly mountToken?: string;
|
|
65
69
|
/** Default /mnt/archil. */
|
|
66
70
|
readonly mountRoot?: string;
|
|
67
71
|
/** Mount with `--force`: the takeover fallback when the supervisor could not revoke. */
|
|
@@ -110,7 +114,43 @@ export interface OpenDurableRunOptions<Tool extends ToolRegistration = ToolRegis
|
|
|
110
114
|
readonly env?: (claim: Claim) => RunEnv;
|
|
111
115
|
/** Wraps the raw database inside the store's fence (instrumentation, fault injection). */
|
|
112
116
|
readonly decorateStore?: (database: SqliteDatabase) => SqliteDatabase;
|
|
117
|
+
/**
|
|
118
|
+
* Awaited once the Harness is open (its registry installed) and the run is live, before the Harness resumes: no
|
|
119
|
+
* resumed task takes a step until it settles, and what it admits (a submission, a write) is committed before anything
|
|
120
|
+
* a resumed task's steps commit. A host reconciles here what a resumed tool depends on (a browser, a mount). A rejection
|
|
121
|
+
* fails the open as any step does (`before-resume`), and the claim is released.
|
|
122
|
+
*/
|
|
123
|
+
readonly beforeResume?: (run: BeforeResume) => void | Promise<void>;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* What `beforeResume` sees: the open Harness, not yet resumed, the open's context, the incarnation it belongs to, and the
|
|
127
|
+
* store the Harness was opened over with its head (a recorder snapshots every document at `head` here).
|
|
128
|
+
*/
|
|
129
|
+
export interface BeforeResume {
|
|
130
|
+
readonly harness: Harness;
|
|
131
|
+
readonly context: Context;
|
|
132
|
+
readonly generation: number;
|
|
133
|
+
/** This incarnation's `run.json`, which the open has already written (`running`, this holder, `detail` null). */
|
|
134
|
+
readonly record: RunRecord;
|
|
135
|
+
/**
|
|
136
|
+
* `run.json` as it was before this open wrote over it: how the run was left. After a park it is `sleeping` with its
|
|
137
|
+
* `wakeAt` and `detail` and the seal its release wrote; after a planned stop, `paused` or `done` with the `detail` it
|
|
138
|
+
* set; after a crash, `running` with the dead holder and no new seal. On a fork's first open it is the record `fork`
|
|
139
|
+
* wrote: `paused`, generation 0, `detail.forkedFrom`. Null only when there was no `run.json` (a new run's first open).
|
|
140
|
+
*/
|
|
141
|
+
readonly previous: RunRecord | null;
|
|
142
|
+
readonly claim: RunClaim;
|
|
143
|
+
/** The store the Harness was opened over, read-only: four reads on an object of their own, and no way to a write. */
|
|
144
|
+
readonly storage: StoreReads;
|
|
145
|
+
/**
|
|
146
|
+
* The store's last committed sequence once the Harness is open, read just before the hook runs. Until the hook commits
|
|
147
|
+
* something, the store's current state is its state at `head`: a document that keeps no history (`latest`, and session
|
|
148
|
+
* and task documents) reads only at "current", a `rewindable` one at `head` too.
|
|
149
|
+
*/
|
|
150
|
+
readonly head: Seq;
|
|
113
151
|
}
|
|
152
|
+
/** The reads of a store that `beforeResume` gets. */
|
|
153
|
+
export type StoreReads = Pick<Storage, "scanConversations" | "scanTasks" | "scanDocuments" | "document">;
|
|
114
154
|
/** The claim as the app sees it: paths, the fenced flag, and a barrier whose failure fences the run. */
|
|
115
155
|
export interface RunClaim {
|
|
116
156
|
readonly ref: RunRef;
|
|
@@ -139,6 +179,8 @@ export interface RunLease {
|
|
|
139
179
|
readonly record: RunRecord;
|
|
140
180
|
/** The seal the previous incarnation's clean release wrote; null on a first run and after a crash. */
|
|
141
181
|
readonly sealedSeq: number | null;
|
|
182
|
+
/** `run.json` as the open found it, before it wrote this incarnation's: null when there was none (a new run's first open). */
|
|
183
|
+
readonly previous: RunRecord | null;
|
|
142
184
|
readonly fenced: boolean;
|
|
143
185
|
/**
|
|
144
186
|
* The seal check on the app's store, before anything commits: the store's head (`storeHead`) below
|
|
@@ -228,12 +270,12 @@ export interface StoreHeadSource {
|
|
|
228
270
|
readonly file: string;
|
|
229
271
|
}
|
|
230
272
|
/**
|
|
231
|
-
* The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable
|
|
232
|
-
*
|
|
233
|
-
* would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq`
|
|
234
|
-
* successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store
|
|
235
|
-
* row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the
|
|
236
|
-
* a unit test pins the read to the sequence pi's commits return.
|
|
273
|
+
* The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable's public
|
|
274
|
+
* `Storage` has no head query (1.0.4 and 1.1.0), and the seal check must run before `Harness.open` can commit (its
|
|
275
|
+
* recovery commit would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq`
|
|
276
|
+
* and stores the successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store
|
|
277
|
+
* without that table, row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the
|
|
278
|
+
* check silently; a unit test pins the read to the sequence pi's commits return.
|
|
237
279
|
*
|
|
238
280
|
* `database` is the store's fence-aware facade (`FencedDatabase`), which turns an I/O-class error into a fence; through
|
|
239
281
|
* a raw connection such an error would read as STORE_HEAD_UNREADABLE.
|
package/dist/run.js
CHANGED
|
@@ -10,6 +10,7 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
10
10
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
11
11
|
import { Harness } from "@earendil-works/pi-durable";
|
|
12
12
|
import { acquire } from "./claim.js";
|
|
13
|
+
import { reapOrRefuse } from "./commands.js";
|
|
13
14
|
import { archilEnv } from "./env.js";
|
|
14
15
|
import { EXIT_FENCED, FencedError, HeldError, ownWriteFenced, PdaError } from "./errors.js";
|
|
15
16
|
import { openArchilStore } from "./store.js";
|
|
@@ -59,6 +60,18 @@ export class LeaseLapsedError extends FencedError {
|
|
|
59
60
|
this.by = by;
|
|
60
61
|
}
|
|
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
|
+
}
|
|
62
75
|
/** Locks this process holds. A collected DatabaseSync closes its file, which would drop the lock without a word. */
|
|
63
76
|
const heldLocks = new Set();
|
|
64
77
|
const SQLITE_BUSY = 5;
|
|
@@ -119,12 +132,12 @@ export function takeOwnerLock(root) {
|
|
|
119
132
|
};
|
|
120
133
|
}
|
|
121
134
|
/**
|
|
122
|
-
* The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable
|
|
123
|
-
*
|
|
124
|
-
* would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq`
|
|
125
|
-
* successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store
|
|
126
|
-
* row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the
|
|
127
|
-
* a unit test pins the read to the sequence pi's commits return.
|
|
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.
|
|
128
141
|
*
|
|
129
142
|
* `database` is the store's fence-aware facade (`FencedDatabase`), which turns an I/O-class error into a fence; through
|
|
130
143
|
* a raw connection such an error would read as STORE_HEAD_UNREADABLE.
|
|
@@ -265,6 +278,9 @@ class Lease {
|
|
|
265
278
|
get sealedSeq() {
|
|
266
279
|
return this.#previous?.sealedSeq ?? null;
|
|
267
280
|
}
|
|
281
|
+
get previous() {
|
|
282
|
+
return this.#previous ?? null;
|
|
283
|
+
}
|
|
268
284
|
get fenced() {
|
|
269
285
|
return this.#fence !== undefined;
|
|
270
286
|
}
|
|
@@ -605,9 +621,12 @@ async function openLease(ref, options, step) {
|
|
|
605
621
|
catch (error) {
|
|
606
622
|
throw new RunError("INVALID_ARGUMENT", `holder ${JSON.stringify(options.holder)}: ${error.message}`, { cause: error });
|
|
607
623
|
}
|
|
624
|
+
if (options.acquire === undefined && !options.mountToken) {
|
|
625
|
+
throw new RunError("INVALID_ARGUMENT", "mountToken is required to mount an Archil disk; a run on a local directory passes localRun(root) instead");
|
|
626
|
+
}
|
|
608
627
|
const claim = await step("acquire", () => (options.acquire ?? acquire)({
|
|
609
628
|
ref,
|
|
610
|
-
token: options.mountToken,
|
|
629
|
+
token: options.mountToken ?? "",
|
|
611
630
|
...(options.mountRoot === undefined ? {} : { mountRoot: options.mountRoot }),
|
|
612
631
|
...(options.force === undefined ? {} : { force: options.force }),
|
|
613
632
|
...(options.host === undefined ? {} : { host: options.host }),
|
|
@@ -680,6 +699,15 @@ class Run {
|
|
|
680
699
|
const storage = lease.observe(this.#store.storage);
|
|
681
700
|
this.#harness = await step("harness", () => Harness.open(storage, { ...this.#options.harness, env: this.env }, this.#context));
|
|
682
701
|
await step("live", () => lease.live());
|
|
702
|
+
const before = this.#options.beforeResume;
|
|
703
|
+
if (before) {
|
|
704
|
+
// The storage the Harness was opened over (the lease observes its commits), and its head now: nothing commits
|
|
705
|
+
// between the open and the hook, since the Harness has not resumed.
|
|
706
|
+
await step("before-resume", async () => {
|
|
707
|
+
const head = (await storeHead(this.#store));
|
|
708
|
+
await before({ harness: this.#harness, context: this.#context, generation: lease.generation, record: lease.record, previous: lease.previous, claim: lease.claim, storage: storeReads(storage), head });
|
|
709
|
+
});
|
|
710
|
+
}
|
|
683
711
|
await step("resume", () => this.#harness.resume());
|
|
684
712
|
}
|
|
685
713
|
/** Undo a failed open: close what this layer opened, then the lease's own undo. */
|
|
@@ -735,6 +763,13 @@ export async function openDurableRun(ref, options) {
|
|
|
735
763
|
let env;
|
|
736
764
|
const lease = await openLease(ref, { ...options, context, cleanup: (c) => env?.cleanup(c) ?? Promise.resolve() }, step);
|
|
737
765
|
try {
|
|
766
|
+
// A claim that records its commands (a local run's): end what a killed incarnation left running, now that the owner
|
|
767
|
+
// lock is held and before the Harness opens, so no command of it still writes in work/ once the run resumes.
|
|
768
|
+
const commands = lease.rawClaim.commands;
|
|
769
|
+
// A process of a killed incarnation that outlives its SIGKILL refuses the open (HeldError, 76): the run never resumes
|
|
770
|
+
// beside it, and its record stays for the next open.
|
|
771
|
+
if (commands !== undefined)
|
|
772
|
+
await step("commands", () => reapOrRefuse(commands));
|
|
738
773
|
env = (options.env ?? ((c) => archilEnv(c)))(lease.rawClaim);
|
|
739
774
|
}
|
|
740
775
|
catch (error) {
|
package/dist/status.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export type RunStatus = (typeof RUN_STATUSES)[number];
|
|
|
19
19
|
export interface RunHolder {
|
|
20
20
|
readonly driver: string;
|
|
21
21
|
readonly host: string;
|
|
22
|
-
/** `/proc/sys/kernel/random/boot_id
|
|
22
|
+
/** `/proc/sys/kernel/random/boot_id` (`kern.bootsessionuuid` on macOS); null where it cannot be read. */
|
|
23
23
|
readonly bootId: string | null;
|
|
24
24
|
readonly pid: number;
|
|
25
25
|
/** When this incarnation claimed the run (ISO 8601). */
|
|
@@ -81,12 +81,14 @@ export declare const ARCHIL_FSTYPE = "fuse.archil";
|
|
|
81
81
|
* A run root held open as a directory for the life of the instance. `at` is `/proc/self/fd/<n>`, a path the kernel
|
|
82
82
|
* resolves through the open directory and never through the root's path (Node has no openat; the magic link stands in
|
|
83
83
|
* for it): a file made, written or renamed under `at` is in the directory opened at acquire, or the call fails (ENOTCONN
|
|
84
|
-
* once the FUSE daemon is gone). It never lands on the local disk under a mountpoint whose mount went away.
|
|
84
|
+
* once the FUSE daemon is gone). It never lands on the local disk under a mountpoint whose mount went away. macOS has no
|
|
85
|
+
* such link: there `at` is the root's path (`byPath`), and only the checks below stand between a write and a root that was
|
|
86
|
+
* replaced.
|
|
85
87
|
*/
|
|
86
88
|
export interface ClaimDir {
|
|
87
89
|
/** The root's path, as the claim names it. */
|
|
88
90
|
readonly root: string;
|
|
89
|
-
/** `/proc/self/fd/<n
|
|
91
|
+
/** `/proc/self/fd/<n>`, or the root's path `byPath`. Throws once closed: the number may then name another file. */
|
|
90
92
|
readonly at: string;
|
|
91
93
|
/**
|
|
92
94
|
* Resolves while the root's path still resolves to the directory held open (same device, same inode). A mount that was
|
|
@@ -107,6 +109,12 @@ export interface OpenClaimDirOptions {
|
|
|
107
109
|
readonly fstype?: string | null;
|
|
108
110
|
/** Default `/proc/self/mountinfo`. */
|
|
109
111
|
readonly mountinfo?: string;
|
|
112
|
+
/**
|
|
113
|
+
* `at` is the root's path, not `/proc/self/fd/<n>`: the default on macOS, which has none. A write is checked against the
|
|
114
|
+
* held directory first (`assertMounted`), but a root renamed and replaced between that check and the write gets the
|
|
115
|
+
* write. For a local directory only.
|
|
116
|
+
*/
|
|
117
|
+
readonly byPath?: boolean;
|
|
110
118
|
}
|
|
111
119
|
/** `major:minor` of a device number, as `/proc/self/mountinfo` writes it (glibc's encoding). */
|
|
112
120
|
export declare function deviceName(dev: bigint): string;
|
package/dist/status.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// claim with write-to-temp, fsync, rename, and read by the supervisor (over the S3 API or a local mount). It is a hint
|
|
3
3
|
// for the supervisor, never the source of truth: the store is. Any error writing it is a fence. The instance writes it
|
|
4
4
|
// through its claim directory (`ClaimDir`), never by path, so a write cannot land under a mountpoint whose mount is gone.
|
|
5
|
-
import { constants
|
|
5
|
+
import { constants } from "node:fs";
|
|
6
6
|
import { open, readFile, rename, rm, stat } from "node:fs/promises";
|
|
7
7
|
import { hostname } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { FencedError, ownWriteFenced, PdaError } from "./errors.js";
|
|
10
|
+
import { thisBoot } from "./process-table.js";
|
|
10
11
|
export const RUN_JSON = "run.json";
|
|
11
12
|
/** The temporary file every write goes through; renamed over `run.json` once it is fsynced. */
|
|
12
13
|
export const RUN_JSON_TEMP = "run.json.tmp";
|
|
@@ -204,7 +205,8 @@ export async function openClaimDir(root, options = {}) {
|
|
|
204
205
|
await handle.close().catch(() => undefined);
|
|
205
206
|
throw error instanceof FencedError ? error : unmounted(`checking the claim root ${root} failed: ${error.message}`, error);
|
|
206
207
|
}
|
|
207
|
-
const
|
|
208
|
+
const byPath = options.byPath ?? process.platform === "darwin";
|
|
209
|
+
const at = byPath ? root : `/proc/self/fd/${handle.fd}`;
|
|
208
210
|
let closed = false;
|
|
209
211
|
const same = (path, now, claims) => {
|
|
210
212
|
if (now.dev === claims.dev && now.ino === claims.ino)
|
|
@@ -234,10 +236,18 @@ export async function openClaimDir(root, options = {}) {
|
|
|
234
236
|
async assertSame(name) {
|
|
235
237
|
if (closed)
|
|
236
238
|
throw unmounted(`the claim directory of ${root} is closed`);
|
|
237
|
-
|
|
239
|
+
if (byPath) {
|
|
240
|
+
// No second way to name the entry: the root must still be the directory held, and the entry on its device.
|
|
241
|
+
const [dir, entry] = await Promise.all([statOf(root), statOf(join(root, name))]);
|
|
242
|
+
same(root, dir, held);
|
|
243
|
+
if (entry.dev !== held.dev)
|
|
244
|
+
throw unmounted(`${name} in the claim directory is on device ${deviceName(entry.dev)}, not the claim's ${deviceName(held.dev)}`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const [named, inDir] = await Promise.all([statOf(join(root, name)), statOf(join(at, name))]);
|
|
238
248
|
if (inDir.dev !== held.dev)
|
|
239
249
|
throw unmounted(`${name} in the claim directory is on device ${deviceName(inDir.dev)}, not the claim's ${deviceName(held.dev)}`);
|
|
240
|
-
same(join(root, name),
|
|
250
|
+
same(join(root, name), named, inDir);
|
|
241
251
|
},
|
|
242
252
|
async close() {
|
|
243
253
|
if (closed)
|
|
@@ -247,18 +257,10 @@ export async function openClaimDir(root, options = {}) {
|
|
|
247
257
|
},
|
|
248
258
|
};
|
|
249
259
|
}
|
|
250
|
-
function bootId() {
|
|
251
|
-
try {
|
|
252
|
-
return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim() || null;
|
|
253
|
-
}
|
|
254
|
-
catch {
|
|
255
|
-
return null;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
260
|
/**
|
|
259
261
|
* This process as a holder: the host driver's `handle` (default driver `local`, this machine's host name and boot id)
|
|
260
262
|
* with this process's pid and `since`. A handle whose named fields have the wrong type is refused (RunRecordError).
|
|
261
263
|
*/
|
|
262
264
|
export function currentHolder(handle = {}, since = new Date()) {
|
|
263
|
-
return holderOf({ driver: "local", host: hostname(), bootId:
|
|
265
|
+
return holderOf({ driver: "local", host: hostname(), bootId: thisBoot(), ...handle, pid: process.pid, since: since.toISOString() });
|
|
264
266
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -39,8 +39,18 @@ export declare const PROFILE_PRAGMAS: {
|
|
|
39
39
|
readonly mmap_size: 0;
|
|
40
40
|
};
|
|
41
41
|
};
|
|
42
|
-
/**
|
|
43
|
-
|
|
42
|
+
/**
|
|
43
|
+
* On macOS, fsync does not flush the drive's cache, so a commit FULL syncs could still be lost to a power cut. These make
|
|
44
|
+
* SQLite sync with F_FULLFSYNC instead, at every commit and every checkpoint.
|
|
45
|
+
*/
|
|
46
|
+
export declare const FULLFSYNC_PRAGMAS: {
|
|
47
|
+
readonly fullfsync: 1;
|
|
48
|
+
readonly checkpoint_fullfsync: 1;
|
|
49
|
+
};
|
|
50
|
+
/** What `profile` must read back on `platform`: its own pragmas, and on macOS `FULLFSYNC_PRAGMAS` too. */
|
|
51
|
+
export declare function profilePragmas(profile: Profile, platform?: NodeJS.Platform): Readonly<Record<string, string | number>>;
|
|
52
|
+
/** Read every pragma of `profile` on `platform` (default this one) back and refuse (StorePragmaError) when any differs. */
|
|
53
|
+
export declare function assertPragmas(database: SqliteExecutor, profile: Profile, platform?: NodeJS.Platform): Promise<void>;
|
|
44
54
|
/**
|
|
45
55
|
* The fenced state of one handle. The first error whose result code is in the I/O, full or not-a-database class marks it
|
|
46
56
|
* fenced, runs `onFenced` once (before the error propagates), and from then on every operation fails with the same
|
|
@@ -84,6 +94,8 @@ export type OpenOptions = {
|
|
|
84
94
|
readonly onFenced?: (error: StoreFencedError) => void;
|
|
85
95
|
/** Wraps the raw database inside the fence, for instrumentation and fault injection. */
|
|
86
96
|
readonly decorate?: (database: SqliteDatabase) => SqliteDatabase;
|
|
97
|
+
/** The platform whose pragmas the store takes (`profilePragmas`); default this one. For tests. */
|
|
98
|
+
readonly platform?: NodeJS.Platform;
|
|
87
99
|
};
|
|
88
100
|
export type ArchilStore = {
|
|
89
101
|
readonly storage: SqliteStorage;
|
package/dist/store.js
CHANGED
|
@@ -64,10 +64,19 @@ export const PROFILE_PRAGMAS = {
|
|
|
64
64
|
exclusive: { locking_mode: "exclusive", journal_mode: "wal", synchronous: 2, mmap_size: 0 },
|
|
65
65
|
shared: { locking_mode: "normal", journal_mode: "wal", synchronous: 2, mmap_size: 0 },
|
|
66
66
|
};
|
|
67
|
-
/**
|
|
68
|
-
|
|
67
|
+
/**
|
|
68
|
+
* On macOS, fsync does not flush the drive's cache, so a commit FULL syncs could still be lost to a power cut. These make
|
|
69
|
+
* SQLite sync with F_FULLFSYNC instead, at every commit and every checkpoint.
|
|
70
|
+
*/
|
|
71
|
+
export const FULLFSYNC_PRAGMAS = { fullfsync: 1, checkpoint_fullfsync: 1 };
|
|
72
|
+
/** What `profile` must read back on `platform`: its own pragmas, and on macOS `FULLFSYNC_PRAGMAS` too. */
|
|
73
|
+
export function profilePragmas(profile, platform = process.platform) {
|
|
74
|
+
return platform === "darwin" ? { ...PROFILE_PRAGMAS[profile], ...FULLFSYNC_PRAGMAS } : PROFILE_PRAGMAS[profile];
|
|
75
|
+
}
|
|
76
|
+
/** Read every pragma of `profile` on `platform` (default this one) back and refuse (StorePragmaError) when any differs. */
|
|
77
|
+
export async function assertPragmas(database, profile, platform = process.platform) {
|
|
69
78
|
const mismatches = [];
|
|
70
|
-
for (const [pragma, expected] of Object.entries(
|
|
79
|
+
for (const [pragma, expected] of Object.entries(profilePragmas(profile, platform))) {
|
|
71
80
|
const row = await database.get(`PRAGMA ${pragma}`);
|
|
72
81
|
const actual = row === undefined ? undefined : Object.values(row)[0];
|
|
73
82
|
if (actual !== expected)
|
|
@@ -203,7 +212,10 @@ export async function openArchilStore(file, profile = "exclusive", options = {})
|
|
|
203
212
|
}
|
|
204
213
|
await database.exec("PRAGMA synchronous = FULL"); // fsync the WAL on every commit
|
|
205
214
|
await database.exec("PRAGMA mmap_size = 0"); // no mmap I/O of the database file on a network filesystem
|
|
206
|
-
|
|
215
|
+
const platform = options.platform ?? process.platform;
|
|
216
|
+
for (const pragma of platform === "darwin" ? Object.keys(FULLFSYNC_PRAGMAS) : [])
|
|
217
|
+
await database.exec(`PRAGMA ${pragma} = ON`);
|
|
218
|
+
await assertPragmas(database, profile, platform);
|
|
207
219
|
const storage = await SqliteStorage.open(database);
|
|
208
220
|
return { storage, database, profile, file };
|
|
209
221
|
}
|
package/dist/supervise.d.ts
CHANGED
|
@@ -241,6 +241,25 @@ export type TokenUser = {
|
|
|
241
241
|
expiresAt?: string;
|
|
242
242
|
createdAt?: string;
|
|
243
243
|
};
|
|
244
|
+
/** What `archilControl` returns: everything `ensureRunning`, `superviseRuns`, `checkHost`, `fork` and the token janitor use. */
|
|
245
|
+
export type ArchilControl = SupervisorControl & CheckControl & {
|
|
246
|
+
listUsers(): Promise<TokenUser[]>;
|
|
247
|
+
};
|
|
248
|
+
/** The two calls of the `disk` client (this package's dependency) that `archilControl` makes. */
|
|
249
|
+
export type DiskClient = Pick<typeof import("disk"), "configure" | "getDisk">;
|
|
250
|
+
/**
|
|
251
|
+
* One Archil disk's control API from an API key, the object `pi-durable-disk supervise` builds for itself: a library
|
|
252
|
+
* caller that composes its own supervisor (with `daytonaHost`, or a driver of its own) passes it as `control`. The S3 and
|
|
253
|
+
* control calls go to the disk the key reaches; `listUsers` reads the disk record again on every call (the list lags by
|
|
254
|
+
* seconds). `configure` is the `disk` client's process-wide setting, so one process controls disks of one key and region.
|
|
255
|
+
* `client` replaces the `disk` client (tests).
|
|
256
|
+
*/
|
|
257
|
+
export declare function archilControl(options: {
|
|
258
|
+
readonly apiKey: string;
|
|
259
|
+
readonly disk: string;
|
|
260
|
+
readonly region: string;
|
|
261
|
+
readonly client?: DiskClient;
|
|
262
|
+
}): Promise<ArchilControl>;
|
|
244
263
|
export interface TokenSweepOptions {
|
|
245
264
|
/** The disk's token users (`getDisk(id).authorizedUsers`; the list lags by seconds). */
|
|
246
265
|
listUsers(): Promise<TokenUser[]>;
|
|
@@ -330,9 +349,9 @@ export declare function checkHost(options: CheckOptions): Promise<CheckReport>;
|
|
|
330
349
|
/**
|
|
331
350
|
* Delete a run's directory tree over S3. A tree with a delegation on it, even an orphaned one, is not deleted: S3
|
|
332
351
|
* DeleteObjects leaves its objects in place and reports no error, and the run id would later reopen with its old
|
|
333
|
-
* store. So: revoke every delegation on the subtree, delete the files, then the
|
|
334
|
-
* directory with children refuses), then list the prefix. Anything left is
|
|
335
|
-
* never reported as deleted.
|
|
352
|
+
* store. So: revoke every delegation on the subtree and its holders' private directories, delete the files, then the
|
|
353
|
+
* directory markers deepest first (a directory with children refuses), then list the prefix. Anything left is
|
|
354
|
+
* RUN_TREE_NOT_DELETED; a non-empty prefix is never reported as deleted.
|
|
336
355
|
*/
|
|
337
356
|
export declare function deleteRunTree(control: CheckControl, id: string): Promise<{
|
|
338
357
|
objects: number;
|
package/dist/supervise.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { open } from "node:fs/promises";
|
|
7
7
|
import { hostname } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import { acquire, createRunDir, findDelegations, matchDelegations, pathlessResolver, mintMountToken, MOUNT_TOKEN_TTL, parseTokenNickname, removeMountToken, runPath, unmountClaim, } from "./claim.js";
|
|
9
|
+
import { acquire, createRunDir, findDelegations, matchDelegations, pathlessResolver, revokeBestEffort, revokeCompanions, mintMountToken, MOUNT_TOKEN_TTL, parseTokenNickname, removeMountToken, runPath, unmountClaim, } from "./claim.js";
|
|
10
10
|
import { FencedError, HeldError, PdaError } from "./errors.js";
|
|
11
11
|
import { parseRunRecord, RUN_JSON } from "./status.js";
|
|
12
12
|
export const CONTROL_TIMEOUT_MS = 10_000;
|
|
@@ -106,7 +106,7 @@ export async function ensureRunning(ref, host, options) {
|
|
|
106
106
|
}
|
|
107
107
|
// A due wake still goes through the delegation check: an instance that wrote `sleeping` and died before releasing
|
|
108
108
|
// left a held or orphaned delegation, which a plain start would hit (76) on every tick.
|
|
109
|
-
const held = await listHeld(opts.control, ref.id, opts.pathless);
|
|
109
|
+
const { held, all } = await listHeld(opts.control, ref.id, opts.pathless);
|
|
110
110
|
// A start in flight: the instance has not written run.json at its generation yet (it may not even have mounted).
|
|
111
111
|
// Revoking it or starting a second one would fence what was just started.
|
|
112
112
|
const baseGraceMs = opts.startGraceMs ?? leaseExpiryMs;
|
|
@@ -144,7 +144,7 @@ export async function ensureRunning(ref, host, options) {
|
|
|
144
144
|
const inFlight = await starting();
|
|
145
145
|
if (inFlight)
|
|
146
146
|
return inFlight;
|
|
147
|
-
await revokeListed(opts.control, ref.id, held);
|
|
147
|
+
await revokeListed(opts.control, ref.id, held, all);
|
|
148
148
|
return start("orphaned", held);
|
|
149
149
|
}
|
|
150
150
|
if (held.some((d) => d.isPending && !d.isOrphaned))
|
|
@@ -157,7 +157,7 @@ export async function ensureRunning(ref, host, options) {
|
|
|
157
157
|
if (inFlight)
|
|
158
158
|
return inFlight;
|
|
159
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);
|
|
160
|
+
await revokeListed(opts.control, ref.id, held, all);
|
|
161
161
|
return start("lease-expired", held, stonith);
|
|
162
162
|
}
|
|
163
163
|
/**
|
|
@@ -219,9 +219,11 @@ async function ensureRunDir(control, id, owner) {
|
|
|
219
219
|
await createRunDir(control, id, owner);
|
|
220
220
|
return true;
|
|
221
221
|
}
|
|
222
|
+
/** The run's delegations (`matchDelegations`), and the listing they came from. */
|
|
222
223
|
async function listHeld(control, id, resolve) {
|
|
223
224
|
try {
|
|
224
|
-
|
|
225
|
+
const all = await control.listDelegations();
|
|
226
|
+
return { held: await matchDelegations(all, id, resolve ?? pathlessResolver(control)), all };
|
|
225
227
|
}
|
|
226
228
|
catch (err) {
|
|
227
229
|
throw new SuperviseError("CONTROL_API_FAILED", `listing delegations on ${runPath(id)} failed`, { cause: err });
|
|
@@ -231,18 +233,20 @@ const sameDelegation = (a, b) => a.clientId === b.clientId && a.inodeId === b.in
|
|
|
231
233
|
/**
|
|
232
234
|
* Revoke exactly the delegations the decision was made on, never a fresh listing: a racing supervisor may have started
|
|
233
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.
|
|
234
237
|
*/
|
|
235
|
-
async function revokeListed(control, id, held) {
|
|
238
|
+
async function revokeListed(control, id, held, all) {
|
|
236
239
|
for (const d of held) {
|
|
237
240
|
try {
|
|
238
241
|
await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId });
|
|
239
242
|
}
|
|
240
243
|
catch (err) {
|
|
241
|
-
const still = await listHeld(control, id).then((now) => now.some((n) => sameDelegation(n, d)));
|
|
244
|
+
const still = await listHeld(control, id).then((now) => now.held.some((n) => sameDelegation(n, d)));
|
|
242
245
|
if (still)
|
|
243
246
|
throw new SuperviseError("CONTROL_API_FAILED", `revoking ${runPath(id)} (client ${d.clientId}) failed`, { cause: err });
|
|
244
247
|
}
|
|
245
248
|
}
|
|
249
|
+
await revokeCompanions(control, held, all);
|
|
246
250
|
}
|
|
247
251
|
function settleWithin(promise, ms) {
|
|
248
252
|
return new Promise((resolve) => {
|
|
@@ -343,6 +347,31 @@ export async function superviseRuns(refs, host, opts) {
|
|
|
343
347
|
}
|
|
344
348
|
return out;
|
|
345
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* One Archil disk's control API from an API key, the object `pi-durable-disk supervise` builds for itself: a library
|
|
352
|
+
* caller that composes its own supervisor (with `daytonaHost`, or a driver of its own) passes it as `control`. The S3 and
|
|
353
|
+
* control calls go to the disk the key reaches; `listUsers` reads the disk record again on every call (the list lags by
|
|
354
|
+
* seconds). `configure` is the `disk` client's process-wide setting, so one process controls disks of one key and region.
|
|
355
|
+
* `client` replaces the `disk` client (tests).
|
|
356
|
+
*/
|
|
357
|
+
export async function archilControl(options) {
|
|
358
|
+
const { configure, getDisk } = options.client ?? (await import("disk"));
|
|
359
|
+
configure({ apiKey: options.apiKey, region: options.region });
|
|
360
|
+
const disk = await getDisk(options.disk);
|
|
361
|
+
return {
|
|
362
|
+
getObject: (key) => disk.getObject(key),
|
|
363
|
+
headObject: (key) => disk.headObject(key),
|
|
364
|
+
putObject: (key, body, putOptions) => disk.putObject(key, body, putOptions),
|
|
365
|
+
addUser: (user) => disk.addUser(user),
|
|
366
|
+
removeUser: (type, identifier) => disk.removeUser(type, identifier),
|
|
367
|
+
listDelegations: () => disk.listDelegations(),
|
|
368
|
+
revokeDelegation: (d) => disk.revokeDelegation(d),
|
|
369
|
+
exec: (command) => disk.exec(command),
|
|
370
|
+
listObjects: (prefix, listOptions) => disk.listObjects(prefix, listOptions),
|
|
371
|
+
deleteObjects: (keys, deleteOptions) => disk.deleteObjects(keys, deleteOptions),
|
|
372
|
+
listUsers: async () => (await getDisk(options.disk)).authorizedUsers ?? [],
|
|
373
|
+
};
|
|
374
|
+
}
|
|
346
375
|
const RELEASED = ["paused", "sleeping", "done", "failed"];
|
|
347
376
|
/**
|
|
348
377
|
* Remove mount-token users that no live mount needs. Removing a token user under a live mount makes that mount lose its
|
|
@@ -501,15 +530,13 @@ export async function checkHost(options) {
|
|
|
501
530
|
/**
|
|
502
531
|
* Delete a run's directory tree over S3. A tree with a delegation on it, even an orphaned one, is not deleted: S3
|
|
503
532
|
* DeleteObjects leaves its objects in place and reports no error, and the run id would later reopen with its old
|
|
504
|
-
* store. So: revoke every delegation on the subtree, delete the files, then the
|
|
505
|
-
* directory with children refuses), then list the prefix. Anything left is
|
|
506
|
-
* never reported as deleted.
|
|
533
|
+
* store. So: revoke every delegation on the subtree and its holders' private directories, delete the files, then the
|
|
534
|
+
* directory markers deepest first (a directory with children refuses), then list the prefix. Anything left is
|
|
535
|
+
* RUN_TREE_NOT_DELETED; a non-empty prefix is never reported as deleted.
|
|
507
536
|
*/
|
|
508
537
|
export async function deleteRunTree(control, id) {
|
|
509
538
|
const prefix = `${runPath(id)}/`;
|
|
510
|
-
const held = await
|
|
511
|
-
for (const d of held)
|
|
512
|
-
await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId }).catch(() => { });
|
|
539
|
+
const held = await revokeBestEffort(control, id);
|
|
513
540
|
const keys = (await control.listObjects(prefix, { recursive: true })).objects.map((o) => o.key);
|
|
514
541
|
const dirs = [...new Set([...keys.filter((k) => k.endsWith("/")), prefix])];
|
|
515
542
|
const depth = (k) => k.split("/").length;
|
package/dist/watchdog.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface SweepOptions {
|
|
|
9
9
|
readonly proc?: string;
|
|
10
10
|
readonly sys?: string;
|
|
11
11
|
readonly kill?: (pid: number, signal: NodeJS.Signals) => void;
|
|
12
|
+
/** Lists processes with this ps(1) instead of reading `proc`: the default on macOS, which has no /proc, unless `proc` is given. */
|
|
13
|
+
readonly ps?: (file: string, args: readonly string[]) => string;
|
|
12
14
|
}
|
|
13
15
|
/** One look at the shared state at `now`. True once the lease has lapsed, for good; the first look records the age. */
|
|
14
16
|
export declare const lapsed: (shared: BigInt64Array, now: bigint) => boolean;
|
|
@@ -16,7 +18,7 @@ export declare const lapsed: (shared: BigInt64Array, now: bigint) => boolean;
|
|
|
16
18
|
* SIGKILL the instance's commands: the process group of each direct child (pi starts each command as its own group's
|
|
17
19
|
* leader), or the child alone when it shares the instance's group; with `ownCgroup`, every other process of the cgroup.
|
|
18
20
|
* 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
|
|
21
|
+
* number of signals sent. Where there is no /proc (macOS) the processes come from one ps listing, and there is no cgroup. `stat` lines are `pid (comm) state ppid pgrp ...`, read after the last `)` since comm may hold
|
|
20
22
|
* spaces and parentheses.
|
|
21
23
|
*/
|
|
22
24
|
export declare const sweep: (instance: number, options?: SweepOptions) => number;
|