%% @doc Gateway worker for reckon-db %% %% This worker process acts as the gateway endpoint for a store. %% It registers with reckon-gater and handles incoming requests %% routed through the gateway API. %% %% Multiple gateway workers can run per store for load balancing. %% Each worker registers independently with the gater's Ra-based %% worker registry. %% %% The message format matches the ExESDB.GatewayWorker from the %% original Elixir implementation. %% %% @author rgfaber -module(reckon_db_gateway_worker). -behaviour(gen_server). -include("reckon_db.hrl"). %% API -export([ start_link/1 ]). %% gen_server callbacks -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). -record(state, { store_id :: atom(), config :: store_config() }). %%==================================================================== %% API %%==================================================================== %% @doc Start a gateway worker for a store %% Workers are not locally registered to allow multiple per store. %% They register with reckon-gater for discovery and load balancing. -spec start_link(store_config()) -> {ok, pid()} | {error, term()}. start_link(Config) -> gen_server:start_link(?MODULE, Config, []). %%==================================================================== %% gen_server callbacks %%==================================================================== %% @private init(#store_config{store_id = StoreId} = Config) -> process_flag(trap_exit, true), %% Register with the gateway ok = reckon_gater_api:register_worker(StoreId, self()), logger:info("Gateway worker for store ~p registered with gater", [StoreId]), {ok, #state{store_id = StoreId, config = Config}}. %%==================================================================== %% Stream Operations (handle_call) %%==================================================================== %% Stream forward handle_call({stream_forward, _StoreId, StreamId, StartVersion, Count}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_streams:read(StoreId, StreamId, StartVersion, Count, forward), {reply, Result, State}; %% Stream backward handle_call({stream_backward, _StoreId, StreamId, StartVersion, Count}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_streams:read(StoreId, StreamId, StartVersion, Count, backward), {reply, Result, State}; %% Get events handle_call({get_events, _StoreId, StreamId, StartVersion, Count, Direction}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_streams:read(StoreId, StreamId, StartVersion, Count, Direction), {reply, Result, State}; %% Read all events globally (cross-stream, sorted by epoch_us) handle_call({read_all_global, _StoreId, Offset, BatchSize}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_streams:read_all_global(StoreId, Offset, BatchSize), {reply, Result, State}; %% Has events handle_call({has_events, _StoreId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_streams:has_events(StoreId), {reply, Result, State}; %% Get streams handle_call({get_streams, _StoreId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_streams:list_streams(StoreId), {reply, Result, State}; %% Delete stream handle_call({delete_stream, _StoreId, StreamId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_streams:delete(StoreId, StreamId), {reply, Result, State}; %% Read events by type (native Khepri filtering) handle_call({read_by_event_types, _StoreId, EventTypes, BatchSize}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_streams:read_by_event_types(StoreId, EventTypes, BatchSize), {reply, Result, State}; %% Read events by tags (native Khepri filtering with ANY/ALL matching) handle_call({read_by_tags, _StoreId, Tags, Match, BatchSize}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_streams:read_by_tags(StoreId, Tags, Match, BatchSize), {reply, Result, State}; %% Get subscription by name (for checkpoint retrieval) handle_call({get_subscription, _StoreId, SubscriptionName}, _From, #state{store_id = StoreId} = State) -> Result = find_subscription_by_name(StoreId, SubscriptionName), {reply, Result, State}; %% Get subscriptions handle_call({get_subscriptions, _StoreId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_subscriptions:list(StoreId), {reply, Result, State}; %% Get version handle_call({get_version, _StoreId, StreamId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_streams:get_version(StoreId, StreamId), {reply, Result, State}; %% Append events (auto-versioned) handle_call({append_events, _StoreId, StreamId, Events}, _From, #state{store_id = StoreId} = State) -> %% Get current version for auto-versioning %% get_version returns integer directly: -1 for no stream, or version number CurrentVersion = reckon_db_streams:get_version(StoreId, StreamId), Result = reckon_db_streams:append(StoreId, StreamId, CurrentVersion, Events), {reply, Result, State}; %% Append events (with expected version) %% Delegate version checking to reckon_db_streams:append/4 which handles %% all version constants: ANY_VERSION (-2), NO_STREAM (-1), STREAM_EXISTS (-4). handle_call({append_events, _StoreId, StreamId, ExpectedVersion, Events}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_streams:append(StoreId, StreamId, ExpectedVersion, Events), {reply, Result, State}; %%==================================================================== %% Snapshot Operations (handle_call) %%==================================================================== %% Read snapshot. Version =:= 0 means "latest" — there is no version-0 %% snapshot in the store (snapshot versions reflect an event index, %% which is the first written event's version, never 0 for an aggregate %% that has emitted events). Use load/2 in that case so callers can %% ask for the most recent snapshot without a List+At round-trip. handle_call({read_snapshot, _StoreId, _SourceUuid, StreamUuid, 0}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_snapshots:load(StoreId, StreamUuid), {reply, Result, State}; handle_call({read_snapshot, _StoreId, _SourceUuid, StreamUuid, Version}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_snapshots:load_at(StoreId, StreamUuid, Version), {reply, Result, State}; %% List snapshots handle_call({list_snapshots, _StoreId, _SourceUuid, StreamUuid}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_snapshots:list(StoreId, StreamUuid), {reply, Result, State}; %%==================================================================== %% Diagnostics Operations (handle_call) %%==================================================================== %% Verify cluster consistency handle_call({verify_cluster_consistency, _StoreId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_cluster:verify_consistency(StoreId), {reply, Result, State}; %% Quick health check handle_call({quick_health_check, _StoreId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_cluster:health_check(StoreId), {reply, Result, State}; %% Verify membership consensus handle_call({verify_membership_consensus, _StoreId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_cluster:verify_membership(StoreId), {reply, Result, State}; %% Check Raft log consistency handle_call({check_raft_log_consistency, _StoreId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_cluster:check_log_consistency(StoreId), {reply, Result, State}; %% List stores handle_call({list_stores}, _From, State) -> Result = reckon_db_store_registry:list_stores(), {reply, Result, State}; %%==================================================================== %% Temporal Query Operations (handle_call) %%==================================================================== %% Read events up to timestamp handle_call({read_until, _StoreId, StreamId, Timestamp}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_temporal:read_until(StoreId, StreamId, Timestamp), {reply, Result, State}; %% Read events up to timestamp with options handle_call({read_until, _StoreId, StreamId, Timestamp, Opts}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_temporal:read_until(StoreId, StreamId, Timestamp, Opts), {reply, Result, State}; %% Read events in time range handle_call({read_range, _StoreId, StreamId, FromTimestamp, ToTimestamp}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_temporal:read_range(StoreId, StreamId, FromTimestamp, ToTimestamp), {reply, Result, State}; %% Read events in time range with options handle_call({read_range, _StoreId, StreamId, FromTimestamp, ToTimestamp, Opts}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_temporal:read_range(StoreId, StreamId, FromTimestamp, ToTimestamp, Opts), {reply, Result, State}; %% Get version at timestamp handle_call({version_at, _StoreId, StreamId, Timestamp}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_temporal:version_at(StoreId, StreamId, Timestamp), {reply, Result, State}; %%==================================================================== %% Scavenge Operations (handle_call) %%==================================================================== %% Scavenge stream handle_call({scavenge, _StoreId, StreamId, Opts}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_scavenge:scavenge(StoreId, StreamId, Opts), {reply, Result, State}; %% Scavenge streams matching pattern handle_call({scavenge_matching, _StoreId, Pattern, Opts}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_scavenge:scavenge_matching(StoreId, Pattern, Opts), {reply, Result, State}; %% Dry-run scavenge handle_call({scavenge_dry_run, _StoreId, StreamId, Opts}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_scavenge:dry_run(StoreId, StreamId, Opts), {reply, Result, State}; %%==================================================================== %% Causation Operations (handle_call) %%==================================================================== %% Get events caused by an event handle_call({get_effects, _StoreId, EventId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_causation:get_effects(StoreId, EventId), {reply, Result, State}; %% Get the event that caused another handle_call({get_cause, _StoreId, EventId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_causation:get_cause(StoreId, EventId), {reply, Result, State}; %% Get causation chain handle_call({get_causation_chain, _StoreId, EventId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_causation:get_chain(StoreId, EventId), {reply, Result, State}; %% Get correlated events handle_call({get_correlated, _StoreId, CorrelationId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_causation:get_correlated(StoreId, CorrelationId), {reply, Result, State}; %% Build causation graph handle_call({build_causation_graph, _StoreId, Id}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_causation:build_graph(StoreId, Id), {reply, Result, State}; %%==================================================================== %% Schema Operations (handle_call) %%==================================================================== %% Get schema handle_call({get_schema, _StoreId, EventType}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_schema:get(StoreId, EventType), {reply, Result, State}; %% List schemas handle_call({list_schemas, _StoreId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_schema:list(StoreId), {reply, Result, State}; %% Get schema version handle_call({get_schema_version, _StoreId, EventType}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_schema:get_version(StoreId, EventType), {reply, Result, State}; %% Upcast events handle_call({upcast_events, _StoreId, Events}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_schema:upcast(StoreId, Events), {reply, {ok, Result}, State}; %%==================================================================== %% Memory Pressure Operations (handle_call) %%==================================================================== %% Get memory pressure level handle_call({get_memory_level, _StoreId}, _From, State) -> Result = try reckon_db_memory:level() catch exit:{noproc, _} -> normal end, {reply, {ok, Result}, State}; %% Get memory stats handle_call({get_memory_stats, _StoreId}, _From, State) -> Result = try reckon_db_memory:get_stats() catch exit:{noproc, _} -> #{level => unknown, memory_used => 0, memory_total => 0} end, {reply, {ok, Result}, State}; %%==================================================================== %% Link Operations (handle_call) %%==================================================================== %% Get link handle_call({get_link, _StoreId, LinkName}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_links:get(StoreId, LinkName), {reply, Result, State}; %% List links handle_call({list_links, _StoreId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_links:list(StoreId), {reply, Result, State}; %% Get link info handle_call({link_info, _StoreId, LinkName}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_links:info(StoreId, LinkName), {reply, Result, State}; %%==================================================================== %% Store Inspector Operations %%==================================================================== handle_call({store_stats, _StoreId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_store_inspector:store_stats(StoreId), {reply, Result, State}; handle_call({list_all_snapshots, _StoreId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_store_inspector:list_all_snapshots(StoreId), {reply, Result, State}; handle_call({list_store_subscriptions, _StoreId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_store_inspector:list_subscriptions(StoreId), {reply, Result, State}; handle_call({subscription_lag, _StoreId, SubscriptionName}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_store_inspector:subscription_lag(StoreId, SubscriptionName), {reply, Result, State}; handle_call({event_type_summary, _StoreId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_store_inspector:event_type_summary(StoreId), {reply, Result, State}; handle_call({stream_info, _StoreId, StreamId}, _From, #state{store_id = StoreId} = State) -> Result = reckon_db_store_inspector:stream_info(StoreId, StreamId), {reply, Result, State}; %% Unknown request handle_call(_Request, _From, State) -> {reply, {error, unknown_request}, State}. %%==================================================================== %% Subscription Operations (handle_cast) %%==================================================================== %% Remove subscription %% Note: unsubscribe uses (StoreId, Type, SubscriptionName), Selector is not needed handle_cast({remove_subscription, _StoreId, Type, _Selector, SubscriptionName}, State) -> #state{store_id = StoreId} = State, reckon_db_subscriptions:unsubscribe(StoreId, Type, SubscriptionName), {noreply, State}; %% Save subscription handle_cast({save_subscription, _StoreId, Type, Selector, SubscriptionName, StartFrom, Subscriber}, State) -> #state{store_id = StoreId} = State, case reckon_db_subscriptions:subscribe(StoreId, Type, Selector, SubscriptionName, #{ start_from => StartFrom, subscriber => Subscriber }) of {ok, _Key} -> ok; {error, {already_exists, _}} -> ok; {error, Reason} -> logger:warning("[gateway_worker] subscription ~s failed: ~p", [SubscriptionName, Reason]) end, {noreply, State}; %% Ack event handle_cast({ack_event, _StoreId, SubscriptionName, _SubscriberPid, Event}, State) -> #state{store_id = StoreId} = State, %% Update subscription position StreamId = maps:get(event_stream_id, Event, maps:get(stream_id, Event, undefined)), EventNumber = maps:get(event_number, Event, maps:get(version, Event, 0)), reckon_db_subscriptions:ack(StoreId, SubscriptionName, StreamId, EventNumber), {noreply, State}; %%==================================================================== %% Snapshot Operations (handle_cast) %%==================================================================== %% Record snapshot handle_cast({record_snapshot, _StoreId, _SourceUuid, StreamUuid, Version, SnapshotRecord}, State) -> #state{store_id = StoreId} = State, reckon_db_snapshots:save(StoreId, StreamUuid, Version, SnapshotRecord), {noreply, State}; %% Delete snapshot handle_cast({delete_snapshot, _StoreId, _SourceUuid, StreamUuid, Version}, State) -> #state{store_id = StoreId} = State, reckon_db_snapshots:delete_at(StoreId, StreamUuid, Version), {noreply, State}; %%==================================================================== %% Schema Operations (handle_cast) %%==================================================================== %% Register schema handle_cast({register_schema, _StoreId, EventType, Schema}, State) -> #state{store_id = StoreId} = State, reckon_db_schema:register(StoreId, EventType, Schema), {noreply, State}; %% Unregister schema handle_cast({unregister_schema, _StoreId, EventType}, State) -> #state{store_id = StoreId} = State, reckon_db_schema:unregister(StoreId, EventType), {noreply, State}; %%==================================================================== %% Link Operations (handle_cast) %%==================================================================== %% Create link handle_cast({create_link, _StoreId, LinkSpec}, State) -> #state{store_id = StoreId} = State, reckon_db_links:create(StoreId, LinkSpec), {noreply, State}; %% Delete link handle_cast({delete_link, _StoreId, LinkName}, State) -> #state{store_id = StoreId} = State, reckon_db_links:delete(StoreId, LinkName), {noreply, State}; %% Start link handle_cast({start_link, _StoreId, LinkName}, State) -> #state{store_id = StoreId} = State, reckon_db_links:start(StoreId, LinkName), {noreply, State}; %% Stop link handle_cast({stop_link, _StoreId, LinkName}, State) -> #state{store_id = StoreId} = State, reckon_db_links:stop(StoreId, LinkName), {noreply, State}; %% Unknown cast handle_cast(_Msg, State) -> {noreply, State}. %%==================================================================== %% Info handlers %%==================================================================== handle_info(_Info, State) -> {noreply, State}. %%==================================================================== %% Termination %%==================================================================== terminate(_Reason, #state{store_id = StoreId}) -> %% Unregister from the gateway reckon_gater_api:unregister_worker(StoreId, self()), logger:info("Gateway worker for store ~p unregistered from gater", [StoreId]), ok. %%==================================================================== %% Internal functions %%==================================================================== %% @private Find subscription by name across all types -spec find_subscription_by_name(atom(), binary()) -> {ok, map()} | {error, not_found}. find_subscription_by_name(StoreId, SubscriptionName) -> case reckon_db_subscriptions:list(StoreId) of {ok, Subscriptions} -> match_subscription_by_name(Subscriptions, SubscriptionName); {error, _} = Error -> Error end. match_subscription_by_name(Subscriptions, SubscriptionName) -> case lists:filter( fun(S) when is_record(S, subscription) -> S#subscription.subscription_name =:= SubscriptionName; (S) when is_map(S) -> maps:get(subscription_name, S, <<>>) =:= SubscriptionName end, Subscriptions ) of [Sub | _] -> {ok, subscription_to_map(Sub)}; [] -> {error, not_found} end. %% @private Convert subscription record to map -spec subscription_to_map(subscription() | map()) -> map(). subscription_to_map(#subscription{} = S) -> #{ type => S#subscription.type, selector => S#subscription.selector, subscription_name => S#subscription.subscription_name, subscriber_pid => S#subscription.subscriber_pid, created_at => S#subscription.created_at, pool_size => S#subscription.pool_size, checkpoint => S#subscription.checkpoint, options => S#subscription.options }; subscription_to_map(Map) when is_map(Map) -> Map.