Close TD-21 atomic write hardening
This commit is contained in:
10
TECHDEBTS.md
10
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.<unique_integer>`) 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
|
||||
|
||||
@@ -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),
|
||||
|
||||
53
test/bds/persistence_test.exs
Normal file
53
test/bds/persistence_test.exs
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user