Current section
Files
Jump to
Current section
Files
src/memory/adk_memory_ets.erl
%% @doc Volatile, deterministic lexical memory adapter.
%%
%% The table is private and owned by an OTP process. All writes are validated
%% before they enter ETS, storage is quota bounded, and result construction is
%% bounded by both hit count and bytes. This adapter is intended for tests and
%% local development; use `adk_memory_mnesia' when restart durability matters.
-module(adk_memory_ets).
-behaviour(gen_server).
-behaviour(adk_memory_service).
-export([start_link/1, init/1, stop/1, capabilities/1,
add_entry/4, add_entry/5,
add_events/5, add_events/6, add_session_to_memory/5,
search/4, search/5,
delete_entry/3, delete_entry/4,
delete_session/3, delete_session/4,
delete_user/2, delete_user/3,
add/3, delete/2, add_session_to_memory/3]).
-export([handle_call/3, handle_cast/2, handle_info/2,
terminate/2, code_change/3]).
-record(state, {table, limits, entries = 0, bytes = 0}).
-define(LEGACY_SCOPE, {user, <<"legacy">>, <<"legacy">>}).
-define(DEFAULT_TIMEOUT, 5000).
start_link(Config) ->
case adk_memory_contract:compile_config(Config) of
{ok, Limits} ->
gen_server:start_link(?MODULE, {server_limits, Limits}, []);
{error, _} = Error -> Error
end.
%% Compatibility constructor. The tagged clause is the OTP callback.
init(Config) when is_map(Config) -> start_link(Config);
init({server_limits, Limits}) ->
Table = ets:new(?MODULE, [set, private, {read_concurrency, true}]),
{ok, #state{table = Table, limits = Limits}}.
stop(Pid) ->
try gen_server:stop(Pid, normal, 5000) of
ok -> ok
catch
exit:{noproc, _} -> ok;
exit:noproc -> ok
end.
capabilities(Pid) -> call(Pid, capabilities).
add_entry(Pid, Scope, Input, Opts) ->
add_entry(Pid, Scope, Input, Opts, #{}).
add_entry(Pid, Scope, Input, Opts, CallOptions) ->
timed_call(Pid,
fun(Deadline) ->
{add_entry, Scope, Input, Opts, Deadline}
end, CallOptions).
add_events(Pid, Scope, SessionId, Events, Opts) ->
add_events(Pid, Scope, SessionId, Events, Opts, #{}).
add_events(Pid, Scope, SessionId, Events, Opts, CallOptions) ->
timed_call(Pid,
fun(Deadline) ->
{add_events, Scope, SessionId, Events, Opts, Deadline}
end, CallOptions).
add_session_to_memory(Pid, Scope, SessionId, Events, Opts) ->
add_events(Pid, Scope, SessionId, Events, Opts).
search(Pid, {user, _, _} = Scope, Query, Opts) ->
search(Pid, Scope, Query, Opts, #{});
search(Pid, Query, Filter, Limit) ->
search(Pid, ?LEGACY_SCOPE, Query,
#{filter => Filter, limit => Limit}, #{}).
search(Pid, Scope, Query, Opts, CallOptions) ->
timed_call(Pid,
fun(Deadline) -> {search, Scope, Query, Opts, Deadline} end,
CallOptions).
delete_entry(Pid, Scope, Id) -> delete_entry(Pid, Scope, Id, #{}).
delete_entry(Pid, Scope, Id, CallOptions) ->
timed_call(Pid,
fun(Deadline) -> {delete_entry, Scope, Id, Deadline} end,
CallOptions).
delete_session(Pid, Scope, SessionId) ->
delete_session(Pid, Scope, SessionId, #{}).
delete_session(Pid, Scope, SessionId, CallOptions) ->
timed_call(Pid,
fun(Deadline) ->
{delete_session, Scope, SessionId, Deadline}
end, CallOptions).
delete_user(Pid, Scope) -> delete_user(Pid, Scope, #{}).
delete_user(Pid, Scope, CallOptions) ->
timed_call(Pid,
fun(Deadline) -> {delete_user, Scope, Deadline} end,
CallOptions).
%% Version-1 wrappers use one reserved scope. They remain intentionally
%% idempotent on delete to preserve the old API.
add(Pid, Content, Metadata) ->
case add_entry(Pid, ?LEGACY_SCOPE,
#{content => Content, metadata => Metadata}, #{}) of
{ok, Entry} -> {ok, maps:get(id, Entry)};
{error, _} = Error -> Error
end.
delete(Pid, Id) ->
case delete_entry(Pid, ?LEGACY_SCOPE, Id) of
{error, not_found} -> ok;
Reply -> Reply
end.
add_session_to_memory(Pid, SessionId, Events) ->
call(Pid, {legacy_session, SessionId, Events}).
call(Pid, Request) when is_pid(Pid) ->
case process_info(Pid, message_queue_len) of
undefined -> {error, memory_service_unavailable};
{message_queue_len, Length} when Length >= 1000 ->
{error, memory_service_overloaded};
_ ->
try gen_server:call(Pid, Request, 5000) of
Reply -> Reply
catch
exit:{timeout, _} -> {error, timeout};
exit:{noproc, _} -> {error, memory_service_unavailable};
exit:{normal, _} -> {error, memory_service_unavailable}
end
end;
call(_, _) -> {error, invalid_memory_handle}.
timed_call(Pid, RequestFun, CallOptions) when is_map(CallOptions) ->
case maps:keys(maps:without([timeout_ms], CallOptions)) of
[] ->
Timeout = maps:get(timeout_ms, CallOptions, ?DEFAULT_TIMEOUT),
case is_integer(Timeout) andalso Timeout > 0 of
true ->
Deadline = erlang:monotonic_time(millisecond) + Timeout,
call(Pid, RequestFun(Deadline), Timeout + 100);
false -> {error, invalid_memory_call_timeout}
end;
Unknown -> {error, {invalid_memory_call_options,
{unknown_keys, lists:sort(Unknown)}}}
end;
timed_call(_Pid, _RequestFun, _CallOptions) ->
{error, invalid_memory_call_options}.
call(Pid, Request, Timeout) when is_pid(Pid), is_integer(Timeout), Timeout > 0 ->
case process_info(Pid, message_queue_len) of
undefined -> {error, memory_service_unavailable};
{message_queue_len, Length} when Length >= 1000 ->
{error, memory_service_overloaded};
_ ->
try gen_server:call(Pid, Request, Timeout) of
Reply -> Reply
catch
exit:{timeout, _} -> {error, timeout};
exit:{noproc, _} -> {error, memory_service_unavailable};
exit:{normal, _} -> {error, memory_service_unavailable}
end
end;
call(_, _, _) -> {error, invalid_memory_handle}.
handle_call(capabilities, _From, State) ->
{reply, adk_memory_contract:capabilities(ets, State#state.limits), State};
handle_call({add_entry, Scope, Input, Opts, Deadline}, _From, State) ->
case request_expired(Deadline) of
true -> {reply, {error, timeout}, State};
false ->
case adk_memory_contract:prepare_entry(
Scope, Input, Opts, State#state.limits) of
{ok, Entry} ->
case request_expired(Deadline) of
true -> {reply, {error, timeout}, State};
false ->
case store_entries([Entry], State) of
{ok, _Added, _Duplicates, NewState} ->
{reply,
{ok, public_entry(
lookup_entry(Entry, NewState))},
NewState};
{error, _} = Error -> {reply, Error, State}
end
end;
{error, _} = Error -> {reply, Error, State}
end
end;
handle_call({add_events, Scope, SessionId, Events, Opts, Deadline},
_From, State) ->
case request_expired(Deadline) of
true -> {reply, {error, timeout}, State};
false ->
case adk_memory_contract:prepare_events(
Scope, SessionId, Events, Opts, State#state.limits) of
{ok, Entries, Skipped} ->
case request_expired(Deadline) of
true -> {reply, {error, timeout}, State};
false ->
case store_entries(Entries, State) of
{ok, Added, Duplicates, NewState} ->
{reply,
{ok, #{added => Added,
duplicates => Duplicates,
skipped => Skipped}}, NewState};
{error, _} = Error -> {reply, Error, State}
end
end;
{error, _} = Error -> {reply, Error, State}
end
end;
handle_call({legacy_session, SessionId, Events}, _From, State) ->
case adk_memory_contract:prepare_legacy_session(
SessionId, Events, State#state.limits) of
{ok, none} -> {reply, ok, State};
{ok, Entry} ->
case store_entries([Entry], State) of
{ok, _, _, NewState} -> {reply, ok, NewState};
{error, _} = Error -> {reply, Error, State}
end;
{error, _} = Error -> {reply, Error, State}
end;
handle_call({search, Scope, Query, Opts, Deadline}, _From, State) ->
case request_expired(Deadline) of
true -> {reply, {error, timeout}, State};
false -> handle_search(Scope, Query, Opts, State)
end;
handle_call({delete_entry, Scope, Id, Deadline}, _From, State) ->
case {request_expired(Deadline), validate_delete(Scope, Id)} of
{true, _} -> {reply, {error, timeout}, State};
{false, {ok, CanonScope}} ->
Key = {CanonScope, Id},
case ets:lookup(State#state.table, Key) of
[{Key, Entry}] ->
case request_expired(Deadline) of
true -> {reply, {error, timeout}, State};
false ->
true = ets:delete(State#state.table, Key),
{reply, ok, subtract_entry(Entry, State)}
end;
[] -> {reply, {error, not_found}, State}
end;
{false, {error, _} = Error} -> {reply, Error, State}
end;
handle_call({delete_session, Scope, SessionId, Deadline}, _From, State) ->
case validate_delete(Scope, SessionId) of
{ok, CanonScope} ->
Entries = [{Key, Entry} || {Key = {EntryScope, _}, Entry}
<- ets:tab2list(State#state.table),
EntryScope =:= CanonScope,
maps:get(session_id,
maps:get(provenance, Entry),
undefined) =:= SessionId],
case request_expired(Deadline) of
true -> {reply, {error, timeout}, State};
false -> delete_matches(Entries, State)
end;
{error, _} = Error -> {reply, Error, State}
end;
handle_call({delete_user, Scope, Deadline}, _From, State) ->
case adk_memory_contract:validate_scope(Scope) of
{ok, CanonScope} ->
Entries = [{Key, Entry} || {Key = {EntryScope, _}, Entry}
<- ets:tab2list(State#state.table),
EntryScope =:= CanonScope],
case request_expired(Deadline) of
true -> {reply, {error, timeout}, State};
false -> delete_matches(Entries, State)
end;
{error, _} = Error -> {reply, Error, State}
end;
handle_call(_Request, _From, State) ->
{reply, {error, unsupported_memory_operation}, State}.
handle_search(Scope, Query, Opts, State) ->
case adk_memory_contract:prepare_search(
Scope, Query, Opts, State#state.limits) of
{ok, CanonScope, Tokens, Filter, Limit} ->
Hits0 = [adk_memory_contract:hit(Entry, Tokens) ||
{{EntryScope, _Id}, Entry} <-
ets:tab2list(State#state.table),
EntryScope =:= CanonScope,
adk_memory_contract:metadata_matches(
maps:get(metadata, Entry), Filter)],
Positive = [Hit || Hit <- Hits0, maps:get(score, Hit) > 0.0],
Sorted = lists:sort(fun compare_hits/2, Positive),
Hits = bound_hits(Sorted, Limit,
maps:get(max_result_bytes,
State#state.limits)),
{reply, {ok, Hits}, State};
{error, _} = Error -> {reply, Error, State}
end.
%% Preserve correlation for callers that used the pre-OTP raw message API.
handle_info({add, From, Ref, Content, Metadata}, State) when is_pid(From) ->
{Reply, NewState} = legacy_add_internal(Content, Metadata, State),
From ! {memory_reply, Ref, Reply},
{noreply, NewState};
handle_info(_Info, State) -> {noreply, State}.
handle_cast(_Message, State) -> {noreply, State}.
terminate(_Reason, _State) -> ok.
code_change(_OldVersion, State, _Extra) -> {ok, State}.
legacy_add_internal(Content, Metadata, State) ->
case adk_memory_contract:prepare_entry(
?LEGACY_SCOPE, #{content => Content, metadata => Metadata}, #{},
State#state.limits) of
{ok, Entry} ->
case store_entries([Entry], State) of
{ok, _, _, NewState} ->
{{ok, maps:get(id, Entry)}, NewState};
{error, _} = Error -> {Error, State}
end;
{error, _} = Error -> {Error, State}
end.
store_entries(Entries, State) ->
case classify_entries(Entries, State, #{}, 0) of
{ok, NewByKey, Duplicates} ->
NewEntries = maps:values(NewByKey),
Added = length(NewEntries),
AddedBytes = lists:sum([maps:get(storage_bytes, E)
|| E <- NewEntries]),
case capacity_ok(Added, AddedBytes, State) of
true ->
true = ets:insert(State#state.table,
[{{maps:get(scope, E), maps:get(id, E)}, E}
|| E <- NewEntries]),
{ok, Added, Duplicates,
State#state{entries = State#state.entries + Added,
bytes = State#state.bytes + AddedBytes}};
false -> {error, memory_capacity_exceeded}
end;
{error, _} = Error -> Error
end.
classify_entries([], _State, NewByKey, Duplicates) ->
{ok, NewByKey, Duplicates};
classify_entries([Entry | Rest], State, NewByKey, Duplicates) ->
Key = {maps:get(scope, Entry), maps:get(id, Entry)},
case maps:find(Key, NewByKey) of
{ok, Existing} ->
case same_entry(Existing, Entry) of
true -> classify_entries(Rest, State, NewByKey, Duplicates + 1);
false -> {error, idempotency_conflict}
end;
error ->
case ets:lookup(State#state.table, Key) of
[] -> classify_entries(Rest, State, NewByKey#{Key => Entry},
Duplicates);
[{Key, Existing}] ->
case same_entry(Existing, Entry) of
true -> classify_entries(Rest, State, NewByKey,
Duplicates + 1);
false -> {error, idempotency_conflict}
end
end
end.
same_entry(Left, Right) ->
maps:get(digest, Left) =:= maps:get(digest, Right) andalso
maps:get(metadata, Left) =:= maps:get(metadata, Right) andalso
maps:get(provenance, Left) =:= maps:get(provenance, Right).
capacity_ok(Added, AddedBytes, State) ->
State#state.entries + Added =< maps:get(max_entries, State#state.limits)
andalso State#state.bytes + AddedBytes =<
maps:get(max_total_bytes, State#state.limits).
lookup_entry(Entry, State) ->
Key = {maps:get(scope, Entry), maps:get(id, Entry)},
[{Key, Stored}] = ets:lookup(State#state.table, Key),
Stored.
public_entry(Entry) -> maps:without([storage_bytes], Entry).
compare_hits(Left, Right) ->
{-maps:get(score, Left), maps:get(timestamp, Left), maps:get(id, Left)} =<
{-maps:get(score, Right), maps:get(timestamp, Right), maps:get(id, Right)}.
bound_hits(Hits, Limit, MaxBytes) ->
bound_hits(Hits, Limit, MaxBytes, 0, []).
bound_hits(_, 0, _MaxBytes, _Bytes, Acc) -> lists:reverse(Acc);
bound_hits([], _Limit, _MaxBytes, _Bytes, Acc) -> lists:reverse(Acc);
bound_hits([Hit | Rest], Limit, MaxBytes, Bytes, Acc) ->
HitBytes = adk_memory_contract:entry_storage_bytes(Hit),
case Bytes + HitBytes =< MaxBytes of
true -> bound_hits(Rest, Limit - 1, MaxBytes,
Bytes + HitBytes, [Hit | Acc]);
false -> lists:reverse(Acc)
end.
validate_delete(Scope, Id) ->
case adk_memory_contract:validate_scope(Scope) of
{ok, CanonScope} when is_binary(Id), byte_size(Id) > 0,
byte_size(Id) =< 512 -> {ok, CanonScope};
{ok, _} -> {error, invalid_memory_identifier};
{error, _} = Error -> Error
end.
delete_matches([], State) -> {reply, {error, not_found}, State};
delete_matches(Entries, State) ->
lists:foreach(fun({Key, _}) -> true = ets:delete(State#state.table, Key) end,
Entries),
NewState = lists:foldl(fun({_Key, Entry}, Acc) ->
subtract_entry(Entry, Acc)
end, State, Entries),
{reply, ok, NewState}.
subtract_entry(Entry, State) ->
State#state{entries = State#state.entries - 1,
bytes = State#state.bytes - maps:get(storage_bytes, Entry)}.
request_expired(Deadline) ->
erlang:monotonic_time(millisecond) >= Deadline.