Packages
reckon_db
5.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_gateway_worker.erl
%% @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};
%% Read events by a metadata key=value pair (the indexed primitive apps
%% build causation/correlation read models on)
handle_call({read_by_metadata, _StoreId, Key, Value}, _From,
#state{store_id = StoreId} = State) ->
Result = reckon_db_streams:read_by_metadata(StoreId, Key, Value),
{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};
%% DCB log read (reckon-db 5.2.2+)
handle_call({dcb_read_log, _StoreId, FromSeq, Limit}, _From,
#state{store_id = StoreId} = State) ->
Result = case reckon_db_dcb:read_log(StoreId, FromSeq, Limit) of
{ok, Events, TotalCount} ->
{ok, #{events => Events, total_count => TotalCount}};
Err -> Err
end,
{reply, Result, State};
%% DCB tags index (reckon-db 5.2.2+)
handle_call({dcb_all_tags, _StoreId}, _From,
#state{store_id = StoreId} = State) ->
Result = reckon_db_dcb:all_tags(StoreId),
{reply, Result, State};
%% DCB event-types index (reckon-db 5.2.2+)
handle_call({dcb_all_event_types, _StoreId}, _From,
#state{store_id = StoreId} = State) ->
Result = reckon_db_dcb:all_event_types(StoreId),
{reply, Result, State};
%% DCB conditional-append (reckon-gater 2.3.0+, reckon-db 3.1.0+).
%% TagFilter is `reckon_gater_types:tag_filter()`; SeqCutoff is `integer()`
%% (-1 means "saw nothing"). See plans/PLAN_DCB_IMPLEMENTATION.md.
handle_call({append_if_no_tag_matches, _StoreId, TagFilter, SeqCutoff, Events},
_From,
#state{store_id = StoreId} = State) ->
Result = reckon_db_streams:append_if_no_tag_matches(
StoreId, TagFilter, SeqCutoff, 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};
%%====================================================================
%% 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};
%% Save subscription. Synchronous as of reckon-db 2.3.5 / reckon-gater
%% 2.1.2 — used to be a fire-and-forget handle_cast that swallowed
%% {invalid_filter, _} into a logger warning, leaving gRPC clients
%% with a phantom success. Now the worker returns the real result
%% of reckon_db_subscriptions:subscribe/5; the gateway translates
%% to gRPC InvalidArgument when needed.
%%
%% {already_exists, _} from the store layer is still mapped to
%% {ok, Key} — re-registering with the same name is idempotent
%% (the subscribe path re-binds the pid and re-arms the trigger;
%% see reckon_db_subscriptions:reregister_subscriber/4).
handle_call({save_subscription, _StoreId, Type, Selector, SubscriptionName, StartFrom, Subscriber}, _From,
#state{store_id = StoreId} = State) ->
Result = case reckon_db_subscriptions:subscribe(StoreId, Type, Selector, SubscriptionName, #{
start_from => StartFrom,
subscriber => Subscriber
}) of
{ok, _Key} = Ok -> Ok;
{error, {already_exists, Key}} -> {ok, Key};
{error, Reason} = Err ->
logger:warning("[gateway_worker] subscription ~s failed: ~p",
[SubscriptionName, Reason]),
Err
end,
{reply, Result, State};
%% Remove subscription. Synchronous as of reckon-db 2.3.6 / reckon-gater
%% 2.1.3. Idempotent: a not_found result is treated as ok because
%% removal is the desired terminal state regardless of starting state.
%% Other store-layer errors (e.g. khepri write failure) propagate.
handle_call({remove_subscription, _StoreId, Type, _Selector, SubscriptionName}, _From,
#state{store_id = StoreId} = State) ->
Result = case reckon_db_subscriptions:unsubscribe(StoreId, Type, SubscriptionName) of
ok -> ok;
{error, not_found} -> ok;
{error, _} = Err -> Err
end,
{reply, Result, State};
%% Ack event. Synchronous as of reckon-db 2.3.6 / reckon-gater 2.1.3.
%% {error, {subscription_not_found, _}} from the store surfaces to the
%% gRPC client as InvalidArgument (whitelisted in reckon_gater_retry).
handle_call({ack_event, _StoreId, SubscriptionName, _SubscriberPid, Event}, _From,
#state{store_id = StoreId} = State) ->
StreamId = maps:get(event_stream_id, Event,
maps:get(stream_id, Event, undefined)),
EventNumber = maps:get(event_number, Event,
maps:get(version, Event, 0)),
Result = reckon_db_subscriptions:ack(StoreId, SubscriptionName, StreamId, EventNumber),
{reply, Result, State};
%% Unknown request
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, 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.