Skip to content

Commit 9da1fdc

Browse files
committed
perf(test): coordinate source require cache misses
Signed-off-by: Ho Lim <subhoya@gmail.com>
1 parent 5ddf9a1 commit 9da1fdc

2 files changed

Lines changed: 439 additions & 31 deletions

File tree

test/helpers/register-source-require.ts

Lines changed: 238 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import crypto from "node:crypto";
55
import fs from "node:fs";
66
import Module from "node:module";
77
import path from "node:path";
8+
import { performance } from "node:perf_hooks";
89
import ts from "typescript";
910

1011
type CommonJsModule = NodeModule & {
@@ -62,6 +63,239 @@ const compilerOptions: ts.CompilerOptions = {
6263
const cacheDir = path.join(repoRoot, "node_modules", ".cache", "nemoclaw-source-require");
6364
const compilerFingerprint = JSON.stringify({ compilerOptions, typescript: ts.version });
6465
fs.mkdirSync(cacheDir, { recursive: true });
66+
const cacheWaitMs = envInt("NEMOCLAW_SOURCE_REQUIRE_CACHE_WAIT_MS", 5_000);
67+
const cachePollMs = Math.max(1, envInt("NEMOCLAW_SOURCE_REQUIRE_CACHE_POLL_MS", 25));
68+
const cacheLockStaleMs = envInt("NEMOCLAW_SOURCE_REQUIRE_CACHE_LOCK_STALE_MS", 30_000);
69+
const statsPath = process.env.NEMOCLAW_SOURCE_REQUIRE_STATS;
70+
71+
const stats = {
72+
cacheHits: 0,
73+
cacheMisses: 0,
74+
compileMs: 0,
75+
duplicateFallbacks: 0,
76+
files: 0,
77+
lockWaits: 0,
78+
readCacheMs: 0,
79+
staleLocks: 0,
80+
transforms: 0,
81+
transformMs: 0,
82+
waitMs: 0,
83+
};
84+
85+
function envInt(name: string, fallback: number): number {
86+
const raw = process.env[name];
87+
if (raw === undefined) return fallback;
88+
const parsed = Number.parseInt(raw, 10);
89+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
90+
}
91+
92+
function nowMs(): number {
93+
return performance.now();
94+
}
95+
96+
const sleepBuffer = new SharedArrayBuffer(4);
97+
const sleepArray = new Int32Array(sleepBuffer);
98+
99+
function sleepSync(ms: number): void {
100+
if (ms <= 0) return;
101+
Atomics.wait(sleepArray, 0, 0, ms);
102+
}
103+
104+
function readCachedOutput(cachePath: string): string | null {
105+
const start = nowMs();
106+
try {
107+
const output = fs.readFileSync(cachePath, "utf8");
108+
stats.readCacheMs += nowMs() - start;
109+
return output;
110+
} catch (error) {
111+
stats.readCacheMs += nowMs() - start;
112+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
113+
return null;
114+
}
115+
}
116+
117+
function writeAtomic(cachePath: string, outputText: string): void {
118+
const temporaryPath = `${cachePath}.${process.pid}.${crypto.randomUUID()}`;
119+
fs.writeFileSync(temporaryPath, outputText, { flag: "wx", mode: 0o600 });
120+
try {
121+
fs.renameSync(temporaryPath, cachePath);
122+
} catch (error) {
123+
fs.rmSync(temporaryPath, { force: true });
124+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
125+
}
126+
}
127+
128+
function parseLockOwner(contents: string): { pid?: number } {
129+
try {
130+
const parsed = JSON.parse(contents);
131+
return typeof parsed?.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
132+
? { pid: parsed.pid }
133+
: {};
134+
} catch {
135+
return {};
136+
}
137+
}
138+
139+
function processIsRunning(pid: number): boolean {
140+
try {
141+
process.kill(pid, 0);
142+
return true;
143+
} catch (error) {
144+
return (error as NodeJS.ErrnoException).code !== "ESRCH";
145+
}
146+
}
147+
148+
function tryAcquireLock(lockPath: string, filename: string): boolean {
149+
try {
150+
fs.writeFileSync(
151+
lockPath,
152+
`${JSON.stringify({
153+
filename,
154+
pid: process.pid,
155+
startedAt: new Date().toISOString(),
156+
startedAtMs: Date.now(),
157+
})}\n`,
158+
{ flag: "wx", mode: 0o600 },
159+
);
160+
return true;
161+
} catch (error) {
162+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
163+
return false;
164+
}
165+
}
166+
167+
function reclaimStaleLock(lockPath: string): boolean {
168+
if (cacheLockStaleMs <= 0) return false;
169+
let snapshot: { contents: string; dev: number; ino: number; mtimeMs: number; size: number };
170+
try {
171+
const stat = fs.statSync(lockPath);
172+
snapshot = {
173+
contents: fs.readFileSync(lockPath, "utf8"),
174+
dev: stat.dev,
175+
ino: stat.ino,
176+
mtimeMs: stat.mtimeMs,
177+
size: stat.size,
178+
};
179+
} catch (error) {
180+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
181+
throw error;
182+
}
183+
const ageMs = Date.now() - snapshot.mtimeMs;
184+
if (ageMs < cacheLockStaleMs) return false;
185+
const { pid } = parseLockOwner(snapshot.contents);
186+
if (pid !== undefined && processIsRunning(pid)) return false;
187+
try {
188+
const current = fs.statSync(lockPath);
189+
if (
190+
current.dev !== snapshot.dev ||
191+
current.ino !== snapshot.ino ||
192+
current.mtimeMs !== snapshot.mtimeMs ||
193+
current.size !== snapshot.size ||
194+
fs.readFileSync(lockPath, "utf8") !== snapshot.contents
195+
) {
196+
return false;
197+
}
198+
fs.rmSync(lockPath, { force: true });
199+
stats.staleLocks += 1;
200+
return true;
201+
} catch (error) {
202+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
203+
throw error;
204+
}
205+
}
206+
207+
function transpileSource(source: string, filename: string): string {
208+
const start = nowMs();
209+
const result = ts.transpileModule(source, {
210+
compilerOptions,
211+
fileName: filename,
212+
reportDiagnostics: true,
213+
});
214+
stats.transformMs += nowMs() - start;
215+
stats.transforms += 1;
216+
const errors = result.diagnostics?.filter(
217+
(diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
218+
);
219+
if (errors && errors.length > 0) {
220+
throw new Error(
221+
errors
222+
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"))
223+
.join("\n"),
224+
);
225+
}
226+
return result.outputText;
227+
}
228+
229+
function waitForCache(cachePath: string): string | null {
230+
if (cacheWaitMs <= 0) return null;
231+
const start = nowMs();
232+
const deadline = start + cacheWaitMs;
233+
stats.lockWaits += 1;
234+
235+
while (nowMs() < deadline) {
236+
const output = readCachedOutput(cachePath);
237+
if (output !== null) {
238+
stats.waitMs += nowMs() - start;
239+
return output;
240+
}
241+
sleepSync(Math.min(cachePollMs, Math.max(0, deadline - nowMs())));
242+
}
243+
stats.waitMs += nowMs() - start;
244+
return null;
245+
}
246+
247+
function compileWithCache(filename: string, source: string, cachePath: string): string {
248+
const cached = readCachedOutput(cachePath);
249+
if (cached !== null) {
250+
stats.cacheHits += 1;
251+
return cached;
252+
}
253+
stats.cacheMisses += 1;
254+
255+
const lockPath = `${cachePath}.lock`;
256+
let ownsLock = tryAcquireLock(lockPath, filename);
257+
if (!ownsLock && reclaimStaleLock(lockPath)) {
258+
const cachedAfterReclaim = readCachedOutput(cachePath);
259+
if (cachedAfterReclaim !== null) {
260+
stats.cacheHits += 1;
261+
return cachedAfterReclaim;
262+
}
263+
ownsLock = tryAcquireLock(lockPath, filename);
264+
}
265+
266+
if (!ownsLock) {
267+
const waited = waitForCache(cachePath);
268+
if (waited !== null) {
269+
stats.cacheHits += 1;
270+
return waited;
271+
}
272+
stats.duplicateFallbacks += 1;
273+
return transpileSource(source, filename);
274+
}
275+
276+
try {
277+
const outputText = transpileSource(source, filename);
278+
writeAtomic(cachePath, outputText);
279+
return outputText;
280+
} finally {
281+
fs.rmSync(lockPath, { force: true });
282+
}
283+
}
284+
285+
if (statsPath) {
286+
process.once("exit", () => {
287+
if (stats.files === 0) return;
288+
const row = {
289+
...stats,
290+
cacheDir,
291+
label: process.env.NEMOCLAW_SOURCE_REQUIRE_STATS_LABEL ?? null,
292+
pid: process.pid,
293+
rssMb: Math.round((process.memoryUsage().rss / 1024 / 1024) * 10) / 10,
294+
};
295+
fs.mkdirSync(path.dirname(statsPath), { recursive: true });
296+
fs.appendFileSync(statsPath, `${JSON.stringify(row)}\n`, { mode: 0o600 });
297+
});
298+
}
65299

66300
const resolveFilename = moduleRuntime._resolveFilename;
67301
moduleRuntime._resolveFilename = function resolveSourceFilename(request, parent, isMain, options) {
@@ -82,6 +316,8 @@ moduleRuntime._resolveFilename = function resolveSourceFilename(request, parent,
82316
};
83317

84318
moduleRuntime._extensions[".ts"] = (module, filename) => {
319+
const compileStart = nowMs();
320+
stats.files += 1;
85321
const source = fs.readFileSync(filename, "utf8");
86322
const cacheKey = crypto
87323
.createHash("sha256")
@@ -92,36 +328,7 @@ moduleRuntime._extensions[".ts"] = (module, filename) => {
92328
.update(compilerFingerprint)
93329
.digest("hex");
94330
const cachePath = path.join(cacheDir, `${cacheKey}.cjs`);
95-
let outputText: string;
96-
97-
try {
98-
outputText = fs.readFileSync(cachePath, "utf8");
99-
} catch (error) {
100-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
101-
const result = ts.transpileModule(source, {
102-
compilerOptions,
103-
fileName: filename,
104-
reportDiagnostics: true,
105-
});
106-
const errors = result.diagnostics?.filter(
107-
(diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
108-
);
109-
if (errors && errors.length > 0) {
110-
throw new Error(
111-
errors
112-
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"))
113-
.join("\n"),
114-
);
115-
}
116-
outputText = result.outputText;
117-
const temporaryPath = `${cachePath}.${process.pid}.${crypto.randomUUID()}`;
118-
fs.writeFileSync(temporaryPath, outputText, { flag: "wx", mode: 0o600 });
119-
try {
120-
fs.renameSync(temporaryPath, cachePath);
121-
} catch (error) {
122-
fs.rmSync(temporaryPath, { force: true });
123-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
124-
}
125-
}
331+
const outputText = compileWithCache(filename, source, cachePath);
332+
stats.compileMs += nowMs() - compileStart;
126333
module._compile(outputText, filename);
127334
};

0 commit comments

Comments
 (0)