Skip to content

Commit 47cd266

Browse files
dahliaclaude
andcommitted
Prevent concurrent codegen race conditions
Add file locking and timestamp checking to vocab codegen to prevent race conditions when multiple processes run codegen simultaneously. Changes: - Add directory-based lock mechanism using atomic mkdir - Add timestamp comparison to skip regeneration when vocab.ts is up to date - Integrate deno fmt, cache, and check into the codegen script itself, making the entire process atomic - Refactor to use @david/dax for cleaner command execution and path handling - Add .vocab-codegen.lock/ to .gitignore The lock automatically expires after 5 minutes to handle stale locks from crashed processes. Co-Authored-By: Claude <[email protected]>
1 parent d9739e0 commit 47cd266

File tree

3 files changed

+149
-12
lines changed

3 files changed

+149
-12
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
.claude/settings.local.json
22
.DS_Store
33
.pnpm-store/
4+
.vocab-codegen.lock/
45
dist/
56
node_modules/
67
package-lock.json

packages/vocab/deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
],
2626
"tasks": {
2727
"check": "deno fmt --check && deno lint && deno check src/*.ts",
28-
"compile": "deno run --allow-read --allow-write --allow-env --check scripts/codegen.ts && deno fmt src/vocab.ts && deno cache src/vocab.ts && deno check src/vocab.ts",
28+
"compile": "deno run --allow-read --allow-write --allow-env --allow-run scripts/codegen.ts",
2929
"test": "deno test --allow-read --allow-write --allow-env --unstable-kv --trace-leaks --parallel"
3030
}
3131
}

packages/vocab/scripts/codegen.ts

Lines changed: 147 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,154 @@
1+
import $ from "@david/dax";
2+
import type { Path } from "@david/dax";
13
import { generateVocab } from "@fedify/vocab-tools";
2-
import { rename } from "node:fs/promises";
3-
import { dirname, join } from "node:path";
44

