@parcha/pi-durable-disk 0.1.0-beta.14 → 0.1.0-beta.16

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.
@@ -0,0 +1,63 @@
1
+ import { type ArchilHost } from "./claim.js";
2
+ import type { SupervisorControl } from "./supervise.js";
3
+ /** The control API both calls use; `archilControl`'s satisfies it. */
4
+ export type ClaimControl = Pick<SupervisorControl, "putObject" | "addUser" | "removeUser" | "listDelegations" | "revokeDelegation" | "exec" | "getObject" | "headObject">;
5
+ /** The path `fn` holds: `root` is where it is mounted on this host. */
6
+ export interface HeldPath {
7
+ readonly root: string;
8
+ readonly path: string;
9
+ /** Aborted once the claim is known lost (a barrier refused) or the caller's `signal` aborts. */
10
+ readonly signal: AbortSignal;
11
+ /** `archil sync` now: every write so far is durable when it resolves; a refusal is FENCED. */
12
+ barrier(): Promise<{
13
+ ms: number;
14
+ }>;
15
+ }
16
+ export interface WithClaimOptions {
17
+ /** How long the holder may hold the path; written in its `.claim` as its deadline. Default 60 s. */
18
+ readonly holdMs?: number;
19
+ /** How long to queue behind a live holder before CLAIM_HELD. Default 30 s. */
20
+ readonly waitMs?: number;
21
+ /** Where the mount goes: `<mountRoot>/runs/<pid>-<uuid>`, of this call's own. Default `/mnt/archil/.claims`. */
22
+ readonly mountRoot?: string;
23
+ readonly host?: ArchilHost;
24
+ readonly signal?: AbortSignal;
25
+ /** The owner and mode of the path's directories it makes when the disk lacks them. Default this process's, 0700. */
26
+ readonly owner?: {
27
+ readonly uid: number;
28
+ readonly gid: number;
29
+ readonly mode?: number;
30
+ };
31
+ }
32
+ export declare const DEFAULT_HOLD_MS = 60000;
33
+ export declare const DEFAULT_WAIT_MS = 30000;
34
+ /**
35
+ * Hold `path` exclusively while `fn` runs: wait for a live holder (`waitMs`), revoke a lost one, mount `<disk>:/<path>` at
36
+ * a fresh `<mountRoot>/runs/<pid>-<uuid>`, write `.claim` with this holder's deadline (`holdMs`), run `fn`, then barrier,
37
+ * unmount and give the mount token back, whether `fn` resolved or threw. Returns `fn`'s value; throws `fn`'s own error,
38
+ * else INVALID_ARGUMENT, CLAIM_HELD (76, retryable), CONTROL_API_FAILED, the mount's errors, or FENCED (75: the claim was
39
+ * revoked from under `fn`, whose writes are then not durable).
40
+ */
41
+ export declare function withClaim<T>(where: {
42
+ readonly control: ClaimControl;
43
+ readonly disk: string;
44
+ readonly region: string;
45
+ readonly path: string;
46
+ }, fn: (held: HeldPath) => Promise<T>, options?: WithClaimOptions): Promise<T>;
47
+ /**
48
+ * `<path>/<file>` over S3, once it is released: no delegation on the path (or an ancestor or a descendant) before or
49
+ * after the read, for SQLite (`sqlite`) no `-wal` with content (its writer checkpointed with TRUNCATE and closed), and the
50
+ * object unchanged across the read. A held path is CLAIM_HELD (76, retryable; never read an older release instead), a
51
+ * WAL with content NOT_RELEASED; a file that moved during every one of `attempts` reads (default 3) is CLAIM_HELD. Null
52
+ * when the file does not exist. It never mounts, so it never blocks a carry back.
53
+ */
54
+ export declare function readReleased(where: {
55
+ readonly control: ClaimControl;
56
+ readonly path: string;
57
+ }, file: string, options?: {
58
+ readonly sqlite?: boolean;
59
+ readonly attempts?: number;
60
+ }): Promise<{
61
+ bytes: Uint8Array;
62
+ etag: string | null;
63
+ } | null>;
@@ -0,0 +1,325 @@
1
+ // A short exclusive claim on any path of a disk (withClaim), and a consistent S3 read of a released file (readReleased),
2
+ // for a host that keeps state beside its runs: pi-durable-agent's agent home under `agent/`, carried back at a run's end.
3
+ //
4
+ // A claim is one exclusive mount. Archil refuses a second client's mount of the path, of an ancestor or of a descendant,
5
+ // and that refusal is the exclusivity; the delegation listing only decides whom to wait for and whom to revoke. A holder
6
+ // is lost when its client is gone (orphaned), or when it holds exactly the path and the `.claim` there names a deadline
7
+ // that has passed and stays the same across a few seconds (a holder that just mounted rewrites it at once); only a lost
8
+ // holder is revoked. Safety rests on the fence, never on the deadline: a holder revoked while still alive gets EIO at
9
+ // its next fsync, so its commit rolls back and its own call throws FENCED. Both calls are supervisor side: they need the
10
+ // API key's control API.
11
+ import { randomUUID } from "node:crypto";
12
+ import { hostname } from "node:os";
13
+ import { join } from "node:path";
14
+ import { setTimeout as sleep } from "node:timers/promises";
15
+ import { CLAIM_PROBE, DEFAULT_MOUNT_ROOT, exclusiveMount, mintMountToken, removeMountToken, revokeCompanions } from "./claim.js";
16
+ import { ClaimError, HeldError } from "./errors.js";
17
+ export const DEFAULT_HOLD_MS = 60_000;
18
+ export const DEFAULT_WAIT_MS = 30_000;
19
+ const POLL_MS = 250;
20
+ /**
21
+ * How long a past-deadline `.claim` must stay the same, with the same holder, before that holder counts as lost. A holder
22
+ * that mounted a moment ago writes its own `.claim` right after its mount; until then the one there is its predecessor's.
23
+ */
24
+ const CONFIRM_MS = 3_000;
25
+ const TOKEN_TTL = "10m";
26
+ const SEGMENT = /^[A-Za-z0-9._-]+$/;
27
+ /** CLAIM_HELD reads as retryable: the path is not released yet, and nothing older stands in for it. */
28
+ const notReleased = (path, why, cause) => new HeldError("claim", `${path} is held by another client and is not released yet (${why}). CLAIM_HELD is retryable: retry within waitMs, ` +
29
+ "and never fall back to an older release.", { cause });
30
+ /** `path` if it is one relative path of safe segments that no run owns; else INVALID_ARGUMENT. */
31
+ function claimPath(path) {
32
+ const segments = path.split("/");
33
+ if (path === "" || segments.some((s) => !SEGMENT.test(s) || s === "." || s === "..")) {
34
+ throw new ClaimError("INVALID_ARGUMENT", `${JSON.stringify(path)} is not a relative path of safe segments (letters, digits, ".", "_", "-")`);
35
+ }
36
+ if (segments[0] === "runs")
37
+ throw new ClaimError("INVALID_ARGUMENT", `${path} is under runs/, which openDurableRun and the supervisor claim`);
38
+ if (segments[0] === ".archil")
39
+ throw new ClaimError("INVALID_ARGUMENT", `${path} is under .archil/, Archil's own`);
40
+ return path;
41
+ }
42
+ /** Whether a delegation's path is `path`, under it, or above it (the disk root included): each conflicts with a mount of it. */
43
+ const conflicts = (delegated, path) => {
44
+ const p = delegated.replace(/^\/+/, "").replace(/\/+$/, "");
45
+ return p === "" || p === path || p.startsWith(`${path}/`) || path.startsWith(`${p}/`);
46
+ };
47
+ /**
48
+ * The delegations that conflict with a mount of `path`, from one listing. A pathless one is attributed by inode with one
49
+ * `exec` (the path's ancestors and everything under it); one that cannot be attributed fails CONTROL_API_FAILED, never
50
+ * reads as unheld.
51
+ */
52
+ async function holdersOf(control, path) {
53
+ let all;
54
+ try {
55
+ all = await control.listDelegations();
56
+ }
57
+ catch (err) {
58
+ throw new ClaimError("CONTROL_API_FAILED", "listing delegations failed", { cause: err });
59
+ }
60
+ const holders = all.filter((d) => d.path !== undefined && d.path !== null && conflicts(d.path, path));
61
+ const pathless = all.filter((d) => !d.path);
62
+ if (pathless.length > 0)
63
+ holders.push(...(await pathlessOn(control, path, pathless)));
64
+ return { holders, all };
65
+ }
66
+ async function pathlessOn(control, path, pathless) {
67
+ if (!control.exec)
68
+ throw new ClaimError("CONTROL_API_FAILED", `a delegation with no path may be on ${path}, and the control API has no exec to attribute it`);
69
+ const inodes = [...new Set(pathless.map((d) => d.inodeId))];
70
+ if (!inodes.every((i) => Number.isSafeInteger(i) && i >= 0))
71
+ throw new ClaimError("CONTROL_API_FAILED", "a pathless delegation has no valid inode id");
72
+ // Every path relative to the disk root as ./<path>, so a segment that begins with "-" is never read as an option.
73
+ const segments = path.split("/");
74
+ const above = [".", ...segments.slice(0, -1).map((_, i) => `./${segments.slice(0, i + 1).join("/")}`)];
75
+ const test = inodes.map((i) => `-inum ${i}`).join(" -o ");
76
+ let r;
77
+ try {
78
+ r = await control.exec(`for a in ${above.join(" ")}; do if [ -e "$a" ]; then stat -c '%i' -- "$a" || exit 3; fi; done; if [ -e ./${path} ]; then find ./${path} \\( ${test} \\) -printf '%i\\n' || exit 3; fi`);
79
+ }
80
+ catch (err) {
81
+ throw new ClaimError("CONTROL_API_FAILED", `attributing pathless delegations to ${path} failed`, { cause: err });
82
+ }
83
+ if (r.exitCode !== 0)
84
+ throw new ClaimError("CONTROL_API_FAILED", `attributing pathless delegations to ${path}: exit ${r.exitCode}`);
85
+ const on = new Set(String(r.stdout ?? "").split("\n").filter(Boolean).map(Number));
86
+ return pathless.filter((d) => on.has(d.inodeId));
87
+ }
88
+ /** `<path>/.claim` over S3, and whether the deadline it names has passed; null when it cannot be read or parsed. */
89
+ async function readClaim(control, path) {
90
+ try {
91
+ const text = new TextDecoder().decode(await control.getObject(`${path}/${CLAIM_PROBE}`));
92
+ const claim = JSON.parse(text);
93
+ const deadline = typeof claim.deadline === "string" ? Date.parse(claim.deadline) : Number.NaN;
94
+ return Number.isFinite(deadline) ? { text, expired: deadline < Date.now() } : null;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ const exactly = (d, path) => typeof d.path === "string" && d.path.replace(/^\/+/, "").replace(/\/+$/, "") === path;
101
+ const clientsOf = (holders) => [...new Set(holders.map((d) => d.clientId))].sort().join(",");
102
+ /**
103
+ * Whether the live holders are lost past their deadline. Only a holder of exactly `path` has its deadline there, so any
104
+ * other (a descendant's, an ancestor's, one with no path) is never lost this way. And the `.claim` must be the same
105
+ * expired one, with the same holders, across CONFIRM_MS: a holder that just mounted rewrites it within that time.
106
+ */
107
+ async function lostPastDeadline(control, path, holders, signal) {
108
+ if (!holders.every((d) => exactly(d, path)))
109
+ return false;
110
+ const first = await readClaim(control, path);
111
+ if (!first?.expired)
112
+ return false;
113
+ await sleep(CONFIRM_MS, undefined, { signal }).catch(() => signal?.throwIfAborted());
114
+ const again = await holdersOf(control, path);
115
+ if (clientsOf(again.holders) !== clientsOf(holders))
116
+ return false;
117
+ const second = await readClaim(control, path);
118
+ return second !== null && second.expired && second.text === first.text;
119
+ }
120
+ /** Until nothing conflicts with a mount of `path`: lost holders are revoked, live ones waited for until `deadline`. */
121
+ async function settle(control, path, deadline, signal) {
122
+ for (;;) {
123
+ signal?.throwIfAborted();
124
+ const { holders, all } = await holdersOf(control, path);
125
+ if (holders.length === 0)
126
+ return;
127
+ if (holders.every((d) => d.isOrphaned) || (await lostPastDeadline(control, path, holders, signal))) {
128
+ try {
129
+ for (const d of holders)
130
+ await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId });
131
+ }
132
+ catch (err) {
133
+ throw new ClaimError("CONTROL_API_FAILED", `revoking the lost holder of ${path} failed`, { cause: err });
134
+ }
135
+ await revokeCompanions(control, holders, all);
136
+ continue;
137
+ }
138
+ signal?.throwIfAborted();
139
+ if (Date.now() >= deadline)
140
+ throw notReleased(path, `a live holder within its deadline: ${holders.map((d) => d.clientId).join(", ")}`);
141
+ await sleep(POLL_MS, undefined, { signal }).catch(() => signal?.throwIfAborted());
142
+ }
143
+ }
144
+ /**
145
+ * The directories of `path` the disk lacks, made over S3 with `owner`: archil mounts no path whose directory is missing
146
+ * ("the configured subdirectory does not exist"). A write refused with 409 met another caller that made the directory
147
+ * and holds it: HeldError, which the caller queues on as on a refused mount.
148
+ */
149
+ async function ensureDirectories(control, path, owner) {
150
+ const segments = path.split("/");
151
+ for (let i = 1; i <= segments.length; i++) {
152
+ const key = `${segments.slice(0, i).join("/")}/`;
153
+ try {
154
+ if ((await control.headObject(key)) === null)
155
+ await control.putObject(key, "", owner);
156
+ }
157
+ catch (err) {
158
+ if (err.status === 409)
159
+ throw new HeldError("claim", `${key} is held by the caller that made it (409)`, { cause: err });
160
+ throw new ClaimError("CONTROL_API_FAILED", `making ${key} on the disk failed`, { cause: err });
161
+ }
162
+ }
163
+ }
164
+ const positive = (name, value) => {
165
+ if (!Number.isSafeInteger(value) || value <= 0)
166
+ throw new ClaimError("INVALID_ARGUMENT", `${name} ${value} is not a positive whole number of milliseconds`);
167
+ return value;
168
+ };
169
+ /**
170
+ * Hold `path` exclusively while `fn` runs: wait for a live holder (`waitMs`), revoke a lost one, mount `<disk>:/<path>` at
171
+ * a fresh `<mountRoot>/runs/<pid>-<uuid>`, write `.claim` with this holder's deadline (`holdMs`), run `fn`, then barrier,
172
+ * unmount and give the mount token back, whether `fn` resolved or threw. Returns `fn`'s value; throws `fn`'s own error,
173
+ * else INVALID_ARGUMENT, CLAIM_HELD (76, retryable), CONTROL_API_FAILED, the mount's errors, or FENCED (75: the claim was
174
+ * revoked from under `fn`, whose writes are then not durable).
175
+ */
176
+ export async function withClaim(where, fn, options = {}) {
177
+ const path = claimPath(where.path);
178
+ const holdMs = positive("holdMs", options.holdMs ?? DEFAULT_HOLD_MS);
179
+ const deadline = Date.now() + positive("waitMs", options.waitMs ?? DEFAULT_WAIT_MS);
180
+ const { control } = where;
181
+ const mountRoot = options.mountRoot ?? join(DEFAULT_MOUNT_ROOT, ".claims");
182
+ const owner = { uid: options.owner?.uid ?? process.getuid(), gid: options.owner?.gid ?? process.getgid(), mode: options.owner?.mode ?? 0o700 };
183
+ for (;;) {
184
+ await settle(control, path, deadline, options.signal);
185
+ options.signal?.throwIfAborted();
186
+ try {
187
+ await ensureDirectories(control, path, owner);
188
+ }
189
+ catch (err) {
190
+ // Another caller made the directory and holds it: queue again, within the same wait.
191
+ if (err instanceof HeldError && Date.now() < deadline && !options.signal?.aborted) {
192
+ await sleep(POLL_MS);
193
+ continue;
194
+ }
195
+ options.signal?.throwIfAborted();
196
+ throw err instanceof HeldError ? notReleased(path, "its directory is being made by another holder", err) : err;
197
+ }
198
+ const minted = await mintMountToken(control, { nickname: `pdc-${path.replaceAll("/", ".")}-${Date.now().toString(36)}`, ttl: TOKEN_TTL });
199
+ const at = new Date();
200
+ let mount;
201
+ try {
202
+ // <mountRoot>/runs/<name>: the shape archil-scoped's cleanup verbs take, for a host that cannot unmount (Sysbox).
203
+ mount = await exclusiveMount({ disk: where.disk, region: where.region, path }, join(mountRoot, "runs", `${process.pid}-${randomUUID()}`), minted.token, {
204
+ host: options.host,
205
+ probe: `${JSON.stringify({ pid: process.pid, host: hostname(), at: at.toISOString(), deadline: new Date(at.getTime() + holdMs).toISOString() })}\n`,
206
+ });
207
+ }
208
+ catch (err) {
209
+ await removeMountToken(control, minted.identifier).catch(() => { });
210
+ // Another caller mounted between the listing and the mount: queue again, within the same wait.
211
+ if (err instanceof HeldError && Date.now() < deadline && !options.signal?.aborted) {
212
+ await sleep(POLL_MS);
213
+ continue;
214
+ }
215
+ options.signal?.throwIfAborted();
216
+ throw err instanceof HeldError ? notReleased(path, "its mount was refused", err) : err;
217
+ }
218
+ // Aborted while it mounted: fn never runs, and nothing it holds is kept.
219
+ if (options.signal?.aborted) {
220
+ await mount.unmount().catch(() => undefined);
221
+ await removeMountToken(control, minted.identifier).catch(() => { });
222
+ options.signal.throwIfAborted();
223
+ }
224
+ const lost = new AbortController();
225
+ const onAbort = () => lost.abort(options.signal?.reason);
226
+ options.signal?.addEventListener("abort", onAbort, { once: true });
227
+ const barrier = async () => {
228
+ try {
229
+ return await mount.barrier();
230
+ }
231
+ catch (err) {
232
+ lost.abort(err);
233
+ throw err;
234
+ }
235
+ };
236
+ let outcome;
237
+ try {
238
+ outcome = { ok: true, value: await fn({ root: mount.root, path, signal: lost.signal, barrier }) };
239
+ }
240
+ catch (error) {
241
+ outcome = { ok: false, error };
242
+ }
243
+ let release;
244
+ try {
245
+ await barrier();
246
+ }
247
+ catch (err) {
248
+ release = err;
249
+ }
250
+ try {
251
+ await mount.unmount();
252
+ }
253
+ catch (err) {
254
+ release ??= err;
255
+ }
256
+ await removeMountToken(control, minted.identifier).catch(() => { });
257
+ options.signal?.removeEventListener("abort", onAbort);
258
+ if (!outcome.ok)
259
+ throw outcome.error;
260
+ if (release !== undefined)
261
+ throw release;
262
+ return outcome.value;
263
+ }
264
+ }
265
+ /** An object's identity for "did it move": its etag, else its size and last-modified time. */
266
+ function identity(head) {
267
+ if (head === null || typeof head !== "object")
268
+ return null;
269
+ const { etag, size, lastModified } = head;
270
+ if (typeof etag === "string" && etag !== "")
271
+ return `etag:${etag}`;
272
+ return `size:${String(size)}@${lastModified instanceof Date ? lastModified.toISOString() : String(lastModified)}`;
273
+ }
274
+ const sizeOf = (head) => (head !== null && typeof head === "object" ? Number(head.size ?? 0) : 0);
275
+ /**
276
+ * `<path>/<file>` over S3, once it is released: no delegation on the path (or an ancestor or a descendant) before or
277
+ * after the read, for SQLite (`sqlite`) no `-wal` with content (its writer checkpointed with TRUNCATE and closed), and the
278
+ * object unchanged across the read. A held path is CLAIM_HELD (76, retryable; never read an older release instead), a
279
+ * WAL with content NOT_RELEASED; a file that moved during every one of `attempts` reads (default 3) is CLAIM_HELD. Null
280
+ * when the file does not exist. It never mounts, so it never blocks a carry back.
281
+ */
282
+ export async function readReleased(where, file, options = {}) {
283
+ const path = claimPath(where.path);
284
+ const key = `${path}/${claimPath(file)}`;
285
+ const attempts = positive("attempts", options.attempts ?? 3);
286
+ const { control } = where;
287
+ const head = async (name) => {
288
+ try {
289
+ return await control.headObject(name);
290
+ }
291
+ catch (err) {
292
+ throw new ClaimError("CONTROL_API_FAILED", `head ${name} failed`, { cause: err });
293
+ }
294
+ };
295
+ for (let attempt = 1;; attempt++) {
296
+ const { holders } = await holdersOf(control, path);
297
+ if (holders.length > 0)
298
+ throw notReleased(path, `held by ${holders.map((d) => d.clientId).join(", ")}`);
299
+ if (options.sqlite && sizeOf(await head(`${key}-wal`)) > 0) {
300
+ throw new ClaimError("NOT_RELEASED", `${key}-wal has content: its writer did not checkpoint (TRUNCATE) and close, so ${key} alone is not the database`);
301
+ }
302
+ const before = await head(key);
303
+ if (before === null)
304
+ return null;
305
+ let bytes;
306
+ try {
307
+ bytes = await control.getObject(key);
308
+ }
309
+ catch (err) {
310
+ throw new ClaimError("CONTROL_API_FAILED", `get ${key} failed`, { cause: err });
311
+ }
312
+ const after = await head(key);
313
+ // A writer that mounted, committed to the WAL and let go during the read leaves the database file as it was.
314
+ if (options.sqlite && sizeOf(await head(`${key}-wal`)) > 0) {
315
+ throw new ClaimError("NOT_RELEASED", `${key}-wal gained content during the read: a writer committed without a checkpoint (TRUNCATE)`);
316
+ }
317
+ const still = (await holdersOf(control, path)).holders.length === 0;
318
+ if (still && after !== null && identity(after) === identity(before) && sizeOf(after) === bytes.length) {
319
+ const etag = after.etag;
320
+ return { bytes, etag: typeof etag === "string" ? etag : null };
321
+ }
322
+ if (attempt >= attempts)
323
+ throw notReleased(path, `${key} changed while it was read, ${attempts} times`);
324
+ }
325
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parcha/pi-durable-disk",
3
- "version": "0.1.0-beta.14",
3
+ "version": "0.1.0-beta.16",
4
4
  "description": "A durable pi host on a network disk (Archil): one owner per run, a fence, durable commits, and a supervisor.",
5
5
  "keywords": [
6
6
  "pi",
@@ -56,13 +56,14 @@
56
56
  "test": "node --test --test-timeout=240000 'test/*.test.ts' 'test/examples/*.test.ts'",
57
57
  "test:acceptance": "node --test --test-concurrency=1 'test/acceptance/*.test.ts'",
58
58
  "test:live": "node --test --test-concurrency=1 'test/live/*.test.ts'",
59
- "test:live:claim": "node --test --test-concurrency=1 test/live/claim.live.test.ts",
59
+ "test:live:claim": "node --test --test-concurrency=1 test/live/claim.live.test.ts test/live/record-home.live.test.ts",
60
60
  "test:live:env": "node --test --test-concurrency=1 test/live/env.live.test.ts",
61
61
  "test:live:lifecycle": "node --test --test-concurrency=1 test/live/lifecycle.live.test.ts",
62
62
  "test:live:run": "node --test --test-concurrency=1 test/live/run.live.test.ts",
63
63
  "test:live:store": "node --test --test-concurrency=1 test/live/store.live.test.ts",
64
64
  "test:live:supervise": "node --test --test-concurrency=1 test/live/supervise.live.test.ts",
65
65
  "test:live:supervise-slow": "node --test --test-concurrency=1 test/live/supervise-slow.live.test.ts",
66
+ "test:live:with-claim": "node --test --test-concurrency=1 test/live/with-claim.live.test.ts",
66
67
  "test:live:docker": "PDA_LIVE_DOCKER=1 node --test --test-concurrency=1 test/live/docker.live.test.ts test/live/docker-park.live.test.ts",
67
68
  "verify:tarball": "node scripts/verify-tarball.mjs"
68
69
  },