Raxol. AgentClientProtocol. Ext. Journal. Writer
(Raxol Agent Client Protocol v0.1.0)
Copy Markdown
View Source
ONE GenServer per session_id — the SINGLE PUBLISHER of a session's live
tail.
Every durable record — streamed updates, turn boundaries, genesis, orphan
repairs — passes through this one process, so "append THEN publish" is
structural, not conventional: the publish call sits lexically AFTER
journal.append/2 returns {:ok, record} (durable, read-visible) and only
post-write code holds a stamped record (no publish-ahead). There is no
second door to the subscriber set.
Responsibilities (this cell)
- Append-then-publish —
append/4writes durably, thensends{:reattach_live, session_id, %Record{}}to every subscriber, at the tail (never publish-ahead). - Lazy genesis + Writer-restart tip-fold orphan repair — on (re)start,
BEFORE honoring the first
append/subscribefor a session (mailbox-serialized, strictly-before), the Writer:- if the journal is empty, appends the genesis
session_createdat offset 1; else - reads the tip; if it is a
turn_startedwith no matchingturn_completed, appends EXACTLY ONE orphanedturn_completed(idempotent: a no-op when the tip is already aturn_completed, so a restart during the fold cannot double-append). Running the fold after the first op is a defect with two witnesses (a new turn on top either buries the strand or double-completes it); the strictly-before ordering closes both. A test-only:__dead_bootstrap_after_op__knob realizes the defect so tests can witness it.
- if the journal is empty, appends the genesis
- Cross-connection turn latch —
append("turn_started", …)while a turn is latched returns{:error, :turn_in_flight}; the latch monitors the appender. Symmetrically,append("turn_completed", …)from a NON-holder while a turn is latched is REFUSED at the durable append itself ({:error, :not_turn_holder}) — the single-holder law guards the APPEND, not just the latch-clear, so a foreign connection can never write a phantom completion row that a reattacher would finalize from (log + telemetry, the journal is never corrupted). - Atomic latch clear — appending
"turn_completed"as the HOLDER clears the turn latch INSIDE the samehandle_call, before the reply. Success therefore never leaves the latch held: a Session crash immediately AFTER a successfulturn_completedappend produces ZERO orphan rows (no doubleturn_completed) and never wedges the next prompt at:turn_in_flight. - Orphan repair on appender death — if the latched appender dies
between
turn_startedandturn_completed, the Writer appends exactly one orphanedturn_completed{"outcome":"orphaned","stopReason":"cancelled"}and clears the latch. - Credit-based lagged producer — each subscriber carries a publish
credit (default
1024, per-Writer configurable via:subscriber_credit). Every live publish consumes one credit; a subscriber that overruns its credit is sent EXACTLY ONE terminal{:reattach_lagged, session_id, last_offset}(last_offset= the highest offset it was actually sent, so the client heals at+1dup-free) and is then demonitored and dropped — the Writer never blocks on a slow subscriber and never drops a middle record for one still attached.credit/3replenishes. A dead subscriber is pruned by its monitor.
Taint — annotate, never filter
The Writer STAMPS taint into the record (via the store) and delivers the record to EVERY subscriber regardless of value. No code path here drops, withholds, or reroutes a record by taint.
Restart limitations
The Writer-restart tip-fold folds on the journal TIP alone. It guarantees that no dangling OPEN turn survives a restart, but it leaves two interleavings inherent to a tip-fold when the Writer dies but the appender Session survives the restart:
- (a) tip =
turn_started, turn still live. The fold reads a danglingturn_startedand synthesizes one orphanedturn_completed— but the real appender is still running; its later real updates and its own realturn_completedthen land AFTER the synthetic completion (a false-orphan of a live turn). - (b) tip = a mid-turn
session_update. The tip is not aturn_started, so the fold no-ops and the in-memory latch is silently lost across the restart; a concurrent secondturn_startedthen succeeds against a turn that is, on the wire, still open.
Supervision
Raxol.AgentClientProtocol.Ext.Journal.ensure_writer/3 starts Writers lazily
under the package's WriterSupervisor (DynamicSupervisor); each Writer is
registered by session_id in the Writer Registry (via/1). The journal
handle {mod, j} is passed in at start_link — its owning process (the app
supervisor in production, the test process in tests) OUTLIVES the Writer,
which is exactly what lets the tip-fold survive a Writer crash.
Summary
Types
Per-subscriber state: pid, remaining publish credit, and highest offset sent.
Functions
Durably append a record, then publish it live. Returns the COMPLETE stamped
%Record{} (offset = record.offset).
Returns a specification to start this module under a supervisor.
Replenish subscriber's publish credit by n. A cast — the Writer never
blocks on credit accounting. A subscriber calls this after forwarding frames so
its credit does not run down under a healthy delivery rate; an already-dropped
(lagged) subscriber is a no-op.
The package-level unique Registry name for Writers (keys = session_id binaries).
Start a Writer.
Register subscriber for the live tail. Serialized against appends in this
Writer's mailbox. Idempotent per pid. Triggers the lazy
bootstrap (genesis / tip-fold) strictly BEFORE registering.
Idempotently detach subscriber from the live tail.
The :via tuple that keys a Writer on its session_id (binary, never atomized).
Look up the live Writer for session_id, or nil. Used by the
Ext.Journal.subscribe/2 facade to route registration through the single
publisher's mailbox.
Types
@type subscriber() :: %{ pid: pid(), credit: non_neg_integer(), sent_hi: non_neg_integer() }
Per-subscriber state: pid, remaining publish credit, and highest offset sent.
@type t() :: %Raxol.AgentClientProtocol.Ext.Journal.Writer{ bootstrapped: boolean(), dead_bootstrap_after_op: boolean(), dead_publish_phantom: boolean(), journal: {module(), term()}, session_id: String.t(), session_meta: map(), subscriber_credit: pos_integer(), subscribers: %{required(reference()) => subscriber()}, turn: turn() }
Functions
@spec append(GenServer.server(), String.t(), map(), String.t()) :: {:ok, Raxol.AgentClientProtocol.Ext.Journal.Record.t()} | {:error, :turn_in_flight | :not_turn_holder}
Durably append a record, then publish it live. Returns the COMPLETE stamped
%Record{} (offset = record.offset).
kind == "turn_started" acquires the turn latch (or returns
{:error, :turn_in_flight} if one is held); kind == "turn_completed" by the
latch HOLDER clears the latch atomically in the same step, while a
turn_completed from a NON-holder while a turn is latched is REFUSED at the
durable append ({:error, :not_turn_holder} — no journal write, no false
boundary); every other kind (and a turn_completed with no open turn) is a
plain append. Triggers the lazy bootstrap strictly BEFORE the append (unless the
dead :__dead_bootstrap_after_op__ knob is set).
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec credit(GenServer.server(), pid(), pos_integer()) :: :ok
Replenish subscriber's publish credit by n. A cast — the Writer never
blocks on credit accounting. A subscriber calls this after forwarding frames so
its credit does not run down under a healthy delivery rate; an already-dropped
(lagged) subscriber is a no-op.
@spec registry() :: atom()
The package-level unique Registry name for Writers (keys = session_id binaries).
@spec start_link(keyword()) :: GenServer.on_start()
Start a Writer.
Options:
:session_id(required, binary):journal(required) — an already-open{module, handle}; the handle's owner MUST outlive the Writer (so a restart can tip-fold).:session_meta— the genesissession_createdpayload (default%{}).:subscriber_credit— per-subscriber publish credit (default1024); a subscriber that overruns it is sent one terminal{:reattach_lagged, ...}and dropped.:name— override the:vianame; passnilto start unregistered (tests). Defaults tovia(session_id).:__dead_publish_phantom__/:__dead_bootstrap_after_op__— TEST-ONLY dead-injector knobs (defaultfalse); see the moduledoc.
@spec subscribe(GenServer.server(), pid()) :: :ok
Register subscriber for the live tail. Serialized against appends in this
Writer's mailbox. Idempotent per pid. Triggers the lazy
bootstrap (genesis / tip-fold) strictly BEFORE registering.
@spec unsubscribe(GenServer.server(), pid()) :: :ok
Idempotently detach subscriber from the live tail.
The :via tuple that keys a Writer on its session_id (binary, never atomized).
Look up the live Writer for session_id, or nil. Used by the
Ext.Journal.subscribe/2 facade to route registration through the single
publisher's mailbox.