Raxol.Journal.Test.FaultJournal (Raxol Journal v2.8.0)

Copy Markdown View Source

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.write fails (: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/2 fails: the session dir path is occupied by a regular file, so File.mkdir_p! in the Writer's init/1 raises and start_link returns {: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.write leaves 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 next append/2 through that handle returns {:error, {:writer_down, _}}.

Meta-invariant support

  • m1 dead-injector detection — every site keeps a per-harness fire counter; assert_all_fired!/2 fails 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!/2 takes the schedule so dead-injector failures also dump it.
  • m6 oracle independence — journal truth comes from raw_scan/1 / raw_records!/1: a raw File.read! plus this module's OWN framed-line decoder. It never consults the Writer's in-memory offset or FileStore.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

scan_entry()

@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(harness, site)

Arm a fault site: it MUST fire before assert_all_fired!/2 or the test fails.

assert_all_fired!(harness, schedule \\ nil)

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).

fired(harness)

Per-site fire counts.

heal_append_fail(writer, original_io)

Restore the Writer's real fd after inject_append_fail/3.

heal_open_fail(session_id, base_dir)

Remove the blocking file so the next open succeeds.

inject_append_fail(harness, writer, scratch_dir)

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.

inject_head_before_publish(harness, session_id, base_dir, record)

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.

inject_open_fail(harness, session_id, base_dir)

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.

inject_write_before_head(harness, dir, records)

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.

kill_writer_brutal(harness_or_nil, writer, dir)

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.

new()

Start a fresh harness (an Agent holding armed-site set + fire counters).

raw_head(dir)

Raw HEAD read: {:ok, map}, {:error, reason} (unparseable), or :missing.

raw_ids!(dir)

Ids of a healthy journal, in file order.

raw_lines!(dir)

{record, raw_line} pairs of a healthy journal (for byte-identity checks).

raw_records!(dir)

Decoded records from a journal expected to be fully healthy — raises on corrupt/torn entries.

raw_scan(dir)

@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_fired(harness, site)

Record that a site fired (called by the site injectors below).

segment_paths(dir)

Segment file paths under dir/journal, ascending — raw File.ls, no Reader.

sites()

All named fault sites.

stop_writer(harness, writer)

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, _}}.