Packages

Erlang document database with MVCC, queries, and replication

Current section

Files

Jump to
barrel_docdb src barrel_timeline.erl
Raw

src/barrel_timeline.erl

%%%-------------------------------------------------------------------
%%% @doc Timeline: branch a database, work on the branch, merge back.
%%%
%%% A branch is an instant fork: both RocksDB stores (docs and
%%% attachments) are checkpointed into the branch's directory (hard
%%% links, O(1) in data size, copy-on-write at the file level), and
%%% the branch opens as a normal database whose storage keys keep the
%%% parent's name (keyspace indirection, see barrel_keyspace). The
%%% fork instant is minted inside the parent's writer, so the
%%% checkpoint is exactly "everything up to fork_hlc".
%%%
%%% The two stores are checkpointed back to back, not atomically:
%%% attachment writes do not serialize through the writer, so an
%%% in-flight attachment may straddle the fork. Attachments are LWW
%%% state with origin guards and sync out of band, the same tolerance
%%% replication has.
%%%
%%% v1 lineage is linear: branching a branch is rejected, which keeps
%%% barrel_keyspace:resolve/1 single-level.
%%% @end
%%%-------------------------------------------------------------------
-module(barrel_timeline).
-include("barrel_docdb.hrl").
-export([branch_db/3, list_branches/1, merge_branch/2]).
-define(MERGE_CKPT_DOC, <<"_timeline/merge">>).
%%====================================================================
%% Branch
%%====================================================================
%% @doc The OPEN branches of a database, from the registry (a parent
%% tracks nothing; on-disk-but-closed branches are not listed).
-spec list_branches(binary()) -> [binary()].
list_branches(Parent) ->
lists:filter(
fun(Name) ->
case barrel_docdb:db_info(Name) of
{ok, #{parent := Parent}} -> true;
_ -> false
end
end,
barrel_docdb:list_dbs()).
%% @doc Fork a database. `Opts':
%% - `at => now' (default): the branch starts at the fork instant.
%% - `data_dir => Dir': base directory for the branch (defaults to the
%% parent's base, i.e. the branch lives next to its parent).
%% Every other option is merged over the inherited parent config for
%% the branch's FIRST open (channels are always inherited: the copied
%% feed rows are keyspace-scoped and a mismatched config would orphan
%% them). Reopening the branch later follows the normal create_db
%% contract: config is runtime-only and must be supplied again.
-spec branch_db(binary(), binary(), map()) ->
{ok, pid()} | {error, term()}.
branch_db(Parent, BranchName, Opts) when is_binary(Parent),
is_binary(BranchName),
is_map(Opts) ->
case barrel_docdb:validate_db_name(BranchName) of
ok ->
case barrel_docdb:open_db(BranchName) of
{ok, _} ->
{error, already_exists};
{error, not_found} ->
case barrel_docdb:open_db(Parent) of
{ok, ParentPid} ->
do_branch(Parent, ParentPid, BranchName, Opts);
{error, not_found} ->
{error, parent_not_found}
end
end;
{error, _} = Err ->
Err
end.
do_branch(Parent, ParentPid, BranchName, Opts) ->
{ok, PInfo} = barrel_db_server:info(ParentPid),
case maps:get(keyspace, PInfo) of
Parent ->
BaseDir = case maps:get(data_dir, Opts, undefined) of
undefined ->
%% next to the parent: db_path is data_dir/Parent
filename:dirname(maps:get(db_path, PInfo));
Dir ->
Dir
end,
BranchPath = filename:join(BaseDir,
binary_to_list(BranchName)),
case filelib:is_file(BranchPath) of
true ->
{error, branch_dir_exists};
false ->
fork(Parent, ParentPid, PInfo, BranchName,
BranchPath, Opts)
end;
_OtherKeyspace ->
%% v1 invariant: linear lineage only
{error, cannot_branch_a_branch}
end.
fork(Parent, ParentPid, PInfo, BranchName, BranchPath, Opts) ->
ok = filelib:ensure_path(BranchPath),
DocsPath = filename:join(BranchPath, "docs"),
AttPath = filename:join(BranchPath, "attachments"),
try
{ok, ForkHlc} =
barrel_db_server:checkpoint_to(ParentPid, DocsPath, AttPath),
ok = barrel_keyspace:write_meta(BranchPath, #{
keyspace => Parent,
parent => Parent,
fork_hlc => barrel_hlc:encode(ForkHlc)
}),
%% an encrypted parent's checkpoint is ciphertext under the
%% parent's key: carry the key-check marker so the branch's
%% open verifies against the same key (same keyspace)
ok = copy_crypto_marker(maps:get(db_path, PInfo), BranchPath),
case maps:get(at, Opts, now) of
now -> ok;
T when is_tuple(T) ->
ok = rewind_branch(DocsPath, PInfo, ForkHlc, T);
BadAt ->
throw({error, {bad_at, BadAt}})
end,
BranchConfig = branch_config(PInfo, BranchPath, Opts),
case barrel_docdb:create_db(BranchName, BranchConfig) of
{ok, Pid} -> {ok, Pid};
{error, _} = StartErr -> throw(StartErr)
end
catch
throw:{error, _} = Thrown ->
_ = file:del_dir_r(BranchPath),
Thrown;
Class:Reason:Stack ->
_ = file:del_dir_r(BranchPath),
erlang:raise(Class, Reason, Stack)
end.
copy_crypto_marker(ParentPath, BranchPath) ->
Src = filename:join(ParentPath, "CRYPTO"),
case filelib:is_regular(Src) of
true ->
{ok, _} = file:copy(Src, filename:join(BranchPath, "CRYPTO")),
ok;
false ->
ok
end.
%% The branch's first open inherits the parent's config (including its
%% encryption spec: the checkpoint is ciphertext under the parent's
%% key); channels are forced from the parent, the data_dir points at
%% the branch's base, and caller options are merged on top of the rest.
branch_config(PInfo, BranchPath, Opts) ->
ParentConfig = maps:get(config, PInfo, #{}),
CallerOpts = maps:without([at, data_dir], Opts),
Merged = maps:merge(ParentConfig, CallerOpts),
WithChannels = case maps:find(channels, ParentConfig) of
{ok, Channels} -> Merged#{channels => Channels};
error -> maps:remove(channels, Merged)
end,
WithChannels#{data_dir => filename:dirname(BranchPath)}.
%%====================================================================
%% Merge
%%====================================================================
%% @doc Ship the branch's edits to its parent: one-shot replication
%% over the local transports starting at the fork instant (exclusive),
%% then the attachment phase from the same boundary. Conflicts resolve
%% on the parent through its configured machinery (deterministic LWW,
%% conflict_merger when set); redelivery is a no-op, and a merge
%% checkpoint (a local doc on the branch) makes repeated merges
%% incremental. A PITR rewind is NOT merged: restored versions sit at
%% or before the fork and the parent's vectors already contain them.
%%
%% Opts: batch_size (default 100), attachments (default true),
%% att_batch_size. Limitations (documented): the parent's
%% conflict_merger is db-open-time config, there is no per-merge
%% override; the report carries replication stats, not per-doc
%% conflict detail. Merge at least once per retention window: branch
%% retention can forget an expired tombstone, and a forgotten delete
%% never ships.
-spec merge_branch(binary(), map()) -> {ok, map()} | {error, term()}.
merge_branch(Branch, Opts) when is_binary(Branch), is_map(Opts) ->
case barrel_docdb:db_info(Branch) of
{ok, #{parent := Parent, fork_hlc := ForkHlc}} ->
case barrel_docdb:open_db(Parent) of
{ok, _} ->
do_merge(Branch, Parent, ForkHlc, Opts);
{error, not_found} ->
{error, parent_not_found}
end;
{ok, _} ->
{error, not_a_branch};
{error, _} = Err ->
Err
end.
do_merge(Branch, Parent, ForkHlc, Opts) ->
Since = max_seq(ForkHlc, merge_ckpt(Branch, <<"last_merged">>)),
DocFun = fun(Seq) ->
update_merge_ckpt(Branch, <<"last_merged">>, Seq)
end,
case barrel_rep_alg:replicate_batch(
Branch, Parent,
barrel_rep_transport_local, barrel_rep_transport_local,
#{since => Since,
batch_size => maps:get(batch_size, Opts, 100),
checkpoint_fun => DocFun}) of
{ok, Stats} ->
case merge_attachments(Branch, Parent, ForkHlc, Opts) of
{error, AttErr} ->
{error, {att_sync_failed, AttErr}};
AttResult ->
{ok, Stats#{
att_sync => AttResult,
last_merged =>
merge_ckpt(Branch, <<"last_merged">>)
}}
end;
{error, _} = Err ->
Err
end.
merge_attachments(Branch, Parent, ForkHlc, Opts) ->
case maps:get(attachments, Opts, true) of
false ->
disabled;
true ->
AttSince = max_seq(ForkHlc,
merge_ckpt(Branch, <<"last_att_merged">>)),
AttFun = fun(Seq) ->
update_merge_ckpt(Branch, <<"last_att_merged">>, Seq)
end,
case barrel_rep_att:sync_from(
Branch, Parent,
barrel_rep_transport_local,
barrel_rep_transport_local,
AttSince, AttFun, Opts) of
{ok, AttStats} -> AttStats;
skipped -> skipped;
{error, _} = Err -> Err
end
end.
merge_ckpt(Branch, Field) ->
case barrel_docdb:get_local_doc(Branch, ?MERGE_CKPT_DOC) of
{ok, Doc} ->
case maps:get(Field, Doc, undefined) of
undefined -> first;
Bin -> barrel_rep_checkpoint:decode_seq(Bin)
end;
{error, not_found} ->
first
end.
update_merge_ckpt(Branch, Field, Seq) ->
Doc = case barrel_docdb:get_local_doc(Branch, ?MERGE_CKPT_DOC) of
{ok, D} -> D;
{error, not_found} -> #{}
end,
barrel_docdb:put_local_doc(
Branch, ?MERGE_CKPT_DOC,
Doc#{Field => barrel_rep_checkpoint:encode_seq(Seq)}).
max_seq(Hlc, first) ->
Hlc;
max_seq(Hlc, Other) ->
case barrel_hlc:less(Hlc, Other) of
true -> Other;
false -> Hlc
end.
%%====================================================================
%% PITR rewind (at => HlcT)
%%====================================================================
%%
%% Runs on the branch's directly-opened store BEFORE the branch is
%% registered: no persistent_term context exists, so everything takes
%% explicit arguments (keyspace, compiled channels) and the store is
%% opened without the compaction filter (its absence drops nothing).
%% Any error aborts the fork, which deletes the branch directory.
%%
%% Semantics: every doc changed after T is restored to its latest
%% retained state at or before T (linear history; concurrent windows
%% are refined by the conflict-window pass), and docs created after T
%% are forgotten entirely. Restored docs keep their ORIGINAL change
%% HLCs, so a later merge ships only branch edits. Embedding columns
%% are dropped (derived data; archived bodies do not carry them).
%% Attachments keep fork-time state (LWW with a feed, no history).
-define(REWIND_BATCH, 200).
rewind_branch(DocsPath, PInfo, ForkHlc, T) ->
case barrel_hlc:less(ForkHlc, T) of
true ->
throw({error, {bad_at, future}});
false ->
ok
end,
case maps:get(history_floor, PInfo, undefined) of
undefined -> ok;
Floor ->
case barrel_hlc:less(T, Floor) of
true -> throw({error, pitr_window_exceeded});
false -> ok
end
end,
Ks = maps:get(keyspace, PInfo),
Config = maps:get(config, PInfo, #{}),
{ok, Compiled} = barrel_channel:compile(
maps:get(channels, Config, #{})),
StoreOpts0 = maps:get(store_opts, Config, #{}),
%% the checkpoint is ciphertext under the parent's key: this direct
%% open resolves it explicitly from the parent's spec + keyspace
%% (no db_server involved). The env handle stays referenced through
%% StoreOpts for the whole rewind.
StoreOpts = case barrel_keyprovider:key_for_db(
maps:get(encryption, Config, disabled), Ks) of
{ok, plaintext} ->
StoreOpts0;
{ok, Key} ->
{ok, Env} = rocksdb:new_env({encrypted, Key}),
StoreOpts0#{env => Env};
{error, KeyErr} ->
throw({error, {encryption_key_error, KeyErr}})
end,
{ok, StoreRef} = barrel_store_rocksdb:open(DocsPath, StoreOpts),
try
rewind(StoreRef, Ks, Compiled, T)
after
barrel_store_rocksdb:close(StoreRef)
end.
rewind(StoreRef, Ks, Compiled, T) ->
{PostT, PostTHistDels} = collect_post_t(StoreRef, Ks, T),
case maps:size(PostT) of
0 ->
ok;
_ ->
Targets = resolve_targets(StoreRef, Ks, T, PostT),
rewind_docs(StoreRef, Ks, Compiled, maps:to_list(Targets)),
GlobalOps = PostTHistDels
++ outbox_rewind_ops(StoreRef, Ks, T)
++ bucket_drop_ops(StoreRef, Ks),
ok = barrel_store_rocksdb:write_batch(StoreRef, GlobalOps,
#{}),
ok = rewrite_last_hlc(StoreRef, Ks)
end.
%% Pass A: post-T history entries per doc (ascending fold; `from' is
%% inclusive, so entries AT T are skipped: they belong to the kept
%% state), plus the delete ops for the post-T history rows.
collect_post_t(StoreRef, Ks, T) ->
barrel_history:fold(
StoreRef, Ks,
fun(#{hlc := Hlc} = Entry, {Map, Dels} = Acc) ->
case barrel_hlc:equal(Hlc, T) of
true ->
{ok, Acc};
false ->
Id = maps:get(id, Entry),
Map2 = maps:update_with(
Id, fun(Es) -> [Entry | Es] end, [Entry], Map),
Del = {delete,
barrel_store_keys:history_key(Ks, Hlc)},
{ok, {Map2, [Del | Dels]}}
end
end,
{#{}, []},
#{from => T}).
%% Pass B: one reverse scan of history at or before T collecting
%% every retained entry of each affected doc, then per doc the
%% CONTAINMENT-MAXIMAL entries (no other entry's vector contains
%% their version) reconstruct the state at T: the deterministic LWW
%% winner among the maximals is restored as current and the other
%% maximals as live conflict siblings, exactly the shape put_version
%% would have left. Docs with no retained entry at or before T either
%% never existed at T (their created_at is after T) and are
%% forgotten, or their T-state predates retention and the fork
%% aborts.
resolve_targets(StoreRef, Ks, T, PostT) ->
Start = barrel_store_keys:history_prefix(Ks),
End = <<(barrel_store_keys:history_key(Ks, T))/binary, 0>>,
Collected = barrel_store_rocksdb:fold_range_reverse(
StoreRef, Start, End,
fun(Key, Value, Acc) ->
Hlc = barrel_store_keys:decode_history_key(Ks, Key),
Entry = (barrel_history:decode_entry(Value))#{hlc => Hlc},
Id = maps:get(id, Entry),
case maps:is_key(Id, PostT) of
true ->
{ok, maps:update_with(
Id, fun(Es) -> [Entry | Es] end, [Entry], Acc)};
false ->
{ok, Acc}
end
end,
#{}),
maps:map(
fun(Id, _) ->
case maps:get(Id, Collected, []) of
[] ->
%% No retained entry at or before T: either the
%% doc was created after T (forget) or its pre-T
%% history was swept (abort). The write-invariant
%% created_at column disambiguates; a missing one
%% aborts conservatively.
case entity_created_at(StoreRef, Ks, Id) of
undefined ->
throw({error, {pitr_window_exceeded, Id}});
CreatedAt ->
case barrel_hlc:less(T, CreatedAt) of
true ->
forget;
false ->
throw({error,
{pitr_window_exceeded, Id}})
end
end;
Entries ->
{Winner, Siblings, Superseded} =
split_window(Entries),
{restore, Winner, Siblings, Superseded}
end
end,
PostT).
entity_created_at(StoreRef, Ks, Id) ->
case barrel_store_rocksdb:get_entity(
StoreRef, barrel_store_keys:doc_entity(Ks, Id)) of
{ok, Columns} ->
case proplists:get_value(?COL_CREATED_AT, Columns, <<>>) of
<<>> -> undefined;
Bin -> barrel_hlc:decode(Bin)
end;
not_found ->
undefined
end.
%% Containment-maximal split of a doc's retained entries at or before
%% T: maximals are the concurrent heads (nothing covers them), the
%% LWW-largest version among them is the winner, the rest are live
%% conflict siblings; everything else is superseded. Linear histories
%% degrade to a single maximal.
split_window(Entries) ->
Maximal = [E || E <- Entries, is_maximal(E, Entries)],
[Winner | Siblings] = lists:sort(
fun(A, B) ->
VA = barrel_version:from_token(maps:get(version, A)),
VB = barrel_version:from_token(maps:get(version, B)),
barrel_version:max(VA, VB) =:= VA
end,
Maximal),
{Winner, Siblings, Entries -- Maximal}.
is_maximal(E, Entries) ->
V = barrel_version:from_token(maps:get(version, E)),
not lists:any(
fun(O) ->
O =/= E andalso barrel_vv:contains(maps:get(vv, O), V)
end,
Entries).
rewind_docs(_StoreRef, _Ks, _Compiled, []) ->
ok;
rewind_docs(StoreRef, Ks, Compiled, Targets) ->
{Chunk, Rest} = case length(Targets) > ?REWIND_BATCH of
true -> lists:split(?REWIND_BATCH, Targets);
false -> {Targets, []}
end,
Ops = lists:flatmap(
fun({Id, Target}) ->
doc_rewind_ops(StoreRef, Ks, Compiled, Id, Target)
end,
Chunk),
ok = barrel_store_rocksdb:write_batch(StoreRef, Ops, #{}),
rewind_docs(StoreRef, Ks, Compiled, Rest).
doc_rewind_ops(StoreRef, Ks, Compiled, Id, Target) ->
Current = read_current_state(StoreRef, Ks, Id),
CommonDels = current_row_dels(Ks, Compiled, Id, Current),
case Target of
forget ->
CommonDels
++ chain_forget_ops(StoreRef, Ks, Id)
++ [{entity_delete, barrel_store_keys:doc_entity(Ks, Id)},
{body_delete, barrel_store_keys:doc_body(Ks, Id)}];
{restore, Winner, Siblings, Superseded} ->
{SiblingOps, Kept} =
sibling_ops(StoreRef, Ks, Id, Siblings),
%% the entity's vector is the MERGE of the winner and its
%% live siblings, exactly what put_version leaves behind
%% (otherwise a redelivered sibling would double up)
MergedVV = lists:foldl(
fun(E, Acc) -> barrel_vv:merge(Acc, maps:get(vv, E)) end,
maps:get(vv, Winner),
Kept),
CommonDels
++ post_t_version_dels(Ks, Id, Winner, Current)
++ SiblingOps
++ superseded_flip_ops(Ks, Id, Superseded)
++ restore_ops(StoreRef, Ks, Compiled, Id,
Winner#{vv := MergedVV}, length(Kept))
end.
%% Live conflict siblings at T: their chain rows flip back to
%% conflict. A sibling whose archived body was dropped by the
%% conflict bound is dropped here too (the bound is deterministic
%% across replicas), chain row and all.
sibling_ops(StoreRef, Ks, Id, Siblings) ->
lists:foldr(
fun(#{version := Token, deleted := Del, vv := VV} = E,
{Ops, Kept}) ->
VEnc = barrel_version:encode(
barrel_version:from_token(Token)),
BodyKey = barrel_store_keys:doc_body_rev(Ks, Id, VEnc),
HasBody = case Del of
true -> true;
false ->
barrel_store_rocksdb:body_get(StoreRef, BodyKey)
=/= not_found
end,
case HasBody of
true ->
Row = <<1:8,
(case Del of true -> 1; false -> 0 end):8,
(barrel_vv:encode(VV))/binary>>,
Op = {put,
barrel_store_keys:doc_version(Ks, Id, VEnc),
Row},
{[Op | Ops], [E | Kept]};
false ->
Drop = [{delete,
barrel_store_keys:doc_version(Ks, Id,
VEnc)}],
{Drop ++ Ops, Kept}
end
end,
{[], []},
Siblings).
%% Non-maximal entries at or before T are superseded at T; post-T
%% resolutions may have left their chain rows in another state, so
%% rewrite them (overwriting an identical row is a no-op).
superseded_flip_ops(Ks, Id, Superseded) ->
[{put,
barrel_store_keys:doc_version(
Ks, Id,
barrel_version:encode(
barrel_version:from_token(maps:get(version, E)))),
<<0:8,
(case maps:get(deleted, E) of true -> 1; false -> 0 end):8,
(barrel_vv:encode(maps:get(vv, E)))/binary>>}
|| E <- Superseded].
%% The doc's CURRENT footprint outside the entity/body: live feed row,
%% path-index rows, channel rows, and the current-body path index.
current_row_dels(Ks, Compiled, Id, Current) ->
#{hlc := CurHlc, rev := CurRev, deleted := CurDeleted,
body := CurBody} = Current,
CurInfo0 = #{id => Id, rev => CurRev, deleted => CurDeleted},
CurInfo = case CurDeleted of
true -> CurInfo0;
false -> CurInfo0#{doc => CurBody}
end,
ArsDels = case CurDeleted of
true ->
[];
false ->
barrel_ars_index:remove_doc_ops(Ks, Id,
barrel_ars:analyze(CurBody))
end,
[{delete, barrel_store_keys:doc_hlc(Ks, CurHlc)}]
++ barrel_changes:remove_path_index_ops(Ks, CurHlc, CurInfo)
++ ArsDels
++ [{delete, barrel_store_keys:channel_key(Ks, C, CurHlc)}
|| C <- maps:keys(Compiled)].
%% Chain rows and archived bodies of versions that became current
%% after T (their history entries are post-T): the restored winner's
%% own archive also clears since it becomes current again.
post_t_version_dels(Ks, Id, #{version := RestToken}, _Current) ->
RestVEnc = barrel_version:encode(
barrel_version:from_token(RestToken)),
[{delete, barrel_store_keys:doc_version(Ks, Id, RestVEnc)},
{body_delete, barrel_store_keys:doc_body_rev(Ks, Id, RestVEnc)}].
chain_forget_ops(StoreRef, Ks, Id) ->
Start = barrel_store_keys:doc_version_prefix(Ks, Id),
End = barrel_store_keys:doc_version_end(Ks, Id),
barrel_store_rocksdb:fold_range(
StoreRef, Start, End,
fun(Key, _Value, Acc) ->
VEnc = barrel_store_keys:decode_doc_version_key(Ks, Id, Key),
{ok, [{delete, Key},
{body_delete,
barrel_store_keys:doc_body_rev(Ks, Id, VEnc)}
| Acc]}
end,
[]).
restore_ops(StoreRef, Ks, Compiled, Id, Entry, NConflicts) ->
#{hlc := RestHlc, version := RestToken, deleted := RestDeleted,
vv := RestVV} = Entry,
Current = read_current_state(StoreRef, Ks, Id),
RestVersion = barrel_version:from_token(RestToken),
RestVEnc = barrel_version:encode(RestVersion),
RestBody = restore_body(StoreRef, Ks, Id, RestVEnc, RestDeleted,
Current),
%% Entity: identity columns from the restored entry; created_at /
%% expires_at / tier carried from the current entity (created_at
%% is write-invariant, the others best effort); embedding columns
%% dropped (derived data).
Cols = [
{?COL_VERSION, RestVEnc},
{?COL_DELETED, case RestDeleted of
true -> <<"true">>;
false -> <<"false">>
end},
{?COL_HLC, barrel_hlc:encode(RestHlc)},
{?COL_VV, barrel_vv:encode(RestVV)},
{?COL_NCONFLICTS, NConflicts},
{?COL_CREATED_AT, maps:get(created_at, Current)},
{?COL_EXPIRES_AT, maps:get(expires_at, Current)},
{?COL_TIER, maps:get(tier, Current)}
],
RestInfo0 = #{id => Id, rev => RestToken, deleted => RestDeleted,
num_conflicts => NConflicts, hlc => RestHlc},
RestInfo = case RestDeleted of
true -> RestInfo0;
false -> RestInfo0#{doc => RestBody}
end,
BodyOps = case RestDeleted of
true ->
[{body_delete, barrel_store_keys:doc_body(Ks, Id)}];
false ->
[{body_put, barrel_store_keys:doc_body(Ks, Id),
barrel_docdb_codec_cbor:encode_cbor(RestBody)}]
end,
ArsOps = case RestDeleted of
true ->
[];
false ->
barrel_ars_index:index_doc_ops(Ks, Id, RestBody)
end,
ChannelOps = case RestDeleted of
true ->
%% restored tombstones get no channel rows (their at-T
%% membership is not reconstructable); documented
[];
false ->
barrel_channel:write_ops_compiled(
Compiled, Ks, RestHlc, RestInfo,
barrel_channel:topics(RestBody), undefined, [])
end,
[{entity_put, barrel_store_keys:doc_entity(Ks, Id), Cols},
{put, barrel_store_keys:doc_hlc(Ks, RestHlc),
barrel_changes:encode_change(RestInfo)}]
++ BodyOps
++ barrel_changes:write_path_index_ops(Ks, RestHlc, RestInfo)
++ ArsOps
++ ChannelOps.
%% The restored version's body: the current body when it is somehow
%% still current, else the version-keyed archive. A missing archive
%% within the window aborts the fork (conflict-bound drops can remove
%% sibling bodies early).
restore_body(_StoreRef, _Ks, _Id, _RestVEnc, true, _Current) ->
undefined;
restore_body(StoreRef, Ks, Id, RestVEnc, false, Current) ->
case maps:get(version_enc, Current) of
RestVEnc ->
maps:get(body, Current);
_ ->
Key = barrel_store_keys:doc_body_rev(Ks, Id, RestVEnc),
case barrel_store_rocksdb:body_get(StoreRef, Key) of
{ok, Cbor} ->
barrel_docdb_codec_cbor:decode_any(Cbor);
not_found ->
throw({error, {pitr_body_missing, Id}})
end
end.
read_current_state(StoreRef, Ks, Id) ->
EntityKey = barrel_store_keys:doc_entity(Ks, Id),
{ok, Columns} = barrel_store_rocksdb:get_entity(StoreRef, EntityKey),
VersionEnc = proplists:get_value(?COL_VERSION, Columns),
Deleted = proplists:get_value(?COL_DELETED, Columns, <<"false">>)
=:= <<"true">>,
Body = case Deleted of
true ->
undefined;
false ->
case barrel_store_rocksdb:body_get(
StoreRef, barrel_store_keys:doc_body(Ks, Id)) of
{ok, Cbor} -> barrel_docdb_codec_cbor:decode_any(Cbor);
not_found -> #{}
end
end,
#{version_enc => VersionEnc,
rev => barrel_version:to_token(barrel_version:decode(VersionEnc)),
hlc => barrel_hlc:decode(proplists:get_value(?COL_HLC, Columns)),
deleted => Deleted,
created_at => proplists:get_value(?COL_CREATED_AT, Columns, <<>>),
expires_at => proplists:get_value(?COL_EXPIRES_AT, Columns, 0),
tier => proplists:get_value(?COL_TIER, Columns, 0),
body => Body}.
%% Pending outbox entries minted after T are work for post-T state:
%% drop them (the tags themselves are config, not data).
outbox_rewind_ops(StoreRef, Ks, T) ->
barrel_store_rocksdb:fold_range(
StoreRef,
barrel_store_keys:outbox_db_prefix(Ks),
barrel_store_keys:outbox_db_end(Ks),
fun(Key, _Value, Acc) ->
{_Tag, Hlc} = barrel_store_keys:decode_outbox_key(Ks, Key),
case barrel_hlc:less(T, Hlc) of
true -> {ok, [{delete, Key} | Acc]};
false -> {ok, Acc}
end
end,
[]).
%% Change buckets are idle-poll hints keyed by wall-clock minute; the
%% rewind invalidates their max bounds, so drop them all (absent
%% buckets just mean the slow path answers).
bucket_drop_ops(StoreRef, Ks) ->
barrel_store_rocksdb:fold_range(
StoreRef,
barrel_store_keys:change_bucket_prefix(Ks),
barrel_store_keys:change_bucket_end(Ks),
fun(Key, _Value, Acc) -> {ok, [{delete, Key} | Acc]} end,
[]).
%% db_last_hlc must equal the newest remaining feed row.
rewrite_last_hlc(StoreRef, Ks) ->
Key = barrel_store_keys:db_last_hlc(Ks),
Last = barrel_store_rocksdb:fold_range_reverse(
StoreRef,
barrel_store_keys:doc_hlc_prefix(Ks),
barrel_store_keys:doc_hlc_end(Ks),
fun(K, _V, _Acc) ->
{stop, barrel_store_keys:decode_hlc_key(Ks, K)}
end,
none),
case Last of
none ->
barrel_store_rocksdb:delete(StoreRef, Key);
Hlc ->
barrel_store_rocksdb:put(StoreRef, Key,
barrel_hlc:encode(Hlc))
end.