Current section

Files

Jump to
nous lib nous memory store ets.ex
Raw

lib/nous/memory/store/ets.ex

defmodule Nous.Memory.Store.ETS do
@moduledoc """
ETS-backed memory store implementation.
Uses an unnamed ETS table so multiple instances can coexist.
Text search uses `String.jaro_distance/2` for fuzzy matching.
Does not implement `search_vector/3` (no vector support in ETS).
"""
@behaviour Nous.Memory.Store
alias Nous.Memory.Entry
@impl true
def init(_opts) do
table = :ets.new(:memory_store, [:set, :public])
{:ok, table}
end
@impl true
def store(table, %Entry{} = entry) do
:ets.insert(table, {entry.id, entry})
{:ok, table}
end
@impl true
def fetch(table, id) do
case :ets.lookup(table, id) do
[{^id, entry}] -> {:ok, entry}
[] -> {:error, :not_found}
end
end
@impl true
def delete(table, id) do
:ets.delete(table, id)
{:ok, table}
end
@impl true
def update(table, id, updates) do
case fetch(table, id) do
{:ok, entry} ->
now = DateTime.utc_now()
updated = struct(entry, Map.put(updates, :updated_at, now))
:ets.insert(table, {id, updated})
{:ok, table}
error ->
error
end
end
@impl true
def search_text(table, query, opts) do
scope = Keyword.get(opts, :scope, %{})
limit = Keyword.get(opts, :limit, 10)
min_score = Keyword.get(opts, :min_score, 0.0)
results =
table
|> scoped_entries(scope)
|> Enum.map(fn entry ->
score = String.jaro_distance(String.downcase(query), String.downcase(entry.content))
{entry, score}
end)
|> Enum.filter(fn {_entry, score} -> score > min_score end)
|> Enum.sort_by(fn {_entry, score} -> score end, :desc)
|> Enum.take(limit)
{:ok, results}
end
@impl true
def list(table, opts) do
scope = Keyword.get(opts, :scope, %{})
entries =
table
|> scoped_entries(scope)
{:ok, entries}
end
# Push the scope filter INTO ETS via a partial-map matchspec, so we only copy
# (and later jaro-score) the matching rows instead of tab2list-copying the
# whole table and filtering in Elixir. At 10k entries a scoped search copied
# ~49 MB (the full table) under tab2list; this drops it to just the matches.
#
# An empty scope still copies everything — unavoidable for a full scan, and
# the documented scaling ceiling for the ETS store. A scope carrying a key
# that isn't an Entry field can't be pushed down (a partial-map pattern
# requires the key to exist on the struct), so we fall back to copy+filter to
# stay behavior-identical.
# A non-map scope (e.g. :global) means "no scope" — return everything.
defp scoped_entries(table, scope) when not is_map(scope), do: all_entries(table)
defp scoped_entries(table, scope) when map_size(scope) == 0, do: all_entries(table)
defp scoped_entries(table, scope) do
if scope_pushable?(scope) do
pattern = {:_, Map.put(scope, :__struct__, Entry)}
table
|> :ets.select([{pattern, [], [:"$_"]}])
|> Enum.map(fn {_id, entry} -> entry end)
else
table |> all_entries() |> filter_by_scope(scope)
end
end
defp scope_pushable?(scope) do
entry_fields = %Entry{} |> Map.from_struct() |> Map.keys()
Enum.all?(Map.keys(scope), &(&1 in entry_fields))
end
defp all_entries(table) do
:ets.tab2list(table) |> Enum.map(fn {_id, entry} -> entry end)
end
# Fallback path only — reached when scope is non-empty AND has a non-Entry
# key (scoped_entries/2 short-circuits the empty-scope case).
defp filter_by_scope(entries, scope) do
Enum.filter(entries, fn entry ->
Enum.all?(scope, fn {key, value} ->
Map.get(entry, key) == value
end)
end)
end
end