5-
async function codegen() {
6-
const scriptsDir = import.meta.dirname;
7-
if (!scriptsDir) {
8-
throw new Error("Could not determine schema directory");
5+
const LOCK_STALE_MS = 5 * 60 * 1000; // 5 minutes
6+
const LOCK_RETRY_MS = 100;
7+
const LOCK_TIMEOUT_MS = 60 * 1000; // 1 minute
8+
9+
/**
10+
* Get the latest mtime from all YAML files in the schema directory.
11+
*/
12+
async function getLatestSourceMtime(schemaDir: Path): Promise<number> {
13+
let latestMtime = 0;
14+
for await (const entry of schemaDir.readDir()) {
15+
if (!entry.isFile) continue;
16+
if (!entry.name.match(/\.ya?ml$/i)) continue;
17+
if (entry.name === "schema.yaml") continue;
18+
const fileStat = await schemaDir.join(entry.name).stat();
19+
if (fileStat?.mtime && fileStat.mtime.getTime() > latestMtime) {
20+
latestMtime = fileStat.mtime.getTime();
21+
}
22+
}
23+
return latestMtime;
24+
}
25+
26+
/**
27+
* Check if the generated file is up to date compared to source files.
28+
*/
29+
async function isUpToDate(
30+
schemaDir: Path,
31+
generatedPath: Path,
32+
): Promise<boolean> {
33+
try {
34+
const [sourceMtime, generatedStat] = await Promise.all([
35+
getLatestSourceMtime(schemaDir),
36+
generatedPath.stat(),
37+
]);
38+
if (!generatedStat?.mtime) return false;
39+
return generatedStat.mtime.getTime() >= sourceMtime;
40+
} catch {
41+
// If generated file doesn't exist, it's not up to date
42+
return false;
43+
}
44+
}
45+
46+
interface Lock {
47+
release(): Promise<void>;
48+
}
49+
50+
/**
51+
* Acquire a directory-based lock. mkdir is atomic on POSIX systems.
52+
*/
53+
async function acquireLock(lockPath: Path): Promise<Lock> {
54+
const startTime = Date.now();
55+
56+
while (true) {
57+
try {
58+
// Use Deno.mkdir directly because dax's mkdir() is recursive by default
59+
await Deno.mkdir(lockPath.toString());
60+
// Write PID and timestamp for stale lock detection
61+
const infoPath = lockPath.join("info");
62+
await infoPath.writeJsonPretty({ pid: Deno.pid, timestamp: Date.now() });
63+
return {
64+
async release() {
65+
try {
66+
await lockPath.remove({ recursive: true });
67+
} catch {
68+
// Ignore errors during cleanup
69+
}
70+
},
71+
};
72+
} catch (e) {
73+
if (!(e instanceof Deno.errors.AlreadyExists)) {
74+
throw e;
75+
}
76+
77+
// Check if lock is stale
78+
try {
79+
const infoPath = lockPath.join("info");
80+
const infoStat = await infoPath.stat();
81+
if (
82+
infoStat?.mtime &&
83+
Date.now() - infoStat.mtime.getTime() > LOCK_STALE_MS
84+
) {
85+
console.warn("Removing stale lock:", lockPath.toString());
86+
await lockPath.remove({ recursive: true });
87+
continue;
88+
}
89+
} catch {
90+
// If we can't read the info file, try to remove the lock
91+
try {
92+
await lockPath.remove({ recursive: true });
93+
continue;
94+
} catch {
95+
// Ignore
96+
}
97+
}
98+
99+
// Check timeout
100+
if (Date.now() - startTime > LOCK_TIMEOUT_MS) {
101+
throw new Error(`Timeout waiting for lock: ${lockPath}`);
102+
}
103+
104+
// Wait and retry
105+
await $.sleep(LOCK_RETRY_MS);
106+
}
9107
}
10-
const schemaDir = join(dirname(scriptsDir), "src");
11-
const generatedPath = join(schemaDir, `vocab-${crypto.randomUUID()}.ts`);
12-
const realPath = join(schemaDir, "vocab.ts");
108+
}
109+
110+
async function codegen() {
111+
const scriptsDir = $.path(import.meta.dirname!);
112+
const packageDir = scriptsDir.parent()!;
113+
const schemaDir = packageDir.join("src");
114+
const realPath = schemaDir.join("vocab.ts");
115+
const lockPath = packageDir.join(".vocab-codegen.lock");
116+
117+
// Acquire lock to prevent concurrent codegen
118+
const lock = await acquireLock(lockPath);
119+
try {
120+
// Check if regeneration is needed (after acquiring lock)
121+
if (await isUpToDate(schemaDir, realPath)) {
122+
$.log("vocab.ts is up to date, skipping codegen");
123+
return;
124+
}
13125

14-
await generateVocab(schemaDir, generatedPath);
15-
await rename(generatedPath, realPath);
126+
$.logStep("Generating", "vocab.ts...");
127+
128+
// Generate to a temporary file first
129+
const generatedPath = schemaDir.join(`vocab-${crypto.randomUUID()}.ts`);
130+
try {
131+
await generateVocab(schemaDir.toString(), generatedPath.toString());
132+
await generatedPath.rename(realPath);
133+
} catch (e) {
134+
// Clean up temp file on error
135+
await generatedPath.remove().catch(() => {});
136+
throw e;
137+
}
138+
139+
$.logStep("Formatting", "vocab.ts...");
140+
await $`deno fmt ${realPath}`;
141+
142+
$.logStep("Caching", "vocab.ts...");
143+
await $`deno cache ${realPath}`;
144+
145+
$.logStep("Type checking", "vocab.ts...");
146+
await $`deno check ${realPath}`;
147+
148+
$.logStep("Codegen", "completed successfully");
149+
} finally {
150+
await lock.release();
151+
}
16152
}
17153

18154
if (import.meta.main) {

0 commit comments

Comments
 (0)