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/4 writes durably, then sends {: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/subscribe for a session (mailbox-serialized, strictly-before), the Writer:
    • if the journal is empty, appends the genesis session_created at offset 1; else
    • reads the tip; if it is a turn_started with no matching turn_completed, appends EXACTLY ONE orphaned turn_completed (idempotent: a no-op when the tip is already a turn_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.
  • 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 same handle_call, before the reply. Success therefore never leaves the latch held: a Session crash immediately AFTER a successful turn_completed append produces ZERO orphan rows (no double turn_completed) and never wedges the next prompt at :turn_in_flight.
  • Orphan repair on appender death — if the latched appender dies between turn_started and turn_completed, the Writer appends exactly one orphaned turn_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 +1 dup-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/3 replenishes. 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 dangling turn_started and synthesizes one orphaned turn_completed — but the real appender is still running; its later real updates and its own real turn_completed then land AFTER the synthetic completion (a false-orphan of a live turn).
  • (b) tip = a mid-turn session_update. The tip is not a turn_started, so the fold no-ops and the in-memory latch is silently lost across the restart; a concurrent second turn_started then 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.

t()

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

subscriber()

@type subscriber() :: %{
  pid: pid(),
  credit: non_neg_integer(),
  sent_hi: non_neg_integer()
}

Per-subscriber state: pid, remaining publish credit, and highest offset sent.

t()

@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()
}

turn()

@type turn() ::
  %{turn_id: term(), appender_ref: reference(), appender_pid: pid()} | nil

Functions

append(server, kind, payload, taint)

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

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

credit(server, subscriber, n)

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

registry()

@spec registry() :: atom()

The package-level unique Registry name for Writers (keys = session_id binaries).

start_link(opts)

@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 genesis session_created payload (default %{}).
  • :subscriber_credit — per-subscriber publish credit (default 1024); a subscriber that overruns it is sent one terminal {:reattach_lagged, ...} and dropped.
  • :name — override the :via name; pass nil to start unregistered (tests). Defaults to via(session_id).
  • :__dead_publish_phantom__ / :__dead_bootstrap_after_op__ — TEST-ONLY dead-injector knobs (default false); see the moduledoc.

subscribe(server, subscriber)

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

unsubscribe(server, subscriber)

@spec unsubscribe(GenServer.server(), pid()) :: :ok

Idempotently detach subscriber from the live tail.

via(session_id)

@spec via(String.t()) :: {:via, module(), {atom(), String.t()}}

The :via tuple that keys a Writer on its session_id (binary, never atomized).

whereis(session_id)

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

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.