Packages
reckon_db
2.2.2
5.11.0
5.10.4
5.10.3
5.10.1
5.10.0
5.9.1
5.9.0
5.8.3
5.8.2
5.8.1
5.8.0
5.7.0
5.6.1
5.6.0
5.5.5
5.5.4
5.5.3
5.5.2
5.5.1
5.5.0
5.4.0
5.2.2
5.2.1
5.2.0
5.1.0
5.0.0
4.0.0
3.1.2
3.1.1
3.0.0
2.3.7
2.3.6
2.3.5
2.3.4
2.3.3
2.3.2
2.3.1
2.3.0
2.2.2
2.2.0
2.1.4
2.1.3
2.1.2
2.1.1
2.1.0
2.0.0
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.3
1.6.2
1.6.1
1.6.0
1.5.1
1.5.0
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.3
1.3.2
1.3.1
1.3.0
1.2.7
1.2.6
1.2.5
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.1.1
1.1.0
1.0.3
1.0.2
1.0.1
1.0.0
BEAM-native Event Store built on Khepri/Ra with Raft consensus. Event sourcing, persistent subscriptions, snapshots, and automatic cluster formation via UDP multicast discovery. Ships embedded Rust NIFs for 3-15x acceleration of crypto, hashing, compression, aggregation, filter matching, and grap...
Current section
Files
Jump to
Current section
Files
src/reckon_db_subscription_health.erl
%% @doc Subscription health monitor for reckon-db
%%
%% Periodic health checks that detect and clean up:
%% - Stale subscriptions: subscriber process is dead
%% - Orphaned emitter pools: pool running without a subscription
%% - Missing emitter pools: subscription exists but no pool on leader
%%
%% Runs as a child of notification_sup (after leader_sup and emitter_sup).
%% Only performs cleanup when this node is the Ra leader.
%%
%% @author rgfaber
-module(reckon_db_subscription_health).
-behaviour(gen_server).
-include("reckon_db.hrl").
-include("reckon_db_telemetry.hrl").
%% API
-export([start_link/1]).
-export([health_check/1]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
-define(DEFAULT_CHECK_INTERVAL, 60000). %% 60 seconds
-define(MAX_CLEANUP_RETRIES, 3).
-record(state, {
store_id :: atom(),
config :: store_config(),
check_interval :: pos_integer(),
cleanup_count :: non_neg_integer()
}).
-record(health_report, {
stale_subscriptions = [] :: [{binary(), binary()}],
orphaned_pools = [] :: [binary()],
missing_pools = [] :: [binary()],
healthy_count = 0 :: non_neg_integer(),
total_subscriptions = 0 :: non_neg_integer(),
total_pools = 0 :: non_neg_integer()
}).
%%====================================================================
%% API
%%====================================================================
-spec start_link(store_config()) -> {ok, pid()} | {error, term()}.
start_link(#store_config{store_id = StoreId} = Config) ->
Name = reckon_db_naming:health_monitor_name(StoreId),
gen_server:start_link({local, Name}, ?MODULE, Config, []).
%% @doc Run an on-demand health check and return the report
-spec health_check(atom()) -> {ok, map()} | {error, term()}.
health_check(StoreId) ->
Name = reckon_db_naming:health_monitor_name(StoreId),
case whereis(Name) of
undefined -> {error, not_running};
_Pid -> gen_server:call(Name, health_check, 10000)
end.
%%====================================================================
%% gen_server callbacks
%%====================================================================
init(#store_config{store_id = StoreId, options = Options} = Config) ->
process_flag(trap_exit, true),
Interval = get_check_interval(Options),
logger:info("Subscription health monitor started (store: ~p, interval: ~pms)",
[StoreId, Interval]),
%% Schedule first check after a delay to let the system stabilize
schedule_check(Interval * 2),
State = #state{
store_id = StoreId,
config = Config,
check_interval = Interval,
cleanup_count = 0
},
{ok, State}.
handle_call(health_check, _From, #state{store_id = StoreId} = State) ->
Report = run_health_check(StoreId),
{reply, {ok, report_to_map(Report)}, State};
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}.
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(check_health, #state{store_id = StoreId,
check_interval = Interval} = State) ->
NewState = case reckon_db_store_coordinator:is_leader(StoreId) of
true ->
Report = run_health_check(StoreId),
CleanedUp = maybe_cleanup(StoreId, Report),
maybe_log_report(StoreId, Report, CleanedUp),
State#state{cleanup_count = State#state.cleanup_count + CleanedUp};
false ->
State
end,
schedule_check(Interval),
{noreply, NewState};
handle_info(_Info, State) ->
{noreply, State}.
terminate(Reason, #state{store_id = StoreId}) ->
logger:info("Subscription health monitor terminating (store: ~p, reason: ~p)",
[StoreId, Reason]),
ok.
%%====================================================================
%% Internal functions
%%====================================================================
%% @private Run a complete health check
-spec run_health_check(atom()) -> #health_report{}.
run_health_check(StoreId) ->
%% Get all subscriptions
Subscriptions = case reckon_db_subscriptions:list(StoreId) of
{ok, Subs} -> Subs;
{error, _} -> []
end,
%% Get all running emitter pool names
RunningPools = get_running_pools(StoreId),
%% Check each subscription
{Stale, Missing, Healthy} = check_subscriptions(StoreId, Subscriptions, RunningPools),
%% Find orphaned pools (running but no subscription)
SubscriptionKeys = [Sub#subscription.id || Sub <- Subscriptions],
Orphaned = find_orphaned_pools(StoreId, RunningPools, SubscriptionKeys),
#health_report{
stale_subscriptions = Stale,
orphaned_pools = Orphaned,
missing_pools = Missing,
healthy_count = Healthy,
total_subscriptions = length(Subscriptions),
total_pools = length(RunningPools)
}.
%% @private Check all subscriptions for health issues
-spec check_subscriptions(atom(), [subscription()], [binary()]) ->
{[{binary(), binary()}], [binary()], non_neg_integer()}.
check_subscriptions(StoreId, Subscriptions, RunningPools) ->
lists:foldl(
fun(#subscription{id = SubId, subscription_name = Name,
subscriber_pid = SubscriberPid}, {StaleAcc, MissingAcc, HealthyAcc}) ->
IsStale = is_subscriber_dead(SubscriberPid),
HasPool = has_running_pool(StoreId, SubId, RunningPools),
case {IsStale, HasPool} of
{true, false} ->
%% Dead subscriber AND no emitter pool = truly stale
{[{SubId, Name} | StaleAcc], MissingAcc, HealthyAcc};
{true, true} ->
%% Dead subscriber BUT emitter pool running =
%% restarted (persisted subscription from previous BEAM).
%% The pool is serving events, so treat as healthy.
{StaleAcc, MissingAcc, HealthyAcc + 1};
{false, false} ->
{StaleAcc, [SubId | MissingAcc], HealthyAcc};
{false, true} ->
{StaleAcc, MissingAcc, HealthyAcc + 1}
end
end,
{[], [], 0},
Subscriptions
).
%% @private Check if a subscriber PID is dead.
%% In a cluster, PIDs from remote nodes may appear. is_process_alive/1
%% only works on local PIDs — remote PIDs are assumed alive.
-spec is_subscriber_dead(pid() | undefined) -> boolean().
is_subscriber_dead(undefined) ->
false;
is_subscriber_dead(Pid) when is_pid(Pid), node(Pid) =:= node() ->
not erlang:is_process_alive(Pid);
is_subscriber_dead(Pid) when is_pid(Pid) ->
false; %% Remote PID — assume alive
is_subscriber_dead(_) ->
false.
%% @private Check if an emitter pool is running for a subscription
-spec has_running_pool(atom(), binary(), [binary()]) -> boolean().
has_running_pool(StoreId, SubId, _RunningPools) ->
PoolName = reckon_db_emitter_pool:name(StoreId, SubId),
whereis(PoolName) =/= undefined.
%% @private Get all running emitter pool subscription IDs
-spec get_running_pools(atom()) -> [binary()].
get_running_pools(StoreId) ->
SupName = reckon_db_naming:emitter_sup_name(StoreId),
case whereis(SupName) of
undefined ->
[];
_Pid ->
Children = supervisor:which_children(SupName),
[extract_sub_id(Id) || {Id, Child, _, _} <- Children,
Child =/= undefined,
Child =/= restarting]
end.
%% @private Extract subscription ID from emitter pool child ID
-spec extract_sub_id(term()) -> binary().
extract_sub_id(Id) when is_atom(Id) ->
%% Pool names are atoms like 'reckon_db_emitter_pool_StoreId_SubId'
%% We need the SubId part
IdStr = atom_to_list(Id),
%% Find the last underscore-separated segment
case string:split(IdStr, "_", trailing) of
[_, SubId] -> list_to_binary(SubId);
_ -> list_to_binary(IdStr)
end;
extract_sub_id(Id) ->
%% Fallback
list_to_binary(io_lib:format("~p", [Id])).
%% @private Find orphaned pools (running without a subscription)
-spec find_orphaned_pools(atom(), [binary()], [binary()]) -> [binary()].
find_orphaned_pools(_StoreId, RunningPools, SubscriptionKeys) ->
[PoolId || PoolId <- RunningPools,
not lists:member(PoolId, SubscriptionKeys)].
%% @private Perform cleanup actions
-spec maybe_cleanup(atom(), #health_report{}) -> non_neg_integer().
maybe_cleanup(StoreId, #health_report{
stale_subscriptions = Stale,
orphaned_pools = Orphaned,
missing_pools = Missing
}) ->
CleanedStale = cleanup_stale_subscriptions(StoreId, Stale),
CleanedOrphaned = cleanup_orphaned_pools(StoreId, Orphaned),
StartedMissing = start_missing_pools(StoreId, Missing),
CleanedStale + CleanedOrphaned + StartedMissing.
%% @private Clean up stale subscriptions (dead subscriber)
-spec cleanup_stale_subscriptions(atom(), [{binary(), binary()}]) -> non_neg_integer().
cleanup_stale_subscriptions(StoreId, Stale) ->
lists:foldl(
fun({SubId, Name}, Count) ->
logger:warning("Cleaning up stale subscription ~s (~s) in store ~p",
[Name, SubId, StoreId]),
%% Stop the emitter pool first
reckon_db_emitter_pool:stop(StoreId, SubId),
%% Remove the subscription
case reckon_db_subscriptions:unsubscribe(StoreId, SubId) of
ok -> Count + 1;
{error, _} -> Count
end
end,
0,
Stale
).
%% @private Clean up orphaned emitter pools
-spec cleanup_orphaned_pools(atom(), [binary()]) -> non_neg_integer().
cleanup_orphaned_pools(StoreId, Orphaned) ->
lists:foldl(
fun(PoolId, Count) ->
logger:warning("Stopping orphaned emitter pool ~s in store ~p",
[PoolId, StoreId]),
case reckon_db_emitter_pool:stop(StoreId, PoolId) of
ok -> Count + 1;
{error, _} -> Count
end
end,
0,
Orphaned
).
%% @private Start missing emitter pools
-spec start_missing_pools(atom(), [binary()]) -> non_neg_integer().
start_missing_pools(StoreId, Missing) ->
lists:foldl(
fun(SubId, Count) ->
Count + try_start_pool(StoreId, SubId)
end,
0,
Missing
).
try_start_pool(StoreId, SubId) ->
case reckon_db_subscriptions:get(StoreId, SubId) of
{ok, Subscription} ->
logger:info("Starting missing emitter pool for ~s in store ~p",
[SubId, StoreId]),
start_emitter_for_pool(StoreId, Subscription);
{error, _} ->
0
end.
start_emitter_for_pool(StoreId, Subscription) ->
case reckon_db_emitter_pool:start_emitter(StoreId, Subscription) of
{ok, _Pid} -> 1;
{error, {already_started, _}} -> 0;
{error, _} -> 0
end.
%% @private Log health report if there are issues
-spec maybe_log_report(atom(), #health_report{}, non_neg_integer()) -> ok.
maybe_log_report(StoreId, #health_report{
stale_subscriptions = Stale,
orphaned_pools = Orphaned,
missing_pools = Missing,
healthy_count = Healthy,
total_subscriptions = Total
}, CleanedUp) ->
case {Stale, Orphaned, Missing} of
{[], [], []} when Total > 0 ->
logger:debug("Subscription health OK: ~p/~p healthy (store: ~p)",
[Healthy, Total, StoreId]);
{[], [], []} ->
ok;
_ ->
logger:info("Subscription health check (store: ~p): "
"~p stale, ~p orphaned, ~p missing, ~p healthy of ~p total. "
"Cleaned up: ~p",
[StoreId, length(Stale), length(Orphaned),
length(Missing), Healthy, Total, CleanedUp])
end,
ok.
%% @private Convert report record to map for API response
-spec report_to_map(#health_report{}) -> map().
report_to_map(#health_report{
stale_subscriptions = Stale,
orphaned_pools = Orphaned,
missing_pools = Missing,
healthy_count = Healthy,
total_subscriptions = TotalSubs,
total_pools = TotalPools
}) ->
#{
stale_subscriptions => [{Id, Name} || {Id, Name} <- Stale],
orphaned_pools => Orphaned,
missing_pools => Missing,
healthy_count => Healthy,
total_subscriptions => TotalSubs,
total_pools => TotalPools,
status => case {Stale, Orphaned, Missing} of
{[], [], []} -> healthy;
_ -> degraded
end
}.
%% @private Schedule the next health check
-spec schedule_check(pos_integer()) -> reference().
schedule_check(Interval) ->
erlang:send_after(Interval, self(), check_health).
%% @private Get the check interval from options
-spec get_check_interval(map() | undefined) -> pos_integer().
get_check_interval(Options) when is_map(Options) ->
maps:get(health_check_interval, Options, ?DEFAULT_CHECK_INTERVAL);
get_check_interval(_) ->
?DEFAULT_CHECK_INTERVAL.