diff --git a/TECHDEBTS.md b/TECHDEBTS.md index 9a8c6e7..1f7a4b6 100644 --- a/TECHDEBTS.md +++ b/TECHDEBTS.md @@ -796,7 +796,15 @@ setting. **Acceptance.** Same pool model in dev and prod; rationale comment in config; no busy-timeout regressions in tests. -### TD-21: Harden `Persistence.atomic_write` +### TD-21: Harden `Persistence.atomic_write` ✅ DONE (2026-06-12) + +**Status: implemented.** `BDS.Persistence.atomic_write/2` now writes through a +per-call temp path (`.tmp.`) instead of the fixed `.tmp` +suffix, eliminating the concurrent-writer collision that previously produced +`:enoent` races. The new test coverage proves two things: many simultaneous +writers to the same target all complete successfully and the final file is one +intact payload, and the post-rebuild glob still ignores atomic temp files +because the temp suffix no longer matches the `*.md` pattern. **Context.** `atomic_write/2` uses a fixed `path <> ".tmp"` temp name — two concurrent writers to the same path corrupt each other's temp file before diff --git a/lib/bds/persistence.ex b/lib/bds/persistence.ex index 8259773..721ddcf 100644 --- a/lib/bds/persistence.ex +++ b/lib/bds/persistence.ex @@ -68,7 +68,7 @@ defmodule BDS.Persistence do def parse_timestamp(_value), do: nil def atomic_write(path, contents) when is_binary(path) and is_binary(contents) do - temp_path = path <> ".tmp" + temp_path = path <> ".tmp." <> Integer.to_string(System.unique_integer([:positive])) with :ok <- File.mkdir_p(Path.dirname(path)), :ok <- File.write(temp_path, contents), diff --git a/test/bds/persistence_test.exs b/test/bds/persistence_test.exs new file mode 100644 index 0000000..7d68285 --- /dev/null +++ b/test/bds/persistence_test.exs @@ -0,0 +1,53 @@ +defmodule BDS.PersistenceTest do + use ExUnit.Case, async: true + + alias BDS.Persistence + alias BDS.Posts.TranslationValidation + + test "atomic_write keeps concurrent writers intact" do + temp_dir = Path.join(System.tmp_dir!(), "bds-persistence-#{System.unique_integer([:positive])}") + path = Path.join(temp_dir, "shared.txt") + payload_a = String.duplicate("alpha", 200_000) + payload_b = String.duplicate("bravo", 200_000) + + on_exit(fn -> File.rm_rf(temp_dir) end) + + parent = self() + + tasks = + for index <- 1..12 do + payload = if rem(index, 2) == 0, do: payload_a, else: payload_b + + Task.async(fn -> + send(parent, :ready) + + receive do + :go -> Persistence.atomic_write(path, payload) + end + end) + end + + for _ <- tasks do + assert_receive :ready + end + + Enum.each(tasks, fn task -> send(task.pid, :go) end) + + assert Enum.map(tasks, &Task.await(&1, 15_000)) == List.duplicate(:ok, length(tasks)) + assert File.read!(path) in [payload_a, payload_b] + end + + test "post rebuild globs ignore atomic temp files" do + temp_dir = Path.join(System.tmp_dir!(), "bds-persistence-glob-#{System.unique_integer([:positive])}") + posts_dir = Path.join(temp_dir, "posts") + + File.mkdir_p!(posts_dir) + File.write!(Path.join(posts_dir, "entry.md"), "---\ntitle: Entry\n---\n") + File.write!(Path.join(posts_dir, "entry.md.tmp.123"), "temp") + + on_exit(fn -> File.rm_rf(temp_dir) end) + + assert TranslationValidation.list_matching_files(posts_dir, "*.md") == + [Path.join(posts_dir, "entry.md")] + end +end \ No newline at end of file