Raxol.Agent.Journal.FileStore.Writer (Raxol Journal v2.8.0)

Copy Markdown View Source

The single-writer GenServer for one session's journal (see the package README, "The journal").

Exactly one Writer per session enforces the single-writer invariant. It:

  • appends framed JSONL (one complete self-delimited JSON object per line), assigning each event a monotonic offset = the event's id;
  • batches :file.datasync on mailbox drain with a ≤200ms ceiling, but syncs immediately for side-effect events (tool_result, approval, ...);
  • never uses delayed_write;
  • rotates to the next NNNNNN.jsonl segment when one passes the size cap;
  • writes meta.json and HEAD atomically only (temp + fsync + rename).

HEAD records the last durable offset (+ config) and is persisted together with each datasync, never in place.

Single-writer across OS processes

The process name is {:global, {Writer, key}}, keyed on the session directory's device and inode (its symlink-resolved path where a directory has no inode), so every spelling of one directory names one Writer. But :global only spans a CONNECTED cluster — two unclustered OS processes on one session dir would each start a Writer and mint duplicate offsets, permanently damaging the journal. A lock file (writer.lock) closes the same-host case. It holds the writer's OS pid and a token for when that BEAM started, and is removed on terminate if it is still this Writer's. The token is ticks:<n> on Linux (starttime in /proc/<pid>/stat, clock ticks since boot) and lstart:<time> elsewhere (ps -o lstart=, read with LC_ALL=C TZ=UTC). Linux does not use ps: procps derives lstart from the current time and the uptime on every run, so one process can print two start times.

The lock appears already filled: its content is written to writer.lock.tmp.<our pid> and that file is linked to writer.lock, which fails if a lock exists. No reader sees an empty or partial lock, so an empty one is a leftover and stale.

