Packages

Erlang document database with MVCC, queries, and replication

Current section

Files

Jump to
barrel_docdb src barrel_docdb_reader.erl
Raw

src/barrel_docdb_reader.erl

%%%-------------------------------------------------------------------
%%% @doc Caller-side point reads over the document store.
%%%
%%% Point reads (`get_doc', `get_docs') need nothing from the database
%%% server state except the store ref and the database name, so they can
%%% run in the caller's process against a RocksDB snapshot instead of
%%% serializing behind the writer. The snapshot keeps the entity and body
%%% reads consistent with each other while a concurrent write batch
%%% commits.
%%%
%%% Callers resolve the store ref via `persistent_term' (the
%%% `{barrel_store, DbName}' registry published by `barrel_db_server';
%%% see `barrel_doc_body_store' for the same pattern). The server's own
%%% `get_doc'/`get_docs' handlers delegate here too, so there is a single
%%% read implementation.
%%% @end
%%%-------------------------------------------------------------------
-module(barrel_docdb_reader).
-include("barrel_docdb.hrl").
-export([get_doc/4, get_docs/4]).
-export([expired/1]).
-export([get_replication_doc/3]).
%% @doc Get a document by id, reading entity and body under one snapshot.
%%
%% Same options and results as `barrel_docdb:get_doc/3'. Returns
%% `{error, not_found}' if the store is closed underneath the caller (the
%% close race maps to the same result as a missing database).
-spec get_doc(barrel_store_rocksdb:db_ref(), db_name(), docid(), map()) ->
{ok, map()} | {ok, binary(), map()} | {error, term()}.
get_doc(StoreRef, DbName0, DocId, Opts) ->
DbName = barrel_keyspace:resolve(DbName0),
with_snapshot(StoreRef, fun(Snapshot) ->
do_get_doc(StoreRef, DbName, DocId, Opts, Snapshot)
end).
%% @doc Get multiple documents by id under one snapshot (batch read).
%%
%% Same options and results as `barrel_docdb:get_docs/3': one result per
%% input id, in order.
-spec get_docs(barrel_store_rocksdb:db_ref(), db_name(), [docid()], map()) ->
[{ok, map()} | {error, term()}].
get_docs(StoreRef, DbName0, DocIds, Opts) ->
DbName = barrel_keyspace:resolve(DbName0),
case with_snapshot(StoreRef, fun(Snapshot) ->
do_get_docs(StoreRef, DbName, DocIds, Opts, Snapshot)
end) of
{error, _} = Err -> [Err || _ <- DocIds];
Results -> Results
end.
%% @doc Read a document for the replication protocol: body (tombstones
%% included), version token, encoded version vector, and deleted flag,
%% all under one snapshot.
-spec get_replication_doc(barrel_store_rocksdb:db_ref(), db_name(), docid()) ->
{ok, #{doc := map(), version := binary(), vv := binary(),
deleted := boolean()}} | {error, term()}.
get_replication_doc(StoreRef, DbName0, DocId) ->
DbName = barrel_keyspace:resolve(DbName0),
with_snapshot(StoreRef, fun(Snapshot) ->
DocEntityKey = barrel_store_keys:doc_entity(DbName, DocId),
case barrel_store_rocksdb:get_entity_with_snapshot(StoreRef, DocEntityKey, Snapshot) of
{ok, Columns} ->
Token = barrel_version:to_token(
barrel_version:decode(
proplists:get_value(?COL_VERSION, Columns))),
VVBin = proplists:get_value(?COL_VV, Columns, <<>>),
Deleted = bin_to_deleted(
proplists:get_value(?COL_DELETED, Columns, <<"false">>)),
BodyKey = barrel_store_keys:doc_body(DbName, DocId),
Body = case barrel_store_rocksdb:body_get_with_snapshot(
StoreRef, BodyKey, Snapshot) of
{ok, CborBin} -> barrel_docdb_codec_cbor:decode_any(CborBin);
not_found -> #{}
end,
{ok, #{doc => Body#{<<"id">> => DocId},
version => Token, vv => VVBin, deleted => Deleted}};
not_found ->
{error, not_found}
end
end).
%%====================================================================
%% Internal
%%====================================================================
%% @private Run a read under a snapshot, always releasing it. A closed
%% store raises badarg from the NIF; map it to the missing-database error.
with_snapshot(StoreRef, Fun) ->
try barrel_store_rocksdb:snapshot(StoreRef) of
{ok, Snapshot} ->
try
Fun(Snapshot)
after
barrel_store_rocksdb:safe_release_snapshot(Snapshot)
end;
{error, _} = Err ->
Err
catch
error:badarg ->
{error, not_found}
end.
%% @private Get a document by id (wide column entity + body CF).
do_get_doc(StoreRef, DbName, DocId, Opts, Snapshot) ->
DocEntityKey = barrel_store_keys:doc_entity(DbName, DocId),
case barrel_store_rocksdb:get_entity_with_snapshot(StoreRef, DocEntityKey, Snapshot) of
{ok, Columns0} ->
case expired(Columns0) of
true -> {error, not_found};
false -> do_get_doc_live(StoreRef, DbName, DocId, Opts,
Snapshot, Columns0)
end;
not_found ->
{error, not_found}
end.
do_get_doc_live(StoreRef, DbName, DocId, Opts, Snapshot, Columns) ->
%% The winner is stored at write time; its token is the API rev
Rev = barrel_version:to_token(
barrel_version:decode(
proplists:get_value(?COL_VERSION, Columns))),
Deleted = bin_to_deleted(proplists:get_value(?COL_DELETED, Columns, <<"false">>)),
IncludeDeleted = maps:get(include_deleted, Opts, false),
case {Deleted, IncludeDeleted} of
{true, false} ->
{error, not_found};
_ ->
%% Get document body from body CF (current body, no rev in key)
BodyKey = barrel_store_keys:doc_body(DbName, DocId),
case barrel_store_rocksdb:body_get_with_snapshot(StoreRef, BodyKey, Snapshot) of
{ok, CborBin} ->
%% Check if raw body requested (for zero-copy CBOR responses)
case maps:get(raw_body, Opts, false) of
true ->
%% Return raw CBOR body with metadata for zero-copy
Meta = #{
id => DocId,
rev => Rev,
deleted => Deleted
},
Meta2 = maybe_add_conflicts_to_meta(
Meta, Columns, Opts,
{StoreRef, DbName, DocId, Snapshot}),
{ok, CborBin, Meta2};
false ->
%% Decode CBOR to get document body
DocBody = barrel_docdb_codec_cbor:decode_any(CborBin),
%% Add metadata
Result = DocBody#{
<<"id">> => DocId,
<<"_rev">> => Rev
},
Result2 = case Deleted of
true -> Result#{<<"_deleted">> => true};
false -> Result
end,
%% Add conflicts if requested
Result3 = maybe_add_conflicts(
Result2, Columns, Opts,
{StoreRef, DbName, DocId, Snapshot}),
%% Attach the embedding column if requested
Result4 = maybe_add_embedding(Result3, Columns, Opts),
{ok, Result4}
end;
not_found ->
{error, not_found}
end
end.
%% @private Get multiple documents by id (batch entity + body fetch).
do_get_docs(StoreRef, DbName, DocIds, Opts, Snapshot) ->
IncludeDeleted = maps:get(include_deleted, Opts, false),
%% Build keys for both entity and body (no rev needed for body)
DocEntityKeys = [barrel_store_keys:doc_entity(DbName, DocId) || DocId <- DocIds],
BodyKeys = [barrel_store_keys:doc_body(DbName, DocId) || DocId <- DocIds],
%% Batch fetch entities and bodies under the same snapshot
DocEntityResults = barrel_store_rocksdb:multi_get_entity_with_snapshot(
StoreRef, DocEntityKeys, Snapshot),
DocBodyResults = barrel_store_rocksdb:body_multi_get_with_snapshot(
StoreRef, BodyKeys, Snapshot),
%% Combine results
DocBodyMap = lists:foldl(
fun({DocId, EntityResult, BodyResult}, Map) ->
case {EntityResult, BodyResult} of
{{ok, Columns}, {ok, CborBin}} ->
Rev = barrel_version:to_token(
barrel_version:decode(
proplists:get_value(?COL_VERSION, Columns))),
Deleted = bin_to_deleted(proplists:get_value(?COL_DELETED, Columns, <<"false">>)),
case {Deleted andalso not IncludeDeleted,
expired(Columns)} of
{true, _} ->
Map; %% Skip deleted
{_, true} ->
Map; %% Lazily expired
_ ->
DocBody = barrel_docdb_codec_cbor:decode_any(CborBin),
Result = DocBody#{<<"id">> => DocId, <<"_rev">> => Rev},
Result2 = case Deleted of
true -> Result#{<<"_deleted">> => true};
false -> Result
end,
Result3 = maybe_add_embedding(Result2, Columns, Opts),
Map#{DocId => {ok, Result3}}
end;
_ -> Map
end
end,
#{},
lists:zip3(DocIds, DocEntityResults, DocBodyResults)
),
%% Return results in original order
[maps:get(DocId, DocBodyMap, {error, not_found}) || DocId <- DocIds].
%% @private Attach the embedding column as the <<"_embedding">> object
%% (vector + source) when include_embedding => true. Off by default, and
%% the object carries its provenance, so read-modify-write loops that
%% resend it round-trip a computed vector as computed, never as a client
%% override.
maybe_add_embedding(Doc, Columns, Opts) ->
case maps:get(include_embedding, Opts, false) of
true ->
case proplists:get_value(?COL_EMBEDDING, Columns) of
undefined -> Doc;
<<>> -> Doc;
Bin ->
Src = proplists:get_value(?COL_EMBEDDING_SRC, Columns,
?EMBEDDING_SRC_CLIENT),
Doc#{<<"_embedding">> => #{
<<"vector">> => barrel_doc:decode_embedding(Bin),
<<"source">> => Src
}}
end;
false ->
Doc
end.
%% @doc Whether an entity's expires_at column marks the document
%% lazily expired: the TTL sweeper turns these into real tombstones,
%% and reads treat them as gone in the meantime.
-spec expired([{binary(), term()}]) -> boolean().
expired(Columns) ->
case proplists:get_value(?COL_EXPIRES_AT, Columns, 0) of
ExpiresMs when is_integer(ExpiresMs), ExpiresMs > 0 ->
ExpiresMs =< erlang:system_time(millisecond);
_ ->
false
end.
%% @private Convert binary back to boolean from wide column storage
-spec bin_to_deleted(binary()) -> boolean().
bin_to_deleted(<<"true">>) -> true;
bin_to_deleted(<<"false">>) -> false;
bin_to_deleted(_) -> false.
%% @private Add conflicts to document if requested and conflicts exist.
%% The entity carries the live sibling count; only conflicted documents
%% scan the version chain (under the read snapshot).
maybe_add_conflicts(Doc, Columns, Opts, ReadCtx) ->
case maps:get(conflicts, Opts, false) of
true ->
case conflict_tokens(Columns, ReadCtx) of
[] -> Doc;
Tokens -> Doc#{<<"_conflicts">> => Tokens}
end;
false ->
Doc
end.
%% @private Add conflicts to metadata map (for raw_body responses)
maybe_add_conflicts_to_meta(Meta, Columns, Opts, ReadCtx) ->
case maps:get(conflicts, Opts, false) of
true ->
case conflict_tokens(Columns, ReadCtx) of
[] -> Meta;
Tokens -> Meta#{conflicts => Tokens}
end;
false ->
Meta
end.
conflict_tokens(Columns, {StoreRef, DbName, DocId, Snapshot}) ->
case proplists:get_value(?COL_NCONFLICTS, Columns, 0) of
0 ->
[];
_ ->
Start = barrel_store_keys:doc_version_prefix(DbName, DocId),
End = barrel_store_keys:doc_version_end(DbName, DocId),
Fold = fun(Key, Value, Acc) ->
case Value of
<<1:8, _/binary>> -> %% conflict flag
VersionEnc = barrel_store_keys:decode_doc_version_key(
DbName, DocId, Key),
{ok, [barrel_version:to_token(
barrel_version:decode(VersionEnc)) | Acc]};
_ ->
{ok, Acc}
end
end,
lists:reverse(
barrel_store_rocksdb:fold_range_with_snapshot(
StoreRef, Start, End, Fold, [], Snapshot))
end.