Packages
reckon_db
4.0.0
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_dcb.erl
%%% @doc DCB conditional-append primitive.
%%%
%%% Implements append_if_no_tag_matches/4 as a khepri:transaction/2
%%% body. The transaction:
%%%
%%% 1. Verifies the tag-filter context ({context_changed, _} on
%%% conflict).
%%% 2. For integrity-enabled stores: verifies the seq counter and
%%% chain-tip still match what the caller observed when
%%% pre-stamping MACs. On mismatch aborts with
%%% {dcb_state_changed, _} and the outer loop retries with a
%%% fresh snapshot.
%%% 3. Writes events under ?DCB_STREAM_PATH ++ [SeqKey] plus one
%%% tag-index entry per tag at ?BY_TAG_PATH ++ [Tag, SeqKey].
%%% 4. Updates the seq counter (and chain-tip if integrity is
%%% enabled).
%%%
%%% The whole sequence happens inside one Ra log entry; atomic across
%%% the cluster. Either everything commits, or an abort comes back
%%% and nothing changed.
%%%
%%% == Integrity (reckon-db 3.2.0+) ==
%%%
%%% On integrity-enabled stores, DCB events carry prev_event_hash +
%%% mac like any other integrity-bearing event. Because Khepri's
%%% Horus extractor rejects code containing crypto:* calls (even on
%%% unreachable branches; extraction sees the whole function), MAC
%%% chains are pre-computed OUTSIDE the transaction. Inside the
%%% transaction we verify the chain-tip + counter are still what we
%%% saw, then write the pre-stamped records.
%%%
%%% Two retry vectors:
%%% - {context_changed, _}: tag-filter conflict. Callers (e.g.,
%%% evoq_decision_runtime) retry with fresh context.
%%% - {dcb_state_changed, _}: counter or chain-tip moved between
%%% our pre-stamp read and the transaction. The retry loop in this
%%% module handles it transparently, bounded by
%%% ?INTEGRITY_RETRY_BUDGET.
%%% @end
-module(reckon_db_dcb).
-include("reckon_db.hrl").
-include_lib("khepri/include/khepri.hrl").
-export([append_if_no_tag_matches/4]).
%% Bounded retry budget for the pre-stamp / transaction race in
%% integrity-on stores. Heavy contention escalates rather than spins.
-define(INTEGRITY_RETRY_BUDGET, 5).
%%====================================================================
%% Public API
%%====================================================================
-spec append_if_no_tag_matches(
StoreId :: atom() | binary(),
TagFilter :: reckon_gater_types:tag_filter(),
SeqCutoff :: reckon_gater_types:seq_cutoff(),
Events :: [map()]
) ->
{ok, LastSeq :: non_neg_integer()}
| {error, {context_changed, non_neg_integer()}}
| {error, no_events}
| {error, dcb_concurrent_writer_exhausted}
| {error, term()}.
append_if_no_tag_matches(_StoreId, _TagFilter, _SeqCutoff, []) ->
{error, no_events};
append_if_no_tag_matches(StoreId, TagFilter, SeqCutoff, Events)
when is_list(Events), is_integer(SeqCutoff) ->
IntegrityCtx = setup_integrity_ctx(StoreId),
try_append(StoreId, TagFilter, SeqCutoff, Events, IntegrityCtx,
?INTEGRITY_RETRY_BUDGET).
%%====================================================================
%% Outer retry loop (chain-tip / counter races on integrity-on stores)
%%====================================================================
try_append(_StoreId, _TF, _SC, _Events, _Ctx, 0) ->
{error, dcb_concurrent_writer_exhausted};
try_append(StoreId, TagFilter, SeqCutoff, Events, IntegrityCtx, Retries) ->
Now = erlang:system_time(millisecond),
EpochUs = erlang:system_time(microsecond),
Snapshot = take_snapshot(StoreId, IntegrityCtx),
{Stamped, FinalTip} = stamp_events(Events, Now, EpochUs, Snapshot),
case khepri:transaction(
StoreId,
fun() -> tx_body(TagFilter, SeqCutoff, Stamped, Snapshot, FinalTip) end) of
{ok, LastSeq} ->
{ok, LastSeq};
{error, {context_changed, _} = Reason} ->
{error, Reason};
{error, {dcb_state_changed, _}} ->
try_append(StoreId, TagFilter, SeqCutoff, Events, IntegrityCtx,
Retries - 1);
{error, _} = Error ->
Error
end.
%%====================================================================
%% Outside transaction: integrity context + state snapshot
%%====================================================================
setup_integrity_ctx(StoreId) ->
case reckon_db_integrity_key:is_enabled(StoreId) of
false -> disabled;
true -> {enabled, reckon_db_integrity_key:get(StoreId)}
end.
%% disabled snapshot:
%% For integrity-off stores. The transaction picks seqs from the
%% live counter; no pre-stamp verification needed beyond tag-filter.
%%
%% #{integrity := {enabled, Key}, ...} snapshot:
%% For integrity-on stores. Carries the seq-counter value and
%% chain-tip value that the transaction will verify haven't moved.
take_snapshot(_StoreId, disabled) ->
disabled;
take_snapshot(StoreId, {enabled, Key}) ->
ExpectedCounter =
case khepri:get(StoreId, ?DCB_SEQ_COUNTER_PATH) of
{ok, N} when is_integer(N) -> N;
_ -> undefined
end,
ExpectedTip =
case khepri:get(StoreId, ?DCB_CHAIN_TIP_PATH) of
{ok, T} when is_binary(T) -> T;
_ -> reckon_gater_integrity:genesis_prev_hash()
end,
NextSeq = case ExpectedCounter of
undefined -> 0;
N1 -> N1 + 1
end,
#{integrity => {enabled, Key},
expected_counter => ExpectedCounter,
expected_tip => ExpectedTip,
next_seq => NextSeq}.
%%====================================================================
%% Outside transaction: pre-stamp event records
%%====================================================================
%% Returns {[{Seq, #event{}}], FinalTip}.
%% - For disabled snapshots, Seq is undefined (the transaction
%% picks the seq from the live counter) and FinalTip is
%% undefined (no chain to track).
%% - For integrity-on snapshots, Seq is pre-assigned, the record
%% carries prev_event_hash + mac, and FinalTip is the chain-hash
%% of the last event.
stamp_events(EventMaps, Now, EpochUs, disabled) ->
Stamped = [{undefined, build_event_record(EM, undefined, Now, EpochUs)}
|| EM <- EventMaps],
{Stamped, undefined};
stamp_events(EventMaps, Now, EpochUs,
#{integrity := {enabled, Key},
expected_tip := Tip,
next_seq := StartSeq}) ->
stamp_chain(EventMaps, StartSeq, Now, EpochUs, Key, Tip, []).
stamp_chain([], _Seq, _Now, _EpochUs, _Key, Tip, Acc) ->
{lists:reverse(Acc), Tip};
stamp_chain([EM | Rest], Seq, Now, EpochUs, Key, Tip, Acc) ->
Record0 = build_event_record(EM, Seq, Now, EpochUs),
Record1 = Record0#event{prev_event_hash = Tip},
Mac = reckon_gater_integrity:compute_event_mac(Record1, Key),
Record2 = Record1#event{mac = Mac},
NextTip = reckon_gater_integrity:compute_chain_hash(Record2, Tip),
stamp_chain(Rest, Seq + 1, Now, EpochUs, Key, NextTip,
[{Seq, Record2} | Acc]).
%%====================================================================
%% Transaction body — strictly Horus-extractable
%%====================================================================
%% NO crypto, NO try/catch, NO message passing. Pre-stamped records
%% arrive ready to write; we verify state pre-conditions and commit.
%% Integrity-OFF path. The transaction picks seqs from the live counter.
tx_body(TagFilter, SeqCutoff, Stamped, disabled, _FinalTip) ->
case reckon_db_dcb_filter:match_any_above_cutoff(TagFilter, SeqCutoff) of
{true, MaxSeq} ->
khepri_tx:abort({context_changed, MaxSeq});
false ->
BaseSeq = next_base_seq_in_tx(),
LastSeq = write_off(Stamped, BaseSeq),
ok = khepri_tx:put(?DCB_SEQ_COUNTER_PATH, LastSeq),
LastSeq
end;
%% Integrity-ON path. Verify counter + chain-tip match snapshot, then
%% write pre-stamped events at their pre-assigned seqs.
tx_body(TagFilter, SeqCutoff, Stamped,
#{expected_counter := ExpectedCounter,
expected_tip := ExpectedTip,
next_seq := StartSeq},
FinalTip) ->
case verify_counter(ExpectedCounter) of
{error, Actual} ->
khepri_tx:abort({dcb_state_changed, {counter, Actual}});
ok ->
case verify_tip(ExpectedTip) of
{error, Actual} ->
khepri_tx:abort({dcb_state_changed, {tip, Actual}});
ok ->
case reckon_db_dcb_filter:match_any_above_cutoff(
TagFilter, SeqCutoff) of
{true, MaxSeq} ->
khepri_tx:abort({context_changed, MaxSeq});
false ->
LastSeq = write_on(Stamped, StartSeq),
ok = khepri_tx:put(?DCB_SEQ_COUNTER_PATH, LastSeq),
ok = khepri_tx:put(?DCB_CHAIN_TIP_PATH, FinalTip),
LastSeq
end
end
end.
%% Integrity-off write: live counter assigns seqs, stamp the seq into
%% the record and write at the right path.
write_off([], LastSeq) ->
LastSeq;
write_off([{undefined, Record0} | Rest], Seq) ->
Record = Record0#event{version = Seq},
write_one_record(Record, Seq),
case Rest of
[] -> Seq;
_ -> write_off(Rest, Seq + 1)
end.
%% Integrity-on write: records already carry the right version (Seq).
write_on([], LastSeq) ->
LastSeq;
write_on([{Seq, Record} | Rest], ExpectedSeq) ->
%% Pre-assigned seq must equal the expected position in the chain.
%% If these diverge, our snapshot was inconsistent — but the
%% counter+tip verification already prevents that.
case Seq =:= ExpectedSeq of
true ->
write_one_record(Record, Seq),
case Rest of
[] -> Seq;
_ -> write_on(Rest, ExpectedSeq + 1)
end;
false ->
khepri_tx:abort({dcb_state_changed, {seq_skew, Seq, ExpectedSeq}})
end.
write_one_record(#event{tags = Tags} = Record, Seq) ->
ok = khepri_tx:put(reckon_db_dcb_paths:event_path(Seq), Record),
TagList = case Tags of
undefined -> [];
L when is_list(L) -> L
end,
lists:foreach(
fun(Tag) when is_binary(Tag) ->
ok = khepri_tx:put(reckon_db_dcb_paths:by_tag_path(Tag, Seq), #{})
end,
TagList),
ok.
verify_counter(ExpectedCounter) ->
Actual = case khepri_tx:get(?DCB_SEQ_COUNTER_PATH) of
{ok, N} when is_integer(N) -> N;
_ -> undefined
end,
case Actual =:= ExpectedCounter of
true -> ok;
false -> {error, Actual}
end.
verify_tip(ExpectedTip) ->
Actual = case khepri_tx:get(?DCB_CHAIN_TIP_PATH) of
{ok, T} when is_binary(T) -> T;
%% Absent path means "no integrity-bearing DCB events
%% yet"; the genesis hash IS what we expected if the
%% snapshot also saw none.
_ -> reckon_gater_integrity:genesis_prev_hash()
end,
case Actual =:= ExpectedTip of
true -> ok;
false -> {error, Actual}
end.
next_base_seq_in_tx() ->
case khepri_tx:get(?DCB_SEQ_COUNTER_PATH) of
{ok, LastAssigned} when is_integer(LastAssigned), LastAssigned >= 0 ->
LastAssigned + 1;
_ ->
0
end.
%%====================================================================
%% Event record builder
%%====================================================================
%% Builds a #event{} record. Seq may be undefined for the
%% integrity-off path (the transaction stamps version at write time);
%% otherwise it's the pre-assigned global DCB seq.
build_event_record(EventMap, Seq, Now, EpochUs) ->
EventId = maps:get(event_id, EventMap, generate_event_id(Seq, EpochUs)),
EventType = maps:get(event_type, EventMap),
Data = maps:get(data, EventMap),
Metadata = maps:get(metadata, EventMap, #{}),
Tags = case maps:get(tags, EventMap, undefined) of
undefined -> undefined;
[] -> undefined;
L when is_list(L) -> L
end,
#event{
event_id = EventId,
event_type = EventType,
stream_id = ?DCB_STREAM,
version = case Seq of undefined -> 0; _ -> Seq end,
data = Data,
metadata = Metadata,
tags = Tags,
timestamp = Now,
epoch_us = EpochUs
%% prev_event_hash + mac stamped later by stamp_chain/7 on
%% integrity-on stores; default undefined otherwise.
}.
generate_event_id(undefined, EpochUs) ->
iolist_to_binary(io_lib:format("dcb-~p-pre", [EpochUs]));
generate_event_id(Seq, EpochUs) ->
iolist_to_binary(io_lib:format("dcb-~p-~p", [EpochUs, Seq])).