Raxol.MCP.Client.Tables (Raxol MCP v2.8.0)

Copy Markdown View Source

The one process that owns the MCP client's per-ORIGIN state.

ADR-0037 decision 2 asked for "a public ETS table with no owning process", which is not a thing that can be built: :ets.new/2 tables are owned by the process that creates them (circuit_breaker.ex:31-33, whose only in-tree caller creates its tables inside a GenServer's init_manager). Two concrete failures follow from letting each client create its own:

  • an era verdict created by a client is a verdict per client, so every client pays for its own probe and the cache caches nothing;
  • a circuit breaker created per client never observes anyone else's failures, so a challenge-serving origin is retried by every client forever.

Both pieces of state outlive any one client, so they are created once, here, and handed out through :persistent_term: a lock-free read of a write-once value rather than a message to this process per request.

This process holds no logic. It creates two tables in init/1 and answers nothing, which is what makes a restart theoretical rather than a live concern. Callers read through eras/0 and breakers/0 per use rather than caching a reference, because a restart replaces both tables and a stale reference raises ArgumentError from ETS.

Why ensure_started/0 exists

The normal path is the supervised child of Raxol.MCP.Supervisor. But a client is also started directly, outside that tree, by Raxol.Agent.McpBundle, and an unreachable table there would mean no remote server at all rather than a slower one. So the HTTP transport ensures the owner exists before its first read. The started process is NOT linked to whoever asked: a client that dies must not take the shared verdict cache with it.

Summary

Functions

The circuit-breaker table, keyed {:origin, origin}.

Returns a specification to start this module under a supervisor.

Every table, starting the owner if nothing has yet.

The era-verdict table, keyed {origin, path}.

The minted single-use spend handles (Raxol.MCP.Client.Reservation).

Start the table owner. One per node, named.

Types

t()

@type t() :: %{eras: :ets.table(), breakers: :ets.table(), reservations: :ets.table()}

Functions

breakers()

@spec breakers() :: :ets.table()

The circuit-breaker table, keyed {:origin, origin}.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

ensure_started()

@spec ensure_started() :: t()

Every table, starting the owner if nothing has yet.

Idempotent, and safe from a transient caller: the owner is started unlinked.

The answer comes from the owner rather than from :persistent_term when this call did not start it. GenServer.start/3 registers the name before init/1 runs, so two clients connecting concurrently both get past the clash while only one has written the terms, and the loser used to read a term that did not exist yet -- an ArgumentError from handle_continue, in the exact embedded configuration (raxol_agent alone, no Raxol.MCP.Supervisor) this function exists for. A call is queued behind init/1, so it cannot observe the gap.

eras()

@spec eras() :: :ets.table()

The era-verdict table, keyed {origin, path}.

Raises if the owner has never run; callers that cannot assume a supervision tree call ensure_started/0 first.

reservations()

@spec reservations() :: :ets.table()

The minted single-use spend handles (Raxol.MCP.Client.Reservation).

start_link(opts \\ [])

@spec start_link(keyword()) :: GenServer.on_start()

Start the table owner. One per node, named.

A name clash is not a failure here. ensure_started/0 starts this process unlinked and outside any tree, so a client that ran before the subsystem booted may already own the tables; the supervised child adopts that owner rather than refusing to start the whole MCP subtree over the name. The tables are the point, and they are the same ones.