Fault-injection harness for ExUnit suites that exercise the journal.
Instruments the REAL Raxol.Agent.Journal.FileStore (no mock journal — the
code under test is the production storage path) with five named fault sites:
:append_fail— the Writer's:file.writefails (:enospc-class): a dead fd is swapped into the live Writer so the next append returns{:error, _}from the production error arm, offset not advanced, Writer alive.:open_fail—FileStore.open/2fails: the session dir path is occupied by a regular file, soFile.mkdir_p!in the Writer'sinit/1raises andstart_linkreturns{:error, _}.:kill_after_write_before_head— bytes reached the OS but the crash beat the HEAD rewrite: complete framed records are appended raw to the newest segment (exactly what a flushed:file.writeleaves behind), then the Writer is killed, leaving HEAD stale behind the journal.:kill_after_head_before_publish— journal + HEAD carry a record the live tail never saw: the record is appended through a shared (joiner) FileStore handle, bypassing the publish step.:writer_down— the Writer process dies under a live handle; the nextappend/2through that handle returns{:error, {:writer_down, _}}.
Meta-invariant support
- m1 dead-injector detection — every site keeps a per-harness fire
counter;
assert_all_fired!/2fails the test if an armed site never fired. A dead injector = green lies. - m2 seed-reproducible schedules — fault schedules are StreamData
generated, so a failing run prints the shrunk schedule and the ExUnit
seed reproduces it exactly.
assert_all_fired!/2takes the schedule so dead-injector failures also dump it. - m6 oracle independence — journal truth comes from
raw_scan/1/raw_records!/1: a rawFile.read!plus this module's OWN framed-line decoder. It never consults the Writer's in-memory offset orFileStore.read/2. Live truth is a subscriber process (the test process), never a publisher return value.
Summary
Types
One raw-scan entry
Functions
Arm a fault site: it MUST fire before assert_all_fired!/2 or the test fails.
Meta-invariant m1: fail if any armed site never fired (dead injector).
Per-site fire counts.
Restore the Writer's real fd after inject_append_fail/3.
Remove the blocking file so the next open succeeds.
Site :append_fail: swap a dead (closed) fd into the live Writer so its next
:file.write returns {:error, _} — the :enospc branch. Returns the
original io term; pass it to heal_append_fail/2 to restore the Writer.
Site :kill_after_head_before_publish: append record through a joiner
FileStore handle sharing the session's single Writer — journal + HEAD get the
record, the live tail never does (the "crash after append, before publish"
window made permanent). Returns the assigned offset.
Site :open_fail: occupy the session dir path with a regular file so
FileStore.open/2 (Writer init/1 -> File.mkdir_p!) fails. Only valid for
a session with no journal on disk yet.
Site :kill_after_write_before_head (data half): append fully-framed,
newline-terminated records raw to the newest segment — byte-for-byte what a
flushed :file.write leaves when the crash beats the HEAD rewrite. Records
must already carry "id" (continuing the sequence) and "schema_version".
A REAL brutal kill (meta-invariant m7: no timer cheats for durability).
Process.exit(writer, :kill) — terminate/2 never runs. Waits for the death
and then for :global to release the Writer's name (the async cleanup a
graceful stop does synchronously), so a reopen is deterministic.
Start a fresh harness (an Agent holding armed-site set + fire counters).
Raw HEAD read: {:ok, map}, {:error, reason} (unparseable), or :missing.
Ids of a healthy journal, in file order.
{record, raw_line} pairs of a healthy journal (for byte-identity checks).
Decoded records from a journal expected to be fully healthy — raises on corrupt/torn entries.
Independent decode of every segment. An unterminated trailing chunk of a
non-last segment is classified :corrupt (bytes can only legally be torn at
the very end of the journal).
Record that a site fired (called by the site injectors below).
Segment file paths under dir/journal, ascending — raw File.ls, no Reader.
All named fault sites.
Site :writer_down: stop the Writer (graceful stop — terminate/2 frees the
:global name synchronously, so a later reopen never races the cleanup).
Any handle still holding this pid gets {:error, {:writer_down, _}}.
Types
@type scan_entry() :: {:ok, map(), String.t()} | {:corrupt, Path.t(), String.t()} | {:torn, Path.t(), String.t()}
One raw-scan entry:
{:ok, record, raw_line}— a complete, decodable framed line{:corrupt, path, raw_line}— a complete line that does not decode{:torn, path, raw_bytes}— unterminated trailing bytes of the LAST segment
Functions
Arm a fault site: it MUST fire before assert_all_fired!/2 or the test fails.
Meta-invariant m1: fail if any armed site never fired (dead injector).
schedule (any term) is included in the failure message so a failing run
dumps its fault schedule alongside the ExUnit seed (m2).
Per-site fire counts.
Restore the Writer's real fd after inject_append_fail/3.
Remove the blocking file so the next open succeeds.
Site :append_fail: swap a dead (closed) fd into the live Writer so its next
:file.write returns {:error, _} — the :enospc branch. Returns the
original io term; pass it to heal_append_fail/2 to restore the Writer.
The dead fd is opened+closed INSIDE the Writer process (the replace_state fun runs there) so the Writer owns it.
Site :kill_after_head_before_publish: append record through a joiner
FileStore handle sharing the session's single Writer — journal + HEAD get the
record, the live tail never does (the "crash after append, before publish"
window made permanent). Returns the assigned offset.
Site :open_fail: occupy the session dir path with a regular file so
FileStore.open/2 (Writer init/1 -> File.mkdir_p!) fails. Only valid for
a session with no journal on disk yet.
Site :kill_after_write_before_head (data half): append fully-framed,
newline-terminated records raw to the newest segment — byte-for-byte what a
flushed :file.write leaves when the crash beats the HEAD rewrite. Records
must already carry "id" (continuing the sequence) and "schema_version".
Pair with stop_writer/1 / kill_writer_brutal/2 for the "kill" half; the
Writer must NOT append again through its stale in-memory offset afterwards.
A REAL brutal kill (meta-invariant m7: no timer cheats for durability).
Process.exit(writer, :kill) — terminate/2 never runs. Waits for the death
and then for :global to release the Writer's name (the async cleanup a
graceful stop does synchronously), so a reopen is deterministic.
Start a fresh harness (an Agent holding armed-site set + fire counters).
Raw HEAD read: {:ok, map}, {:error, reason} (unparseable), or :missing.
Ids of a healthy journal, in file order.
{record, raw_line} pairs of a healthy journal (for byte-identity checks).
Decoded records from a journal expected to be fully healthy — raises on corrupt/torn entries.
@spec raw_scan(Path.t()) :: [scan_entry()]
Independent decode of every segment. An unterminated trailing chunk of a
non-last segment is classified :corrupt (bytes can only legally be torn at
the very end of the journal).
Record that a site fired (called by the site injectors below).
Segment file paths under dir/journal, ascending — raw File.ls, no Reader.
All named fault sites.
Site :writer_down: stop the Writer (graceful stop — terminate/2 frees the
:global name synchronously, so a later reopen never races the cleanup).
Any handle still holding this pid gets {:error, {:writer_down, _}}.