fix(FN-4122): use unique tmp filename for task.json atomic writes
Two TaskStore instances writing to the same task concurrently raced on a shared task.json.tmp filename: one writer's rename consumed the tmp file, the other ENOENTed. withTaskLock only serializes within a single store instance, so cross-process writers (engine + dashboard server) were unprotected. Each write now uses task.json.<pid>.<uuid>.tmp and cleans up its own tmp on rename failure. Adds a regression test that drives 40 concurrent same-task updates across 4 TaskStore instances. Fixes FN-4122, FN-4123, FN-4148.
This commit is contained in:
5
.changeset/fix-task-json-tmp-race.md
Normal file
5
.changeset/fix-task-json-tmp-race.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix `ENOENT: ... rename 'task.json.tmp' -> 'task.json'` failures when two TaskStore instances (e.g. engine + dashboard server) write to the same task concurrently. The shared `task.json.tmp` filename caused one writer's `rename` to consume the tmp file and the other's to ENOENT. Each write now uses a unique tmp filename (`task.json.<pid>.<uuid>.tmp`) and cleans up its own tmp on rename failure.
|
||||||
@@ -222,6 +222,29 @@ describe("TaskStore concurrent writes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("FN-4122/FN-4123/FN-4148: concurrent same-task writes across store instances don't ENOENT on task.json.tmp", async () => {
|
||||||
|
// Reproducer for the in-review failure mode where two TaskStore instances
|
||||||
|
// (e.g. engine + dashboard server) wrote to the same task simultaneously.
|
||||||
|
// Both writers used a shared `task.json.tmp` filename: one rename consumed
|
||||||
|
// the tmp, the other ENOENTed because it was no longer there. Fix uses a
|
||||||
|
// unique tmp filename per write.
|
||||||
|
const task = await primary.createTask({ description: "Cross-instance same-task race" });
|
||||||
|
|
||||||
|
const writes = Array.from({ length: 40 }, (_, index) =>
|
||||||
|
stores[index % stores.length].updateTask(task.id, {
|
||||||
|
title: `Race title ${index}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// None should reject with ENOENT on task.json.tmp.
|
||||||
|
const results = await Promise.allSettled(writes);
|
||||||
|
const rejections = results.filter((r): r is PromiseRejectedResult => r.status === "rejected");
|
||||||
|
expect(rejections.map((r) => (r.reason as Error).message)).toEqual([]);
|
||||||
|
|
||||||
|
const reloaded = await primary.getTask(task.id);
|
||||||
|
expect(reloaded.title).toMatch(/^Race title \d+$/);
|
||||||
|
});
|
||||||
|
|
||||||
it("moves different tasks concurrently without SQLITE_BUSY failures", async () => {
|
it("moves different tasks concurrently without SQLITE_BUSY failures", async () => {
|
||||||
const tasks: Task[] = await Promise.all(
|
const tasks: Task[] = await Promise.all(
|
||||||
Array.from({ length: 10 }, (_, index) => primary.createTask({ description: `Move task ${index}` })),
|
Array.from({ length: 10 }, (_, index) => primary.createTask({ description: `Move task ${index}` })),
|
||||||
|
|||||||
@@ -1834,11 +1834,25 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
|
|
||||||
private async writeTaskJsonFile(dir: string, task: Task): Promise<void> {
|
private async writeTaskJsonFile(dir: string, task: Task): Promise<void> {
|
||||||
const taskJsonPath = join(dir, "task.json");
|
const taskJsonPath = join(dir, "task.json");
|
||||||
const tmpPath = join(dir, "task.json.tmp");
|
// Use a unique tmp filename per write so concurrent writers to the same task
|
||||||
|
// don't race on a shared `task.json.tmp` (one rename consumes it, the other
|
||||||
|
// ENOENTs). See FN-4122/FN-4123/FN-4148 for the reproducer.
|
||||||
|
const tmpPath = join(dir, `task.json.${process.pid}.${randomUUID()}.tmp`);
|
||||||
this.suppressWatcher(taskJsonPath);
|
this.suppressWatcher(taskJsonPath);
|
||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
await writeFile(tmpPath, JSON.stringify(task));
|
await writeFile(tmpPath, JSON.stringify(task));
|
||||||
await rename(tmpPath, taskJsonPath);
|
try {
|
||||||
|
await rename(tmpPath, taskJsonPath);
|
||||||
|
} catch (err) {
|
||||||
|
// Best-effort cleanup of our tmp on rename failure so we don't leave
|
||||||
|
// orphaned `task.json.*.tmp` files behind.
|
||||||
|
try {
|
||||||
|
await unlink(tmpPath);
|
||||||
|
} catch {
|
||||||
|
// ignore — tmp may already be gone
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user