defmodule OpenAgents.Forge.WAL.Local do
@moduledoc """
Filesystem adapter for `OpenAgents.Forge.WAL` — the development and test backend.
Layout under the durable base directory
(`Application.get_env(:openagents, :forge_wal_dir)`).
<base>/<repo>/index.json
<base>/<repo>/entries/<key>
The generation is an integer stored inside `index.json` as `"generation"`;
`read_index/1` strips it from the returned index and hands it back as the
opaque generation token. CAS is made atomic by serializing every
`cas_index/3` for a repo through `:global.trans/2` on a per-repo lock and
writing the index via a temp file plus `File.rename/2`, so readers never
observe a partial document and concurrent writers never both win.
"""
@behaviour OpenAgents.Forge.WAL
alias OpenAgents.Forge.WAL
@impl WAL
def read_index(repo) do
case File.read(index_path(repo)) do
{:ok, raw} -> decode_index(raw)
{:error, :enoent} -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
@impl WAL
def cas_index(repo, expected, index) when is_map(index) do
:global.trans({{:forge_wal, repo}, self()}, fn ->
do_cas(repo, expected, index)
end)
end
@impl WAL
def put_entry(repo, seq, payload) when is_integer(seq) and seq >= 0 and is_binary(payload) do
key = WAL.entry_key(seq, payload)
path = Path.join(repo_dir(repo), key)
with :ok <- File.mkdir_p(Path.dirname(path)),
:ok <- File.write(path, payload) do
{:ok, key}
end
end
@impl WAL
def put_entry_file(repo, seq, source_path) when is_integer(seq) and seq >= 0 do
with {:ok, key} <- WAL.entry_key_file(seq, source_path) do
destination = Path.join(repo_dir(repo), key)
copy_file_atomically(source_path, destination, key)
end
end
@impl WAL
def put_object(repo, object_key, payload) when is_binary(payload) do
path = Path.join(repo_dir(repo), object_key)
with :ok <- File.mkdir_p(Path.dirname(path)),
:ok <- File.write(path, payload) do
{:ok, object_key}
end
end
@impl WAL
def get_entry(repo, object_key) do
case File.read(Path.join(repo_dir(repo), object_key)) do
{:ok, payload} -> {:ok, payload}
{:error, :enoent} -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
@impl WAL
def get_entry_file(repo, object_key, destination_path) do
source_path = Path.join(repo_dir(repo), object_key)
case File.stat(source_path) do
{:ok, %File.Stat{type: :regular}} ->
case copy_file_atomically(source_path, destination_path, destination_path) do
{:ok, _destination} -> :ok
{:error, reason} -> {:error, reason}
end
{:error, :enoent} ->
{:error, :not_found}
{:ok, _not_regular} ->
{:error, :not_found}
{:error, reason} ->
{:error, reason}
end
end
@impl WAL
def delete_repo(repo) do
case File.rm_rf(repo_dir(repo)) do
{:ok, _removed} -> :ok
{:error, reason, _path} -> {:error, reason}
end
end
## Internal
defp do_cas(repo, :none, index) do
if File.exists?(index_path(repo)) do
{:error, :cas_conflict}
else
write_index(repo, 1, index)
end
end
defp do_cas(repo, expected, index) when is_integer(expected) do
case read_index(repo) do
{:ok, ^expected, _current} -> write_index(repo, expected + 1, index)
{:ok, _other, _current} -> {:error, :cas_conflict}
{:error, :not_found} -> {:error, :cas_conflict}
{:error, reason} -> {:error, reason}
end
end
defp do_cas(_repo, expected, _index), do: {:error, {:invalid_generation, expected}}
defp write_index(repo, generation, index) do
path = index_path(repo)
temp = path <> ".tmp." <> Integer.to_string(System.unique_integer([:positive]))
with {:ok, encoded} <- Jason.encode(Map.put(index, "generation", generation)),
:ok <- File.mkdir_p(Path.dirname(path)),
:ok <- File.write(temp, encoded),
:ok <- File.rename(temp, path) do
{:ok, generation}
end
end
defp copy_file_atomically(source, destination, result) do
temporary = destination <> ".tmp." <> Integer.to_string(System.unique_integer([:positive]))
with :ok <- File.mkdir_p(Path.dirname(destination)),
{:ok, _bytes} <- File.copy(source, temporary),
:ok <- File.rename(temporary, destination) do
{:ok, result}
else
{:error, reason} ->
File.rm(temporary)
{:error, reason}
end
end
defp decode_index(raw) do
case Jason.decode(raw) do
{:ok, %{"generation" => generation} = index} when is_integer(generation) ->
{:ok, generation, Map.delete(index, "generation")}
{:ok, other} ->
{:error, {:invalid_index, other}}
{:error, reason} ->
{:error, {:invalid_index, reason}}
end
end
defp index_path(repo), do: Path.join(repo_dir(repo), "index.json")
defp repo_dir(repo), do: Path.join(base_dir(), repo)
defp base_dir do
Application.get_env(:openagents, :forge_wal_dir) ||
"/var/lib/openagents/forge-wal"
end
end