defmodule Raxol.Agent.ThreadLog.Ets do @moduledoc """ ETS-backed `Raxol.Agent.ThreadLog` adapter. Stores events in a named ETS ordered_set keyed on `{thread_id, sequence}`. Iteration is naturally sequence-ordered within a thread; `list/3` walks the table starting at `{thread_id, from}` and stops when the thread changes or the limit is reached. Sequence allocation uses a sibling ETS `set` keyed on `thread_id` with `:ets.update_counter/3` for atomic per-thread increments. Counters live in `{_seq}` (e.g. `:raxol_agent_threads_seq` by default). ## Config %{table: :my_agent_threads} Defaults to `:raxol_agent_threads`. Two agents sharing the same table name share its events; isolate per-agent (or per-suite of agents) by passing distinct names. ## Lifetime Both tables live for the duration of the BEAM. Not supervised, not persisted. Use `Raxol.Agent.ThreadLog.Postgrex` when events must outlive the BEAM or be shared across nodes. """ @behaviour Raxol.Agent.ThreadLog alias Raxol.Agent.ThreadEvent @default_table :raxol_agent_threads @doc "Ensure both the event table and its sibling sequence counter exist." @spec ensure_tables(map()) :: {atom(), atom()} def ensure_tables(config) do events = Map.get(config, :table, @default_table) counters = :"#{events}_seq" case :ets.whereis(events) do :undefined -> _ = :ets.new(events, [ :ordered_set, :public, :named_table, read_concurrency: true, write_concurrency: true ]) :ok _ -> :ok end case :ets.whereis(counters) do :undefined -> _ = :ets.new(counters, [ :set, :public, :named_table, write_concurrency: true ]) :ok _ -> :ok end {events, counters} end @impl true def append(config, thread_id, kind, payload, opts \\ []) do {events, counters} = ensure_tables(config) metadata = Keyword.get(opts, :metadata, %{}) recorded_at = Keyword.get(opts, :recorded_at, DateTime.utc_now()) sequence = next_sequence(counters, thread_id) event = ThreadEvent.new( thread_id: thread_id, sequence: sequence, kind: kind, payload: payload, metadata: metadata, recorded_at: recorded_at ) _ = :ets.insert(events, {{thread_id, sequence}, event}) {:ok, event} end # Atomic sequence allocation via :ets.update_counter. Initial value # is 0 (the first sequence assigned to a fresh thread); subsequent # writes increment. defp next_sequence(counters, thread_id) do :ets.update_counter(counters, thread_id, {2, 1}, {thread_id, -1}) end @impl true def list(config, thread_id, opts \\ []) do {events, _counters} = ensure_tables(config) from = Keyword.get(opts, :from, 0) to = Keyword.get(opts, :to, :infinity) limit = Keyword.get(opts, :limit, 1_000) order = Keyword.get(opts, :order, :asc) stream = walk_thread(events, thread_id, from, to) result = case limit do :all -> Enum.to_list(stream) n when is_integer(n) and n >= 0 -> Enum.take(stream, n) end {:ok, maybe_reverse(result, order)} end @impl true def list_by_kind(config, thread_id, kind, opts \\ []) do {:ok, all} = list(config, thread_id, Keyword.put(opts, :limit, :all)) limit = Keyword.get(opts, :limit, 1_000) filtered = all |> Enum.filter(fn event -> event.kind == kind end) |> Enum.take(limit) {:ok, filtered} end @impl true def latest(config, thread_id) do {events, _counters} = ensure_tables(config) case :ets.prev(events, {thread_id, :max}) do :"$end_of_table" -> {:error, :not_found} {^thread_id, _seq} = key -> [{^key, event}] = :ets.lookup(events, key) {:ok, event} _other_thread_key -> {:error, :not_found} end end @impl true def truncate(config, thread_id, before) do {events, _counters} = ensure_tables(config) # `:ets.next` skips the start key, so seed with a sentinel before # the lowest possible sequence (0). do_truncate(events, thread_id, before, {thread_id, -1}) :ok end # Walk from {thread_id, 0} upward, deleting keys with sequence < before. defp do_truncate(events, thread_id, before, key) do case :ets.next(events, key) do :"$end_of_table" -> :ok {^thread_id, seq} = found_key when seq < before -> _ = :ets.delete(events, found_key) do_truncate(events, thread_id, before, found_key) {^thread_id, _seq} -> :ok _other -> :ok end end # --- Walk helpers --------------------------------------------------------- defp walk_thread(events, thread_id, from, to) do start_key = {thread_id, max(from - 1, -1)} Stream.unfold(start_key, fn key -> next_in_thread(events, thread_id, to, key) end) end defp next_in_thread(events, thread_id, to, key) do case :ets.next(events, key) do :"$end_of_table" -> nil {^thread_id, seq} = found_key when to == :infinity or seq <= to -> [{^found_key, event}] = :ets.lookup(events, found_key) {event, found_key} {^thread_id, _seq_past_to} -> nil _other_thread -> nil end end defp maybe_reverse(list, :asc), do: list defp maybe_reverse(list, :desc), do: Enum.reverse(list) end