A holder counts as alive only when kill -0 <pid> succeeds and the pid is a beam whose start equals the recorded token, so a pid the OS reused for another program, or for a later BEAM, is stale. So is a pid kill -0 may not signal (another account's process): a private journal's directory is 0700, and no other account's BEAM can hold its lock. A lock with no token, or a token of another kind than this host reads, is judged by the pid and the beam name alone. A holder whose state cannot be read (ps fails, or this BEAM cannot read its own start) counts as alive: only "no such process" makes it gone. kill and ps are run from /bin or /usr/bin when they are there, and looked up on PATH only otherwise.

A lock naming this BEAM's own pid is stale: a second Writer for the same directory cannot be running here.

A stale lock is removed inside a gate, writer.lock.reclaim, which is created the same way as the lock, and only if it still reads as it did when it was judged; the reclaimer then creates writer.lock as above. So one reclaimer acts at a time and none removes a lock created after it looked. A claimer that finds a live BEAM in the gate is refused: that BEAM, or a claimer that beats it to the lock, is taking the journal. A gate whose holder is gone was left by a reclaimer that died inside it, and the next one removes it. Residual: two claimers that judge such a leftover gate at once can both enter.

Init refuses when a confirmed-live foreign BEAM holds the lock. Where no lock can be taken (non-Unix, no kill, the lock file cannot be created) the Writer proceeds without one, so a lock-subsystem hiccup never blocks journaling, unless it was started with require_lock: true: then it refuses too, and FileStore.open/2 answers {:error, {:journal_locked, :lock_unavailable}}.

A refusal is :ignore, never {:stop, reason}: an abnormal init exit also reaches the linked opener as an exit signal, which kills a non-trapping caller before it can read the {:error, _} return. FileStore.open/2 turns the :ignore into {:error, {:journal_locked, _}}; what the opener does then is its own choice (a surface carries on unjournaled, a session that requires its journal does not start).

Write-failure tolerance

A failing disk (e.g. ENOSPC) surfaces as a logged [:raxol, :agent, :journal, :write_failed] event, never a Writer crash: the append's own :file.write already returns {:error, _}, and the follow-on datasync, HEAD/meta atomic writes, and segment rotation all degrade (log + continue, retry on the next append) rather than MatchError-exiting the process out from under every open handle on the session.

Chained journals

A journal created with chain: true records "chain": true in meta.json and is chained for life, whatever later openers pass. Each record is sealed by Raxol.Agent.Journal.Chain (prev_hash + hash), written as its canonical JSON, and HEAD carries the tip hash beside the durable offset. If the chain does not verify when the Writer starts, it refuses every append with {:error, :damaged} and leaves HEAD untouched: a broken chain cannot be extended, and its anchor is evidence.

Private journals

With private: true every file this Writer creates -- segments, meta.json, HEAD, writer.lock and the temp files behind atomic writes -- is chmod 0600 before a byte is written to it, and a segment it reopens is tightened the same way. Raxol.Agent.Journal.FileStore.open/2 has already made the directories 0700, so no other account can open a file in the moment between its creation and the chmod.

Summary

Functions

Atomic check-and-append: run check against the freshest on-disk records and append event only if it returns :ok — as ONE step inside the single Writer, so no other append can interleave between the check and the append.

Append events as consecutive records in ONE Writer call, so no other append can land between them. Returns their offsets in order. Nothing is appended when any event fails to encode (or, on a chained journal, to seal).

Returns a specification to start this module under a supervisor.

The schema_version stamped on records written without an explicit override.

The OS pid holding dir's writer lock, or reclaiming a stale one, when it is a CONFIRMED-LIVE foreign BEAM, else nil (no lock, a stale one, our own, or liveness unprovable).

The path of dir's writer lock file.

The Writer that has the journal in dir open, or nil.

Functions

append(pid, event)

@spec append(pid(), map()) :: {:ok, non_neg_integer()} | {:error, term()}

append_checked(pid, event, check)

@spec append_checked(pid(), map(), ([map()] -> :ok | {:error, term()})) ::
  {:ok, non_neg_integer()} | {:error, term()}

Atomic check-and-append: run check against the freshest on-disk records and append event only if it returns :ok — as ONE step inside the single Writer, so no other append can interleave between the check and the append.

Closes the read-then-append race of a caller that validates against its own (possibly stale) read snapshot: e.g. the checkpoint turn-boundary rule, where a concurrent turn_started through a shared joiner handle must not slip in between validation and commit.

check receives the current record list and returns :ok or {:error, reason} (relayed verbatim; nothing appended). A raising check replies {:error, {:check_raised, ...}} — fail-closed — rather than crashing the Writer out from under every other handle on the session.

append_many(pid, events, timeout \\ 5000)

@spec append_many(pid(), [map()], timeout()) ::
  {:ok, [non_neg_integer()]} | {:error, term()}

Append events as consecutive records in ONE Writer call, so no other append can land between them. Returns their offsets in order. Nothing is appended when any event fails to encode (or, on a chained journal, to seal).

timeout bounds the call (default 5_000 ms). A caller that times out does not withdraw the request: it stays queued and the events land later.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

default_schema_version()

@spec default_schema_version() :: String.t()

The schema_version stamped on records written without an explicit override.

Bumping this is a freeze event: a bump must arrive with a frozen golden corpus under priv/golden/ for the version being left behind, so every historical shape stays replayable by a future upcast-on-read. See the package README, "Frozen golden corpora".

flush(pid)

@spec flush(pid()) :: :ok

lock_holder(dir)

@spec lock_holder(Path.t()) :: String.t() | nil

The OS pid holding dir's writer lock, or reclaiming a stale one, when it is a CONFIRMED-LIVE foreign BEAM, else nil (no lock, a stale one, our own, or liveness unprovable).

A read-only pre-flight for FileStore.open/2, so a refusal is reported as a plain {:error, {:journal_locked, holder}} naming the holder. It does not replace the claim in init/1 — that is the authority, this is the message.

lock_path(dir)

@spec lock_path(Path.t()) :: Path.t()

The path of dir's writer lock file.

start_link(opts)

whereis(dir)

@spec whereis(Path.t()) :: pid() | nil

The Writer that has the journal in dir open, or nil.