Packages

AI agent framework for Elixir built on OTP. TEA-based agents with crash isolation, inter-agent messaging, team supervision, and real SSE streaming to Anthropic, OpenAI, Ollama, and more.

Current section

Files

Jump to
raxol_agent lib raxol agent cache.ex
Raw

lib/raxol/agent/cache.ex

defmodule Raxol.Agent.Cache do
@moduledoc """
Behaviour for `Raxol.Agent.Policy.Cache` storage.
Implementations decide the storage medium. Two adapters ship with
raxol_agent:
* `Raxol.Agent.Cache.Ets` -- in-process, named ETS table; the
default for single-node agents.
* `Raxol.Agent.Cache.Postgrex` -- Postgres-backed via the
optional `:postgrex` dependency; the default for cross-process
or multi-node sharing.
## TTL contract
`put/4` takes a TTL in milliseconds. Implementations record the
absolute expiry (`DateTime.utc_now + ttl_ms`) and serve `get/2` as
`{:ok, value}` while not expired, `:miss` once expired. Expiry is
lazy by default: a `get/2` on a stale entry returns `:miss` and
the entry is removed. Adapters that want eager sweep (background
GenServer that scans and deletes expired entries) are free to do
so without breaking the lazy-expiry contract.
TTL of `0` means "no expiry": the entry lives until explicitly
deleted via `delete/2` or `flush/1`. Negative TTL is invalid and
raises `ArgumentError` at the dispatcher (`Cache.put/4`).
## Keys and values
Keys are arbitrary Erlang terms; adapters hash or serialize them
as needed. Values are also arbitrary terms; the Postgrex adapter
uses `:erlang.term_to_binary/1` so values round-trip faithfully
across BEAM nodes.
## Configuration
When wired into a `Policy.Cache`, the configuration carries
alongside the module:
Policy.Cache.ets(ttl: :timer.minutes(5))
# ↓ resolves to ...
{Raxol.Agent.Cache.Ets, %{table: :raxol_agent_cache}}
Policy.Cache.postgrex(ttl: :timer.minutes(5), conn: MyApp.Postgrex)
# ↓ resolves to ...
{Raxol.Agent.Cache.Postgrex,
%{conn: MyApp.Postgrex, table: "raxol_agent_cache"}}
Adapters destructure `{module, config}` and forward `config` to
every callback. A bare module (no tuple) gets `%{}`.
"""
@typedoc "Per-call configuration map passed to every callback."
@type config :: map()
@typedoc "Cache key; arbitrary term."
@type key :: term()
@typedoc "Cache value; arbitrary term."
@type value :: term()
@typedoc "Time-to-live in milliseconds. `0` means no expiry."
@type ttl_ms :: non_neg_integer()
@doc """
Return `{:ok, value}` when `key` is present and not expired,
`:miss` otherwise. Implementations may garbage-collect expired
entries opportunistically on `get/2`.
"""
@callback get(config(), key()) :: {:ok, value()} | :miss
@doc """
Store `value` under `key` with a TTL in milliseconds.
A `ttl_ms` of `0` means "no expiry" (entry persists until
`delete/2` or `flush/1`). Implementations record the absolute
expiry timestamp at write time so the get-side check is a single
comparison against `DateTime.utc_now/0`.
"""
@callback put(config(), key(), value(), ttl_ms()) :: :ok
@doc """
Remove the entry for `key`. Idempotent: returns `:ok` even if the
key was not present.
"""
@callback delete(config(), key()) :: :ok
@doc """
Remove all entries managed by this config. Idempotent.
"""
@callback flush(config()) :: :ok
# --- Module-level dispatcher --------------------------------------------
@doc """
Normalize the cache opt into `{module, config}` form.
Accepts a bare module, a `{module, config}` tuple, or `nil`.
Returns `nil` for `nil` input.
"""
@spec normalize(module() | {module(), config()} | nil) ::
{module(), config()} | nil
def normalize(nil), do: nil
def normalize({module, config}) when is_atom(module) and is_map(config),
do: {module, config}
def normalize(module) when is_atom(module), do: {module, %{}}
@doc """
Dispatch `get` to the adapter. Returns `{:ok, value}` or `:miss`.
Returns `:miss` when the adapter tuple is `nil` (no cache
configured) so callers can treat "no cache" the same as "cache
miss" without branching.
"""
@spec get({module(), config()} | nil, key()) :: {:ok, value()} | :miss
def get(nil, _key), do: :miss
def get({module, config}, key) when is_atom(module),
do: module.get(config, key)
@doc """
Dispatch `put` to the adapter.
Raises `ArgumentError` on negative `ttl_ms`. Returns `:ok` when the
adapter tuple is `nil` (no-op, no cache configured).
"""
@spec put({module(), config()} | nil, key(), value(), ttl_ms()) :: :ok
def put(nil, _key, _value, _ttl_ms), do: :ok
def put({module, config}, key, value, ttl_ms)
when is_atom(module) and is_integer(ttl_ms) and ttl_ms >= 0,
do: module.put(config, key, value, ttl_ms)
def put(_adapter, _key, _value, ttl_ms) when is_integer(ttl_ms) do
raise ArgumentError,
"Cache.put/4 ttl_ms must be a non-negative integer, got: #{ttl_ms}"
end
@doc "Dispatch `delete` to the adapter. No-op when adapter is `nil`."
@spec delete({module(), config()} | nil, key()) :: :ok
def delete(nil, _key), do: :ok
def delete({module, config}, key) when is_atom(module),
do: module.delete(config, key)
@doc "Dispatch `flush` to the adapter. No-op when adapter is `nil`."
@spec flush({module(), config()} | nil) :: :ok
def flush(nil), do: :ok
def flush({module, config}) when is_atom(module), do: module.flush(config)
end