Packages
ex_esdb
0.7.2
0.11.0
0.10.0
0.9.0
0.8.0
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.3
0.3.2
0.3.1
0.3.0
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
0.0.20
0.0.19
0.0.18
0.0.17
0.0.16
0.0.15
0.0.14-alpha
0.0.13-alpha
0.0.12-alpha
0.0.11-alpha
0.0.10-alpha
0.0.9-alpha
0.0.8-alpha
0.0.6-alpha
0.0.5-alpha
0.0.4-alpha
0.0.3-alpha
0.0.2-alfa
0.0.1-alfa
ExESDB is a reincarnation of rabbitmq/khepri, specialized for use as a BEAM-native event store.
Current section
Files
Jump to
Current section
Files
lib/ex_esdb/gateway_worker.ex
defmodule ExESDB.GatewayWorker do
@moduledoc """
GatewayWorker processes are started on each node in the cluster,
and contain the implementation functions for the Gater.API.
"""
use GenServer
alias ExESDB.SnapshotsReader, as: SnapshotsR
alias ExESDB.SnapshotsWriter, as: SnapshotsW
alias ExESDB.StoreRegistry, as: StoreRegistry
alias ExESDB.SubscriptionsReader, as: SubsR
alias ExESDB.SubscriptionsWriter, as: SubsW
alias ExESDB.StreamsHelper, as: StreamsH
alias ExESDB.StreamsReader, as: StreamsR
alias ExESDB.StreamsWriter, as: StreamsW
alias ExESDB.Themes, as: Themes
alias ExESDB.ConsistencyChecker, as: ConsistencyChecker
require Logger
@type store :: atom()
@type stream :: String.t()
@type subscription_name :: String.t()
@type error :: term
@type subscription_type :: :by_stream | :by_event_type | :by_event_pattern
@type selector_type :: String.t() | map()
############ HANDLE_CALL ############
@impl GenServer
def handle_call({:stream_forward, store, stream_id, start_version, count}, _from, state) do
reply_with_stream_result(
StreamsR.stream_events(store, stream_id, start_version, count, :forward),
state
)
end
@impl GenServer
def handle_call({:stream_backward, store, stream_id, start_version, count}, _from, state) do
reply_with_stream_result(
StreamsR.stream_events(store, stream_id, start_version, count, :backward),
state
)
end
@impl GenServer
def handle_call({:get_events, store, stream_id, start_version, count, direction}, _from, state) do
result = StreamsR.stream_events(store, stream_id, start_version, count, direction)
reply_with_enumerable_result(result, state)
end
@impl GenServer
def handle_call({:get_streams, store}, _from, state) do
case StreamsR.get_streams(store) do
{:ok, streams} ->
{:reply, {:ok, Enum.to_list(streams)}, state}
{:error, _reason} ->
{:reply, {:ok, []}, state}
end
end
@impl GenServer
def handle_call({:get_subscriptions, store}, _from, state) do
reply =
store
|> SubsR.get_subscriptions()
{:reply, {:ok, reply}, state}
end
@impl GenServer
def handle_call({:get_version, store, stream}, _from, state) do
version =
store
|> StreamsH.get_version!(stream)
{:reply, {:ok, version}, state}
end
@impl GenServer
def handle_call({:append_events, store, stream_id, events}, _from, state) do
current_version =
store
|> StreamsH.get_version!(stream_id)
reply =
store
|> StreamsW.append_events(stream_id, current_version, events)
{:reply, reply, state}
end
@impl GenServer
def handle_call({:append_events, store, stream_id, expected_version, events}, _from, state) do
Logger.warning(
"[GATEWAY_WORKER] append_events to stream #{inspect(stream_id)} with expected version #{inspect(expected_version)}"
)
current_version =
store
|> StreamsH.get_version!(stream_id)
Logger.warning("[GATEWAY_WORKER] current version is #{inspect(current_version)}")
reply =
case current_version
|> version_matches?(expected_version) do
true ->
store
|> StreamsW.append_events(stream_id, current_version, events)
false ->
{:error, {:wrong_expected_version, current_version}}
end
{:reply, reply, state}
end
@impl GenServer
def handle_call({:read_snapshot, store, source_uuid, stream_uuid, version}, _from, state) do
reply_with_result(
SnapshotsR.read_snapshot(store, source_uuid, stream_uuid, version),
state
)
end
@impl GenServer
def handle_call({:list_snapshots, store, source_uuid, stream_uuid}, _from, state) do
reply_with_result(
SnapshotsR.list_snapshots(store, source_uuid, stream_uuid),
state
)
end
@impl GenServer
def handle_call({:verify_cluster_consistency, store}, _from, state) do
reply_with_result(ConsistencyChecker.verify_cluster_consistency(store), state)
end
@impl GenServer
def handle_call({:quick_health_check, store}, _from, state) do
reply_with_result(ConsistencyChecker.quick_health_check(store), state)
end
@impl GenServer
def handle_call({:verify_membership_consensus, store}, _from, state) do
reply_with_result(ConsistencyChecker.verify_membership_consensus(store), state)
end
@impl GenServer
def handle_call({:check_raft_log_consistency, store}, _from, state) do
reply_with_result(ConsistencyChecker.check_raft_log_consistency(store), state)
end
@impl GenServer
def handle_call({:list_stores}, _from, state) do
{:ok, stores} = StoreRegistry.list_stores()
{:reply, {:ok, stores}, state}
end
################ HANDLE_CAST #############
@impl true
def handle_cast(
{:remove_subscription, store, type, selector, subscription_name},
state
) do
store
|> SubsW.delete_subscription(type, selector, subscription_name)
{:noreply, state}
end
@impl true
def handle_cast(
{:save_subscription, store, type, selector, subscription_name, start_from, subscriber},
state
) do
store
|> SubsW.put_subscription(type, selector, subscription_name, start_from, subscriber)
{:noreply, state}
end
@impl true
def handle_cast(
{:ack_event, store, subscription_name, subscriber_pid, event},
state
) do
%{
event_stream_id: stream_id,
event_number: event_number
} = event
# Use 0-based versioning: next event to process is event_number + 1
store
|> SubsW.put_subscription(
:by_stream,
"$#{stream_id}",
subscription_name,
event_number + 1,
subscriber_pid
)
{:noreply, state}
end
@impl true
def handle_cast(
{:record_snapshot, store, source_uuid, stream_uuid, version, snapshot_record},
state
) do
store
|> SnapshotsW.record_snapshot(source_uuid, stream_uuid, version, snapshot_record)
{:noreply, state}
end
@impl true
def handle_cast({:delete_snapshot, store, source_uuid, stream_uuid, version}, state) do
store
|> SnapshotsW.delete_snapshot(source_uuid, stream_uuid, version)
{:noreply, state}
end
############# PLUMBING #############
def child_spec(opts) do
%{
id: {__MODULE__, :rand.uniform(10_000)},
start: {__MODULE__, :start_link, [opts]},
type: :worker,
restart: :permanent,
shutdown: 5000
}
end
def start_link(opts) do
GenServer.start_link(
__MODULE__,
opts
)
end
def gateway_worker_name(store_id),
do: {:gateway_worker, store_id, node(), :rand.uniform(10_000)}
@impl true
def init(opts) do
Process.flag(:trap_exit, true)
store_id = Keyword.get(opts, :store_id, :undefined_store_id)
name = gateway_worker_name(store_id)
new_state = Keyword.put(opts, :gateway_worker_name, name)
msg = "[#{inspect(name)}] is UP, joining the cluster."
IO.puts(Themes.gateway_worker(self(), msg))
# Register with Swarm if available
case register_with_swarm(name) do
:ok ->
{:ok, new_state}
{:error, reason} ->
Logger.warning(
"Failed to register with Swarm: #{inspect(reason)}. Continuing without distributed registration."
)
{:ok, new_state}
end
end
# Helper functions for safe Swarm operations
defp register_with_swarm(name) do
try do
case Swarm.register_name(name, self()) do
:yes -> :ok
:no -> {:error, :already_registered}
other -> {:error, other}
end
rescue
error -> {:error, {:exception, error}}
catch
:exit, reason -> {:error, {:exit, reason}}
type, reason -> {:error, {type, reason}}
end
end
defp unregister_from_swarm(name) do
try do
Swarm.unregister_name(name)
:ok
rescue
error ->
Logger.warning("Failed to unregister from Swarm: #{inspect(error)}")
:error
catch
:exit, reason ->
Logger.warning("Failed to unregister from Swarm (exit): #{inspect(reason)}")
:error
type, reason ->
Logger.warning("Failed to unregister from Swarm (#{type}): #{inspect(reason)}")
:error
end
end
@impl true
def terminate(reason, state) do
name = Keyword.get(state, :gateway_worker_name)
msg = "[#{inspect(name)}] is TERMINATED with reason #{inspect(reason)}, leaving the cluster."
IO.puts("#{Themes.gateway_worker(self(), msg)}")
unregister_from_swarm(name)
:ok
end
@impl true
def handle_info({:EXIT, _pid, reason}, state) do
name = Keyword.get(state, :gateway_worker_name)
msg = "[#{inspect(name)}] is EXITING with reason #{inspect(reason)}, leaving the cluster."
IO.puts("#{Themes.gateway_worker(self(), msg)}")
unregister_from_swarm(name)
{:noreply, state}
end
# Helper functions to reduce repetitive reply patterns
defp reply_with_result({:ok, data}, state), do: {:reply, {:ok, data}, state}
defp reply_with_result({:error, reason}, state), do: {:reply, {:error, reason}, state}
defp reply_with_stream_result({:ok, stream}, state), do: {:reply, {:ok, stream}, state}
defp reply_with_stream_result({:error, reason}, state), do: {:reply, {:error, reason}, state}
defp reply_with_enumerable_result({:ok, data}, state) do
{:reply, {:ok, Enum.to_list(data)}, state}
end
defp reply_with_enumerable_result({:error, reason}, state) do
{:reply, {:error, reason}, state}
end
# Helper function to check if current version matches expected version
defp version_matches?(_current, :any), do: true
defp version_matches?(current, :stream_exists) when current >= 0, do: true
# Stream doesn't exist
defp version_matches?(-1, :stream_exists), do: false
defp version_matches?(current, expected) when is_integer(expected), do: current == expected
end