feat(HAI-020): complete Step 1 — atomic writes for config.json with config lock
This commit is contained in:
@@ -137,6 +137,36 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Atomic config writes ──────────────────────────────────────────
|
||||
|
||||
describe("atomic config writes", () => {
|
||||
it("produces valid config.json with unique sequential IDs after 5 parallel createTask calls", async () => {
|
||||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||
store.createTask({ description: `Concurrent task ${i}` }),
|
||||
);
|
||||
const tasks = await Promise.all(promises);
|
||||
|
||||
// All IDs should be unique
|
||||
const ids = tasks.map((t) => t.id);
|
||||
expect(new Set(ids).size).toBe(5);
|
||||
|
||||
// IDs should be sequential (HAI-001 through HAI-005)
|
||||
const sortedIds = [...ids].sort();
|
||||
expect(sortedIds).toEqual(["HAI-001", "HAI-002", "HAI-003", "HAI-004", "HAI-005"]);
|
||||
|
||||
// config.json should be valid JSON with nextId = 6
|
||||
const configPath = join(rootDir, ".hai", "config.json");
|
||||
const raw = await readFile(configPath, "utf-8");
|
||||
const config = JSON.parse(raw);
|
||||
expect(config.nextId).toBe(6);
|
||||
|
||||
// No .tmp files left behind
|
||||
const haiDir = join(rootDir, ".hai");
|
||||
const files = await readdir(haiDir);
|
||||
expect(files.filter((f) => f.endsWith(".tmp"))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Concurrent stress test ───────────────────────────────────────
|
||||
|
||||
describe("concurrent stress", () => {
|
||||
|
||||
@@ -31,6 +31,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
private debounceMs = 150;
|
||||
/** Per-task promise chain for serializing writes */
|
||||
private taskLocks: Map<string, Promise<void>> = new Map();
|
||||
/** Promise chain for serializing config.json read-modify-write cycles */
|
||||
private configLock: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(private rootDir: string) {
|
||||
super();
|
||||
@@ -46,6 +48,26 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize all mutations to config.json by chaining promises.
|
||||
* Concurrent callers will queue behind each other, preventing
|
||||
* lost-update races on the nextId counter.
|
||||
*/
|
||||
private withConfigLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
let resolve: () => void;
|
||||
const next = new Promise<void>((r) => { resolve = r; });
|
||||
const prev = this.configLock;
|
||||
this.configLock = next;
|
||||
|
||||
return prev.then(async () => {
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
resolve!();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize all mutations to a given task's task.json by chaining promises
|
||||
* per task ID. Concurrent callers for the same ID will queue behind each other.
|
||||
@@ -120,12 +142,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
|
||||
async updateSettings(patch: Partial<Settings>): Promise<Settings> {
|
||||
const config = await this.readConfig();
|
||||
const current = { ...DEFAULT_SETTINGS, ...config.settings };
|
||||
const updated = { ...current, ...patch };
|
||||
config.settings = updated;
|
||||
await this.writeConfig(config);
|
||||
return updated;
|
||||
return this.withConfigLock(async () => {
|
||||
const config = await this.readConfig();
|
||||
const current = { ...DEFAULT_SETTINGS, ...config.settings };
|
||||
const updated = { ...current, ...patch };
|
||||
config.settings = updated;
|
||||
await this.writeConfig(config);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
private async readConfig(): Promise<BoardConfig> {
|
||||
@@ -133,16 +157,29 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically write config.json by writing to a temp file first, then
|
||||
* renaming into place. The rename is atomic on POSIX filesystems,
|
||||
* preventing partial writes from corrupting the file.
|
||||
*/
|
||||
private async atomicWriteConfig(config: BoardConfig): Promise<void> {
|
||||
const tmpPath = this.configPath + ".tmp";
|
||||
await writeFile(tmpPath, JSON.stringify(config, null, 2));
|
||||
await rename(tmpPath, this.configPath);
|
||||
}
|
||||
|
||||
private async writeConfig(config: BoardConfig): Promise<void> {
|
||||
await writeFile(this.configPath, JSON.stringify(config, null, 2));
|
||||
await this.atomicWriteConfig(config);
|
||||
}
|
||||
|
||||
private async allocateId(): Promise<string> {
|
||||
const config = await this.readConfig();
|
||||
const id = `HAI-${String(config.nextId).padStart(3, "0")}`;
|
||||
config.nextId++;
|
||||
await this.writeConfig(config);
|
||||
return id;
|
||||
return this.withConfigLock(async () => {
|
||||
const config = await this.readConfig();
|
||||
const id = `HAI-${String(config.nextId).padStart(3, "0")}`;
|
||||
config.nextId++;
|
||||
await this.writeConfig(config);
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
private taskDir(id: string): string {
|
||||
|
||||
Reference in New Issue
Block a user