diff --git a/src/hb_store_volatile.erl b/src/hb_store_volatile.erl
index 59e1ff7a6..713d1c07c 100644
--- a/src/hb_store_volatile.erl
+++ b/src/hb_store_volatile.erl
@@ -1,13 +1,15 @@
-%%% @doc A lightweight in-memory HyperBEAM store backed by ETS. The store is
-%%% volatile: It does not persist data to disk ever, and -- critically -- can
-%%% be configured to expire all data periodically. This is useful for testing
-%%% and as a short-term in-memory cache, not for instances where an `ok` from
-%%% the `write` function should imply data persistence.
+%%% @doc A lightweight in-memory HyperBEAM store backed by a single ETS
+%%% `ordered_set`. The store is volatile: it does not persist data to disk
+%%% ever, and -- critically -- can be configured to expire all data
+%%% periodically. This is useful for testing and as a short-term in-memory
+%%% cache, not for instances where an `ok` from the `write` function should
+%%% imply data persistence.
%%%
-%%% This store keeps all data in-memory and does not flush to any persistent
-%%% backend. It supports the core `hb_store` interface semantics used by
-%%% `hb_store` and `hb_cache`: writes, reads, groups, links, type checks,
-%%% path resolution, and resets.
+%%% Each entry is stored as `{Path, {raw, Bin} | {link, Target} | group}`.
+%%% Group membership is discovered by a lexicographic range scan over the
+%%% `Path ++ "/"` prefix, mirroring the LMDB and FS store layouts. An
+%%% explicit `group` marker is inserted at every ancestor path so that
+%%% `type/3` stays a single lookup.
-module(hb_store_volatile).
-export([start/3, stop/3, reset/3, scope/0, scope/1]).
-export([write/3, read/3, list/3, type/3, link/3, group/3, resolve/3]).
@@ -19,12 +21,12 @@
%% @doc Start the ETS-backed store and return the store instance message.
start(StoreOpts = #{ <<"name">> := Name }, _Req, _Opts) ->
- ?event(cache_ets, {starting_ets_store, Name}),
+ ?event(store_volatile, {starting_ets_store, Name}),
Parent = self(),
spawn(
fun() ->
Table = ets:new(hb_store_volatile, [
- set,
+ ordered_set,
public,
{read_concurrency, true},
{write_concurrency, true}
@@ -37,10 +39,13 @@ start(StoreOpts = #{ <<"name">> := Name }, _Req, _Opts) ->
receive
{ok, InstanceMessage} ->
{ok, InstanceMessage}
+ after 5000 ->
+ {error, start_timeout}
end.
-%% @doc Owner loop for the ETS store. Simply waits for a stop message and exits.
-%% Until the store is stopped, the table will remain alive.
+%% @doc Owner loop for the ETS store. Supervised long-lived server: blocks
+%% indefinitely on stop/reset/other messages; the table stays alive until
+%% the owner exits.
owner_loop(StoreOpts) ->
receive
{stop, From, Ref} ->
@@ -81,37 +86,34 @@ scope() -> local.
scope(_) -> scope().
%% @doc Remove all entries from the ETS table.
+reset(Opts, _Req, _NodeOpts) ->
+ reset_store(Opts).
+
reset_store(Opts) ->
- #{ <<"ets-table">> := Table } = hb_store:find(Opts),
+ Table = table(Opts),
ets:delete_all_objects(Table),
?event(store_volatile, {reset, {table, Table}}),
ok.
-reset(Opts, _Req, _NodeOpts) ->
- reset_store(Opts).
-%% @doc Write a value at the key path.
+%% @doc Write one or more entries. Request maps are folded into individual
+%% writes. Any raw/link entry at an ancestor path is converted to a group
+%% marker (raw/link entries have no descendants, so no subtree purge is
+%% needed). If the target key previously held a group, its descendants are
+%% deleted first.
write(Opts, Req, _NodeOpts) when is_map(Req) ->
maps:fold(
- fun(Key, Value, ok) ->
- write_path(Opts, Key, Value);
- (_Key, _Value, Error) ->
+ fun(Path, Value, ok) ->
+ put_entry(Opts, Path, {raw, Value});
+ (_Path, _Value, Error) ->
Error
end,
ok,
Req
).
-write_path(Opts, RawKey, Value) ->
- Key = hb_path:to_binary(RawKey),
- #{ <<"ets-table">> := Table } = hb_store:find(Opts),
- ensure_parent_groups(Table, Key),
- ?event(store_volatile, {write, {key, Key}}),
- ets:insert(Table, {Key, {raw, Value}}),
- ok.
-%% @doc Read a value, following links when needed.
+%% @doc Read a value, following links when needed. Group paths return
+%% `{composite, Children}` with the immediate child names.
read(Opts, #{ <<"read">> := RawKey }, _NodeOpts) ->
- read_path(Opts, RawKey).
-read_path(Opts, RawKey) ->
read_resolved(Opts, resolve_path(Opts, RawKey), 0).
read_resolved(_Opts, _Key, Depth) when Depth > ?MAX_REDIRECTS ->
@@ -121,9 +123,9 @@ read_resolved(Opts, Key, Depth) ->
{raw, Value} ->
?event(store_volatile, {hit, {key, Key}}),
{ok, Value};
- {group, Set} ->
+ group ->
?event(store_volatile, {hit, {key, Key}}),
- {composite, sets:to_list(Set)};
+ {composite, immediate_children(Opts, Key)};
{link, Link} ->
?event(store_volatile, {hit, {key, Key}}),
read_resolved(Opts, hb_path:to_binary(Link), Depth + 1);
@@ -132,11 +134,18 @@ read_resolved(Opts, Key, Depth) ->
{error, not_found}
end.
-%% @doc Resolve links through a path segment-by-segment.
+%% @doc Resolve a path segment-by-segment, following any links encountered
+%% at intermediate positions.
resolve(Opts, #{ <<"resolve">> := Key }, _NodeOpts) ->
{ok, resolve_path(Opts, Key)}.
+
resolve_path(Opts, Key) ->
- resolve_path(Opts, <<>>, hb_path:term_to_path_parts(hb_path:to_binary(Key), Opts), 0).
+ resolve_path(
+ Opts,
+ <<>>,
+ hb_path:term_to_path_parts(hb_path:to_binary(Key), Opts),
+ 0
+ ).
resolve_path(_Opts, CurrPath, [], _Depth) ->
hb_path:to_binary(CurrPath);
@@ -151,25 +160,30 @@ resolve_path(Opts, CurrPath, [Next | Rest], Depth) ->
resolve_path(Opts, PathPart, Rest, Depth)
end.
-%% @doc List child names under a group path.
-list(Opts, #{ <<"list">> := Path }, _NodeOpts) ->
- list_path(Opts, Path).
+%% @doc List immediate child names under a group path.
+list(Opts, #{ <<"list">> := RawPath }, _NodeOpts) ->
+ list_path(Opts, hb_path:to_binary(RawPath)).
+
+list_path(Opts, <<"">>) ->
+ list_path(Opts, ?ROOT_GROUP);
list_path(Opts, Path) ->
- ResolvedPath =
- case Path of
- <<"">> -> ?ROOT_GROUP;
- <<"/">> -> ?ROOT_GROUP;
- _ -> resolve_path(Opts, Path)
- end,
- case lookup_entry(Opts, ResolvedPath) of
- {group, Set} ->
- {ok, sets:to_list(Set)};
+ case lookup_entry(Opts, Path) of
+ group ->
+ {ok, immediate_children(Opts, Path)};
{link, Link} ->
- list_path(Opts, Link);
- {raw, Value} when is_map(Value) ->
- {ok, maps:keys(Value)};
- {raw, Value} when is_list(Value) ->
- {ok, Value};
+ list_path(Opts, hb_path:to_binary(Link));
+ nil when Path =:= ?ROOT_GROUP ->
+ %% Empty store at root — no entries here; return `not_found`
+ %% rather than `{ok, []}` so a chained store can serve the
+ %% request.
+ {error, not_found};
+ nil ->
+ %% Not at this exact path; try resolving intermediate-segment
+ %% links. If resolution yields the same path, it's truly absent.
+ case resolve_path(Opts, Path) of
+ Path -> {error, not_found};
+ Resolved -> list_path(Opts, Resolved)
+ end;
_ ->
{error, not_found}
end.
@@ -177,82 +191,94 @@ list_path(Opts, Path) ->
%% @doc Determine the item type at a path.
type(Opts, #{ <<"type">> := RawKey }, _NodeOpts) ->
type_path(Opts, RawKey).
+
type_path(Opts, RawKey) ->
Key = resolve_path(Opts, RawKey),
case lookup_entry(Opts, Key) of
- {raw, _} ->
- {ok, simple};
- {group, _} ->
- {ok, composite};
- {link, Link} ->
- type_path(Opts, Link);
- _ ->
- {error, not_found}
+ {raw, _} -> {ok, simple};
+ group -> {ok, composite};
+ {link, Link} -> type_path(Opts, hb_path:to_binary(Link));
+ _ -> {error, not_found}
end.
-%% @doc Ensure a group exists at the given path.
+%% @doc Ensure a group exists at the given path. Idempotent on existing
+%% groups; converts a raw/link entry to an empty group.
group(Opts, #{ <<"group">> := RawKey }, _NodeOpts) ->
Key = hb_path:to_binary(RawKey),
- #{ <<"ets-table">> := Table } = hb_store:find(Opts),
- ensure_dir(Table, Key),
+ Table = table(Opts),
+ case lookup_entry(Table, Key) of
+ group -> ok;
+ _ -> put_entry(Opts, Key, group)
+ end,
ok.
-%% @doc Create or replace a link from New to Existing.
+%% @doc Create or replace one or more links. Request maps are folded; each
+%% `New => Existing` pair installs a link at New targeting Existing.
link(Opts, Req, _NodeOpts) when is_map(Req) ->
maps:fold(
- fun(LinkPath, ExistingPath, ok) ->
- link_path(Opts, LinkPath, ExistingPath);
- (_LinkPath, _ExistingPath, Error) ->
+ fun(New, Existing, ok) ->
+ link_path(Opts, New, Existing);
+ (_New, _Existing, Error) ->
Error
end,
ok,
Req
).
-link_path(_, LinkPath, LinkPath) ->
+
+link_path(_Opts, Same, Same) ->
ok;
link_path(Opts, RawNew, RawExisting) ->
- Existing = hb_path:to_binary(RawExisting),
- New = hb_path:to_binary(RawNew),
- #{ <<"ets-table">> := Table } = hb_store:find(Opts),
- ensure_parent_groups(Table, New),
- ets:insert(Table, {New, {link, Existing}}),
+ put_entry(Opts, RawNew, {link, hb_path:to_binary(RawExisting)}).
+
+%% @doc Install an entry at Key, purging any prior group subtree and
+%% ensuring ancestor group markers exist.
+put_entry(Opts, RawKey, Entry) ->
+ Key = hb_path:to_binary(RawKey),
+ Table = table(Opts),
+ maybe_delete_subtree(Table, Key),
+ ensure_parent_groups(Table, Key),
+ ?event(store_volatile, {put, {key, Key}}),
+ ets:insert(Table, {Key, Entry}),
ok.
+table(Opts) ->
+ #{ <<"ets-table">> := Table } = hb_store:find(Opts),
+ Table.
+
join_path(<<>>, Next) ->
hb_path:to_binary(Next);
join_path(CurrPath, Next) ->
hb_path:to_binary([CurrPath, Next]).
lookup_entry(Opts, Key) when is_map(Opts) ->
- #{ <<"ets-table">> := Table } = hb_store:find(Opts),
- lookup_entry(Table, Key);
+ lookup_entry(table(Opts), Key);
lookup_entry(Table, Key) ->
case ets:lookup(Table, Key) of
- [] ->
- nil;
- [{_, Entry}] ->
- Entry
+ [] -> nil;
+ [{_, Entry}] -> Entry
end.
+%% @doc Walk ancestor paths from root to parent, inserting a `group` marker
+%% via unconditional `ets:insert/2` at each. A raw or link entry sitting at
+%% an intermediate path is converted to a group — those entry types have no
+%% descendants, so no subtree purge is required.
ensure_parent_groups(Table, Key) ->
case filename:dirname(Key) of
<<".">> ->
- add_group_child(Table, ?ROOT_GROUP, filename:basename(Key));
+ ets:insert(Table, {?ROOT_GROUP, group});
ParentDir ->
- ensure_dir(Table, ParentDir),
- add_group_child(Table, ParentDir, filename:basename(Key))
+ ensure_dir(Table, ParentDir)
end.
ensure_dir(Table, Path) ->
- PathParts = hb_path:term_to_path_parts(Path),
- ensure_dir(Table, ?ROOT_GROUP, PathParts).
+ ets:insert(Table, {?ROOT_GROUP, group}),
+ ensure_dir(Table, ?ROOT_GROUP, hb_path:term_to_path_parts(Path)).
ensure_dir(_Table, _CurrentGroup, []) ->
ok;
ensure_dir(Table, CurrentGroup, [Next | Rest]) ->
- add_group_child(Table, CurrentGroup, Next),
NextGroup = next_group_path(CurrentGroup, Next),
- ensure_group(Table, NextGroup),
+ ets:insert(Table, {NextGroup, group}),
ensure_dir(Table, NextGroup, Rest).
next_group_path(?ROOT_GROUP, Next) ->
@@ -260,24 +286,98 @@ next_group_path(?ROOT_GROUP, Next) ->
next_group_path(CurrentGroup, Next) ->
hb_path:to_binary([CurrentGroup, Next]).
-ensure_group(Table, GroupPath) ->
- case lookup_entry(Table, GroupPath) of
- {group, _} ->
- ok;
+%% @doc If `Key` currently holds a `group` marker, delete all descendants
+%% (entries whose path starts with `<>`).
+maybe_delete_subtree(Table, Key) ->
+ case lookup_entry(Table, Key) of
+ group ->
+ Prefix = <>,
+ PLen = byte_size(Prefix),
+ ets:select_delete(Table, [
+ {{'$1', '_'},
+ [{'=:=', {binary_part, '$1', 0, PLen}, {const, Prefix}}],
+ [true]}
+ ]);
_ ->
- ets:insert(Table, {GroupPath, {group, sets:new()}})
+ ok
end.
-add_group_child(Table, GroupPath, Child) ->
- Set =
- case lookup_entry(Table, GroupPath) of
- {group, ExistingSet} ->
- ExistingSet;
+%% @doc Return immediate child names under GroupPath. Walks the ordered_set
+%% with `ets:next/2`, jumping past each child's own subtree after it has
+%% been recorded (see `advance_past_child/4`). Cost is O(immediate
+%% children), not O(descendants). For root listings the scan must start
+%% from the true beginning of the table — `ets:next(Table, ?ROOT_GROUP)`
+%% would skip any top-level key whose binary sort order is less than
+%% `<<"/">>` (for example base64url paths starting with `-`) — so the
+%% root marker row itself is skipped explicitly inside the loop instead.
+immediate_children(Opts, GroupPath) ->
+ Table = table(Opts),
+ Seen =
+ case GroupPath of
+ ?ROOT_GROUP ->
+ collect_immediate_children(
+ Table, <<>>, ets:first(Table), #{});
_ ->
- sets:new()
+ Prefix = <>,
+ collect_immediate_children(
+ Table, Prefix, ets:next(Table, Prefix), #{})
end,
- ets:insert(Table, {GroupPath, {group, sets:add_element(Child, Set)}}),
- ok.
+ maps:keys(Seen).
+
+collect_immediate_children(_Table, _Prefix, '$end_of_table', Seen) ->
+ Seen;
+collect_immediate_children(Table, <<>> = Prefix, ?ROOT_GROUP, Seen) ->
+ collect_immediate_children(Table, Prefix, ets:next(Table, ?ROOT_GROUP), Seen);
+collect_immediate_children(Table, Prefix, Key, Seen) ->
+ PLen = byte_size(Prefix),
+ case Key of
+ <> ->
+ [Name | _] = binary:split(Rest, <<"/">>),
+ case is_map_key(Name, Seen) of
+ true ->
+ %% Re-entered a subtree we already recorded (possible
+ %% when a sibling like `<>` with X < $/ is
+ %% interleaved in ordered_set iteration). Jump past
+ %% Name's subtree in one step.
+ NextKey = skip_subtree(Table, Prefix, Name),
+ collect_immediate_children(Table, Prefix, NextKey, Seen);
+ false ->
+ NextKey = advance_past_child(Table, Prefix, Name, Key),
+ collect_immediate_children(
+ Table, Prefix, NextKey, Seen#{Name => true})
+ end;
+ _ ->
+ Seen
+ end.
+
+%% @doc Advance to the next sibling of Name under Prefix. Plain `ets:next`
+%% handles the shallow case at one-op cost; if it lands inside Name's own
+%% subtree, jump past it via `skip_subtree/3`.
+advance_past_child(Table, Prefix, Name, Key) ->
+ case ets:next(Table, Key) of
+ '$end_of_table' ->
+ '$end_of_table';
+ NextKey ->
+ SubtreePrefix = <>,
+ SPLen = byte_size(SubtreePrefix),
+ case NextKey of
+ <> ->
+ skip_subtree(Table, Prefix, Name);
+ _ ->
+ NextKey
+ end
+ end.
+
+%% @doc Jump past every key under `<>`. The lex successor
+%% of that prefix is `<>` (since $0 = $/ + 1); an explicit
+%% `ets:member` probe preserves a literal sibling named `<>`
+%% if one exists (possible with hex-TXID-shaped keys).
+skip_subtree(Table, Prefix, Name) ->
+ SkipPast = <>,
+ case ets:member(Table, SkipPast) of
+ true -> SkipPast;
+ false -> ets:next(Table, SkipPast)
+ end.
%%% Tests
@@ -299,14 +399,167 @@ max_ttl_test() ->
?assertEqual({error, not_found}, hb_store:read(StoreOpts, <<"a">>, #{})),
ok = hb_store:stop(StoreOpts).
+empty_root_reports_not_found_test() ->
+ %% A fresh store with no writes must report `{error, not_found}` on
+ %% `list(<<"/">>)` so that, when used first in a store chain, the
+ %% dispatcher falls through to the next store instead of stopping on
+ %% a spurious `{ok, []}`.
+ S = hb_test_utils:test_store(?MODULE, <<"empty-root-test">>),
+ hb_store:start(S),
+ ?assertEqual({error, not_found}, hb_store:list(S, <<"/">>, #{})),
+ ok = hb_store:stop(S).
+
list_root_test() ->
- StoreOpts =
- #{
- <<"store-module">> => ?MODULE,
- <<"name">> => <<"ets-list-root-test">>
- },
- ok = hb_store:start(StoreOpts),
- ok = hb_store:write(StoreOpts, #{ <<"a">> => <<"b">> }, #{}),
- {ok, Keys} = hb_store:list(StoreOpts, <<"/">>, #{}),
- ?assert(lists:member(<<"a">>, Keys)),
- ok = hb_store:stop(StoreOpts).
+ S = hb_test_utils:test_store(?MODULE, <<"list-root-test">>),
+ hb_store:start(S),
+ ok = hb_store:write(S, #{ <<"alpha">> => <<"1">>,
+ <<"beta/child">> => <<"2">> }, #{}),
+ {ok, Keys} = hb_store:list(S, <<"/">>, #{}),
+ ?assertNot(lists:member(<<>>, Keys)),
+ ?assertEqual([<<"alpha">>, <<"beta">>], lists:sort(Keys)),
+ ok = hb_store:stop(S).
+
+list_root_includes_pre_slash_keys_test() ->
+ %% Top-level keys whose first byte sorts before "/" (47) — e.g. "-" (45),
+ %% common in base64url/TXID paths — must appear in the root listing.
+ S = hb_test_utils:test_store(?MODULE, <<"list-root-dash-test">>),
+ hb_store:start(S),
+ ok = hb_store:write(S, #{ <<"-a">> => <<"1">>,
+ <<"alpha">> => <<"2">> }, #{}),
+ {ok, Keys} = hb_store:list(S, <<"/">>, #{}),
+ ?assertEqual([<<"-a">>, <<"alpha">>], lists:sort(Keys)),
+ ok = hb_store:stop(S).
+
+list_test() ->
+ S = hb_test_utils:test_store(?MODULE, <<"list-test">>),
+ hb_store:start(S),
+ ok = hb_store:write(S,
+ #{ <<"colors/red">> => <<"1">>,
+ <<"colors/blue">> => <<"2">>,
+ <<"colors/multi/foo">> => <<"3">> }, #{}),
+ {ok, Children} = hb_store:list(S, <<"colors">>, #{}),
+ ?assertEqual(
+ [<<"blue">>, <<"multi">>, <<"red">>], lists:sort(Children)),
+ {ok, Deep} = hb_store:list(S, <<"colors/multi">>, #{}),
+ ?assertEqual([<<"foo">>], Deep),
+ ok = hb_store:stop(S).
+
+list_dedup_test() ->
+ S = hb_test_utils:test_store(?MODULE, <<"list-dedup-test">>),
+ hb_store:start(S),
+ ok = hb_store:link(S,
+ #{ <<"a/link">> => <<"target1">> }, #{}),
+ ok = hb_store:link(S,
+ #{ <<"a/link">> => <<"target2">> }, #{}),
+ {ok, Children} = hb_store:list(S, <<"a">>, #{}),
+ ?assertEqual([<<"link">>], lists:usort(Children)),
+ ok = hb_store:stop(S).
+
+list_with_link_test() ->
+ S = hb_test_utils:test_store(?MODULE, <<"list-with-link-test">>),
+ hb_store:start(S),
+ ok = hb_store:write(S, #{ <<"real/child">> => <<"v">> }, #{}),
+ ok = hb_store:link(S, #{ <<"alias">> => <<"real">> }, #{}),
+ {ok, Children} = hb_store:list(S, <<"alias">>, #{}),
+ ?assertEqual([<<"child">>], Children),
+ ok = hb_store:stop(S).
+
+overwrite_link_to_raw_test() ->
+ S = hb_test_utils:test_store(?MODULE, <<"overwrite-link-to-raw-test">>),
+ hb_store:start(S),
+ ok = hb_store:link(S, #{ <<"p/x">> => <<"target">> }, #{}),
+ ok = hb_store:write(S, #{ <<"p/x">> => <<"val">> }, #{}),
+ ?assertEqual({ok, <<"val">>}, hb_store:read(S, <<"p/x">>, #{})),
+ ok = hb_store:stop(S).
+
+overwrite_group_to_raw_test() ->
+ S = hb_test_utils:test_store(?MODULE, <<"overwrite-group-to-raw-test">>),
+ hb_store:start(S),
+ ok = hb_store:write(S, #{ <<"g/child">> => <<"v">> }, #{}),
+ ?assertEqual({ok, composite}, hb_store:type(S, <<"g">>, #{})),
+ ok = hb_store:write(S, #{ <<"g">> => <<"raw">> }, #{}),
+ ?assertEqual({ok, simple}, hb_store:type(S, <<"g">>, #{})),
+ ?assertEqual({error, not_found}, hb_store:read(S, <<"g/child">>, #{})),
+ ok = hb_store:group(S, <<"g">>, #{}),
+ {ok, Empty} = hb_store:list(S, <<"g">>, #{}),
+ ?assertEqual([], Empty),
+ ok = hb_store:stop(S).
+
+overwrite_group_to_link_test() ->
+ S = hb_test_utils:test_store(?MODULE, <<"overwrite-group-to-link-test">>),
+ hb_store:start(S),
+ ok = hb_store:write(S, #{ <<"g/child">> => <<"v">> }, #{}),
+ ok = hb_store:write(S, #{ <<"other/x">> => <<"w">> }, #{}),
+ ok = hb_store:link(S, #{ <<"g">> => <<"other">> }, #{}),
+ ?assertEqual({error, not_found}, hb_store:read(S, <<"g/child">>, #{})),
+ {ok, Children} = hb_store:list(S, <<"g">>, #{}),
+ ?assertEqual([<<"x">>], Children),
+ ok = hb_store:stop(S).
+
+implicit_group_conversion_test() ->
+ S = hb_test_utils:test_store(?MODULE, <<"implicit-group-conversion-test">>),
+ hb_store:start(S),
+ ok = hb_store:write(S, #{ <<"a">> => <<"raw_val">> }, #{}),
+ ?assertEqual({ok, simple}, hb_store:type(S, <<"a">>, #{})),
+ ok = hb_store:write(S, #{ <<"a/b">> => <<"child_val">> }, #{}),
+ ?assertEqual({ok, composite}, hb_store:type(S, <<"a">>, #{})),
+ {ok, Children} = hb_store:list(S, <<"a">>, #{}),
+ ?assertEqual([<<"b">>], Children),
+ ok = hb_store:stop(S).
+
+list_deep_subtree_test() ->
+ S = hb_test_utils:test_store(?MODULE, <<"list-deep-test">>),
+ hb_store:start(S),
+ Writes = maps:from_list(
+ [
+ {<<"root/heavy/d", (integer_to_binary(I))/binary>>, <<"v">>}
+ ||
+ I <- lists:seq(1, 200)
+ ]
+ ),
+ ok = hb_store:write(S, Writes#{ <<"root/other">> => <<"v">> }, #{}),
+ {ok, Children} = hb_store:list(S, <<"root">>, #{}),
+ ?assertEqual([<<"heavy">>, <<"other">>], lists:sort(Children)),
+ ok = hb_store:stop(S).
+
+sibling_with_zero_suffix_test() ->
+ S = hb_test_utils:test_store(?MODULE, <<"sibling-zero-test">>),
+ hb_store:start(S),
+ ok = hb_store:write(S,
+ #{ <<"p/a">> => <<"1">>,
+ <<"p/a0">> => <<"2">>,
+ <<"p/a/leaf">> => <<"3">> }, #{}),
+ {ok, Children} = hb_store:list(S, <<"p">>, #{}),
+ ?assertEqual([<<"a">>, <<"a0">>], lists:sort(Children)),
+ ok = hb_store:stop(S).
+
+list_large_flat_group_test() ->
+ %% Regression for the quadratic-dedup issue: listing a flat group with
+ %% many children must be linear in the child count, not quadratic.
+ S = hb_test_utils:test_store(?MODULE, <<"list-large-flat-test">>),
+ hb_store:start(S),
+ N = 5000,
+ Writes = maps:from_list(
+ [{<<"grp/k", (integer_to_binary(I))/binary>>, <<"v">>}
+ || I <- lists:seq(1, N)]
+ ),
+ ok = hb_store:write(S, Writes, #{}),
+ {_, {ok, Children}} = timer:tc(
+ fun() -> hb_store:list(S, <<"grp">>, #{}) end),
+ ?assertEqual(N, length(Children)),
+ ?assertEqual(N, length(lists:usort(Children))),
+ ok = hb_store:stop(S).
+
+prefixed_sibling_no_duplicate_test() ->
+ %% Child "a" has a subtree ("a/leaf") and also a sibling "a-" whose
+ %% first differentiating byte sorts before "/" (45 < 47). ordered_set
+ %% iteration visits p/a, then p/a-, then p/a/leaf — re-entering a's
+ %% subtree after a sibling. list/3 must not report "a" twice.
+ S = hb_test_utils:test_store(?MODULE, <<"prefixed-sibling-test">>),
+ hb_store:start(S),
+ ok = hb_store:write(S,
+ #{ <<"p/a/leaf">> => <<"1">>,
+ <<"p/a-">> => <<"2">> }, #{}),
+ {ok, Children} = hb_store:list(S, <<"p">>, #{}),
+ ?assertEqual([<<"a">>, <<"a-">>], lists:sort(Children)),
+ ok = hb_store:stop(S).