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.datasyncon mailbox drain with a≤200msceiling, but syncs immediately for side-effect events (tool_result,approval, ...); - never uses
delayed_write; - rotates to the next
NNNNNN.jsonlsegment when one passes the size cap; - writes
meta.jsonandHEADatomically 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
@spec append(pid(), map()) :: {:ok, non_neg_integer()} | {:error, term()}
@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.
@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.
Returns a specification to start this module under a supervisor.
See Supervisor.
@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".
@spec flush(pid()) :: :ok
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.
The path of dir's writer lock file.
The Writer that has the journal in dir open, or nil.