Packages
reckon_db
1.3.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_causation.erl
%% @doc Causation and correlation tracking for reckon-db
%%
%% Provides functionality to trace event lineage:
%% - Causation ID: Links an event to its direct cause
%% - Correlation ID: Groups related events in a business process/saga
%% - Actor ID: Identifies who/what triggered the event
%%
%% Standard metadata fields (convention):
%% ```
%% #{
%% causation_id => binary(), %% ID of event/command that caused this
%% correlation_id => binary(), %% Business process/saga ID
%% actor_id => binary() %% Who/what triggered this
%% }
%% '''
%%
%% Use cases:
%% - Debugging distributed event flows
%% - Audit trails
%% - Saga/process manager state reconstruction
%% - Dependency analysis
%%
%% @author rgfaber
-module(reckon_db_causation).
-include("reckon_db.hrl").
-include("reckon_db_telemetry.hrl").
%% API
-export([
get_effects/2,
get_cause/2,
get_chain/2,
get_correlated/2,
build_graph/2,
to_dot/1
]).
%%====================================================================
%% Types
%%====================================================================
-type causation_graph() :: #{
nodes := [event()],
edges := [{binary(), binary()}], %% {cause_event_id, effect_event_id}
root := binary() | undefined
}.
-export_type([causation_graph/0]).
%%====================================================================
%% API
%%====================================================================
%% @doc Get all events caused by the given event.
%%
%% Returns events whose causation_id matches the given event_id.
-spec get_effects(atom(), binary()) -> {ok, [event()]} | {error, term()}.
get_effects(StoreId, EventId) ->
StartTime = erlang:monotonic_time(),
Result = scan_for_metadata(StoreId, causation_id, EventId),
Duration = erlang:monotonic_time() - StartTime,
emit_telemetry(StoreId, EventId, causation_effects, Duration, Result),
Result.
%% @doc Get the event that caused the given event.
%%
%% Finds the event whose event_id matches this event's causation_id.
-spec get_cause(atom(), binary()) -> {ok, event()} | {error, not_found | term()}.
get_cause(StoreId, EventId) ->
StartTime = erlang:monotonic_time(),
Result = case find_event_by_id(StoreId, EventId) of
{ok, Event} ->
case maps:get(causation_id, Event#event.metadata, undefined) of
undefined ->
{error, no_cause};
CausationId ->
find_event_by_id(StoreId, CausationId)
end;
Error ->
Error
end,
Duration = erlang:monotonic_time() - StartTime,
emit_telemetry(StoreId, EventId, causation_cause, Duration, Result),
Result.
%% @doc Get the full causation chain from root to the given event.
%%
%% Walks backward through causation_id links until reaching
%% an event with no cause. Returns events in order from root to target.
-spec get_chain(atom(), binary()) -> {ok, [event()]} | {error, term()}.
get_chain(StoreId, EventId) ->
StartTime = erlang:monotonic_time(),
Result = case find_event_by_id(StoreId, EventId) of
{ok, Event} ->
Chain = build_chain_backward(StoreId, Event, [Event]),
{ok, lists:reverse(Chain)};
Error ->
Error
end,
Duration = erlang:monotonic_time() - StartTime,
emit_telemetry(StoreId, EventId, causation_chain, Duration, Result),
Result.
%% @doc Get all events sharing the same correlation ID.
%%
%% Useful for finding all events in a saga or business process.
-spec get_correlated(atom(), binary()) -> {ok, [event()]} | {error, term()}.
get_correlated(StoreId, CorrelationId) ->
StartTime = erlang:monotonic_time(),
Result = scan_for_metadata(StoreId, correlation_id, CorrelationId),
Duration = erlang:monotonic_time() - StartTime,
emit_telemetry(StoreId, CorrelationId, causation_correlated, Duration, Result),
Result.
%% @doc Build a causation graph for visualization.
%%
%% Accepts either an event_id (builds graph from that event) or
%% a correlation_id (builds graph from all correlated events).
%% Returns nodes and edges suitable for graph rendering.
-spec build_graph(atom(), binary()) -> {ok, causation_graph()} | {error, term()}.
build_graph(StoreId, Id) ->
%% Try as event_id first, then as correlation_id
case find_event_by_id(StoreId, Id) of
{ok, RootEvent} ->
build_graph_from_event(StoreId, RootEvent);
{error, not_found} ->
%% Try as correlation_id
case get_correlated(StoreId, Id) of
{ok, Events} when Events =/= [] ->
build_graph_from_correlation(Events);
{ok, []} ->
{error, not_found};
Error ->
Error
end
end.
%% @doc Export a causation graph as DOT format for Graphviz.
%%
%% Usage: dot -Tpng -o graph.png with the output.
-spec to_dot(causation_graph()) -> binary().
to_dot(#{nodes := Nodes, edges := Edges}) ->
Header = <<"digraph causation {\n">>,
Indent = <<" ">>,
%% Node definitions
NodeLines = lists:map(
fun(Event) ->
Id = Event#event.event_id,
Type = Event#event.event_type,
Label = io_lib:format("~s [label=\"~s\\n~s\"];",
[escape_dot(Id), escape_dot(Id), escape_dot(Type)]),
[Indent, list_to_binary(Label), <<"\n">>]
end,
Nodes
),
%% Edge definitions
EdgeLines = lists:map(
fun({From, To}) ->
Line = io_lib:format("~s -> ~s;", [escape_dot(From), escape_dot(To)]),
[Indent, list_to_binary(Line), <<"\n">>]
end,
Edges
),
Footer = <<"}\n">>,
iolist_to_binary([Header, NodeLines, EdgeLines, Footer]).
%%====================================================================
%% Internal functions
%%====================================================================
%% @private Scan all streams for events matching metadata field
-spec scan_for_metadata(atom(), atom(), binary()) -> {ok, [event()]} | {error, term()}.
scan_for_metadata(StoreId, Field, Value) ->
%% Get all streams
case reckon_db_streams:list_streams(StoreId) of
{ok, StreamIds} ->
Events = lists:foldl(
fun(StreamId, Acc) ->
MatchingEvents = scan_stream_for_metadata(StoreId, StreamId, Field, Value),
Acc ++ MatchingEvents
end,
[],
StreamIds
),
%% Sort by epoch_us for consistent ordering
Sorted = lists:sort(
fun(A, B) -> A#event.epoch_us =< B#event.epoch_us end,
Events
),
{ok, Sorted};
Error ->
Error
end.
%% @private Scan a single stream for matching events
-spec scan_stream_for_metadata(atom(), binary(), atom(), binary()) -> [event()].
scan_stream_for_metadata(StoreId, StreamId, Field, Value) ->
case reckon_db_streams:read(StoreId, StreamId, 0, 10000, forward) of
{ok, Events} ->
lists:filter(
fun(Event) ->
case maps:get(Field, Event#event.metadata, undefined) of
Value -> true;
_ -> false
end
end,
Events
);
{error, _} ->
[]
end.
%% @private Find an event by its ID across all streams
-spec find_event_by_id(atom(), binary()) -> {ok, event()} | {error, not_found | term()}.
find_event_by_id(StoreId, EventId) ->
case reckon_db_streams:list_streams(StoreId) of
{ok, StreamIds} ->
find_event_in_streams(StoreId, StreamIds, EventId);
Error ->
Error
end.
%% @private Search streams for an event
-spec find_event_in_streams(atom(), [binary()], binary()) -> {ok, event()} | {error, not_found}.
find_event_in_streams(_StoreId, [], _EventId) ->
{error, not_found};
find_event_in_streams(StoreId, [StreamId | Rest], EventId) ->
case scan_stream_for_event(StoreId, StreamId, EventId) of
{ok, Event} ->
{ok, Event};
not_found ->
find_event_in_streams(StoreId, Rest, EventId)
end.
%% @private Scan a stream for an event by ID
-spec scan_stream_for_event(atom(), binary(), binary()) -> {ok, event()} | not_found.
scan_stream_for_event(StoreId, StreamId, EventId) ->
case reckon_db_streams:read(StoreId, StreamId, 0, 10000, forward) of
{ok, Events} ->
case lists:search(fun(E) -> E#event.event_id =:= EventId end, Events) of
{value, Event} -> {ok, Event};
false -> not_found
end;
{error, _} ->
not_found
end.
%% @private Build causation chain by walking backward
-spec build_chain_backward(atom(), event(), [event()]) -> [event()].
build_chain_backward(StoreId, Event, Acc) ->
case maps:get(causation_id, Event#event.metadata, undefined) of
undefined ->
Acc;
CausationId ->
case find_event_by_id(StoreId, CausationId) of
{ok, CauseEvent} ->
build_chain_backward(StoreId, CauseEvent, [CauseEvent | Acc]);
{error, _} ->
Acc
end
end.
%% @private Build graph starting from a single event
-spec build_graph_from_event(atom(), event()) -> {ok, causation_graph()}.
build_graph_from_event(StoreId, RootEvent) ->
%% First, get the full chain to find the true root
{ok, Chain} = get_chain(StoreId, RootEvent#event.event_id),
TrueRoot = hd(Chain),
%% Then, build graph by finding all effects recursively
{AllNodes, AllEdges} = collect_effects(StoreId, TrueRoot, [TrueRoot], []),
{ok, #{
nodes => AllNodes,
edges => AllEdges,
root => TrueRoot#event.event_id
}}.
%% @private Build graph from correlated events
-spec build_graph_from_correlation([event()]) -> {ok, causation_graph()}.
build_graph_from_correlation(Events) ->
%% Build edges from causation_id relationships
EventMap = maps:from_list([{E#event.event_id, E} || E <- Events]),
Edges = lists:filtermap(
fun(Event) ->
case maps:get(causation_id, Event#event.metadata, undefined) of
undefined ->
false;
CausationId ->
case maps:is_key(CausationId, EventMap) of
true -> {true, {CausationId, Event#event.event_id}};
false -> false
end
end
end,
Events
),
%% Find roots (events with no incoming edges)
AllTargets = [To || {_, To} <- Edges],
Roots = [E || E <- Events, not lists:member(E#event.event_id, AllTargets)],
RootId = case Roots of
[First | _] -> First#event.event_id;
[] -> undefined
end,
{ok, #{
nodes => Events,
edges => Edges,
root => RootId
}}.
%% @private Recursively collect effects
-spec collect_effects(atom(), event(), [event()], [{binary(), binary()}]) ->
{[event()], [{binary(), binary()}]}.
collect_effects(StoreId, Event, AccNodes, AccEdges) ->
EventId = Event#event.event_id,
case get_effects(StoreId, EventId) of
{ok, Effects} ->
%% Add edges from this event to its effects
NewEdges = [{EventId, E#event.event_id} || E <- Effects],
%% Filter out already visited events to prevent cycles
VisitedIds = [E#event.event_id || E <- AccNodes],
NewEffects = [E || E <- Effects,
not lists:member(E#event.event_id, VisitedIds)],
%% Recursively collect from new effects
lists:foldl(
fun(EffectEvent, {Nodes, Edges}) ->
collect_effects(StoreId, EffectEvent,
[EffectEvent | Nodes],
NewEdges ++ Edges)
end,
{AccNodes, AccEdges ++ NewEdges},
NewEffects
);
{error, _} ->
{AccNodes, AccEdges}
end.
%% @private Escape string for DOT format
-spec escape_dot(binary()) -> binary().
escape_dot(Bin) ->
%% Replace quotes and backslashes
Escaped = binary:replace(Bin, <<"\\">>, <<"\\\\">>, [global]),
binary:replace(Escaped, <<"\"">>, <<"\\\"">>, [global]).
%% @private Emit telemetry for causation queries
-spec emit_telemetry(atom(), binary(), atom(), integer(), term()) -> ok.
emit_telemetry(StoreId, Id, QueryType, Duration, Result) ->
EventCount = case Result of
{ok, Events} when is_list(Events) -> length(Events);
{ok, _} -> 1;
{error, _} -> 0
end,
telemetry:execute(
[reckon_db, causation, query],
#{duration => Duration, event_count => EventCount},
#{store_id => StoreId, id => Id, query_type => QueryType}
),
ok.