Current section

Files

Jump to
macula_neuroevolution src silos lc_event_emitter.erl
Raw

src/silos/lc_event_emitter.erl

%% @doc Zero-config event emitter for Liquid Conglomerate silos.
%%
%% Provides fire-and-forget event emission that automatically uses
%% esdb_lineage_backend when available. If the backend is not installed,
%% events are silently dropped (no-op).
%%
%% Auto-detects if macula_neuroevolution_esdb is available. If the
%% esdb_lineage_backend module exists, events are persisted. Otherwise,
%% emit/3 returns ok immediately (no-op).
%%
%% Event emission never blocks the silo. Backend state is cached in
%% persistent_term for efficiency. The backend uses spawn for async writes
%% and errors are logged, not propagated.
%%
%% Events are routed to streams using the pattern: lc-REALM.SILO_TYPE
%%
%% @author Macula.io
%% @copyright 2025 Macula.io
-module(lc_event_emitter).
%% API
-export([
emit/3,
emit/4,
emit_batch/1,
is_backend_available/0,
get_backend_state/0
]).
-define(BACKEND_MODULE, esdb_lineage_backend).
-define(STATE_KEY, {?MODULE, backend_state}).
-define(AVAILABLE_KEY, {?MODULE, backend_available}).
%%% ============================================================================
%%% API Functions
%%% ============================================================================
%% @doc Emit a single LC silo event (fire-and-forget).
%% Uses the default realm from the event payload or "default".
-spec emit(SiloType, EventType, Payload) -> ok when
SiloType :: atom(),
EventType :: atom(),
Payload :: map().
emit(SiloType, EventType, Payload) ->
Realm = maps:get(realm, Payload, <<"default">>),
emit(SiloType, EventType, Realm, Payload).
%% @doc Emit a single LC silo event with explicit realm (fire-and-forget).
-spec emit(SiloType, EventType, Realm, Payload) -> ok when
SiloType :: atom(),
EventType :: atom(),
Realm :: binary(),
Payload :: map().
emit(SiloType, EventType, Realm, Payload) ->
case is_backend_available() of
false ->
ok;
true ->
Event = build_event(SiloType, EventType, Realm, Payload),
State = get_backend_state(),
?BACKEND_MODULE:persist_event(Event, State)
end.
%% @doc Emit a batch of LC silo events (fire-and-forget).
%% Each event map should have keys: silo, event_type, realm (optional), payload.
-spec emit_batch(Events) -> ok when
Events :: [map()].
emit_batch([]) ->
ok;
emit_batch(Events) ->
case is_backend_available() of
false ->
ok;
true ->
BuiltEvents = [build_event_from_map(E) || E <- Events],
State = get_backend_state(),
?BACKEND_MODULE:persist_batch(BuiltEvents, State)
end.
%% @doc Check if the event store backend is available.
%%
%% Result is cached after first check.
-spec is_backend_available() -> boolean().
is_backend_available() ->
case persistent_term:get(?AVAILABLE_KEY, undefined) of
undefined ->
Available = check_backend_available(),
persistent_term:put(?AVAILABLE_KEY, Available),
Available;
Available ->
Available
end.
%% @doc Get the cached backend state.
%%
%% Initializes the backend on first call if available.
%% Returns undefined if backend is not available.
-spec get_backend_state() -> term() | undefined.
get_backend_state() ->
case is_backend_available() of
false ->
undefined;
true ->
case persistent_term:get(?STATE_KEY, undefined) of
undefined ->
init_backend_state();
State ->
State
end
end.
%%% ============================================================================
%%% Internal Functions
%%% ============================================================================
%% @private Check if the backend module exists and is loaded.
check_backend_available() ->
case code:which(?BACKEND_MODULE) of
non_existing ->
false;
_ ->
%% Module exists, try to ensure it's loaded
case code:ensure_loaded(?BACKEND_MODULE) of
{module, ?BACKEND_MODULE} -> true;
{error, _} -> false
end
end.
%% @private Initialize the backend state and cache it.
init_backend_state() ->
try
%% Use default store_id - the backend provides sensible defaults
{ok, State} = ?BACKEND_MODULE:init(#{}),
persistent_term:put(?STATE_KEY, State),
State
catch
_:Reason ->
error_logger:warning_msg(
"[lc_event_emitter] Failed to initialize backend: ~p~n",
[Reason]
),
%% Mark as unavailable to prevent repeated failures
persistent_term:put(?AVAILABLE_KEY, false),
undefined
end.
%% @private Build an event map from silo type, event type, realm, and payload.
build_event(SiloType, EventType, Realm, Payload) ->
#{
event_type => EventType,
silo => SiloType,
realm => Realm,
timestamp => erlang:system_time(millisecond),
payload => Payload
}.
%% @private Build an event from a map specification.
build_event_from_map(EventSpec) ->
SiloType = maps:get(silo, EventSpec),
EventType = maps:get(event_type, EventSpec),
Realm = maps:get(realm, EventSpec, <<"default">>),
Payload = maps:get(payload, EventSpec, #{}),
build_event(SiloType, EventType, Realm, Payload).