Packages
reckon_db
3.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_snapshots.erl
%% @doc Snapshots API facade for reckon-db
%%
%% Provides the public API for snapshot operations:
%% - save: Save aggregate state as a snapshot
%% - load: Load the latest snapshot for a stream
%% - load_at: Load a specific snapshot version
%% - list: List all snapshots for a stream
%% - delete: Delete snapshots for a stream
%% - exists: Check if a snapshot exists
%%
%% Snapshots are used to optimize event replay by storing
%% aggregate state at specific versions.
%%
%% @author rgfaber
-module(reckon_db_snapshots).
-include("reckon_db.hrl").
-include("reckon_db_telemetry.hrl").
%% API
-export([
save/4,
save/5,
load/2,
load_at/3,
list/2,
delete/2,
delete_at/3,
exists/2,
exists_at/3
]).
%%====================================================================
%% Types
%%====================================================================
-type snapshot_data() :: map() | binary().
-type snapshot_metadata() :: map().
-export_type([snapshot_data/0, snapshot_metadata/0]).
%%====================================================================
%% API
%%====================================================================
%% @doc Save a snapshot with default empty metadata
%%
%% Parameters:
%% StoreId - The store identifier
%% StreamId - The stream this snapshot belongs to
%% Version - The event version this snapshot represents
%% Data - The aggregate state to snapshot
%%
%% Returns ok on success or {error, Reason} on failure.
-spec save(atom(), binary(), non_neg_integer(), snapshot_data()) ->
ok | {error, term()}.
save(StoreId, StreamId, Version, Data) ->
save(StoreId, StreamId, Version, Data, #{}).
%% @doc Save a snapshot with metadata
-spec save(atom(), binary(), non_neg_integer(), snapshot_data(), snapshot_metadata()) ->
ok | {error, term()}.
save(StoreId, StreamId, Version, Data, Metadata) ->
StartTime = erlang:monotonic_time(),
Snapshot0 = #snapshot{
stream_id = StreamId,
version = Version,
data = Data,
metadata = Metadata,
timestamp = erlang:system_time(millisecond)
},
%% Apply integrity if enabled on this store. For an
%% integrity-disabled store this is a pass-through; for an
%% integrity-enabled store this populates anchor_hash + mac
%% so a subsequent load can verify the snapshot AND that the
%% underlying stream's chain at this version is unchanged.
case apply_snapshot_integrity(StoreId, StreamId, Version, Snapshot0) of
{ok, Snapshot} ->
Result = reckon_db_snapshots_store:put(StoreId, Snapshot),
emit_save_telemetry(Result, StartTime, StoreId, StreamId, Version, Data),
Result;
{error, _} = Err ->
logger:warning("Refusing to save snapshot: store=~p, stream=~s, "
"version=~p, reason=~p",
[StoreId, StreamId, Version, Err]),
Err
end.
%% @doc Load the latest snapshot for a stream.
%%
%% Returns {ok, Snapshot} if found and integrity-valid.
%% Returns {error, not_found} if no snapshot exists.
%% Returns {error, {integrity_violation, _}} if the store has
%% integrity enabled and the loaded snapshot fails verification.
%% Callers (typically aggregate rebuild) should treat an integrity
%% violation as a directive to fall back to full event replay from
%% the stream's chain_start_version watermark.
-spec load(atom(), binary()) ->
{ok, snapshot()} | {error, not_found} | {error, term()}.
load(StoreId, StreamId) ->
StartTime = erlang:monotonic_time(),
Result =
case reckon_db_snapshots_store:get_latest(StoreId, StreamId) of
undefined ->
{error, not_found};
Snapshot ->
maybe_verify_snapshot_on_load(StoreId, StreamId, Snapshot)
end,
Duration = erlang:monotonic_time() - StartTime,
%% Emit telemetry for successful reads
case Result of
{ok, #snapshot{version = Version, data = Data}} ->
DataSize = estimate_size(Data),
telemetry:execute(
?SNAPSHOT_READ,
#{duration => Duration, size_bytes => DataSize},
#{store_id => StoreId, stream_id => StreamId, version => Version}
);
_ ->
ok
end,
Result.
%% @doc Load a specific snapshot version.
%%
%% Same verification semantics as load/2.
-spec load_at(atom(), binary(), non_neg_integer()) ->
{ok, snapshot()} | {error, not_found} | {error, term()}.
load_at(StoreId, StreamId, Version) ->
case reckon_db_snapshots_store:get(StoreId, StreamId, Version) of
undefined ->
{error, not_found};
Snapshot ->
maybe_verify_snapshot_on_load(StoreId, StreamId, Snapshot)
end.
%% @doc List all snapshots for a stream
-spec list(atom(), binary()) -> {ok, [snapshot()]} | {error, term()}.
list(StoreId, StreamId) ->
reckon_db_snapshots_store:list(StoreId, StreamId).
%% @doc Delete all snapshots for a stream
-spec delete(atom(), binary()) -> ok | {error, term()}.
delete(StoreId, StreamId) ->
reckon_db_snapshots_store:delete(StoreId, StreamId).
%% @doc Delete a specific snapshot version
-spec delete_at(atom(), binary(), non_neg_integer()) -> ok | {error, term()}.
delete_at(StoreId, StreamId, Version) ->
reckon_db_snapshots_store:delete(StoreId, StreamId, Version).
%% @doc Check if any snapshot exists for a stream
-spec exists(atom(), binary()) -> boolean().
exists(StoreId, StreamId) ->
reckon_db_snapshots_store:exists(StoreId, StreamId).
%% @doc Check if a specific snapshot version exists
-spec exists_at(atom(), binary(), non_neg_integer()) -> boolean().
exists_at(StoreId, StreamId, Version) ->
reckon_db_snapshots_store:exists(StoreId, StreamId, Version).
%%====================================================================
%% Internal functions
%%====================================================================
%% @private Estimate the size of data in bytes
-spec estimate_size(term()) -> non_neg_integer().
estimate_size(Data) when is_binary(Data) ->
byte_size(Data);
estimate_size(Data) when is_map(Data) ->
%% Rough estimate based on term_to_binary size
byte_size(term_to_binary(Data));
estimate_size(Data) ->
byte_size(term_to_binary(Data)).
%% @private Emit save-side telemetry, mirroring the original
%% telemetry/logging behaviour now that the save path branches on
%% integrity outcome.
emit_save_telemetry(ok, StartTime, StoreId, StreamId, Version, Data) ->
DataSize = estimate_size(Data),
Duration = erlang:monotonic_time() - StartTime,
telemetry:execute(
?SNAPSHOT_CREATED,
#{system_time => erlang:system_time(millisecond),
size_bytes => DataSize, duration => Duration},
#{store_id => StoreId, stream_id => StreamId, version => Version}
),
logger:debug("Snapshot saved: store=~p, stream=~s, version=~p",
[StoreId, StreamId, Version]);
emit_save_telemetry({error, Reason}, _StartTime, StoreId, StreamId, Version, _Data) ->
logger:warning(
"Failed to save snapshot: store=~p, stream=~s, version=~p, reason=~p",
[StoreId, StreamId, Version, Reason]).
%%====================================================================
%% Tamper-resistance — save side
%%====================================================================
%% @private Apply snapshot integrity if enabled on the store.
%%
%% For integrity-disabled stores: returns the snapshot unchanged.
%%
%% For integrity-enabled stores: computes the anchor_hash (the chain
%% hash of the event at the snapshot's version) by reading that event
%% from storage, then computes the snapshot MAC over the snapshot
%% record with both fields populated. The resulting snapshot is what
%% gets persisted.
%%
%% If integrity is enabled but the event at the snapshot version is
%% not present or not integrity-bearing, the save is refused: a
%% snapshot whose anchor cannot be established would be unverifiable
%% at load time and is strictly worse than no snapshot at all.
-spec apply_snapshot_integrity(
atom(), binary(), non_neg_integer(), snapshot()
) -> {ok, snapshot()} | {error, term()}.
apply_snapshot_integrity(StoreId, StreamId, Version, Snapshot) ->
case reckon_db_integrity_key:is_enabled(StoreId) of
false ->
{ok, Snapshot};
true ->
case compute_event_chain_hash(StoreId, StreamId, Version) of
{ok, AnchorHash} ->
Key = reckon_db_integrity_key:get(StoreId),
Snapshot1 = Snapshot#snapshot{anchor_hash = AnchorHash},
Mac = reckon_gater_integrity:compute_snapshot_mac(
Snapshot1, Key),
{ok, Snapshot1#snapshot{mac = Mac}};
{error, _} = Err ->
Err
end
end.
%%====================================================================
%% Tamper-resistance — load side
%%====================================================================
%% @private Run snapshot verification if integrity is enabled on the
%% store AND the snapshot itself carries integrity fields. Returns
%% {ok, Snapshot} unchanged in all other cases (the snapshot is
%% treated as legacy data).
maybe_verify_snapshot_on_load(StoreId, StreamId,
#snapshot{version = Version} = Snapshot) ->
case should_verify_snapshot(StoreId, Snapshot) of
false ->
{ok, Snapshot};
true ->
case compute_event_chain_hash(StoreId, StreamId, Version) of
{ok, ActualAnchor} ->
Key = reckon_db_integrity_key:get(StoreId),
case reckon_gater_integrity:verify_snapshot(
Snapshot, ActualAnchor, Key) of
ok ->
{ok, Snapshot};
{integrity_violation, _} = Violation ->
emit_load_violation_telemetry(
StoreId, StreamId, Version, Violation),
{error, Violation}
end;
{error, _} = Err ->
Err
end
end.
should_verify_snapshot(StoreId, Snapshot) ->
reckon_db_integrity_key:is_enabled(StoreId)
andalso (not reckon_gater_integrity:is_legacy_snapshot(Snapshot)).
emit_load_violation_telemetry(StoreId, StreamId, Version, _Violation) ->
telemetry:execute(
[reckon, db, snapshot, integrity, violation],
#{system_time => erlang:system_time(millisecond)},
#{store_id => StoreId, stream_id => StreamId, version => Version}
).
%%====================================================================
%% Tamper-resistance — shared helper
%%====================================================================
%% @private Read the event at (StreamId, Version) and compute its
%% chain hash. Returns {ok, Hash32} on success.
%%
%% Used by both save (to capture the anchor at snapshot time) and
%% load (to verify that the anchor still matches). Bypasses the
%% verifying read in reckon_db_streams because we want the raw
%% record - if the stream itself has been tampered, the anchor
%% comparison will catch it.
compute_event_chain_hash(StoreId, StreamId, Version) ->
PaddedVersion = pad_version_for_event(Version),
Path = [streams, StreamId, PaddedVersion],
case khepri:get(StoreId, Path) of
{ok, #event{prev_event_hash = PrevHash} = Event}
when is_binary(PrevHash) ->
{ok, reckon_gater_integrity:compute_chain_hash(Event, PrevHash)};
{ok, #event{prev_event_hash = undefined}} ->
{error, {snapshot_anchor_unavailable,
#{stream_id => StreamId,
version => Version,
reason => event_is_legacy}}};
_Other ->
{error, {snapshot_anchor_unavailable,
#{stream_id => StreamId,
version => Version,
reason => event_not_found}}}
end.
pad_version_for_event(Version) ->
VersionStr = integer_to_list(Version),
Padding = ?VERSION_PADDING - length(VersionStr),
list_to_binary(lists:duplicate(Padding, $0) ++ VersionStr).