Skip to content

Commit d7e7931

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

2 files changed

Lines changed: 413 additions & 31 deletions

File tree

test/helpers/register-source-require.ts

Lines changed: 220 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,221 @@ 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 = 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(lockPath: string): { pid?: number } {
129+
try {
130+
const parsed = JSON.parse(fs.readFileSync(lockPath, "utf8"));
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 ageMs: number;
170+
try {
171+
ageMs = Date.now() - fs.statSync(lockPath).mtimeMs;
172+
} catch (error) {
173+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
174+
throw error;
175+
}
176+
if (ageMs < cacheLockStaleMs) return false;
177+
const { pid } = parseLockOwner(lockPath);
178+
if (pid !== undefined && processIsRunning(pid)) return false;
179+
try {
180+
fs.rmSync(lockPath, { force: true });
181+
stats.staleLocks += 1;
182+
return true;
183+
} catch (error) {
184+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
185+
throw error;
186+
}
187+
}
188+
189+
function transpileSource(source: string, filename: string): string {
190+
const start = nowMs();
191+
const result = ts.transpileModule(source, {
192+
compilerOptions,
193+
fileName: filename,
194+
reportDiagnostics: true,
195+
});
196+
stats.transformMs += nowMs() - start;
197+
stats.transforms += 1;
198+
const errors = result.diagnostics?.filter(
199+
(diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
200+
);
201+
if (errors && errors.length > 0) {
202+
throw new Error(
203+
errors
204+
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"))
205+
.join("\n"),
206+
);
207+
}
208+
return result.outputText;
209+
}
210+
211+
function waitForCache(cachePath: string): string | null {
212+
if (cacheWaitMs <= 0) return null;
213+
const start = nowMs();
214+
const deadline = start + cacheWaitMs;
215+
stats.lockWaits += 1;
216+
217+
while (nowMs() < deadline) {
218+
const output = readCachedOutput(cachePath);
219+
if (output !== null) {
220+
stats.waitMs += nowMs() - start;
221+
return output;
222+
}
223+
sleepSync(Math.min(cachePollMs, Math.max(0, deadline - nowMs())));
224+
}
225+
stats.waitMs += nowMs() - start;
226+
return null;
227+
}
228+
229+
function compileWithCache(filename: string, source: string, cachePath: string): string {
230+
const cached = readCachedOutput(cachePath);
231+
if (cached !== null) {
232+
stats.cacheHits += 1;
233+
return cached;
234+
}
235+
stats.cacheMisses += 1;
236+
237+
const lockPath = `${cachePath}.lock`;
238+
let ownsLock = tryAcquireLock(lockPath, filename);
239+
if (!ownsLock && reclaimStaleLock(lockPath)) {
240+
const cachedAfterReclaim = readCachedOutput(cachePath);
241+
if (cachedAfterReclaim !== null) {
242+
stats.cacheHits += 1;
243+
return cachedAfterReclaim;
244+
}
245+
ownsLock = tryAcquireLock(lockPath, filename);
246+
}
247+
248+
if (!ownsLock) {
249+
const waited = waitForCache(cachePath);
250+
if (waited !== null) {
251+
stats.cacheHits += 1;
252+
return waited;
253+
}
254+
stats.duplicateFallbacks += 1;
255+
return transpileSource(source, filename);
256+
}
257+
258+
try {
259+
const outputText = transpileSource(source, filename);
260+
writeAtomic(cachePath, outputText);
261+
return outputText;
262+
} finally {
263+
fs.rmSync(lockPath, { force: true });
264+
}
265+
}
266+
267+
if (statsPath) {
268+
process.once("exit", () => {
269+
if (stats.files === 0) return;
270+
const row = {
271+
...stats,
272+
cacheDir,
273+
label: process.env.NEMOCLAW_SOURCE_REQUIRE_STATS_LABEL ?? null,
274+
pid: process.pid,
275+
rssMb: Math.round((process.memoryUsage().rss / 1024 / 1024) * 10) / 10,
276+
};
277+
fs.mkdirSync(path.dirname(statsPath), { recursive: true });
278+
fs.appendFileSync(statsPath, `${JSON.stringify(row)}\n`, { mode: 0o600 });
279+
});
280+
}
65281

66282
const resolveFilename = moduleRuntime._resolveFilename;
67283
moduleRuntime._resolveFilename = function resolveSourceFilename(request, parent, isMain, options) {
@@ -82,6 +298,8 @@ moduleRuntime._resolveFilename = function resolveSourceFilename(request, parent,
82298
};
83299

84300
moduleRuntime._extensions[".ts"] = (module, filename) => {
301+
const compileStart = nowMs();
302+
stats.files += 1;
85303
const source = fs.readFileSync(filename, "utf8");
86304
const cacheKey = crypto
87305
.createHash("sha256")
@@ -92,36 +310,7 @@ moduleRuntime._extensions[".ts"] = (module, filename) => {
92310
.update(compilerFingerprint)
93311
.digest("hex");
94312
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-
}
313+
const outputText = compileWithCache(filename, source, cachePath);
314+
stats.compileMs += nowMs() - compileStart;
126315
module._compile(outputText, filename);
127316
};

0 commit comments

Comments
 (0)