Current section

Files

Jump to
reckon_db src reckon_db_emitter.erl
Raw

src/reckon_db_emitter.erl

%% @doc Emitter worker for reckon-db
%%
%% A gen_server that handles event broadcasting for subscriptions.
%% Each subscription has a pool of emitter workers that receive events
%% from Khepri triggers and forward them to subscribers.
%%
%% Message types handled:
%% - {broadcast, Topic, Event}: Broadcast event to all subscribers on topic
%% - {forward_to_local, Topic, Event}: Forward event locally (optimization)
%% - {events, [Event]}: Direct event delivery to subscriber pid
%%
%% @author rgfaber
-module(reckon_db_emitter).
-behaviour(gen_server).
-include("reckon_db.hrl").
-include("reckon_db_telemetry.hrl").
%% API
-export([
start_link/4,
child_spec/4,
update_subscriber/2
]).
%% gen_server callbacks
-export([
init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
terminate/2,
code_change/3
]).
-record(state, {
store_id :: atom(),
subscription_key :: binary(),
subscriber :: pid() | undefined,
topic :: binary(),
active = true :: boolean()
}).
%%====================================================================
%% API
%%====================================================================
%% @doc Start an emitter worker
-spec start_link(atom(), binary(), pid() | undefined, atom()) -> {ok, pid()} | {error, term()}.
start_link(StoreId, SubscriptionKey, Subscriber, EmitterName) ->
gen_server:start_link({local, EmitterName}, ?MODULE,
{StoreId, SubscriptionKey, Subscriber}, []).
%% @doc Create a child spec for the emitter worker
-spec child_spec(atom(), binary(), pid() | undefined, atom()) -> supervisor:child_spec().
child_spec(StoreId, SubscriptionKey, Subscriber, EmitterName) ->
#{
id => EmitterName,
start => {?MODULE, start_link, [StoreId, SubscriptionKey, Subscriber, EmitterName]},
restart => permanent,
shutdown => 5000,
type => worker,
modules => [?MODULE]
}.
%% @doc Update the subscriber pid
-spec update_subscriber(pid() | atom(), pid()) -> ok.
update_subscriber(Emitter, NewSubscriber) ->
gen_server:cast(Emitter, {update_subscriber, NewSubscriber}).
%%====================================================================
%% gen_server callbacks
%%====================================================================
init({StoreId, SubscriptionKey, Subscriber}) ->
process_flag(trap_exit, true),
Topic = reckon_db_emitter_group:topic(StoreId, SubscriptionKey),
%% Join the emitter group
ok = reckon_db_emitter_group:join(StoreId, SubscriptionKey, self()),
logger:info("Emitter worker started: store=~p, subscription=~s, topic=~s",
[StoreId, SubscriptionKey, Topic]),
State = #state{
store_id = StoreId,
subscription_key = SubscriptionKey,
subscriber = Subscriber,
topic = Topic,
active = true
},
{ok, State}.
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}.
handle_cast({update_subscriber, NewSubscriber}, State) ->
{noreply, State#state{subscriber = NewSubscriber}};
handle_cast(_Msg, State) ->
{noreply, State}.
%% Handle broadcast messages (from remote emitters)
handle_info({broadcast, Topic, Event}, State) ->
handle_event_delivery(Topic, Event, State),
{noreply, State};
%% Handle forward_to_local messages (from local emitters)
handle_info({forward_to_local, Topic, Event}, State) ->
handle_event_delivery(Topic, Event, State),
{noreply, State};
%% Handle direct events message
handle_info({events, Events}, #state{subscriber = Subscriber} = State) when is_list(Events) ->
maybe_forward_events(Subscriber, Events),
{noreply, State};
%% Handle EXIT from linked processes
handle_info({'EXIT', Pid, Reason}, #state{subscriber = Subscriber} = State) ->
case Subscriber of
Pid ->
logger:info("Subscriber ~p exited with reason: ~p", [Pid, Reason]),
{noreply, State#state{subscriber = undefined}};
_ ->
{noreply, State}
end;
handle_info(_Info, State) ->
{noreply, State}.
terminate(Reason, #state{store_id = StoreId, subscription_key = SubscriptionKey}) ->
%% Leave the emitter group
ok = reckon_db_emitter_group:leave(StoreId, SubscriptionKey, self()),
logger:info("Emitter worker terminating: store=~p, subscription=~s, reason=~p",
[StoreId, SubscriptionKey, Reason]),
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%%====================================================================
%% Internal functions
%%====================================================================
%% @private Handle event delivery to subscriber
-spec handle_event_delivery(binary(), event(), #state{}) -> ok.
handle_event_delivery(_Topic, _Event, #state{active = false}) ->
ok;
handle_event_delivery(Topic, Event, #state{
store_id = StoreId,
subscription_key = SubscriptionKey,
subscriber = Subscriber
}) ->
telemetry:execute(
?SUBSCRIPTION_EVENT_DELIVERED,
#{count => 1},
#{store_id => StoreId, subscription_key => SubscriptionKey, topic => Topic}
),
deliver_event(Subscriber, Event, StoreId, SubscriptionKey, Topic).
maybe_forward_events(undefined, _Events) ->
ok;
maybe_forward_events(Pid, Events) when is_pid(Pid), node(Pid) =:= node() ->
case erlang:is_process_alive(Pid) of
true -> Pid ! {events, Events};
false -> ok
end;
maybe_forward_events(Pid, Events) when is_pid(Pid) ->
%% Remote pid — `!' works across Erlang distribution. The previous
%% silent-drop here caused subscriptions to lose events whenever
%% the broadcast/catch-up source ran on a node other than the
%% subscriber's node.
catch (Pid ! {events, Events}),
ok.
deliver_event(undefined, Event, StoreId, _SubscriptionKey, Topic) ->
broadcast_to_topic(StoreId, Topic, Event);
deliver_event(Pid, Event, StoreId, SubscriptionKey, _Topic) when is_pid(Pid) ->
send_to_subscriber(Pid, Event, StoreId, SubscriptionKey).
%% @private Broadcast event to topic subscribers via pg
-spec broadcast_to_topic(atom(), binary(), event()) -> ok.
broadcast_to_topic(StoreId, Topic, Event) ->
%% Get subscribers for the topic
Group = {StoreId, Topic, subscribers},
Members = pg:get_members(?RECKON_DB_PG_SCOPE, Group),
lists:foreach(
fun(Pid) ->
catch Pid ! {events, [Event]}
end,
Members
),
ok.
%% @private Send event directly to subscriber. Liveness probing only
%% runs for local pids — `erlang:is_process_alive/1' is undefined for
%% remote pids. For remote subscribers we rely on Erlang distribution
%% delivery semantics (the message is dropped silently by the runtime
%% if the remote pid is dead — same effect as the local liveness
%% short-circuit, just less observable).
%%
%% The previous implementation silently dropped EVERY remote-pid
%% delivery, which made `by_stream' subscriptions miss events whenever
%% the trigger fired on a node other than the subscriber's node
%% (i.e. virtually always, for subscribers attached to a non-leader
%% gateway).
-spec send_to_subscriber(pid(), event(), atom(), binary()) -> ok.
send_to_subscriber(Pid, Event, StoreId, SubscriptionKey) when node(Pid) =:= node() ->
case erlang:is_process_alive(Pid) of
true ->
Pid ! {events, [Event]},
ok;
false ->
logger:warning("Subscriber ~p is dead for subscription ~s in store ~p, "
"stopping emitter pool",
[Pid, SubscriptionKey, StoreId]),
%% Stop the emitter pool asynchronously to avoid blocking the
%% emitter worker during event delivery
spawn(fun() -> reckon_db_emitter_pool:stop(StoreId, SubscriptionKey) end),
ok
end;
send_to_subscriber(Pid, Event, _StoreId, _SubscriptionKey) when is_pid(Pid) ->
catch (Pid ! {events, [Event]}),
ok.