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:
gsxdsm
2026-05-12 09:41:43 -07:00
parent 6709703411
commit 67c680c8d5
3 changed files with 44 additions and 2 deletions

View File

@@ -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 () => {
const tasks: Task[] = await Promise.all(
Array.from({ length: 10 }, (_, index) => primary.createTask({ description: `Move task ${index}` })),