@smoothbricks/nx-cache-server 0.0.0-stage → 0.1.1
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/README.md +131 -2
- package/dist/server/nx-cache-server.js +710 -0
- package/dist/ts/config.d.ts +58 -0
- package/dist/ts/config.d.ts.map +1 -0
- package/dist/ts/config.js +116 -0
- package/dist/ts/gc.d.ts +36 -0
- package/dist/ts/gc.d.ts.map +1 -0
- package/dist/ts/gc.js +135 -0
- package/dist/ts/index.d.ts +6 -0
- package/dist/ts/index.d.ts.map +1 -0
- package/dist/ts/index.js +5 -0
- package/dist/ts/log.d.ts +16 -0
- package/dist/ts/log.d.ts.map +1 -0
- package/dist/ts/log.js +22 -0
- package/dist/ts/main.d.ts +3 -0
- package/dist/ts/main.d.ts.map +1 -0
- package/dist/ts/main.js +86 -0
- package/dist/ts/root-lease.d.ts +7 -0
- package/dist/ts/root-lease.d.ts.map +1 -0
- package/dist/ts/root-lease.js +72 -0
- package/dist/ts/server.d.ts +71 -0
- package/dist/ts/server.d.ts.map +1 -0
- package/dist/ts/server.js +282 -0
- package/dist/ts/store.d.ts +74 -0
- package/dist/ts/store.d.ts.map +1 -0
- package/dist/ts/store.js +166 -0
- package/package.json +103 -4
- package/src/__tests__/supervisor.fixture.ts +15 -0
- package/src/config.ts +201 -0
- package/src/gc.ts +164 -0
- package/src/index.ts +5 -0
- package/src/log.ts +26 -0
- package/src/main.ts +97 -0
- package/src/root-lease.ts +71 -0
- package/src/server.ts +416 -0
- package/src/store.ts +215 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/// <reference types="bun" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The cache directory: one file per key, and a write that cannot replace one.
|
|
5
|
+
*
|
|
6
|
+
* `link(2)` is the whole design. A staged upload becomes an entry by hard-linking it to
|
|
7
|
+
* `<directory>/<key>`, which the kernel either performs or refuses with `EEXIST` — atomically, with no
|
|
8
|
+
* window in which a reader sees a partial file and no window in which two writers of the same key both
|
|
9
|
+
* believe they won. `rename(2)` would be wrong here: it replaces silently, so the loser of a race
|
|
10
|
+
* would overwrite the winner's artifact and both would be told they stored it.
|
|
11
|
+
*
|
|
12
|
+
* Staging lives at `<directory>/.staging` for two reasons: it is on the same filesystem, which
|
|
13
|
+
* `link(2)` requires, and the leading dot puts it outside the cache-key charset, so no request can
|
|
14
|
+
* name it. Opening the store reclaims whatever a crash left there — a nonzero count in the startup log
|
|
15
|
+
* is how an operator learns a previous process died mid-upload.
|
|
16
|
+
*
|
|
17
|
+
* One process owns one directory at a time. `openCacheStore` takes a persistent kernel lock on
|
|
18
|
+
* `<directory>/.owner.lock` before it touches staging, so a second open is refused with `store-locked`
|
|
19
|
+
* and reclaims nothing until the first store is closed or its process dies.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { link, mkdir, open, readdir, unlink } from 'node:fs/promises';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { Readable } from 'node:stream';
|
|
25
|
+
import { logLine, logValue } from './log.js';
|
|
26
|
+
import { tryAcquireRootLease } from './root-lease.js';
|
|
27
|
+
|
|
28
|
+
const STAGING = '.staging';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The cache-key charset. nx sends a task hash and nothing else — twenty decimal digits today
|
|
32
|
+
* (`.nx/cache/10018087211778381691`) — and this accepts any hex, decimal or base64url rendering of
|
|
33
|
+
* one. What it rejects is what makes `join(directory, key)` safe by construction: no `/`, no `\`, and
|
|
34
|
+
* no `.`, so no key can name a parent directory, an absolute path, or the staging directory.
|
|
35
|
+
*/
|
|
36
|
+
const CACHE_KEY = /^[A-Za-z0-9_-]{1,128}$/;
|
|
37
|
+
|
|
38
|
+
export interface CacheStore {
|
|
39
|
+
readonly directory: string;
|
|
40
|
+
readonly staging: string;
|
|
41
|
+
/** Staged uploads a previous process left behind, removed while opening. */
|
|
42
|
+
readonly reclaimedStagingFiles: number;
|
|
43
|
+
/** Releases the root lease. Call only after in-flight requests and publication have drained. */
|
|
44
|
+
close(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type CacheStoreOpenResult =
|
|
48
|
+
| { readonly ok: true; readonly store: CacheStore }
|
|
49
|
+
| { readonly ok: false; readonly kind: 'store-locked'; readonly path: string };
|
|
50
|
+
|
|
51
|
+
/** A whole, verified staged upload; only publication makes it visible to GET. */
|
|
52
|
+
export interface CompleteUpload {
|
|
53
|
+
readonly kind: 'ready';
|
|
54
|
+
readonly key: string;
|
|
55
|
+
readonly stagedPath: string;
|
|
56
|
+
readonly bytes: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type StageOutcome =
|
|
60
|
+
| CompleteUpload
|
|
61
|
+
| { readonly kind: 'incomplete'; readonly bytes: number; readonly stagingRetained: boolean }
|
|
62
|
+
| { readonly kind: 'failed'; readonly reason: string; readonly stagingRetained: boolean };
|
|
63
|
+
|
|
64
|
+
export type StoreOutcome =
|
|
65
|
+
| { readonly kind: 'stored'; readonly bytes: number; readonly stagingRetained: boolean }
|
|
66
|
+
| { readonly kind: 'exists'; readonly stagingRetained: boolean }
|
|
67
|
+
| { readonly kind: 'failed'; readonly reason: string; readonly stagingRetained: boolean };
|
|
68
|
+
|
|
69
|
+
/** The common conflict outcome carries no per-request data. */
|
|
70
|
+
const ALREADY_STORED: StoreOutcome = { kind: 'exists', stagingRetained: false };
|
|
71
|
+
|
|
72
|
+
export function isCacheKey(candidate: string): boolean {
|
|
73
|
+
return CACHE_KEY.test(candidate);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function entryPath(store: CacheStore, key: string): string {
|
|
77
|
+
return join(store.directory, key);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function openCacheStore(directory: string): Promise<CacheStoreOpenResult> {
|
|
81
|
+
await mkdir(directory, { recursive: true });
|
|
82
|
+
const lockPath = join(directory, '.owner.lock');
|
|
83
|
+
const lease = await tryAcquireRootLease(lockPath);
|
|
84
|
+
if (!lease) return { ok: false, kind: 'store-locked', path: lockPath };
|
|
85
|
+
try {
|
|
86
|
+
const staging = join(directory, STAGING);
|
|
87
|
+
await mkdir(staging, { recursive: true });
|
|
88
|
+
const abandoned = await readdir(staging);
|
|
89
|
+
for (const name of abandoned) {
|
|
90
|
+
await unlink(join(staging, name));
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
store: { directory, staging, reclaimedStagingFiles: abandoned.length, close: () => lease.close() },
|
|
95
|
+
};
|
|
96
|
+
} catch (failure) {
|
|
97
|
+
await lease.close();
|
|
98
|
+
throw failure;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type ReadOutcome =
|
|
103
|
+
| { readonly kind: 'found'; readonly bytes: number; readonly body: ReadableStream<Uint8Array> }
|
|
104
|
+
| { readonly kind: 'missing' }
|
|
105
|
+
| { readonly kind: 'failed'; readonly reason: string };
|
|
106
|
+
|
|
107
|
+
const MISSING: ReadOutcome = { kind: 'missing' };
|
|
108
|
+
|
|
109
|
+
/** Pin an opened inode before answering: a concurrent sweep may unlink its path, not truncate GET. */
|
|
110
|
+
export async function readEntry(store: CacheStore, key: string): Promise<ReadOutcome> {
|
|
111
|
+
try {
|
|
112
|
+
const file = await open(entryPath(store, key), 'r');
|
|
113
|
+
try {
|
|
114
|
+
const stats = await file.stat();
|
|
115
|
+
if (!stats.isFile() || stats.size === 0) {
|
|
116
|
+
await file.close();
|
|
117
|
+
return MISSING;
|
|
118
|
+
}
|
|
119
|
+
// APFS can leave atime unchanged on a real read. Mark SIEVE's visited bit explicitly on the
|
|
120
|
+
// pinned inode, and only when it is clear; the original modification time remains the cursor.
|
|
121
|
+
if (stats.atimeMs <= stats.mtimeMs) {
|
|
122
|
+
await file.utimes((stats.mtimeMs + 1) / 1000, stats.mtimeMs / 1000);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
kind: 'found',
|
|
126
|
+
bytes: stats.size,
|
|
127
|
+
body: Readable.toWeb(file.createReadStream({ autoClose: true })),
|
|
128
|
+
};
|
|
129
|
+
} catch (cause) {
|
|
130
|
+
await file.close();
|
|
131
|
+
return { kind: 'failed', reason: describe(cause) };
|
|
132
|
+
}
|
|
133
|
+
} catch (cause) {
|
|
134
|
+
return errnoOf(cause) === 'ENOENT' ? MISSING : { kind: 'failed', reason: describe(cause) };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Stage the body and verify its length. Publication is a separate atomic operation.
|
|
140
|
+
*
|
|
141
|
+
* WHY the length is checked before the link and not after: an entry is permanent, so publishing a
|
|
142
|
+
* truncated one poisons that task hash forever — every later build fetches an artifact nx cannot
|
|
143
|
+
* extract, and never-replace means nobody can correct it. A body that ends short of `declaredBytes`
|
|
144
|
+
* is therefore discarded while it is still only a staged file.
|
|
145
|
+
*/
|
|
146
|
+
export async function stageEntry(
|
|
147
|
+
store: CacheStore,
|
|
148
|
+
key: string,
|
|
149
|
+
body: ReadableStream<Uint8Array> | null,
|
|
150
|
+
declaredBytes: number,
|
|
151
|
+
): Promise<StageOutcome> {
|
|
152
|
+
const staged = join(store.staging, `${key}.${crypto.randomUUID()}`);
|
|
153
|
+
let bytes: number;
|
|
154
|
+
try {
|
|
155
|
+
// `Response` is the streaming form of `Bun.write`: the body reaches the disk in chunks instead of
|
|
156
|
+
// being buffered whole in this process.
|
|
157
|
+
bytes = await Bun.write(staged, new Response(body));
|
|
158
|
+
} catch (cause) {
|
|
159
|
+
const stagingRetained = !(await discard(staged));
|
|
160
|
+
return { kind: 'failed', reason: describe(cause), stagingRetained };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (bytes !== declaredBytes) {
|
|
164
|
+
const stagingRetained = !(await discard(staged));
|
|
165
|
+
return { kind: 'incomplete', bytes, stagingRetained };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { kind: 'ready', key, stagedPath: staged, bytes };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Publish by never-replace hard link. The server serializes this operation with its SIEVE sweep. */
|
|
172
|
+
export async function publishEntry(store: CacheStore, upload: CompleteUpload): Promise<StoreOutcome> {
|
|
173
|
+
try {
|
|
174
|
+
await link(upload.stagedPath, entryPath(store, upload.key));
|
|
175
|
+
} catch (cause) {
|
|
176
|
+
const stagingRetained = !(await discard(upload.stagedPath));
|
|
177
|
+
if (errnoOf(cause) === 'EEXIST') {
|
|
178
|
+
return stagingRetained ? { kind: 'exists', stagingRetained } : ALREADY_STORED;
|
|
179
|
+
}
|
|
180
|
+
return { kind: 'failed', reason: describe(cause), stagingRetained };
|
|
181
|
+
}
|
|
182
|
+
const stagingRetained = !(await discard(upload.stagedPath));
|
|
183
|
+
return { kind: 'stored', bytes: upload.bytes, stagingRetained };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Drop a staged upload once it has been published or abandoned. A staging file that outlives its
|
|
188
|
+
* request is leaked disk, so a failure to remove one is reported rather than dropped; `ENOENT` is not
|
|
189
|
+
* a failure, it is the file already being gone.
|
|
190
|
+
*/
|
|
191
|
+
async function discard(staged: string): Promise<boolean> {
|
|
192
|
+
try {
|
|
193
|
+
await unlink(staged);
|
|
194
|
+
return true;
|
|
195
|
+
} catch (cause) {
|
|
196
|
+
if (errnoOf(cause) === 'ENOENT') return true;
|
|
197
|
+
logLine(`ev=staging.leaked path=${logValue(staged)} reason=${logValue(describe(cause))}`);
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Read an errno off a thrown value without asserting its type. */
|
|
203
|
+
export function errnoOf(cause: unknown): string | undefined {
|
|
204
|
+
if (typeof cause !== 'object' || cause === null || !('code' in cause)) return undefined;
|
|
205
|
+
const { code } = cause;
|
|
206
|
+
return typeof code === 'string' ? code : undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function describe(cause: unknown): string {
|
|
210
|
+
if (cause instanceof Error) {
|
|
211
|
+
const errno = errnoOf(cause);
|
|
212
|
+
return errno === undefined ? cause.message : `${errno}: ${cause.message}`;
|
|
213
|
+
}
|
|
214
|
+
return String(cause);
|
|
215
|
+
}
|