From 7ab57824dcfaf07ce9f7de32b26d1941e64701cc Mon Sep 17 00:00:00 2001 From: Thales Macedo Garitezi Date: Mon, 2 Oct 2023 16:20:16 -0300 Subject: [PATCH 01/31] chore(ds): change return type of `storage_layer:next/{1,2}` Part of https://emqx.atlassian.net/browse/EMQX-10942 The goal is to help make it clear to the caller of `next` what to do next: if the iterator should still be used or if no new messages will ever come out of it. From: ```erlang -spec next(iterator()) -> {value, binary(), iterator()} | none | {error, closed}. ``` To: ```erlang -spec next(iterator()) -> {ok, iterator(), [binary()]} | end_of_stream. -spec next(iterator(), pos_integer()) -> {ok, iterator(), [binary()]} | end_of_stream. ``` --- .../test/emqx_persistent_messages_SUITE.erl | 4 +- .../src/emqx_ds_message_storage_bitmask.erl | 27 ++++++--- .../src/emqx_ds_storage_layer.erl | 57 ++++++++++++++----- .../test/emqx_ds_storage_layer_SUITE.erl | 21 ++++--- .../props/prop_replay_message_storage.erl | 7 +-- 5 files changed, 76 insertions(+), 40 deletions(-) diff --git a/apps/emqx/test/emqx_persistent_messages_SUITE.erl b/apps/emqx/test/emqx_persistent_messages_SUITE.erl index 751b7e4b8..2d8768e65 100644 --- a/apps/emqx/test/emqx_persistent_messages_SUITE.erl +++ b/apps/emqx/test/emqx_persistent_messages_SUITE.erl @@ -272,9 +272,9 @@ consume(Shard, IteratorId) when is_binary(IteratorId) -> consume(It) -> case emqx_ds_storage_layer:next(It) of - {value, Msg, NIt} -> + {ok, NIt, [Msg]} -> [emqx_persistent_message:deserialize(Msg) | consume(NIt)]; - none -> + end_of_stream -> [] end. diff --git a/apps/emqx_durable_storage/src/emqx_ds_message_storage_bitmask.erl b/apps/emqx_durable_storage/src/emqx_ds_message_storage_bitmask.erl index 7b141b202..be8a207bb 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_message_storage_bitmask.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_message_storage_bitmask.erl @@ -90,7 +90,7 @@ -export([next/1]). -export([preserve_iterator/1]). --export([restore_iterator/3]). +-export([restore_iterator/2]). -export([refresh_iterator/1]). %% Debug/troubleshooting: @@ -217,6 +217,7 @@ -opaque db() :: #db{}. -opaque iterator() :: #it{}. +-type serialized_iterator() :: binary(). -type keymapper() :: #keymapper{}. -type keyspace_filter() :: #filter{}. @@ -340,22 +341,30 @@ next(It0 = #it{filter = #filter{keymapper = Keymapper}}) -> {error, closed} end. --spec preserve_iterator(iterator()) -> binary(). -preserve_iterator(#it{cursor = Cursor}) -> +-spec preserve_iterator(iterator()) -> serialized_iterator(). +preserve_iterator(#it{ + cursor = Cursor, + filter = #filter{ + topic_filter = TopicFilter, + start_time = StartTime + } +}) -> State = #{ v => 1, - cursor => Cursor + cursor => Cursor, + replay => {TopicFilter, StartTime} }, term_to_binary(State). --spec restore_iterator(db(), emqx_ds:replay(), binary()) -> +-spec restore_iterator(db(), serialized_iterator()) -> {ok, iterator()} | {error, _TODO}. -restore_iterator(DB, Replay, Serial) when is_binary(Serial) -> +restore_iterator(DB, Serial) when is_binary(Serial) -> State = binary_to_term(Serial), - restore_iterator(DB, Replay, State); -restore_iterator(DB, Replay, #{ + restore_iterator(DB, State); +restore_iterator(DB, #{ v := 1, - cursor := Cursor + cursor := Cursor, + replay := Replay = {_TopicFilter, _StartTime} }) -> case make_iterator(DB, Replay) of {ok, It} when Cursor == undefined -> diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl index 6137a1ed7..25a58950d 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl @@ -12,7 +12,7 @@ -export([store/5]). -export([delete/4]). --export([make_iterator/2, next/1]). +-export([make_iterator/2, next/1, next/2]). -export([ preserve_iterator/2, @@ -131,7 +131,7 @@ -callback make_iterator(_Schema, emqx_ds:replay()) -> {ok, _It} | {error, _}. --callback restore_iterator(_Schema, emqx_ds:replay(), binary()) -> {ok, _It} | {error, _}. +-callback restore_iterator(_Schema, _Serialized :: binary()) -> {ok, _It} | {error, _}. -callback preserve_iterator(_It) -> term(). @@ -175,21 +175,52 @@ make_iterator(Shard, Replay = {_, StartTime}) -> replay = Replay }). --spec next(iterator()) -> {value, binary(), iterator()} | none | {error, closed}. -next(It = #it{module = Mod, data = ItData}) -> +-spec next(iterator()) -> {ok, iterator(), [binary()]} | end_of_stream. +next(It = #it{}) -> + next(It, _BatchSize = 1). + +-spec next(iterator(), pos_integer()) -> {ok, iterator(), [binary()]} | end_of_stream. +next(#it{data = {?MODULE, end_of_stream}}, _BatchSize) -> + end_of_stream; +next( + It = #it{shard = Shard, module = Mod, gen = Gen, data = {?MODULE, retry, Serialized}}, BatchSize +) -> + #{data := DBData} = meta_get_gen(Shard, Gen), + {ok, ItData} = Mod:restore_iterator(DBData, Serialized), + next(It#it{data = ItData}, BatchSize); +next(It = #it{}, BatchSize) -> + do_next(It, BatchSize, _Acc = []). + +-spec do_next(iterator(), non_neg_integer(), [binary()]) -> + {ok, iterator(), [binary()]} | end_of_stream. +do_next(It, N, Acc) when N =< 0 -> + {ok, It, lists:reverse(Acc)}; +do_next(It = #it{module = Mod, data = ItData}, N, Acc) -> case Mod:next(ItData) of {value, Val, ItDataNext} -> - {value, Val, It#it{data = ItDataNext}}; - {error, _} = Error -> - Error; + do_next(It#it{data = ItDataNext}, N - 1, [Val | Acc]); + {error, _} = _Error -> + %% todo: log? + %% iterator might be invalid now; will need to re-open it. + Serialized = Mod:preserve_iterator(ItData), + {ok, It#it{data = {?MODULE, retry, Serialized}}, lists:reverse(Acc)}; none -> case open_next_iterator(It) of {ok, ItNext} -> - next(ItNext); - {error, _} = Error -> - Error; + do_next(ItNext, N, Acc); + {error, _} = _Error -> + %% todo: log? + %% fixme: only bad options may lead to this? + %% return an "empty" iterator to be re-opened when retrying? + Serialized = Mod:preserve_iterator(ItData), + {ok, It#it{data = {?MODULE, retry, Serialized}}, lists:reverse(Acc)}; none -> - none + case Acc of + [] -> + end_of_stream; + _ -> + {ok, It#it{data = {?MODULE, end_of_stream}}, lists:reverse(Acc)} + end end end. @@ -407,8 +438,8 @@ open_iterator(#{module := Mod, data := Data}, It = #it{}) -> -spec open_restore_iterator(generation(), iterator(), binary()) -> {ok, iterator()} | {error, _Reason}. -open_restore_iterator(#{module := Mod, data := Data}, It = #it{replay = Replay}, Serial) -> - case Mod:restore_iterator(Data, Replay, Serial) of +open_restore_iterator(#{module := Mod, data := Data}, It = #it{}, Serial) -> + case Mod:restore_iterator(Data, Serial) of {ok, ItData} -> {ok, It#it{module = Mod, data = ItData}}; Err -> diff --git a/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl b/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl index 3a872934f..10596e216 100644 --- a/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl +++ b/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl @@ -201,7 +201,7 @@ t_iterate_multigen_preserve_restore(_Config) -> ok = emqx_ds_storage_layer:preserve_iterator(It3, ReplayID), {ok, It4} = emqx_ds_storage_layer:restore_iterator(?SHARD, ReplayID), {It5, Res200} = iterate(It4, 1000), - ?assertEqual(none, It5), + ?assertEqual({end_of_stream, []}, iterate(It5, 1)), ?assertEqual( lists:sort([{Topic, TS} || Topic <- TopicsMatching, TS <- Timestamps]), lists:sort([binary_to_term(Payload) || Payload <- Res10 ++ Res100 ++ Res200]) @@ -224,21 +224,20 @@ iterate(DB, TopicFilter, StartTime) -> iterate(It) -> case emqx_ds_storage_layer:next(It) of - {value, Payload, ItNext} -> + {ok, ItNext, [Payload]} -> [Payload | iterate(ItNext)]; - none -> + end_of_stream -> [] end. -iterate(It, 0) -> - {It, []}; +iterate(end_of_stream, _N) -> + {end_of_stream, []}; iterate(It, N) -> - case emqx_ds_storage_layer:next(It) of - {value, Payload, ItNext} -> - {ItFinal, Ps} = iterate(ItNext, N - 1), - {ItFinal, [Payload | Ps]}; - none -> - {none, []} + case emqx_ds_storage_layer:next(It, N) of + {ok, ItFinal, Payloads} -> + {ItFinal, Payloads}; + end_of_stream -> + {end_of_stream, []} end. iterator(DB, TopicFilter, StartTime) -> diff --git a/apps/emqx_durable_storage/test/props/prop_replay_message_storage.erl b/apps/emqx_durable_storage/test/props/prop_replay_message_storage.erl index f9964bebe..d96996534 100644 --- a/apps/emqx_durable_storage/test/props/prop_replay_message_storage.erl +++ b/apps/emqx_durable_storage/test/props/prop_replay_message_storage.erl @@ -225,12 +225,9 @@ run_iterator_commands([iterate | Rest], It, Ctx) -> [] end; run_iterator_commands([{preserve, restore} | Rest], It, Ctx) -> - #{ - db := DB, - replay := Replay - } = Ctx, + #{db := DB} = Ctx, Serial = emqx_ds_message_storage_bitmask:preserve_iterator(It), - {ok, ItNext} = emqx_ds_message_storage_bitmask:restore_iterator(DB, Replay, Serial), + {ok, ItNext} = emqx_ds_message_storage_bitmask:restore_iterator(DB, Serial), run_iterator_commands(Rest, ItNext, Ctx); run_iterator_commands([], It, _Ctx) -> iterate_db(It). From f1454bb57eb5452c8d7773be6379b5ca2d9322f3 Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Fri, 29 Sep 2023 17:11:03 +0200 Subject: [PATCH 02/31] feat(ds): learned topic structure --- apps/emqx_durable_storage/src/emqx_ds_lts.erl | 509 ++++++++++++++++++ rebar.config | 4 + 2 files changed, 513 insertions(+) create mode 100644 apps/emqx_durable_storage/src/emqx_ds_lts.erl diff --git a/apps/emqx_durable_storage/src/emqx_ds_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_lts.erl new file mode 100644 index 000000000..384677d21 --- /dev/null +++ b/apps/emqx_durable_storage/src/emqx_ds_lts.erl @@ -0,0 +1,509 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_ds_lts). + +%% API: +-export([trie_create/0, topic_key/3, match_topics/2, lookup_topic_key/2, dump_to_dot/2]). + +%% Debug: +-export([trie_next/3, trie_insert/3]). + +-export_type([static_key/0, trie/0]). + +-include_lib("stdlib/include/ms_transform.hrl"). + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + +%%================================================================================ +%% Type declarations +%%================================================================================ + +-define(EOT, []). %% End Of Topic +-define(PLUS, '+'). + +-type edge() :: binary() | ?EOT | ?PLUS. + +%% Fixed size binary +-type static_key() :: binary(). + +-define(PREFIX, prefix). +-type state() :: static_key() | ?PREFIX. + +-type msg_storage_key() :: {static_key(), _Varying :: [binary()]}. + +-type threshold_fun() :: fun((non_neg_integer()) -> non_neg_integer()). + +-record(trie, + { trie :: ets:tid() + , stats :: ets:tid() + }). + +-opaque trie() :: #trie{}. + +-record(trans, + { key :: {state(), edge()} + , next :: state() + }). + +%%================================================================================ +%% API funcions +%%================================================================================ + +%% @doc Create an empty trie +-spec trie_create() -> trie(). +trie_create() -> + Trie = ets:new(trie, [{keypos, #trans.key}, set]), + Stats = ets:new(stats, [{keypos, 1}, set]), + #trie{ trie = Trie + , stats = Stats + }. + +%% @doc Create a topic key, +-spec topic_key(trie(), threshold_fun(), [binary()]) -> msg_storage_key(). +topic_key(Trie, ThresholdFun, Tokens) -> + do_topic_key(Trie, ThresholdFun, 0, ?PREFIX, Tokens, []). + +%% @doc Return an exisiting topic key if it exists. +-spec lookup_topic_key(trie(), [binary()]) -> {ok, msg_storage_key()} | undefined. +lookup_topic_key(Trie, Tokens) -> + do_lookup_topic_key(Trie, ?PREFIX, Tokens, []). + +%% @doc Return list of keys of topics that match a given topic filter +-spec match_topics(trie(), [binary() | '+' | '#']) -> + [{static_key(), _Varying :: binary() | ?PLUS}]. +match_topics(Trie, TopicFilter) -> + do_match_topics(Trie, ?PREFIX, [], TopicFilter). + +%% @doc Dump trie to graphviz format for debugging +-spec dump_to_dot(trie(), file:filename()) -> ok. +dump_to_dot(#trie{trie = Trie, stats = Stats}, Filename) -> + L = ets:tab2list(Trie), + {Nodes0, Edges} = + lists:foldl( + fun(#trans{key = {From, Label}, next = To}, {AccN, AccEdge}) -> + Edge = {From, To, Label}, + {[From, To] ++ AccN, [Edge|AccEdge]} + end, + {[], []}, + L), + Nodes = + lists:map( + fun(Node) -> + case ets:lookup(Stats, Node) of + [{_, NChildren}] -> ok; + [] -> NChildren = 0 + end, + {Node, NChildren} + end, + lists:usort(Nodes0)), + {ok, FD} = file:open(Filename, [write]), + Print = fun (?PREFIX) -> "prefix"; + (NodeId) -> binary:encode_hex(NodeId) + end, + io:format(FD, "digraph {~n", []), + lists:foreach( + fun({Node, NChildren}) -> + Id = Print(Node), + io:format(FD, " \"~s\" [label=\"~s : ~p\"];~n", [Id, Id, NChildren]) + end, + Nodes), + lists:foreach( + fun({From, To, Label}) -> + io:format(FD, " \"~s\" -> \"~s\" [label=\"~s\"];~n", [Print(From), Print(To), Label]) + end, + Edges), + io:format(FD, "}~n", []), + file:close(FD). + +%%================================================================================ +%% Internal exports +%%================================================================================ + +-spec trie_next(trie(), state(), binary() | ?EOT) -> {Wildcard, state()} | undefined + when Wildcard :: boolean(). +trie_next(#trie{trie = Trie}, State, ?EOT) -> + case ets:lookup(Trie, {State, ?EOT}) of + [#trans{next = Next}] -> {false, Next}; + [] -> undefined + end; +trie_next(#trie{trie = Trie}, State, Token) -> + case ets:lookup(Trie, {State, ?PLUS}) of + [#trans{next = Next}] -> + {true, Next}; + [] -> + case ets:lookup(Trie, {State, Token}) of + [#trans{next = Next}] -> {false, Next}; + [] -> undefined + end + end. + +-spec trie_insert(trie(), state(), edge()) -> {Updated, state()} + when Updated :: false | non_neg_integer(). +trie_insert(#trie{trie = Trie, stats = Stats}, State, Token) -> + Key = {State, Token}, + NewState = get_id_for_key(State, Token), + Rec = #trans{ key = Key + , next = NewState + }, + case ets:insert_new(Trie, Rec) of + true -> + Inc = case Token of + ?EOT -> 0; + ?PLUS -> 0; + _ -> 1 + end, + NChildren = ets:update_counter(Stats, State, {2, Inc}, {State, 0}), + {NChildren, NewState}; + false -> + [#trans{next = NextState}] = ets:lookup(Trie, Key), + {false, NextState} + end. + +%%================================================================================ +%% Internal functions +%%================================================================================ + +-spec get_id_for_key(state(), edge()) -> static_key(). +get_id_for_key(_State, _Token) -> + %% Requirements for the return value: + %% + %% It should be globally unique for the `{State, Token}` pair. Other + %% than that, there's no requirements. The return value doesn't even + %% have to be deterministic, since the states are saved in the trie. + %% + %% The generated value becomes the ID of the topic in the durable + %% storage. Its size should be relatively small to reduce the + %% overhead of storing messages. + %% + %% If we want to impress computer science crowd, sorry, I mean to + %% minimize storage requirements, we can even employ Huffman coding + %% based on the frequency of messages. + crypto:strong_rand_bytes(8). + +%% erlfmt-ignore +-spec do_match_topics(trie(), state(), non_neg_integer(), [binary() | '+' | '#']) -> + list(). +do_match_topics(Trie, State, Varying, []) -> + case trie_next(Trie, State, ?EOT) of + {false, Static} -> [{Static, lists:reverse(Varying)}]; + undefined -> [] + end; +do_match_topics(Trie, State, Varying, ['#']) -> + Emanating = emanating(Trie, State, ?PLUS), + lists:flatmap( + fun({?EOT, Static}) -> + [{Static, lists:reverse(Varying)}]; + ({?PLUS, NextState}) -> + do_match_topics(Trie, NextState, [?PLUS|Varying], ['#']); + ({_, NextState}) -> + do_match_topics(Trie, NextState, Varying, ['#']) + end, + Emanating); +do_match_topics(Trie, State, Varying, [Level|Rest]) -> + Emanating = emanating(Trie, State, Level), + lists:flatmap( + fun({?EOT, _NextState}) -> + []; + ({?PLUS, NextState}) -> + do_match_topics(Trie, NextState, [Level|Varying], Rest); + ({_, NextState}) -> + do_match_topics(Trie, NextState, Varying, Rest) + end, + Emanating). + +-spec do_lookup_topic_key(trie(), state(), [binary()], [binary()]) -> + {ok, msg_storage_key()} | undefined. +do_lookup_topic_key(Trie, State, [], Varying) -> + case trie_next(Trie, State, ?EOT) of + {false, Static} -> + {ok, {Static, lists:reverse(Varying)}}; + undefined -> + undefined + end; +do_lookup_topic_key(Trie, State, [Tok|Rest], Varying) -> + case trie_next(Trie, State, Tok) of + {true, NextState} -> + do_lookup_topic_key(Trie, NextState, Rest, [Tok|Varying]); + {false, NextState} -> + do_lookup_topic_key(Trie, NextState, Rest, Varying); + undefined -> + undefined + end. + +do_topic_key(Trie, _, _, State, [], Varying) -> + {_, false, Static} = trie_next_(Trie, State, ?EOT), + {Static, lists:reverse(Varying)}; +do_topic_key(Trie, ThresholdFun, Depth, State, [Tok|Rest], Varying0) -> + Threshold = ThresholdFun(Depth), % TODO: it's not necessary to call it every time. + Varying = case trie_next_(Trie, State, Tok) of + {NChildren, _, _DiscardState} when is_integer(NChildren), NChildren > Threshold -> + {_, NextState} = trie_insert(Trie, State, ?PLUS), + [Tok|Varying0]; + {_, false, NextState} -> + Varying0; + {_, true, NextState} -> + [Tok|Varying0] + end, + do_topic_key(Trie, ThresholdFun, Depth + 1, NextState, Rest, Varying). + +-spec trie_next_(trie(), state(), binary() | ?EOT) -> {New, Wildcard, state()} + when New :: false | non_neg_integer(), + Wildcard :: boolean(). +trie_next_(Trie, State, Token) -> + case trie_next(Trie, State, Token) of + {Wildcard, NextState} -> + {false, Wildcard, NextState}; + undefined -> + {Updated, NextState} = trie_insert(Trie, State, Token), + {Updated, false, NextState} + end. + +%% @doc Return all edges emanating from a node: +%% erlfmt-ignore +-spec emanating(trie(), state(), edge()) -> [{edge(), state()}]. +emanating(#trie{trie = Tab}, State, ?PLUS) -> + ets:select(Tab, ets:fun2ms( + fun(#trans{key = {S, Edge}, next = Next}) when S == State -> + {Edge, Next} + end)); +emanating(#trie{trie = Tab}, State, ?EOT) -> + case ets:lookup(Tab, {State, ?EOT}) of + [#trans{next = Next}] -> [{?EOT, Next}]; + [] -> [] + end; +emanating(#trie{trie = Tab}, State, Bin) when is_binary(Bin) -> + [{Edge, Next} || #trans{key = {_, Edge}, next = Next} <- + ets:lookup(Tab, {State, ?PLUS}) ++ + ets:lookup(Tab, {State, Bin})]. + +%%================================================================================ +%% Tests +%%================================================================================ + +-ifdef(TEST). + +trie_basic_test() -> + T = trie_create(), + ?assertMatch(undefined, trie_next(T, ?PREFIX, <<"foo">>)), + {1, S1} = trie_insert(T, ?PREFIX, <<"foo">>), + ?assertMatch({false, S1}, trie_insert(T, ?PREFIX, <<"foo">>)), + ?assertMatch({false, S1}, trie_next(T, ?PREFIX, <<"foo">>)), + + ?assertMatch(undefined, trie_next(T, ?PREFIX, <<"bar">>)), + {2, S2} = trie_insert(T, ?PREFIX, <<"bar">>), + ?assertMatch({false, S2}, trie_insert(T, ?PREFIX, <<"bar">>)), + + ?assertMatch(undefined, trie_next(T, S1, <<"foo">>)), + ?assertMatch(undefined, trie_next(T, S1, <<"bar">>)), + {1, S11} = trie_insert(T, S1, <<"foo">>), + {2, S12} = trie_insert(T, S1, <<"bar">>), + ?assertMatch({false, S11}, trie_next(T, S1, <<"foo">>)), + ?assertMatch({false, S12}, trie_next(T, S1, <<"bar">>)), + + ?assertMatch(undefined, trie_next(T, S11, <<"bar">>)), + {1, S111} = trie_insert(T, S11, <<"bar">>), + ?assertMatch({false, S111}, trie_next(T, S11, <<"bar">>)). + +lookup_key_test() -> + T = trie_create(), + {_, S1} = trie_insert(T, ?PREFIX, <<"foo">>), + {_, S11} = trie_insert(T, S1, <<"foo">>), + %% Topics don't match until we insert ?EOT: + ?assertMatch( undefined + , lookup_topic_key(T, [<<"foo">>]) + ), + ?assertMatch( undefined + , lookup_topic_key(T, [<<"foo">>, <<"foo">>]) + ), + {_, S10} = trie_insert(T, S1, ?EOT), + {_, S110} = trie_insert(T, S11, ?EOT), + ?assertMatch( {ok, {S10, []}} + , lookup_topic_key(T, [<<"foo">>]) + ), + ?assertMatch( {ok, {S110, []}} + , lookup_topic_key(T, [<<"foo">>, <<"foo">>]) + ), + %% The rest of keys still don't match: + ?assertMatch( undefined + , lookup_topic_key(T, [<<"bar">>]) + ), + ?assertMatch( undefined + , lookup_topic_key(T, [<<"bar">>, <<"foo">>]) + ). + +wildcard_lookup_test() -> + T = trie_create(), + {1, S1} = trie_insert(T, ?PREFIX, <<"foo">>), + {0, S11} = trie_insert(T, S1, ?PLUS), %% Plus doesn't increase the number of children + {1, S111} = trie_insert(T, S11, <<"foo">>), + {0, S1110} = trie_insert(T, S111, ?EOT), %% ?EOT doesn't increase the number of children + ?assertMatch( {ok, {S1110, [<<"bar">>]}} + , lookup_topic_key(T, [<<"foo">>, <<"bar">>, <<"foo">>]) + ), + ?assertMatch( {ok, {S1110, [<<"quux">>]}} + , lookup_topic_key(T, [<<"foo">>, <<"quux">>, <<"foo">>]) + ), + ?assertMatch( undefined + , lookup_topic_key(T, [<<"foo">>]) + ), + ?assertMatch( undefined + , lookup_topic_key(T, [<<"foo">>, <<"bar">>]) + ), + ?assertMatch( undefined + , lookup_topic_key(T, [<<"foo">>, <<"bar">>, <<"bar">>]) + ), + ?assertMatch( undefined + , lookup_topic_key(T, [<<"bar">>, <<"foo">>, <<"foo">>]) + ), + {_, S10} = trie_insert(T, S1, ?EOT), + ?assertMatch( {ok, {S10, []}} + , lookup_topic_key(T, [<<"foo">>]) + ). + +%% erlfmt-ignore +topic_key_test() -> + T = trie_create(), + try + Threshold = 3, + ThresholdFun = fun(0) -> 1000; + (_) -> Threshold + end, + %% Test that bottom layer threshold is high: + lists:foreach( + fun(I) -> + {_, []} = test_key(T, ThresholdFun, [I, 99, 99, 99]) + end, + lists:seq(1, 10)), + %% Test adding children on the 2nd level: + lists:foreach( + fun(I) -> + case test_key(T, ThresholdFun, [1, I, 1]) of + {_, []} when I < Threshold -> + ok; + {_, [Var]} -> + ?assertEqual(Var, integer_to_binary(I)) + end + end, + lists:seq(1, 100)), + %% This doesn't affect 2nd level with a different prefix: + {_, []} = test_key(T, ThresholdFun, [2, 1, 1]), + %% Now create another level of +: + lists:foreach( + fun(I) -> + case test_key(T, ThresholdFun, [1, 42, 1, I, 42]) of + {_, [<<"42">>]} when I =< Threshold -> %% TODO: off by 1 error + ok; + {_, [<<"42">>, Var]} -> + ?assertEqual(Var, integer_to_binary(I)); + Ret -> + error({Ret, I}) + end + end, + lists:seq(1, 100)) + after + dump_to_dot(T, atom_to_list(?FUNCTION_NAME) ++ ".dot") + end. + +%% erlfmt-ignore +topic_match_test() -> + T = trie_create(), + try + Threshold = 2, + ThresholdFun = fun(0) -> 1000; + (_) -> Threshold + end, + {S1, []} = test_key(T, ThresholdFun, [1]), + {S11, []} = test_key(T, ThresholdFun, [1, 1]), + {S12, []} = test_key(T, ThresholdFun, [1, 2]), + {S111, []} = test_key(T, ThresholdFun, [1, 1, 1]), + %% Match concrete topics: + assert_match_topics(T, [1], [{S1, []}]), + assert_match_topics(T, [1, 1], [{S11, []}]), + assert_match_topics(T, [1, 1, 1], [{S111, []}]), + %% Match topics with +: + assert_match_topics(T, [1, '+'], [{S11, []}, {S12, []}]), + assert_match_topics(T, [1, '+', 1], [{S111, []}]), + %% Match topics with #: + assert_match_topics(T, [1, '#'], [{S1, []}, {S11, []}, {S12, []}, {S111, []}]), + assert_match_topics(T, [1, 1, '#'], [{S11, []}, {S111, []}]), + %% Now add learned wildcards: + {S21, []} = test_key(T, ThresholdFun, [2, 1]), + {S22, []} = test_key(T, ThresholdFun, [2, 2]), + {S2_, [<<"3">>]} = test_key(T, ThresholdFun, [2, 3]), + {S2_11, [_]} = test_key(T, ThresholdFun, [2, 1, 1, 1]), + {S2_12, [_]} = test_key(T, ThresholdFun, [2, 1, 1, 2]), + {S2_1_, [_, _]} = test_key(T, ThresholdFun, [2, 1, 1, 3]), + %% Check matching: + assert_match_topics(T, [2, 2], + [{S22, []}, {S2_, [<<"2">>]}]), + assert_match_topics(T, [2, '+'], + [{S22, []}, {S21, []}, {S2_, ['+']}]), + assert_match_topics(T, [2, 1, 1, 2], + [{S2_12, [<<"1">>]}, + {S2_1_, [<<"1">>, <<"2">>]}]), + assert_match_topics(T, [2, '#'], + [{S21, []}, {S22, []}, {S2_, ['+']}, + {S2_11, ['+']}, {S2_12, ['+']}, + {S2_1_, ['+', '+']}]), + ok + after + dump_to_dot(T, atom_to_list(?FUNCTION_NAME) ++ ".dot") + end. + +-define(keys_history, topic_key_history). + +%% erlfmt-ignore +assert_match_topics(Trie, Filter0, Expected) -> + Filter = lists:map(fun(I) when is_integer(I) -> integer_to_binary(I); + (I) -> I + end, + Filter0), + Matched = match_topics(Trie, Filter), + ?assertMatch( #{missing := [], unexpected := []} + , #{ missing => Expected -- Matched + , unexpected => Matched -- Expected + } + , Filter + ). + +%% erlfmt-ignore +test_key(Trie, Threshold, Topic0) -> + Topic = [integer_to_binary(I) || I <- Topic0], + Ret = topic_key(Trie, Threshold, Topic), + Ret = topic_key(Trie, Threshold, Topic), %% Test idempotency + case get(?keys_history) of + undefined -> OldHistory = #{}; + OldHistory -> ok + end, + %% Test that the generated keys are always unique for the topic: + History = maps:update_with( + Ret, + fun(Old) -> + case Old =:= Topic of + true -> Old; + false -> error(#{'$msg' => "Duplicate key!", key => Ret, old_topic => Old, new_topic => Topic}) + end + end, + Topic, + OldHistory), + put(?keys_history, History), + {ok, Ret} = lookup_topic_key(Trie, Topic), + Ret. + +-endif. diff --git a/rebar.config b/rebar.config index 3ba8edc4b..81fa04231 100644 --- a/rebar.config +++ b/rebar.config @@ -106,6 +106,10 @@ emqx_exproto_pb % generated code for protobuf ]}. +{eunit_opts, + [ verbose + ]}. + {project_plugins, [ erlfmt, {rebar3_hex, "7.0.2"}, From c91df2f5cd917335f973224ed6e32ef177ad28f5 Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Mon, 2 Oct 2023 23:04:43 +0200 Subject: [PATCH 03/31] refactor(ds): Create a prototype of replication layer --- apps/emqx/src/emqx_persistent_session_ds.erl | 2 +- .../emqx_persistent_session_ds_proto_v1.erl | 17 +- apps/emqx_durable_storage/IMPLEMENTATION.md | 42 ---- apps/emqx_durable_storage/README.md | 9 +- apps/emqx_durable_storage/src/emqx_ds.erl | 199 ++++++++++-------- apps/emqx_durable_storage/src/emqx_ds.erl_ | 189 +++++++++++++++++ apps/emqx_durable_storage/src/emqx_ds_lts.erl | 3 +- .../src/emqx_ds_message_storage_bitmask.erl | 15 +- .../src/emqx_ds_replication_layer.erl | 128 +++++++++++ .../src/emqx_ds_storage_layer.erl | 27 ++- .../src/proto/emqx_ds_proto_v1.erl | 56 +++++ 11 files changed, 539 insertions(+), 148 deletions(-) create mode 100644 apps/emqx_durable_storage/src/emqx_ds.erl_ create mode 100644 apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl create mode 100644 apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl diff --git a/apps/emqx/src/emqx_persistent_session_ds.erl b/apps/emqx/src/emqx_persistent_session_ds.erl index e456211fc..174a02156 100644 --- a/apps/emqx/src/emqx_persistent_session_ds.erl +++ b/apps/emqx/src/emqx_persistent_session_ds.erl @@ -74,7 +74,7 @@ ]). %% FIXME --define(DS_SHARD_ID, <<"local">>). +-define(DS_SHARD_ID, atom_to_binary(node())). -define(DEFAULT_KEYSPACE, default). -define(DS_SHARD, {?DEFAULT_KEYSPACE, ?DS_SHARD_ID}). diff --git a/apps/emqx/src/proto/emqx_persistent_session_ds_proto_v1.erl b/apps/emqx/src/proto/emqx_persistent_session_ds_proto_v1.erl index d35ccd963..edaaea775 100644 --- a/apps/emqx/src/proto/emqx_persistent_session_ds_proto_v1.erl +++ b/apps/emqx/src/proto/emqx_persistent_session_ds_proto_v1.erl @@ -23,7 +23,8 @@ open_iterator/4, close_iterator/2, - close_all_iterators/2 + close_all_iterators/2, + get_streams/5 ]). -include_lib("emqx/include/bpapi.hrl"). @@ -50,6 +51,20 @@ open_iterator(Nodes, TopicFilter, StartMS, IteratorID) -> ?TIMEOUT ). +-spec get_streams( + node(), + emqx_ds:keyspace(), + emqx_ds:shard_id(), + emqx_ds:topic_filter(), + emqx_ds:time()) -> + [emqx_ds_storage_layer:stream()]. +get_streams(Node, Keyspace, ShardId, TopicFilter, StartTime) -> + erpc:call( + Node, + emqx_ds_storage_layer, + get_streams, + [Keyspace, ShardId, TopicFilter, StartTime]). + -spec close_iterator( [node()], emqx_ds:iterator_id() diff --git a/apps/emqx_durable_storage/IMPLEMENTATION.md b/apps/emqx_durable_storage/IMPLEMENTATION.md index 9c0c5928c..33f02dfc4 100644 --- a/apps/emqx_durable_storage/IMPLEMENTATION.md +++ b/apps/emqx_durable_storage/IMPLEMENTATION.md @@ -31,48 +31,6 @@ Read pattern: pseudoserial Number of records: O(total write throughput * retention time) -## Session storage - -Data there is updated when: - -- A new client connects with clean session = false -- Client subscribes to a topic -- Client unsubscribes to a topic -- Garbage collection is performed - -Write throughput: low - -Data is read when a client connects and replay agents are started - -Read throughput: low - -Data format: - -`#session{clientId = "foobar", iterators = [ItKey1, ItKey2, ItKey3, ...]}` - -Number of records: O(N clients) - -Size of record: O(N subscriptions per clients) - -## Iterator storage - -Data is written every time a client acks a message. - -Data is read when a client reconnects and we restart replay agents. - -`#iterator{key = IterKey, data = Blob}` - -Number of records: O(N clients * N subscriptions per client) - -Size of record: O(1) - -Write throughput: high, lots of small updates - -Write pattern: mostly key overwrite - -Read throughput: low - -Read pattern: random # Push vs. Pull model diff --git a/apps/emqx_durable_storage/README.md b/apps/emqx_durable_storage/README.md index 7de43bee0..f01af0c37 100644 --- a/apps/emqx_durable_storage/README.md +++ b/apps/emqx_durable_storage/README.md @@ -1,9 +1,10 @@ # EMQX Replay -`emqx_ds` is a durable storage for MQTT messages within EMQX. -It implements the following scenarios: -- Persisting messages published by clients -- +`emqx_ds` is a generic durable storage for MQTT messages within EMQX. + +Concepts: + + > 0. App overview introduction > 1. let people know what your project can do specifically. Is it a base diff --git a/apps/emqx_durable_storage/src/emqx_ds.erl b/apps/emqx_durable_storage/src/emqx_ds.erl index feaa37bc0..ad6a07330 100644 --- a/apps/emqx_durable_storage/src/emqx_ds.erl +++ b/apps/emqx_durable_storage/src/emqx_ds.erl @@ -15,48 +15,29 @@ %%-------------------------------------------------------------------- -module(emqx_ds). --include_lib("stdlib/include/ms_transform.hrl"). --include_lib("snabbkaffe/include/snabbkaffe.hrl"). +%% Management API: +-export([create_db/2]). -%% API: --export([ensure_shard/2]). -%% Messages: --export([message_store/2, message_store/1, message_stats/0]). -%% Iterator: --export([iterator_update/2, iterator_next/1, iterator_stats/0]). +%% Message storage API: +-export([message_store/3, message_store/2]). + +%% Message replay API: +-export([get_streams/3, open_iterator/2, next/2]). %% internal exports: -export([]). --export_type([ - keyspace/0, - message_id/0, - message_stats/0, - message_store_opts/0, - replay/0, - replay_id/0, - iterator_id/0, - iterator/0, - shard/0, - shard_id/0, - topic/0, - topic_filter/0, - time/0 -]). +-export_type([db/0, time/0, topic_filter/0, topic/0]). %%================================================================================ %% Type declarations %%================================================================================ --type iterator() :: term(). - --type iterator_id() :: binary(). - --type message_store_opts() :: #{}. - --type message_stats() :: #{}. - --type message_id() :: binary(). +%% Different DBs are completely independent from each other. They +%% could represent something like different tenants. +%% +%% Topics stored in different DBs aren't necesserily disjoint. +-type db() :: binary(). %% Parsed topic. -type topic() :: list(binary()). @@ -64,9 +45,30 @@ %% Parsed topic filter. -type topic_filter() :: list(binary() | '+' | '#' | ''). --type keyspace() :: atom(). --type shard_id() :: binary(). --type shard() :: {keyspace(), shard_id()}. +%% This record enapsulates the stream entity from the replication +%% level. +%% +%% TODO: currently the stream is hardwired to only support the +%% internal rocksdb storage. In t he future we want to add another +%% implementations for emqx_ds, so this type has to take this into +%% account. +-record(stream, + { shard :: emqx_ds:shard() + , enc :: emqx_ds_replication_layer:stream() + }). + +-type stream_rank() :: {integer(), integer()}. + +-opaque stream() :: #stream{}. + +%% This record encapsulates the iterator entity from the replication +%% level. +-record(iterator, + { shard :: emqx_ds:shard() + , enc :: enqx_ds_replication_layer:iterator() + }). + +-opaque iterator() :: #iterator{}. %% Timestamp %% Earliest possible timestamp is 0. @@ -74,70 +76,89 @@ %% use in emqx_guid. Otherwise, the iterators won't match the message timestamps. -type time() :: non_neg_integer(). --type replay_id() :: binary(). +-type message_store_opts() :: #{}. --type replay() :: { - _TopicFilter :: topic_filter(), - _StartTime :: time() -}. +-type create_db_opts() :: #{}. + +-type message_id() :: binary(). %%================================================================================ %% API funcions %%================================================================================ --spec ensure_shard(shard(), emqx_ds_storage_layer:options()) -> - ok | {error, _Reason}. -ensure_shard(Shard, Options) -> - case emqx_ds_storage_layer_sup:start_shard(Shard, Options) of - {ok, _Pid} -> - ok; - {error, {already_started, _Pid}} -> - ok; - {error, Reason} -> - {error, Reason} +-spec create_db(db(), create_db_opts()) -> ok. +create_db(DB, Opts) -> + emqx_ds_replication_layer:create_db(DB, Opts). + +-spec message_store(db(), [emqx_types:message()], message_store_opts()) -> + {ok, [message_id()]} | {error, _}. +message_store(DB, Msgs, Opts) -> + emqx_ds_replication_layer:message_store(DB, Msgs, Opts). + +-spec message_store(db(), [emqx_types:message()]) -> {ok, [message_id()]} | {error, _}. +message_store(DB, Msgs) -> + message_store(DB, Msgs, #{}). + +%% @doc Get a list of streams needed for replaying a topic filter. +%% +%% Motivation: under the hood, EMQX may store different topics at +%% different locations or even in different databases. A wildcard +%% topic filter may require pulling data from any number of locations. +%% +%% Stream is an abstraction exposed by `emqx_ds' that reflects the +%% notion that different topics can be stored differently, but hides +%% the implementation details. +%% +%% Rules: +%% +%% 1. New streams matching the topic filter can appear without notice, +%% so the replayer must periodically call this function to get the +%% updated list of streams. +%% +%% 2. Streams may depend on one another. Therefore, care should be +%% taken while replaying them in parallel to avoid out-of-order +%% replay. This function returns stream together with its +%% "coordinates": `{X, T, Stream}'. If X coordinate of two streams is +%% different, then they can be replayed in parallel. If it's the +%% same, then the stream with smaller T coordinate should be replayed +%% first. +-spec get_streams(db(), topic_filter(), time()) -> [{stream_rank(), stream()}]. +get_streams(DB, TopicFilter, StartTime) -> + Shards = emqx_ds_replication_layer:list_shards(DB), + lists:flatmap( + fun(Shard) -> + Streams = emqx_ds_replication_layer:get_streams(Shard, TopicFilter, StartTime), + [{Rank, #stream{ shard = Shard + , enc = I + }} || {Rank, I} <- Streams] + end, + Shards). + +-spec open_iterator(stream(), time()) -> {ok, iterator()} | {error, _}. +open_iterator(#stream{shard = Shard, enc = Stream}, StartTime) -> + case emqx_ds_replication_layer:open_iterator(Shard, Stream, StartTime) of + {ok, Iter} -> + {ok, #iterator{shard = Shard, enc = Iter}}; + Err = {error, _} -> + Err end. -%%-------------------------------------------------------------------------------- -%% Message -%%-------------------------------------------------------------------------------- --spec message_store([emqx_types:message()], message_store_opts()) -> - {ok, [message_id()]} | {error, _}. -message_store(_Msg, _Opts) -> - %% TODO - {error, not_implemented}. +-spec next(iterator(), non_neg_integer()) -> {ok, iterator(), [emqx_types:message()]} | end_of_stream. +next(#iterator{shard = Shard, enc = Iter0}, BatchSize) -> + case emqx_ds_replication_layer:next(Shard, Iter0, BatchSize) of + {ok, Iter, Batch} -> + {ok, #iterator{shard = Shard, enc = Iter}, Batch}; + end_of_stream -> + end_of_stream + end. --spec message_store([emqx_types:message()]) -> {ok, [message_id()]} | {error, _}. -message_store(Msg) -> - %% TODO - message_store(Msg, #{}). +%%================================================================================ +%% behavior callbacks +%%================================================================================ --spec message_stats() -> message_stats(). -message_stats() -> - #{}. - -%%-------------------------------------------------------------------------------- -%% Session -%%-------------------------------------------------------------------------------- - -%%-------------------------------------------------------------------------------- -%% Iterator (pull API) -%%-------------------------------------------------------------------------------- - -%% @doc Called when a client acks a message --spec iterator_update(iterator_id(), iterator()) -> ok. -iterator_update(_IterId, _Iter) -> - %% TODO - ok. - -%% @doc Called when a client acks a message --spec iterator_next(iterator()) -> {value, emqx_types:message(), iterator()} | none | {error, _}. -iterator_next(_Iter) -> - %% TODO - none. - --spec iterator_stats() -> #{}. -iterator_stats() -> - #{}. +%%================================================================================ +%% Internal exports +%%================================================================================ %%================================================================================ %% Internal functions diff --git a/apps/emqx_durable_storage/src/emqx_ds.erl_ b/apps/emqx_durable_storage/src/emqx_ds.erl_ new file mode 100644 index 000000000..61b4c4bb3 --- /dev/null +++ b/apps/emqx_durable_storage/src/emqx_ds.erl_ @@ -0,0 +1,189 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- +-module(emqx_ds). + +-include_lib("stdlib/include/ms_transform.hrl"). +-include_lib("snabbkaffe/include/snabbkaffe.hrl"). + +%% API: +-export([ensure_shard/2]). +%% Messages: +-export([message_store/2, message_store/1, message_stats/0]). +%% Iterator: +-export([get_streams/3, open_iterator/1, next/2]). + +%% internal exports: +-export([]). + +-export_type([ + stream/0, + keyspace/0, + message_id/0, + message_stats/0, + message_store_opts/0, + replay/0, + replay_id/0, + %iterator_id/0, + iterator/0, + topic/0, + topic_filter/0, + time/0 +]). + +%%================================================================================ +%% Type declarations +%%================================================================================ + +%% This record enapsulates the stream entity from the storage level. +%% +%% TODO: currently the stream is hardwired to only support the +%% internal rocksdb storage. In t he future we want to add another +%% implementations for emqx_ds, so this type has to take this into +%% account. +-record(stream, + { shard :: emqx_ds:shard() + , :: emqx_ds_storage_layer:stream() + }). + +-opaque stream() :: #stream{}. + +-type iterator() :: term(). + +%-type iterator_id() :: binary(). + +-type message_store_opts() :: #{}. + +-type message_stats() :: #{}. + +-type message_id() :: binary(). + +%% Parsed topic. +-type topic() :: list(binary()). + +%% Parsed topic filter. +-type topic_filter() :: list(binary() | '+' | '#' | ''). + +-type keyspace() :: atom(). +-type shard_id() :: binary(). +-type shard() :: {keyspace(), shard_id()}. + +%% Timestamp +%% Earliest possible timestamp is 0. +%% TODO granularity? Currently, we should always use micro second, as that's the unit we +%% use in emqx_guid. Otherwise, the iterators won't match the message timestamps. +-type time() :: non_neg_integer(). + +-type replay_id() :: binary(). + +-type replay() :: { + _TopicFilter :: topic_filter(), + _StartTime :: time() +}. + +%%================================================================================ +%% API funcions +%%================================================================================ + +%% @doc Get a list of streams needed for replaying a topic filter. +%% +%% Motivation: under the hood, EMQX may store different topics at +%% different locations or even in different databases. A wildcard +%% topic filter may require pulling data from any number of locations. +%% +%% Stream is an abstraction exposed by `emqx_ds' that reflects the +%% notion that different topics can be stored differently, but hides +%% the implementation details. +%% +%% Rules: +%% +%% 1. New streams matching the topic filter can appear without notice, +%% so the replayer must periodically call this function to get the +%% updated list of streams. +%% +%% 2. Streams may depend on one another. Therefore, care should be +%% taken while replaying them in parallel to avoid out-of-order +%% replay. This function returns stream together with its +%% "coordinates": `{X, T, Stream}'. If X coordinate of two streams is +%% different, then they can be replayed in parallel. If it's the +%% same, then the stream with smaller T coordinate should be replayed +%% first. +-spec get_streams(keyspace(), topic_filter(), time()) -> [{integer(), integer(), stream()}]. +get_streams(Keyspace, TopicFilter, StartTime) -> + ShardIds = emqx_ds_replication_layer:get_all_shards(Keyspace), + lists:flatmap( + fun(Shard) -> + Node = emqx_ds_replication_layer:shard_to_node(Shard), + try + Streams = emqx_persistent_session_ds_proto_v1:get_streams(Node, Keyspace, Shard, TopicFilter, StartTime), + [#stream{ shard = {Keyspace, ShardId} + , stream = Stream + } || Stream <- Streams] + catch + error:{erpc, _} -> + %% The caller has to periodically refresh the + %% list of streams anyway, so it's ok to ignore + %% transient errors. + [] + end + end, + ShardIds). + +-spec ensure_shard(shard(), emqx_ds_storage_layer:options()) -> + ok | {error, _Reason}. +ensure_shard(Shard, Options) -> + case emqx_ds_storage_layer_sup:start_shard(Shard, Options) of + {ok, _Pid} -> + ok; + {error, {already_started, _Pid}} -> + ok; + {error, Reason} -> + {error, Reason} + end. + +%%-------------------------------------------------------------------------------- +%% Message +%%-------------------------------------------------------------------------------- + +-spec message_store([emqx_types:message()], message_store_opts()) -> + {ok, [message_id()]} | {error, _}. +message_store(Msg, Opts) -> + message_store(Msg, Opts). + +-spec message_store([emqx_types:message()]) -> {ok, [message_id()]} | {error, _}. +message_store(Msg) -> + message_store(Msg, #{}). + +-spec message_stats() -> message_stats(). +message_stats() -> + #{}. + +%%-------------------------------------------------------------------------------- +%% Iterator (pull API) +%%-------------------------------------------------------------------------------- + +-spec open_iterator(stream()) -> {ok, iterator()}. +open_iterator(#stream{shard = {_Keyspace, _ShardId}, stream = _StorageSpecificStream}) -> + error(todo). + +-spec next(iterator(), non_neg_integer()) -> + {ok, iterator(), [emqx_types:message()]} + | end_of_stream. +next(_Iterator, _BatchSize) -> + error(todo). + +%%================================================================================ +%% Internal functions +%%================================================================================ diff --git a/apps/emqx_durable_storage/src/emqx_ds_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_lts.erl index 384677d21..9d206ee81 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_lts.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_lts.erl @@ -458,7 +458,8 @@ topic_match_test() -> [{S2_12, [<<"1">>]}, {S2_1_, [<<"1">>, <<"2">>]}]), assert_match_topics(T, [2, '#'], - [{S21, []}, {S22, []}, {S2_, ['+']}, + [{S21, []}, {S22, []}, + {S2_, ['+']}, {S2_11, ['+']}, {S2_12, ['+']}, {S2_1_, ['+', '+']}]), ok diff --git a/apps/emqx_durable_storage/src/emqx_ds_message_storage_bitmask.erl b/apps/emqx_durable_storage/src/emqx_ds_message_storage_bitmask.erl index be8a207bb..f51d556f1 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_message_storage_bitmask.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_message_storage_bitmask.erl @@ -85,9 +85,9 @@ -export([store/5]). -export([delete/4]). --export([make_iterator/2]). --export([make_iterator/3]). --export([next/1]). + +-export([get_streams/2]). +-export([make_iterator/2, make_iterator/3, next/1]). -export([preserve_iterator/1]). -export([restore_iterator/2]). @@ -112,7 +112,7 @@ compute_topic_seek/4 ]). --export_type([db/0, iterator/0, schema/0]). +-export_type([db/0, stream/0, iterator/0, schema/0]). -export_type([options/0]). -export_type([iteration_options/0]). @@ -131,6 +131,8 @@ %% Type declarations %%================================================================================ +-opaque stream() :: singleton_stream. + -type topic() :: emqx_ds:topic(). -type topic_filter() :: emqx_ds:topic_filter(). -type time() :: emqx_ds:time(). @@ -288,6 +290,11 @@ delete(DB = #db{handle = DBHandle, cf = CFHandle}, MessageID, PublishedAt, Topic Key = make_message_key(Topic, PublishedAt, MessageID, DB#db.keymapper), rocksdb:delete(DBHandle, CFHandle, Key, DB#db.write_options). +-spec get_streams(db(), emqx_ds:reply()) -> + [stream()]. +get_streams(_, _) -> + [singleton_stream]. + -spec make_iterator(db(), emqx_ds:replay()) -> {ok, iterator()} | {error, _TODO}. make_iterator(DB, Replay) -> diff --git a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl new file mode 100644 index 000000000..9fe08e0a2 --- /dev/null +++ b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl @@ -0,0 +1,128 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- +-module(emqx_ds_replication_layer). + +-export([ + list_shards/1, + create_db/2, + message_store/3, + get_streams/3, + open_iterator/3, + next/3 + ]). + + +%% internal exports: +-export([ do_create_shard_v1/2, + do_get_streams_v1/3, + do_open_iterator/3, + do_next_v1/3 + ]). + +-export_type([shard/0, stream/0, iterator/0]). + +%%================================================================================ +%% Type declarations +%%================================================================================ + +-opaque stream() :: emqx_ds_storage_layer:stream(). + +-type shard() :: binary(). + +-opaque iterator() :: emqx_ds_storage_layer:iterator(). + +%%================================================================================ +%% API functions +%%================================================================================ + +-spec list_shards(emqx_ds:db()) -> [shard()]. +list_shards(DB) -> + %% TODO: milestone 5 + lists:map( + fun(Node) -> + term_to_binary({DB, Node}) + end, + list_nodes()). + +-spec create_db(emqx_ds:db(), emqx_ds:create_db_opts()) -> ok. +create_db(DB, Opts) -> + lists:foreach( + fun(Node) -> + Shard = term_to_binary({DB, Node}), + emqx_ds_proto_v1:create_shard(Node, Shard, Opts) + end, + list_nodes()). + +-spec message_store(emqx_ds:db(), [emqx_types:message()], emqx_ds:message_store_opts()) -> + {ok, [message_id()]} | {error, _}. +message_store(DB, Msg, Opts) -> + %% TODO: milestone 5. Currently we store messages locally. + Shard = term_to_binary({DB, node()}), + emqx_ds_storage_layer:message_store(Shard, Msg, Opts). + +-spec get_streams(shard(), emqx_ds:topic_filter(), emqx_ds:time()) -> [{emqx_ds:stream_rank(), stream()}]. +get_streams(Shard, TopicFilter, StartTime) -> + Node = node_of_shard(Shard), + emqx_ds_proto_v1:get_streams(Node, Shard, TopicFilter, StartTime). + +-spec open_iterator(shard(), stream(), emqx_ds:time()) -> {ok, iterator()} | {error, _}. +open_iterator(Shard, Stream, StartTime) -> + Node = node_of_shard(Shard), + emqx_ds_proto_v1:open_iterator(Node, Shard, Stream, StartTime). + +-spec next(shard(), iterator(), non_neg_integer()) -> + {ok, iterator(), [emqx_types:message()]} | end_of_stream. +next(Shard, Iter, BatchSize) -> + Node = node_of_shard(Shard), + emqx_ds_proto_v1:next(Node, Shard, Iter, BatchSize). + +%%================================================================================ +%% behavior callbacks +%%================================================================================ + +%%================================================================================ +%% Internal exports (RPC targets) +%%================================================================================ + +-spec do_create_shard_v1(shard(), emqx_ds:create_db_opts()) -> ok. +do_create_shard_v1(Shard, Opts) -> + error({todo, Shard, Opts}). + +-spec do_get_streams_v1(shard(), emqx_ds:topic_filter(), emqx_ds:time()) -> + [{emqx_ds:stream_rank(), stream()}]. +do_get_streams_v1(Shard, TopicFilter, StartTime) -> + error({todo, Shard, TopicFilter, StartTime}). + +-spec do_open_iterator_v1(shard(), stream(), emqx_ds:time()) -> iterator(). +do_open_iterator_v1(Shard, Stream, Time) -> + error({todo, Shard, Stream, StartTime}). + +-spec do_next_v1(shard(), iterator(), non_neg_integer()) -> + {ok, iterator(), [emqx_types:message()]} | end_of_stream. +do_next_v1(Shard, Iter, BatchSize) -> + error({todo, Shard, Iter, BatchSize}). + +%%================================================================================ +%% Internal functions +%%================================================================================ + +-spec node_of_shard(shard()) -> node(). +node_of_shard(ShardId) -> + {_DB, Node} = binary_to_term(ShardId), + Node. + +list_nodes() -> + mria:running_nodes(). diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl index 25a58950d..7a96cab51 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl @@ -9,6 +9,7 @@ -export([start_link/2]). -export([create_generation/3]). +-export([get_streams/3]). -export([store/5]). -export([delete/4]). @@ -27,7 +28,7 @@ %% behaviour callbacks: -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). --export_type([cf_refs/0, gen_id/0, options/0, state/0, iterator/0]). +-export_type([stream/0, cf_refs/0, gen_id/0, options/0, state/0, iterator/0]). -export_type([db_options/0, db_write_options/0, db_read_options/0]). -compile({inline, [meta_lookup/2]}). @@ -36,6 +37,8 @@ %% Type declarations %%================================================================================ +-opaque stream() :: {term()}. + -type options() :: #{ dir => file:filename() }. @@ -114,10 +117,10 @@ cf_refs(), _Schema ) -> - term(). + _DB. -callback store( - _Schema, + _DB, _MessageID :: binary(), emqx_ds:time(), emqx_ds:topic(), @@ -125,13 +128,16 @@ ) -> ok | {error, _}. --callback delete(_Schema, _MessageID :: binary(), emqx_ds:time(), emqx_ds:topic()) -> +-callback delete(_DB, _MessageID :: binary(), emqx_ds:time(), emqx_ds:topic()) -> ok | {error, _}. --callback make_iterator(_Schema, emqx_ds:replay()) -> +-callback get_streams(_DB, emqx_ds:topic_filter(), emqx_ds:time()) -> + [_Stream]. + +-callback make_iterator(_DB, emqx_ds:replay()) -> {ok, _It} | {error, _}. --callback restore_iterator(_Schema, _Serialized :: binary()) -> {ok, _It} | {error, _}. +-callback restore_iterator(_DB, _Serialized :: binary()) -> {ok, _It} | {error, _}. -callback preserve_iterator(_It) -> term(). @@ -146,6 +152,15 @@ start_link(Shard = {Keyspace, ShardId}, Options) -> gen_server:start_link(?REF(Keyspace, ShardId), ?MODULE, {Shard, Options}, []). +-spec get_streams(emqx_ds:keyspace(), emqx_ds:shard_id(), emqx_ds:topic_filter(), emqx_ds:time()) -> [stream()]. +get_streams(KeySpace, TopicFilter, StartTime) -> + %% FIXME: messages can be potentially stored in multiple + %% generations. This function should return the results from all + %% of them! + %% Otherwise we could LOSE messages when generations are switched. + {GenId, #{module := Mod, }} = meta_lookup_gen(Shard, StartTime), + + -spec create_generation( emqx_ds:shard(), emqx_ds:time(), emqx_ds_conf:backend_config() ) -> diff --git a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl new file mode 100644 index 000000000..f5d802003 --- /dev/null +++ b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl @@ -0,0 +1,56 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- +-module(emqx_ds_proto_v1). + +-behavior(emqx_bpapi). + +%% API: +-export([]). + +%% behavior callbacks: +-export([introduced_in/0]). + +%%================================================================================ +%% API funcions +%%================================================================================ + +-spec create_shard(node(), emqx_ds_replication_layer:shard(), emqx_ds:create_db_opts()) -> + ok. +create_shard(Node, Shard, Opts) -> + erpc:call(Node, emqx_ds_replication_layer, do_create_shard_v1, [Shard, Opts]). + +-spec get_streams(node(), emqx_ds_replication_layer:shard(), emqx_ds:topic_filter(), emqx_ds:time()) -> + [emqx_ds_replication_layer:stream()]. +get_streams(Shard, TopicFilter, Time) -> + erpc:call(Node, emqx_ds_replication_layer, do_get_streams_v1, [Shard, TopicFilter, Time]). + +-spec open_iterator(node(), emqx_ds_replication_layer:shard(), emqx_ds_replication_layer:stream(), emqx_ds:time()) -> + {ok, emqx_ds_replication_layer:iterator()} | {error, _}. +open_iterator(Node, Shard, Stream, StartTime) -> + erpc:call(Node, emqx_ds_replication_layer, do_open_iterator_v1, [Shard, Stream, Time]). + +-spec next(node(), emqx_ds_replication_layer:shard(), emqx_ds_replication_layer:iterator(), non_neg_integer()) -> + {ok, emqx_ds_replication_layer:iterator(), [emqx_types:messages()]} | end_of_stream. +next(Node, Shard, Iter, BatchSize) -> + erpc:call(Node, emqx_ds_replication_layer, do_next_v1, [Shard, Iter, BatchSize]). + +%%================================================================================ +%% behavior callbacks +%%================================================================================ + +introduced_in() -> + %% FIXME + "5.3.0". From 7095cb8583752a18e166822131536756f4d8e788 Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Tue, 3 Oct 2023 01:01:39 +0200 Subject: [PATCH 04/31] refactor(ds): Refactor storage layer --- apps/emqx/src/emqx_persistent_message.erl | 15 +---- apps/emqx_durable_storage/src/emqx_ds.erl | 17 ++++-- .../src/emqx_ds_replication_layer.erl | 18 ++++-- .../src/emqx_ds_storage_layer.erl | 55 ++++++++++++------- apps/emqx_durable_storage/src/emqx_ds_sup.erl | 4 +- .../src/proto/emqx_ds_proto_v1.erl | 6 +- .../test/emqx_ds_storage_layer_SUITE.erl | 36 ++++++++---- 7 files changed, 93 insertions(+), 58 deletions(-) diff --git a/apps/emqx/src/emqx_persistent_message.erl b/apps/emqx/src/emqx_persistent_message.erl index 609b0139d..3f38b4030 100644 --- a/apps/emqx/src/emqx_persistent_message.erl +++ b/apps/emqx/src/emqx_persistent_message.erl @@ -23,9 +23,7 @@ %% Message persistence -export([ - persist/1, - serialize/1, - deserialize/1 + persist/1 ]). %% FIXME @@ -83,18 +81,9 @@ needs_persistence(Msg) -> not (emqx_message:get_flag(dup, Msg) orelse emqx_message:is_sys(Msg)). store_message(Msg) -> - ID = emqx_message:id(Msg), - Timestamp = emqx_guid:timestamp(ID), - Topic = emqx_topic:words(emqx_message:topic(Msg)), - emqx_ds_storage_layer:store(?DS_SHARD, ID, Timestamp, Topic, serialize(Msg)). + emqx_ds:message_store([Msg]). has_subscribers(#message{topic = Topic}) -> emqx_persistent_session_ds_router:has_any_route(Topic). %% - -serialize(Msg) -> - term_to_binary(emqx_message:to_map(Msg)). - -deserialize(Bin) -> - emqx_message:from_map(binary_to_term(Bin)). diff --git a/apps/emqx_durable_storage/src/emqx_ds.erl b/apps/emqx_durable_storage/src/emqx_ds.erl index ad6a07330..762478932 100644 --- a/apps/emqx_durable_storage/src/emqx_ds.erl +++ b/apps/emqx_durable_storage/src/emqx_ds.erl @@ -19,7 +19,7 @@ -export([create_db/2]). %% Message storage API: --export([message_store/3, message_store/2]). +-export([message_store/1, message_store/2, message_store/3]). %% Message replay API: -export([get_streams/3, open_iterator/2, next/2]). @@ -53,7 +53,7 @@ %% implementations for emqx_ds, so this type has to take this into %% account. -record(stream, - { shard :: emqx_ds:shard() + { shard :: emqx_ds_replication_layer:shard() , enc :: emqx_ds_replication_layer:stream() }). @@ -64,7 +64,7 @@ %% This record encapsulates the iterator entity from the replication %% level. -record(iterator, - { shard :: emqx_ds:shard() + { shard :: emqx_ds_replication_layer:shard() , enc :: enqx_ds_replication_layer:iterator() }). @@ -80,7 +80,9 @@ -type create_db_opts() :: #{}. --type message_id() :: binary(). +-type message_id() :: emqx_ds_replication_layer:message_id(). + +-define(DEFAULT_DB, <<"default">>). %%================================================================================ %% API funcions @@ -90,6 +92,11 @@ create_db(DB, Opts) -> emqx_ds_replication_layer:create_db(DB, Opts). +-spec message_store([emqx_types:message()]) -> + {ok, [message_id()]} | {error, _}. +message_store(Msgs) -> + message_store(?DEFAULT_DB, Msgs, #{}). + -spec message_store(db(), [emqx_types:message()], message_store_opts()) -> {ok, [message_id()]} | {error, _}. message_store(DB, Msgs, Opts) -> @@ -143,7 +150,7 @@ open_iterator(#stream{shard = Shard, enc = Stream}, StartTime) -> Err end. --spec next(iterator(), non_neg_integer()) -> {ok, iterator(), [emqx_types:message()]} | end_of_stream. +-spec next(iterator(), pos_integer()) -> {ok, iterator(), [emqx_types:message()]} | end_of_stream. next(#iterator{shard = Shard, enc = Iter0}, BatchSize) -> case emqx_ds_replication_layer:next(Shard, Iter0, BatchSize) of {ok, Iter, Batch} -> diff --git a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl index 9fe08e0a2..af6087188 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl @@ -28,11 +28,11 @@ %% internal exports: -export([ do_create_shard_v1/2, do_get_streams_v1/3, - do_open_iterator/3, + do_open_iterator_v1/3, do_next_v1/3 ]). --export_type([shard/0, stream/0, iterator/0]). +-export_type([shard/0, stream/0, iterator/0, message_id/0]). %%================================================================================ %% Type declarations @@ -44,6 +44,8 @@ -opaque iterator() :: emqx_ds_storage_layer:iterator(). +-type message_id() :: emqx_ds_storage_layer:message_id(). + %%================================================================================ %% API functions %%================================================================================ @@ -83,10 +85,18 @@ open_iterator(Shard, Stream, StartTime) -> Node = node_of_shard(Shard), emqx_ds_proto_v1:open_iterator(Node, Shard, Stream, StartTime). --spec next(shard(), iterator(), non_neg_integer()) -> +-spec next(shard(), iterator(), pos_integer()) -> {ok, iterator(), [emqx_types:message()]} | end_of_stream. next(Shard, Iter, BatchSize) -> Node = node_of_shard(Shard), + %% TODO: iterator can contain information that is useful for + %% reconstructing messages sent over the network. For example, + %% when we send messages with the learned topic index, we could + %% send the static part of topic once, and append it to the + %% messages on the receiving node, hence saving some network. + %% + %% This kind of trickery should be probably done here in the + %% replication layer. Or, perhaps, in the logic lary. emqx_ds_proto_v1:next(Node, Shard, Iter, BatchSize). %%================================================================================ @@ -107,7 +117,7 @@ do_get_streams_v1(Shard, TopicFilter, StartTime) -> error({todo, Shard, TopicFilter, StartTime}). -spec do_open_iterator_v1(shard(), stream(), emqx_ds:time()) -> iterator(). -do_open_iterator_v1(Shard, Stream, Time) -> +do_open_iterator_v1(Shard, Stream, StartTime) -> error({todo, Shard, Stream, StartTime}). -spec do_next_v1(shard(), iterator(), non_neg_integer()) -> diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl index 7a96cab51..f4dbbe6f4 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl @@ -10,7 +10,7 @@ -export([create_generation/3]). -export([get_streams/3]). --export([store/5]). +-export([message_store/3]). -export([delete/4]). -export([make_iterator/2, next/1, next/2]). @@ -33,11 +33,13 @@ -compile({inline, [meta_lookup/2]}). +-include_lib("emqx/include/emqx.hrl"). + %%================================================================================ %% Type declarations %%================================================================================ --opaque stream() :: {term()}. +-type stream() :: term(). %% Opaque term returned by the generation callback module -type options() :: #{ dir => file:filename() @@ -101,7 +103,7 @@ %% 3. `inplace_update_support`? -define(ITERATOR_CF_OPTS, []). --define(REF(Keyspace, ShardId), {via, gproc, {n, l, {?MODULE, Keyspace, ShardId}}}). +-define(REF(ShardId), {via, gproc, {n, l, {?MODULE, ShardId}}}). %%================================================================================ %% Callbacks @@ -149,30 +151,34 @@ -spec start_link(emqx_ds:shard(), emqx_ds_storage_layer:options()) -> {ok, pid()}. -start_link(Shard = {Keyspace, ShardId}, Options) -> - gen_server:start_link(?REF(Keyspace, ShardId), ?MODULE, {Shard, Options}, []). +start_link(Shard, Options) -> + gen_server:start_link(?REF(Shard), ?MODULE, {Shard, Options}, []). --spec get_streams(emqx_ds:keyspace(), emqx_ds:shard_id(), emqx_ds:topic_filter(), emqx_ds:time()) -> [stream()]. -get_streams(KeySpace, TopicFilter, StartTime) -> - %% FIXME: messages can be potentially stored in multiple - %% generations. This function should return the results from all - %% of them! - %% Otherwise we could LOSE messages when generations are switched. - {GenId, #{module := Mod, }} = meta_lookup_gen(Shard, StartTime), +-spec get_streams(emqx_ds:shard_id(), emqx_ds:topic_filter(), emqx_ds:time()) -> [_Stream]. +get_streams(_ShardId, _TopicFilter, _StartTime) -> + []. -spec create_generation( emqx_ds:shard(), emqx_ds:time(), emqx_ds_conf:backend_config() ) -> {ok, gen_id()} | {error, nonmonotonic}. -create_generation({Keyspace, ShardId}, Since, Config = {_Module, _Options}) -> - gen_server:call(?REF(Keyspace, ShardId), {create_generation, Since, Config}). +create_generation(ShardId, Since, Config = {_Module, _Options}) -> + gen_server:call(?REF(ShardId), {create_generation, Since, Config}). --spec store(emqx_ds:shard(), emqx_guid:guid(), emqx_ds:time(), emqx_ds:topic(), binary()) -> - ok | {error, _}. -store(Shard, GUID, Time, Topic, Msg) -> - {_GenId, #{module := Mod, data := Data}} = meta_lookup_gen(Shard, Time), - Mod:store(Data, GUID, Time, Topic, Msg). +-spec message_store(emqx_ds:shard(), [emqx_types:message()], emqx_ds:message_store_opts()) -> + {ok, _MessageId} | {error, _}. +message_store(Shard, Msgs, _Opts) -> + {ok, lists:map( + fun(Msg) -> + GUID = emqx_message:id(Msg), + Timestamp = Msg#message.timestamp, + {_GenId, #{module := Mod, data := ModState}} = meta_lookup_gen(Shard, Timestamp), + Topic = emqx_topic:words(emqx_message:topic(Msg)), + Payload = serialize(Msg), + Mod:store(ModState, GUID, Timestamp, Topic, Payload) + end, + Msgs)}. -spec delete(emqx_ds:shard(), emqx_guid:guid(), emqx_ds:time(), emqx_ds:topic()) -> ok | {error, _}. @@ -212,7 +218,8 @@ do_next(It, N, Acc) when N =< 0 -> {ok, It, lists:reverse(Acc)}; do_next(It = #it{module = Mod, data = ItData}, N, Acc) -> case Mod:next(ItData) of - {value, Val, ItDataNext} -> + {value, Bin, ItDataNext} -> + Val = deserialize(Bin), do_next(It#it{data = ItDataNext}, N - 1, [Val | Acc]); {error, _} = _Error -> %% todo: log? @@ -663,6 +670,14 @@ is_gen_valid(Shard, GenId, Since) when GenId > 0 -> is_gen_valid(_Shard, 0, 0) -> ok. +serialize(Msg) -> + %% TODO: remove topic, GUID, etc. from the stored message. + term_to_binary(emqx_message:to_map(Msg)). + +deserialize(Bin) -> + emqx_message:from_map(binary_to_term(Bin)). + + %% -spec store_cfs(rocksdb:db_handle(), [{string(), rocksdb:cf_handle()}]) -> ok. %% store_cfs(DBHandle, CFRefs) -> %% lists:foreach( diff --git a/apps/emqx_durable_storage/src/emqx_ds_sup.erl b/apps/emqx_durable_storage/src/emqx_ds_sup.erl index ca939e892..d371a2346 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_sup.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_sup.erl @@ -30,7 +30,7 @@ start_link() -> %%================================================================================ init([]) -> - Children = [shard_sup()], + Children = [storage_layer_sup()], SupFlags = #{ strategy => one_for_all, intensity => 0, @@ -42,7 +42,7 @@ init([]) -> %% Internal functions %%================================================================================ -shard_sup() -> +storage_layer_sup() -> #{ id => local_store_shard_sup, start => {emqx_ds_storage_layer_sup, start_link, []}, diff --git a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl index f5d802003..79285fe16 100644 --- a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl +++ b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl @@ -34,15 +34,15 @@ create_shard(Node, Shard, Opts) -> -spec get_streams(node(), emqx_ds_replication_layer:shard(), emqx_ds:topic_filter(), emqx_ds:time()) -> [emqx_ds_replication_layer:stream()]. -get_streams(Shard, TopicFilter, Time) -> +get_streams(Node, Shard, TopicFilter, Time) -> erpc:call(Node, emqx_ds_replication_layer, do_get_streams_v1, [Shard, TopicFilter, Time]). -spec open_iterator(node(), emqx_ds_replication_layer:shard(), emqx_ds_replication_layer:stream(), emqx_ds:time()) -> {ok, emqx_ds_replication_layer:iterator()} | {error, _}. open_iterator(Node, Shard, Stream, StartTime) -> - erpc:call(Node, emqx_ds_replication_layer, do_open_iterator_v1, [Shard, Stream, Time]). + erpc:call(Node, emqx_ds_replication_layer, do_open_iterator_v1, [Shard, Stream, StartTime]). --spec next(node(), emqx_ds_replication_layer:shard(), emqx_ds_replication_layer:iterator(), non_neg_integer()) -> +-spec next(node(), emqx_ds_replication_layer:shard(), emqx_ds_replication_layer:iterator(), pos_integer()) -> {ok, emqx_ds_replication_layer:iterator(), [emqx_types:messages()]} | end_of_stream. next(Node, Shard, Iter, BatchSize) -> erpc:call(Node, emqx_ds_replication_layer, do_next_v1, [Shard, Iter, BatchSize]). diff --git a/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl b/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl index 10596e216..981f1062a 100644 --- a/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl +++ b/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl @@ -6,6 +6,7 @@ -compile(export_all). -compile(nowarn_export_all). +-include_lib("emqx/include/emqx.hrl"). -include_lib("common_test/include/ct.hrl"). -include_lib("stdlib/include/assert.hrl"). @@ -39,19 +40,24 @@ t_open(_Config) -> t_store(_Config) -> MessageID = emqx_guid:gen(), PublishedAt = 1000, - Topic = [<<"foo">>, <<"bar">>], + Topic = <<"foo/bar">>, Payload = <<"message">>, - ?assertMatch(ok, emqx_ds_storage_layer:store(?SHARD, MessageID, PublishedAt, Topic, Payload)). + Msg = #message{ + id = MessageID, + topic = Topic, + payload = Payload, + timestamp = PublishedAt + }, + ?assertMatch({ok, [_]}, emqx_ds_storage_layer:message_store(?SHARD, [Msg], #{})). %% Smoke test for iteration through a concrete topic t_iterate(_Config) -> %% Prepare data: - Topics = [[<<"foo">>, <<"bar">>], [<<"foo">>, <<"bar">>, <<"baz">>], [<<"a">>]], + Topics = [<<"foo/bar">>, <<"foo/bar/baz">>, <<"a">>], Timestamps = lists:seq(1, 10), [ - emqx_ds_storage_layer:store( + store( ?SHARD, - emqx_guid:gen(), PublishedAt, Topic, integer_to_binary(PublishedAt) @@ -61,7 +67,7 @@ t_iterate(_Config) -> %% Iterate through individual topics: [ begin - {ok, It} = emqx_ds_storage_layer:make_iterator(?SHARD, {Topic, 0}), + {ok, It} = emqx_ds_storage_layer:make_iterator(?SHARD, {parse_topic(Topic), 0}), Values = iterate(It), ?assertEqual(lists:map(fun integer_to_binary/1, Timestamps), Values) end @@ -149,7 +155,7 @@ t_create_gen(_Config) -> Topics = ["foo/bar", "foo/bar/baz"], Timestamps = lists:seq(1, 100), [ - ?assertEqual(ok, store(?SHARD, PublishedAt, Topic, <<>>)) + ?assertMatch({ok, [_]}, store(?SHARD, PublishedAt, Topic, <<>>)) || Topic <- Topics, PublishedAt <- Timestamps ]. @@ -215,16 +221,24 @@ t_iterate_multigen_preserve_restore(_Config) -> emqx_ds_storage_layer:restore_iterator(?SHARD, ReplayID) ). +store(Shard, PublishedAt, TopicL, Payload) when is_list(TopicL) -> + store(Shard, PublishedAt, list_to_binary(TopicL), Payload); store(Shard, PublishedAt, Topic, Payload) -> ID = emqx_guid:gen(), - emqx_ds_storage_layer:store(Shard, ID, PublishedAt, parse_topic(Topic), Payload). + Msg = #message{ + id = ID, + topic = Topic, + timestamp = PublishedAt, + payload = Payload + }, + emqx_ds_storage_layer:message_store(Shard, [Msg], #{}). iterate(DB, TopicFilter, StartTime) -> iterate(iterator(DB, TopicFilter, StartTime)). iterate(It) -> case emqx_ds_storage_layer:next(It) of - {ok, ItNext, [Payload]} -> + {ok, ItNext, [#message{payload = Payload}]} -> [Payload | iterate(ItNext)]; end_of_stream -> [] @@ -234,8 +248,8 @@ iterate(end_of_stream, _N) -> {end_of_stream, []}; iterate(It, N) -> case emqx_ds_storage_layer:next(It, N) of - {ok, ItFinal, Payloads} -> - {ItFinal, Payloads}; + {ok, ItFinal, Messages} -> + {ItFinal, [Payload || #message{payload = Payload} <- Messages]}; end_of_stream -> {end_of_stream, []} end. From 59d01dc82334ec634ca1894b5b85d2abd228944f Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Tue, 3 Oct 2023 02:48:06 +0200 Subject: [PATCH 05/31] refactor(ds): Implement emqx_ds:open_db --- apps/emqx/src/emqx_persistent_message.erl | 13 +------- apps/emqx_durable_storage/src/emqx_ds.erl | 9 +++--- apps/emqx_durable_storage/src/emqx_ds.erl_ | 2 +- .../src/emqx_ds_message_storage_bitmask.erl | 12 ++------ .../src/emqx_ds_replication_layer.erl | 30 +++++++++++-------- .../src/emqx_ds_storage_layer.erl | 25 ++++++++++------ .../src/emqx_ds_storage_layer_sup.erl | 11 +++++++ .../test/emqx_ds_storage_layer_SUITE.erl | 11 +++---- 8 files changed, 59 insertions(+), 54 deletions(-) diff --git a/apps/emqx/src/emqx_persistent_message.erl b/apps/emqx/src/emqx_persistent_message.erl index 3f38b4030..96c767d7e 100644 --- a/apps/emqx/src/emqx_persistent_message.erl +++ b/apps/emqx/src/emqx_persistent_message.erl @@ -42,18 +42,7 @@ init() -> ?WHEN_ENABLED(begin - ok = emqx_ds:ensure_shard( - ?DS_SHARD, - #{ - dir => filename:join([ - emqx:data_dir(), - ds, - messages, - ?DEFAULT_KEYSPACE, - ?DS_SHARD_ID - ]) - } - ), + ok = emqx_ds:create_db(<<"default">>, #{}), ok = emqx_persistent_session_ds_router:init_tables(), ok = emqx_persistent_session_ds:create_tables(), ok diff --git a/apps/emqx_durable_storage/src/emqx_ds.erl b/apps/emqx_durable_storage/src/emqx_ds.erl index 762478932..70cdd8d17 100644 --- a/apps/emqx_durable_storage/src/emqx_ds.erl +++ b/apps/emqx_durable_storage/src/emqx_ds.erl @@ -16,7 +16,7 @@ -module(emqx_ds). %% Management API: --export([create_db/2]). +-export([open_db/2]). %% Message storage API: -export([message_store/1, message_store/2, message_store/3]). @@ -88,9 +88,9 @@ %% API funcions %%================================================================================ --spec create_db(db(), create_db_opts()) -> ok. -create_db(DB, Opts) -> - emqx_ds_replication_layer:create_db(DB, Opts). +-spec open_db(db(), create_db_opts()) -> ok. +open_db(DB, Opts) -> + emqx_ds_replication_layer:open_db(DB, Opts). -spec message_store([emqx_types:message()]) -> {ok, [message_id()]} | {error, _}. @@ -102,6 +102,7 @@ message_store(Msgs) -> message_store(DB, Msgs, Opts) -> emqx_ds_replication_layer:message_store(DB, Msgs, Opts). +%% TODO: Do we really need to return message IDs? It's extra work... -spec message_store(db(), [emqx_types:message()]) -> {ok, [message_id()]} | {error, _}. message_store(DB, Msgs) -> message_store(DB, Msgs, #{}). diff --git a/apps/emqx_durable_storage/src/emqx_ds.erl_ b/apps/emqx_durable_storage/src/emqx_ds.erl_ index 61b4c4bb3..1acbcc7c7 100644 --- a/apps/emqx_durable_storage/src/emqx_ds.erl_ +++ b/apps/emqx_durable_storage/src/emqx_ds.erl_ @@ -143,7 +143,7 @@ get_streams(Keyspace, TopicFilter, StartTime) -> -spec ensure_shard(shard(), emqx_ds_storage_layer:options()) -> ok | {error, _Reason}. -ensure_shard(Shard, Options) -> +ensure_shard(Sharzd, Options) -> case emqx_ds_storage_layer_sup:start_shard(Shard, Options) of {ok, _Pid} -> ok; diff --git a/apps/emqx_durable_storage/src/emqx_ds_message_storage_bitmask.erl b/apps/emqx_durable_storage/src/emqx_ds_message_storage_bitmask.erl index f51d556f1..3290b03e6 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_message_storage_bitmask.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_message_storage_bitmask.erl @@ -87,7 +87,7 @@ -export([delete/4]). -export([get_streams/2]). --export([make_iterator/2, make_iterator/3, next/1]). +-export([make_iterator/3, next/1]). -export([preserve_iterator/1]). -export([restore_iterator/2]). @@ -295,13 +295,6 @@ delete(DB = #db{handle = DBHandle, cf = CFHandle}, MessageID, PublishedAt, Topic get_streams(_, _) -> [singleton_stream]. --spec make_iterator(db(), emqx_ds:replay()) -> - {ok, iterator()} | {error, _TODO}. -make_iterator(DB, Replay) -> - {Keyspace, _ShardId} = DB#db.shard, - Options = emqx_ds_conf:iteration_options(Keyspace), - make_iterator(DB, Replay, Options). - -spec make_iterator(db(), emqx_ds:replay(), iteration_options()) -> % {error, invalid_start_time}? might just start from the beginning of time % and call it a day: client violated the contract anyway. @@ -373,7 +366,8 @@ restore_iterator(DB, #{ cursor := Cursor, replay := Replay = {_TopicFilter, _StartTime} }) -> - case make_iterator(DB, Replay) of + Options = #{}, % TODO: passthrough options + case make_iterator(DB, Replay, Options) of {ok, It} when Cursor == undefined -> % Iterator was preserved right after it has been made. {ok, It}; diff --git a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl index af6087188..846d2ca0c 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl @@ -17,7 +17,7 @@ -export([ list_shards/1, - create_db/2, + open_db/2, message_store/3, get_streams/3, open_iterator/3, @@ -26,7 +26,7 @@ %% internal exports: --export([ do_create_shard_v1/2, +-export([ do_open_shard_v1/2, do_get_streams_v1/3, do_open_iterator_v1/3, do_next_v1/3 @@ -55,16 +55,16 @@ list_shards(DB) -> %% TODO: milestone 5 lists:map( fun(Node) -> - term_to_binary({DB, Node}) + shard_id(DB, Node) end, list_nodes()). --spec create_db(emqx_ds:db(), emqx_ds:create_db_opts()) -> ok. -create_db(DB, Opts) -> +-spec open_db(emqx_ds:db(), emqx_ds:create_db_opts()) -> ok. +open_db(DB, Opts) -> lists:foreach( fun(Node) -> - Shard = term_to_binary({DB, Node}), - emqx_ds_proto_v1:create_shard(Node, Shard, Opts) + Shard = shard_id(DB, Node), + emqx_ds_proto_v1:open_shard(Node, Shard, Opts) end, list_nodes()). @@ -107,9 +107,9 @@ next(Shard, Iter, BatchSize) -> %% Internal exports (RPC targets) %%================================================================================ --spec do_create_shard_v1(shard(), emqx_ds:create_db_opts()) -> ok. -do_create_shard_v1(Shard, Opts) -> - error({todo, Shard, Opts}). +-spec do_open_shard_v1(shard(), emqx_ds:create_db_opts()) -> ok. +do_open_shard_v1(Shard, Opts) -> + emqx_ds_storage_layer_sup:ensure_shard(Shard, Opts). -spec do_get_streams_v1(shard(), emqx_ds:topic_filter(), emqx_ds:time()) -> [{emqx_ds:stream_rank(), stream()}]. @@ -129,10 +129,16 @@ do_next_v1(Shard, Iter, BatchSize) -> %% Internal functions %%================================================================================ +shard_id(DB, Node) -> + %% TODO: don't bake node name into the schema, don't repeat the + %% Mnesia's 1M$ mistake. + NodeBin = atom_to_binary(Node), + <>. + -spec node_of_shard(shard()) -> node(). node_of_shard(ShardId) -> - {_DB, Node} = binary_to_term(ShardId), - Node. + [_DB, NodeBin] = binary:split(ShardId, <<":">>), + binary_to_atom(NodeBin). list_nodes() -> mria:running_nodes(). diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl index f4dbbe6f4..93c1aaa1f 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl @@ -69,6 +69,7 @@ -record(s, { shard :: emqx_ds:shard(), + keyspace :: emqx_ds_conf:keyspace(), db :: rocksdb:db_handle(), cf_iterator :: rocksdb:cf_handle(), cf_generations :: cf_refs() @@ -176,7 +177,8 @@ message_store(Shard, Msgs, _Opts) -> {_GenId, #{module := Mod, data := ModState}} = meta_lookup_gen(Shard, Timestamp), Topic = emqx_topic:words(emqx_message:topic(Msg)), Payload = serialize(Msg), - Mod:store(ModState, GUID, Timestamp, Topic, Payload) + Mod:store(ModState, GUID, Timestamp, Topic, Payload), + GUID end, Msgs)}. @@ -356,7 +358,7 @@ populate_metadata(GenId, S = #s{shard = Shard, db = DBHandle}) -> meta_register_gen(Shard, GenId, Gen). -spec ensure_current_generation(state()) -> state(). -ensure_current_generation(S = #s{shard = {Keyspace, _ShardId}, db = DBHandle}) -> +ensure_current_generation(S = #s{shard = _Shard, keyspace = Keyspace, db = DBHandle}) -> case schema_get_current(DBHandle) of undefined -> Config = emqx_ds_conf:keyspace_config(Keyspace), @@ -396,9 +398,11 @@ create_gen(GenId, Since, {Module, Options}, S = #s{db = DBHandle, cf_generations {ok, Gen, S#s{cf_generations = NewCFs ++ CFs}}. -spec open_db(emqx_ds:shard(), options()) -> {ok, state()} | {error, _TODO}. -open_db(Shard = {Keyspace, ShardId}, Options) -> - DefaultDir = filename:join([atom_to_binary(Keyspace), ShardId]), +open_db(Shard, Options) -> + DefaultDir = binary_to_list(Shard), DBDir = unicode:characters_to_list(maps:get(dir, Options, DefaultDir)), + %% TODO: properly forward keyspace + Keyspace = maps:get(keyspace, Options, default_keyspace), DBOptions = [ {create_if_missing, true}, {create_missing_column_families, true} @@ -423,6 +427,7 @@ open_db(Shard = {Keyspace, ShardId}, Options) -> {CFNames, _} = lists:unzip(ExistingCFs), {ok, #s{ shard = Shard, + keyspace = Keyspace, db = DBHandle, cf_iterator = CFIterator, cf_generations = lists:zip(CFNames, CFRefs) @@ -451,7 +456,8 @@ open_next_iterator(Gen = #{}, It) -> -spec open_iterator(generation(), iterator()) -> {ok, iterator()} | {error, _Reason}. open_iterator(#{module := Mod, data := Data}, It = #it{}) -> - case Mod:make_iterator(Data, It#it.replay) of + Options = #{}, % TODO: passthrough options + case Mod:make_iterator(Data, It#it.replay, Options) of {ok, ItData} -> {ok, It#it{module = Mod, data = ItData}}; Err -> @@ -611,9 +617,9 @@ meta_register_gen(Shard, GenId, Gen) -> -spec meta_lookup_gen(emqx_ds:shard(), emqx_ds:time()) -> {gen_id(), generation()}. meta_lookup_gen(Shard, Time) -> - % TODO - % Is cheaper persistent term GC on update here worth extra lookup? I'm leaning - % towards a "no". + %% TODO + %% Is cheaper persistent term GC on update here worth extra lookup? I'm leaning + %% towards a "no". Current = meta_lookup(Shard, current), Gens = meta_lookup(Shard, Current), find_gen(Time, Current, Gens). @@ -671,7 +677,8 @@ is_gen_valid(_Shard, 0, 0) -> ok. serialize(Msg) -> - %% TODO: remove topic, GUID, etc. from the stored message. + %% TODO: remove topic, GUID, etc. from the stored + %% message. Reconstruct it from the metadata. term_to_binary(emqx_message:to_map(Msg)). deserialize(Bin) -> diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer_sup.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer_sup.erl index 56c8c760a..2e4f56f10 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer_sup.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer_sup.erl @@ -35,6 +35,17 @@ stop_shard(Shard) -> ok = supervisor:terminate_child(?SUP, Shard), ok = supervisor:delete_child(?SUP, Shard). +-spec ensure_shard(emqx_ds:shard(), emqx_ds_storage_layer:options()) -> ok | {error, _Reason}. +ensure_shard(Shard, Options) -> + case start_shard(Shard, Options) of + {ok, _Pid} -> + ok; + {error, {already_started, _Pid}} -> + ok; + {error, Reason} -> + {error, Reason} + end. + %%================================================================================ %% behaviour callbacks %%================================================================================ diff --git a/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl b/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl index 981f1062a..25198cfd7 100644 --- a/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl +++ b/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl @@ -282,14 +282,11 @@ init_per_testcase(TC, Config) -> end_per_testcase(TC, _Config) -> ok = emqx_ds_storage_layer_sup:stop_shard(shard(TC)). -keyspace(TC) -> - list_to_atom(lists:concat([?MODULE, "_", TC])). - -shard_id(_TC) -> - <<"shard">>. - shard(TC) -> - {keyspace(TC), shard_id(TC)}. + iolist_to_binary([?MODULE_STRING, "_", atom_to_list(TC)]). + +keyspace(TC) -> + TC. set_keyspace_config(Keyspace, Config) -> ok = application:set_env(emqx_ds, keyspace_config, #{Keyspace => Config}). From c6a721a7eb431d5bd910bf84f23939f0caef744e Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Tue, 3 Oct 2023 17:13:16 +0200 Subject: [PATCH 06/31] refactor(ds): Passthrough open_db and get_channels to storage layer --- ...l => emqx_persistent_session_ds_SUITE.erl} | 2 +- apps/emqx/src/emqx_persistent_message.erl | 10 +- ...ds.erl => emqx_persistent_session_ds.erl_} | 64 +- .../emqx_persistent_session_ds_proto_v1.erl | 22 +- apps/emqx_durable_storage/src/emqx_ds.erl | 155 ++- apps/emqx_durable_storage/src/emqx_ds_lts.erl | 337 ++++--- .../src/emqx_ds_replication_layer.erl | 173 ++-- .../src/emqx_ds_storage_layer.erl | 893 ++++++------------ .../src/emqx_ds_storage_layer.erl_ | 714 ++++++++++++++ ...erl => emqx_ds_storage_layer_bitmask.erl_} | 18 +- .../src/emqx_ds_storage_layer_sup.erl | 2 +- .../src/emqx_ds_storage_reference.erl | 136 +++ .../src/proto/emqx_ds_proto_v1.erl | 33 +- .../test/emqx_ds_SUITE.erl | 107 +++ ...mqx_ds_message_storage_bitmask_SUITE.erl_} | 0 ...E.erl => emqx_ds_storage_layer_SUITE.erl_} | 0 scripts/check-elixir-applications.exs | 2 +- scripts/check-elixir-deps-discrepancies.exs | 2 +- ...elixir-emqx-machine-boot-discrepancies.exs | 2 +- scripts/check_missing_reboot_apps.exs | 2 +- 20 files changed, 1683 insertions(+), 991 deletions(-) rename apps/emqx/integration_test/{emqx_ds_SUITE.erl => emqx_persistent_session_ds_SUITE.erl} (99%) rename apps/emqx/src/{emqx_persistent_session_ds.erl => emqx_persistent_session_ds.erl_} (90%) create mode 100644 apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl_ rename apps/emqx_durable_storage/src/{emqx_ds_message_storage_bitmask.erl => emqx_ds_storage_layer_bitmask.erl_} (98%) create mode 100644 apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl create mode 100644 apps/emqx_durable_storage/test/emqx_ds_SUITE.erl rename apps/emqx_durable_storage/test/{emqx_ds_message_storage_bitmask_SUITE.erl => emqx_ds_message_storage_bitmask_SUITE.erl_} (100%) rename apps/emqx_durable_storage/test/{emqx_ds_storage_layer_SUITE.erl => emqx_ds_storage_layer_SUITE.erl_} (100%) diff --git a/apps/emqx/integration_test/emqx_ds_SUITE.erl b/apps/emqx/integration_test/emqx_persistent_session_ds_SUITE.erl similarity index 99% rename from apps/emqx/integration_test/emqx_ds_SUITE.erl rename to apps/emqx/integration_test/emqx_persistent_session_ds_SUITE.erl index 34c15b505..d2d23e8cd 100644 --- a/apps/emqx/integration_test/emqx_ds_SUITE.erl +++ b/apps/emqx/integration_test/emqx_persistent_session_ds_SUITE.erl @@ -1,7 +1,7 @@ %%-------------------------------------------------------------------- %% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. %%-------------------------------------------------------------------- --module(emqx_ds_SUITE). +-module(emqx_persistent_session_ds_SUITE). -compile(export_all). -compile(nowarn_export_all). diff --git a/apps/emqx/src/emqx_persistent_message.erl b/apps/emqx/src/emqx_persistent_message.erl index 96c767d7e..8801acce5 100644 --- a/apps/emqx/src/emqx_persistent_message.erl +++ b/apps/emqx/src/emqx_persistent_message.erl @@ -27,10 +27,6 @@ ]). %% FIXME --define(DS_SHARD_ID, <<"local">>). --define(DEFAULT_KEYSPACE, default). --define(DS_SHARD, {?DEFAULT_KEYSPACE, ?DS_SHARD_ID}). - -define(WHEN_ENABLED(DO), case is_store_enabled() of true -> DO; @@ -42,9 +38,9 @@ init() -> ?WHEN_ENABLED(begin - ok = emqx_ds:create_db(<<"default">>, #{}), + ok = emqx_ds:open_db(<<"default">>, #{}), ok = emqx_persistent_session_ds_router:init_tables(), - ok = emqx_persistent_session_ds:create_tables(), + %ok = emqx_persistent_session_ds:create_tables(), ok end). @@ -70,7 +66,7 @@ needs_persistence(Msg) -> not (emqx_message:get_flag(dup, Msg) orelse emqx_message:is_sys(Msg)). store_message(Msg) -> - emqx_ds:message_store([Msg]). + emqx_ds:store_batch([Msg]). has_subscribers(#message{topic = Topic}) -> emqx_persistent_session_ds_router:has_any_route(Topic). diff --git a/apps/emqx/src/emqx_persistent_session_ds.erl b/apps/emqx/src/emqx_persistent_session_ds.erl_ similarity index 90% rename from apps/emqx/src/emqx_persistent_session_ds.erl rename to apps/emqx/src/emqx_persistent_session_ds.erl_ index 174a02156..3fff5f7ba 100644 --- a/apps/emqx/src/emqx_persistent_session_ds.erl +++ b/apps/emqx/src/emqx_persistent_session_ds.erl_ @@ -62,22 +62,6 @@ -export([session_open/1]). -endif. -%% RPC --export([ - ensure_iterator_closed_on_all_shards/1, - ensure_all_iterators_closed/1 -]). --export([ - do_open_iterator/3, - do_ensure_iterator_closed/1, - do_ensure_all_iterators_closed/1 -]). - -%% FIXME --define(DS_SHARD_ID, atom_to_binary(node())). --define(DEFAULT_KEYSPACE, default). --define(DS_SHARD, {?DEFAULT_KEYSPACE, ?DS_SHARD_ID}). - %% Currently, this is the clientid. We avoid `emqx_types:clientid()' because that can be %% an atom, in theory (?). -type id() :: binary(). @@ -157,7 +141,6 @@ destroy(#{clientid := ClientID}) -> destroy_session(ClientID). destroy_session(ClientID) -> - _ = ensure_all_iterators_closed(ClientID), session_drop(ClientID). %%-------------------------------------------------------------------- @@ -410,9 +393,9 @@ open_iterator_on_all_shards(TopicFilter, Iterator) -> %% RPC target. -spec do_open_iterator(emqx_types:words(), emqx_ds:time(), emqx_ds:iterator_id()) -> {ok, emqx_ds_storage_layer:iterator()} | {error, _Reason}. -do_open_iterator(TopicFilter, StartMS, IteratorID) -> - Replay = {TopicFilter, StartMS}, - emqx_ds_storage_layer:ensure_iterator(?DS_SHARD, IteratorID, Replay). +do_open_iterator(TopicFilter, StartMS, _IteratorID) -> + %% TODO: wrong + {ok, emqx_ds:make_iterator(TopicFilter, StartMS)}. -spec del_subscription(topic(), iterator(), id()) -> ok. @@ -420,49 +403,8 @@ del_subscription(TopicFilterBin, #{id := IteratorID}, DSSessionID) -> % N.B.: see comments in `?MODULE:add_subscription' for a discussion about the % order of operations here. TopicFilter = emqx_topic:words(TopicFilterBin), - Ctx = #{iterator_id => IteratorID}, - ?tp_span( - persistent_session_ds_close_iterators, - Ctx, - ok = ensure_iterator_closed_on_all_shards(IteratorID) - ), - ?tp_span( - persistent_session_ds_iterator_delete, - Ctx, - session_del_iterator(DSSessionID, TopicFilter) - ), ok = emqx_persistent_session_ds_router:do_delete_route(TopicFilterBin, DSSessionID). --spec ensure_iterator_closed_on_all_shards(emqx_ds:iterator_id()) -> ok. -ensure_iterator_closed_on_all_shards(IteratorID) -> - %% Note: currently, shards map 1:1 to nodes, but this will change in the future. - Nodes = emqx:running_nodes(), - Results = emqx_persistent_session_ds_proto_v1:close_iterator(Nodes, IteratorID), - %% TODO: handle errors - true = lists:all(fun(Res) -> Res =:= {ok, ok} end, Results), - ok. - -%% RPC target. --spec do_ensure_iterator_closed(emqx_ds:iterator_id()) -> ok. -do_ensure_iterator_closed(IteratorID) -> - ok = emqx_ds_storage_layer:discard_iterator(?DS_SHARD, IteratorID), - ok. - --spec ensure_all_iterators_closed(id()) -> ok. -ensure_all_iterators_closed(DSSessionID) -> - %% Note: currently, shards map 1:1 to nodes, but this will change in the future. - Nodes = emqx:running_nodes(), - Results = emqx_persistent_session_ds_proto_v1:close_all_iterators(Nodes, DSSessionID), - %% TODO: handle errors - true = lists:all(fun(Res) -> Res =:= {ok, ok} end, Results), - ok. - -%% RPC target. --spec do_ensure_all_iterators_closed(id()) -> ok. -do_ensure_all_iterators_closed(DSSessionID) -> - ok = emqx_ds_storage_layer:discard_iterator_prefix(?DS_SHARD, DSSessionID), - ok. - %%-------------------------------------------------------------------- %% Session tables operations %%-------------------------------------------------------------------- diff --git a/apps/emqx/src/proto/emqx_persistent_session_ds_proto_v1.erl b/apps/emqx/src/proto/emqx_persistent_session_ds_proto_v1.erl index edaaea775..d9b882f3d 100644 --- a/apps/emqx/src/proto/emqx_persistent_session_ds_proto_v1.erl +++ b/apps/emqx/src/proto/emqx_persistent_session_ds_proto_v1.erl @@ -52,18 +52,20 @@ open_iterator(Nodes, TopicFilter, StartMS, IteratorID) -> ). -spec get_streams( - node(), - emqx_ds:keyspace(), - emqx_ds:shard_id(), - emqx_ds:topic_filter(), - emqx_ds:time()) -> - [emqx_ds_storage_layer:stream()]. + node(), + emqx_ds:keyspace(), + emqx_ds:shard_id(), + emqx_ds:topic_filter(), + emqx_ds:time() +) -> + [emqx_ds_storage_layer:stream()]. get_streams(Node, Keyspace, ShardId, TopicFilter, StartTime) -> erpc:call( - Node, - emqx_ds_storage_layer, - get_streams, - [Keyspace, ShardId, TopicFilter, StartTime]). + Node, + emqx_ds_storage_layer, + get_streams, + [Keyspace, ShardId, TopicFilter, StartTime] + ). -spec close_iterator( [node()], diff --git a/apps/emqx_durable_storage/src/emqx_ds.erl b/apps/emqx_durable_storage/src/emqx_ds.erl index 70cdd8d17..6a20afbf1 100644 --- a/apps/emqx_durable_storage/src/emqx_ds.erl +++ b/apps/emqx_durable_storage/src/emqx_ds.erl @@ -13,31 +13,44 @@ %% See the License for the specific language governing permissions and %% limitations under the License. %%-------------------------------------------------------------------- + +%% @doc Main interface module for `emqx_durable_storage' application. +%% +%% It takes care of forwarding calls to the underlying DBMS. Currently +%% only the embedded `emqx_ds_replication_layer' storage is supported, +%% so all the calls are simply passed through. -module(emqx_ds). %% Management API: -export([open_db/2]). %% Message storage API: --export([message_store/1, message_store/2, message_store/3]). +-export([store_batch/1, store_batch/2, store_batch/3]). %% Message replay API: --export([get_streams/3, open_iterator/2, next/2]). +-export([get_streams/3, make_iterator/2, next/2]). -%% internal exports: +%% Misc. API: -export([]). --export_type([db/0, time/0, topic_filter/0, topic/0]). +-export_type([ + db/0, + time/0, + topic_filter/0, + topic/0, + stream/0, + stream_rank/0, + iterator/0, + next_result/1, next_result/0, + store_batch_result/0, + make_iterator_result/1, make_iterator_result/0 +]). %%================================================================================ %% Type declarations %%================================================================================ -%% Different DBs are completely independent from each other. They -%% could represent something like different tenants. -%% -%% Topics stored in different DBs aren't necesserily disjoint. --type db() :: binary(). +-type db() :: emqx_ds_replication_layer:db(). %% Parsed topic. -type topic() :: list(binary()). @@ -45,30 +58,22 @@ %% Parsed topic filter. -type topic_filter() :: list(binary() | '+' | '#' | ''). -%% This record enapsulates the stream entity from the replication -%% level. -%% -%% TODO: currently the stream is hardwired to only support the -%% internal rocksdb storage. In t he future we want to add another -%% implementations for emqx_ds, so this type has to take this into -%% account. --record(stream, - { shard :: emqx_ds_replication_layer:shard() - , enc :: emqx_ds_replication_layer:stream() - }). - -type stream_rank() :: {integer(), integer()}. --opaque stream() :: #stream{}. +-opaque stream() :: emqx_ds_replication_layer:stream(). -%% This record encapsulates the iterator entity from the replication -%% level. --record(iterator, - { shard :: emqx_ds_replication_layer:shard() - , enc :: enqx_ds_replication_layer:iterator() - }). +-opaque iterator() :: emqx_ds_replication_layer:iterator(). --opaque iterator() :: #iterator{}. +-type store_batch_result() :: ok | {error, _}. + +-type make_iterator_result(Iterator) :: {ok, Iterator} | {error, _}. + +-type make_iterator_result() :: make_iterator_result(iterator()). + +-type next_result(Iterator) :: + {ok, Iterator, [emqx_types:message()]} | {ok, end_of_stream} | {error, _}. + +-type next_result() :: next_result(iterator()). %% Timestamp %% Earliest possible timestamp is 0. @@ -78,7 +83,9 @@ -type message_store_opts() :: #{}. --type create_db_opts() :: #{}. +-type create_db_opts() :: + %% TODO: keyspace + #{}. -type message_id() :: emqx_ds_replication_layer:message_id(). @@ -88,24 +95,24 @@ %% API funcions %%================================================================================ +%% @doc Different DBs are completely independent from each other. They +%% could represent something like different tenants. -spec open_db(db(), create_db_opts()) -> ok. open_db(DB, Opts) -> emqx_ds_replication_layer:open_db(DB, Opts). --spec message_store([emqx_types:message()]) -> - {ok, [message_id()]} | {error, _}. -message_store(Msgs) -> - message_store(?DEFAULT_DB, Msgs, #{}). +-spec store_batch([emqx_types:message()]) -> store_batch_result(). +store_batch(Msgs) -> + store_batch(?DEFAULT_DB, Msgs, #{}). --spec message_store(db(), [emqx_types:message()], message_store_opts()) -> - {ok, [message_id()]} | {error, _}. -message_store(DB, Msgs, Opts) -> - emqx_ds_replication_layer:message_store(DB, Msgs, Opts). +-spec store_batch(db(), [emqx_types:message()], message_store_opts()) -> store_batch_result(). +store_batch(DB, Msgs, Opts) -> + emqx_ds_replication_layer:store_batch(DB, Msgs, Opts). %% TODO: Do we really need to return message IDs? It's extra work... --spec message_store(db(), [emqx_types:message()]) -> {ok, [message_id()]} | {error, _}. -message_store(DB, Msgs) -> - message_store(DB, Msgs, #{}). +-spec store_batch(db(), [emqx_types:message()]) -> {ok, [message_id()]} | {error, _}. +store_batch(DB, Msgs) -> + store_batch(DB, Msgs, #{}). %% @doc Get a list of streams needed for replaying a topic filter. %% @@ -113,56 +120,44 @@ message_store(DB, Msgs) -> %% different locations or even in different databases. A wildcard %% topic filter may require pulling data from any number of locations. %% -%% Stream is an abstraction exposed by `emqx_ds' that reflects the -%% notion that different topics can be stored differently, but hides -%% the implementation details. +%% Stream is an abstraction exposed by `emqx_ds' that, on one hand, +%% reflects the notion that different topics can be stored +%% differently, but hides the implementation details. %% %% Rules: %% -%% 1. New streams matching the topic filter can appear without notice, -%% so the replayer must periodically call this function to get the -%% updated list of streams. +%% 0. There is no 1-to-1 mapping between MQTT topics and streams. One +%% stream can contain any number of MQTT topics. +%% +%% 1. New streams matching the topic filter and start time can appear +%% without notice, so the replayer must periodically call this +%% function to get the updated list of streams. %% %% 2. Streams may depend on one another. Therefore, care should be %% taken while replaying them in parallel to avoid out-of-order %% replay. This function returns stream together with its -%% "coordinates": `{X, T, Stream}'. If X coordinate of two streams is -%% different, then they can be replayed in parallel. If it's the -%% same, then the stream with smaller T coordinate should be replayed -%% first. +%% "coordinate": `stream_rank()'. +%% +%% Stream rank is a tuple of two integers, let's call them X and Y. If +%% X coordinate of two streams is different, they are independent and +%% can be replayed in parallel. If it's the same, then the stream with +%% smaller Y coordinate should be replayed first. If Y coordinates are +%% equal, then the streams are independent. +%% +%% Stream is fully consumed when `next/3' function returns +%% `end_of_stream'. Then the client can proceed to replaying streams +%% that depend on the given one. -spec get_streams(db(), topic_filter(), time()) -> [{stream_rank(), stream()}]. get_streams(DB, TopicFilter, StartTime) -> - Shards = emqx_ds_replication_layer:list_shards(DB), - lists:flatmap( - fun(Shard) -> - Streams = emqx_ds_replication_layer:get_streams(Shard, TopicFilter, StartTime), - [{Rank, #stream{ shard = Shard - , enc = I - }} || {Rank, I} <- Streams] - end, - Shards). + emqx_ds_replication_layer:get_streams(DB, TopicFilter, StartTime). --spec open_iterator(stream(), time()) -> {ok, iterator()} | {error, _}. -open_iterator(#stream{shard = Shard, enc = Stream}, StartTime) -> - case emqx_ds_replication_layer:open_iterator(Shard, Stream, StartTime) of - {ok, Iter} -> - {ok, #iterator{shard = Shard, enc = Iter}}; - Err = {error, _} -> - Err - end. +-spec make_iterator(stream(), time()) -> make_iterator_result(). +make_iterator(Stream, StartTime) -> + emqx_ds_replication_layer:make_iterator(Stream, StartTime). --spec next(iterator(), pos_integer()) -> {ok, iterator(), [emqx_types:message()]} | end_of_stream. -next(#iterator{shard = Shard, enc = Iter0}, BatchSize) -> - case emqx_ds_replication_layer:next(Shard, Iter0, BatchSize) of - {ok, Iter, Batch} -> - {ok, #iterator{shard = Shard, enc = Iter}, Batch}; - end_of_stream -> - end_of_stream - end. - -%%================================================================================ -%% behavior callbacks -%%================================================================================ +-spec next(iterator(), pos_integer()) -> next_result(). +next(Iter, BatchSize) -> + emqx_ds_replication_layer:next(Iter, BatchSize). %%================================================================================ %% Internal exports diff --git a/apps/emqx_durable_storage/src/emqx_ds_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_lts.erl index 9d206ee81..fcc9f2b36 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_lts.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_lts.erl @@ -34,7 +34,8 @@ %% Type declarations %%================================================================================ --define(EOT, []). %% End Of Topic +%% End Of Topic +-define(EOT, []). -define(PLUS, '+'). -type edge() :: binary() | ?EOT | ?PLUS. @@ -49,17 +50,17 @@ -type threshold_fun() :: fun((non_neg_integer()) -> non_neg_integer()). --record(trie, - { trie :: ets:tid() - , stats :: ets:tid() - }). +-record(trie, { + trie :: ets:tid(), + stats :: ets:tid() +}). -opaque trie() :: #trie{}. --record(trans, - { key :: {state(), edge()} - , next :: state() - }). +-record(trans, { + key :: {state(), edge()}, + next :: state() +}). %%================================================================================ %% API funcions @@ -70,9 +71,10 @@ trie_create() -> Trie = ets:new(trie, [{keypos, #trans.key}, set]), Stats = ets:new(stats, [{keypos, 1}, set]), - #trie{ trie = Trie - , stats = Stats - }. + #trie{ + trie = Trie, + stats = Stats + }. %% @doc Create a topic key, -spec topic_key(trie(), threshold_fun(), [binary()]) -> msg_storage_key(). @@ -86,7 +88,7 @@ lookup_topic_key(Trie, Tokens) -> %% @doc Return list of keys of topics that match a given topic filter -spec match_topics(trie(), [binary() | '+' | '#']) -> - [{static_key(), _Varying :: binary() | ?PLUS}]. + [{static_key(), _Varying :: binary() | ?PLUS}]. match_topics(Trie, TopicFilter) -> do_match_topics(Trie, ?PREFIX, [], TopicFilter). @@ -96,38 +98,43 @@ dump_to_dot(#trie{trie = Trie, stats = Stats}, Filename) -> L = ets:tab2list(Trie), {Nodes0, Edges} = lists:foldl( - fun(#trans{key = {From, Label}, next = To}, {AccN, AccEdge}) -> - Edge = {From, To, Label}, - {[From, To] ++ AccN, [Edge|AccEdge]} - end, - {[], []}, - L), + fun(#trans{key = {From, Label}, next = To}, {AccN, AccEdge}) -> + Edge = {From, To, Label}, + {[From, To] ++ AccN, [Edge | AccEdge]} + end, + {[], []}, + L + ), Nodes = lists:map( - fun(Node) -> - case ets:lookup(Stats, Node) of - [{_, NChildren}] -> ok; - [] -> NChildren = 0 - end, - {Node, NChildren} - end, - lists:usort(Nodes0)), - {ok, FD} = file:open(Filename, [write]), - Print = fun (?PREFIX) -> "prefix"; - (NodeId) -> binary:encode_hex(NodeId) + fun(Node) -> + case ets:lookup(Stats, Node) of + [{_, NChildren}] -> ok; + [] -> NChildren = 0 + end, + {Node, NChildren} end, + lists:usort(Nodes0) + ), + {ok, FD} = file:open(Filename, [write]), + Print = fun + (?PREFIX) -> "prefix"; + (NodeId) -> binary:encode_hex(NodeId) + end, io:format(FD, "digraph {~n", []), lists:foreach( - fun({Node, NChildren}) -> - Id = Print(Node), - io:format(FD, " \"~s\" [label=\"~s : ~p\"];~n", [Id, Id, NChildren]) - end, - Nodes), + fun({Node, NChildren}) -> + Id = Print(Node), + io:format(FD, " \"~s\" [label=\"~s : ~p\"];~n", [Id, Id, NChildren]) + end, + Nodes + ), lists:foreach( - fun({From, To, Label}) -> - io:format(FD, " \"~s\" -> \"~s\" [label=\"~s\"];~n", [Print(From), Print(To), Label]) - end, - Edges), + fun({From, To, Label}) -> + io:format(FD, " \"~s\" -> \"~s\" [label=\"~s\"];~n", [Print(From), Print(To), Label]) + end, + Edges + ), io:format(FD, "}~n", []), file:close(FD). @@ -135,12 +142,12 @@ dump_to_dot(#trie{trie = Trie, stats = Stats}, Filename) -> %% Internal exports %%================================================================================ --spec trie_next(trie(), state(), binary() | ?EOT) -> {Wildcard, state()} | undefined - when Wildcard :: boolean(). +-spec trie_next(trie(), state(), binary() | ?EOT) -> {Wildcard, state()} | undefined when + Wildcard :: boolean(). trie_next(#trie{trie = Trie}, State, ?EOT) -> case ets:lookup(Trie, {State, ?EOT}) of [#trans{next = Next}] -> {false, Next}; - [] -> undefined + [] -> undefined end; trie_next(#trie{trie = Trie}, State, Token) -> case ets:lookup(Trie, {State, ?PLUS}) of @@ -149,25 +156,27 @@ trie_next(#trie{trie = Trie}, State, Token) -> [] -> case ets:lookup(Trie, {State, Token}) of [#trans{next = Next}] -> {false, Next}; - [] -> undefined + [] -> undefined end end. --spec trie_insert(trie(), state(), edge()) -> {Updated, state()} - when Updated :: false | non_neg_integer(). +-spec trie_insert(trie(), state(), edge()) -> {Updated, state()} when + Updated :: false | non_neg_integer(). trie_insert(#trie{trie = Trie, stats = Stats}, State, Token) -> Key = {State, Token}, NewState = get_id_for_key(State, Token), - Rec = #trans{ key = Key - , next = NewState - }, + Rec = #trans{ + key = Key, + next = NewState + }, case ets:insert_new(Trie, Rec) of true -> - Inc = case Token of - ?EOT -> 0; - ?PLUS -> 0; - _ -> 1 - end, + Inc = + case Token of + ?EOT -> 0; + ?PLUS -> 0; + _ -> 1 + end, NChildren = ets:update_counter(Stats, State, {2, Inc}, {State, 0}), {NChildren, NewState}; false -> @@ -202,69 +211,75 @@ get_id_for_key(_State, _Token) -> do_match_topics(Trie, State, Varying, []) -> case trie_next(Trie, State, ?EOT) of {false, Static} -> [{Static, lists:reverse(Varying)}]; - undefined -> [] + undefined -> [] end; do_match_topics(Trie, State, Varying, ['#']) -> Emanating = emanating(Trie, State, ?PLUS), lists:flatmap( - fun({?EOT, Static}) -> - [{Static, lists:reverse(Varying)}]; - ({?PLUS, NextState}) -> - do_match_topics(Trie, NextState, [?PLUS|Varying], ['#']); - ({_, NextState}) -> - do_match_topics(Trie, NextState, Varying, ['#']) - end, - Emanating); -do_match_topics(Trie, State, Varying, [Level|Rest]) -> + fun + ({?EOT, Static}) -> + [{Static, lists:reverse(Varying)}]; + ({?PLUS, NextState}) -> + do_match_topics(Trie, NextState, [?PLUS | Varying], ['#']); + ({_, NextState}) -> + do_match_topics(Trie, NextState, Varying, ['#']) + end, + Emanating + ); +do_match_topics(Trie, State, Varying, [Level | Rest]) -> Emanating = emanating(Trie, State, Level), lists:flatmap( - fun({?EOT, _NextState}) -> - []; - ({?PLUS, NextState}) -> - do_match_topics(Trie, NextState, [Level|Varying], Rest); - ({_, NextState}) -> - do_match_topics(Trie, NextState, Varying, Rest) - end, - Emanating). + fun + ({?EOT, _NextState}) -> + []; + ({?PLUS, NextState}) -> + do_match_topics(Trie, NextState, [Level | Varying], Rest); + ({_, NextState}) -> + do_match_topics(Trie, NextState, Varying, Rest) + end, + Emanating + ). -spec do_lookup_topic_key(trie(), state(), [binary()], [binary()]) -> - {ok, msg_storage_key()} | undefined. + {ok, msg_storage_key()} | undefined. do_lookup_topic_key(Trie, State, [], Varying) -> - case trie_next(Trie, State, ?EOT) of - {false, Static} -> - {ok, {Static, lists:reverse(Varying)}}; - undefined -> - undefined - end; -do_lookup_topic_key(Trie, State, [Tok|Rest], Varying) -> - case trie_next(Trie, State, Tok) of - {true, NextState} -> - do_lookup_topic_key(Trie, NextState, Rest, [Tok|Varying]); - {false, NextState} -> - do_lookup_topic_key(Trie, NextState, Rest, Varying); - undefined -> - undefined - end. + case trie_next(Trie, State, ?EOT) of + {false, Static} -> + {ok, {Static, lists:reverse(Varying)}}; + undefined -> + undefined + end; +do_lookup_topic_key(Trie, State, [Tok | Rest], Varying) -> + case trie_next(Trie, State, Tok) of + {true, NextState} -> + do_lookup_topic_key(Trie, NextState, Rest, [Tok | Varying]); + {false, NextState} -> + do_lookup_topic_key(Trie, NextState, Rest, Varying); + undefined -> + undefined + end. do_topic_key(Trie, _, _, State, [], Varying) -> {_, false, Static} = trie_next_(Trie, State, ?EOT), {Static, lists:reverse(Varying)}; -do_topic_key(Trie, ThresholdFun, Depth, State, [Tok|Rest], Varying0) -> - Threshold = ThresholdFun(Depth), % TODO: it's not necessary to call it every time. - Varying = case trie_next_(Trie, State, Tok) of - {NChildren, _, _DiscardState} when is_integer(NChildren), NChildren > Threshold -> - {_, NextState} = trie_insert(Trie, State, ?PLUS), - [Tok|Varying0]; - {_, false, NextState} -> - Varying0; - {_, true, NextState} -> - [Tok|Varying0] - end, +do_topic_key(Trie, ThresholdFun, Depth, State, [Tok | Rest], Varying0) -> + % TODO: it's not necessary to call it every time. + Threshold = ThresholdFun(Depth), + Varying = + case trie_next_(Trie, State, Tok) of + {NChildren, _, _DiscardState} when is_integer(NChildren), NChildren > Threshold -> + {_, NextState} = trie_insert(Trie, State, ?PLUS), + [Tok | Varying0]; + {_, false, NextState} -> + Varying0; + {_, true, NextState} -> + [Tok | Varying0] + end, do_topic_key(Trie, ThresholdFun, Depth + 1, NextState, Rest, Varying). --spec trie_next_(trie(), state(), binary() | ?EOT) -> {New, Wildcard, state()} - when New :: false | non_neg_integer(), - Wildcard :: boolean(). +-spec trie_next_(trie(), state(), binary() | ?EOT) -> {New, Wildcard, state()} when + New :: false | non_neg_integer(), + Wildcard :: boolean(). trie_next_(Trie, State, Token) -> case trie_next(Trie, State, Token) of {Wildcard, NextState} -> @@ -278,19 +293,26 @@ trie_next_(Trie, State, Token) -> %% erlfmt-ignore -spec emanating(trie(), state(), edge()) -> [{edge(), state()}]. emanating(#trie{trie = Tab}, State, ?PLUS) -> - ets:select(Tab, ets:fun2ms( - fun(#trans{key = {S, Edge}, next = Next}) when S == State -> - {Edge, Next} - end)); + ets:select( + Tab, + ets:fun2ms( + fun(#trans{key = {S, Edge}, next = Next}) when S == State -> + {Edge, Next} + end + ) + ); emanating(#trie{trie = Tab}, State, ?EOT) -> case ets:lookup(Tab, {State, ?EOT}) of [#trans{next = Next}] -> [{?EOT, Next}]; - [] -> [] + [] -> [] end; emanating(#trie{trie = Tab}, State, Bin) when is_binary(Bin) -> - [{Edge, Next} || #trans{key = {_, Edge}, next = Next} <- - ets:lookup(Tab, {State, ?PLUS}) ++ - ets:lookup(Tab, {State, Bin})]. + [ + {Edge, Next} + || #trans{key = {_, Edge}, next = Next} <- + ets:lookup(Tab, {State, ?PLUS}) ++ + ets:lookup(Tab, {State, Bin}) + ]. %%================================================================================ %% Tests @@ -325,56 +347,71 @@ lookup_key_test() -> {_, S1} = trie_insert(T, ?PREFIX, <<"foo">>), {_, S11} = trie_insert(T, S1, <<"foo">>), %% Topics don't match until we insert ?EOT: - ?assertMatch( undefined - , lookup_topic_key(T, [<<"foo">>]) - ), - ?assertMatch( undefined - , lookup_topic_key(T, [<<"foo">>, <<"foo">>]) - ), + ?assertMatch( + undefined, + lookup_topic_key(T, [<<"foo">>]) + ), + ?assertMatch( + undefined, + lookup_topic_key(T, [<<"foo">>, <<"foo">>]) + ), {_, S10} = trie_insert(T, S1, ?EOT), {_, S110} = trie_insert(T, S11, ?EOT), - ?assertMatch( {ok, {S10, []}} - , lookup_topic_key(T, [<<"foo">>]) - ), - ?assertMatch( {ok, {S110, []}} - , lookup_topic_key(T, [<<"foo">>, <<"foo">>]) - ), + ?assertMatch( + {ok, {S10, []}}, + lookup_topic_key(T, [<<"foo">>]) + ), + ?assertMatch( + {ok, {S110, []}}, + lookup_topic_key(T, [<<"foo">>, <<"foo">>]) + ), %% The rest of keys still don't match: - ?assertMatch( undefined - , lookup_topic_key(T, [<<"bar">>]) - ), - ?assertMatch( undefined - , lookup_topic_key(T, [<<"bar">>, <<"foo">>]) - ). + ?assertMatch( + undefined, + lookup_topic_key(T, [<<"bar">>]) + ), + ?assertMatch( + undefined, + lookup_topic_key(T, [<<"bar">>, <<"foo">>]) + ). wildcard_lookup_test() -> T = trie_create(), {1, S1} = trie_insert(T, ?PREFIX, <<"foo">>), - {0, S11} = trie_insert(T, S1, ?PLUS), %% Plus doesn't increase the number of children + %% Plus doesn't increase the number of children + {0, S11} = trie_insert(T, S1, ?PLUS), {1, S111} = trie_insert(T, S11, <<"foo">>), - {0, S1110} = trie_insert(T, S111, ?EOT), %% ?EOT doesn't increase the number of children - ?assertMatch( {ok, {S1110, [<<"bar">>]}} - , lookup_topic_key(T, [<<"foo">>, <<"bar">>, <<"foo">>]) - ), - ?assertMatch( {ok, {S1110, [<<"quux">>]}} - , lookup_topic_key(T, [<<"foo">>, <<"quux">>, <<"foo">>]) - ), - ?assertMatch( undefined - , lookup_topic_key(T, [<<"foo">>]) - ), - ?assertMatch( undefined - , lookup_topic_key(T, [<<"foo">>, <<"bar">>]) - ), - ?assertMatch( undefined - , lookup_topic_key(T, [<<"foo">>, <<"bar">>, <<"bar">>]) - ), - ?assertMatch( undefined - , lookup_topic_key(T, [<<"bar">>, <<"foo">>, <<"foo">>]) - ), + %% ?EOT doesn't increase the number of children + {0, S1110} = trie_insert(T, S111, ?EOT), + ?assertMatch( + {ok, {S1110, [<<"bar">>]}}, + lookup_topic_key(T, [<<"foo">>, <<"bar">>, <<"foo">>]) + ), + ?assertMatch( + {ok, {S1110, [<<"quux">>]}}, + lookup_topic_key(T, [<<"foo">>, <<"quux">>, <<"foo">>]) + ), + ?assertMatch( + undefined, + lookup_topic_key(T, [<<"foo">>]) + ), + ?assertMatch( + undefined, + lookup_topic_key(T, [<<"foo">>, <<"bar">>]) + ), + ?assertMatch( + undefined, + lookup_topic_key(T, [<<"foo">>, <<"bar">>, <<"bar">>]) + ), + ?assertMatch( + undefined, + lookup_topic_key(T, [<<"bar">>, <<"foo">>, <<"foo">>]) + ), {_, S10} = trie_insert(T, S1, ?EOT), - ?assertMatch( {ok, {S10, []}} - , lookup_topic_key(T, [<<"foo">>]) - ). + ?assertMatch( + {ok, {S10, []}}, + lookup_topic_key(T, [<<"foo">>]) + ). %% erlfmt-ignore topic_key_test() -> diff --git a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl index 846d2ca0c..5d4749c30 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl @@ -16,33 +16,52 @@ -module(emqx_ds_replication_layer). -export([ - list_shards/1, - open_db/2, - message_store/3, - get_streams/3, - open_iterator/3, - next/3 - ]). - + list_shards/1, + open_db/2, + store_batch/3, + get_streams/3, + make_iterator/2, + next/2 +]). %% internal exports: --export([ do_open_shard_v1/2, - do_get_streams_v1/3, - do_open_iterator_v1/3, - do_next_v1/3 - ]). +-export([ + do_open_shard_v1/2, + do_get_streams_v1/3, + do_make_iterator_v1/3, + do_next_v1/3 +]). --export_type([shard/0, stream/0, iterator/0, message_id/0]). +-export_type([shard_id/0, stream/0, iterator/0, message_id/0]). %%================================================================================ %% Type declarations %%================================================================================ --opaque stream() :: emqx_ds_storage_layer:stream(). +-type db() :: binary(). --type shard() :: binary(). +-type shard_id() :: binary(). --opaque iterator() :: emqx_ds_storage_layer:iterator(). +%% This record enapsulates the stream entity from the replication +%% level. +%% +%% TODO: currently the stream is hardwired to only support the +%% internal rocksdb storage. In t he future we want to add another +%% implementations for emqx_ds, so this type has to take this into +%% account. +-record(stream, { + shard :: emqx_ds_replication_layer:shard_id(), + enc :: emqx_ds_replication_layer:stream() +}). + +-opaque stream() :: stream(). + +-record(iterator, { + shard :: emqx_ds_replication_layer:shard_id(), + enc :: enqx_ds_replication_layer:iterator() +}). + +-opaque iterator() :: #iterator{}. -type message_id() :: emqx_ds_storage_layer:message_id(). @@ -50,44 +69,71 @@ %% API functions %%================================================================================ --spec list_shards(emqx_ds:db()) -> [shard()]. +-spec list_shards(emqx_ds:db()) -> [shard_id()]. list_shards(DB) -> %% TODO: milestone 5 lists:map( - fun(Node) -> - shard_id(DB, Node) - end, - list_nodes()). + fun(Node) -> + shard_id(DB, Node) + end, + list_nodes() + ). --spec open_db(emqx_ds:db(), emqx_ds:create_db_opts()) -> ok. +-spec open_db(emqx_ds:db(), emqx_ds:create_db_opts()) -> ok | {error, _}. open_db(DB, Opts) -> + %% TODO: improve error reporting, don't just crash lists:foreach( - fun(Node) -> - Shard = shard_id(DB, Node), - emqx_ds_proto_v1:open_shard(Node, Shard, Opts) - end, - list_nodes()). + fun(Node) -> + Shard = shard_id(DB, Node), + ok = emqx_ds_proto_v1:open_shard(Node, Shard, Opts) + end, + list_nodes() + ). --spec message_store(emqx_ds:db(), [emqx_types:message()], emqx_ds:message_store_opts()) -> - {ok, [message_id()]} | {error, _}. -message_store(DB, Msg, Opts) -> - %% TODO: milestone 5. Currently we store messages locally. - Shard = term_to_binary({DB, node()}), - emqx_ds_storage_layer:message_store(Shard, Msg, Opts). +-spec store_batch(emqx_ds:db(), [emqx_types:message()], emqx_ds:message_store_opts()) -> + emqx_ds:store_batch_result(). +store_batch(DB, Msg, Opts) -> + %% TODO: Currently we store messages locally. + Shard = shard_id(DB, node()), + emqx_ds_storage_layer:store_batch(Shard, Msg, Opts). --spec get_streams(shard(), emqx_ds:topic_filter(), emqx_ds:time()) -> [{emqx_ds:stream_rank(), stream()}]. -get_streams(Shard, TopicFilter, StartTime) -> +-spec get_streams(db(), emqx_ds:topic_filter(), emqx_ds:time()) -> + [{emqx_ds:stream_rank(), stream()}]. +get_streams(DB, TopicFilter, StartTime) -> + Shards = emqx_ds_replication_layer:list_shards(DB), + lists:flatmap( + fun(Shard) -> + Node = node_of_shard(Shard), + Streams = emqx_ds_proto_v1:get_streams(Node, Shard, TopicFilter, StartTime), + lists:map( + fun({RankY, Stream}) -> + RankX = erlang:phash2(Shard, 255), + Rank = {RankX, RankY}, + {Rank, #stream{ + shard = Shard, + enc = Stream + }} + end, + Streams + ) + end, + Shards + ). + +-spec make_iterator(stream(), emqx_ds:time()) -> emqx_ds:make_iterator_result(iterator()). +make_iterator(Stream, StartTime) -> + #stream{shard = Shard, enc = StorageStream} = Stream, Node = node_of_shard(Shard), - emqx_ds_proto_v1:get_streams(Node, Shard, TopicFilter, StartTime). + case emqx_ds_proto_v1:make_iterator(Node, Shard, StorageStream, StartTime) of + {ok, Iter} -> + {ok, #iterator{shard = Shard, enc = Iter}}; + Err = {error, _} -> + Err + end. --spec open_iterator(shard(), stream(), emqx_ds:time()) -> {ok, iterator()} | {error, _}. -open_iterator(Shard, Stream, StartTime) -> - Node = node_of_shard(Shard), - emqx_ds_proto_v1:open_iterator(Node, Shard, Stream, StartTime). - --spec next(shard(), iterator(), pos_integer()) -> - {ok, iterator(), [emqx_types:message()]} | end_of_stream. -next(Shard, Iter, BatchSize) -> +-spec next(iterator(), pos_integer()) -> emqx_ds:next_result(iterator()). +next(Iter0, BatchSize) -> + #iterator{shard = Shard, enc = StorageIter0} = Iter0, Node = node_of_shard(Shard), %% TODO: iterator can contain information that is useful for %% reconstructing messages sent over the network. For example, @@ -97,7 +143,13 @@ next(Shard, Iter, BatchSize) -> %% %% This kind of trickery should be probably done here in the %% replication layer. Or, perhaps, in the logic lary. - emqx_ds_proto_v1:next(Node, Shard, Iter, BatchSize). + case emqx_ds_proto_v1:next(Node, Shard, StorageIter0, BatchSize) of + {ok, StorageIter, Batch} -> + Iter = #iterator{shard = Shard, enc = StorageIter}, + {ok, Iter, Batch}; + Other -> + Other + end. %%================================================================================ %% behavior callbacks @@ -107,35 +159,38 @@ next(Shard, Iter, BatchSize) -> %% Internal exports (RPC targets) %%================================================================================ --spec do_open_shard_v1(shard(), emqx_ds:create_db_opts()) -> ok. +-spec do_open_shard_v1(shard_id(), emqx_ds:create_db_opts()) -> ok. do_open_shard_v1(Shard, Opts) -> - emqx_ds_storage_layer_sup:ensure_shard(Shard, Opts). + emqx_ds_storage_layer:open_shard(Shard, Opts). --spec do_get_streams_v1(shard(), emqx_ds:topic_filter(), emqx_ds:time()) -> - [{emqx_ds:stream_rank(), stream()}]. +-spec do_get_streams_v1(shard_id(), emqx_ds:topic_filter(), emqx_ds:time()) -> + [{integer(), _Stream}]. do_get_streams_v1(Shard, TopicFilter, StartTime) -> - error({todo, Shard, TopicFilter, StartTime}). + emqx_ds_storage_layer:get_streams(Shard, TopicFilter, StartTime). --spec do_open_iterator_v1(shard(), stream(), emqx_ds:time()) -> iterator(). -do_open_iterator_v1(Shard, Stream, StartTime) -> - error({todo, Shard, Stream, StartTime}). +-spec do_make_iterator_v1(shard_id(), _Stream, emqx_ds:time()) -> {ok, iterator()} | {error, _}. +do_make_iterator_v1(Shard, Stream, StartTime) -> + emqx_ds_storage_layer:make_iterator(Shard, Stream, StartTime). --spec do_next_v1(shard(), iterator(), non_neg_integer()) -> - {ok, iterator(), [emqx_types:message()]} | end_of_stream. +-spec do_next_v1(shard_id(), Iter, pos_integer()) -> emqx_ds:next_result(Iter). do_next_v1(Shard, Iter, BatchSize) -> - error({todo, Shard, Iter, BatchSize}). + emqx_ds_storage_layer:next(Shard, Iter, BatchSize). %%================================================================================ %% Internal functions %%================================================================================ +add_shard_to_rank(Shard, RankY) -> + RankX = erlang:phash2(Shard, 255), + {RankX, RankY}. + shard_id(DB, Node) -> %% TODO: don't bake node name into the schema, don't repeat the %% Mnesia's 1M$ mistake. NodeBin = atom_to_binary(Node), - <>. + <>. --spec node_of_shard(shard()) -> node(). +-spec node_of_shard(shard_id()) -> node(). node_of_shard(ShardId) -> [_DB, NodeBin] = binary:split(ShardId, <<":">>), binary_to_atom(NodeBin). diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl index 93c1aaa1f..fdd81a095 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl @@ -1,332 +1,240 @@ %%-------------------------------------------------------------------- -%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. %%-------------------------------------------------------------------- -module(emqx_ds_storage_layer). -behaviour(gen_server). -%% API: --export([start_link/2]). --export([create_generation/3]). +%% Replication layer API: +-export([open_shard/2, store_batch/3, get_streams/3, make_iterator/3, next/3]). --export([get_streams/3]). --export([message_store/3]). --export([delete/4]). +%% gen_server +-export([start_link/2, init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). --export([make_iterator/2, next/1, next/2]). +%% internal exports: +-export([]). --export([ - preserve_iterator/2, - restore_iterator/2, - discard_iterator/2, - ensure_iterator/3, - discard_iterator_prefix/2, - list_iterator_prefix/2, - foldl_iterator_prefix/4 -]). - -%% behaviour callbacks: --export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). - --export_type([stream/0, cf_refs/0, gen_id/0, options/0, state/0, iterator/0]). --export_type([db_options/0, db_write_options/0, db_read_options/0]). - --compile({inline, [meta_lookup/2]}). - --include_lib("emqx/include/emqx.hrl"). +-export_type([gen_id/0, generation/0, cf_refs/0, stream/0, iterator/0]). %%================================================================================ %% Type declarations %%================================================================================ --type stream() :: term(). %% Opaque term returned by the generation callback module - --type options() :: #{ - dir => file:filename() -}. - -%% see rocksdb:db_options() --type db_options() :: proplists:proplist(). -%% see rocksdb:write_options() --type db_write_options() :: proplists:proplist(). -%% see rocksdb:read_options() --type db_read_options() :: proplists:proplist(). +-type shard_id() :: emqx_ds_replication_layer:shard_id(). -type cf_refs() :: [{string(), rocksdb:cf_handle()}]. -%% Message storage generation -%% Keep in mind that instances of this type are persisted in long-term storage. --type generation() :: #{ - %% Module that handles data for the generation +-type gen_id() :: 0..16#ffff. + +%% Note: this record might be stored permanently on a remote node. +-record(stream, { + generation :: gen_id(), + enc :: _EncapsultatedData, + misc = #{} :: map() +}). + +-opaque stream() :: #stream{}. + +%% Note: this record might be stored permanently on a remote node. +-record(it, { + generation :: gen_id(), + enc :: _EncapsultatedData, + misc = #{} :: map() +}). + +-opaque iterator() :: #it{}. + +%%%% Generation: + +-type generation(Data) :: #{ + %% Module that handles data for the generation: module := module(), - %% Module-specific data defined at generation creation time - data := term(), + %% Module-specific data defined at generation creation time: + data := Data, %% When should this generation become active? %% This generation should only contain messages timestamped no earlier than that. %% The very first generation will have `since` equal 0. - since := emqx_ds:time() + since := emqx_ds:time(), + until := emqx_ds:time() | undefined }. --record(s, { - shard :: emqx_ds:shard(), - keyspace :: emqx_ds_conf:keyspace(), - db :: rocksdb:db_handle(), - cf_iterator :: rocksdb:cf_handle(), - cf_generations :: cf_refs() -}). +%% Schema for a generation. Persistent term. +-type generation_schema() :: generation(term()). --record(it, { - shard :: emqx_ds:shard(), - gen :: gen_id(), - replay :: emqx_ds:replay(), - module :: module(), - data :: term() -}). +%% Runtime view of generation: +-type generation() :: generation(term()). --type gen_id() :: 0..16#ffff. +%%%% Shard: --opaque state() :: #s{}. --opaque iterator() :: #it{}. +-type shard(GenData) :: #{ + current_generation := gen_id(), + default_generation_module := module(), + default_generation_config := term(), + {generation, gen_id()} => GenData +}. -%% Contents of the default column family: -%% -%% [{<<"genNN">>, #generation{}}, ..., -%% {<<"current">>, GenID}] +%% Shard schema (persistent): +-type shard_schema() :: shard(generation_schema()). --define(DEFAULT_CF, "default"). --define(DEFAULT_CF_OPTS, []). +%% Shard (runtime): +-type shard() :: shard(generation()). --define(ITERATOR_CF, "$iterators"). +%%================================================================================ +%% Generation callbacks +%%================================================================================ -%% TODO -%% 1. CuckooTable might be of use here / `OptimizeForPointLookup(...)`. -%% 2. Supposedly might be compressed _very_ effectively. -%% 3. `inplace_update_support`? --define(ITERATOR_CF_OPTS, []). +%% Create the new schema given generation id and the options. +%% Create rocksdb column families. +-callback create(shard_id(), rocksdb:db_handle(), gen_id(), _Options) -> + {_Schema, cf_refs()}. + +%% Open the existing schema +-callback open(shard_id(), rocsdb:db_handle(), gen_id(), cf_refs(), _Schema) -> + _Data. + +-callback store_batch(shard_id(), _Data, [emqx_types:message()], emqx_ds:message_store_opts()) -> + ok. + +-callback get_streams(shard_id(), _Data, emqx_ds:topic_filter(), emqx_ds:time()) -> + [_Stream]. + +-callback make_iterator(shard_id(), _Data, _Stream, emqx_ds:time()) -> + emqx_ds:make_iterator_result(_Iterator). + +-callback next(shard_id(), _Data, Iter, pos_integer()) -> + {ok, Iter, [emqx_types:message()]} | {error, _}. + +%%================================================================================ +%% API for the replication layer +%%================================================================================ + +-spec open_shard(shard_id(), emqx_ds:create_db_opts()) -> ok. +open_shard(Shard, Options) -> + emqx_ds_storage_layer_sup:ensure_shard(Shard, Options). + +-spec store_batch(shard_id(), [emqx_types:message()], emqx_ds:message_store_opts()) -> + emqx_ds:store_batch_result(). +store_batch(Shard, Messages, Options) -> + %% We always store messages in the current generation: + GenId = generation_current(Shard), + #{module := Mod, data := GenData} = generation_get(Shard, GenId), + Mod:store_batch(Shard, GenData, Messages, Options). + +-spec get_streams(shard_id(), emqx_ds:topic_filter(), emqx_ds:time()) -> + [{integer(), stream()}]. +get_streams(Shard, TopicFilter, StartTime) -> + Gens = generations_since(Shard, StartTime), + lists:flatmap( + fun(GenId) -> + #{module := Mod, data := GenData} = generation_get(Shard, GenId), + Streams = Mod:get_streams(Shard, GenData, TopicFilter, StartTime), + [ + {GenId, #stream{ + generation = GenId, + enc = Stream + }} + || Stream <- Streams + ] + end, + Gens + ). + +-spec make_iterator(shard_id(), stream(), emqx_ds:time()) -> + emqx_ds:make_iterator_result(iterator()). +make_iterator(Shard, #stream{generation = GenId, enc = Stream}, StartTime) -> + #{module := Mod, data := GenData} = generation_get(Shard, GenId), + case Mod:make_iterator(Shard, GenData, Stream, StartTime) of + {ok, Iter} -> + {ok, #it{ + generation = GenId, + enc = Iter + }}; + {error, _} = Err -> + Err + end. + +-spec next(shard_id(), iterator(), pos_integer()) -> + emqx_ds:next_result(iterator()). +next(Shard, Iter = #it{generation = GenId, enc = GenIter0}, BatchSize) -> + #{module := Mod, data := GenData} = generation_get(Shard, GenId), + Current = generation_current(Shard), + case Mod:next(Shard, GenData, GenIter0, BatchSize) of + {ok, _GenIter, []} when GenId < Current -> + %% This is a past generation. Storage layer won't write + %% any more messages here. The iterator reached the end: + %% the stream has been fully replayed. + {ok, end_of_stream}; + {ok, GenIter, Batch} -> + {ok, Iter#it{enc = GenIter}, Batch}; + Error = {error, _} -> + Error + end. + +%%================================================================================ +%% gen_server for the shard +%%================================================================================ -define(REF(ShardId), {via, gproc, {n, l, {?MODULE, ShardId}}}). -%%================================================================================ -%% Callbacks -%%================================================================================ - --callback create_new(rocksdb:db_handle(), gen_id(), _Options :: term()) -> - {_Schema, cf_refs()}. - --callback open( - emqx_ds:shard(), - rocksdb:db_handle(), - gen_id(), - cf_refs(), - _Schema -) -> - _DB. - --callback store( - _DB, - _MessageID :: binary(), - emqx_ds:time(), - emqx_ds:topic(), - _Payload :: binary() -) -> - ok | {error, _}. - --callback delete(_DB, _MessageID :: binary(), emqx_ds:time(), emqx_ds:topic()) -> - ok | {error, _}. - --callback get_streams(_DB, emqx_ds:topic_filter(), emqx_ds:time()) -> - [_Stream]. - --callback make_iterator(_DB, emqx_ds:replay()) -> - {ok, _It} | {error, _}. - --callback restore_iterator(_DB, _Serialized :: binary()) -> {ok, _It} | {error, _}. - --callback preserve_iterator(_It) -> term(). - --callback next(It) -> {value, binary(), It} | none | {error, closed}. - -%%================================================================================ -%% API funcions -%%================================================================================ - --spec start_link(emqx_ds:shard(), emqx_ds_storage_layer:options()) -> +-spec start_link(emqx_ds:shard_id(), emqx_ds:create_db_opts()) -> {ok, pid()}. start_link(Shard, Options) -> gen_server:start_link(?REF(Shard), ?MODULE, {Shard, Options}, []). --spec get_streams(emqx_ds:shard_id(), emqx_ds:topic_filter(), emqx_ds:time()) -> [_Stream]. -get_streams(_ShardId, _TopicFilter, _StartTime) -> - []. +-record(s, { + shard_id :: emqx_ds:shard_id(), + db :: rocksdb:db_handle(), + cf_refs :: cf_refs(), + schema :: shard_schema(), + shard :: shard() +}). +-type server_state() :: #s{}. --spec create_generation( - emqx_ds:shard(), emqx_ds:time(), emqx_ds_conf:backend_config() -) -> - {ok, gen_id()} | {error, nonmonotonic}. -create_generation(ShardId, Since, Config = {_Module, _Options}) -> - gen_server:call(?REF(ShardId), {create_generation, Since, Config}). +-define(DEFAULT_CF, "default"). +-define(DEFAULT_CF_OPTS, []). --spec message_store(emqx_ds:shard(), [emqx_types:message()], emqx_ds:message_store_opts()) -> - {ok, _MessageId} | {error, _}. -message_store(Shard, Msgs, _Opts) -> - {ok, lists:map( - fun(Msg) -> - GUID = emqx_message:id(Msg), - Timestamp = Msg#message.timestamp, - {_GenId, #{module := Mod, data := ModState}} = meta_lookup_gen(Shard, Timestamp), - Topic = emqx_topic:words(emqx_message:topic(Msg)), - Payload = serialize(Msg), - Mod:store(ModState, GUID, Timestamp, Topic, Payload), - GUID - end, - Msgs)}. - --spec delete(emqx_ds:shard(), emqx_guid:guid(), emqx_ds:time(), emqx_ds:topic()) -> - ok | {error, _}. -delete(Shard, GUID, Time, Topic) -> - {_GenId, #{module := Mod, data := Data}} = meta_lookup_gen(Shard, Time), - Mod:delete(Data, GUID, Time, Topic). - --spec make_iterator(emqx_ds:shard(), emqx_ds:replay()) -> - {ok, iterator()} | {error, _TODO}. -make_iterator(Shard, Replay = {_, StartTime}) -> - {GenId, Gen} = meta_lookup_gen(Shard, StartTime), - open_iterator(Gen, #it{ - shard = Shard, - gen = GenId, - replay = Replay - }). - --spec next(iterator()) -> {ok, iterator(), [binary()]} | end_of_stream. -next(It = #it{}) -> - next(It, _BatchSize = 1). - --spec next(iterator(), pos_integer()) -> {ok, iterator(), [binary()]} | end_of_stream. -next(#it{data = {?MODULE, end_of_stream}}, _BatchSize) -> - end_of_stream; -next( - It = #it{shard = Shard, module = Mod, gen = Gen, data = {?MODULE, retry, Serialized}}, BatchSize -) -> - #{data := DBData} = meta_get_gen(Shard, Gen), - {ok, ItData} = Mod:restore_iterator(DBData, Serialized), - next(It#it{data = ItData}, BatchSize); -next(It = #it{}, BatchSize) -> - do_next(It, BatchSize, _Acc = []). - --spec do_next(iterator(), non_neg_integer(), [binary()]) -> - {ok, iterator(), [binary()]} | end_of_stream. -do_next(It, N, Acc) when N =< 0 -> - {ok, It, lists:reverse(Acc)}; -do_next(It = #it{module = Mod, data = ItData}, N, Acc) -> - case Mod:next(ItData) of - {value, Bin, ItDataNext} -> - Val = deserialize(Bin), - do_next(It#it{data = ItDataNext}, N - 1, [Val | Acc]); - {error, _} = _Error -> - %% todo: log? - %% iterator might be invalid now; will need to re-open it. - Serialized = Mod:preserve_iterator(ItData), - {ok, It#it{data = {?MODULE, retry, Serialized}}, lists:reverse(Acc)}; - none -> - case open_next_iterator(It) of - {ok, ItNext} -> - do_next(ItNext, N, Acc); - {error, _} = _Error -> - %% todo: log? - %% fixme: only bad options may lead to this? - %% return an "empty" iterator to be re-opened when retrying? - Serialized = Mod:preserve_iterator(ItData), - {ok, It#it{data = {?MODULE, retry, Serialized}}, lists:reverse(Acc)}; - none -> - case Acc of - [] -> - end_of_stream; - _ -> - {ok, It#it{data = {?MODULE, end_of_stream}}, lists:reverse(Acc)} - end - end - end. - --spec preserve_iterator(iterator(), emqx_ds:iterator_id()) -> - ok | {error, _TODO}. -preserve_iterator(It = #it{}, IteratorID) -> - iterator_put_state(IteratorID, It). - --spec restore_iterator(emqx_ds:shard(), emqx_ds:replay_id()) -> - {ok, iterator()} | {error, _TODO}. -restore_iterator(Shard, ReplayID) -> - case iterator_get_state(Shard, ReplayID) of - {ok, Serial} -> - restore_iterator_state(Shard, Serial); - not_found -> - {error, not_found}; - {error, _Reason} = Error -> - Error - end. - --spec ensure_iterator(emqx_ds:shard(), emqx_ds:iterator_id(), emqx_ds:replay()) -> - {ok, iterator()} | {error, _TODO}. -ensure_iterator(Shard, IteratorID, Replay = {_TopicFilter, _StartMS}) -> - case restore_iterator(Shard, IteratorID) of - {ok, It} -> - {ok, It}; - {error, not_found} -> - {ok, It} = make_iterator(Shard, Replay), - ok = emqx_ds_storage_layer:preserve_iterator(It, IteratorID), - {ok, It}; - Error -> - Error - end. - --spec discard_iterator(emqx_ds:shard(), emqx_ds:replay_id()) -> - ok | {error, _TODO}. -discard_iterator(Shard, ReplayID) -> - iterator_delete(Shard, ReplayID). - --spec discard_iterator_prefix(emqx_ds:shard(), binary()) -> - ok | {error, _TODO}. -discard_iterator_prefix(Shard, KeyPrefix) -> - case do_discard_iterator_prefix(Shard, KeyPrefix) of - {ok, _} -> ok; - Error -> Error - end. - --spec list_iterator_prefix( - emqx_ds:shard(), - binary() -) -> {ok, [emqx_ds:iterator_id()]} | {error, _TODO}. -list_iterator_prefix(Shard, KeyPrefix) -> - do_list_iterator_prefix(Shard, KeyPrefix). - --spec foldl_iterator_prefix( - emqx_ds:shard(), - binary(), - fun((_Key :: binary(), _Value :: binary(), Acc) -> Acc), - Acc -) -> {ok, Acc} | {error, _TODO} when - Acc :: term(). -foldl_iterator_prefix(Shard, KeyPrefix, Fn, Acc) -> - do_foldl_iterator_prefix(Shard, KeyPrefix, Fn, Acc). - -%%================================================================================ -%% behaviour callbacks -%%================================================================================ - -init({Shard, Options}) -> +init({ShardId, Options}) -> process_flag(trap_exit, true), - {ok, S0} = open_db(Shard, Options), - S = ensure_current_generation(S0), - ok = populate_metadata(S), + erase_schema_runtime(ShardId), + {ok, DB, CFRefs0} = rocksdb_open(ShardId, Options), + {Schema, CFRefs} = + case get_schema_persistent(DB) of + not_found -> + create_new_shard_schema(ShardId, DB, CFRefs0, Options); + Scm -> + {Scm, CFRefs0} + end, + Shard = open_shard(ShardId, DB, CFRefs, Schema), + S = #s{ + shard_id = ShardId, + db = DB, + cf_refs = CFRefs, + schema = Schema, + shard = Shard + }, + commit_metadata(S), {ok, S}. -handle_call({create_generation, Since, Config}, _From, S) -> - case create_new_gen(Since, Config, S) of - {ok, GenId, NS} -> - {reply, {ok, GenId}, NS}; - {error, _} = Error -> - {reply, Error, S} - end; +%% handle_call({create_generation, Since, Config}, _From, S) -> +%% case create_new_gen(Since, Config, S) of +%% {ok, GenId, NS} -> +%% {reply, {ok, GenId}, NS}; +%% {error, _} = Error -> +%% {reply, Error, S} +%% end; handle_call(_Call, _From, S) -> {reply, {error, unknown_call}, S}. @@ -336,359 +244,156 @@ handle_cast(_Cast, S) -> handle_info(_Info, S) -> {noreply, S}. -terminate(_Reason, #s{db = DB, shard = Shard}) -> - meta_erase(Shard), +terminate(_Reason, #s{db = DB, shard_id = ShardId}) -> + erase_schema_runtime(ShardId), ok = rocksdb:close(DB). +%%================================================================================ +%% Internal exports +%%================================================================================ + %%================================================================================ %% Internal functions %%================================================================================ --record(db, {handle :: rocksdb:db_handle(), cf_iterator :: rocksdb:cf_handle()}). +-spec open_shard(shard_id(), rocksdb:db_handle(), cf_refs(), shard_schema()) -> + shard(). +open_shard(ShardId, DB, CFRefs, ShardSchema) -> + %% Transform generation schemas to generation runtime data: + maps:map( + fun + ({generation, GenId}, GenSchema) -> + open_generation(ShardId, DB, CFRefs, GenId, GenSchema); + (_K, Val) -> + Val + end, + ShardSchema + ). --spec populate_metadata(state()) -> ok. -populate_metadata(S = #s{shard = Shard, db = DBHandle, cf_iterator = CFIterator}) -> - ok = meta_put(Shard, db, #db{handle = DBHandle, cf_iterator = CFIterator}), - Current = schema_get_current(DBHandle), - lists:foreach(fun(GenId) -> populate_metadata(GenId, S) end, lists:seq(0, Current)). +-spec open_generation(shard_id(), rocksdb:db_handle(), cf_refs(), gen_id(), generation_schema()) -> + generation(). +open_generation(ShardId, DB, CFRefs, GenId, GenSchema) -> + #{module := Mod, data := Schema} = GenSchema, + RuntimeData = Mod:open(ShardId, DB, GenId, CFRefs, Schema), + GenSchema#{data => RuntimeData}. --spec populate_metadata(gen_id(), state()) -> ok. -populate_metadata(GenId, S = #s{shard = Shard, db = DBHandle}) -> - Gen = open_gen(GenId, schema_get_gen(DBHandle, GenId), S), - meta_register_gen(Shard, GenId, Gen). - --spec ensure_current_generation(state()) -> state(). -ensure_current_generation(S = #s{shard = _Shard, keyspace = Keyspace, db = DBHandle}) -> - case schema_get_current(DBHandle) of - undefined -> - Config = emqx_ds_conf:keyspace_config(Keyspace), - {ok, _, NS} = create_new_gen(0, Config, S), - NS; - _GenId -> - S - end. - --spec create_new_gen(emqx_ds:time(), emqx_ds_conf:backend_config(), state()) -> - {ok, gen_id(), state()} | {error, nonmonotonic}. -create_new_gen(Since, Config, S = #s{shard = Shard, db = DBHandle}) -> - GenId = get_next_id(meta_get_current(Shard)), - GenId = get_next_id(schema_get_current(DBHandle)), - case is_gen_valid(Shard, GenId, Since) of - ok -> - {ok, Gen, NS} = create_gen(GenId, Since, Config, S), - %% TODO: Transaction? Column family creation can't be transactional, anyway. - ok = schema_put_gen(DBHandle, GenId, Gen), - ok = schema_put_current(DBHandle, GenId), - ok = meta_register_gen(Shard, GenId, open_gen(GenId, Gen, NS)), - {ok, GenId, NS}; - {error, _} = Error -> - Error - end. - --spec create_gen(gen_id(), emqx_ds:time(), emqx_ds_conf:backend_config(), state()) -> - {ok, generation(), state()}. -create_gen(GenId, Since, {Module, Options}, S = #s{db = DBHandle, cf_generations = CFs}) -> - % TODO: Backend implementation should ensure idempotency. - {Schema, NewCFs} = Module:create_new(DBHandle, GenId, Options), - Gen = #{ - module => Module, - data => Schema, - since => Since +-spec create_new_shard_schema(shard_id(), rocksdb:db_handle(), cf_refs(), _Options) -> + {shard_schema(), cf_refs()}. +create_new_shard_schema(ShardId, DB, CFRefs, _Options) -> + GenId = 1, + %% TODO: read from options/config + Mod = emqx_ds_storage_reference, + ModConfig = #{}, + {GenData, NewCFRefs} = Mod:create(ShardId, DB, GenId, ModConfig), + GenSchema = #{module => Mod, data => GenData, since => 0, until => undefined}, + ShardSchema = #{ + current_generation => GenId, + default_generation_module => Mod, + default_generation_confg => ModConfig, + {generation, GenId} => GenSchema }, - {ok, Gen, S#s{cf_generations = NewCFs ++ CFs}}. + {ShardSchema, NewCFRefs ++ CFRefs}. --spec open_db(emqx_ds:shard(), options()) -> {ok, state()} | {error, _TODO}. -open_db(Shard, Options) -> +%% @doc Commit current state of the server to both rocksdb and the persistent term +-spec commit_metadata(server_state()) -> ok. +commit_metadata(#s{shard_id = ShardId, schema = Schema, shard = Runtime, db = DB}) -> + ok = put_schema_persistent(DB, Schema), + put_schema_runtime(ShardId, Runtime). + +-spec rocksdb_open(shard_id(), emqx_ds:create_db_opts()) -> + {ok, rocksdb:db_handle(), cf_refs()} | {error, _TODO}. +rocksdb_open(Shard, Options) -> DefaultDir = binary_to_list(Shard), DBDir = unicode:characters_to_list(maps:get(dir, Options, DefaultDir)), - %% TODO: properly forward keyspace - Keyspace = maps:get(keyspace, Options, default_keyspace), DBOptions = [ {create_if_missing, true}, {create_missing_column_families, true} - | emqx_ds_conf:db_options(Keyspace) + | maps:get(db_options, Options, []) ], _ = filelib:ensure_dir(DBDir), ExistingCFs = case rocksdb:list_column_families(DBDir, DBOptions) of {ok, CFs} -> - [{Name, []} || Name <- CFs, Name /= ?DEFAULT_CF, Name /= ?ITERATOR_CF]; + [{Name, []} || Name <- CFs, Name /= ?DEFAULT_CF]; % DB is not present. First start {error, {db_open, _}} -> [] end, ColumnFamilies = [ - {?DEFAULT_CF, ?DEFAULT_CF_OPTS}, - {?ITERATOR_CF, ?ITERATOR_CF_OPTS} + {?DEFAULT_CF, ?DEFAULT_CF_OPTS} | ExistingCFs ], case rocksdb:open(DBDir, DBOptions, ColumnFamilies) of - {ok, DBHandle, [_CFDefault, CFIterator | CFRefs]} -> + {ok, DBHandle, [_CFDefault | CFRefs]} -> {CFNames, _} = lists:unzip(ExistingCFs), - {ok, #s{ - shard = Shard, - keyspace = Keyspace, - db = DBHandle, - cf_iterator = CFIterator, - cf_generations = lists:zip(CFNames, CFRefs) - }}; + {ok, DBHandle, lists:zip(CFNames, CFRefs)}; Error -> Error end. --spec open_gen(gen_id(), generation(), state()) -> generation(). -open_gen( - GenId, - Gen = #{module := Mod, data := Data}, - #s{shard = Shard, db = DBHandle, cf_generations = CFs} -) -> - DB = Mod:open(Shard, DBHandle, GenId, CFs, Data), - Gen#{data := DB}. +%%-------------------------------------------------------------------------------- +%% Schema access +%%-------------------------------------------------------------------------------- --spec open_next_iterator(iterator()) -> {ok, iterator()} | {error, _Reason} | none. -open_next_iterator(It = #it{shard = Shard, gen = GenId}) -> - open_next_iterator(meta_get_gen(Shard, GenId + 1), It#it{gen = GenId + 1}). +-spec generation_current(shard_id()) -> gen_id(). +generation_current(Shard) -> + #{current_generation := Current} = get_schema_runtime(Shard), + Current. -open_next_iterator(undefined, _It) -> - none; -open_next_iterator(Gen = #{}, It) -> - open_iterator(Gen, It). +-spec generation_get(shard_id(), gen_id()) -> generation(). +generation_get(Shard, GenId) -> + #{{generation, GenId} := GenData} = get_schema_runtime(Shard), + GenData. --spec open_iterator(generation(), iterator()) -> {ok, iterator()} | {error, _Reason}. -open_iterator(#{module := Mod, data := Data}, It = #it{}) -> - Options = #{}, % TODO: passthrough options - case Mod:make_iterator(Data, It#it.replay, Options) of - {ok, ItData} -> - {ok, It#it{module = Mod, data = ItData}}; - Err -> - Err - end. - --spec open_restore_iterator(generation(), iterator(), binary()) -> - {ok, iterator()} | {error, _Reason}. -open_restore_iterator(#{module := Mod, data := Data}, It = #it{}, Serial) -> - case Mod:restore_iterator(Data, Serial) of - {ok, ItData} -> - {ok, It#it{module = Mod, data = ItData}}; - Err -> - Err - end. - -%% - --define(KEY_REPLAY_STATE(IteratorId), <<(IteratorId)/binary, "rs">>). --define(KEY_REPLAY_STATE_PAT(KeyReplayState), begin - <> = (KeyReplayState), - IteratorId -end). - --define(ITERATION_WRITE_OPTS, []). --define(ITERATION_READ_OPTS, []). - -iterator_get_state(Shard, ReplayID) -> - #db{handle = Handle, cf_iterator = CF} = meta_lookup(Shard, db), - rocksdb:get(Handle, CF, ?KEY_REPLAY_STATE(ReplayID), ?ITERATION_READ_OPTS). - -iterator_put_state(ID, It = #it{shard = Shard}) -> - #db{handle = Handle, cf_iterator = CF} = meta_lookup(Shard, db), - Serial = preserve_iterator_state(It), - rocksdb:put(Handle, CF, ?KEY_REPLAY_STATE(ID), Serial, ?ITERATION_WRITE_OPTS). - -iterator_delete(Shard, ID) -> - #db{handle = Handle, cf_iterator = CF} = meta_lookup(Shard, db), - rocksdb:delete(Handle, CF, ?KEY_REPLAY_STATE(ID), ?ITERATION_WRITE_OPTS). - -preserve_iterator_state(#it{ - gen = Gen, - replay = {TopicFilter, StartTime}, - module = Mod, - data = ItData -}) -> - term_to_binary(#{ - v => 1, - gen => Gen, - filter => TopicFilter, - start => StartTime, - st => Mod:preserve_iterator(ItData) - }). - -restore_iterator_state(Shard, Serial) when is_binary(Serial) -> - restore_iterator_state(Shard, binary_to_term(Serial)); -restore_iterator_state( - Shard, - #{ - v := 1, - gen := Gen, - filter := TopicFilter, - start := StartTime, - st := State - } -) -> - It = #it{shard = Shard, gen = Gen, replay = {TopicFilter, StartTime}}, - open_restore_iterator(meta_get_gen(Shard, Gen), It, State). - -do_list_iterator_prefix(Shard, KeyPrefix) -> - Fn = fun(K0, _V, Acc) -> - K = ?KEY_REPLAY_STATE_PAT(K0), - [K | Acc] - end, - do_foldl_iterator_prefix(Shard, KeyPrefix, Fn, []). - -do_discard_iterator_prefix(Shard, KeyPrefix) -> - #db{handle = DBHandle, cf_iterator = CF} = meta_lookup(Shard, db), - Fn = fun(K, _V, _Acc) -> ok = rocksdb:delete(DBHandle, CF, K, ?ITERATION_WRITE_OPTS) end, - do_foldl_iterator_prefix(Shard, KeyPrefix, Fn, ok). - -do_foldl_iterator_prefix(Shard, KeyPrefix, Fn, Acc) -> - #db{handle = Handle, cf_iterator = CF} = meta_lookup(Shard, db), - case rocksdb:iterator(Handle, CF, ?ITERATION_READ_OPTS) of - {ok, It} -> - NextAction = {seek, KeyPrefix}, - do_foldl_iterator_prefix(Handle, CF, It, KeyPrefix, NextAction, Fn, Acc); - Error -> - Error - end. - -do_foldl_iterator_prefix(DBHandle, CF, It, KeyPrefix, NextAction, Fn, Acc) -> - case rocksdb:iterator_move(It, NextAction) of - {ok, K = <>, V} -> - NewAcc = Fn(K, V, Acc), - do_foldl_iterator_prefix(DBHandle, CF, It, KeyPrefix, next, Fn, NewAcc); - {ok, _K, _V} -> - ok = rocksdb:iterator_close(It), - {ok, Acc}; - {error, invalid_iterator} -> - ok = rocksdb:iterator_close(It), - {ok, Acc}; - Error -> - ok = rocksdb:iterator_close(It), - Error - end. - -%% Functions for dealing with the metadata stored persistently in rocksdb - --define(CURRENT_GEN, <<"current">>). --define(SCHEMA_WRITE_OPTS, []). --define(SCHEMA_READ_OPTS, []). - --spec schema_get_gen(rocksdb:db_handle(), gen_id()) -> generation(). -schema_get_gen(DBHandle, GenId) -> - {ok, Bin} = rocksdb:get(DBHandle, schema_gen_key(GenId), ?SCHEMA_READ_OPTS), - binary_to_term(Bin). - --spec schema_put_gen(rocksdb:db_handle(), gen_id(), generation()) -> ok | {error, _}. -schema_put_gen(DBHandle, GenId, Gen) -> - rocksdb:put(DBHandle, schema_gen_key(GenId), term_to_binary(Gen), ?SCHEMA_WRITE_OPTS). - --spec schema_get_current(rocksdb:db_handle()) -> gen_id() | undefined. -schema_get_current(DBHandle) -> - case rocksdb:get(DBHandle, ?CURRENT_GEN, ?SCHEMA_READ_OPTS) of - {ok, Bin} -> - binary_to_integer(Bin); - not_found -> - undefined - end. - --spec schema_put_current(rocksdb:db_handle(), gen_id()) -> ok | {error, _}. -schema_put_current(DBHandle, GenId) -> - rocksdb:put(DBHandle, ?CURRENT_GEN, integer_to_binary(GenId), ?SCHEMA_WRITE_OPTS). - --spec schema_gen_key(integer()) -> binary(). -schema_gen_key(N) -> - <<"gen", N:32>>. - --undef(CURRENT_GEN). --undef(SCHEMA_WRITE_OPTS). --undef(SCHEMA_READ_OPTS). - -%% Functions for dealing with the runtime shard metadata: - --define(PERSISTENT_TERM(SHARD, GEN), {?MODULE, SHARD, GEN}). - --spec meta_register_gen(emqx_ds:shard(), gen_id(), generation()) -> ok. -meta_register_gen(Shard, GenId, Gen) -> - Gs = - case GenId > 0 of - true -> meta_lookup(Shard, GenId - 1); - false -> [] +-spec generations_since(shard_id(), emqx_ds:time()) -> [gen_id()]. +generations_since(Shard, Since) -> + Schema = get_schema_runtime(Shard), + maps:fold( + fun + ({generation, GenId}, #{until := Until}, Acc) when Until >= Since -> + [GenId | Acc]; + (_K, _V, Acc) -> + Acc end, - ok = meta_put(Shard, GenId, [Gen | Gs]), - ok = meta_put(Shard, current, GenId). + [], + Schema + ). --spec meta_lookup_gen(emqx_ds:shard(), emqx_ds:time()) -> {gen_id(), generation()}. -meta_lookup_gen(Shard, Time) -> - %% TODO - %% Is cheaper persistent term GC on update here worth extra lookup? I'm leaning - %% towards a "no". - Current = meta_lookup(Shard, current), - Gens = meta_lookup(Shard, Current), - find_gen(Time, Current, Gens). +-define(PERSISTENT_TERM(SHARD), {emqx_ds_storage_layer, SHARD}). -find_gen(Time, GenId, [Gen = #{since := Since} | _]) when Time >= Since -> - {GenId, Gen}; -find_gen(Time, GenId, [_Gen | Rest]) -> - find_gen(Time, GenId - 1, Rest). +-spec get_schema_runtime(shard_id()) -> shard(). +get_schema_runtime(Shard) -> + persistent_term:get(?PERSISTENT_TERM(Shard)). --spec meta_get_gen(emqx_ds:shard(), gen_id()) -> generation() | undefined. -meta_get_gen(Shard, GenId) -> - case meta_lookup(Shard, GenId, []) of - [Gen | _Older] -> Gen; - [] -> undefined - end. +-spec put_schema_runtime(shard_id(), shard()) -> ok. +put_schema_runtime(Shard, RuntimeSchema) -> + persistent_term:put(?PERSISTENT_TERM(Shard), RuntimeSchema), + ok. --spec meta_get_current(emqx_ds:shard()) -> gen_id() | undefined. -meta_get_current(Shard) -> - meta_lookup(Shard, current, undefined). - --spec meta_lookup(emqx_ds:shard(), _K) -> _V. -meta_lookup(Shard, K) -> - persistent_term:get(?PERSISTENT_TERM(Shard, K)). - --spec meta_lookup(emqx_ds:shard(), _K, Default) -> _V | Default. -meta_lookup(Shard, K, Default) -> - persistent_term:get(?PERSISTENT_TERM(Shard, K), Default). - --spec meta_put(emqx_ds:shard(), _K, _V) -> ok. -meta_put(Shard, K, V) -> - persistent_term:put(?PERSISTENT_TERM(Shard, K), V). - --spec meta_erase(emqx_ds:shard()) -> ok. -meta_erase(Shard) -> - [ - persistent_term:erase(K) - || {K = ?PERSISTENT_TERM(Z, _), _} <- persistent_term:get(), Z =:= Shard - ], +-spec erase_schema_runtime(shard_id()) -> ok. +erase_schema_runtime(Shard) -> + persistent_term:erase(?PERSISTENT_TERM(Shard)), ok. -undef(PERSISTENT_TERM). -get_next_id(undefined) -> 0; -get_next_id(GenId) -> GenId + 1. +-define(ROCKSDB_SCHEMA_KEY, <<"schema_v1">>). -is_gen_valid(Shard, GenId, Since) when GenId > 0 -> - [GenPrev | _] = meta_lookup(Shard, GenId - 1), - case GenPrev of - #{since := SincePrev} when Since > SincePrev -> - ok; - #{} -> - {error, nonmonotonic} - end; -is_gen_valid(_Shard, 0, 0) -> - ok. +-spec get_schema_persistent(rocksdb:db_handle()) -> shard_schema() | not_found. +get_schema_persistent(DB) -> + case rocksdb:get(DB, ?ROCKSDB_SCHEMA_KEY, []) of + {ok, Blob} -> + Schema = binary_to_term(Blob), + %% Sanity check: + #{current_generation := _, default_generation_module := _} = Schema, + Schema; + not_found -> + not_found + end. -serialize(Msg) -> - %% TODO: remove topic, GUID, etc. from the stored - %% message. Reconstruct it from the metadata. - term_to_binary(emqx_message:to_map(Msg)). +-spec put_schema_persistent(rocksdb:db_handle(), shard_schema()) -> ok. +put_schema_persistent(DB, Schema) -> + Blob = term_to_binary(Schema), + rocksdb:put(DB, ?ROCKSDB_SCHEMA_KEY, Blob, []). -deserialize(Bin) -> - emqx_message:from_map(binary_to_term(Bin)). - - -%% -spec store_cfs(rocksdb:db_handle(), [{string(), rocksdb:cf_handle()}]) -> ok. -%% store_cfs(DBHandle, CFRefs) -> -%% lists:foreach( -%% fun({CFName, CFRef}) -> -%% persistent_term:put({self(), CFName}, {DBHandle, CFRef}) -%% end, -%% CFRefs). +-undef(ROCKSDB_SCHEMA_KEY). diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl_ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl_ new file mode 100644 index 000000000..32f18d18b --- /dev/null +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl_ @@ -0,0 +1,714 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- +-module(emqx_ds_storage_layer). + +-behaviour(gen_server). + +%% API: +-export([start_link/2]). +-export([create_generation/3]). + +-export([open_shard/2, get_streams/3]). +-export([message_store/3]). +-export([delete/4]). + +-export([make_iterator/3, next/1, next/2]). + +-export([ + preserve_iterator/2, + restore_iterator/2, + discard_iterator/2, + ensure_iterator/3, + discard_iterator_prefix/2, + list_iterator_prefix/2, + foldl_iterator_prefix/4 +]). + +%% gen_server callbacks: +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). + +-export_type([stream/0, cf_refs/0, gen_id/0, options/0, state/0, iterator/0]). +-export_type([db_options/0, db_write_options/0, db_read_options/0]). + +-compile({inline, [meta_lookup/2]}). + +-include_lib("emqx/include/emqx.hrl"). + +%%================================================================================ +%% Type declarations +%%================================================================================ + +-type options() :: #{ + dir => file:filename() +}. + +%% see rocksdb:db_options() +-type db_options() :: proplists:proplist(). +%% see rocksdb:write_options() +-type db_write_options() :: proplists:proplist(). +%% see rocksdb:read_options() +-type db_read_options() :: proplists:proplist(). + +-type cf_refs() :: [{string(), rocksdb:cf_handle()}]. + +%% Message storage generation +%% Keep in mind that instances of this type are persisted in long-term storage. +-type generation() :: #{ + %% Module that handles data for the generation + module := module(), + %% Module-specific data defined at generation creation time + data := term(), + %% When should this generation become active? + %% This generation should only contain messages timestamped no earlier than that. + %% The very first generation will have `since` equal 0. + since := emqx_ds:time() +}. + +-record(s, { + shard :: emqx_ds:shard(), + keyspace :: emqx_ds_conf:keyspace(), + db :: rocksdb:db_handle(), + cf_iterator :: rocksdb:cf_handle(), + cf_generations :: cf_refs() +}). + +-record(stream, + { generation :: gen_id() + , topic_filter :: emqx_ds:topic_filter() + , since :: emqx_ds:time() + , enc :: _EncapsultatedData + }). + +-opaque stream() :: #stream{}. + +-record(it, { + shard :: emqx_ds:shard(), + gen :: gen_id(), + replay :: emqx_ds:replay(), + module :: module(), + data :: term() +}). + +-type gen_id() :: 0..16#ffff. + +-opaque state() :: #s{}. +-opaque iterator() :: #it{}. + +%% Contents of the default column family: +%% +%% [{<<"genNN">>, #generation{}}, ..., +%% {<<"current">>, GenID}] + +-define(DEFAULT_CF, "default"). +-define(DEFAULT_CF_OPTS, []). + +-define(ITERATOR_CF, "$iterators"). + +%% TODO +%% 1. CuckooTable might be of use here / `OptimizeForPointLookup(...)`. +%% 2. Supposedly might be compressed _very_ effectively. +%% 3. `inplace_update_support`? +-define(ITERATOR_CF_OPTS, []). + +-define(REF(ShardId), {via, gproc, {n, l, {?MODULE, ShardId}}}). + +%%================================================================================ +%% Callbacks +%%================================================================================ + +-callback create_new(rocksdb:db_handle(), gen_id(), _Options :: term()) -> + {_Schema, cf_refs()}. + +-callback open( + emqx_ds:shard(), + rocksdb:db_handle(), + gen_id(), + cf_refs(), + _Schema +) -> + _DB. + +-callback store( + _DB, + _MessageID :: binary(), + emqx_ds:time(), + emqx_ds:topic(), + _Payload :: binary() +) -> + ok | {error, _}. + +-callback delete(_DB, _MessageID :: binary(), emqx_ds:time(), emqx_ds:topic()) -> + ok | {error, _}. + +-callback get_streams(_DB, emqx_ds:topic_filter(), emqx_ds:time()) -> + [{_TopicRankX, _Stream}]. + +-callback make_iterator(_DB, emqx_ds:replay()) -> + {ok, _It} | {error, _}. + +-callback restore_iterator(_DB, _Serialized :: binary()) -> {ok, _It} | {error, _}. + +-callback preserve_iterator(_It) -> term(). + +-callback next(It) -> {value, binary(), It} | none | {error, closed}. + +%%================================================================================ +%% Replication layer API +%%================================================================================ + +-spec open_shard(emqx_ds_replication_layer:shard(), emqx_ds_storage_layer:options()) -> ok. +open_shard(Shard, Options) -> + emqx_ds_storage_layer_sup:ensure_shard(Shard, Options). + +-spec get_streams(emqx_ds:shard_id(), emqx_ds:topic_filter(), emqx_ds:time()) -> [{emqx_ds:stream_rank(), _Stream}]. +get_streams(Shard, TopicFilter, StartTime) -> + %% TODO: lookup ALL generations + {GenId, #{module := Mod, data := ModState}} = meta_lookup_gen(Shard, StartTime), + lists:map( + fun({RankX, ModStream}) -> + Stream = #stream{ generation = GenId + , topic_filter = TopicFilter + , since = StartTime + , enc = ModStream + }, + Rank = {RankX, GenId}, + {Rank, Stream} + end, + Mod:get_streams(ModState, TopicFilter, StartTime)). + +-spec message_store(emqx_ds:shard(), [emqx_types:message()], emqx_ds:message_store_opts()) -> + {ok, _MessageId} | {error, _}. +message_store(Shard, Msgs, _Opts) -> + {ok, lists:map( + fun(Msg) -> + GUID = emqx_message:id(Msg), + Timestamp = Msg#message.timestamp, + {_GenId, #{module := Mod, data := ModState}} = meta_lookup_gen(Shard, Timestamp), + Topic = emqx_topic:words(emqx_message:topic(Msg)), + Payload = serialize(Msg), + Mod:store(ModState, GUID, Timestamp, Topic, Payload), + GUID + end, + Msgs)}. + +-spec next(iterator()) -> {ok, iterator(), [binary()]} | end_of_stream. +next(It = #it{}) -> + next(It, _BatchSize = 1). + +-spec next(iterator(), pos_integer()) -> {ok, iterator(), [binary()]} | end_of_stream. +next(#it{data = {?MODULE, end_of_stream}}, _BatchSize) -> + end_of_stream; +next( + It = #it{shard = Shard, module = Mod, gen = Gen, data = {?MODULE, retry, Serialized}}, BatchSize +) -> + #{data := DBData} = meta_get_gen(Shard, Gen), + {ok, ItData} = Mod:restore_iterator(DBData, Serialized), + next(It#it{data = ItData}, BatchSize); +next(It = #it{}, BatchSize) -> + do_next(It, BatchSize, _Acc = []). + +%%================================================================================ +%% API functions +%%================================================================================ + +-spec create_generation( + emqx_ds:shard(), emqx_ds:time(), emqx_ds_conf:backend_config() +) -> + {ok, gen_id()} | {error, nonmonotonic}. +create_generation(ShardId, Since, Config = {_Module, _Options}) -> + gen_server:call(?REF(ShardId), {create_generation, Since, Config}). + +-spec delete(emqx_ds:shard(), emqx_guid:guid(), emqx_ds:time(), emqx_ds:topic()) -> + ok | {error, _}. +delete(Shard, GUID, Time, Topic) -> + {_GenId, #{module := Mod, data := Data}} = meta_lookup_gen(Shard, Time), + Mod:delete(Data, GUID, Time, Topic). + +-spec make_iterator(emqx_ds:shard(), stream(), emqx_ds:time()) -> + {ok, iterator()} | {error, _TODO}. +make_iterator(Shard, Stream, StartTime) -> + #stream{ topic_filter = TopicFilter + , since = Since + , enc = Enc + } = Stream, + {GenId, Gen} = meta_lookup_gen(Shard, StartTime), + Replay = {TopicFilter, Since}, + case Mod:make_iterator(Data, Replay, Options) of + #it{ gen = GenId, + replay = {TopicFilter, Since} + }. + +-spec do_next(iterator(), non_neg_integer(), [binary()]) -> + {ok, iterator(), [binary()]} | end_of_stream. +do_next(It, N, Acc) when N =< 0 -> + {ok, It, lists:reverse(Acc)}; +do_next(It = #it{module = Mod, data = ItData}, N, Acc) -> + case Mod:next(ItData) of + {value, Bin, ItDataNext} -> + Val = deserialize(Bin), + do_next(It#it{data = ItDataNext}, N - 1, [Val | Acc]); + {error, _} = _Error -> + %% todo: log? + %% iterator might be invalid now; will need to re-open it. + Serialized = Mod:preserve_iterator(ItData), + {ok, It#it{data = {?MODULE, retry, Serialized}}, lists:reverse(Acc)}; + none -> + case open_next_iterator(It) of + {ok, ItNext} -> + do_next(ItNext, N, Acc); + {error, _} = _Error -> + %% todo: log? + %% fixme: only bad options may lead to this? + %% return an "empty" iterator to be re-opened when retrying? + Serialized = Mod:preserve_iterator(ItData), + {ok, It#it{data = {?MODULE, retry, Serialized}}, lists:reverse(Acc)}; + none -> + case Acc of + [] -> + end_of_stream; + _ -> + {ok, It#it{data = {?MODULE, end_of_stream}}, lists:reverse(Acc)} + end + end + end. + +-spec preserve_iterator(iterator(), emqx_ds:iterator_id()) -> + ok | {error, _TODO}. +preserve_iterator(It = #it{}, IteratorID) -> + iterator_put_state(IteratorID, It). + +-spec restore_iterator(emqx_ds:shard(), emqx_ds:replay_id()) -> + {ok, iterator()} | {error, _TODO}. +restore_iterator(Shard, ReplayID) -> + case iterator_get_state(Shard, ReplayID) of + {ok, Serial} -> + restore_iterator_state(Shard, Serial); + not_found -> + {error, not_found}; + {error, _Reason} = Error -> + Error + end. + +-spec ensure_iterator(emqx_ds:shard(), emqx_ds:iterator_id(), emqx_ds:replay()) -> + {ok, iterator()} | {error, _TODO}. +ensure_iterator(Shard, IteratorID, Replay = {_TopicFilter, _StartMS}) -> + case restore_iterator(Shard, IteratorID) of + {ok, It} -> + {ok, It}; + {error, not_found} -> + {ok, It} = make_iterator(Shard, Replay), + ok = emqx_ds_storage_layer:preserve_iterator(It, IteratorID), + {ok, It}; + Error -> + Error + end. + +-spec discard_iterator(emqx_ds:shard(), emqx_ds:replay_id()) -> + ok | {error, _TODO}. +discard_iterator(Shard, ReplayID) -> + iterator_delete(Shard, ReplayID). + +-spec discard_iterator_prefix(emqx_ds:shard(), binary()) -> + ok | {error, _TODO}. +discard_iterator_prefix(Shard, KeyPrefix) -> + case do_discard_iterator_prefix(Shard, KeyPrefix) of + {ok, _} -> ok; + Error -> Error + end. + +-spec list_iterator_prefix( + emqx_ds:shard(), + binary() +) -> {ok, [emqx_ds:iterator_id()]} | {error, _TODO}. +list_iterator_prefix(Shard, KeyPrefix) -> + do_list_iterator_prefix(Shard, KeyPrefix). + +-spec foldl_iterator_prefix( + emqx_ds:shard(), + binary(), + fun((_Key :: binary(), _Value :: binary(), Acc) -> Acc), + Acc +) -> {ok, Acc} | {error, _TODO} when + Acc :: term(). +foldl_iterator_prefix(Shard, KeyPrefix, Fn, Acc) -> + do_foldl_iterator_prefix(Shard, KeyPrefix, Fn, Acc). + +%%================================================================================ +%% gen_server +%%================================================================================ + +-spec start_link(emqx_ds:shard(), emqx_ds_storage_layer:options()) -> + {ok, pid()}. +start_link(Shard, Options) -> + gen_server:start_link(?REF(Shard), ?MODULE, {Shard, Options}, []). + +init({Shard, Options}) -> + process_flag(trap_exit, true), + {ok, S0} = do_open_db(Shard, Options), + S = ensure_current_generation(S0), + ok = populate_metadata(S), + {ok, S}. + +handle_call({create_generation, Since, Config}, _From, S) -> + case create_new_gen(Since, Config, S) of + {ok, GenId, NS} -> + {reply, {ok, GenId}, NS}; + {error, _} = Error -> + {reply, Error, S} + end; +handle_call(_Call, _From, S) -> + {reply, {error, unknown_call}, S}. + +handle_cast(_Cast, S) -> + {noreply, S}. + +handle_info(_Info, S) -> + {noreply, S}. + +terminate(_Reason, #s{db = DB, shard = Shard}) -> + meta_erase(Shard), + ok = rocksdb:close(DB). + +%%================================================================================ +%% Internal functions +%%================================================================================ + +-record(db, {handle :: rocksdb:db_handle(), cf_iterator :: rocksdb:cf_handle()}). + +-spec populate_metadata(state()) -> ok. +populate_metadata(S = #s{shard = Shard, db = DBHandle, cf_iterator = CFIterator}) -> + ok = meta_put(Shard, db, #db{handle = DBHandle, cf_iterator = CFIterator}), + Current = schema_get_current(DBHandle), + lists:foreach(fun(GenId) -> populate_metadata(GenId, S) end, lists:seq(0, Current)). + +-spec populate_metadata(gen_id(), state()) -> ok. +populate_metadata(GenId, S = #s{shard = Shard, db = DBHandle}) -> + Gen = open_gen(GenId, schema_get_gen(DBHandle, GenId), S), + meta_register_gen(Shard, GenId, Gen). + +-spec ensure_current_generation(state()) -> state(). +ensure_current_generation(S = #s{shard = _Shard, keyspace = Keyspace, db = DBHandle}) -> + case schema_get_current(DBHandle) of + undefined -> + Config = emqx_ds_conf:keyspace_config(Keyspace), + {ok, _, NS} = create_new_gen(0, Config, S), + NS; + _GenId -> + S + end. + +-spec create_new_gen(emqx_ds:time(), emqx_ds_conf:backend_config(), state()) -> + {ok, gen_id(), state()} | {error, nonmonotonic}. +create_new_gen(Since, Config, S = #s{shard = Shard, db = DBHandle}) -> + GenId = get_next_id(meta_get_current(Shard)), + GenId = get_next_id(schema_get_current(DBHandle)), + case is_gen_valid(Shard, GenId, Since) of + ok -> + {ok, Gen, NS} = create_gen(GenId, Since, Config, S), + %% TODO: Transaction? Column family creation can't be transactional, anyway. + ok = schema_put_gen(DBHandle, GenId, Gen), + ok = schema_put_current(DBHandle, GenId), + ok = meta_register_gen(Shard, GenId, open_gen(GenId, Gen, NS)), + {ok, GenId, NS}; + {error, _} = Error -> + Error + end. + +-spec create_gen(gen_id(), emqx_ds:time(), emqx_ds_conf:backend_config(), state()) -> + {ok, generation(), state()}. +create_gen(GenId, Since, {Module, Options}, S = #s{db = DBHandle, cf_generations = CFs}) -> + % TODO: Backend implementation should ensure idempotency. + {Schema, NewCFs} = Module:create_new(DBHandle, GenId, Options), + Gen = #{ + module => Module, + data => Schema, + since => Since + }, + {ok, Gen, S#s{cf_generations = NewCFs ++ CFs}}. + +-spec do_open_db(emqx_ds:shard(), options()) -> {ok, state()} | {error, _TODO}. +do_open_db(Shard, Options) -> + DefaultDir = binary_to_list(Shard), + DBDir = unicode:characters_to_list(maps:get(dir, Options, DefaultDir)), + %% TODO: properly forward keyspace + Keyspace = maps:get(keyspace, Options, default_keyspace), + DBOptions = [ + {create_if_missing, true}, + {create_missing_column_families, true} + | emqx_ds_conf:db_options(Keyspace) + ], + _ = filelib:ensure_dir(DBDir), + ExistingCFs = + case rocksdb:list_column_families(DBDir, DBOptions) of + {ok, CFs} -> + [{Name, []} || Name <- CFs, Name /= ?DEFAULT_CF, Name /= ?ITERATOR_CF]; + % DB is not present. First start + {error, {db_open, _}} -> + [] + end, + ColumnFamilies = [ + {?DEFAULT_CF, ?DEFAULT_CF_OPTS}, + {?ITERATOR_CF, ?ITERATOR_CF_OPTS} + | ExistingCFs + ], + case rocksdb:open(DBDir, DBOptions, ColumnFamilies) of + {ok, DBHandle, [_CFDefault, CFIterator | CFRefs]} -> + {CFNames, _} = lists:unzip(ExistingCFs), + {ok, #s{ + shard = Shard, + keyspace = Keyspace, + db = DBHandle, + cf_iterator = CFIterator, + cf_generations = lists:zip(CFNames, CFRefs) + }}; + Error -> + Error + end. + +-spec open_gen(gen_id(), generation(), state()) -> generation(). +open_gen( + GenId, + Gen = #{module := Mod, data := Data}, + #s{shard = Shard, db = DBHandle, cf_generations = CFs} +) -> + DB = Mod:open(Shard, DBHandle, GenId, CFs, Data), + Gen#{data := DB}. + +-spec open_next_iterator(iterator()) -> {ok, iterator()} | {error, _Reason} | none. +open_next_iterator(It = #it{shard = Shard, gen = GenId}) -> + open_next_iterator(meta_get_gen(Shard, GenId + 1), It#it{gen = GenId + 1}). + +open_next_iterator(undefined, _It) -> + none; +open_next_iterator(Gen = #{}, It) -> + open_iterator(Gen, It). + +-spec open_restore_iterator(generation(), iterator(), binary()) -> + {ok, iterator()} | {error, _Reason}. +open_restore_iterator(#{module := Mod, data := Data}, It = #it{}, Serial) -> + case Mod:restore_iterator(Data, Serial) of + {ok, ItData} -> + {ok, It#it{module = Mod, data = ItData}}; + Err -> + Err + end. + +%% + +-define(KEY_REPLAY_STATE(IteratorId), <<(IteratorId)/binary, "rs">>). +-define(KEY_REPLAY_STATE_PAT(KeyReplayState), begin + <> = (KeyReplayState), + IteratorId +end). + +-define(ITERATION_WRITE_OPTS, []). +-define(ITERATION_READ_OPTS, []). + +iterator_get_state(Shard, ReplayID) -> + #db{handle = Handle, cf_iterator = CF} = meta_lookup(Shard, db), + rocksdb:get(Handle, CF, ?KEY_REPLAY_STATE(ReplayID), ?ITERATION_READ_OPTS). + +iterator_put_state(ID, It = #it{shard = Shard}) -> + #db{handle = Handle, cf_iterator = CF} = meta_lookup(Shard, db), + Serial = preserve_iterator_state(It), + rocksdb:put(Handle, CF, ?KEY_REPLAY_STATE(ID), Serial, ?ITERATION_WRITE_OPTS). + +iterator_delete(Shard, ID) -> + #db{handle = Handle, cf_iterator = CF} = meta_lookup(Shard, db), + rocksdb:delete(Handle, CF, ?KEY_REPLAY_STATE(ID), ?ITERATION_WRITE_OPTS). + +preserve_iterator_state(#it{ + gen = Gen, + replay = {TopicFilter, StartTime}, + module = Mod, + data = ItData +}) -> + term_to_binary(#{ + v => 1, + gen => Gen, + filter => TopicFilter, + start => StartTime, + st => Mod:preserve_iterator(ItData) + }). + +restore_iterator_state(Shard, Serial) when is_binary(Serial) -> + restore_iterator_state(Shard, binary_to_term(Serial)); +restore_iterator_state( + Shard, + #{ + v := 1, + gen := Gen, + filter := TopicFilter, + start := StartTime, + st := State + } +) -> + It = #it{shard = Shard, gen = Gen, replay = {TopicFilter, StartTime}}, + open_restore_iterator(meta_get_gen(Shard, Gen), It, State). + +do_list_iterator_prefix(Shard, KeyPrefix) -> + Fn = fun(K0, _V, Acc) -> + K = ?KEY_REPLAY_STATE_PAT(K0), + [K | Acc] + end, + do_foldl_iterator_prefix(Shard, KeyPrefix, Fn, []). + +do_discard_iterator_prefix(Shard, KeyPrefix) -> + #db{handle = DBHandle, cf_iterator = CF} = meta_lookup(Shard, db), + Fn = fun(K, _V, _Acc) -> ok = rocksdb:delete(DBHandle, CF, K, ?ITERATION_WRITE_OPTS) end, + do_foldl_iterator_prefix(Shard, KeyPrefix, Fn, ok). + +do_foldl_iterator_prefix(Shard, KeyPrefix, Fn, Acc) -> + #db{handle = Handle, cf_iterator = CF} = meta_lookup(Shard, db), + case rocksdb:iterator(Handle, CF, ?ITERATION_READ_OPTS) of + {ok, It} -> + NextAction = {seek, KeyPrefix}, + do_foldl_iterator_prefix(Handle, CF, It, KeyPrefix, NextAction, Fn, Acc); + Error -> + Error + end. + +do_foldl_iterator_prefix(DBHandle, CF, It, KeyPrefix, NextAction, Fn, Acc) -> + case rocksdb:iterator_move(It, NextAction) of + {ok, K = <>, V} -> + NewAcc = Fn(K, V, Acc), + do_foldl_iterator_prefix(DBHandle, CF, It, KeyPrefix, next, Fn, NewAcc); + {ok, _K, _V} -> + ok = rocksdb:iterator_close(It), + {ok, Acc}; + {error, invalid_iterator} -> + ok = rocksdb:iterator_close(It), + {ok, Acc}; + Error -> + ok = rocksdb:iterator_close(It), + Error + end. + +%% Functions for dealing with the metadata stored persistently in rocksdb + +-define(CURRENT_GEN, <<"current">>). +-define(SCHEMA_WRITE_OPTS, []). +-define(SCHEMA_READ_OPTS, []). + +-spec schema_get_gen(rocksdb:db_handle(), gen_id()) -> generation(). +schema_get_gen(DBHandle, GenId) -> + {ok, Bin} = rocksdb:get(DBHandle, schema_gen_key(GenId), ?SCHEMA_READ_OPTS), + binary_to_term(Bin). + +-spec schema_put_gen(rocksdb:db_handle(), gen_id(), generation()) -> ok | {error, _}. +schema_put_gen(DBHandle, GenId, Gen) -> + rocksdb:put(DBHandle, schema_gen_key(GenId), term_to_binary(Gen), ?SCHEMA_WRITE_OPTS). + +-spec schema_get_current(rocksdb:db_handle()) -> gen_id() | undefined. +schema_get_current(DBHandle) -> + case rocksdb:get(DBHandle, ?CURRENT_GEN, ?SCHEMA_READ_OPTS) of + {ok, Bin} -> + binary_to_integer(Bin); + not_found -> + undefined + end. + +-spec schema_put_current(rocksdb:db_handle(), gen_id()) -> ok | {error, _}. +schema_put_current(DBHandle, GenId) -> + rocksdb:put(DBHandle, ?CURRENT_GEN, integer_to_binary(GenId), ?SCHEMA_WRITE_OPTS). + +-spec schema_gen_key(integer()) -> binary(). +schema_gen_key(N) -> + <<"gen", N:32>>. + +-undef(CURRENT_GEN). +-undef(SCHEMA_WRITE_OPTS). +-undef(SCHEMA_READ_OPTS). + +%% Functions for dealing with the runtime shard metadata: + +-define(PERSISTENT_TERM(SHARD, GEN), {emqx_ds_storage_layer, SHARD, GEN}). + +-spec meta_register_gen(emqx_ds:shard(), gen_id(), generation()) -> ok. +meta_register_gen(Shard, GenId, Gen) -> + Gs = + case GenId > 0 of + true -> meta_lookup(Shard, GenId - 1); + false -> [] + end, + ok = meta_put(Shard, GenId, [Gen | Gs]), + ok = meta_put(Shard, current, GenId). + +-spec meta_lookup_gen(emqx_ds:shard(), emqx_ds:time()) -> {gen_id(), generation()}. +meta_lookup_gen(Shard, Time) -> + %% TODO + %% Is cheaper persistent term GC on update here worth extra lookup? I'm leaning + %% towards a "no". + Current = meta_lookup(Shard, current), + Gens = meta_lookup(Shard, Current), + find_gen(Time, Current, Gens). + +find_gen(Time, GenId, [Gen = #{since := Since} | _]) when Time >= Since -> + {GenId, Gen}; +find_gen(Time, GenId, [_Gen | Rest]) -> + find_gen(Time, GenId - 1, Rest). + +-spec meta_get_gen(emqx_ds:shard(), gen_id()) -> generation() | undefined. +meta_get_gen(Shard, GenId) -> + case meta_lookup(Shard, GenId, []) of + [Gen | _Older] -> Gen; + [] -> undefined + end. + +-spec meta_get_current(emqx_ds:shard()) -> gen_id() | undefined. +meta_get_current(Shard) -> + meta_lookup(Shard, current, undefined). + +-spec meta_lookup(emqx_ds:shard(), _K) -> _V. +meta_lookup(Shard, Key) -> + persistent_term:get(?PERSISTENT_TERM(Shard, Key)). + +-spec meta_lookup(emqx_ds:shard(), _K, Default) -> _V | Default. +meta_lookup(Shard, K, Default) -> + persistent_term:get(?PERSISTENT_TERM(Shard, K), Default). + +-spec meta_put(emqx_ds:shard(), _K, _V) -> ok. +meta_put(Shard, K, V) -> + persistent_term:put(?PERSISTENT_TERM(Shard, K), V). + +-spec meta_erase(emqx_ds:shard()) -> ok. +meta_erase(Shard) -> + [ + persistent_term:erase(K) + || {K = ?PERSISTENT_TERM(Z, _), _} <- persistent_term:get(), Z =:= Shard + ], + ok. + +-undef(PERSISTENT_TERM). + +get_next_id(undefined) -> 0; +get_next_id(GenId) -> GenId + 1. + +is_gen_valid(Shard, GenId, Since) when GenId > 0 -> + [GenPrev | _] = meta_lookup(Shard, GenId - 1), + case GenPrev of + #{since := SincePrev} when Since > SincePrev -> + ok; + #{} -> + {error, nonmonotonic} + end; +is_gen_valid(_Shard, 0, 0) -> + ok. + +serialize(Msg) -> + %% TODO: remove topic, GUID, etc. from the stored + %% message. Reconstruct it from the metadata. + term_to_binary(emqx_message:to_map(Msg)). + +deserialize(Bin) -> + emqx_message:from_map(binary_to_term(Bin)). + + +%% -spec store_cfs(rocksdb:db_handle(), [{string(), rocksdb:cf_handle()}]) -> ok. +%% store_cfs(DBHandle, CFRefs) -> +%% lists:foreach( +%% fun({CFName, CFRef}) -> +%% persistent_term:put({self(), CFName}, {DBHandle, CFRef}) +%% end, +%% CFRefs). diff --git a/apps/emqx_durable_storage/src/emqx_ds_message_storage_bitmask.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer_bitmask.erl_ similarity index 98% rename from apps/emqx_durable_storage/src/emqx_ds_message_storage_bitmask.erl rename to apps/emqx_durable_storage/src/emqx_ds_storage_layer_bitmask.erl_ index 3290b03e6..bdf5a1453 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_message_storage_bitmask.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer_bitmask.erl_ @@ -83,15 +83,11 @@ -export([create_new/3, open/5]). -export([make_keymapper/1]). --export([store/5]). --export([delete/4]). +-export([store/5, delete/4]). --export([get_streams/2]). --export([make_iterator/3, next/1]). +-export([get_streams/3, make_iterator/3, next/1]). --export([preserve_iterator/1]). --export([restore_iterator/2]). --export([refresh_iterator/1]). +-export([preserve_iterator/1, restore_iterator/2, refresh_iterator/1]). %% Debug/troubleshooting: %% Keymappers @@ -131,7 +127,7 @@ %% Type declarations %%================================================================================ --opaque stream() :: singleton_stream. +-opaque stream() :: emqx_ds:topic_filter(). -type topic() :: emqx_ds:topic(). -type topic_filter() :: emqx_ds:topic_filter(). @@ -290,10 +286,10 @@ delete(DB = #db{handle = DBHandle, cf = CFHandle}, MessageID, PublishedAt, Topic Key = make_message_key(Topic, PublishedAt, MessageID, DB#db.keymapper), rocksdb:delete(DBHandle, CFHandle, Key, DB#db.write_options). --spec get_streams(db(), emqx_ds:reply()) -> +-spec get_streams(db(), emqx_ds:topic_filter(), emqx_ds:time()) -> [stream()]. -get_streams(_, _) -> - [singleton_stream]. +get_streams(_, TopicFilter, _) -> + [{0, TopicFilter}]. -spec make_iterator(db(), emqx_ds:replay(), iteration_options()) -> % {error, invalid_start_time}? might just start from the beginning of time diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer_sup.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer_sup.erl index 2e4f56f10..bf73e3ac8 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer_sup.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer_sup.erl @@ -6,7 +6,7 @@ -behaviour(supervisor). %% API: --export([start_link/0, start_shard/2, stop_shard/1]). +-export([start_link/0, start_shard/2, stop_shard/1, ensure_shard/2]). %% behaviour callbacks: -export([init/1]). diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl new file mode 100644 index 000000000..1fbad5f1b --- /dev/null +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl @@ -0,0 +1,136 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +%% @doc Reference implementation of the storage. +%% +%% Trivial, extremely slow and inefficient. It also doesn't handle +%% restart of the Erlang node properly, so obviously it's only to be +%% used for testing. +-module(emqx_ds_storage_reference). + +-behavior(emqx_ds_storage_layer). + +%% API: +-export([]). + +%% behavior callbacks: +-export([create/4, open/5, store_batch/4, get_streams/4, make_iterator/4, next/4]). + +%% internal exports: +-export([]). + +-export_type([]). + +-include_lib("emqx/include/emqx.hrl"). + +%%================================================================================ +%% Type declarations +%%================================================================================ + +%% Permanent state: +-record(schema, {}). + +%% Runtime state: +-record(s, { + db :: rocksdb:db_handle(), + cf :: rocksdb:cf_handle() +}). + +-record(stream, {topic_filter :: emqx_ds:topic_filter()}). + +-record(it, { + topic_filter :: emqx_ds:topic_filter(), + start_time :: emqx_ds:time(), + last_seen_message_key = first :: binary() | first +}). + +%%================================================================================ +%% API funcions +%%================================================================================ + +%%================================================================================ +%% behavior callbacks +%%================================================================================ + +create(_ShardId, DBHandle, GenId, _Options) -> + CFName = data_cf(GenId), + {ok, CFHandle} = rocksdb:create_column_family(DBHandle, CFName, []), + Schema = #schema{}, + {Schema, [{CFName, CFHandle}]}. + +open(_Shard, DBHandle, GenId, CFRefs, #schema{}) -> + {_, CF} = lists:keyfind(data_cf(GenId), 1, CFRefs), + #s{db = DBHandle, cf = CF}. + +store_batch(_ShardId, #s{db = DB, cf = CF}, Messages, _Options) -> + lists:foreach( + fun(Msg) -> + Id = erlang:unique_integer([monotonic]), + Key = <>, + Val = term_to_binary(Msg), + rocksdb:put(DB, CF, Key, Val, []) + end, + Messages + ). + +get_streams(_Shard, _Data, TopicFilter, _StartTime) -> + [#stream{topic_filter = TopicFilter}]. + +make_iterator(_Shard, _Data, #stream{topic_filter = TopicFilter}, StartTime) -> + {ok, #it{ + topic_filter = TopicFilter, + start_time = StartTime + }}. + +next(_Shard, #s{db = DB, cf = CF}, It0, BatchSize) -> + #it{topic_filter = TopicFilter, start_time = StartTime, last_seen_message_key = Key0} = It0, + {ok, ITHandle} = rocksdb:iterator(DB, CF, []), + Action = case Key0 of + first -> + first; + _ -> + rocksdb:iterator_move(ITHandle, Key0), + next + end, + {Key, Messages} = do_next(TopicFilter, StartTime, ITHandle, Action, BatchSize, Key0, []), + rocksdb:iterator_close(ITHandle), + It = It0#it{last_seen_message_key = Key}, + {ok, It, lists:reverse(Messages)}. + +%%================================================================================ +%% Internal functions +%%================================================================================ + +do_next(_, _, _, _, 0, Key, Acc) -> + {Key, Acc}; +do_next(TopicFilter, StartTime, IT, Action, NLeft, Key0, Acc) -> + case rocksdb:iterator_move(IT, Action) of + {ok, Key, Blob} -> + Msg = #message{topic = Topic, timestamp = TS} = binary_to_term(Blob), + case emqx_topic:match(Topic, TopicFilter) andalso TS >= StartTime of + true -> + do_next(TopicFilter, StartTime, IT, next, NLeft - 1, Key, [Msg | Acc]); + false -> + do_next(TopicFilter, StartTime, IT, next, NLeft, Key, Acc) + end; + {error, invalid_iterator} -> + {Key0, Acc} + end. + +%% @doc Generate a column family ID for the MQTT messages +-spec data_cf(emqx_ds_storage_layer:gen_id()) -> [char()]. +data_cf(GenId) -> + ?MODULE_STRING ++ integer_to_list(GenId). diff --git a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl index 79285fe16..df3d64bc3 100644 --- a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl +++ b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl @@ -17,8 +17,9 @@ -behavior(emqx_bpapi). +-include_lib("emqx/include/bpapi.hrl"). %% API: --export([]). +-export([open_shard/3, get_streams/4, make_iterator/4, next/4]). %% behavior callbacks: -export([introduced_in/0]). @@ -27,23 +28,29 @@ %% API funcions %%================================================================================ --spec create_shard(node(), emqx_ds_replication_layer:shard(), emqx_ds:create_db_opts()) -> - ok. -create_shard(Node, Shard, Opts) -> - erpc:call(Node, emqx_ds_replication_layer, do_create_shard_v1, [Shard, Opts]). +-spec open_shard(node(), emqx_ds_replication_layer:shard(), emqx_ds:create_db_opts()) -> + ok. +open_shard(Node, Shard, Opts) -> + erpc:call(Node, emqx_ds_replication_layer, do_open_shard_v1, [Shard, Opts]). --spec get_streams(node(), emqx_ds_replication_layer:shard(), emqx_ds:topic_filter(), emqx_ds:time()) -> - [emqx_ds_replication_layer:stream()]. +-spec get_streams( + node(), emqx_ds_replication_layer:shard(), emqx_ds:topic_filter(), emqx_ds:time() +) -> + [{integer(), emqx_ds_replication_layer:stream()}]. get_streams(Node, Shard, TopicFilter, Time) -> erpc:call(Node, emqx_ds_replication_layer, do_get_streams_v1, [Shard, TopicFilter, Time]). --spec open_iterator(node(), emqx_ds_replication_layer:shard(), emqx_ds_replication_layer:stream(), emqx_ds:time()) -> - {ok, emqx_ds_replication_layer:iterator()} | {error, _}. -open_iterator(Node, Shard, Stream, StartTime) -> - erpc:call(Node, emqx_ds_replication_layer, do_open_iterator_v1, [Shard, Stream, StartTime]). +-spec make_iterator(node(), emqx_ds_replication_layer:shard(), _Stream, emqx_ds:time()) -> + {ok, emqx_ds_replication_layer:iterator()} | {error, _}. +make_iterator(Node, Shard, Stream, StartTime) -> + erpc:call(Node, emqx_ds_replication_layer, do_make_iterator_v1, [Shard, Stream, StartTime]). --spec next(node(), emqx_ds_replication_layer:shard(), emqx_ds_replication_layer:iterator(), pos_integer()) -> - {ok, emqx_ds_replication_layer:iterator(), [emqx_types:messages()]} | end_of_stream. +-spec next( + node(), emqx_ds_replication_layer:shard(), emqx_ds_replication_layer:iterator(), pos_integer() +) -> + {ok, emqx_ds_replication_layer:iterator(), [emqx_types:messages()]} + | {ok, end_of_stream} + | {error, _}. next(Node, Shard, Iter, BatchSize) -> erpc:call(Node, emqx_ds_replication_layer, do_next_v1, [Shard, Iter, BatchSize]). diff --git a/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl b/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl new file mode 100644 index 000000000..effe3b695 --- /dev/null +++ b/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl @@ -0,0 +1,107 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- +-module(emqx_ds_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("common_test/include/ct.hrl"). +-include_lib("stdlib/include/assert.hrl"). + +%% A simple smoke test that verifies that opening the DB doesn't crash +t_00_smoke_open(_Config) -> + ?assertMatch(ok, emqx_ds:open_db(<<"DB1">>, #{})), + ?assertMatch(ok, emqx_ds:open_db(<<"DB1">>, #{})). + +%% A simple smoke test that verifies that storing the messages doesn't +%% crash +t_01_smoke_store(_Config) -> + DB = <<"default">>, + ?assertMatch(ok, emqx_ds:open_db(DB, #{})), + Msg = message(<<"foo/bar">>, <<"foo">>, 0), + ?assertMatch(ok, emqx_ds:store_batch(DB, [Msg])). + +%% A simple smoke test that verifies that getting the list of streams +%% doesn't crash and that iterators can be opened. +t_02_smoke_get_streams_start_iter(_Config) -> + DB = <<"default">>, + ?assertMatch(ok, emqx_ds:open_db(DB, #{})), + StartTime = 0, + [{Rank, Stream}] = emqx_ds:get_streams(DB, ['#'], StartTime), + ?assertMatch({_, _}, Rank), + ?assertMatch({ok, _Iter}, emqx_ds:make_iterator(Stream, StartTime)). + +%% A simple smoke test that verifies that it's possible to iterate +%% over messages. +t_03_smoke_iterate(_Config) -> + DB = atom_to_binary(?FUNCTION_NAME), + ?assertMatch(ok, emqx_ds:open_db(DB, #{})), + StartTime = 0, + Msgs = [ + message(<<"foo/bar">>, <<"1">>, 0), + message(<<"foo">>, <<"2">>, 1), + message(<<"bar/bar">>, <<"3">>, 2) + ], + ?assertMatch(ok, emqx_ds:store_batch(DB, Msgs)), + [{_, Stream}] = emqx_ds:get_streams(DB, ['#'], StartTime), + {ok, Iter0} = emqx_ds:make_iterator(Stream, StartTime), + {ok, Iter, Batch} = iterate(Iter0, 1), + ?assertEqual(Msgs, Batch, {Iter0, Iter}). + +message(Topic, Payload, PublishedAt) -> + #message{ + topic = Topic, + payload = Payload, + timestamp = PublishedAt, + id = emqx_guid:gen() + }. + +iterate(It, BatchSize) -> + iterate(It, BatchSize, []). + +iterate(It0, BatchSize, Acc) -> + case emqx_ds:next(It0, BatchSize) of + {ok, It, []} -> + {ok, It, Acc}; + {ok, It, Msgs} -> + iterate(It, BatchSize, Acc ++ Msgs); + Ret -> + Ret + end. + +%% CT callbacks + +all() -> emqx_common_test_helpers:all(?MODULE). + +init_per_suite(Config) -> + Apps = emqx_cth_suite:start( + [mria, emqx_durable_storage], + #{work_dir => ?config(priv_dir, Config)} + ), + [{apps, Apps} | Config]. + +end_per_suite(Config) -> + ok = emqx_cth_suite:stop(?config(apps, Config)), + ok. + +init_per_testcase(_TC, Config) -> + snabbkaffe:fix_ct_logging(), + application:ensure_all_started(emqx_durable_storage), + Config. + +end_per_testcase(_TC, _Config) -> + ok = application:stop(emqx_durable_storage). diff --git a/apps/emqx_durable_storage/test/emqx_ds_message_storage_bitmask_SUITE.erl b/apps/emqx_durable_storage/test/emqx_ds_message_storage_bitmask_SUITE.erl_ similarity index 100% rename from apps/emqx_durable_storage/test/emqx_ds_message_storage_bitmask_SUITE.erl rename to apps/emqx_durable_storage/test/emqx_ds_message_storage_bitmask_SUITE.erl_ diff --git a/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl b/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl_ similarity index 100% rename from apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl rename to apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl_ diff --git a/scripts/check-elixir-applications.exs b/scripts/check-elixir-applications.exs index 42c838199..1e604c69f 100755 --- a/scripts/check-elixir-applications.exs +++ b/scripts/check-elixir-applications.exs @@ -1,4 +1,4 @@ -#!/usr/bin/env elixir +#! /usr/bin/env elixir defmodule CheckElixirApplications do alias EMQXUmbrella.MixProject diff --git a/scripts/check-elixir-deps-discrepancies.exs b/scripts/check-elixir-deps-discrepancies.exs index 408079d7d..1363219ed 100755 --- a/scripts/check-elixir-deps-discrepancies.exs +++ b/scripts/check-elixir-deps-discrepancies.exs @@ -1,4 +1,4 @@ -#!/usr/bin/env elixir +#! /usr/bin/env elixir # ensure we have a fresh rebar.lock diff --git a/scripts/check-elixir-emqx-machine-boot-discrepancies.exs b/scripts/check-elixir-emqx-machine-boot-discrepancies.exs index d07e6978f..9ffdc47bf 100755 --- a/scripts/check-elixir-emqx-machine-boot-discrepancies.exs +++ b/scripts/check-elixir-emqx-machine-boot-discrepancies.exs @@ -1,4 +1,4 @@ -#!/usr/bin/env elixir +#! /usr/bin/env elixir defmodule CheckElixirEMQXMachineBootDiscrepancies do alias EMQXUmbrella.MixProject diff --git a/scripts/check_missing_reboot_apps.exs b/scripts/check_missing_reboot_apps.exs index 91d4b39ea..7f2178ec1 100755 --- a/scripts/check_missing_reboot_apps.exs +++ b/scripts/check_missing_reboot_apps.exs @@ -1,4 +1,4 @@ -#!/usr/bin/env elixir +#! /usr/bin/env elixir alias EMQXUmbrella.MixProject From 6d65707d41df06219ba63b1c4e735c52444c7981 Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Thu, 5 Oct 2023 01:43:35 +0200 Subject: [PATCH 07/31] refactor(ds): Implement drop_db function --- apps/emqx_durable_storage/src/emqx_ds.erl | 24 ++++++++++++---- .../src/emqx_ds_replication_layer.erl | 28 ++++++++++++++----- .../src/emqx_ds_storage_layer.erl | 18 ++++++++---- .../src/emqx_ds_storage_reference.erl | 15 +++++----- .../src/proto/emqx_ds_proto_v1.erl | 7 ++++- .../test/emqx_ds_SUITE.erl | 17 ++++++----- 6 files changed, 77 insertions(+), 32 deletions(-) diff --git a/apps/emqx_durable_storage/src/emqx_ds.erl b/apps/emqx_durable_storage/src/emqx_ds.erl index 6a20afbf1..293f2e531 100644 --- a/apps/emqx_durable_storage/src/emqx_ds.erl +++ b/apps/emqx_durable_storage/src/emqx_ds.erl @@ -22,7 +22,7 @@ -module(emqx_ds). %% Management API: --export([open_db/2]). +-export([open_db/2, drop_db/1]). %% Message storage API: -export([store_batch/1, store_batch/2, store_batch/3]). @@ -50,7 +50,7 @@ %% Type declarations %%================================================================================ --type db() :: emqx_ds_replication_layer:db(). +-type db() :: atom(). %% Parsed topic. -type topic() :: list(binary()). @@ -101,6 +101,12 @@ open_db(DB, Opts) -> emqx_ds_replication_layer:open_db(DB, Opts). +%% @doc TODO: currently if one or a few shards are down, they won't be +%% deleted. +-spec drop_db(db()) -> ok. +drop_db(DB) -> + emqx_ds_replication_layer:drop_db(DB). + -spec store_batch([emqx_types:message()]) -> store_batch_result(). store_batch(Msgs) -> store_batch(?DEFAULT_DB, Msgs, #{}). @@ -124,7 +130,15 @@ store_batch(DB, Msgs) -> %% reflects the notion that different topics can be stored %% differently, but hides the implementation details. %% -%% Rules: +%% While having to work with multiple iterators to replay a topic +%% filter may be cumbersome, it opens up some possibilities: +%% +%% 1. It's possible to parallelize replays +%% +%% 2. Streams can be shared between different clients to implement +%% shared subscriptions +%% +%% IMPORTANT RULES: %% %% 0. There is no 1-to-1 mapping between MQTT topics and streams. One %% stream can contain any number of MQTT topics. @@ -145,8 +159,8 @@ store_batch(DB, Msgs) -> %% equal, then the streams are independent. %% %% Stream is fully consumed when `next/3' function returns -%% `end_of_stream'. Then the client can proceed to replaying streams -%% that depend on the given one. +%% `end_of_stream'. Then and only then the client can proceed to +%% replaying streams that depend on the given one. -spec get_streams(db(), topic_filter(), time()) -> [{stream_rank(), stream()}]. get_streams(DB, TopicFilter, StartTime) -> emqx_ds_replication_layer:get_streams(DB, TopicFilter, StartTime). diff --git a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl index 5d4749c30..e1c775d5a 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl @@ -18,6 +18,7 @@ -export([ list_shards/1, open_db/2, + drop_db/1, store_batch/3, get_streams/3, make_iterator/2, @@ -27,6 +28,7 @@ %% internal exports: -export([ do_open_shard_v1/2, + do_drop_shard_v1/1, do_get_streams_v1/3, do_make_iterator_v1/3, do_next_v1/3 @@ -38,9 +40,9 @@ %% Type declarations %%================================================================================ --type db() :: binary(). +-type db() :: emqx_ds:db(). --type shard_id() :: binary(). +-type shard_id() :: {emqx_ds:db(), atom()}. %% This record enapsulates the stream entity from the replication %% level. @@ -90,6 +92,16 @@ open_db(DB, Opts) -> list_nodes() ). +-spec drop_db(emqx_ds:db()) -> ok | {error, _}. +drop_db(DB) -> + lists:foreach( + fun(Node) -> + Shard = shard_id(DB, Node), + ok = emqx_ds_proto_v1:drop_shard(Node, Shard) + end, + list_nodes() + ). + -spec store_batch(emqx_ds:db(), [emqx_types:message()], emqx_ds:message_store_opts()) -> emqx_ds:store_batch_result(). store_batch(DB, Msg, Opts) -> @@ -163,6 +175,10 @@ next(Iter0, BatchSize) -> do_open_shard_v1(Shard, Opts) -> emqx_ds_storage_layer:open_shard(Shard, Opts). +-spec do_drop_shard_v1(shard_id()) -> ok. +do_drop_shard_v1(Shard) -> + emqx_ds_storage_layer:drop_shard(Shard). + -spec do_get_streams_v1(shard_id(), emqx_ds:topic_filter(), emqx_ds:time()) -> [{integer(), _Stream}]. do_get_streams_v1(Shard, TopicFilter, StartTime) -> @@ -187,13 +203,11 @@ add_shard_to_rank(Shard, RankY) -> shard_id(DB, Node) -> %% TODO: don't bake node name into the schema, don't repeat the %% Mnesia's 1M$ mistake. - NodeBin = atom_to_binary(Node), - <>. + {DB, Node}. -spec node_of_shard(shard_id()) -> node(). -node_of_shard(ShardId) -> - [_DB, NodeBin] = binary:split(ShardId, <<":">>), - binary_to_atom(NodeBin). +node_of_shard({_DB, Node}) -> + Node. list_nodes() -> mria:running_nodes(). diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl index fdd81a095..d531c5985 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl @@ -18,13 +18,13 @@ -behaviour(gen_server). %% Replication layer API: --export([open_shard/2, store_batch/3, get_streams/3, make_iterator/3, next/3]). +-export([open_shard/2, drop_shard/1, store_batch/3, get_streams/3, make_iterator/3, next/3]). %% gen_server -export([start_link/2, init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). %% internal exports: --export([]). +-export([drop_shard/1]). -export_type([gen_id/0, generation/0, cf_refs/0, stream/0, iterator/0]). @@ -124,6 +124,11 @@ open_shard(Shard, Options) -> emqx_ds_storage_layer_sup:ensure_shard(Shard, Options). +-spec drop_shard(shard_id()) -> ok. +drop_shard(Shard) -> + emqx_ds_storage_layer_sup:stop_shard(Shard), + ok = rocksdb:destroy(db_dir(Shard), []). + -spec store_batch(shard_id(), [emqx_types:message()], emqx_ds:message_store_opts()) -> emqx_ds:store_batch_result(). store_batch(Shard, Messages, Options) -> @@ -188,7 +193,7 @@ next(Shard, Iter = #it{generation = GenId, enc = GenIter0}, BatchSize) -> -define(REF(ShardId), {via, gproc, {n, l, {?MODULE, ShardId}}}). --spec start_link(emqx_ds:shard_id(), emqx_ds:create_db_opts()) -> +-spec start_link(shard_id(), emqx_ds:create_db_opts()) -> {ok, pid()}. start_link(Shard, Options) -> gen_server:start_link(?REF(Shard), ?MODULE, {Shard, Options}, []). @@ -303,13 +308,12 @@ commit_metadata(#s{shard_id = ShardId, schema = Schema, shard = Runtime, db = DB -spec rocksdb_open(shard_id(), emqx_ds:create_db_opts()) -> {ok, rocksdb:db_handle(), cf_refs()} | {error, _TODO}. rocksdb_open(Shard, Options) -> - DefaultDir = binary_to_list(Shard), - DBDir = unicode:characters_to_list(maps:get(dir, Options, DefaultDir)), DBOptions = [ {create_if_missing, true}, {create_missing_column_families, true} | maps:get(db_options, Options, []) ], + DBDir = db_dir(Shard), _ = filelib:ensure_dir(DBDir), ExistingCFs = case rocksdb:list_column_families(DBDir, DBOptions) of @@ -331,6 +335,10 @@ rocksdb_open(Shard, Options) -> Error end. +-spec db_dir(shard_id()) -> file:filename(). +db_dir({DB, ShardId}) -> + lists:flatten([atom_to_list(DB), $:, atom_to_list(ShardId)]). + %%-------------------------------------------------------------------------------- %% Schema access %%-------------------------------------------------------------------------------- diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl index 1fbad5f1b..c0fb29ceb 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl @@ -98,13 +98,14 @@ make_iterator(_Shard, _Data, #stream{topic_filter = TopicFilter}, StartTime) -> next(_Shard, #s{db = DB, cf = CF}, It0, BatchSize) -> #it{topic_filter = TopicFilter, start_time = StartTime, last_seen_message_key = Key0} = It0, {ok, ITHandle} = rocksdb:iterator(DB, CF, []), - Action = case Key0 of - first -> - first; - _ -> - rocksdb:iterator_move(ITHandle, Key0), - next - end, + Action = + case Key0 of + first -> + first; + _ -> + rocksdb:iterator_move(ITHandle, Key0), + next + end, {Key, Messages} = do_next(TopicFilter, StartTime, ITHandle, Action, BatchSize, Key0, []), rocksdb:iterator_close(ITHandle), It = It0#it{last_seen_message_key = Key}, diff --git a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl index df3d64bc3..60671cef7 100644 --- a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl +++ b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl @@ -19,7 +19,7 @@ -include_lib("emqx/include/bpapi.hrl"). %% API: --export([open_shard/3, get_streams/4, make_iterator/4, next/4]). +-export([open_shard/3, drop_shard/2, get_streams/4, make_iterator/4, next/4]). %% behavior callbacks: -export([introduced_in/0]). @@ -33,6 +33,11 @@ open_shard(Node, Shard, Opts) -> erpc:call(Node, emqx_ds_replication_layer, do_open_shard_v1, [Shard, Opts]). +-spec drop_shard(node(), emqx_ds_replication_layer:shard()) -> + ok. +drop_shard(Node, Shard) -> + erpc:call(Node, emqx_ds_replication_layer, do_drop_shard_v1, [Shard]). + -spec get_streams( node(), emqx_ds_replication_layer:shard(), emqx_ds:topic_filter(), emqx_ds:time() ) -> diff --git a/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl b/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl index effe3b695..eabd03277 100644 --- a/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl +++ b/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl @@ -22,15 +22,18 @@ -include_lib("common_test/include/ct.hrl"). -include_lib("stdlib/include/assert.hrl"). -%% A simple smoke test that verifies that opening the DB doesn't crash -t_00_smoke_open(_Config) -> - ?assertMatch(ok, emqx_ds:open_db(<<"DB1">>, #{})), - ?assertMatch(ok, emqx_ds:open_db(<<"DB1">>, #{})). +%% A simple smoke test that verifies that opening/closing the DB +%% doesn't crash +t_00_smoke_open_drop(_Config) -> + DB = 'DB', + ?assertMatch(ok, emqx_ds:open_db(DB, #{})), + ?assertMatch(ok, emqx_ds:open_db(DB, #{})), + ?assertMatch(ok, emqx_ds:drop_db(DB)). %% A simple smoke test that verifies that storing the messages doesn't %% crash t_01_smoke_store(_Config) -> - DB = <<"default">>, + DB = default, ?assertMatch(ok, emqx_ds:open_db(DB, #{})), Msg = message(<<"foo/bar">>, <<"foo">>, 0), ?assertMatch(ok, emqx_ds:store_batch(DB, [Msg])). @@ -38,7 +41,7 @@ t_01_smoke_store(_Config) -> %% A simple smoke test that verifies that getting the list of streams %% doesn't crash and that iterators can be opened. t_02_smoke_get_streams_start_iter(_Config) -> - DB = <<"default">>, + DB = ?FUNCTION_NAME, ?assertMatch(ok, emqx_ds:open_db(DB, #{})), StartTime = 0, [{Rank, Stream}] = emqx_ds:get_streams(DB, ['#'], StartTime), @@ -48,7 +51,7 @@ t_02_smoke_get_streams_start_iter(_Config) -> %% A simple smoke test that verifies that it's possible to iterate %% over messages. t_03_smoke_iterate(_Config) -> - DB = atom_to_binary(?FUNCTION_NAME), + DB = ?FUNCTION_NAME, ?assertMatch(ok, emqx_ds:open_db(DB, #{})), StartTime = 0, Msgs = [ From 2972bf14ee4c67c6794e14f95742efc2d2322bd2 Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Thu, 5 Oct 2023 02:32:23 +0200 Subject: [PATCH 08/31] refactor(ds): Implement create_generation gen_rpc storage layer call --- apps/emqx/src/emqx_persistent_message.erl | 7 +- apps/emqx_durable_storage/src/emqx_ds.erl | 8 +-- .../src/emqx_ds_replication_layer.erl | 4 -- .../src/emqx_ds_storage_layer.erl | 72 +++++++++++++------ .../src/emqx_ds_storage_reference.erl | 2 +- .../test/emqx_ds_SUITE.erl | 31 +++++++- 6 files changed, 85 insertions(+), 39 deletions(-) diff --git a/apps/emqx/src/emqx_persistent_message.erl b/apps/emqx/src/emqx_persistent_message.erl index 8801acce5..82717cd01 100644 --- a/apps/emqx/src/emqx_persistent_message.erl +++ b/apps/emqx/src/emqx_persistent_message.erl @@ -26,6 +26,8 @@ persist/1 ]). +-define(PERSISTENT_MESSAGE_DB, emqx_persistent_message). + %% FIXME -define(WHEN_ENABLED(DO), case is_store_enabled() of @@ -38,7 +40,7 @@ init() -> ?WHEN_ENABLED(begin - ok = emqx_ds:open_db(<<"default">>, #{}), + ok = emqx_ds:open_db(?PERSISTENT_MESSAGE_DB, #{}), ok = emqx_persistent_session_ds_router:init_tables(), %ok = emqx_persistent_session_ds:create_tables(), ok @@ -65,8 +67,9 @@ persist(Msg) -> needs_persistence(Msg) -> not (emqx_message:get_flag(dup, Msg) orelse emqx_message:is_sys(Msg)). +-spec store_message(emqx_types:message()) -> emqx_ds:store_batch_result(). store_message(Msg) -> - emqx_ds:store_batch([Msg]). + emqx_ds:store_batch(?PERSISTENT_MESSAGE_DB, [Msg]). has_subscribers(#message{topic = Topic}) -> emqx_persistent_session_ds_router:has_any_route(Topic). diff --git a/apps/emqx_durable_storage/src/emqx_ds.erl b/apps/emqx_durable_storage/src/emqx_ds.erl index 293f2e531..cf4b5a031 100644 --- a/apps/emqx_durable_storage/src/emqx_ds.erl +++ b/apps/emqx_durable_storage/src/emqx_ds.erl @@ -25,7 +25,7 @@ -export([open_db/2, drop_db/1]). %% Message storage API: --export([store_batch/1, store_batch/2, store_batch/3]). +-export([store_batch/2, store_batch/3]). %% Message replay API: -export([get_streams/3, make_iterator/2, next/2]). @@ -89,8 +89,6 @@ -type message_id() :: emqx_ds_replication_layer:message_id(). --define(DEFAULT_DB, <<"default">>). - %%================================================================================ %% API funcions %%================================================================================ @@ -107,10 +105,6 @@ open_db(DB, Opts) -> drop_db(DB) -> emqx_ds_replication_layer:drop_db(DB). --spec store_batch([emqx_types:message()]) -> store_batch_result(). -store_batch(Msgs) -> - store_batch(?DEFAULT_DB, Msgs, #{}). - -spec store_batch(db(), [emqx_types:message()], message_store_opts()) -> store_batch_result(). store_batch(DB, Msgs, Opts) -> emqx_ds_replication_layer:store_batch(DB, Msgs, Opts). diff --git a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl index e1c775d5a..b43604469 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl @@ -196,10 +196,6 @@ do_next_v1(Shard, Iter, BatchSize) -> %% Internal functions %%================================================================================ -add_shard_to_rank(Shard, RankY) -> - RankX = erlang:phash2(Shard, 255), - {RankX, RankY}. - shard_id(DB, Node) -> %% TODO: don't bake node name into the schema, don't repeat the %% Mnesia's 1M$ mistake. diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl index d531c5985..e9d4edc06 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl @@ -24,10 +24,10 @@ -export([start_link/2, init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). %% internal exports: --export([drop_shard/1]). - -export_type([gen_id/0, generation/0, cf_refs/0, stream/0, iterator/0]). +-include_lib("snabbkaffe/include/snabbkaffe.hrl"). + %%================================================================================ %% Type declarations %%================================================================================ @@ -79,9 +79,11 @@ %%%% Shard: -type shard(GenData) :: #{ + %% ID of the current generation (where the new data is written:) current_generation := gen_id(), - default_generation_module := module(), - default_generation_config := term(), + %% This data is used to create new generation: + prototype := {module(), term()}, + %% Generations: {generation, gen_id()} => GenData }. @@ -206,6 +208,9 @@ start_link(Shard, Options) -> shard :: shard() }). +%% Note: we specify gen_server requests as records to make use of Dialyzer: +-record(call_create_generation, {since :: emqx_ds:time()}). + -type server_state() :: #s{}. -define(DEFAULT_CF, "default"). @@ -213,6 +218,7 @@ start_link(Shard, Options) -> init({ShardId, Options}) -> process_flag(trap_exit, true), + logger:set_process_metadata(#{shard_id => ShardId, domain => [ds, storage_layer, shard]}), erase_schema_runtime(ShardId), {ok, DB, CFRefs0} = rocksdb_open(ShardId, Options), {Schema, CFRefs} = @@ -233,13 +239,10 @@ init({ShardId, Options}) -> commit_metadata(S), {ok, S}. -%% handle_call({create_generation, Since, Config}, _From, S) -> -%% case create_new_gen(Since, Config, S) of -%% {ok, GenId, NS} -> -%% {reply, {ok, GenId}, NS}; -%% {error, _} = Error -> -%% {reply, Error, S} -%% end; +handle_call(#call_create_generation{since = Since}, _From, S0) -> + S = add_generation(S0, Since), + commit_metadata(S), + {reply, ok, S}; handle_call(_Call, _From, S) -> {reply, {error, unknown_call}, S}. @@ -275,29 +278,52 @@ open_shard(ShardId, DB, CFRefs, ShardSchema) -> ShardSchema ). +-spec add_generation(server_state(), emqx_ds:time()) -> server_state(). +add_generation(S0, Since) -> + #s{shard_id = ShardId, db = DB, schema = Schema0, shard = Shard0, cf_refs = CFRefs0} = S0, + {GenId, Schema, NewCFRefs} = new_generation(ShardId, DB, Schema0, Since), + CFRefs = NewCFRefs ++ CFRefs0, + Key = {generation, GenId}, + Generation = open_generation(ShardId, DB, CFRefs, GenId, maps:get(Key, Schema)), + Shard = Shard0#{Key => Generation}, + S0#s{ + cf_refs = CFRefs, + schema = Schema, + shard = Shard + }. + -spec open_generation(shard_id(), rocksdb:db_handle(), cf_refs(), gen_id(), generation_schema()) -> generation(). open_generation(ShardId, DB, CFRefs, GenId, GenSchema) -> + ?tp(debug, ds_open_generation, #{gen_id => GenId, schema => GenSchema}), #{module := Mod, data := Schema} = GenSchema, RuntimeData = Mod:open(ShardId, DB, GenId, CFRefs, Schema), GenSchema#{data => RuntimeData}. -spec create_new_shard_schema(shard_id(), rocksdb:db_handle(), cf_refs(), _Options) -> {shard_schema(), cf_refs()}. -create_new_shard_schema(ShardId, DB, CFRefs, _Options) -> - GenId = 1, - %% TODO: read from options/config - Mod = emqx_ds_storage_reference, - ModConfig = #{}, - {GenData, NewCFRefs} = Mod:create(ShardId, DB, GenId, ModConfig), - GenSchema = #{module => Mod, data => GenData, since => 0, until => undefined}, - ShardSchema = #{ +create_new_shard_schema(ShardId, DB, CFRefs, Options) -> + ?tp(notice, ds_create_new_shard_schema, #{shard => ShardId, options => Options}), + %% TODO: read prototype from options/config + Schema0 = #{ + current_generation => 0, + prototype => {emqx_ds_storage_reference, #{}} + }, + {_NewGenId, Schema, NewCFRefs} = new_generation(ShardId, DB, Schema0, _Since = 0), + {Schema, NewCFRefs ++ CFRefs}. + +-spec new_generation(shard_id(), rocksdb:db_handle(), shard_schema(), emqx_ds:time()) -> + {gen_id(), shard_schema(), cf_refs()}. +new_generation(ShardId, DB, Schema0, Since) -> + #{current_generation := PrevGenId, prototype := {Mod, ModConf}} = Schema0, + GenId = PrevGenId + 1, + {GenData, NewCFRefs} = Mod:create(ShardId, DB, GenId, ModConf), + GenSchema = #{module => Mod, data => GenData, since => Since, until => undefined}, + Schema = Schema0#{ current_generation => GenId, - default_generation_module => Mod, - default_generation_confg => ModConfig, {generation, GenId} => GenSchema }, - {ShardSchema, NewCFRefs ++ CFRefs}. + {GenId, Schema, NewCFRefs}. %% @doc Commit current state of the server to both rocksdb and the persistent term -spec commit_metadata(server_state()) -> ok. @@ -393,7 +419,7 @@ get_schema_persistent(DB) -> {ok, Blob} -> Schema = binary_to_term(Blob), %% Sanity check: - #{current_generation := _, default_generation_module := _} = Schema, + #{current_generation := _, prototype := _} = Schema, Schema; not_found -> not_found diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl index c0fb29ceb..fd480eeab 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl @@ -103,7 +103,7 @@ next(_Shard, #s{db = DB, cf = CF}, It0, BatchSize) -> first -> first; _ -> - rocksdb:iterator_move(ITHandle, Key0), + _ = rocksdb:iterator_move(ITHandle, Key0), next end, {Key, Messages} = do_next(TopicFilter, StartTime, ITHandle, Action, BatchSize, Key0, []), diff --git a/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl b/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl index eabd03277..1935e41cf 100644 --- a/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl +++ b/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl @@ -21,9 +21,10 @@ -include_lib("emqx/include/emqx.hrl"). -include_lib("common_test/include/ct.hrl"). -include_lib("stdlib/include/assert.hrl"). +-include_lib("snabbkaffe/include/snabbkaffe.hrl"). %% A simple smoke test that verifies that opening/closing the DB -%% doesn't crash +%% doesn't crash, and not much else t_00_smoke_open_drop(_Config) -> DB = 'DB', ?assertMatch(ok, emqx_ds:open_db(DB, #{})), @@ -65,6 +66,32 @@ t_03_smoke_iterate(_Config) -> {ok, Iter, Batch} = iterate(Iter0, 1), ?assertEqual(Msgs, Batch, {Iter0, Iter}). +%% Verify that iterators survive restart of the application. This is +%% an important property, since the lifetime of the iterators is tied +%% to the external resources, such as clients' sessions, and they +%% should always be able to continue replaying the topics from where +%% they are left off. +t_04_restart(_Config) -> + DB = ?FUNCTION_NAME, + ?assertMatch(ok, emqx_ds:open_db(DB, #{})), + StartTime = 0, + Msgs = [ + message(<<"foo/bar">>, <<"1">>, 0), + message(<<"foo">>, <<"2">>, 1), + message(<<"bar/bar">>, <<"3">>, 2) + ], + ?assertMatch(ok, emqx_ds:store_batch(DB, Msgs)), + [{_, Stream}] = emqx_ds:get_streams(DB, ['#'], StartTime), + {ok, Iter0} = emqx_ds:make_iterator(Stream, StartTime), + %% Restart the application: + ?tp(warning, emqx_ds_SUITE_restart_app, #{}), + ok = application:stop(emqx_durable_storage), + {ok, _} = application:ensure_all_started(emqx_durable_storage), + ok = emqx_ds:open_db(DB, #{}), + %% The old iterator should be still operational: + {ok, Iter, Batch} = iterate(Iter0, 1), + ?assertEqual(Msgs, Batch, {Iter0, Iter}). + message(Topic, Payload, PublishedAt) -> #message{ topic = Topic, @@ -102,7 +129,7 @@ end_per_suite(Config) -> ok. init_per_testcase(_TC, Config) -> - snabbkaffe:fix_ct_logging(), + %% snabbkaffe:fix_ct_logging(), application:ensure_all_started(emqx_durable_storage), Config. From 903b3863d1fe0e627f547b3db06db3bed9f8388b Mon Sep 17 00:00:00 2001 From: Thales Macedo Garitezi Date: Thu, 5 Oct 2023 17:17:08 -0300 Subject: [PATCH 09/31] chore(ps_ds): make persistent session module use new `emqx_ds` APIs --- .../emqx_persistent_session_ds_SUITE.erl | 125 +++----- apps/emqx/src/emqx_persistent_message.erl | 2 +- ...ds.erl_ => emqx_persistent_session_ds.erl} | 286 +++++++++--------- .../test/emqx_persistent_messages_SUITE.erl | 66 ++-- .../src/emqx_ds_replication_layer.erl | 10 +- 5 files changed, 203 insertions(+), 286 deletions(-) rename apps/emqx/src/{emqx_persistent_session_ds.erl_ => emqx_persistent_session_ds.erl} (66%) diff --git a/apps/emqx/integration_test/emqx_persistent_session_ds_SUITE.erl b/apps/emqx/integration_test/emqx_persistent_session_ds_SUITE.erl index d2d23e8cd..ee5d203e4 100644 --- a/apps/emqx/integration_test/emqx_persistent_session_ds_SUITE.erl +++ b/apps/emqx/integration_test/emqx_persistent_session_ds_SUITE.erl @@ -14,7 +14,6 @@ -define(DEFAULT_KEYSPACE, default). -define(DS_SHARD_ID, <<"local">>). -define(DS_SHARD, {?DEFAULT_KEYSPACE, ?DS_SHARD_ID}). --define(ITERATOR_REF_TAB, emqx_ds_iterator_ref). -import(emqx_common_test_helpers, [on_exit/1]). @@ -91,9 +90,6 @@ get_mqtt_port(Node, Type) -> {_IP, Port} = erpc:call(Node, emqx_config, get, [[listeners, Type, default, bind]]), Port. -get_all_iterator_refs(Node) -> - erpc:call(Node, mnesia, dirty_all_keys, [?ITERATOR_REF_TAB]). - get_all_iterator_ids(Node) -> Fn = fun(K, _V, Acc) -> [K | Acc] end, erpc:call(Node, fun() -> @@ -126,6 +122,32 @@ start_client(Opts0 = #{}) -> on_exit(fun() -> catch emqtt:stop(Client) end), Client. +restart_node(Node, NodeSpec) -> + ?tp(will_restart_node, #{}), + ?tp(notice, "restarting node", #{node => Node}), + true = monitor_node(Node, true), + ok = erpc:call(Node, init, restart, []), + receive + {nodedown, Node} -> + ok + after 10_000 -> + ct:fail("node ~p didn't stop", [Node]) + end, + ?tp(notice, "waiting for nodeup", #{node => Node}), + wait_nodeup(Node), + wait_gen_rpc_down(NodeSpec), + ?tp(notice, "restarting apps", #{node => Node}), + Apps = maps:get(apps, NodeSpec), + ok = erpc:call(Node, emqx_cth_suite, load_apps, [Apps]), + _ = erpc:call(Node, emqx_cth_suite, start_apps, [Apps, NodeSpec]), + %% have to re-inject this so that we may stop the node succesfully at the + %% end.... + ok = emqx_cth_cluster:set_node_opts(Node, NodeSpec), + ok = snabbkaffe:forward_trace(Node), + ?tp(notice, "node restarted", #{node => Node}), + ?tp(restarted_node, #{}), + ok. + %%------------------------------------------------------------------------------ %% Testcases %%------------------------------------------------------------------------------ @@ -143,24 +165,14 @@ t_non_persistent_session_subscription(_Config) -> {ok, _} = emqtt:connect(Client), ?tp(notice, "subscribing", #{}), {ok, _, [?RC_GRANTED_QOS_2]} = emqtt:subscribe(Client, SubTopicFilter, qos2), - IteratorRefs = get_all_iterator_refs(node()), - IteratorIds = get_all_iterator_ids(node()), ok = emqtt:stop(Client), - #{ - iterator_refs => IteratorRefs, - iterator_ids => IteratorIds - } + ok end, - fun(Res, Trace) -> + fun(Trace) -> ct:pal("trace:\n ~p", [Trace]), - #{ - iterator_refs := IteratorRefs, - iterator_ids := IteratorIds - } = Res, - ?assertEqual([], IteratorRefs), - ?assertEqual({ok, []}, IteratorIds), + ?assertEqual([], ?of_kind(ds_session_subscription_added, Trace)), ok end ), @@ -175,7 +187,7 @@ t_session_subscription_idempotency(Config) -> ?check_trace( begin ?force_ordering( - #{?snk_kind := persistent_session_ds_iterator_added}, + #{?snk_kind := persistent_session_ds_subscription_added}, _NEvents0 = 1, #{?snk_kind := will_restart_node}, _Guard0 = true @@ -187,32 +199,7 @@ t_session_subscription_idempotency(Config) -> _Guard1 = true ), - spawn_link(fun() -> - ?tp(will_restart_node, #{}), - ?tp(notice, "restarting node", #{node => Node1}), - true = monitor_node(Node1, true), - ok = erpc:call(Node1, init, restart, []), - receive - {nodedown, Node1} -> - ok - after 10_000 -> - ct:fail("node ~p didn't stop", [Node1]) - end, - ?tp(notice, "waiting for nodeup", #{node => Node1}), - wait_nodeup(Node1), - wait_gen_rpc_down(Node1Spec), - ?tp(notice, "restarting apps", #{node => Node1}), - Apps = maps:get(apps, Node1Spec), - ok = erpc:call(Node1, emqx_cth_suite, load_apps, [Apps]), - _ = erpc:call(Node1, emqx_cth_suite, start_apps, [Apps, Node1Spec]), - %% have to re-inject this so that we may stop the node succesfully at the - %% end.... - ok = emqx_cth_cluster:set_node_opts(Node1, Node1Spec), - ok = snabbkaffe:forward_trace(Node1), - ?tp(notice, "node restarted", #{node => Node1}), - ?tp(restarted_node, #{}), - ok - end), + spawn_link(fun() -> restart_node(Node1, Node1Spec) end), ?tp(notice, "starting 1", #{}), Client0 = start_client(#{port => Port, clientid => ClientId}), @@ -223,7 +210,7 @@ t_session_subscription_idempotency(Config) -> receive {'EXIT', {shutdown, _}} -> ok - after 0 -> ok + after 100 -> ok end, process_flag(trap_exit, false), @@ -240,10 +227,7 @@ t_session_subscription_idempotency(Config) -> end, fun(Trace) -> ct:pal("trace:\n ~p", [Trace]), - %% Exactly one iterator should have been opened. SubTopicFilterWords = emqx_topic:words(SubTopicFilter), - ?assertEqual([{ClientId, SubTopicFilterWords}], get_all_iterator_refs(Node1)), - ?assertMatch({ok, [_]}, get_all_iterator_ids(Node1)), ?assertMatch( {ok, #{}, #{SubTopicFilterWords := #{}}}, erpc:call(Node1, emqx_persistent_session_ds, session_open, [ClientId]) @@ -262,7 +246,10 @@ t_session_unsubscription_idempotency(Config) -> ?check_trace( begin ?force_ordering( - #{?snk_kind := persistent_session_ds_close_iterators, ?snk_span := {complete, _}}, + #{ + ?snk_kind := persistent_session_ds_subscription_delete, + ?snk_span := {complete, _} + }, _NEvents0 = 1, #{?snk_kind := will_restart_node}, _Guard0 = true @@ -270,36 +257,11 @@ t_session_unsubscription_idempotency(Config) -> ?force_ordering( #{?snk_kind := restarted_node}, _NEvents1 = 1, - #{?snk_kind := persistent_session_ds_iterator_delete, ?snk_span := start}, + #{?snk_kind := persistent_session_ds_subscription_route_delete, ?snk_span := start}, _Guard1 = true ), - spawn_link(fun() -> - ?tp(will_restart_node, #{}), - ?tp(notice, "restarting node", #{node => Node1}), - true = monitor_node(Node1, true), - ok = erpc:call(Node1, init, restart, []), - receive - {nodedown, Node1} -> - ok - after 10_000 -> - ct:fail("node ~p didn't stop", [Node1]) - end, - ?tp(notice, "waiting for nodeup", #{node => Node1}), - wait_nodeup(Node1), - wait_gen_rpc_down(Node1Spec), - ?tp(notice, "restarting apps", #{node => Node1}), - Apps = maps:get(apps, Node1Spec), - ok = erpc:call(Node1, emqx_cth_suite, load_apps, [Apps]), - _ = erpc:call(Node1, emqx_cth_suite, start_apps, [Apps, Node1Spec]), - %% have to re-inject this so that we may stop the node succesfully at the - %% end.... - ok = emqx_cth_cluster:set_node_opts(Node1, Node1Spec), - ok = snabbkaffe:forward_trace(Node1), - ?tp(notice, "node restarted", #{node => Node1}), - ?tp(restarted_node, #{}), - ok - end), + spawn_link(fun() -> restart_node(Node1, Node1Spec) end), ?tp(notice, "starting 1", #{}), Client0 = start_client(#{port => Port, clientid => ClientId}), @@ -312,7 +274,7 @@ t_session_unsubscription_idempotency(Config) -> receive {'EXIT', {shutdown, _}} -> ok - after 0 -> ok + after 100 -> ok end, process_flag(trap_exit, false), @@ -327,7 +289,7 @@ t_session_unsubscription_idempotency(Config) -> ?wait_async_action( emqtt:unsubscribe(Client1, SubTopicFilter), #{ - ?snk_kind := persistent_session_ds_iterator_delete, + ?snk_kind := persistent_session_ds_subscription_route_delete, ?snk_span := {complete, _} }, 15_000 @@ -339,9 +301,10 @@ t_session_unsubscription_idempotency(Config) -> end, fun(Trace) -> ct:pal("trace:\n ~p", [Trace]), - %% No iterators remaining - ?assertEqual([], get_all_iterator_refs(Node1)), - ?assertEqual({ok, []}, get_all_iterator_ids(Node1)), + ?assertMatch( + {ok, #{}, Subs = #{}} when map_size(Subs) =:= 0, + erpc:call(Node1, emqx_persistent_session_ds, session_open, [ClientId]) + ), ok end ), diff --git a/apps/emqx/src/emqx_persistent_message.erl b/apps/emqx/src/emqx_persistent_message.erl index 82717cd01..f3ec9def5 100644 --- a/apps/emqx/src/emqx_persistent_message.erl +++ b/apps/emqx/src/emqx_persistent_message.erl @@ -42,7 +42,7 @@ init() -> ?WHEN_ENABLED(begin ok = emqx_ds:open_db(?PERSISTENT_MESSAGE_DB, #{}), ok = emqx_persistent_session_ds_router:init_tables(), - %ok = emqx_persistent_session_ds:create_tables(), + ok = emqx_persistent_session_ds:create_tables(), ok end). diff --git a/apps/emqx/src/emqx_persistent_session_ds.erl_ b/apps/emqx/src/emqx_persistent_session_ds.erl similarity index 66% rename from apps/emqx/src/emqx_persistent_session_ds.erl_ rename to apps/emqx/src/emqx_persistent_session_ds.erl index 3fff5f7ba..9bc9e0b91 100644 --- a/apps/emqx/src/emqx_persistent_session_ds.erl_ +++ b/apps/emqx/src/emqx_persistent_session_ds.erl @@ -65,10 +65,13 @@ %% Currently, this is the clientid. We avoid `emqx_types:clientid()' because that can be %% an atom, in theory (?). -type id() :: binary(). --type iterator() :: emqx_ds:iterator(). --type iterator_id() :: emqx_ds:iterator_id(). -type topic_filter() :: emqx_ds:topic_filter(). --type iterators() :: #{topic_filter() => iterator()}. +-type subscription_id() :: {id(), topic_filter()}. +-type subscription() :: #{ + start_time := emqx_ds:time(), + propts := map(), + extra := map() +}. -type session() :: #{ %% Client ID id := id(), @@ -77,7 +80,7 @@ %% When the session should expire expires_at := timestamp() | never, %% Client’s Subscriptions. - iterators := #{topic() => iterator()}, + iterators := #{topic() => subscription()}, %% props := map() }. @@ -90,6 +93,8 @@ -export_type([id/0]). +-define(PERSISTENT_MESSAGE_DB, emqx_persistent_message). + %% -spec create(clientinfo(), conninfo(), emqx_session:conf()) -> @@ -121,17 +126,17 @@ ensure_session(ClientID, Conf) -> open_session(ClientID) -> case session_open(ClientID) of - {ok, Session, Iterators} -> - Session#{iterators => prep_iterators(Iterators)}; + {ok, Session, Subscriptions} -> + Session#{iterators => prep_subscriptions(Subscriptions)}; false -> false end. -prep_iterators(Iterators) -> +prep_subscriptions(Subscriptions) -> maps:fold( - fun(Topic, Iterator, Acc) -> Acc#{emqx_topic:join(Topic) => Iterator} end, + fun(Topic, Subscription, Acc) -> Acc#{emqx_topic:join(Topic) => Subscription} end, #{}, - Iterators + Subscriptions ). -spec destroy(session() | clientinfo()) -> ok. @@ -228,7 +233,7 @@ unsubscribe( ) when is_map_key(TopicFilter, Iters) -> Iterator = maps:get(TopicFilter, Iters), SubOpts = maps:get(props, Iterator), - ok = del_subscription(TopicFilter, Iterator, ID), + ok = del_subscription(TopicFilter, ID), {ok, Session#{iterators := maps:remove(TopicFilter, Iters)}, SubOpts}; unsubscribe( _TopicFilter, @@ -327,91 +332,67 @@ terminate(_Reason, _Session = #{}) -> %%-------------------------------------------------------------------- -spec add_subscription(topic(), emqx_types:subopts(), id()) -> - emqx_ds:iterator(). + subscription(). add_subscription(TopicFilterBin, SubOpts, DSSessionID) -> - % N.B.: we chose to update the router before adding the subscription to the - % session/iterator table. The reasoning for this is as follows: - % - % Messages matching this topic filter should start to be persisted as soon as - % possible to avoid missing messages. If this is the first such persistent - % session subscription, it's important to do so early on. - % - % This could, in turn, lead to some inconsistency: if such a route gets - % created but the session/iterator data fails to be updated accordingly, we - % have a dangling route. To remove such dangling routes, we may have a - % periodic GC process that removes routes that do not have a matching - % persistent subscription. Also, route operations use dirty mnesia - % operations, which inherently have room for inconsistencies. - % - % In practice, we use the iterator reference table as a source of truth, - % since it is guarded by a transaction context: we consider a subscription - % operation to be successful if it ended up changing this table. Both router - % and iterator information can be reconstructed from this table, if needed. + %% N.B.: we chose to update the router before adding the subscription to the + %% session/iterator table. The reasoning for this is as follows: + %% + %% Messages matching this topic filter should start to be persisted as soon as + %% possible to avoid missing messages. If this is the first such persistent + %% session subscription, it's important to do so early on. + %% + %% This could, in turn, lead to some inconsistency: if such a route gets + %% created but the session/iterator data fails to be updated accordingly, we + %% have a dangling route. To remove such dangling routes, we may have a + %% periodic GC process that removes routes that do not have a matching + %% persistent subscription. Also, route operations use dirty mnesia + %% operations, which inherently have room for inconsistencies. + %% + %% In practice, we use the iterator reference table as a source of truth, + %% since it is guarded by a transaction context: we consider a subscription + %% operation to be successful if it ended up changing this table. Both router + %% and iterator information can be reconstructed from this table, if needed. ok = emqx_persistent_session_ds_router:do_add_route(TopicFilterBin, DSSessionID), TopicFilter = emqx_topic:words(TopicFilterBin), - {ok, Iterator, IsNew} = session_add_iterator( + {ok, DSSubExt, IsNew} = session_add_subscription( DSSessionID, TopicFilter, SubOpts ), - Ctx = #{iterator => Iterator, is_new => IsNew}, - ?tp(persistent_session_ds_iterator_added, Ctx), + ?tp(persistent_session_ds_subscription_added, #{sub => DSSubExt, is_new => IsNew}), + %% we'll list streams and open iterators when implementing message replay. + DSSubExt. + +-spec update_subscription(topic(), subscription(), emqx_types:subopts(), id()) -> + subscription(). +update_subscription(TopicFilterBin, DSSubExt, SubOpts, DSSessionID) -> + TopicFilter = emqx_topic:words(TopicFilterBin), + {ok, NDSSubExt, false} = session_add_subscription( + DSSessionID, TopicFilter, SubOpts + ), + ok = ?tp(persistent_session_ds_iterator_updated, #{sub => DSSubExt}), + NDSSubExt. + +-spec del_subscription(topic(), id()) -> + ok. +del_subscription(TopicFilterBin, DSSessionId) -> + TopicFilter = emqx_topic:words(TopicFilterBin), ?tp_span( - persistent_session_ds_open_iterators, - Ctx, - ok = open_iterator_on_all_shards(TopicFilter, Iterator) + persistent_session_ds_subscription_delete, + #{session_id => DSSessionId}, + ok = session_del_subscription(DSSessionId, TopicFilter) ), - Iterator. - --spec update_subscription(topic(), iterator(), emqx_types:subopts(), id()) -> - iterator(). -update_subscription(TopicFilterBin, Iterator, SubOpts, DSSessionID) -> - TopicFilter = emqx_topic:words(TopicFilterBin), - {ok, NIterator, false} = session_add_iterator( - DSSessionID, TopicFilter, SubOpts - ), - ok = ?tp(persistent_session_ds_iterator_updated, #{iterator => Iterator}), - NIterator. - --spec open_iterator_on_all_shards(emqx_types:words(), emqx_ds:iterator()) -> ok. -open_iterator_on_all_shards(TopicFilter, Iterator) -> - ?tp(persistent_session_ds_will_open_iterators, #{iterator => Iterator}), - %% Note: currently, shards map 1:1 to nodes, but this will change in the future. - Nodes = emqx:running_nodes(), - Results = emqx_persistent_session_ds_proto_v1:open_iterator( - Nodes, - TopicFilter, - maps:get(start_time, Iterator), - maps:get(id, Iterator) - ), - %% TODO - %% 1. Handle errors. - %% 2. Iterator handles are rocksdb resources, it's doubtful they survive RPC. - %% Even if they do, we throw them away here anyway. All in all, we probably should - %% hold each of them in a process on the respective node. - true = lists:all(fun(Res) -> element(1, Res) =:= ok end, Results), - ok. - -%% RPC target. --spec do_open_iterator(emqx_types:words(), emqx_ds:time(), emqx_ds:iterator_id()) -> - {ok, emqx_ds_storage_layer:iterator()} | {error, _Reason}. -do_open_iterator(TopicFilter, StartMS, _IteratorID) -> - %% TODO: wrong - {ok, emqx_ds:make_iterator(TopicFilter, StartMS)}. - --spec del_subscription(topic(), iterator(), id()) -> - ok. -del_subscription(TopicFilterBin, #{id := IteratorID}, DSSessionID) -> - % N.B.: see comments in `?MODULE:add_subscription' for a discussion about the - % order of operations here. - TopicFilter = emqx_topic:words(TopicFilterBin), - ok = emqx_persistent_session_ds_router:do_delete_route(TopicFilterBin, DSSessionID). + ?tp_span( + persistent_session_ds_subscription_route_delete, + #{session_id => DSSessionId}, + ok = emqx_persistent_session_ds_router:do_delete_route(TopicFilterBin, DSSessionId) + ). %%-------------------------------------------------------------------- %% Session tables operations %%-------------------------------------------------------------------- -define(SESSION_TAB, emqx_ds_session). --define(ITERATOR_REF_TAB, emqx_ds_iterator_ref). --define(DS_MRIA_SHARD, emqx_ds_shard). +-define(SESSION_SUBSCRIPTIONS_TAB, emqx_ds_session_subscriptions). +-define(DS_MRIA_SHARD, emqx_ds_session_shard). -record(session, { %% same as clientid @@ -423,12 +404,13 @@ del_subscription(TopicFilterBin, #{id := IteratorID}, DSSessionID) -> props = #{} :: map() }). --record(iterator_ref, { - ref_id :: {id(), emqx_ds:topic_filter()}, - it_id :: emqx_ds:iterator_id(), +-record(ds_sub, { + id :: subscription_id(), start_time :: emqx_ds:time(), - props = #{} :: map() + props = #{} :: map(), + extra = #{} :: map() }). +-type ds_sub() :: #ds_sub{}. create_tables() -> ok = mria:create_table( @@ -442,15 +424,16 @@ create_tables() -> ] ), ok = mria:create_table( - ?ITERATOR_REF_TAB, + ?SESSION_SUBSCRIPTIONS_TAB, [ {rlog_shard, ?DS_MRIA_SHARD}, {type, ordered_set}, {storage, storage()}, - {record_name, iterator_ref}, - {attributes, record_info(fields, iterator_ref)} + {record_name, ds_sub}, + {attributes, record_info(fields, ds_sub)} ] ), + ok = mria:wait_for_tables([?SESSION_TAB, ?SESSION_SUBSCRIPTIONS_TAB]), ok. -dialyzer({nowarn_function, storage/0}). @@ -471,26 +454,26 @@ storage() -> %% Note: session API doesn't handle session takeovers, it's the job of %% the broker. -spec session_open(id()) -> - {ok, session(), iterators()} | false. + {ok, session(), #{topic() => subscription()}} | false. session_open(SessionId) -> transaction(fun() -> case mnesia:read(?SESSION_TAB, SessionId, write) of [Record = #session{}] -> - Session = export_record(Record), - IteratorRefs = session_read_iterators(SessionId), - Iterators = export_iterators(IteratorRefs), - {ok, Session, Iterators}; + Session = export_session(Record), + DSSubs = session_read_subscriptions(SessionId), + Subscriptions = export_subscriptions(DSSubs), + {ok, Session, Subscriptions}; [] -> false end end). -spec session_ensure_new(id(), _Props :: map()) -> - {ok, session(), iterators()}. + {ok, session(), #{topic() => subscription()}}. session_ensure_new(SessionId, Props) -> transaction(fun() -> - ok = session_drop_iterators(SessionId), - Session = export_record(session_create(SessionId, Props)), + ok = session_drop_subscriptions(SessionId), + Session = export_session(session_create(SessionId, Props)), {ok, Session, #{}} end). @@ -510,80 +493,80 @@ session_create(SessionId, Props) -> session_drop(DSSessionId) -> transaction(fun() -> %% TODO: ensure all iterators from this clientid are closed? - ok = session_drop_iterators(DSSessionId), + ok = session_drop_subscriptions(DSSessionId), ok = mnesia:delete(?SESSION_TAB, DSSessionId, write) end). -session_drop_iterators(DSSessionId) -> - IteratorRefs = session_read_iterators(DSSessionId), - ok = lists:foreach(fun session_del_iterator/1, IteratorRefs). +session_drop_subscriptions(DSSessionId) -> + IteratorRefs = session_read_subscriptions(DSSessionId), + ok = lists:foreach(fun session_del_subscription/1, IteratorRefs). %% @doc Called when a client subscribes to a topic. Idempotent. --spec session_add_iterator(id(), topic_filter(), _Props :: map()) -> - {ok, iterator(), _IsNew :: boolean()}. -session_add_iterator(DSSessionId, TopicFilter, Props) -> - IteratorRefId = {DSSessionId, TopicFilter}, +-spec session_add_subscription(id(), topic_filter(), _Props :: map()) -> + {ok, subscription(), _IsNew :: boolean()}. +session_add_subscription(DSSessionId, TopicFilter, Props) -> + DSSubId = {DSSessionId, TopicFilter}, transaction(fun() -> - case mnesia:read(?ITERATOR_REF_TAB, IteratorRefId, write) of + case mnesia:read(?SESSION_SUBSCRIPTIONS_TAB, DSSubId, write) of [] -> - IteratorRef = session_insert_iterator(DSSessionId, TopicFilter, Props), - Iterator = export_record(IteratorRef), + DSSub = session_insert_subscription(DSSessionId, TopicFilter, Props), + DSSubExt = export_subscription(DSSub), ?tp( ds_session_subscription_added, - #{iterator => Iterator, session_id => DSSessionId} + #{sub => DSSubExt, session_id => DSSessionId} ), - {ok, Iterator, _IsNew = true}; - [#iterator_ref{} = IteratorRef] -> - NIteratorRef = session_update_iterator(IteratorRef, Props), - NIterator = export_record(NIteratorRef), + {ok, DSSubExt, _IsNew = true}; + [#ds_sub{} = DSSub] -> + NDSSub = session_update_subscription(DSSub, Props), + NDSSubExt = export_subscription(NDSSub), ?tp( ds_session_subscription_present, - #{iterator => NIterator, session_id => DSSessionId} + #{sub => NDSSubExt, session_id => DSSessionId} ), - {ok, NIterator, _IsNew = false} + {ok, NDSSubExt, _IsNew = false} end end). -session_insert_iterator(DSSessionId, TopicFilter, Props) -> - {IteratorId, StartMS} = new_iterator_id(DSSessionId), - IteratorRef = #iterator_ref{ - ref_id = {DSSessionId, TopicFilter}, - it_id = IteratorId, +-spec session_insert_subscription(id(), topic_filter(), map()) -> ds_sub(). +session_insert_subscription(DSSessionId, TopicFilter, Props) -> + {DSSubId, StartMS} = new_subscription_id(DSSessionId, TopicFilter), + DSSub = #ds_sub{ + id = DSSubId, start_time = StartMS, - props = Props + props = Props, + extra = #{} }, - ok = mnesia:write(?ITERATOR_REF_TAB, IteratorRef, write), - IteratorRef. + ok = mnesia:write(?SESSION_SUBSCRIPTIONS_TAB, DSSub, write), + DSSub. -session_update_iterator(IteratorRef, Props) -> - NIteratorRef = IteratorRef#iterator_ref{props = Props}, - ok = mnesia:write(?ITERATOR_REF_TAB, NIteratorRef, write), - NIteratorRef. +-spec session_update_subscription(ds_sub(), map()) -> ds_sub(). +session_update_subscription(DSSub, Props) -> + NDSSub = DSSub#ds_sub{props = Props}, + ok = mnesia:write(?SESSION_SUBSCRIPTIONS_TAB, NDSSub, write), + NDSSub. -%% @doc Called when a client unsubscribes from a topic. --spec session_del_iterator(id(), topic_filter()) -> ok. -session_del_iterator(DSSessionId, TopicFilter) -> - IteratorRefId = {DSSessionId, TopicFilter}, +session_del_subscription(DSSessionId, TopicFilter) -> + DSSubId = {DSSessionId, TopicFilter}, transaction(fun() -> - mnesia:delete(?ITERATOR_REF_TAB, IteratorRefId, write) + mnesia:delete(?SESSION_SUBSCRIPTIONS_TAB, DSSubId, write) end). -session_del_iterator(#iterator_ref{ref_id = IteratorRefId}) -> - mnesia:delete(?ITERATOR_REF_TAB, IteratorRefId, write). +session_del_subscription(#ds_sub{id = DSSubId}) -> + mnesia:delete(?SESSION_SUBSCRIPTIONS_TAB, DSSubId, write). -session_read_iterators(DSSessionId) -> +session_read_subscriptions(DSSessionId) -> % NOTE: somewhat convoluted way to trick dialyzer - Pat = erlang:make_tuple(record_info(size, iterator_ref), '_', [ - {1, iterator_ref}, - {#iterator_ref.ref_id, {DSSessionId, '_'}} + Pat = erlang:make_tuple(record_info(size, ds_sub), '_', [ + {1, ds_sub}, + {#ds_sub.id, {DSSessionId, '_'}} ]), - mnesia:match_object(?ITERATOR_REF_TAB, Pat, read). + mnesia:match_object(?SESSION_SUBSCRIPTIONS_TAB, Pat, read). --spec new_iterator_id(id()) -> {iterator_id(), emqx_ds:time()}. -new_iterator_id(DSSessionId) -> +-spec new_subscription_id(id(), topic_filter()) -> {subscription_id(), emqx_ds:time()}. +new_subscription_id(DSSessionId, TopicFilter) -> NowMS = erlang:system_time(microsecond), - IteratorId = <>, - {IteratorId, NowMS}. + DSSubId = {DSSessionId, TopicFilter}, + {DSSubId, NowMS}. %%-------------------------------------------------------------------------------- @@ -593,19 +576,20 @@ transaction(Fun) -> %%-------------------------------------------------------------------------------- -export_iterators(IteratorRefs) -> +export_subscriptions(DSSubs) -> lists:foldl( - fun(IteratorRef = #iterator_ref{ref_id = {_DSSessionId, TopicFilter}}, Acc) -> - Acc#{TopicFilter => export_record(IteratorRef)} + fun(DSSub = #ds_sub{id = {_DSSessionId, TopicFilter}}, Acc) -> + Acc#{TopicFilter => export_subscription(DSSub)} end, #{}, - IteratorRefs + DSSubs ). -export_record(#session{} = Record) -> - export_record(Record, #session.id, [id, created_at, expires_at, props], #{}); -export_record(#iterator_ref{} = Record) -> - export_record(Record, #iterator_ref.it_id, [id, start_time, props], #{}). +export_session(#session{} = Record) -> + export_record(Record, #session.id, [id, created_at, expires_at, props], #{}). + +export_subscription(#ds_sub{} = Record) -> + export_record(Record, #ds_sub.start_time, [start_time, props, extra], #{}). export_record(Record, I, [Field | Rest], Acc) -> export_record(Record, I + 1, Rest, Acc#{Field => element(I, Record)}); diff --git a/apps/emqx/test/emqx_persistent_messages_SUITE.erl b/apps/emqx/test/emqx_persistent_messages_SUITE.erl index 2d8768e65..32e59a114 100644 --- a/apps/emqx/test/emqx_persistent_messages_SUITE.erl +++ b/apps/emqx/test/emqx_persistent_messages_SUITE.erl @@ -29,6 +29,7 @@ -define(DEFAULT_KEYSPACE, default). -define(DS_SHARD_ID, <<"local">>). -define(DS_SHARD, {?DEFAULT_KEYSPACE, ?DS_SHARD_ID}). +-define(PERSISTENT_MESSAGE_DB, emqx_persistent_message). all() -> emqx_common_test_helpers:all(?MODULE). @@ -62,6 +63,7 @@ end_per_testcase(t_session_subscription_iterators, Config) -> end_per_testcase(_TestCase, Config) -> Apps = ?config(apps, Config), emqx_common_test_helpers:call_janitor(60_000), + clear_db(), emqx_cth_suite:stop(Apps), ok. @@ -96,7 +98,7 @@ t_messages_persisted(_Config) -> ct:pal("Results = ~p", [Results]), - Persisted = consume(?DS_SHARD, {['#'], 0}), + Persisted = consume(['#'], 0), ct:pal("Persisted = ~p", [Persisted]), @@ -139,7 +141,7 @@ t_messages_persisted_2(_Config) -> {ok, #{reason_code := ?RC_NO_MATCHING_SUBSCRIBERS}} = emqtt:publish(CP, T(<<"client/2/topic">>), <<"8">>, 1), - Persisted = consume(?DS_SHARD, {['#'], 0}), + Persisted = consume(['#'], 0), ct:pal("Persisted = ~p", [Persisted]), @@ -155,7 +157,7 @@ t_messages_persisted_2(_Config) -> %% TODO: test quic and ws too t_session_subscription_iterators(Config) -> - [Node1, Node2] = ?config(nodes, Config), + [Node1, _Node2] = ?config(nodes, Config), Port = get_mqtt_port(Node1, tcp), Topic = <<"t/topic">>, SubTopicFilter = <<"t/+">>, @@ -202,11 +204,8 @@ t_session_subscription_iterators(Config) -> messages => [Message1, Message2, Message3, Message4] } end, - fun(Results, Trace) -> + fun(Trace) -> ct:pal("trace:\n ~p", [Trace]), - #{ - messages := [_Message1, Message2, Message3 | _] - } = Results, case ?of_kind(ds_session_subscription_added, Trace) of [] -> %% Since `emqx_durable_storage' is a dependency of `emqx', it gets @@ -228,17 +227,6 @@ t_session_subscription_iterators(Config) -> ), ok end, - ?assertMatch({ok, [_]}, get_all_iterator_ids(Node1)), - {ok, [IteratorId]} = get_all_iterator_ids(Node1), - ?assertMatch({ok, [IteratorId]}, get_all_iterator_ids(Node2)), - ReplayMessages1 = erpc:call(Node1, fun() -> consume(?DS_SHARD, IteratorId) end), - ExpectedMessages = [Message2, Message3], - %% Note: it is expected that this will break after replayers are in place. - %% They might have consumed all the messages by this time. - ?assertEqual(ExpectedMessages, ReplayMessages1), - %% Different DS shard - ReplayMessages2 = erpc:call(Node2, fun() -> consume(?DS_SHARD, IteratorId) end), - ?assertEqual([], ReplayMessages2), ok end ), @@ -263,33 +251,21 @@ connect(Opts0 = #{}) -> {ok, _} = emqtt:connect(Client), Client. -consume(Shard, Replay = {_TopicFiler, _StartMS}) -> - {ok, It} = emqx_ds_storage_layer:make_iterator(Shard, Replay), - consume(It); -consume(Shard, IteratorId) when is_binary(IteratorId) -> - {ok, It} = emqx_ds_storage_layer:restore_iterator(Shard, IteratorId), +consume(TopicFiler, StartMS) -> + [{_, Stream}] = emqx_ds:get_streams(?PERSISTENT_MESSAGE_DB, TopicFiler, StartMS), + {ok, It} = emqx_ds:make_iterator(Stream, StartMS), consume(It). consume(It) -> - case emqx_ds_storage_layer:next(It) of - {ok, NIt, [Msg]} -> - [emqx_persistent_message:deserialize(Msg) | consume(NIt)]; - end_of_stream -> + case emqx_ds:next(It, 100) of + {ok, _NIt, _Msgs = []} -> + []; + {ok, NIt, Msgs} -> + Msgs ++ consume(NIt); + {ok, end_of_stream} -> [] end. -delete_all_messages() -> - Persisted = consume(?DS_SHARD, {['#'], 0}), - lists:foreach( - fun(Msg) -> - GUID = emqx_message:id(Msg), - Topic = emqx_topic:words(emqx_message:topic(Msg)), - Timestamp = emqx_guid:timestamp(GUID), - ok = emqx_ds_storage_layer:delete(?DS_SHARD, GUID, Timestamp, Topic) - end, - Persisted - ). - receive_messages(Count) -> receive_messages(Count, []). @@ -306,13 +282,6 @@ receive_messages(Count, Msgs) -> publish(Node, Message) -> erpc:call(Node, emqx, publish, [Message]). -get_iterator_ids(Node, ClientId) -> - Channel = erpc:call(Node, fun() -> - [ConnPid] = emqx_cm:lookup_channels(ClientId), - sys:get_state(ConnPid) - end), - emqx_connection:info({channel, {session, iterators}}, Channel). - app_specs() -> [ emqx_durable_storage, @@ -330,5 +299,6 @@ get_mqtt_port(Node, Type) -> {_IP, Port} = erpc:call(Node, emqx_config, get, [[listeners, Type, default, bind]]), Port. -get_all_iterator_ids(Node) -> - erpc:call(Node, emqx_ds_storage_layer, list_iterator_prefix, [?DS_SHARD, <<>>]). +clear_db() -> + ok = emqx_ds:drop_db(?PERSISTENT_MESSAGE_DB), + ok. diff --git a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl index b43604469..a28c9de52 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl @@ -48,19 +48,19 @@ %% level. %% %% TODO: currently the stream is hardwired to only support the -%% internal rocksdb storage. In t he future we want to add another +%% internal rocksdb storage. In the future we want to add another %% implementations for emqx_ds, so this type has to take this into %% account. -record(stream, { shard :: emqx_ds_replication_layer:shard_id(), - enc :: emqx_ds_replication_layer:stream() + enc :: emqx_ds_storage_layer:stream() }). --opaque stream() :: stream(). +-opaque stream() :: #stream{}. -record(iterator, { shard :: emqx_ds_replication_layer:shard_id(), - enc :: enqx_ds_replication_layer:iterator() + enc :: enqx_ds_storage_layer:iterator() }). -opaque iterator() :: #iterator{}. @@ -154,7 +154,7 @@ next(Iter0, BatchSize) -> %% messages on the receiving node, hence saving some network. %% %% This kind of trickery should be probably done here in the - %% replication layer. Or, perhaps, in the logic lary. + %% replication layer. Or, perhaps, in the logic layer. case emqx_ds_proto_v1:next(Node, Shard, StorageIter0, BatchSize) of {ok, StorageIter, Batch} -> Iter = #iterator{shard = Shard, enc = StorageIter}, From 51a6f623fd1c0775dc87afe772b1821baacc695a Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Tue, 10 Oct 2023 22:15:14 +0200 Subject: [PATCH 10/31] refactor(ds): Split out bitfield keymapper to a different module --- .../src/emqx_ds_bitmask_keymapper.erl | 693 ++++++++++++++++++ 1 file changed, 693 insertions(+) create mode 100644 apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl diff --git a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl new file mode 100644 index 000000000..44f171b55 --- /dev/null +++ b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl @@ -0,0 +1,693 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- +-module(emqx_ds_bitmask_keymapper). + +%%================================================================================ +%% @doc This module is used to map N-dimensional coordinates to a +%% 1-dimensional space. +%% +%% Example: +%% +%% Let us assume that `T' is a topic and `t' is time. These are the two +%% dimensions used to index messages. They can be viewed as +%% "coordinates" of an MQTT message in a 2D space. +%% +%% Oftentimes, when wildcard subscription is used, keys must be +%% scanned in both dimensions simultaneously. +%% +%% Rocksdb allows to iterate over sorted keys very fast. This means we +%% need to map our two-dimentional keys to a single index that is +%% sorted in a way that helps to iterate over both time and topic +%% without having to do a lot of random seeks. +%% +%% == Mapping of 2D keys to rocksdb keys == +%% +%% We use "zigzag" pattern to store messages, where rocksdb key is +%% composed like like this: +%% +%% |ttttt|TTTTTTTTT|tttt| +%% ^ ^ ^ +%% | | | +%% +-------+ | +---------+ +%% | | | +%% most significant topic hash least significant +%% bits of timestamp bits of timestamp +%% (a.k.a epoch) (a.k.a time offset) +%% +%% Topic hash is level-aware: each topic level is hashed separately +%% and the resulting hashes are bitwise-concatentated. This allows us +%% to map topics to fixed-length bitstrings while keeping some degree +%% of information about the hierarchy. +%% +%% Next important concept is what we call "epoch". Duration of the +%% epoch is determined by maximum time offset. Epoch is calculated by +%% shifting bits of the timestamp right. +%% +%% The resulting index is a space-filling curve that looks like +%% this in the topic-time 2D space: +%% +%% T ^ ---->------ |---->------ |---->------ +%% | --/ / --/ / --/ +%% | -<-/ | -<-/ | -<-/ +%% | -/ | -/ | -/ +%% | ---->------ | ---->------ | ---->------ +%% | --/ / --/ / --/ +%% | ---/ | ---/ | ---/ +%% | -/ ^ -/ ^ -/ +%% | ---->------ | ---->------ | ---->------ +%% | --/ / --/ / --/ +%% | -<-/ | -<-/ | -<-/ +%% | -/ | -/ | -/ +%% | ---->------| ---->------| ----------> +%% | +%% -+------------+-----------------------------> t +%% epoch +%% +%% This structure allows to quickly seek to a the first message that +%% was recorded in a certain epoch in a certain topic or a +%% group of topics matching filter like `foo/bar/#`. +%% +%% Due to its structure, for each pair of rocksdb keys K1 and K2, such +%% that K1 > K2 and topic(K1) = topic(K2), timestamp(K1) > +%% timestamp(K2). +%% That is, replay doesn't reorder messages published in each +%% individual topic. +%% +%% This property doesn't hold between different topics, but it's not deemed +%% a problem right now. +%% +%%================================================================================ + +%% API: +-export([make_keymapper/1, vector_to_key/2, key_to_vector/2, next_range/3]). + +%% behavior callbacks: +-export([]). + +%% internal exports: +-export([]). + +-export_type([vector/0, key/0, dimension/0, offset/0, bitsize/0, bitsource/0, keymapper/0]). + +-compile( + {inline, [ + ones/1, + extract/2 + ]} +). + +-ifdef(TEST). +-include_lib("proper/include/proper.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-endif. + +%%================================================================================ +%% Type declarations +%%================================================================================ + +-type scalar() :: integer(). + +-type vector() :: [scalar()]. + +%% N-th coordinate of a vector: +-type dimension() :: pos_integer(). + +-type offset() :: non_neg_integer(). + +-type bitsize() :: pos_integer(). + +%% The resulting 1D key: +-type key() :: binary(). + +-type bitsource() :: + %% Consume `_Size` bits from timestamp starting at `_Offset`th + %% bit from N-th element of the input vector: + {dimension(), offset(), bitsize()}. + +-record(scan_action, { + src_bitmask :: integer(), + src_offset :: offset(), + dst_offset :: offset() +}). + +-type scanner() :: [[#scan_action{}]]. + +-record(keymapper, { + schema :: [bitsource()], + scanner :: scanner(), + size :: non_neg_integer(), + dim_sizeof :: [non_neg_integer()] +}). + +-opaque keymapper() :: #keymapper{}. + +-type scalar_range() :: any | {'=', scalar()} | {'>=', scalar()}. + +%%================================================================================ +%% API functions +%%================================================================================ + +%% @doc +%% +%% Note: Dimension is 1-based. +-spec make_keymapper([bitsource()]) -> keymapper(). +make_keymapper(Bitsources) -> + Arr0 = array:new([{fixed, false}, {default, {0, []}}]), + {Size, Arr} = fold_bitsources( + fun(DestOffset, {Dim0, Offset, Size}, Acc) -> + Dim = Dim0 - 1, + Action = #scan_action{ + src_bitmask = ones(Size), src_offset = Offset, dst_offset = DestOffset + }, + {DimSizeof, Actions} = array:get(Dim, Acc), + array:set(Dim, {DimSizeof + Size, [Action | Actions]}, Acc) + end, + Arr0, + Bitsources + ), + {DimSizeof, Scanner} = lists:unzip(array:to_list(Arr)), + #keymapper{ + schema = Bitsources, + scanner = Scanner, + size = Size, + dim_sizeof = DimSizeof + }. + +%% @doc Map N-dimensional vector to a scalar key. +%% +%% Note: this function is not injective. +-spec vector_to_key(keymapper(), vector()) -> key(). +vector_to_key(#keymapper{scanner = []}, []) -> + 0; +vector_to_key(#keymapper{scanner = [Actions | Scanner]}, [Coord | Vector]) -> + do_vector_to_key(Actions, Scanner, Coord, Vector, 0). + +%% @doc Map key to a vector. +%% +%% Note: `vector_to_key(key_to_vector(K)) = K' but +%% `key_to_vector(vector_to_key(V)) = V' is not guaranteed. +-spec key_to_vector(keymapper(), key()) -> vector(). +key_to_vector(#keymapper{scanner = Scanner}, Key) -> + lists:map( + fun(Actions) -> + lists:foldl( + fun(Action, Acc) -> + Acc bor extract_inv(Key, Action) + end, + 0, + Actions + ) + end, + Scanner + ). + +%% @doc Given a keymapper, a filter, and a key, return a triple containing: +%% +%% 1. `NextKey', a key that is greater than the given one, and is +%% within the given range. +%% +%% 2. `Bitmask' +%% +%% 3. `Bitfilter' +%% +%% Bitmask and bitfilter can be used to verify that key any K is in +%% the range using the following inequality: +%% +%% K >= NextKey && (K band Bitmask) =:= Bitfilter. +%% +%% ...or `undefined' if the next key is outside the range. +-spec next_range(keymapper(), [scalar_range()], key()) -> {key(), integer(), integer()} | undefined. +next_range(Keymapper, Filter0, PrevKey) -> + %% Key -> Vector -> +1 on vector -> Key + Filter = desugar_filter(Keymapper, Filter0), + PrevVec = key_to_vector(Keymapper, PrevKey), + case inc_vector(Filter, PrevVec) of + overflow -> + undefined; + NextVec -> + NewKey = vector_to_key(Keymapper, NextVec), + Bitmask = make_bitmask(Keymapper, Filter), + Bitfilter = NewKey band Bitmask, + {NewKey, Bitmask, Bitfilter} + end. + +%%================================================================================ +%% Internal functions +%%================================================================================ + +-spec make_bitmask(keymapper(), [{non_neg_integer(), non_neg_integer()}]) -> non_neg_integer(). +make_bitmask(Keymapper = #keymapper{dim_sizeof = DimSizeof}, Ranges) -> + BitmaskVector = lists:map( + fun + ({{N, N}, Bits}) -> + %% For strict equality we can employ bitmask: + ones(Bits); + (_) -> + 0 + end, + lists:zip(Ranges, DimSizeof) + ), + vector_to_key(Keymapper, BitmaskVector). + +-spec inc_vector([{non_neg_integer(), non_neg_integer()}], vector()) -> vector() | overflow. +inc_vector(Filter, Vec0) -> + case normalize_vector(Filter, Vec0) of + {true, Vec} -> + Vec; + {false, Vec} -> + do_inc_vector(Filter, Vec, []) + end. + +do_inc_vector([], [], _Acc) -> + overflow; +do_inc_vector([{Min, Max} | Intervals], [Elem | Vec], Acc) -> + case Elem of + Max -> + do_inc_vector(Intervals, Vec, [Min | Acc]); + _ when Elem < Max -> + lists:reverse(Acc) ++ [Elem + 1 | Vec] + end. + +normalize_vector(Intervals, Vec0) -> + Vec = lists:map( + fun + ({{Min, _Max}, Elem}) when Min > Elem -> + Min; + ({{_Min, Max}, Elem}) when Max < Elem -> + Max; + ({_, Elem}) -> + Elem + end, + lists:zip(Intervals, Vec0) + ), + {Vec > Vec0, Vec}. + +%% Transform inequalities into a list of closed intervals that the +%% vector elements should lie in. +desugar_filter(#keymapper{dim_sizeof = DimSizeof}, Filter) -> + lists:map( + fun + ({any, Bitsize}) -> + {0, ones(Bitsize)}; + ({{'=', Val}, _Bitsize}) -> + {Val, Val}; + ({{'>=', Val}, Bitsize}) -> + {Val, ones(Bitsize)} + end, + lists:zip(Filter, DimSizeof) + ). + +-spec fold_bitsources(fun((_DstOffset :: non_neg_integer(), bitsource(), Acc) -> Acc), Acc, [ + bitsource() +]) -> {bitsize(), Acc}. +fold_bitsources(Fun, InitAcc, Bitsources) -> + lists:foldl( + fun(Bitsource = {_Dim, _Offset, Size}, {DstOffset, Acc0}) -> + Acc = Fun(DstOffset, Bitsource, Acc0), + {DstOffset + Size, Acc} + end, + {0, InitAcc}, + Bitsources + ). + +%% Specialized version of fold: +do_vector_to_key([], [], _Coord, [], Acc) -> + Acc; +do_vector_to_key([], [NewActions | Scanner], _Coord, [NewCoord | Vector], Acc) -> + do_vector_to_key(NewActions, Scanner, NewCoord, Vector, Acc); +do_vector_to_key([Action | Actions], Scanner, Coord, Vector, Acc0) -> + Acc = Acc0 bor extract(Coord, Action), + do_vector_to_key(Actions, Scanner, Coord, Vector, Acc). + +-spec extract(_Source :: scalar(), #scan_action{}) -> integer(). +extract(Src, #scan_action{src_bitmask = SrcBitmask, src_offset = SrcOffset, dst_offset = DstOffset}) -> + ((Src bsr SrcOffset) band SrcBitmask) bsl DstOffset. + +%% extract^-1 +-spec extract_inv(_Dest :: scalar(), #scan_action{}) -> integer(). +extract_inv(Dest, #scan_action{ + src_bitmask = SrcBitmask, src_offset = SrcOffset, dst_offset = DestOffset +}) -> + ((Dest bsr DestOffset) band SrcBitmask) bsl SrcOffset. + +ones(Bits) -> + 1 bsl Bits - 1. + +%% Create a bitmask that is sufficient to cover a given number. E.g.: +%% +%% 2#1000 -> 2#1111; 2#0 -> 2#0; 2#10101 -> 2#11111 +bitmask_of(N) -> + %% FIXME: avoid floats + NBits = ceil(math:log2(N + 1)), + ones(NBits). + +%%================================================================================ +%% Unit tests +%%================================================================================ + +-ifdef(TEST). + +bitmask_of_test() -> + ?assertEqual(2#0, bitmask_of(0)), + ?assertEqual(2#1, bitmask_of(1)), + ?assertEqual(2#11, bitmask_of(2#10)), + ?assertEqual(2#11, bitmask_of(2#11)), + ?assertEqual(2#1111, bitmask_of(2#1000)), + ?assertEqual(2#1111, bitmask_of(2#1111)), + ?assertEqual(ones(128), bitmask_of(ones(128))), + ?assertEqual(ones(256), bitmask_of(ones(256))). + +make_keymapper0_test() -> + Schema = [], + ?assertEqual( + #keymapper{ + schema = Schema, + scanner = [], + size = 0, + dim_sizeof = [] + }, + make_keymapper(Schema) + ). + +make_keymapper1_test() -> + Schema = [{1, 0, 3}, {2, 0, 5}], + ?assertEqual( + #keymapper{ + schema = Schema, + scanner = [ + [#scan_action{src_bitmask = 2#111, src_offset = 0, dst_offset = 0}], + [#scan_action{src_bitmask = 2#11111, src_offset = 0, dst_offset = 3}] + ], + size = 8, + dim_sizeof = [3, 5] + }, + make_keymapper(Schema) + ). + +make_keymapper2_test() -> + Schema = [{1, 0, 3}, {2, 0, 5}, {1, 3, 5}], + ?assertEqual( + #keymapper{ + schema = Schema, + scanner = [ + [ + #scan_action{src_bitmask = 2#11111, src_offset = 3, dst_offset = 8}, + #scan_action{src_bitmask = 2#111, src_offset = 0, dst_offset = 0} + ], + [#scan_action{src_bitmask = 2#11111, src_offset = 0, dst_offset = 3}] + ], + size = 13, + dim_sizeof = [8, 5] + }, + make_keymapper(Schema) + ). + +vector_to_key0_test() -> + Schema = [], + Vector = [], + ?assertEqual(0, vec2key(Schema, Vector)). + +vector_to_key1_test() -> + Schema = [{1, 0, 8}], + ?assertEqual(16#ff, vec2key(Schema, [16#ff])), + ?assertEqual(16#1a, vec2key(Schema, [16#1a])), + ?assertEqual(16#ff, vec2key(Schema, [16#aaff])). + +%% Test handling of source offset: +vector_to_key2_test() -> + Schema = [{1, 8, 8}], + ?assertEqual(0, vec2key(Schema, [16#ff])), + ?assertEqual(16#1a, vec2key(Schema, [16#1aff])), + ?assertEqual(16#aa, vec2key(Schema, [16#11aaff])). + +%% Basic test of 2D vector: +vector_to_key3_test() -> + Schema = [{1, 0, 8}, {2, 0, 8}], + ?assertEqual(16#aaff, vec2key(Schema, [16#ff, 16#aa])), + ?assertEqual(16#2211, vec2key(Schema, [16#aa11, 16#bb22])). + +%% Advanced test with 2D vector: +vector_to_key4_test() -> + Schema = [{1, 0, 8}, {2, 0, 8}, {1, 8, 8}, {2, 16, 8}], + ?assertEqual(16#bb112211, vec2key(Schema, [16#aa1111, 16#bb2222])). + +key_to_vector0_test() -> + Schema = [], + key2vec(Schema, []). + +key_to_vector1_test() -> + Schema = [{1, 0, 8}, {2, 0, 8}], + key2vec(Schema, [1, 1]), + key2vec(Schema, [255, 255]), + key2vec(Schema, [255, 1]), + key2vec(Schema, [0, 1]), + key2vec(Schema, [255, 0]). + +key_to_vector2_test() -> + Schema = [{1, 0, 3}, {2, 0, 8}, {1, 3, 5}], + key2vec(Schema, [1, 1]), + key2vec(Schema, [255, 255]), + key2vec(Schema, [255, 1]), + key2vec(Schema, [0, 1]), + key2vec(Schema, [255, 0]). + +inc_vector0_test() -> + Keymapper = make_keymapper([]), + ?assertMatch(overflow, incvec(Keymapper, [], [])). + +inc_vector1_test() -> + Keymapper = make_keymapper([{1, 0, 8}]), + ?assertMatch([3], incvec(Keymapper, [{'=', 3}], [1])), + ?assertMatch([3], incvec(Keymapper, [{'=', 3}], [2])), + ?assertMatch(overflow, incvec(Keymapper, [{'=', 3}], [3])), + ?assertMatch(overflow, incvec(Keymapper, [{'=', 3}], [4])), + ?assertMatch(overflow, incvec(Keymapper, [{'=', 3}], [255])), + %% Now with >=: + ?assertMatch([1], incvec(Keymapper, [{'>=', 0}], [0])), + ?assertMatch([255], incvec(Keymapper, [{'>=', 0}], [254])), + ?assertMatch(overflow, incvec(Keymapper, [{'>=', 0}], [255])), + + ?assertMatch([100], incvec(Keymapper, [{'>=', 100}], [0])), + ?assertMatch([100], incvec(Keymapper, [{'>=', 100}], [99])), + ?assertMatch([255], incvec(Keymapper, [{'>=', 100}], [254])), + ?assertMatch(overflow, incvec(Keymapper, [{'>=', 100}], [255])). + +inc_vector2_test() -> + Keymapper = make_keymapper([{1, 0, 8}, {2, 0, 8}, {3, 0, 8}]), + Filter = [{'>=', 0}, {'=', 100}, {'>=', 30}], + ?assertMatch([0, 100, 30], incvec(Keymapper, Filter, [0, 0, 0])), + ?assertMatch([1, 100, 30], incvec(Keymapper, Filter, [0, 100, 30])), + ?assertMatch([255, 100, 30], incvec(Keymapper, Filter, [254, 100, 30])), + ?assertMatch([0, 100, 31], incvec(Keymapper, Filter, [255, 100, 30])), + ?assertMatch([0, 100, 30], incvec(Keymapper, Filter, [0, 100, 29])), + ?assertMatch(overflow, incvec(Keymapper, Filter, [255, 100, 255])), + ?assertMatch([255, 100, 255], incvec(Keymapper, Filter, [254, 100, 255])), + ?assertMatch([0, 100, 255], incvec(Keymapper, Filter, [255, 100, 254])), + %% Nasty cases (shouldn't happen, hopefully): + ?assertMatch([1, 100, 30], incvec(Keymapper, Filter, [0, 101, 0])), + ?assertMatch([1, 100, 33], incvec(Keymapper, Filter, [0, 101, 33])), + ?assertMatch([0, 100, 255], incvec(Keymapper, Filter, [255, 101, 254])), + ?assertMatch(overflow, incvec(Keymapper, Filter, [255, 101, 255])). + +make_bitmask0_test() -> + Keymapper = make_keymapper([]), + ?assertMatch(0, mkbmask(Keymapper, [])). + +make_bitmask1_test() -> + Keymapper = make_keymapper([{1, 0, 8}]), + ?assertEqual(0, mkbmask(Keymapper, [any])), + ?assertEqual(16#ff, mkbmask(Keymapper, [{'=', 1}])), + ?assertEqual(16#ff, mkbmask(Keymapper, [{'=', 255}])), + ?assertEqual(0, mkbmask(Keymapper, [{'>=', 0}])), + ?assertEqual(0, mkbmask(Keymapper, [{'>=', 1}])), + ?assertEqual(0, mkbmask(Keymapper, [{'>=', 16#f}])). + +make_bitmask2_test() -> + Keymapper = make_keymapper([{1, 0, 3}, {2, 0, 4}, {3, 0, 2}]), + ?assertEqual(2#00_0000_000, mkbmask(Keymapper, [any, any, any])), + ?assertEqual(2#11_0000_000, mkbmask(Keymapper, [any, any, {'=', 0}])), + ?assertEqual(2#00_1111_000, mkbmask(Keymapper, [any, {'=', 0}, any])), + ?assertEqual(2#00_0000_111, mkbmask(Keymapper, [{'=', 0}, any, any])). + +make_bitmask3_test() -> + %% Key format of type |TimeOffset|Topic|Epoch|: + Keymapper = make_keymapper([{1, 8, 8}, {2, 0, 8}, {1, 0, 8}]), + ?assertEqual(2#00000000_00000000_00000000, mkbmask(Keymapper, [any, any])), + ?assertEqual(2#11111111_11111111_11111111, mkbmask(Keymapper, [{'=', 33}, {'=', 22}])), + ?assertEqual(2#11111111_11111111_11111111, mkbmask(Keymapper, [{'=', 33}, {'=', 22}])), + ?assertEqual(2#00000000_11111111_00000000, mkbmask(Keymapper, [{'>=', 255}, {'=', 22}])). + +next_range0_test() -> + Keymapper = make_keymapper([]), + Filter = [], + PrevKey = 0, + ?assertMatch(undefined, next_range(Keymapper, Filter, PrevKey)). + +next_range1_test() -> + Keymapper = make_keymapper([{1, 0, 8}, {2, 0, 8}]), + ?assertMatch(undefined, next_range(Keymapper, [{'=', 0}, {'=', 0}], 0)), + ?assertMatch({1, 16#ffff, 1}, next_range(Keymapper, [{'=', 1}, {'=', 0}], 0)), + ?assertMatch({16#100, 16#ffff, 16#100}, next_range(Keymapper, [{'=', 0}, {'=', 1}], 0)), + %% Now with any: + ?assertMatch({1, 0, 0}, next_range(Keymapper, [any, any], 0)), + ?assertMatch({2, 0, 0}, next_range(Keymapper, [any, any], 1)), + ?assertMatch({16#fffb, 0, 0}, next_range(Keymapper, [any, any], 16#fffa)), + %% Now with >=: + ?assertMatch( + {16#42_30, 16#ff00, 16#42_00}, next_range(Keymapper, [{'>=', 16#30}, {'=', 16#42}], 0) + ), + ?assertMatch( + {16#42_31, 16#ff00, 16#42_00}, + next_range(Keymapper, [{'>=', 16#30}, {'=', 16#42}], 16#42_30) + ), + + ?assertMatch( + {16#30_42, 16#00ff, 16#00_42}, next_range(Keymapper, [{'=', 16#42}, {'>=', 16#30}], 0) + ), + ?assertMatch( + {16#31_42, 16#00ff, 16#00_42}, + next_range(Keymapper, [{'=', 16#42}, {'>=', 16#30}], 16#00_43) + ). + +%% Bunch of tests that verifying that next_range doesn't skip over keys: + +-define(assertIterComplete(A, B), + ?assertEqual(A -- [0], B) +). + +-define(assertSameSet(A, B), + ?assertIterComplete(lists:sort(A), lists:sort(B)) +). + +iterate1_test() -> + SizeX = 3, + SizeY = 3, + Keymapper = make_keymapper([{1, 0, SizeX}, {2, 0, SizeY}]), + Keys = test_iteration(Keymapper, [any, any]), + Expected = [ + X bor (Y bsl SizeX) + || Y <- lists:seq(0, ones(SizeY)), X <- lists:seq(0, ones(SizeX)) + ], + ?assertIterComplete(Expected, Keys). + +iterate2_test() -> + SizeX = 64, + SizeY = 3, + Keymapper = make_keymapper([{1, 0, SizeX}, {2, 0, SizeY}]), + X = 123456789, + Keys = test_iteration(Keymapper, [{'=', X}, any]), + Expected = [ + X bor (Y bsl SizeX) + || Y <- lists:seq(0, ones(SizeY)) + ], + ?assertIterComplete(Expected, Keys). + +iterate3_test() -> + SizeX = 3, + SizeY = 64, + Y = 42, + Keymapper = make_keymapper([{1, 0, SizeX}, {2, 0, SizeY}]), + Keys = test_iteration(Keymapper, [any, {'=', Y}]), + Expected = [ + X bor (Y bsl SizeX) + || X <- lists:seq(0, ones(SizeX)) + ], + ?assertIterComplete(Expected, Keys). + +iterate4_test() -> + SizeX = 8, + SizeY = 4, + MinX = 16#fa, + MinY = 16#a, + Keymapper = make_keymapper([{1, 0, SizeX}, {2, 0, SizeY}]), + Keys = test_iteration(Keymapper, [{'>=', MinX}, {'>=', MinY}]), + Expected = [ + X bor (Y bsl SizeX) + || Y <- lists:seq(MinY, ones(SizeY)), X <- lists:seq(MinX, ones(SizeX)) + ], + ?assertIterComplete(Expected, Keys). + +iterate1_prop() -> + Size = 4, + ?FORALL( + {SizeX, SizeY}, + {integer(1, Size), integer(1, Size)}, + ?FORALL( + {SplitX, MinX, MinY}, + {integer(0, SizeX), integer(0, SizeX), integer(0, SizeY)}, + begin + Keymapper = make_keymapper([ + {1, 0, SplitX}, {2, 0, SizeY}, {1, SplitX, SizeX - SplitX} + ]), + Keys = test_iteration(Keymapper, [{'>=', MinX}, {'>=', MinY}]), + Expected = [ + vector_to_key(Keymapper, [X, Y]) + || X <- lists:seq(MinX, ones(SizeX)), + Y <- lists:seq(MinY, ones(SizeY)) + ], + ?assertSameSet(Expected, Keys), + true + end + ) + ). + +iterate5_test() -> + ?assert(proper:quickcheck(iterate1_prop(), 100)). + +iterate2_prop() -> + Size = 4, + ?FORALL( + {SizeX, SizeY}, + {integer(1, Size), integer(1, Size)}, + ?FORALL( + {SplitX, MinX, MinY}, + {integer(0, SizeX), integer(0, SizeX), integer(0, SizeY)}, + begin + Keymapper = make_keymapper([ + {1, SplitX, SizeX - SplitX}, {2, 0, SizeY}, {1, 0, SplitX} + ]), + Keys = test_iteration(Keymapper, [{'>=', MinX}, {'>=', MinY}]), + Expected = [ + vector_to_key(Keymapper, [X, Y]) + || X <- lists:seq(MinX, ones(SizeX)), + Y <- lists:seq(MinY, ones(SizeY)) + ], + ?assertSameSet(Expected, Keys), + true + end + ) + ). + +iterate6_test() -> + ?assert(proper:quickcheck(iterate2_prop(), 1000)). + +test_iteration(Keymapper, Filter) -> + test_iteration(Keymapper, Filter, 0). + +test_iteration(Keymapper, Filter, PrevKey) -> + case next_range(Keymapper, Filter, PrevKey) of + undefined -> + []; + {Key, Bitmask, Bitfilter} -> + ?assert((Key band Bitmask) =:= Bitfilter), + [Key | test_iteration(Keymapper, Filter, Key)] + end. + +mkbmask(Keymapper, Filter0) -> + Filter = desugar_filter(Keymapper, Filter0), + make_bitmask(Keymapper, Filter). + +incvec(Keymapper, Filter0, Vector) -> + Filter = desugar_filter(Keymapper, Filter0), + inc_vector(Filter, Vector). + +key2vec(Schema, Vector) -> + Keymapper = make_keymapper(Schema), + Key = vector_to_key(Keymapper, Vector), + ?assertEqual(Vector, key_to_vector(Keymapper, Key)). + +vec2key(Schema, Vector) -> + vector_to_key(make_keymapper(Schema), Vector). + +-endif. From c149e0e2df8d373cf09b472cadc8dc159426411a Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Wed, 11 Oct 2023 15:51:52 +0200 Subject: [PATCH 11/31] fix(ds): Pass topic filter to emqx_ds:make_iterator call --- apps/emqx_durable_storage/src/emqx_ds.erl | 8 ++--- .../src/emqx_ds_bitmask_keymapper.erl | 35 ++++++++++--------- .../src/emqx_ds_replication_layer.erl | 16 ++++----- .../src/emqx_ds_storage_layer.erl | 10 +++--- .../src/emqx_ds_storage_reference.erl | 10 +++--- .../src/proto/emqx_ds_proto_v1.erl | 8 ++--- .../test/emqx_ds_SUITE.erl | 15 ++++---- 7 files changed, 53 insertions(+), 49 deletions(-) diff --git a/apps/emqx_durable_storage/src/emqx_ds.erl b/apps/emqx_durable_storage/src/emqx_ds.erl index cf4b5a031..dd6af9a03 100644 --- a/apps/emqx_durable_storage/src/emqx_ds.erl +++ b/apps/emqx_durable_storage/src/emqx_ds.erl @@ -28,7 +28,7 @@ -export([store_batch/2, store_batch/3]). %% Message replay API: --export([get_streams/3, make_iterator/2, next/2]). +-export([get_streams/3, make_iterator/3, next/2]). %% Misc. API: -export([]). @@ -159,9 +159,9 @@ store_batch(DB, Msgs) -> get_streams(DB, TopicFilter, StartTime) -> emqx_ds_replication_layer:get_streams(DB, TopicFilter, StartTime). --spec make_iterator(stream(), time()) -> make_iterator_result(). -make_iterator(Stream, StartTime) -> - emqx_ds_replication_layer:make_iterator(Stream, StartTime). +-spec make_iterator(stream(), topic_filter(), time()) -> make_iterator_result(). +make_iterator(Stream, TopicFilter, StartTime) -> + emqx_ds_replication_layer:make_iterator(Stream, TopicFilter, StartTime). -spec next(iterator(), pos_integer()) -> next_result(). next(Iter, BatchSize) -> diff --git a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl index 44f171b55..fd2d41946 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl @@ -334,29 +334,30 @@ extract_inv(Dest, #scan_action{ ones(Bits) -> 1 bsl Bits - 1. -%% Create a bitmask that is sufficient to cover a given number. E.g.: -%% -%% 2#1000 -> 2#1111; 2#0 -> 2#0; 2#10101 -> 2#11111 -bitmask_of(N) -> - %% FIXME: avoid floats - NBits = ceil(math:log2(N + 1)), - ones(NBits). - %%================================================================================ %% Unit tests %%================================================================================ -ifdef(TEST). -bitmask_of_test() -> - ?assertEqual(2#0, bitmask_of(0)), - ?assertEqual(2#1, bitmask_of(1)), - ?assertEqual(2#11, bitmask_of(2#10)), - ?assertEqual(2#11, bitmask_of(2#11)), - ?assertEqual(2#1111, bitmask_of(2#1000)), - ?assertEqual(2#1111, bitmask_of(2#1111)), - ?assertEqual(ones(128), bitmask_of(ones(128))), - ?assertEqual(ones(256), bitmask_of(ones(256))). +%% %% Create a bitmask that is sufficient to cover a given number. E.g.: +%% %% +%% %% 2#1000 -> 2#1111; 2#0 -> 2#0; 2#10101 -> 2#11111 +%% bitmask_of(N) -> +%% %% FIXME: avoid floats +%% NBits = ceil(math:log2(N + 1)), +%% ones(NBits). + + +%% bitmask_of_test() -> +%% ?assertEqual(2#0, bitmask_of(0)), +%% ?assertEqual(2#1, bitmask_of(1)), +%% ?assertEqual(2#11, bitmask_of(2#10)), +%% ?assertEqual(2#11, bitmask_of(2#11)), +%% ?assertEqual(2#1111, bitmask_of(2#1000)), +%% ?assertEqual(2#1111, bitmask_of(2#1111)), +%% ?assertEqual(ones(128), bitmask_of(ones(128))), +%% ?assertEqual(ones(256), bitmask_of(ones(256))). make_keymapper0_test() -> Schema = [], diff --git a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl index a28c9de52..aeb2ce646 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl @@ -21,7 +21,7 @@ drop_db/1, store_batch/3, get_streams/3, - make_iterator/2, + make_iterator/3, next/2 ]). @@ -30,7 +30,7 @@ do_open_shard_v1/2, do_drop_shard_v1/1, do_get_streams_v1/3, - do_make_iterator_v1/3, + do_make_iterator_v1/4, do_next_v1/3 ]). @@ -132,11 +132,11 @@ get_streams(DB, TopicFilter, StartTime) -> Shards ). --spec make_iterator(stream(), emqx_ds:time()) -> emqx_ds:make_iterator_result(iterator()). -make_iterator(Stream, StartTime) -> +-spec make_iterator(stream(), emqx_ds:topic_filter(), emqx_ds:time()) -> emqx_ds:make_iterator_result(iterator()). +make_iterator(Stream, TopicFilter, StartTime) -> #stream{shard = Shard, enc = StorageStream} = Stream, Node = node_of_shard(Shard), - case emqx_ds_proto_v1:make_iterator(Node, Shard, StorageStream, StartTime) of + case emqx_ds_proto_v1:make_iterator(Node, Shard, StorageStream, TopicFilter, StartTime) of {ok, Iter} -> {ok, #iterator{shard = Shard, enc = Iter}}; Err = {error, _} -> @@ -184,9 +184,9 @@ do_drop_shard_v1(Shard) -> do_get_streams_v1(Shard, TopicFilter, StartTime) -> emqx_ds_storage_layer:get_streams(Shard, TopicFilter, StartTime). --spec do_make_iterator_v1(shard_id(), _Stream, emqx_ds:time()) -> {ok, iterator()} | {error, _}. -do_make_iterator_v1(Shard, Stream, StartTime) -> - emqx_ds_storage_layer:make_iterator(Shard, Stream, StartTime). +-spec do_make_iterator_v1(shard_id(), _Stream, emqx_ds:topic_filter(), emqx_ds:time()) -> {ok, iterator()} | {error, _}. +do_make_iterator_v1(Shard, Stream, TopicFilter, StartTime) -> + emqx_ds_storage_layer:make_iterator(Shard, Stream, TopicFilter, StartTime). -spec do_next_v1(shard_id(), Iter, pos_integer()) -> emqx_ds:next_result(Iter). do_next_v1(Shard, Iter, BatchSize) -> diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl index e9d4edc06..744ac869f 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl @@ -18,7 +18,7 @@ -behaviour(gen_server). %% Replication layer API: --export([open_shard/2, drop_shard/1, store_batch/3, get_streams/3, make_iterator/3, next/3]). +-export([open_shard/2, drop_shard/1, store_batch/3, get_streams/3, make_iterator/4, next/3]). %% gen_server -export([start_link/2, init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). @@ -112,7 +112,7 @@ -callback get_streams(shard_id(), _Data, emqx_ds:topic_filter(), emqx_ds:time()) -> [_Stream]. --callback make_iterator(shard_id(), _Data, _Stream, emqx_ds:time()) -> +-callback make_iterator(shard_id(), _Data, _Stream, emqx_ds:topic_filter(), emqx_ds:time()) -> emqx_ds:make_iterator_result(_Iterator). -callback next(shard_id(), _Data, Iter, pos_integer()) -> @@ -158,11 +158,11 @@ get_streams(Shard, TopicFilter, StartTime) -> Gens ). --spec make_iterator(shard_id(), stream(), emqx_ds:time()) -> +-spec make_iterator(shard_id(), stream(), emqx_ds:topic_filter(), emqx_ds:time()) -> emqx_ds:make_iterator_result(iterator()). -make_iterator(Shard, #stream{generation = GenId, enc = Stream}, StartTime) -> +make_iterator(Shard, #stream{generation = GenId, enc = Stream}, TopicFilter, StartTime) -> #{module := Mod, data := GenData} = generation_get(Shard, GenId), - case Mod:make_iterator(Shard, GenData, Stream, StartTime) of + case Mod:make_iterator(Shard, GenData, Stream, TopicFilter, StartTime) of {ok, Iter} -> {ok, #it{ generation = GenId, diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl index fd480eeab..5a91f9ecd 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl @@ -27,7 +27,7 @@ -export([]). %% behavior callbacks: --export([create/4, open/5, store_batch/4, get_streams/4, make_iterator/4, next/4]). +-export([create/4, open/5, store_batch/4, get_streams/4, make_iterator/5, next/4]). %% internal exports: -export([]). @@ -49,7 +49,7 @@ cf :: rocksdb:cf_handle() }). --record(stream, {topic_filter :: emqx_ds:topic_filter()}). +-record(stream, {}). -record(it, { topic_filter :: emqx_ds:topic_filter(), @@ -86,10 +86,10 @@ store_batch(_ShardId, #s{db = DB, cf = CF}, Messages, _Options) -> Messages ). -get_streams(_Shard, _Data, TopicFilter, _StartTime) -> - [#stream{topic_filter = TopicFilter}]. +get_streams(_Shard, _Data, _TopicFilter, _StartTime) -> + [#stream{}]. -make_iterator(_Shard, _Data, #stream{topic_filter = TopicFilter}, StartTime) -> +make_iterator(_Shard, _Data, #stream{}, TopicFilter, StartTime) -> {ok, #it{ topic_filter = TopicFilter, start_time = StartTime diff --git a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl index 60671cef7..d4d7b3631 100644 --- a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl +++ b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl @@ -19,7 +19,7 @@ -include_lib("emqx/include/bpapi.hrl"). %% API: --export([open_shard/3, drop_shard/2, get_streams/4, make_iterator/4, next/4]). +-export([open_shard/3, drop_shard/2, get_streams/4, make_iterator/5, next/4]). %% behavior callbacks: -export([introduced_in/0]). @@ -45,10 +45,10 @@ drop_shard(Node, Shard) -> get_streams(Node, Shard, TopicFilter, Time) -> erpc:call(Node, emqx_ds_replication_layer, do_get_streams_v1, [Shard, TopicFilter, Time]). --spec make_iterator(node(), emqx_ds_replication_layer:shard(), _Stream, emqx_ds:time()) -> +-spec make_iterator(node(), emqx_ds_replication_layer:shard(), _Stream, emqx_ds:topic_filter(), emqx_ds:time()) -> {ok, emqx_ds_replication_layer:iterator()} | {error, _}. -make_iterator(Node, Shard, Stream, StartTime) -> - erpc:call(Node, emqx_ds_replication_layer, do_make_iterator_v1, [Shard, Stream, StartTime]). +make_iterator(Node, Shard, Stream, TopicFilter, StartTime) -> + erpc:call(Node, emqx_ds_replication_layer, do_make_iterator_v1, [Shard, Stream, TopicFilter, StartTime]). -spec next( node(), emqx_ds_replication_layer:shard(), emqx_ds_replication_layer:iterator(), pos_integer() diff --git a/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl b/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl index 1935e41cf..2dc77c563 100644 --- a/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl +++ b/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl @@ -45,9 +45,10 @@ t_02_smoke_get_streams_start_iter(_Config) -> DB = ?FUNCTION_NAME, ?assertMatch(ok, emqx_ds:open_db(DB, #{})), StartTime = 0, - [{Rank, Stream}] = emqx_ds:get_streams(DB, ['#'], StartTime), + TopicFilter = ['#'], + [{Rank, Stream}] = emqx_ds:get_streams(DB, TopicFilter, StartTime), ?assertMatch({_, _}, Rank), - ?assertMatch({ok, _Iter}, emqx_ds:make_iterator(Stream, StartTime)). + ?assertMatch({ok, _Iter}, emqx_ds:make_iterator(Stream, TopicFilter, StartTime)). %% A simple smoke test that verifies that it's possible to iterate %% over messages. @@ -55,14 +56,15 @@ t_03_smoke_iterate(_Config) -> DB = ?FUNCTION_NAME, ?assertMatch(ok, emqx_ds:open_db(DB, #{})), StartTime = 0, + TopicFilter = ['#'], Msgs = [ message(<<"foo/bar">>, <<"1">>, 0), message(<<"foo">>, <<"2">>, 1), message(<<"bar/bar">>, <<"3">>, 2) ], ?assertMatch(ok, emqx_ds:store_batch(DB, Msgs)), - [{_, Stream}] = emqx_ds:get_streams(DB, ['#'], StartTime), - {ok, Iter0} = emqx_ds:make_iterator(Stream, StartTime), + [{_, Stream}] = emqx_ds:get_streams(DB, TopicFilter, StartTime), + {ok, Iter0} = emqx_ds:make_iterator(Stream, TopicFilter, StartTime), {ok, Iter, Batch} = iterate(Iter0, 1), ?assertEqual(Msgs, Batch, {Iter0, Iter}). @@ -74,6 +76,7 @@ t_03_smoke_iterate(_Config) -> t_04_restart(_Config) -> DB = ?FUNCTION_NAME, ?assertMatch(ok, emqx_ds:open_db(DB, #{})), + TopicFilter = ['#'], StartTime = 0, Msgs = [ message(<<"foo/bar">>, <<"1">>, 0), @@ -81,8 +84,8 @@ t_04_restart(_Config) -> message(<<"bar/bar">>, <<"3">>, 2) ], ?assertMatch(ok, emqx_ds:store_batch(DB, Msgs)), - [{_, Stream}] = emqx_ds:get_streams(DB, ['#'], StartTime), - {ok, Iter0} = emqx_ds:make_iterator(Stream, StartTime), + [{_, Stream}] = emqx_ds:get_streams(DB, TopicFilter, StartTime), + {ok, Iter0} = emqx_ds:make_iterator(Stream, TopicFilter, StartTime), %% Restart the application: ?tp(warning, emqx_ds_SUITE_restart_app, #{}), ok = application:stop(emqx_durable_storage), From f1ab7c8a7c83d1109d287ba5b8ab95ce64376e9b Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Wed, 11 Oct 2023 16:38:16 +0200 Subject: [PATCH 12/31] feat(ds): Add persist callback to LTS trie --- apps/emqx_durable_storage/src/emqx_ds_lts.erl | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/apps/emqx_durable_storage/src/emqx_ds_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_lts.erl index fcc9f2b36..5422979b7 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_lts.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_lts.erl @@ -17,10 +17,10 @@ -module(emqx_ds_lts). %% API: --export([trie_create/0, topic_key/3, match_topics/2, lookup_topic_key/2, dump_to_dot/2]). +-export([trie_create/1, trie_create/0, topic_key/3, match_topics/2, lookup_topic_key/2]). %% Debug: --export([trie_next/3, trie_insert/3]). +-export([trie_next/3, trie_insert/3, dump_to_dot/2]). -export_type([static_key/0, trie/0]). @@ -46,11 +46,16 @@ -define(PREFIX, prefix). -type state() :: static_key() | ?PREFIX. --type msg_storage_key() :: {static_key(), _Varying :: [binary()]}. +-type varying() :: [binary()]. + +-type msg_storage_key() :: {static_key(), varying()}. -type threshold_fun() :: fun((non_neg_integer()) -> non_neg_integer()). +-type persist_callback() :: fun((_Key, _Val) -> ok). + -record(trie, { + persist :: persist_callback(), trie :: ets:tid(), stats :: ets:tid() }). @@ -67,16 +72,23 @@ %%================================================================================ %% @doc Create an empty trie --spec trie_create() -> trie(). -trie_create() -> +-spec trie_create(persist_callback()) -> trie(). +trie_create(Persist) -> Trie = ets:new(trie, [{keypos, #trans.key}, set]), Stats = ets:new(stats, [{keypos, 1}, set]), #trie{ + persist = Persist, trie = Trie, stats = Stats }. -%% @doc Create a topic key, +-spec trie_create() -> trie(). +trie_create() -> + trie_create(fun(_, _) -> + ok + end). + +%% @doc Lookup the topic key. Create a new one, if not found. -spec topic_key(trie(), threshold_fun(), [binary()]) -> msg_storage_key(). topic_key(Trie, ThresholdFun, Tokens) -> do_topic_key(Trie, ThresholdFun, 0, ?PREFIX, Tokens, []). @@ -161,8 +173,9 @@ trie_next(#trie{trie = Trie}, State, Token) -> end. -spec trie_insert(trie(), state(), edge()) -> {Updated, state()} when - Updated :: false | non_neg_integer(). -trie_insert(#trie{trie = Trie, stats = Stats}, State, Token) -> + NChildren :: non_neg_integer(), + Updated :: false | NChildren. +trie_insert(#trie{trie = Trie, stats = Stats, persist = Persist}, State, Token) -> Key = {State, Token}, NewState = get_id_for_key(State, Token), Rec = #trans{ @@ -171,6 +184,7 @@ trie_insert(#trie{trie = Trie, stats = Stats}, State, Token) -> }, case ets:insert_new(Trie, Rec) of true -> + ok = Persist(Key, NewState), Inc = case Token of ?EOT -> 0; @@ -206,7 +220,7 @@ get_id_for_key(_State, _Token) -> crypto:strong_rand_bytes(8). %% erlfmt-ignore --spec do_match_topics(trie(), state(), non_neg_integer(), [binary() | '+' | '#']) -> +-spec do_match_topics(trie(), state(), [binary() | '+'], [binary() | '+' | '#']) -> list(). do_match_topics(Trie, State, Varying, []) -> case trie_next(Trie, State, ?EOT) of @@ -260,6 +274,8 @@ do_lookup_topic_key(Trie, State, [Tok | Rest], Varying) -> end. do_topic_key(Trie, _, _, State, [], Varying) -> + %% We reached the end of topic. Assert: Trie node that corresponds + %% to EOT cannot be a wildcard. {_, false, Static} = trie_next_(Trie, State, ?EOT), {Static, lists:reverse(Varying)}; do_topic_key(Trie, ThresholdFun, Depth, State, [Tok | Rest], Varying0) -> @@ -268,11 +284,15 @@ do_topic_key(Trie, ThresholdFun, Depth, State, [Tok | Rest], Varying0) -> Varying = case trie_next_(Trie, State, Tok) of {NChildren, _, _DiscardState} when is_integer(NChildren), NChildren > Threshold -> + %% Number of children for the trie node reached the + %% threshold, we need to insert wildcard here: {_, NextState} = trie_insert(Trie, State, ?PLUS), [Tok | Varying0]; {_, false, NextState} -> Varying0; {_, true, NextState} -> + %% This topic level is marked as wildcard in the trie, + %% we need to add it to the varying part of the key: [Tok | Varying0] end, do_topic_key(Trie, ThresholdFun, Depth + 1, NextState, Rest, Varying). From ac91dbc58fc6bc29cbdca54a11d1efe4880a3e17 Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Wed, 11 Oct 2023 16:49:25 +0200 Subject: [PATCH 13/31] feat(ds): Restore LTS trie from a dump --- .../src/emqx_ds_bitmask_keymapper.erl | 1 - apps/emqx_durable_storage/src/emqx_ds_lts.erl | 38 ++++++++++++++----- .../src/emqx_ds_replication_layer.erl | 6 ++- .../src/proto/emqx_ds_proto_v1.erl | 8 +++- 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl index fd2d41946..2f28de293 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl @@ -348,7 +348,6 @@ ones(Bits) -> %% NBits = ceil(math:log2(N + 1)), %% ones(NBits). - %% bitmask_of_test() -> %% ?assertEqual(2#0, bitmask_of(0)), %% ?assertEqual(2#1, bitmask_of(1)), diff --git a/apps/emqx_durable_storage/src/emqx_ds_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_lts.erl index 5422979b7..e9d3124f9 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_lts.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_lts.erl @@ -17,7 +17,9 @@ -module(emqx_ds_lts). %% API: --export([trie_create/1, trie_create/0, topic_key/3, match_topics/2, lookup_topic_key/2]). +-export([ + trie_create/1, trie_create/0, trie_restore/2, topic_key/3, match_topics/2, lookup_topic_key/2 +]). %% Debug: -export([trie_next/3, trie_insert/3, dump_to_dot/2]). @@ -85,8 +87,19 @@ trie_create(Persist) -> -spec trie_create() -> trie(). trie_create() -> trie_create(fun(_, _) -> - ok - end). + ok + end). + +%% @doc Restore trie from a dump +-spec trie_restore(persist_callback(), [{_Key, _Val}]) -> trie(). +trie_restore(Persist, Dump) -> + Trie = trie_create(Persist), + lists:foreach( + fun({{StateFrom, Token}, StateTo}) -> + trie_insert(Trie, StateFrom, Token, StateTo) + end, + Dump + ). %% @doc Lookup the topic key. Create a new one, if not found. -spec topic_key(trie(), threshold_fun(), [binary()]) -> msg_storage_key(). @@ -173,11 +186,20 @@ trie_next(#trie{trie = Trie}, State, Token) -> end. -spec trie_insert(trie(), state(), edge()) -> {Updated, state()} when - NChildren :: non_neg_integer(), + NChildren :: non_neg_integer(), Updated :: false | NChildren. -trie_insert(#trie{trie = Trie, stats = Stats, persist = Persist}, State, Token) -> +trie_insert(Trie, State, Token) -> + trie_insert(Trie, State, Token, get_id_for_key(State, Token)). + +%%================================================================================ +%% Internal functions +%%================================================================================ + +-spec trie_insert(trie(), state(), edge(), state()) -> {Updated, state()} when + NChildren :: non_neg_integer(), + Updated :: false | NChildren. +trie_insert(#trie{trie = Trie, stats = Stats, persist = Persist}, State, Token, NewState) -> Key = {State, Token}, - NewState = get_id_for_key(State, Token), Rec = #trans{ key = Key, next = NewState @@ -198,10 +220,6 @@ trie_insert(#trie{trie = Trie, stats = Stats, persist = Persist}, State, Token) {false, NextState} end. -%%================================================================================ -%% Internal functions -%%================================================================================ - -spec get_id_for_key(state(), edge()) -> static_key(). get_id_for_key(_State, _Token) -> %% Requirements for the return value: diff --git a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl index aeb2ce646..5b4ad8666 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl @@ -132,7 +132,8 @@ get_streams(DB, TopicFilter, StartTime) -> Shards ). --spec make_iterator(stream(), emqx_ds:topic_filter(), emqx_ds:time()) -> emqx_ds:make_iterator_result(iterator()). +-spec make_iterator(stream(), emqx_ds:topic_filter(), emqx_ds:time()) -> + emqx_ds:make_iterator_result(iterator()). make_iterator(Stream, TopicFilter, StartTime) -> #stream{shard = Shard, enc = StorageStream} = Stream, Node = node_of_shard(Shard), @@ -184,7 +185,8 @@ do_drop_shard_v1(Shard) -> do_get_streams_v1(Shard, TopicFilter, StartTime) -> emqx_ds_storage_layer:get_streams(Shard, TopicFilter, StartTime). --spec do_make_iterator_v1(shard_id(), _Stream, emqx_ds:topic_filter(), emqx_ds:time()) -> {ok, iterator()} | {error, _}. +-spec do_make_iterator_v1(shard_id(), _Stream, emqx_ds:topic_filter(), emqx_ds:time()) -> + {ok, iterator()} | {error, _}. do_make_iterator_v1(Shard, Stream, TopicFilter, StartTime) -> emqx_ds_storage_layer:make_iterator(Shard, Stream, TopicFilter, StartTime). diff --git a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl index d4d7b3631..df9115a78 100644 --- a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl +++ b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl @@ -45,10 +45,14 @@ drop_shard(Node, Shard) -> get_streams(Node, Shard, TopicFilter, Time) -> erpc:call(Node, emqx_ds_replication_layer, do_get_streams_v1, [Shard, TopicFilter, Time]). --spec make_iterator(node(), emqx_ds_replication_layer:shard(), _Stream, emqx_ds:topic_filter(), emqx_ds:time()) -> +-spec make_iterator( + node(), emqx_ds_replication_layer:shard(), _Stream, emqx_ds:topic_filter(), emqx_ds:time() +) -> {ok, emqx_ds_replication_layer:iterator()} | {error, _}. make_iterator(Node, Shard, Stream, TopicFilter, StartTime) -> - erpc:call(Node, emqx_ds_replication_layer, do_make_iterator_v1, [Shard, Stream, TopicFilter, StartTime]). + erpc:call(Node, emqx_ds_replication_layer, do_make_iterator_v1, [ + Shard, Stream, TopicFilter, StartTime + ]). -spec next( node(), emqx_ds_replication_layer:shard(), emqx_ds_replication_layer:iterator(), pos_integer() From 7428e7037b51da049d780c6c38959e01bcbc712a Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Wed, 11 Oct 2023 20:53:34 +0200 Subject: [PATCH 14/31] feat(ds): Bitfield + Learned Topic Structure --- apps/emqx_durable_storage/src/emqx_ds.erl | 18 +- .../src/emqx_ds_bitmask_keymapper.erl | 60 ++- apps/emqx_durable_storage/src/emqx_ds_lts.erl | 58 +-- .../src/emqx_ds_replication_layer.erl | 2 +- .../src/emqx_ds_storage_bitfield_lts.erl | 346 ++++++++++++++++++ .../src/emqx_ds_storage_layer.erl | 23 +- .../src/emqx_ds_storage_layer_sup.erl | 4 +- .../src/emqx_ds_storage_reference.erl | 6 +- .../test/emqx_ds_SUITE.erl | 20 +- ...emqx_ds_message_storage_bitmask_SUITE.erl_ | 188 ---------- .../emqx_ds_storage_bitfield_lts_SUITE.erl | 343 +++++++++++++++++ .../test/emqx_ds_storage_layer_SUITE.erl_ | 292 --------------- 12 files changed, 821 insertions(+), 539 deletions(-) create mode 100644 apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl delete mode 100644 apps/emqx_durable_storage/test/emqx_ds_message_storage_bitmask_SUITE.erl_ create mode 100644 apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl delete mode 100644 apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl_ diff --git a/apps/emqx_durable_storage/src/emqx_ds.erl b/apps/emqx_durable_storage/src/emqx_ds.erl index dd6af9a03..b1a003e93 100644 --- a/apps/emqx_durable_storage/src/emqx_ds.erl +++ b/apps/emqx_durable_storage/src/emqx_ds.erl @@ -34,6 +34,8 @@ -export([]). -export_type([ + create_db_opts/0, + builtin_db_opts/0, db/0, time/0, topic_filter/0, @@ -58,7 +60,7 @@ %% Parsed topic filter. -type topic_filter() :: list(binary() | '+' | '#' | ''). --type stream_rank() :: {integer(), integer()}. +-type stream_rank() :: {term(), integer()}. -opaque stream() :: emqx_ds_replication_layer:stream(). @@ -83,9 +85,14 @@ -type message_store_opts() :: #{}. +-type builtin_db_opts() :: + #{ + backend := builtin, + storage := emqx_ds_storage_layer:prototype() + }. + -type create_db_opts() :: - %% TODO: keyspace - #{}. + builtin_db_opts(). -type message_id() :: emqx_ds_replication_layer:message_id(). @@ -96,7 +103,7 @@ %% @doc Different DBs are completely independent from each other. They %% could represent something like different tenants. -spec open_db(db(), create_db_opts()) -> ok. -open_db(DB, Opts) -> +open_db(DB, Opts = #{backend := builtin}) -> emqx_ds_replication_layer:open_db(DB, Opts). %% @doc TODO: currently if one or a few shards are down, they won't be @@ -109,8 +116,7 @@ drop_db(DB) -> store_batch(DB, Msgs, Opts) -> emqx_ds_replication_layer:store_batch(DB, Msgs, Opts). -%% TODO: Do we really need to return message IDs? It's extra work... --spec store_batch(db(), [emqx_types:message()]) -> {ok, [message_id()]} | {error, _}. +-spec store_batch(db(), [emqx_types:message()]) -> store_batch_result(). store_batch(DB, Msgs) -> store_batch(DB, Msgs, #{}). diff --git a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl index 2f28de293..4b6fcbcdf 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl @@ -80,20 +80,24 @@ %%================================================================================ %% API: --export([make_keymapper/1, vector_to_key/2, key_to_vector/2, next_range/3]). - -%% behavior callbacks: --export([]). - -%% internal exports: --export([]). +-export([ + make_keymapper/1, + vector_to_key/2, + bin_vector_to_key/2, + key_to_vector/2, + bin_key_to_vector/2, + next_range/3, + key_to_bitstring/2, + bitstring_to_key/2 +]). -export_type([vector/0, key/0, dimension/0, offset/0, bitsize/0, bitsource/0, keymapper/0]). -compile( {inline, [ ones/1, - extract/2 + extract/2, + extract_inv/2 ]} ). @@ -118,7 +122,7 @@ -type bitsize() :: pos_integer(). %% The resulting 1D key: --type key() :: binary(). +-type key() :: non_neg_integer(). -type bitsource() :: %% Consume `_Size` bits from timestamp starting at `_Offset`th @@ -148,7 +152,8 @@ %% API functions %%================================================================================ -%% @doc +%% @doc Create a keymapper object that stores the "schema" of the +%% transformation from a list of bitsources. %% %% Note: Dimension is 1-based. -spec make_keymapper([bitsource()]) -> keymapper(). @@ -183,6 +188,19 @@ vector_to_key(#keymapper{scanner = []}, []) -> vector_to_key(#keymapper{scanner = [Actions | Scanner]}, [Coord | Vector]) -> do_vector_to_key(Actions, Scanner, Coord, Vector, 0). +%% @doc Same as `vector_to_key', but it works with binaries, and outputs a binary. +-spec bin_vector_to_key(keymapper(), [binary()]) -> binary(). +bin_vector_to_key(Keymapper = #keymapper{dim_sizeof = DimSizeof, size = Size}, Binaries) -> + Vec = lists:map( + fun({Bin, SizeOf}) -> + <> = Bin, + Int + end, + lists:zip(Binaries, DimSizeof) + ), + Key = vector_to_key(Keymapper, Vec), + <>. + %% @doc Map key to a vector. %% %% Note: `vector_to_key(key_to_vector(K)) = K' but @@ -202,6 +220,18 @@ key_to_vector(#keymapper{scanner = Scanner}, Key) -> Scanner ). +%% @doc Same as `key_to_vector', but it works with binaries. +-spec bin_key_to_vector(keymapper(), binary()) -> [binary()]. +bin_key_to_vector(Keymapper = #keymapper{dim_sizeof = DimSizeof, size = Size}, BinKey) -> + <> = BinKey, + Vector = key_to_vector(Keymapper, Key), + lists:map( + fun({Elem, SizeOf}) -> + <> + end, + lists:zip(Vector, DimSizeof) + ). + %% @doc Given a keymapper, a filter, and a key, return a triple containing: %% %% 1. `NextKey', a key that is greater than the given one, and is @@ -232,6 +262,15 @@ next_range(Keymapper, Filter0, PrevKey) -> {NewKey, Bitmask, Bitfilter} end. +-spec bitstring_to_key(keymapper(), bitstring()) -> key(). +bitstring_to_key(#keymapper{size = Size}, Bin) -> + <> = Bin, + Key. + +-spec key_to_bitstring(keymapper(), key()) -> bitstring(). +key_to_bitstring(#keymapper{size = Size}, Key) -> + <>. + %%================================================================================ %% Internal functions %%================================================================================ @@ -311,7 +350,6 @@ fold_bitsources(Fun, InitAcc, Bitsources) -> Bitsources ). -%% Specialized version of fold: do_vector_to_key([], [], _Coord, [], Acc) -> Acc; do_vector_to_key([], [NewActions | Scanner], _Coord, [NewCoord | Vector], Acc) -> diff --git a/apps/emqx_durable_storage/src/emqx_ds_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_lts.erl index e9d3124f9..a6e67c069 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_lts.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_lts.erl @@ -24,7 +24,7 @@ %% Debug: -export([trie_next/3, trie_insert/3, dump_to_dot/2]). --export_type([static_key/0, trie/0]). +-export_type([options/0, static_key/0, trie/0]). -include_lib("stdlib/include/ms_transform.hrl"). @@ -43,12 +43,12 @@ -type edge() :: binary() | ?EOT | ?PLUS. %% Fixed size binary --type static_key() :: binary(). +-type static_key() :: non_neg_integer(). -define(PREFIX, prefix). -type state() :: static_key() | ?PREFIX. --type varying() :: [binary()]. +-type varying() :: [binary() | ?PLUS]. -type msg_storage_key() :: {static_key(), varying()}. @@ -56,8 +56,15 @@ -type persist_callback() :: fun((_Key, _Val) -> ok). +-type options() :: + #{ + persist_callback => persist_callback(), + static_key_size => pos_integer() + }. + -record(trie, { persist :: persist_callback(), + static_key_size :: pos_integer(), trie :: ets:tid(), stats :: ets:tid() }). @@ -74,32 +81,40 @@ %%================================================================================ %% @doc Create an empty trie --spec trie_create(persist_callback()) -> trie(). -trie_create(Persist) -> - Trie = ets:new(trie, [{keypos, #trans.key}, set]), - Stats = ets:new(stats, [{keypos, 1}, set]), +-spec trie_create(options()) -> trie(). +trie_create(UserOpts) -> + Defaults = #{ + persist_callback => fun(_, _) -> ok end, + static_key_size => 8 + }, + #{ + persist_callback := Persist, + static_key_size := StaticKeySize + } = maps:merge(Defaults, UserOpts), + Trie = ets:new(trie, [{keypos, #trans.key}, set, public]), + Stats = ets:new(stats, [{keypos, 1}, set, public]), #trie{ persist = Persist, + static_key_size = StaticKeySize, trie = Trie, stats = Stats }. -spec trie_create() -> trie(). trie_create() -> - trie_create(fun(_, _) -> - ok - end). + trie_create(#{}). %% @doc Restore trie from a dump --spec trie_restore(persist_callback(), [{_Key, _Val}]) -> trie(). -trie_restore(Persist, Dump) -> - Trie = trie_create(Persist), +-spec trie_restore(options(), [{_Key, _Val}]) -> trie(). +trie_restore(Options, Dump) -> + Trie = trie_create(Options), lists:foreach( fun({{StateFrom, Token}, StateTo}) -> trie_insert(Trie, StateFrom, Token, StateTo) end, Dump - ). + ), + Trie. %% @doc Lookup the topic key. Create a new one, if not found. -spec topic_key(trie(), threshold_fun(), [binary()]) -> msg_storage_key(). @@ -113,7 +128,7 @@ lookup_topic_key(Trie, Tokens) -> %% @doc Return list of keys of topics that match a given topic filter -spec match_topics(trie(), [binary() | '+' | '#']) -> - [{static_key(), _Varying :: binary() | ?PLUS}]. + [msg_storage_key()]. match_topics(Trie, TopicFilter) -> do_match_topics(Trie, ?PREFIX, [], TopicFilter). @@ -189,7 +204,7 @@ trie_next(#trie{trie = Trie}, State, Token) -> NChildren :: non_neg_integer(), Updated :: false | NChildren. trie_insert(Trie, State, Token) -> - trie_insert(Trie, State, Token, get_id_for_key(State, Token)). + trie_insert(Trie, State, Token, get_id_for_key(Trie, State, Token)). %%================================================================================ %% Internal functions @@ -220,8 +235,8 @@ trie_insert(#trie{trie = Trie, stats = Stats, persist = Persist}, State, Token, {false, NextState} end. --spec get_id_for_key(state(), edge()) -> static_key(). -get_id_for_key(_State, _Token) -> +-spec get_id_for_key(trie(), state(), edge()) -> static_key(). +get_id_for_key(#trie{static_key_size = Size}, _State, _Token) -> %% Requirements for the return value: %% %% It should be globally unique for the `{State, Token}` pair. Other @@ -235,7 +250,8 @@ get_id_for_key(_State, _Token) -> %% If we want to impress computer science crowd, sorry, I mean to %% minimize storage requirements, we can even employ Huffman coding %% based on the frequency of messages. - crypto:strong_rand_bytes(8). + <> = crypto:strong_rand_bytes(Size), + Int. %% erlfmt-ignore -spec do_match_topics(trie(), state(), [binary() | '+'], [binary() | '+' | '#']) -> @@ -492,7 +508,7 @@ topic_key_test() -> end, lists:seq(1, 100)) after - dump_to_dot(T, atom_to_list(?FUNCTION_NAME) ++ ".dot") + dump_to_dot(T, filename:join("_build", atom_to_list(?FUNCTION_NAME) ++ ".dot")) end. %% erlfmt-ignore @@ -539,7 +555,7 @@ topic_match_test() -> {S2_1_, ['+', '+']}]), ok after - dump_to_dot(T, atom_to_list(?FUNCTION_NAME) ++ ".dot") + dump_to_dot(T, filename:join("_build", atom_to_list(?FUNCTION_NAME) ++ ".dot")) end. -define(keys_history, topic_key_history). diff --git a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl index 5b4ad8666..06cead725 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl @@ -119,7 +119,7 @@ get_streams(DB, TopicFilter, StartTime) -> Streams = emqx_ds_proto_v1:get_streams(Node, Shard, TopicFilter, StartTime), lists:map( fun({RankY, Stream}) -> - RankX = erlang:phash2(Shard, 255), + RankX = Shard, Rank = {RankX, RankY}, {Rank, #stream{ shard = Shard, diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl new file mode 100644 index 000000000..e8bfdaa2e --- /dev/null +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl @@ -0,0 +1,346 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +%% @doc Reference implementation of the storage. +%% +%% Trivial, extremely slow and inefficient. It also doesn't handle +%% restart of the Erlang node properly, so obviously it's only to be +%% used for testing. +-module(emqx_ds_storage_bitfield_lts). + +-behavior(emqx_ds_storage_layer). + +%% API: +-export([]). + +%% behavior callbacks: +-export([create/4, open/5, store_batch/4, get_streams/4, make_iterator/5, next/4]). + +%% internal exports: +-export([]). + +-export_type([options/0]). + +-include_lib("emqx/include/emqx.hrl"). + +%%================================================================================ +%% Type declarations +%%================================================================================ + +-type options() :: + #{ + bits_per_wildcard_level => pos_integer(), + topic_index_bytes => pos_integer(), + epoch_bits => non_neg_integer() + }. + +%% Permanent state: +-type schema() :: + #{ + bits_per_wildcard_level := pos_integer(), + topic_index_bytes := pos_integer(), + epoch_bits := non_neg_integer(), + ts_offset_bits := non_neg_integer() + }. + +%% Runtime state: +-record(s, { + db :: rocksdb:db_handle(), + data :: rocksdb:cf_handle(), + trie :: emqx_ds_lts:trie(), + keymappers :: array:array(emqx_ds_bitmask_keymapper:keymapper()) +}). + +-record(stream, { + storage_key :: emqx_ds_lts:msg_storage_key() +}). + +-record(it, { + topic_filter :: emqx_ds:topic_filter(), + start_time :: emqx_ds:time(), + storage_key :: emqx_ds_lts:msg_storage_key(), + last_seen_key = 0 :: emqx_ds_bitmask_keymapper:key(), + key_filter :: [emqx_ds_bitmask_keymapper:scalar_range()] +}). + +-define(QUICKCHECK_KEY(KEY, BITMASK, BITFILTER), + ((KEY band BITMASK) =:= BITFILTER) +). + +%%================================================================================ +%% API funcions +%%================================================================================ + +%%================================================================================ +%% behavior callbacks +%%================================================================================ + +create(_ShardId, DBHandle, GenId, Options) -> + %% Get options: + BitsPerTopicLevel = maps:get(bits_per_wildcard_level, Options, 64), + TopicIndexBytes = maps:get(topic_index_bytes, Options, 4), + TSOffsetBits = maps:get(epoch_bits, Options, 5), + %% Create column families: + DataCFName = data_cf(GenId), + TrieCFName = trie_cf(GenId), + {ok, DataCFHandle} = rocksdb:create_column_family(DBHandle, DataCFName, []), + {ok, TrieCFHandle} = rocksdb:create_column_family(DBHandle, TrieCFName, []), + %% Create schema: + + % Fixed size_of MQTT message timestamp + SizeOfTS = 64, + Schema = #{ + bits_per_wildcard_level => BitsPerTopicLevel, + topic_index_bytes => TopicIndexBytes, + epoch_bits => SizeOfTS - TSOffsetBits, + ts_offset_bits => TSOffsetBits + }, + {Schema, [{DataCFName, DataCFHandle}, {TrieCFName, TrieCFHandle}]}. + +open(_Shard, DBHandle, GenId, CFRefs, Schema) -> + #{ + bits_per_wildcard_level := BitsPerTopicLevel, + topic_index_bytes := TopicIndexBytes, + epoch_bits := EpochBits, + ts_offset_bits := TSOffsetBits + } = Schema, + {_, DataCF} = lists:keyfind(data_cf(GenId), 1, CFRefs), + {_, TrieCF} = lists:keyfind(trie_cf(GenId), 1, CFRefs), + Trie = restore_trie(TopicIndexBytes, DBHandle, TrieCF), + %% If user's topics have more than learned 10 wildcard levels then + %% total carnage is going on; learned topic structure doesn't + %% really apply: + MaxWildcardLevels = 10, + Keymappers = array:from_list( + [ + make_keymapper(TopicIndexBytes, EpochBits, BitsPerTopicLevel, TSOffsetBits, N) + || N <- lists:seq(0, MaxWildcardLevels) + ] + ), + #s{db = DBHandle, data = DataCF, trie = Trie, keymappers = Keymappers}. + +store_batch(_ShardId, S = #s{db = DB, data = Data}, Messages, _Options) -> + lists:foreach( + fun(Msg) -> + {Key, _} = make_key(S, Msg), + Val = serialize(Msg), + rocksdb:put(DB, Data, Key, Val, []) + end, + Messages + ). + +get_streams(_Shard, #s{trie = Trie}, TopicFilter, _StartTime) -> + Indexes = emqx_ds_lts:match_topics(Trie, TopicFilter), + [ + #stream{ + storage_key = I + } + || I <- Indexes + ]. + +make_iterator(_Shard, _Data, #stream{storage_key = StorageKey}, TopicFilter, StartTime) -> + {TopicIndex, Varying} = StorageKey, + Filter = [ + {'=', TopicIndex}, + {'>=', StartTime} + | lists:map( + fun + ('+') -> + any; + (TopicLevel) when is_binary(TopicLevel) -> + {'=', hash_topic_level(TopicLevel)} + end, + Varying + ) + ], + {ok, #it{ + topic_filter = TopicFilter, + start_time = StartTime, + storage_key = StorageKey, + key_filter = Filter + }}. + +next(_Shard, #s{db = DB, data = CF, keymappers = Keymappers}, It0, BatchSize) -> + #it{ + key_filter = KeyFilter + } = It0, + % TODO: ugh, so ugly + NVarying = length(KeyFilter) - 2, + Keymapper = array:get(NVarying, Keymappers), + {ok, ITHandle} = rocksdb:iterator(DB, CF, []), + try + next_loop(ITHandle, Keymapper, It0, [], BatchSize) + after + rocksdb:iterator_close(ITHandle) + end. + +%%================================================================================ +%% Internal functions +%%================================================================================ + +next_loop(_, _, It, Acc, 0) -> + {ok, It, lists:reverse(Acc)}; +next_loop(ITHandle, KeyMapper, It0, Acc0, N0) -> + {Key1, Bitmask, Bitfilter} = next_range(KeyMapper, It0), + case iterator_move(KeyMapper, ITHandle, {seek, Key1}) of + {ok, Key, Val} when ?QUICKCHECK_KEY(Key, Bitmask, Bitfilter) -> + Msg = deserialize(Val), + It1 = It0#it{last_seen_key = Key}, + case check_message(It1, Msg) of + true -> + N1 = N0 - 1, + Acc1 = [Msg | Acc0]; + false -> + N1 = N0, + Acc1 = Acc0 + end, + {N, It, Acc} = traverse_interval( + ITHandle, KeyMapper, Bitmask, Bitfilter, It1, Acc1, N1 + ), + next_loop(ITHandle, KeyMapper, It, Acc, N); + {ok, Key, _Val} -> + It = It0#it{last_seen_key = Key}, + next_loop(ITHandle, KeyMapper, It, Acc0, N0); + {error, invalid_iterator} -> + {ok, It0, lists:reverse(Acc0)} + end. + +traverse_interval(_, _, _, _, It, Acc, 0) -> + {0, It, Acc}; +traverse_interval(ITHandle, KeyMapper, Bitmask, Bitfilter, It0, Acc, N) -> + %% TODO: supply the upper limit to rocksdb to the last extra seek: + case iterator_move(KeyMapper, ITHandle, next) of + {ok, Key, Val} when ?QUICKCHECK_KEY(Key, Bitmask, Bitfilter) -> + Msg = deserialize(Val), + It = It0#it{last_seen_key = Key}, + case check_message(It, Msg) of + true -> + traverse_interval( + ITHandle, KeyMapper, Bitmask, Bitfilter, It, [Msg | Acc], N - 1 + ); + false -> + traverse_interval(ITHandle, KeyMapper, Bitmask, Bitfilter, It, Acc, N) + end; + {ok, Key, _Val} -> + It = It0#it{last_seen_key = Key}, + {N, It, Acc}; + {error, invalid_iterator} -> + {0, It0, Acc} + end. + +next_range(KeyMapper, #it{key_filter = KeyFilter, last_seen_key = PrevKey}) -> + emqx_ds_bitmask_keymapper:next_range(KeyMapper, KeyFilter, PrevKey). + +check_message(_Iterator, _Msg) -> + %% TODO. + true. + +iterator_move(KeyMapper, ITHandle, Action0) -> + Action = + case Action0 of + next -> + next; + {seek, Int} -> + {seek, emqx_ds_bitmask_keymapper:key_to_bitstring(KeyMapper, Int)} + end, + case rocksdb:iterator_move(ITHandle, Action) of + {ok, KeyBin, Val} -> + {ok, emqx_ds_bitmask_keymapper:bitstring_to_key(KeyMapper, KeyBin), Val}; + {ok, KeyBin} -> + {ok, emqx_ds_bitmask_keymapper:bitstring_to_key(KeyMapper, KeyBin)}; + Other -> + Other + end. + +-spec make_key(#s{}, #message{}) -> {binary(), [binary()]}. +make_key(#s{keymappers = KeyMappers, trie = Trie}, #message{timestamp = Timestamp, topic = TopicBin}) -> + Tokens = emqx_topic:tokens(TopicBin), + {TopicIndex, Varying} = emqx_ds_lts:topic_key(Trie, fun threshold_fun/1, Tokens), + VaryingHashes = [hash_topic_level(I) || I <- Varying], + KeyMapper = array:get(length(Varying), KeyMappers), + KeyBin = make_key(KeyMapper, TopicIndex, Timestamp, VaryingHashes), + {KeyBin, Varying}. + +-spec make_key(emqx_ds_bitmask_keymapper:keymapper(), emqx_ds_lts:static_key(), emqx_ds:time(), [ + non_neg_integer() +]) -> + binary(). +make_key(KeyMapper, TopicIndex, Timestamp, Varying) -> + emqx_ds_bitmask_keymapper:key_to_bitstring( + KeyMapper, + emqx_ds_bitmask_keymapper:vector_to_key(KeyMapper, [TopicIndex, Timestamp | Varying]) + ). + +%% TODO: don't hardcode the thresholds +threshold_fun(0) -> + 100; +threshold_fun(_) -> + 20. + +hash_topic_level(TopicLevel) -> + <> = erlang:md5(TopicLevel), + Int. + +serialize(Msg) -> + term_to_binary(Msg). + +deserialize(Blob) -> + binary_to_term(Blob). + +-define(BYTE_SIZE, 8). + +%% erlfmt-ignore +make_keymapper(TopicIndexBytes, EpochBits, BitsPerTopicLevel, TSOffsetBits, N) -> + Bitsources = + %% Dimension Offset Bitsize + [{1, 0, TopicIndexBytes * ?BYTE_SIZE}, %% Topic index + {2, TSOffsetBits, EpochBits }] ++ %% Timestamp epoch + [{2 + I, 0, BitsPerTopicLevel } %% Varying topic levels + || I <- lists:seq(1, N)] ++ + [{2, 0, TSOffsetBits }], %% Timestamp offset + emqx_ds_bitmask_keymapper:make_keymapper(Bitsources). + +-spec restore_trie(pos_integer(), rocksdb:db_handle(), rocksdb:cf_handle()) -> emqx_ds_lts:trie(). +restore_trie(TopicIndexBytes, DB, CF) -> + PersistCallback = fun(Key, Val) -> + rocksdb:put(DB, CF, term_to_binary(Key), term_to_binary(Val), []) + end, + {ok, IT} = rocksdb:iterator(DB, CF, []), + try + Dump = read_persisted_trie(IT, rocksdb:iterator_move(IT, first)), + TrieOpts = #{persist_callback => PersistCallback, static_key_size => TopicIndexBytes}, + emqx_ds_lts:trie_restore(TrieOpts, Dump) + after + rocksdb:iterator_close(IT) + end. + +read_persisted_trie(IT, {ok, KeyB, ValB}) -> + [ + {binary_to_term(KeyB), binary_to_term(ValB)} + | read_persisted_trie(IT, rocksdb:iterator_move(IT, next)) + ]; +read_persisted_trie(IT, {error, invalid_iterator}) -> + []. + +%% @doc Generate a column family ID for the MQTT messages +-spec data_cf(emqx_ds_storage_layer:gen_id()) -> [char()]. +data_cf(GenId) -> + "emqx_ds_storage_bitfield_lts_data" ++ integer_to_list(GenId). + +%% @doc Generate a column family ID for the trie +-spec trie_cf(emqx_ds_storage_layer:gen_id()) -> [char()]. +trie_cf(GenId) -> + "emqx_ds_storage_bitfield_lts_trie" ++ integer_to_list(GenId). diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl index 744ac869f..bce976559 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl @@ -32,6 +32,10 @@ %% Type declarations %%================================================================================ +-type prototype() :: + {emqx_ds_storage_reference, emqx_ds_storage_reference:options()} + | {emqx_ds_storage_bitfield_lts, emqx_ds_storage_bitfield_lts:options()}. + -type shard_id() :: emqx_ds_replication_layer:shard_id(). -type cf_refs() :: [{string(), rocksdb:cf_handle()}]. @@ -107,7 +111,7 @@ _Data. -callback store_batch(shard_id(), _Data, [emqx_types:message()], emqx_ds:message_store_opts()) -> - ok. + emqx_ds:store_batch_result(). -callback get_streams(shard_id(), _Data, emqx_ds:topic_filter(), emqx_ds:time()) -> [_Stream]. @@ -122,7 +126,7 @@ %% API for the replication layer %%================================================================================ --spec open_shard(shard_id(), emqx_ds:create_db_opts()) -> ok. +-spec open_shard(shard_id(), emqx_ds:builtin_db_opts()) -> ok. open_shard(Shard, Options) -> emqx_ds_storage_layer_sup:ensure_shard(Shard, Options). @@ -195,7 +199,7 @@ next(Shard, Iter = #it{generation = GenId, enc = GenIter0}, BatchSize) -> -define(REF(ShardId), {via, gproc, {n, l, {?MODULE, ShardId}}}). --spec start_link(shard_id(), emqx_ds:create_db_opts()) -> +-spec start_link(shard_id(), emqx_ds:builtin_db_opts()) -> {ok, pid()}. start_link(Shard, Options) -> gen_server:start_link(?REF(Shard), ?MODULE, {Shard, Options}, []). @@ -224,7 +228,8 @@ init({ShardId, Options}) -> {Schema, CFRefs} = case get_schema_persistent(DB) of not_found -> - create_new_shard_schema(ShardId, DB, CFRefs0, Options); + Prototype = maps:get(storage, Options), + create_new_shard_schema(ShardId, DB, CFRefs0, Prototype); Scm -> {Scm, CFRefs0} end, @@ -300,14 +305,14 @@ open_generation(ShardId, DB, CFRefs, GenId, GenSchema) -> RuntimeData = Mod:open(ShardId, DB, GenId, CFRefs, Schema), GenSchema#{data => RuntimeData}. --spec create_new_shard_schema(shard_id(), rocksdb:db_handle(), cf_refs(), _Options) -> +-spec create_new_shard_schema(shard_id(), rocksdb:db_handle(), cf_refs(), prototype()) -> {shard_schema(), cf_refs()}. -create_new_shard_schema(ShardId, DB, CFRefs, Options) -> - ?tp(notice, ds_create_new_shard_schema, #{shard => ShardId, options => Options}), +create_new_shard_schema(ShardId, DB, CFRefs, Prototype) -> + ?tp(notice, ds_create_new_shard_schema, #{shard => ShardId, prototype => Prototype}), %% TODO: read prototype from options/config Schema0 = #{ current_generation => 0, - prototype => {emqx_ds_storage_reference, #{}} + prototype => Prototype }, {_NewGenId, Schema, NewCFRefs} = new_generation(ShardId, DB, Schema0, _Since = 0), {Schema, NewCFRefs ++ CFRefs}. @@ -331,7 +336,7 @@ commit_metadata(#s{shard_id = ShardId, schema = Schema, shard = Runtime, db = DB ok = put_schema_persistent(DB, Schema), put_schema_runtime(ShardId, Runtime). --spec rocksdb_open(shard_id(), emqx_ds:create_db_opts()) -> +-spec rocksdb_open(shard_id(), emqx_ds:builtin_db_opts()) -> {ok, rocksdb:db_handle(), cf_refs()} | {error, _TODO}. rocksdb_open(Shard, Options) -> DBOptions = [ diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer_sup.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer_sup.erl index bf73e3ac8..fac7204bf 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer_sup.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer_sup.erl @@ -25,7 +25,7 @@ start_link() -> supervisor:start_link({local, ?SUP}, ?MODULE, []). --spec start_shard(emqx_ds:shard(), emqx_ds_storage_layer:options()) -> +-spec start_shard(emqx_ds_replication_layer:shard_id(), emqx_ds:create_db_opts()) -> supervisor:startchild_ret(). start_shard(Shard, Options) -> supervisor:start_child(?SUP, shard_child_spec(Shard, Options)). @@ -63,7 +63,7 @@ init([]) -> %% Internal functions %%================================================================================ --spec shard_child_spec(emqx_ds:shard(), emqx_ds_storage_layer:options()) -> +-spec shard_child_spec(emqx_ds_replication_layer:shard_id(), emqx_ds:create_db_opts()) -> supervisor:child_spec(). shard_child_spec(Shard, Options) -> #{ diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl index 5a91f9ecd..9c7fc3158 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl @@ -32,7 +32,7 @@ %% internal exports: -export([]). --export_type([]). +-export_type([options/0]). -include_lib("emqx/include/emqx.hrl"). @@ -40,6 +40,8 @@ %% Type declarations %%================================================================================ +-type options() :: #{}. + %% Permanent state: -record(schema, {}). @@ -134,4 +136,4 @@ do_next(TopicFilter, StartTime, IT, Action, NLeft, Key0, Acc) -> %% @doc Generate a column family ID for the MQTT messages -spec data_cf(emqx_ds_storage_layer:gen_id()) -> [char()]. data_cf(GenId) -> - ?MODULE_STRING ++ integer_to_list(GenId). + "emqx_ds_storage_reference" ++ integer_to_list(GenId). diff --git a/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl b/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl index 2dc77c563..9637431d3 100644 --- a/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl +++ b/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl @@ -23,19 +23,25 @@ -include_lib("stdlib/include/assert.hrl"). -include_lib("snabbkaffe/include/snabbkaffe.hrl"). +opts() -> + #{ + backend => builtin, + storage => {emqx_ds_storage_reference, #{}} + }. + %% A simple smoke test that verifies that opening/closing the DB %% doesn't crash, and not much else t_00_smoke_open_drop(_Config) -> DB = 'DB', - ?assertMatch(ok, emqx_ds:open_db(DB, #{})), - ?assertMatch(ok, emqx_ds:open_db(DB, #{})), + ?assertMatch(ok, emqx_ds:open_db(DB, opts())), + ?assertMatch(ok, emqx_ds:open_db(DB, opts())), ?assertMatch(ok, emqx_ds:drop_db(DB)). %% A simple smoke test that verifies that storing the messages doesn't %% crash t_01_smoke_store(_Config) -> DB = default, - ?assertMatch(ok, emqx_ds:open_db(DB, #{})), + ?assertMatch(ok, emqx_ds:open_db(DB, opts())), Msg = message(<<"foo/bar">>, <<"foo">>, 0), ?assertMatch(ok, emqx_ds:store_batch(DB, [Msg])). @@ -43,7 +49,7 @@ t_01_smoke_store(_Config) -> %% doesn't crash and that iterators can be opened. t_02_smoke_get_streams_start_iter(_Config) -> DB = ?FUNCTION_NAME, - ?assertMatch(ok, emqx_ds:open_db(DB, #{})), + ?assertMatch(ok, emqx_ds:open_db(DB, opts())), StartTime = 0, TopicFilter = ['#'], [{Rank, Stream}] = emqx_ds:get_streams(DB, TopicFilter, StartTime), @@ -54,7 +60,7 @@ t_02_smoke_get_streams_start_iter(_Config) -> %% over messages. t_03_smoke_iterate(_Config) -> DB = ?FUNCTION_NAME, - ?assertMatch(ok, emqx_ds:open_db(DB, #{})), + ?assertMatch(ok, emqx_ds:open_db(DB, opts())), StartTime = 0, TopicFilter = ['#'], Msgs = [ @@ -75,7 +81,7 @@ t_03_smoke_iterate(_Config) -> %% they are left off. t_04_restart(_Config) -> DB = ?FUNCTION_NAME, - ?assertMatch(ok, emqx_ds:open_db(DB, #{})), + ?assertMatch(ok, emqx_ds:open_db(DB, opts())), TopicFilter = ['#'], StartTime = 0, Msgs = [ @@ -90,7 +96,7 @@ t_04_restart(_Config) -> ?tp(warning, emqx_ds_SUITE_restart_app, #{}), ok = application:stop(emqx_durable_storage), {ok, _} = application:ensure_all_started(emqx_durable_storage), - ok = emqx_ds:open_db(DB, #{}), + ok = emqx_ds:open_db(DB, opts()), %% The old iterator should be still operational: {ok, Iter, Batch} = iterate(Iter0, 1), ?assertEqual(Msgs, Batch, {Iter0, Iter}). diff --git a/apps/emqx_durable_storage/test/emqx_ds_message_storage_bitmask_SUITE.erl_ b/apps/emqx_durable_storage/test/emqx_ds_message_storage_bitmask_SUITE.erl_ deleted file mode 100644 index 599bd6c7b..000000000 --- a/apps/emqx_durable_storage/test/emqx_ds_message_storage_bitmask_SUITE.erl_ +++ /dev/null @@ -1,188 +0,0 @@ -%%-------------------------------------------------------------------- -%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved. -%%-------------------------------------------------------------------- --module(emqx_ds_message_storage_bitmask_SUITE). - --compile(export_all). --compile(nowarn_export_all). - --include_lib("stdlib/include/assert.hrl"). - --import(emqx_ds_message_storage_bitmask, [ - make_keymapper/1, - keymapper_info/1, - compute_topic_bitmask/2, - compute_time_bitmask/1, - compute_topic_seek/4 -]). - -all() -> emqx_common_test_helpers:all(?MODULE). - -t_make_keymapper(_) -> - ?assertMatch( - #{ - source := [ - {timestamp, 9, 23}, - {hash, level, 2}, - {hash, level, 4}, - {hash, levels, 8}, - {timestamp, 0, 9} - ], - bitsize := 46, - epoch := 512 - }, - keymapper_info( - make_keymapper(#{ - timestamp_bits => 32, - topic_bits_per_level => [2, 4, 8], - epoch => 1000 - }) - ) - ). - -t_make_keymapper_single_hash_level(_) -> - ?assertMatch( - #{ - source := [ - {timestamp, 0, 32}, - {hash, levels, 16} - ], - bitsize := 48, - epoch := 1 - }, - keymapper_info( - make_keymapper(#{ - timestamp_bits => 32, - topic_bits_per_level => [16], - epoch => 1 - }) - ) - ). - -t_make_keymapper_no_timestamp(_) -> - ?assertMatch( - #{ - source := [ - {hash, level, 4}, - {hash, level, 8}, - {hash, levels, 16} - ], - bitsize := 28, - epoch := 1 - }, - keymapper_info( - make_keymapper(#{ - timestamp_bits => 0, - topic_bits_per_level => [4, 8, 16], - epoch => 42 - }) - ) - ). - -t_compute_topic_bitmask(_) -> - KM = make_keymapper(#{topic_bits_per_level => [3, 4, 5, 2], timestamp_bits => 0, epoch => 1}), - ?assertEqual( - 2#111_1111_11111_11, - compute_topic_bitmask([<<"foo">>, <<"bar">>], KM) - ), - ?assertEqual( - 2#111_0000_11111_11, - compute_topic_bitmask([<<"foo">>, '+'], KM) - ), - ?assertEqual( - 2#111_0000_00000_11, - compute_topic_bitmask([<<"foo">>, '+', '+'], KM) - ), - ?assertEqual( - 2#111_0000_11111_00, - compute_topic_bitmask([<<"foo">>, '+', <<"bar">>, '+'], KM) - ). - -t_compute_topic_bitmask_wildcard(_) -> - KM = make_keymapper(#{topic_bits_per_level => [3, 4, 5, 2], timestamp_bits => 0, epoch => 1}), - ?assertEqual( - 2#000_0000_00000_00, - compute_topic_bitmask(['#'], KM) - ), - ?assertEqual( - 2#111_0000_00000_00, - compute_topic_bitmask([<<"foo">>, '#'], KM) - ), - ?assertEqual( - 2#111_1111_11111_00, - compute_topic_bitmask([<<"foo">>, <<"bar">>, <<"baz">>, '#'], KM) - ). - -t_compute_topic_bitmask_wildcard_long_tail(_) -> - KM = make_keymapper(#{topic_bits_per_level => [3, 4, 5, 2], timestamp_bits => 0, epoch => 1}), - ?assertEqual( - 2#111_1111_11111_11, - compute_topic_bitmask([<<"foo">>, <<"bar">>, <<"baz">>, <<>>, <<"xyzzy">>], KM) - ), - ?assertEqual( - 2#111_1111_11111_00, - compute_topic_bitmask([<<"foo">>, <<"bar">>, <<"baz">>, <<>>, '#'], KM) - ). - -t_compute_time_bitmask(_) -> - KM = make_keymapper(#{topic_bits_per_level => [1, 2, 3], timestamp_bits => 10, epoch => 200}), - ?assertEqual(2#111_000000_1111111, compute_time_bitmask(KM)). - -t_compute_time_bitmask_epoch_only(_) -> - KM = make_keymapper(#{topic_bits_per_level => [1, 2, 3], timestamp_bits => 10, epoch => 1}), - ?assertEqual(2#1111111111_000000, compute_time_bitmask(KM)). - -%% Filter = |123|***|678|***| -%% Mask = |123|***|678|***| -%% Key1 = |123|011|108|121| → Seek = 0 |123|011|678|000| -%% Key2 = |123|011|679|919| → Seek = 0 |123|012|678|000| -%% Key3 = |123|999|679|001| → Seek = 1 |123|000|678|000| → eos -%% Key4 = |125|011|179|017| → Seek = 1 |123|000|678|000| → eos - -t_compute_next_topic_seek(_) -> - KM = make_keymapper(#{topic_bits_per_level => [8, 8, 16, 12], timestamp_bits => 0, epoch => 1}), - ?assertMatch( - none, - compute_topic_seek( - 16#FD_42_4242_043, - 16#FD_42_4242_042, - 16#FF_FF_FFFF_FFF, - KM - ) - ), - ?assertMatch( - 16#FD_11_0678_000, - compute_topic_seek( - 16#FD_11_0108_121, - 16#FD_00_0678_000, - 16#FF_00_FFFF_000, - KM - ) - ), - ?assertMatch( - 16#FD_12_0678_000, - compute_topic_seek( - 16#FD_11_0679_919, - 16#FD_00_0678_000, - 16#FF_00_FFFF_000, - KM - ) - ), - ?assertMatch( - none, - compute_topic_seek( - 16#FD_FF_0679_001, - 16#FD_00_0678_000, - 16#FF_00_FFFF_000, - KM - ) - ), - ?assertMatch( - none, - compute_topic_seek( - 16#FE_11_0179_017, - 16#FD_00_0678_000, - 16#FF_00_FFFF_000, - KM - ) - ). diff --git a/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl b/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl new file mode 100644 index 000000000..f9a7b02c4 --- /dev/null +++ b/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl @@ -0,0 +1,343 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- +-module(emqx_ds_storage_bitfield_lts_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("common_test/include/ct.hrl"). +-include_lib("stdlib/include/assert.hrl"). + +-define(SHARD, shard(?FUNCTION_NAME)). + +-define(DEFAULT_CONFIG, #{ + backend => builtin, + storage => {emqx_ds_storage_bitfield_lts, #{}} +}). + +-define(COMPACT_CONFIG, #{ + backend => builtin, + storage => + {emqx_ds_storage_bitfield_lts, #{ + bits_per_wildcard_level => 8 + }} +}). + +%% Smoke test for opening and reopening the database +t_open(_Config) -> + ok = emqx_ds_storage_layer_sup:stop_shard(?SHARD), + {ok, _} = emqx_ds_storage_layer_sup:start_shard(?SHARD, #{}). + +%% Smoke test of store function +t_store(_Config) -> + MessageID = emqx_guid:gen(), + PublishedAt = 1000, + Topic = <<"foo/bar">>, + Payload = <<"message">>, + Msg = #message{ + id = MessageID, + topic = Topic, + payload = Payload, + timestamp = PublishedAt + }, + ?assertMatch(ok, emqx_ds_storage_layer:store_batch(?SHARD, [Msg], #{})). + +%% Smoke test for iteration through a concrete topic +t_iterate(_Config) -> + %% Prepare data: + Topics = [<<"foo/bar">>, <<"foo/bar/baz">>, <<"a">>], + Timestamps = lists:seq(1, 10), + Batch = [ + make_message(PublishedAt, Topic, integer_to_binary(PublishedAt)) + || Topic <- Topics, PublishedAt <- Timestamps + ], + ok = emqx_ds_storage_layer:store_batch(?SHARD, Batch, []), + %% Iterate through individual topics: + [ + begin + [{_Rank, Stream}] = emqx_ds_storage_layer:get_streams(?SHARD, parse_topic(Topic), 0), + {ok, It} = emqx_ds_storage_layer:make_iterator(?SHARD, Stream, parse_topic(Topic), 0), + {ok, NextIt, Messages} = emqx_ds_storage_layer:next(?SHARD, It, 100), + ?assertEqual( + lists:map(fun integer_to_binary/1, Timestamps), + payloads(Messages) + ), + {ok, _, []} = emqx_ds_storage_layer:next(?SHARD, NextIt, 100) + end + || Topic <- Topics + ], + ok. + +-define(assertSameSet(A, B), ?assertEqual(lists:sort(A), lists:sort(B))). + +%% Smoke test that verifies that concrete topics become individual +%% streams, unless there's too many of them +t_get_streams(_Config) -> + %% Prepare data: + Topics = [<<"foo/bar">>, <<"foo/bar/baz">>, <<"a">>], + Timestamps = lists:seq(1, 10), + Batch = [ + make_message(PublishedAt, Topic, integer_to_binary(PublishedAt)) + || Topic <- Topics, PublishedAt <- Timestamps + ], + ok = emqx_ds_storage_layer:store_batch(?SHARD, Batch, []), + GetStream = fun(Topic) -> + StartTime = 0, + emqx_ds_storage_layer:get_streams(?SHARD, parse_topic(Topic), StartTime) + end, + %% Get streams for individual topics to use as a reference for later: + [FooBar = {_, _}] = GetStream(<<"foo/bar">>), + [FooBarBaz] = GetStream(<<"foo/bar/baz">>), + [A] = GetStream(<<"a">>), + %% Restart shard to make sure trie is persisted: + ok = emqx_ds_storage_layer_sup:stop_shard(?SHARD), + {ok, _} = emqx_ds_storage_layer_sup:start_shard(?SHARD, #{}), + %% Test various wildcards: + [] = GetStream(<<"bar/foo">>), + ?assertEqual([FooBar], GetStream("+/+")), + ?assertSameSet([FooBar, FooBarBaz], GetStream(<<"foo/#">>)), + ?assertSameSet([FooBar, FooBarBaz, A], GetStream(<<"#">>)), + %% Now insert a bunch of messages with different topics to create wildcards: + NewBatch = [ + begin + B = integer_to_binary(I), + make_message(100, <<"foo/bar/", B/binary>>, <<"filler", B/binary>>) + end + || I <- lists:seq(1, 200) + ], + ok = emqx_ds_storage_layer:store_batch(?SHARD, NewBatch, []), + %% Check that "foo/bar/baz" topic now appears in two streams: + %% "foo/bar/baz" and "foo/bar/+": + NewStreams = lists:sort(GetStream(<<"foo/bar/baz">>)), + ?assertMatch([_, _], NewStreams), + ?assertMatch([_], NewStreams -- [FooBarBaz]), + ok. + +%% Smoke test for iteration with wildcard topic filter +%% t_iterate_wildcard(_Config) -> +%% %% Prepare data: +%% Topics = ["foo/bar", "foo/bar/baz", "a", "a/bar"], +%% Timestamps = lists:seq(1, 10), +%% _ = [ +%% store(?SHARD, PublishedAt, Topic, term_to_binary({Topic, PublishedAt})) +%% || Topic <- Topics, PublishedAt <- Timestamps +%% ], +%% ?assertEqual( +%% lists:sort([{Topic, PublishedAt} || Topic <- Topics, PublishedAt <- Timestamps]), +%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "#", 0)]) +%% ), +%% ?assertEqual( +%% [], +%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "#", 10 + 1)]) +%% ), +%% ?assertEqual( +%% lists:sort([{Topic, PublishedAt} || Topic <- Topics, PublishedAt <- lists:seq(5, 10)]), +%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "#", 5)]) +%% ), +%% ?assertEqual( +%% lists:sort([ +%% {Topic, PublishedAt} +%% || Topic <- ["foo/bar", "foo/bar/baz"], PublishedAt <- Timestamps +%% ]), +%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "foo/#", 0)]) +%% ), +%% ?assertEqual( +%% lists:sort([{"foo/bar", PublishedAt} || PublishedAt <- Timestamps]), +%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "foo/+", 0)]) +%% ), +%% ?assertEqual( +%% [], +%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "foo/+/bar", 0)]) +%% ), +%% ?assertEqual( +%% lists:sort([ +%% {Topic, PublishedAt} +%% || Topic <- ["foo/bar", "foo/bar/baz", "a/bar"], PublishedAt <- Timestamps +%% ]), +%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "+/bar/#", 0)]) +%% ), +%% ?assertEqual( +%% lists:sort([{Topic, PublishedAt} || Topic <- ["a", "a/bar"], PublishedAt <- Timestamps]), +%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "a/#", 0)]) +%% ), +%% ?assertEqual( +%% [], +%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "a/+/+", 0)]) +%% ), +%% ok. + + +%% t_create_gen(_Config) -> +%% {ok, 1} = emqx_ds_storage_layer:create_generation(?SHARD, 5, ?DEFAULT_CONFIG), +%% ?assertEqual( +%% {error, nonmonotonic}, +%% emqx_ds_storage_layer:create_generation(?SHARD, 1, ?DEFAULT_CONFIG) +%% ), +%% ?assertEqual( +%% {error, nonmonotonic}, +%% emqx_ds_storage_layer:create_generation(?SHARD, 5, ?DEFAULT_CONFIG) +%% ), +%% {ok, 2} = emqx_ds_storage_layer:create_generation(?SHARD, 10, ?COMPACT_CONFIG), +%% Topics = ["foo/bar", "foo/bar/baz"], +%% Timestamps = lists:seq(1, 100), +%% [ +%% ?assertMatch({ok, [_]}, store(?SHARD, PublishedAt, Topic, <<>>)) +%% || Topic <- Topics, PublishedAt <- Timestamps +%% ]. + +%% t_iterate_multigen(_Config) -> +%% {ok, 1} = emqx_ds_storage_layer:create_generation(?SHARD, 10, ?COMPACT_CONFIG), +%% {ok, 2} = emqx_ds_storage_layer:create_generation(?SHARD, 50, ?DEFAULT_CONFIG), +%% {ok, 3} = emqx_ds_storage_layer:create_generation(?SHARD, 1000, ?DEFAULT_CONFIG), +%% Topics = ["foo/bar", "foo/bar/baz", "a", "a/bar"], +%% Timestamps = lists:seq(1, 100), +%% _ = [ +%% store(?SHARD, PublishedAt, Topic, term_to_binary({Topic, PublishedAt})) +%% || Topic <- Topics, PublishedAt <- Timestamps +%% ], +%% ?assertEqual( +%% lists:sort([ +%% {Topic, PublishedAt} +%% || Topic <- ["foo/bar", "foo/bar/baz"], PublishedAt <- Timestamps +%% ]), +%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "foo/#", 0)]) +%% ), +%% ?assertEqual( +%% lists:sort([ +%% {Topic, PublishedAt} +%% || Topic <- ["a", "a/bar"], PublishedAt <- lists:seq(60, 100) +%% ]), +%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "a/#", 60)]) +%% ). + +%% t_iterate_multigen_preserve_restore(_Config) -> +%% ReplayID = atom_to_binary(?FUNCTION_NAME), +%% {ok, 1} = emqx_ds_storage_layer:create_generation(?SHARD, 10, ?COMPACT_CONFIG), +%% {ok, 2} = emqx_ds_storage_layer:create_generation(?SHARD, 50, ?DEFAULT_CONFIG), +%% {ok, 3} = emqx_ds_storage_layer:create_generation(?SHARD, 100, ?DEFAULT_CONFIG), +%% Topics = ["foo/bar", "foo/bar/baz", "a/bar"], +%% Timestamps = lists:seq(1, 100), +%% TopicFilter = "foo/#", +%% TopicsMatching = ["foo/bar", "foo/bar/baz"], +%% _ = [ +%% store(?SHARD, TS, Topic, term_to_binary({Topic, TS})) +%% || Topic <- Topics, TS <- Timestamps +%% ], +%% It0 = iterator(?SHARD, TopicFilter, 0), +%% {It1, Res10} = iterate(It0, 10), +%% % preserve mid-generation +%% ok = emqx_ds_storage_layer:preserve_iterator(It1, ReplayID), +%% {ok, It2} = emqx_ds_storage_layer:restore_iterator(?SHARD, ReplayID), +%% {It3, Res100} = iterate(It2, 88), +%% % preserve on the generation boundary +%% ok = emqx_ds_storage_layer:preserve_iterator(It3, ReplayID), +%% {ok, It4} = emqx_ds_storage_layer:restore_iterator(?SHARD, ReplayID), +%% {It5, Res200} = iterate(It4, 1000), +%% ?assertEqual({end_of_stream, []}, iterate(It5, 1)), +%% ?assertEqual( +%% lists:sort([{Topic, TS} || Topic <- TopicsMatching, TS <- Timestamps]), +%% lists:sort([binary_to_term(Payload) || Payload <- Res10 ++ Res100 ++ Res200]) +%% ), +%% ?assertEqual( +%% ok, +%% emqx_ds_storage_layer:discard_iterator(?SHARD, ReplayID) +%% ), +%% ?assertEqual( +%% {error, not_found}, +%% emqx_ds_storage_layer:restore_iterator(?SHARD, ReplayID) +%% ). + +make_message(PublishedAt, Topic, Payload) when is_list(Topic) -> + make_message(PublishedAt, list_to_binary(Topic), Payload); +make_message(PublishedAt, Topic, Payload) when is_binary(Topic) -> + ID = emqx_guid:gen(), + #message{ + id = ID, + topic = Topic, + timestamp = PublishedAt, + payload = Payload + }. + +store(Shard, PublishedAt, TopicL, Payload) when is_list(TopicL) -> + store(Shard, PublishedAt, list_to_binary(TopicL), Payload); +store(Shard, PublishedAt, Topic, Payload) -> + ID = emqx_guid:gen(), + Msg = #message{ + id = ID, + topic = Topic, + timestamp = PublishedAt, + payload = Payload + }, + emqx_ds_storage_layer:message_store(Shard, [Msg], #{}). + +%% iterate(Shard, TopicFilter, StartTime) -> +%% Streams = emqx_ds_storage_layer:get_streams(Shard, TopicFilter, StartTime), +%% lists:flatmap( +%% fun(Stream) -> +%% iterate(Shard, iterator(Shard, Stream, TopicFilter, StartTime)) +%% end, +%% Streams). + +%% iterate(Shard, It) -> +%% case emqx_ds_storage_layer:next(Shard, It) of +%% {ok, ItNext, [#message{payload = Payload}]} -> +%% [Payload | iterate(Shard, ItNext)]; +%% end_of_stream -> +%% [] +%% end. + +%% iterate(_Shard, end_of_stream, _N) -> +%% {end_of_stream, []}; +%% iterate(Shard, It, N) -> +%% case emqx_ds_storage_layer:next(Shard, It, N) of +%% {ok, ItFinal, Messages} -> +%% {ItFinal, [Payload || #message{payload = Payload} <- Messages]}; +%% end_of_stream -> +%% {end_of_stream, []} +%% end. + +%% iterator(Shard, Stream, TopicFilter, StartTime) -> +%% {ok, It} = emqx_ds_storage_layer:make_iterator(Shard, Stream, parse_topic(TopicFilter), StartTime), +%% It. + +payloads(Messages) -> + lists:map( + fun(#message{payload = P}) -> + P + end, + Messages + ). + +parse_topic(Topic = [L | _]) when is_binary(L); is_atom(L) -> + Topic; +parse_topic(Topic) -> + emqx_topic:words(iolist_to_binary(Topic)). + +%% CT callbacks + +all() -> emqx_common_test_helpers:all(?MODULE). + +init_per_suite(Config) -> + {ok, _} = application:ensure_all_started(emqx_durable_storage), + Config. + +end_per_suite(_Config) -> + ok = application:stop(emqx_durable_storage). + +init_per_testcase(TC, Config) -> + {ok, _} = emqx_ds_storage_layer_sup:start_shard(shard(TC), ?DEFAULT_CONFIG), + Config. + +end_per_testcase(TC, _Config) -> + ok = emqx_ds_storage_layer_sup:stop_shard(shard(TC)). + +shard(TC) -> + {?MODULE, TC}. + +keyspace(TC) -> + TC. + +set_keyspace_config(Keyspace, Config) -> + ok = application:set_env(emqx_ds, keyspace_config, #{Keyspace => Config}). diff --git a/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl_ b/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl_ deleted file mode 100644 index 25198cfd7..000000000 --- a/apps/emqx_durable_storage/test/emqx_ds_storage_layer_SUITE.erl_ +++ /dev/null @@ -1,292 +0,0 @@ -%%-------------------------------------------------------------------- -%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved. -%%-------------------------------------------------------------------- --module(emqx_ds_storage_layer_SUITE). - --compile(export_all). --compile(nowarn_export_all). - --include_lib("emqx/include/emqx.hrl"). --include_lib("common_test/include/ct.hrl"). --include_lib("stdlib/include/assert.hrl"). - --define(SHARD, shard(?FUNCTION_NAME)). - --define(DEFAULT_CONFIG, - {emqx_ds_message_storage_bitmask, #{ - timestamp_bits => 64, - topic_bits_per_level => [8, 8, 32, 16], - epoch => 5, - iteration => #{ - iterator_refresh => {every, 5} - } - }} -). - --define(COMPACT_CONFIG, - {emqx_ds_message_storage_bitmask, #{ - timestamp_bits => 16, - topic_bits_per_level => [16, 16], - epoch => 10 - }} -). - -%% Smoke test for opening and reopening the database -t_open(_Config) -> - ok = emqx_ds_storage_layer_sup:stop_shard(?SHARD), - {ok, _} = emqx_ds_storage_layer_sup:start_shard(?SHARD, #{}). - -%% Smoke test of store function -t_store(_Config) -> - MessageID = emqx_guid:gen(), - PublishedAt = 1000, - Topic = <<"foo/bar">>, - Payload = <<"message">>, - Msg = #message{ - id = MessageID, - topic = Topic, - payload = Payload, - timestamp = PublishedAt - }, - ?assertMatch({ok, [_]}, emqx_ds_storage_layer:message_store(?SHARD, [Msg], #{})). - -%% Smoke test for iteration through a concrete topic -t_iterate(_Config) -> - %% Prepare data: - Topics = [<<"foo/bar">>, <<"foo/bar/baz">>, <<"a">>], - Timestamps = lists:seq(1, 10), - [ - store( - ?SHARD, - PublishedAt, - Topic, - integer_to_binary(PublishedAt) - ) - || Topic <- Topics, PublishedAt <- Timestamps - ], - %% Iterate through individual topics: - [ - begin - {ok, It} = emqx_ds_storage_layer:make_iterator(?SHARD, {parse_topic(Topic), 0}), - Values = iterate(It), - ?assertEqual(lists:map(fun integer_to_binary/1, Timestamps), Values) - end - || Topic <- Topics - ], - ok. - -%% Smoke test for iteration with wildcard topic filter -t_iterate_wildcard(_Config) -> - %% Prepare data: - Topics = ["foo/bar", "foo/bar/baz", "a", "a/bar"], - Timestamps = lists:seq(1, 10), - _ = [ - store(?SHARD, PublishedAt, Topic, term_to_binary({Topic, PublishedAt})) - || Topic <- Topics, PublishedAt <- Timestamps - ], - ?assertEqual( - lists:sort([{Topic, PublishedAt} || Topic <- Topics, PublishedAt <- Timestamps]), - lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "#", 0)]) - ), - ?assertEqual( - [], - lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "#", 10 + 1)]) - ), - ?assertEqual( - lists:sort([{Topic, PublishedAt} || Topic <- Topics, PublishedAt <- lists:seq(5, 10)]), - lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "#", 5)]) - ), - ?assertEqual( - lists:sort([ - {Topic, PublishedAt} - || Topic <- ["foo/bar", "foo/bar/baz"], PublishedAt <- Timestamps - ]), - lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "foo/#", 0)]) - ), - ?assertEqual( - lists:sort([{"foo/bar", PublishedAt} || PublishedAt <- Timestamps]), - lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "foo/+", 0)]) - ), - ?assertEqual( - [], - lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "foo/+/bar", 0)]) - ), - ?assertEqual( - lists:sort([ - {Topic, PublishedAt} - || Topic <- ["foo/bar", "foo/bar/baz", "a/bar"], PublishedAt <- Timestamps - ]), - lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "+/bar/#", 0)]) - ), - ?assertEqual( - lists:sort([{Topic, PublishedAt} || Topic <- ["a", "a/bar"], PublishedAt <- Timestamps]), - lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "a/#", 0)]) - ), - ?assertEqual( - [], - lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "a/+/+", 0)]) - ), - ok. - -t_iterate_long_tail_wildcard(_Config) -> - Topic = "b/c/d/e/f/g", - TopicFilter = "b/c/d/e/+/+", - Timestamps = lists:seq(1, 100), - _ = [ - store(?SHARD, PublishedAt, Topic, term_to_binary({Topic, PublishedAt})) - || PublishedAt <- Timestamps - ], - ?assertEqual( - lists:sort([{"b/c/d/e/f/g", PublishedAt} || PublishedAt <- lists:seq(50, 100)]), - lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, TopicFilter, 50)]) - ). - -t_create_gen(_Config) -> - {ok, 1} = emqx_ds_storage_layer:create_generation(?SHARD, 5, ?DEFAULT_CONFIG), - ?assertEqual( - {error, nonmonotonic}, - emqx_ds_storage_layer:create_generation(?SHARD, 1, ?DEFAULT_CONFIG) - ), - ?assertEqual( - {error, nonmonotonic}, - emqx_ds_storage_layer:create_generation(?SHARD, 5, ?DEFAULT_CONFIG) - ), - {ok, 2} = emqx_ds_storage_layer:create_generation(?SHARD, 10, ?COMPACT_CONFIG), - Topics = ["foo/bar", "foo/bar/baz"], - Timestamps = lists:seq(1, 100), - [ - ?assertMatch({ok, [_]}, store(?SHARD, PublishedAt, Topic, <<>>)) - || Topic <- Topics, PublishedAt <- Timestamps - ]. - -t_iterate_multigen(_Config) -> - {ok, 1} = emqx_ds_storage_layer:create_generation(?SHARD, 10, ?COMPACT_CONFIG), - {ok, 2} = emqx_ds_storage_layer:create_generation(?SHARD, 50, ?DEFAULT_CONFIG), - {ok, 3} = emqx_ds_storage_layer:create_generation(?SHARD, 1000, ?DEFAULT_CONFIG), - Topics = ["foo/bar", "foo/bar/baz", "a", "a/bar"], - Timestamps = lists:seq(1, 100), - _ = [ - store(?SHARD, PublishedAt, Topic, term_to_binary({Topic, PublishedAt})) - || Topic <- Topics, PublishedAt <- Timestamps - ], - ?assertEqual( - lists:sort([ - {Topic, PublishedAt} - || Topic <- ["foo/bar", "foo/bar/baz"], PublishedAt <- Timestamps - ]), - lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "foo/#", 0)]) - ), - ?assertEqual( - lists:sort([ - {Topic, PublishedAt} - || Topic <- ["a", "a/bar"], PublishedAt <- lists:seq(60, 100) - ]), - lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "a/#", 60)]) - ). - -t_iterate_multigen_preserve_restore(_Config) -> - ReplayID = atom_to_binary(?FUNCTION_NAME), - {ok, 1} = emqx_ds_storage_layer:create_generation(?SHARD, 10, ?COMPACT_CONFIG), - {ok, 2} = emqx_ds_storage_layer:create_generation(?SHARD, 50, ?DEFAULT_CONFIG), - {ok, 3} = emqx_ds_storage_layer:create_generation(?SHARD, 100, ?DEFAULT_CONFIG), - Topics = ["foo/bar", "foo/bar/baz", "a/bar"], - Timestamps = lists:seq(1, 100), - TopicFilter = "foo/#", - TopicsMatching = ["foo/bar", "foo/bar/baz"], - _ = [ - store(?SHARD, TS, Topic, term_to_binary({Topic, TS})) - || Topic <- Topics, TS <- Timestamps - ], - It0 = iterator(?SHARD, TopicFilter, 0), - {It1, Res10} = iterate(It0, 10), - % preserve mid-generation - ok = emqx_ds_storage_layer:preserve_iterator(It1, ReplayID), - {ok, It2} = emqx_ds_storage_layer:restore_iterator(?SHARD, ReplayID), - {It3, Res100} = iterate(It2, 88), - % preserve on the generation boundary - ok = emqx_ds_storage_layer:preserve_iterator(It3, ReplayID), - {ok, It4} = emqx_ds_storage_layer:restore_iterator(?SHARD, ReplayID), - {It5, Res200} = iterate(It4, 1000), - ?assertEqual({end_of_stream, []}, iterate(It5, 1)), - ?assertEqual( - lists:sort([{Topic, TS} || Topic <- TopicsMatching, TS <- Timestamps]), - lists:sort([binary_to_term(Payload) || Payload <- Res10 ++ Res100 ++ Res200]) - ), - ?assertEqual( - ok, - emqx_ds_storage_layer:discard_iterator(?SHARD, ReplayID) - ), - ?assertEqual( - {error, not_found}, - emqx_ds_storage_layer:restore_iterator(?SHARD, ReplayID) - ). - -store(Shard, PublishedAt, TopicL, Payload) when is_list(TopicL) -> - store(Shard, PublishedAt, list_to_binary(TopicL), Payload); -store(Shard, PublishedAt, Topic, Payload) -> - ID = emqx_guid:gen(), - Msg = #message{ - id = ID, - topic = Topic, - timestamp = PublishedAt, - payload = Payload - }, - emqx_ds_storage_layer:message_store(Shard, [Msg], #{}). - -iterate(DB, TopicFilter, StartTime) -> - iterate(iterator(DB, TopicFilter, StartTime)). - -iterate(It) -> - case emqx_ds_storage_layer:next(It) of - {ok, ItNext, [#message{payload = Payload}]} -> - [Payload | iterate(ItNext)]; - end_of_stream -> - [] - end. - -iterate(end_of_stream, _N) -> - {end_of_stream, []}; -iterate(It, N) -> - case emqx_ds_storage_layer:next(It, N) of - {ok, ItFinal, Messages} -> - {ItFinal, [Payload || #message{payload = Payload} <- Messages]}; - end_of_stream -> - {end_of_stream, []} - end. - -iterator(DB, TopicFilter, StartTime) -> - {ok, It} = emqx_ds_storage_layer:make_iterator(DB, {parse_topic(TopicFilter), StartTime}), - It. - -parse_topic(Topic = [L | _]) when is_binary(L); is_atom(L) -> - Topic; -parse_topic(Topic) -> - emqx_topic:words(iolist_to_binary(Topic)). - -%% CT callbacks - -all() -> emqx_common_test_helpers:all(?MODULE). - -init_per_suite(Config) -> - {ok, _} = application:ensure_all_started(emqx_durable_storage), - Config. - -end_per_suite(_Config) -> - ok = application:stop(emqx_durable_storage). - -init_per_testcase(TC, Config) -> - ok = set_keyspace_config(keyspace(TC), ?DEFAULT_CONFIG), - {ok, _} = emqx_ds_storage_layer_sup:start_shard(shard(TC), #{}), - Config. - -end_per_testcase(TC, _Config) -> - ok = emqx_ds_storage_layer_sup:stop_shard(shard(TC)). - -shard(TC) -> - iolist_to_binary([?MODULE_STRING, "_", atom_to_list(TC)]). - -keyspace(TC) -> - TC. - -set_keyspace_config(Keyspace, Config) -> - ok = application:set_env(emqx_ds, keyspace_config, #{Keyspace => Config}). From 56b6b176c2c12a986cb007b91017bf2edba81e4e Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Fri, 13 Oct 2023 19:50:18 +0200 Subject: [PATCH 15/31] fix(ds): LTS shall keeps the concrete topic indexes --- apps/emqx_durable_storage/src/emqx_ds_lts.erl | 62 +++++++++++-------- .../emqx_ds_storage_bitfield_lts_SUITE.erl | 1 - 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/apps/emqx_durable_storage/src/emqx_ds_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_lts.erl index a6e67c069..c9a73e3e0 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_lts.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_lts.erl @@ -159,7 +159,7 @@ dump_to_dot(#trie{trie = Trie, stats = Stats}, Filename) -> {ok, FD} = file:open(Filename, [write]), Print = fun (?PREFIX) -> "prefix"; - (NodeId) -> binary:encode_hex(NodeId) + (NodeId) -> integer_to_binary(NodeId, 16) end, io:format(FD, "digraph {~n", []), lists:foreach( @@ -190,12 +190,12 @@ trie_next(#trie{trie = Trie}, State, ?EOT) -> [] -> undefined end; trie_next(#trie{trie = Trie}, State, Token) -> - case ets:lookup(Trie, {State, ?PLUS}) of + case ets:lookup(Trie, {State, Token}) of [#trans{next = Next}] -> - {true, Next}; + {false, Next}; [] -> - case ets:lookup(Trie, {State, Token}) of - [#trans{next = Next}] -> {false, Next}; + case ets:lookup(Trie, {State, ?PLUS}) of + [#trans{next = Next}] -> {true, Next}; [] -> undefined end end. @@ -317,11 +317,11 @@ do_topic_key(Trie, ThresholdFun, Depth, State, [Tok | Rest], Varying0) -> Threshold = ThresholdFun(Depth), Varying = case trie_next_(Trie, State, Tok) of - {NChildren, _, _DiscardState} when is_integer(NChildren), NChildren > Threshold -> + {NChildren, _, NextState} when is_integer(NChildren), NChildren >= Threshold -> %% Number of children for the trie node reached the - %% threshold, we need to insert wildcard here: - {_, NextState} = trie_insert(Trie, State, ?PLUS), - [Tok | Varying0]; + %% threshold, we need to insert wildcard here. + {_, _WildcardState} = trie_insert(Trie, State, ?PLUS), + Varying0; {_, false, NextState} -> Varying0; {_, true, NextState} -> @@ -331,6 +331,7 @@ do_topic_key(Trie, ThresholdFun, Depth, State, [Tok | Rest], Varying0) -> end, do_topic_key(Trie, ThresholdFun, Depth + 1, NextState, Rest, Varying). +%% @doc Has side effects! Inserts missing elements -spec trie_next_(trie(), state(), binary() | ?EOT) -> {New, Wildcard, state()} when New :: false | non_neg_integer(), Wildcard :: boolean(). @@ -471,29 +472,36 @@ wildcard_lookup_test() -> topic_key_test() -> T = trie_create(), try - Threshold = 3, + Threshold = 4, ThresholdFun = fun(0) -> 1000; (_) -> Threshold end, %% Test that bottom layer threshold is high: lists:foreach( fun(I) -> - {_, []} = test_key(T, ThresholdFun, [I, 99, 99, 99]) + {_, []} = test_key(T, ThresholdFun, [I, 99999, 999999, 99999]) end, lists:seq(1, 10)), %% Test adding children on the 2nd level: lists:foreach( fun(I) -> case test_key(T, ThresholdFun, [1, I, 1]) of - {_, []} when I < Threshold -> + {_, []} -> + ?assert(I < Threshold, {I, '<', Threshold}), ok; {_, [Var]} -> + ?assert(I >= Threshold, {I, '>=', Threshold}), ?assertEqual(Var, integer_to_binary(I)) end end, lists:seq(1, 100)), %% This doesn't affect 2nd level with a different prefix: - {_, []} = test_key(T, ThresholdFun, [2, 1, 1]), + ?assertMatch({_, []}, test_key(T, ThresholdFun, [2, 1, 1])), + ?assertMatch({_, []}, test_key(T, ThresholdFun, [2, 10, 1])), + %% This didn't retroactively change the indexes that were + %% created prior to reaching the threshold: + ?assertMatch({_, []}, test_key(T, ThresholdFun, [1, 1, 1])), + ?assertMatch({_, []}, test_key(T, ThresholdFun, [1, 2, 1])), %% Now create another level of +: lists:foreach( fun(I) -> @@ -531,28 +539,29 @@ topic_match_test() -> assert_match_topics(T, [1, '+'], [{S11, []}, {S12, []}]), assert_match_topics(T, [1, '+', 1], [{S111, []}]), %% Match topics with #: - assert_match_topics(T, [1, '#'], [{S1, []}, {S11, []}, {S12, []}, {S111, []}]), - assert_match_topics(T, [1, 1, '#'], [{S11, []}, {S111, []}]), + assert_match_topics(T, [1, '#'], + [{S1, []}, + {S11, []}, {S12, []}, + {S111, []}]), + assert_match_topics(T, [1, 1, '#'], + [{S11, []}, + {S111, []}]), %% Now add learned wildcards: {S21, []} = test_key(T, ThresholdFun, [2, 1]), {S22, []} = test_key(T, ThresholdFun, [2, 2]), {S2_, [<<"3">>]} = test_key(T, ThresholdFun, [2, 3]), - {S2_11, [_]} = test_key(T, ThresholdFun, [2, 1, 1, 1]), - {S2_12, [_]} = test_key(T, ThresholdFun, [2, 1, 1, 2]), - {S2_1_, [_, _]} = test_key(T, ThresholdFun, [2, 1, 1, 3]), - %% Check matching: + {S2_11, [<<"3">>]} = test_key(T, ThresholdFun, [2, 3, 1, 1]), + {S2_12, [<<"4">>]} = test_key(T, ThresholdFun, [2, 4, 1, 2]), + {S2_1_, [<<"3">>, <<"3">>]} = test_key(T, ThresholdFun, [2, 3, 1, 3]), + %% %% Check matching: assert_match_topics(T, [2, 2], [{S22, []}, {S2_, [<<"2">>]}]), assert_match_topics(T, [2, '+'], [{S22, []}, {S21, []}, {S2_, ['+']}]), - assert_match_topics(T, [2, 1, 1, 2], - [{S2_12, [<<"1">>]}, - {S2_1_, [<<"1">>, <<"2">>]}]), assert_match_topics(T, [2, '#'], [{S21, []}, {S22, []}, {S2_, ['+']}, - {S2_11, ['+']}, {S2_12, ['+']}, - {S2_1_, ['+', '+']}]), + {S2_11, ['+']}, {S2_12, ['+']}, {S2_1_, ['+', '+']}]), ok after dump_to_dot(T, filename:join("_build", atom_to_list(?FUNCTION_NAME) ++ ".dot")) @@ -578,7 +587,10 @@ assert_match_topics(Trie, Filter0, Expected) -> test_key(Trie, Threshold, Topic0) -> Topic = [integer_to_binary(I) || I <- Topic0], Ret = topic_key(Trie, Threshold, Topic), - Ret = topic_key(Trie, Threshold, Topic), %% Test idempotency + %% Test idempotency: + Ret1 = topic_key(Trie, Threshold, Topic), + ?assertEqual(Ret, Ret1, Topic), + %% Add new key to the history: case get(?keys_history) of undefined -> OldHistory = #{}; OldHistory -> ok diff --git a/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl b/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl index f9a7b02c4..22a608a7f 100644 --- a/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl +++ b/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl @@ -168,7 +168,6 @@ t_get_streams(_Config) -> %% ), %% ok. - %% t_create_gen(_Config) -> %% {ok, 1} = emqx_ds_storage_layer:create_generation(?SHARD, 5, ?DEFAULT_CONFIG), %% ?assertEqual( From 164ae9e94a814f788c3c1145e01f45067b77acf9 Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Sat, 14 Oct 2023 00:32:36 +0200 Subject: [PATCH 16/31] feat(ds): LTS bitfield storage passes all tests --- .../src/emqx_ds_bitmask_keymapper.erl | 28 +++- .../src/emqx_ds_storage_bitfield_lts.erl | 136 +++++++++++----- .../emqx_ds_storage_bitfield_lts_SUITE.erl | 146 +++++++++++++++++- 3 files changed, 260 insertions(+), 50 deletions(-) diff --git a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl index 4b6fcbcdf..5c3ae42d8 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl @@ -88,7 +88,8 @@ bin_key_to_vector/2, next_range/3, key_to_bitstring/2, - bitstring_to_key/2 + bitstring_to_key/2, + bitsize/1 ]). -export_type([vector/0, key/0, dimension/0, offset/0, bitsize/0, bitsource/0, keymapper/0]). @@ -146,7 +147,7 @@ -opaque keymapper() :: #keymapper{}. --type scalar_range() :: any | {'=', scalar()} | {'>=', scalar()}. +-type scalar_range() :: any | {'=', scalar() | infinity} | {'>=', scalar()}. %%================================================================================ %% API functions @@ -179,6 +180,10 @@ make_keymapper(Bitsources) -> dim_sizeof = DimSizeof }. +-spec bitsize(keymapper()) -> pos_integer(). +bitsize(#keymapper{size = Size}) -> + Size. + %% @doc Map N-dimensional vector to a scalar key. %% %% Note: this function is not injective. @@ -264,8 +269,12 @@ next_range(Keymapper, Filter0, PrevKey) -> -spec bitstring_to_key(keymapper(), bitstring()) -> key(). bitstring_to_key(#keymapper{size = Size}, Bin) -> - <> = Bin, - Key. + case Bin of + <> -> + Key; + _ -> + error({invalid_key, Bin, Size}) + end. -spec key_to_bitstring(keymapper(), key()) -> bitstring(). key_to_bitstring(#keymapper{size = Size}, Key) -> @@ -329,6 +338,9 @@ desugar_filter(#keymapper{dim_sizeof = DimSizeof}, Filter) -> fun ({any, Bitsize}) -> {0, ones(Bitsize)}; + ({{'=', infinity}, Bitsize}) -> + Val = ones(Bitsize), + {Val, Val}; ({{'=', Val}, _Bitsize}) -> {Val, Val}; ({{'>=', Val}, Bitsize}) -> @@ -470,6 +482,14 @@ vector_to_key4_test() -> Schema = [{1, 0, 8}, {2, 0, 8}, {1, 8, 8}, {2, 16, 8}], ?assertEqual(16#bb112211, vec2key(Schema, [16#aa1111, 16#bb2222])). +%% Test with binaries: +vector_to_key_bin_test() -> + Schema = [{1, 0, 8 * 4}, {2, 0, 8 * 5}, {3, 0, 8 * 5}], + Keymapper = make_keymapper(lists:reverse(Schema)), + ?assertMatch( + <<"wellhelloworld">>, bin_vector_to_key(Keymapper, [<<"well">>, <<"hello">>, <<"world">>]) + ). + key_to_vector0_test() -> Schema = [], key2vec(Schema, []). diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl index e8bfdaa2e..7b8fbab0d 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl @@ -35,6 +35,7 @@ -export_type([options/0]). -include_lib("emqx/include/emqx.hrl"). +-include_lib("snabbkaffe/include/trace.hrl"). %%================================================================================ %% Type declarations @@ -52,7 +53,7 @@ #{ bits_per_wildcard_level := pos_integer(), topic_index_bytes := pos_integer(), - epoch_bits := non_neg_integer(), + ts_bits := non_neg_integer(), ts_offset_bits := non_neg_integer() }. @@ -80,6 +81,8 @@ ((KEY band BITMASK) =:= BITFILTER) ). +-define(COUNTER, emqx_ds_storage_bitfield_lts_counter). + %%================================================================================ %% API funcions %%================================================================================ @@ -92,20 +95,17 @@ create(_ShardId, DBHandle, GenId, Options) -> %% Get options: BitsPerTopicLevel = maps:get(bits_per_wildcard_level, Options, 64), TopicIndexBytes = maps:get(topic_index_bytes, Options, 4), - TSOffsetBits = maps:get(epoch_bits, Options, 5), + TSOffsetBits = maps:get(epoch_bits, Options, 8), %% TODO: change to 10 to make it around ~1 sec %% Create column families: DataCFName = data_cf(GenId), TrieCFName = trie_cf(GenId), {ok, DataCFHandle} = rocksdb:create_column_family(DBHandle, DataCFName, []), {ok, TrieCFHandle} = rocksdb:create_column_family(DBHandle, TrieCFName, []), %% Create schema: - - % Fixed size_of MQTT message timestamp - SizeOfTS = 64, Schema = #{ bits_per_wildcard_level => BitsPerTopicLevel, topic_index_bytes => TopicIndexBytes, - epoch_bits => SizeOfTS - TSOffsetBits, + ts_bits => 64, ts_offset_bits => TSOffsetBits }, {Schema, [{DataCFName, DataCFHandle}, {TrieCFName, TrieCFHandle}]}. @@ -114,19 +114,19 @@ open(_Shard, DBHandle, GenId, CFRefs, Schema) -> #{ bits_per_wildcard_level := BitsPerTopicLevel, topic_index_bytes := TopicIndexBytes, - epoch_bits := EpochBits, + ts_bits := TSBits, ts_offset_bits := TSOffsetBits } = Schema, {_, DataCF} = lists:keyfind(data_cf(GenId), 1, CFRefs), {_, TrieCF} = lists:keyfind(trie_cf(GenId), 1, CFRefs), Trie = restore_trie(TopicIndexBytes, DBHandle, TrieCF), - %% If user's topics have more than learned 10 wildcard levels then - %% total carnage is going on; learned topic structure doesn't - %% really apply: + %% If user's topics have more than learned 10 wildcard levels, + %% then it's total carnage; learned topic structure won't help + %% much: MaxWildcardLevels = 10, Keymappers = array:from_list( [ - make_keymapper(TopicIndexBytes, EpochBits, BitsPerTopicLevel, TSOffsetBits, N) + make_keymapper(TopicIndexBytes, BitsPerTopicLevel, TSBits, TSOffsetBits, N) || N <- lists:seq(0, MaxWildcardLevels) ] ), @@ -180,11 +180,18 @@ next(_Shard, #s{db = DB, data = CF, keymappers = Keymappers}, It0, BatchSize) -> % TODO: ugh, so ugly NVarying = length(KeyFilter) - 2, Keymapper = array:get(NVarying, Keymappers), - {ok, ITHandle} = rocksdb:iterator(DB, CF, []), + %% Calculate lower and upper bounds for iteration: + LowerBound = lower_bound(Keymapper, KeyFilter), + UpperBound = upper_bound(Keymapper, KeyFilter), + {ok, ITHandle} = rocksdb:iterator(DB, CF, [ + {iterate_lower_bound, LowerBound}, {iterate_upper_bound, UpperBound} + ]), try + put(?COUNTER, 0), next_loop(ITHandle, Keymapper, It0, [], BatchSize) after - rocksdb:iterator_close(ITHandle) + rocksdb:iterator_close(ITHandle), + erase(?COUNTER) end. %%================================================================================ @@ -193,35 +200,42 @@ next(_Shard, #s{db = DB, data = CF, keymappers = Keymappers}, It0, BatchSize) -> next_loop(_, _, It, Acc, 0) -> {ok, It, lists:reverse(Acc)}; -next_loop(ITHandle, KeyMapper, It0, Acc0, N0) -> - {Key1, Bitmask, Bitfilter} = next_range(KeyMapper, It0), - case iterator_move(KeyMapper, ITHandle, {seek, Key1}) of - {ok, Key, Val} when ?QUICKCHECK_KEY(Key, Bitmask, Bitfilter) -> - Msg = deserialize(Val), - It1 = It0#it{last_seen_key = Key}, - case check_message(It1, Msg) of - true -> - N1 = N0 - 1, - Acc1 = [Msg | Acc0]; - false -> - N1 = N0, - Acc1 = Acc0 - end, - {N, It, Acc} = traverse_interval( - ITHandle, KeyMapper, Bitmask, Bitfilter, It1, Acc1, N1 - ), - next_loop(ITHandle, KeyMapper, It, Acc, N); - {ok, Key, _Val} -> - It = It0#it{last_seen_key = Key}, - next_loop(ITHandle, KeyMapper, It, Acc0, N0); - {error, invalid_iterator} -> +next_loop(ITHandle, KeyMapper, It0 = #it{last_seen_key = Key0, key_filter = KeyFilter}, Acc0, N0) -> + inc_counter(), + case next_range(KeyMapper, It0) of + {Key1, Bitmask, Bitfilter} when Key1 > Key0 -> + case iterator_move(KeyMapper, ITHandle, {seek, Key1}) of + {ok, Key, Val} when ?QUICKCHECK_KEY(Key, Bitmask, Bitfilter) -> + assert_progress(bitmask_match, KeyMapper, KeyFilter, Key0, Key1), + Msg = deserialize(Val), + It1 = It0#it{last_seen_key = Key}, + case check_message(It1, Msg) of + true -> + N1 = N0 - 1, + Acc1 = [Msg | Acc0]; + false -> + N1 = N0, + Acc1 = Acc0 + end, + {N, It, Acc} = traverse_interval( + ITHandle, KeyMapper, Bitmask, Bitfilter, It1, Acc1, N1 + ), + next_loop(ITHandle, KeyMapper, It, Acc, N); + {ok, Key, _Val} -> + assert_progress(bitmask_miss, KeyMapper, KeyFilter, Key0, Key1), + It = It0#it{last_seen_key = Key}, + next_loop(ITHandle, KeyMapper, It, Acc0, N0); + {error, invalid_iterator} -> + {ok, It0, lists:reverse(Acc0)} + end; + _ -> {ok, It0, lists:reverse(Acc0)} end. traverse_interval(_, _, _, _, It, Acc, 0) -> {0, It, Acc}; traverse_interval(ITHandle, KeyMapper, Bitmask, Bitfilter, It0, Acc, N) -> - %% TODO: supply the upper limit to rocksdb to the last extra seek: + inc_counter(), case iterator_move(KeyMapper, ITHandle, next) of {ok, Key, Val} when ?QUICKCHECK_KEY(Key, Bitmask, Bitfilter) -> Msg = deserialize(Val), @@ -265,6 +279,28 @@ iterator_move(KeyMapper, ITHandle, Action0) -> Other end. +assert_progress(_Msg, _KeyMapper, _KeyFilter, Key0, Key1) when Key1 > Key0 -> + ?tp_ignore_side_effects_in_prod( + emqx_ds_storage_bitfield_lts_iter_move, + #{ location => _Msg + , key0 => format_key(_KeyMapper, Key0) + , key1 => format_key(_KeyMapper, Key1) + }), + ok; +assert_progress(Msg, KeyMapper, KeyFilter, Key0, Key1) -> + Str0 = format_key(KeyMapper, Key0), + Str1 = format_key(KeyMapper, Key1), + error(#{'$msg' => Msg, key0 => Str0, key1 => Str1, step => get(?COUNTER), keyfilter => lists:map(fun format_keyfilter/1, KeyFilter)}). + +format_key(KeyMapper, Key) -> + Vec = [integer_to_list(I, 16) || I <- emqx_ds_bitmask_keymapper:key_to_vector(KeyMapper, Key)], + lists:flatten(io_lib:format("~.16B (~s)", [Key, string:join(Vec, ",")])). + +format_keyfilter(any) -> + any; +format_keyfilter({Op, Val}) -> + {Op, integer_to_list(Val, 16)}. + -spec make_key(#s{}, #message{}) -> {binary(), [binary()]}. make_key(#s{keymappers = KeyMappers, trie = Trie}, #message{timestamp = Timestamp, topic = TopicBin}) -> Tokens = emqx_topic:tokens(TopicBin), @@ -303,15 +339,33 @@ deserialize(Blob) -> -define(BYTE_SIZE, 8). %% erlfmt-ignore -make_keymapper(TopicIndexBytes, EpochBits, BitsPerTopicLevel, TSOffsetBits, N) -> +make_keymapper(TopicIndexBytes, BitsPerTopicLevel, TSBits, TSOffsetBits, N) -> Bitsources = %% Dimension Offset Bitsize [{1, 0, TopicIndexBytes * ?BYTE_SIZE}, %% Topic index - {2, TSOffsetBits, EpochBits }] ++ %% Timestamp epoch + {2, TSOffsetBits, TSBits - TSOffsetBits }] ++ %% Timestamp epoch [{2 + I, 0, BitsPerTopicLevel } %% Varying topic levels || I <- lists:seq(1, N)] ++ [{2, 0, TSOffsetBits }], %% Timestamp offset - emqx_ds_bitmask_keymapper:make_keymapper(Bitsources). + Keymapper = emqx_ds_bitmask_keymapper:make_keymapper(lists:reverse(Bitsources)), + %% Assert: + case emqx_ds_bitmask_keymapper:bitsize(Keymapper) rem 8 of + 0 -> + ok; + _ -> + error(#{'$msg' => "Non-even key size", bitsources => Bitsources}) + end, + Keymapper. + +upper_bound(Keymapper, [TopicIndex | Rest]) -> + filter_to_key(Keymapper, [TopicIndex | [{'=', infinity} || _ <- Rest]]). + +lower_bound(Keymapper, [TopicIndex | Rest]) -> + filter_to_key(Keymapper, [TopicIndex | [{'=', 0} || _ <- Rest]]). + +filter_to_key(KeyMapper, KeyFilter) -> + {Key, _, _} = emqx_ds_bitmask_keymapper:next_range(KeyMapper, KeyFilter, 0), + emqx_ds_bitmask_keymapper:key_to_bitstring(KeyMapper, Key). -spec restore_trie(pos_integer(), rocksdb:db_handle(), rocksdb:cf_handle()) -> emqx_ds_lts:trie(). restore_trie(TopicIndexBytes, DB, CF) -> @@ -335,6 +389,10 @@ read_persisted_trie(IT, {ok, KeyB, ValB}) -> read_persisted_trie(IT, {error, invalid_iterator}) -> []. +inc_counter() -> + N = get(?COUNTER), + put(?COUNTER, N + 1). + %% @doc Generate a column family ID for the MQTT messages -spec data_cf(emqx_ds_storage_layer:gen_id()) -> [char()]. data_cf(GenId) -> diff --git a/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl b/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl index 22a608a7f..957383f30 100644 --- a/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl +++ b/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl @@ -8,6 +8,7 @@ -include_lib("emqx/include/emqx.hrl"). -include_lib("common_test/include/ct.hrl"). +-include_lib("snabbkaffe/include/snabbkaffe.hrl"). -include_lib("stdlib/include/assert.hrl"). -define(SHARD, shard(?FUNCTION_NAME)). @@ -72,10 +73,10 @@ t_iterate(_Config) -> -define(assertSameSet(A, B), ?assertEqual(lists:sort(A), lists:sort(B))). -%% Smoke test that verifies that concrete topics become individual -%% streams, unless there's too many of them +%% Smoke test that verifies that concrete topics are mapped to +%% individual streams, unless there's too many of them. t_get_streams(_Config) -> - %% Prepare data: + %% Prepare data (without wildcards): Topics = [<<"foo/bar">>, <<"foo/bar/baz">>, <<"a">>], Timestamps = lists:seq(1, 10), Batch = [ @@ -91,11 +92,13 @@ t_get_streams(_Config) -> [FooBar = {_, _}] = GetStream(<<"foo/bar">>), [FooBarBaz] = GetStream(<<"foo/bar/baz">>), [A] = GetStream(<<"a">>), - %% Restart shard to make sure trie is persisted: + %% Restart shard to make sure trie is persisted and restored: ok = emqx_ds_storage_layer_sup:stop_shard(?SHARD), {ok, _} = emqx_ds_storage_layer_sup:start_shard(?SHARD, #{}), - %% Test various wildcards: + %% Verify that there are no "ghost streams" for topics that don't + %% have any messages: [] = GetStream(<<"bar/foo">>), + %% Test some wildcard patterns: ?assertEqual([FooBar], GetStream("+/+")), ?assertSameSet([FooBar, FooBarBaz], GetStream(<<"foo/#">>)), ?assertSameSet([FooBar, FooBarBaz, A], GetStream(<<"#">>)), @@ -110,11 +113,139 @@ t_get_streams(_Config) -> ok = emqx_ds_storage_layer:store_batch(?SHARD, NewBatch, []), %% Check that "foo/bar/baz" topic now appears in two streams: %% "foo/bar/baz" and "foo/bar/+": - NewStreams = lists:sort(GetStream(<<"foo/bar/baz">>)), + NewStreams = lists:sort(GetStream("foo/bar/baz")), ?assertMatch([_, _], NewStreams), - ?assertMatch([_], NewStreams -- [FooBarBaz]), + ?assert(lists:member(FooBarBaz, NewStreams)), + %% Verify that size of the trie is still relatively small, even + %% after processing 200+ topics: + AllStreams = GetStream("#"), + NTotal = length(AllStreams), + ?assert(NTotal < 30, {NTotal, '<', 30}), + ?assert(lists:member(FooBar, AllStreams)), + ?assert(lists:member(FooBarBaz, AllStreams)), + ?assert(lists:member(A, AllStreams)), ok. +t_replay(_Config) -> + %% Create concrete topics: + Topics = [<<"foo/bar">>, <<"foo/bar/baz">>], + Timestamps = lists:seq(1, 10), + Batch1 = [ + make_message(PublishedAt, Topic, integer_to_binary(PublishedAt)) + || Topic <- Topics, PublishedAt <- Timestamps + ], + ok = emqx_ds_storage_layer:store_batch(?SHARD, Batch1, []), + %% Create wildcard topics `wildcard/+/suffix/foo' and `wildcard/+/suffix/bar': + Batch2 = [ + begin + B = integer_to_binary(I), + make_message( + TS, <<"wildcard/", B/binary, "/suffix/", Suffix/binary>>, integer_to_binary(TS) + ) + end + || I <- lists:seq(1, 200), TS <- lists:seq(1, 10), Suffix <- [<<"foo">>, <<"bar">>] + ], + ok = emqx_ds_storage_layer:store_batch(?SHARD, Batch2, []), + %% Check various topic filters: + Messages = Batch1 ++ Batch2, + %% Missing topics (no ghost messages): + ?assertNot(check(?SHARD, <<"missing/foo/bar">>, 0, Messages)), + %% Regular topics: + ?assert(check(?SHARD, <<"foo/bar">>, 0, Messages)), + ?assert(check(?SHARD, <<"foo/bar/baz">>, 0, Messages)), + ?assert(check(?SHARD, <<"foo/#">>, 0, Messages)), + ?assert(check(?SHARD, <<"foo/+">>, 0, Messages)), + ?assert(check(?SHARD, <<"foo/+/+">>, 0, Messages)), + ?assert(check(?SHARD, <<"+/+/+">>, 0, Messages)), + ?assert(check(?SHARD, <<"+/+/baz">>, 0, Messages)), + %% Learned wildcard topics: + ?assertNot(check(?SHARD, <<"wildcard/1000/suffix/foo">>, 0, [])), + ?assert(check(?SHARD, <<"wildcard/1/suffix/foo">>, 0, Messages)), + ?assert(check(?SHARD, <<"wildcard/100/suffix/foo">>, 0, Messages)), + ?assert(check(?SHARD, <<"wildcard/+/suffix/foo">>, 0, Messages)), + ?assert(check(?SHARD, <<"wildcard/1/suffix/+">>, 0, Messages)), + ?assert(check(?SHARD, <<"wildcard/100/suffix/+">>, 0, Messages)), + ?assert(check(?SHARD, <<"wildcard/#">>, 0, Messages)), + ?assert(check(?SHARD, <<"wildcard/1/#">>, 0, Messages)), + ?assert(check(?SHARD, <<"wildcard/100/#">>, 0, Messages)), + ?assert(check(?SHARD, <<"#">>, 0, Messages)), + ok. + +check(Shard, TopicFilter, StartTime, ExpectedMessages) -> + ExpectedFiltered = lists:filter( + fun(#message{topic = Topic, timestamp = TS}) -> + emqx_topic:match(Topic, TopicFilter) andalso TS >= StartTime + end, + ExpectedMessages + ), + ?check_trace( + #{timetrap => 10_000}, + begin + Dump = dump_messages(Shard, TopicFilter, StartTime), + verify_dump(TopicFilter, StartTime, Dump), + Missing = ExpectedFiltered -- Dump, + Extras = Dump -- ExpectedFiltered, + ?assertMatch( + #{missing := [], unexpected := []}, + #{ + missing => Missing, + unexpected => Extras, + topic_filter => TopicFilter, + start_time => StartTime + } + ) + end, + []), + length(ExpectedFiltered) > 0. + +verify_dump(TopicFilter, StartTime, Dump) -> + lists:foldl( + fun(#message{topic = Topic, timestamp = TS}, Acc) -> + %% Verify that the topic of the message returned by the + %% iterator matches the expected topic filter: + ?assert(emqx_topic:match(Topic, TopicFilter), {unexpected_topic, Topic, TopicFilter}), + %% Verify that timestamp of the message is greater than + %% the StartTime of the iterator: + ?assert(TS >= StartTime, {start_time, TopicFilter, TS, StartTime}), + %% Verify that iterator didn't reorder messages + %% (timestamps for each topic are growing): + LastTopicTs = maps:get(Topic, Acc, -1), + ?assert(TS >= LastTopicTs, {topic_ts_reordering, Topic, TS, LastTopicTs}), + Acc#{Topic => TS} + end, + #{}, + Dump + ). + +dump_messages(Shard, TopicFilter, StartTime) -> + Streams = emqx_ds_storage_layer:get_streams(Shard, parse_topic(TopicFilter), StartTime), + lists:flatmap( + fun({_Rank, Stream}) -> + dump_stream(Shard, Stream, TopicFilter, StartTime) + end, + Streams + ). + +dump_stream(Shard, Stream, TopicFilter, StartTime) -> + BatchSize = 3, + {ok, Iterator} = emqx_ds_storage_layer:make_iterator( + Shard, Stream, parse_topic(TopicFilter), StartTime + ), + Loop = fun F(It, 0) -> + error({too_many_iterations, It}); + F(It, N) -> + case emqx_ds_storage_layer:next(Shard, It, BatchSize) of + end_of_stream -> + []; + {ok, _NextIt, []} -> + []; + {ok, NextIt, Batch} -> + Batch ++ F(NextIt, N - 1) + end + end, + MaxIterations = 1000, + Loop(Iterator, MaxIterations). + %% Smoke test for iteration with wildcard topic filter %% t_iterate_wildcard(_Config) -> %% %% Prepare data: @@ -317,6 +448,7 @@ parse_topic(Topic) -> %% CT callbacks all() -> emqx_common_test_helpers:all(?MODULE). +suite() -> [{timetrap, {seconds, 20}}]. init_per_suite(Config) -> {ok, _} = application:ensure_all_started(emqx_durable_storage), From ef46c09cafe087c27fa515e4bcdc8515b428d9df Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Sat, 14 Oct 2023 01:01:10 +0200 Subject: [PATCH 17/31] feat(ds): Implement ratchet function for bitmask keymapper --- apps/emqx/src/emqx_persistent_message.erl | 5 +- .../src/emqx_ds_bitmask.hrl | 36 + .../src/emqx_ds_bitmask_keymapper.erl | 581 +++++++------- .../src/emqx_ds_storage_bitfield_lts.erl | 187 ++--- .../src/emqx_ds_storage_layer.erl_ | 714 ----------------- .../src/emqx_ds_storage_layer_bitmask.erl_ | 748 ------------------ .../emqx_ds_storage_bitfield_lts_SUITE.erl | 124 +-- 7 files changed, 436 insertions(+), 1959 deletions(-) create mode 100644 apps/emqx_durable_storage/src/emqx_ds_bitmask.hrl delete mode 100644 apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl_ delete mode 100644 apps/emqx_durable_storage/src/emqx_ds_storage_layer_bitmask.erl_ diff --git a/apps/emqx/src/emqx_persistent_message.erl b/apps/emqx/src/emqx_persistent_message.erl index f3ec9def5..632ff2a27 100644 --- a/apps/emqx/src/emqx_persistent_message.erl +++ b/apps/emqx/src/emqx_persistent_message.erl @@ -40,7 +40,10 @@ init() -> ?WHEN_ENABLED(begin - ok = emqx_ds:open_db(?PERSISTENT_MESSAGE_DB, #{}), + ok = emqx_ds:open_db(?PERSISTENT_MESSAGE_DB, #{ + backend => builtin, + storage => {emqx_ds_storage_bitfield_lts, #{}} + }), ok = emqx_persistent_session_ds_router:init_tables(), ok = emqx_persistent_session_ds:create_tables(), ok diff --git a/apps/emqx_durable_storage/src/emqx_ds_bitmask.hrl b/apps/emqx_durable_storage/src/emqx_ds_bitmask.hrl new file mode 100644 index 000000000..31af0e034 --- /dev/null +++ b/apps/emqx_durable_storage/src/emqx_ds_bitmask.hrl @@ -0,0 +1,36 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- +-ifndef(EMQX_DS_BITMASK_HRL). +-define(EMQX_DS_BITMASK_HRL, true). + +-record(filter_scan_action, { + offset :: emqx_ds_bitmask_keymapper:offset(), + size :: emqx_ds_bitmask_keymapper:bitsize(), + min :: non_neg_integer(), + max :: non_neg_integer() +}). + +-record(filter, { + size :: non_neg_integer(), + bitfilter :: non_neg_integer(), + bitmask :: non_neg_integer(), + %% Ranges (in _bitsource_ basis): + bitsource_ranges :: array:array(#filter_scan_action{}), + range_min :: non_neg_integer(), + range_max :: non_neg_integer() +}). + +-endif. diff --git a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl index 5c3ae42d8..a512a141c 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl @@ -86,9 +86,12 @@ bin_vector_to_key/2, key_to_vector/2, bin_key_to_vector/2, - next_range/3, key_to_bitstring/2, bitstring_to_key/2, + make_filter/2, + ratchet/2, + bin_increment/2, + bin_checkmask/2, bitsize/1 ]). @@ -149,6 +152,10 @@ -type scalar_range() :: any | {'=', scalar() | infinity} | {'>=', scalar()}. +-include("emqx_ds_bitmask.hrl"). + +-type filter() :: #filter{}. + %%================================================================================ %% API functions %%================================================================================ @@ -237,36 +244,6 @@ bin_key_to_vector(Keymapper = #keymapper{dim_sizeof = DimSizeof, size = Size}, B lists:zip(Vector, DimSizeof) ). -%% @doc Given a keymapper, a filter, and a key, return a triple containing: -%% -%% 1. `NextKey', a key that is greater than the given one, and is -%% within the given range. -%% -%% 2. `Bitmask' -%% -%% 3. `Bitfilter' -%% -%% Bitmask and bitfilter can be used to verify that key any K is in -%% the range using the following inequality: -%% -%% K >= NextKey && (K band Bitmask) =:= Bitfilter. -%% -%% ...or `undefined' if the next key is outside the range. --spec next_range(keymapper(), [scalar_range()], key()) -> {key(), integer(), integer()} | undefined. -next_range(Keymapper, Filter0, PrevKey) -> - %% Key -> Vector -> +1 on vector -> Key - Filter = desugar_filter(Keymapper, Filter0), - PrevVec = key_to_vector(Keymapper, PrevKey), - case inc_vector(Filter, PrevVec) of - overflow -> - undefined; - NextVec -> - NewKey = vector_to_key(Keymapper, NextVec), - Bitmask = make_bitmask(Keymapper, Filter), - Bitfilter = NewKey band Bitmask, - {NewKey, Bitmask, Bitfilter} - end. - -spec bitstring_to_key(keymapper(), bitstring()) -> key(). bitstring_to_key(#keymapper{size = Size}, Bin) -> case Bin of @@ -280,60 +257,208 @@ bitstring_to_key(#keymapper{size = Size}, Bin) -> key_to_bitstring(#keymapper{size = Size}, Key) -> <>. +%% @doc Create a filter object that facilitates range scans. +-spec make_filter(keymapper(), [scalar_range()]) -> filter(). +make_filter(KeyMapper = #keymapper{schema = Schema, dim_sizeof = DimSizeof, size = Size}, Filter0) -> + NDim = length(DimSizeof), + %% Transform "symbolic" inequations to ranges: + Filter1 = inequations_to_ranges(KeyMapper, Filter0), + {Bitmask, Bitfilter} = make_bitfilter(KeyMapper, Filter1), + %% Calculate maximum source offset as per bitsource specification: + MaxOffset = lists:foldl( + fun({Dim, Offset, _Size}, Acc) -> + maps:update_with(Dim, fun(OldVal) -> max(OldVal, Offset) end, 0, Acc) + end, + #{}, + Schema + ), + %% Adjust minimum and maximum values for each interval like this: + %% + %% Min: 110100|101011 -> 110100|00000 + %% Max: 110101|001011 -> 110101|11111 + %% ^ + %% | + %% max offset + %% + %% This is needed so when we increment the vector, we always scan + %% the full range of least significant bits. + Filter2 = lists:map( + fun + ({{Val, Val}, _Dim}) -> + {Val, Val}; + ({{Min0, Max0}, Dim}) -> + Offset = maps:get(Dim, MaxOffset, 0), + %% Set least significant bits of Min to 0: + Min = (Min0 bsr Offset) bsl Offset, + %% Set least significant bits of Max to 1: + Max = Max0 bor ones(Offset), + {Min, Max} + end, + lists:zip(Filter1, lists:seq(1, NDim)) + ), + %% Project the vector into "bitsource coordinate system": + {_, Filter} = fold_bitsources( + fun(DstOffset, {Dim, SrcOffset, Size}, Acc) -> + {Min0, Max0} = lists:nth(Dim, Filter2), + Min = (Min0 bsr SrcOffset) band ones(Size), + Max = (Max0 bsr SrcOffset) band ones(Size), + Action = #filter_scan_action{ + offset = DstOffset, + size = Size, + min = Min, + max = Max + }, + [Action | Acc] + end, + [], + Schema + ), + Ranges = array:from_list(lists:reverse(Filter)), + %% Compute estimated upper and lower bounds of a _continous_ + %% interval where all keys lie: + case Filter of + [] -> + RangeMin = 0, + RangeMax = 0; + [#filter_scan_action{offset = MSBOffset, min = MSBMin, max = MSBMax} | _] -> + RangeMin = MSBMin bsl MSBOffset, + RangeMax = MSBMax bsl MSBOffset bor ones(MSBOffset) + end, + %% Final value + #filter{ + size = Size, + bitmask = Bitmask, + bitfilter = Bitfilter, + bitsource_ranges = Ranges, + range_min = RangeMin, + range_max = RangeMax + }. + +-spec ratchet(filter(), key()) -> key() | overflow. +ratchet(#filter{bitsource_ranges = Ranges, range_max = Max}, Key) when Key =< Max -> + NDim = array:size(Ranges), + case ratchet_scan(Ranges, NDim, Key, 0, _Pivot = {-1, 0}, _Carry = 0) of + overflow -> + overflow; + {Pivot, Increment} -> + ratchet_do(Ranges, Key, NDim - 1, Pivot, Increment) + end; +ratchet(_, _) -> + overflow. + +-spec bin_increment(filter(), binary()) -> binary() | overflow. +bin_increment(Filter = #filter{size = Size}, <<>>) -> + Key = ratchet(Filter, 0), + <>; +bin_increment(Filter = #filter{size = Size, bitmask = Bitmask, bitfilter = Bitfilter}, KeyBin) -> + <> = KeyBin, + Key1 = Key0 + 1, + if + Key1 band Bitmask =:= Bitfilter -> + %% TODO: check overflow + <>; + true -> + case ratchet(Filter, Key1) of + overflow -> + overflow; + Key -> + <> + end + end. + +-spec bin_checkmask(filter(), binary()) -> boolean(). +bin_checkmask(#filter{size = Size, bitmask = Bitmask, bitfilter = Bitfilter}, Key) -> + case Key of + <> -> + Int band Bitmask =:= Bitfilter; + _ -> + false + end. + %%================================================================================ %% Internal functions %%================================================================================ --spec make_bitmask(keymapper(), [{non_neg_integer(), non_neg_integer()}]) -> non_neg_integer(). -make_bitmask(Keymapper = #keymapper{dim_sizeof = DimSizeof}, Ranges) -> - BitmaskVector = lists:map( +%% Note: this function operates in bitsource basis, scanning it from 0 +%% to NDim (i.e. from the least significant bits to the most +%% significant bits) +ratchet_scan(_Ranges, NDim, _Key, NDim, Pivot, 0) -> + %% We've reached the end: + Pivot; +ratchet_scan(_Ranges, NDim, _Key, NDim, _Pivot, 1) -> + %% We've reached the end, but key is still not large enough: + overflow; +ratchet_scan(Ranges, NDim, Key, I, Pivot0, Carry) -> + #filter_scan_action{offset = Offset, size = Size, min = Min, max = Max} = array:get(I, Ranges), + %% Extract I-th element of the vector from the original key: + Elem = ((Key bsr Offset) band ones(Size)) + Carry, + if + Elem < Min -> + %% I-th coordinate is less than the specified minimum. + %% + %% We reset this coordinate to the minimum value. It means + %% we incremented this bitposition, the less significant + %% bits have to be reset to their respective minimum + %% values: + Pivot = {I + 1, 0}, + ratchet_scan(Ranges, NDim, Key, I + 1, Pivot, 0); + Elem > Max -> + %% I-th coordinate is larger than the specified + %% minimum. We can only fix this problem by incrementing + %% the next coordinate (i.e. more significant bits). + %% + %% We reset this coordinate to the minimum value, and + %% increment the next coordinate (by setting `Carry' to + %% 1). + Pivot = {I + 1, 1}, + ratchet_scan(Ranges, NDim, Key, I + 1, Pivot, 1); + true -> + %% Coordinate is within range: + ratchet_scan(Ranges, NDim, Key, I + 1, Pivot0, 0) + end. + +%% Note: this function operates in bitsource basis, scanning it from +%% NDim to 0. It applies the transformation specified by +%% `ratchet_scan'. +ratchet_do(Ranges, Key, I, _Pivot, _Increment) when I < 0 -> + 0; +ratchet_do(Ranges, Key, I, Pivot, Increment) -> + #filter_scan_action{offset = Offset, size = Size, min = Min} = array:get(I, Ranges), + Mask = ones(Offset + Size) bxor ones(Offset), + Elem = + if + I > Pivot -> + Mask band Key; + I =:= Pivot -> + (Mask band Key) + (Increment bsl Offset); + true -> + Min bsl Offset + end, + %% erlang:display( + %% {ratchet_do, I, integer_to_list(Key, 16), integer_to_list(Mask, 2), + %% integer_to_list(Elem, 16)} + %% ), + Elem bor ratchet_do(Ranges, Key, I - 1, Pivot, Increment). + +-spec make_bitfilter(keymapper(), [{non_neg_integer(), non_neg_integer()}]) -> + {non_neg_integer(), non_neg_integer()}. +make_bitfilter(Keymapper = #keymapper{dim_sizeof = DimSizeof}, Ranges) -> + L = lists:map( fun ({{N, N}, Bits}) -> %% For strict equality we can employ bitmask: - ones(Bits); + {ones(Bits), N}; (_) -> - 0 + {0, 0} end, lists:zip(Ranges, DimSizeof) ), - vector_to_key(Keymapper, BitmaskVector). - --spec inc_vector([{non_neg_integer(), non_neg_integer()}], vector()) -> vector() | overflow. -inc_vector(Filter, Vec0) -> - case normalize_vector(Filter, Vec0) of - {true, Vec} -> - Vec; - {false, Vec} -> - do_inc_vector(Filter, Vec, []) - end. - -do_inc_vector([], [], _Acc) -> - overflow; -do_inc_vector([{Min, Max} | Intervals], [Elem | Vec], Acc) -> - case Elem of - Max -> - do_inc_vector(Intervals, Vec, [Min | Acc]); - _ when Elem < Max -> - lists:reverse(Acc) ++ [Elem + 1 | Vec] - end. - -normalize_vector(Intervals, Vec0) -> - Vec = lists:map( - fun - ({{Min, _Max}, Elem}) when Min > Elem -> - Min; - ({{_Min, Max}, Elem}) when Max < Elem -> - Max; - ({_, Elem}) -> - Elem - end, - lists:zip(Intervals, Vec0) - ), - {Vec > Vec0, Vec}. + {Bitmask, Bitfilter} = lists:unzip(L), + {vector_to_key(Keymapper, Bitmask), vector_to_key(Keymapper, Bitfilter)}. %% Transform inequalities into a list of closed intervals that the %% vector elements should lie in. -desugar_filter(#keymapper{dim_sizeof = DimSizeof}, Filter) -> +inequations_to_ranges(#keymapper{dim_sizeof = DimSizeof}, Filter) -> lists:map( fun ({any, Bitsize}) -> @@ -390,24 +515,6 @@ ones(Bits) -> -ifdef(TEST). -%% %% Create a bitmask that is sufficient to cover a given number. E.g.: -%% %% -%% %% 2#1000 -> 2#1111; 2#0 -> 2#0; 2#10101 -> 2#11111 -%% bitmask_of(N) -> -%% %% FIXME: avoid floats -%% NBits = ceil(math:log2(N + 1)), -%% ones(NBits). - -%% bitmask_of_test() -> -%% ?assertEqual(2#0, bitmask_of(0)), -%% ?assertEqual(2#1, bitmask_of(1)), -%% ?assertEqual(2#11, bitmask_of(2#10)), -%% ?assertEqual(2#11, bitmask_of(2#11)), -%% ?assertEqual(2#1111, bitmask_of(2#1000)), -%% ?assertEqual(2#1111, bitmask_of(2#1111)), -%% ?assertEqual(ones(128), bitmask_of(ones(128))), -%% ?assertEqual(ones(256), bitmask_of(ones(256))). - make_keymapper0_test() -> Schema = [], ?assertEqual( @@ -510,235 +617,117 @@ key_to_vector2_test() -> key2vec(Schema, [0, 1]), key2vec(Schema, [255, 0]). -inc_vector0_test() -> - Keymapper = make_keymapper([]), - ?assertMatch(overflow, incvec(Keymapper, [], [])). - -inc_vector1_test() -> - Keymapper = make_keymapper([{1, 0, 8}]), - ?assertMatch([3], incvec(Keymapper, [{'=', 3}], [1])), - ?assertMatch([3], incvec(Keymapper, [{'=', 3}], [2])), - ?assertMatch(overflow, incvec(Keymapper, [{'=', 3}], [3])), - ?assertMatch(overflow, incvec(Keymapper, [{'=', 3}], [4])), - ?assertMatch(overflow, incvec(Keymapper, [{'=', 3}], [255])), - %% Now with >=: - ?assertMatch([1], incvec(Keymapper, [{'>=', 0}], [0])), - ?assertMatch([255], incvec(Keymapper, [{'>=', 0}], [254])), - ?assertMatch(overflow, incvec(Keymapper, [{'>=', 0}], [255])), - - ?assertMatch([100], incvec(Keymapper, [{'>=', 100}], [0])), - ?assertMatch([100], incvec(Keymapper, [{'>=', 100}], [99])), - ?assertMatch([255], incvec(Keymapper, [{'>=', 100}], [254])), - ?assertMatch(overflow, incvec(Keymapper, [{'>=', 100}], [255])). - -inc_vector2_test() -> - Keymapper = make_keymapper([{1, 0, 8}, {2, 0, 8}, {3, 0, 8}]), - Filter = [{'>=', 0}, {'=', 100}, {'>=', 30}], - ?assertMatch([0, 100, 30], incvec(Keymapper, Filter, [0, 0, 0])), - ?assertMatch([1, 100, 30], incvec(Keymapper, Filter, [0, 100, 30])), - ?assertMatch([255, 100, 30], incvec(Keymapper, Filter, [254, 100, 30])), - ?assertMatch([0, 100, 31], incvec(Keymapper, Filter, [255, 100, 30])), - ?assertMatch([0, 100, 30], incvec(Keymapper, Filter, [0, 100, 29])), - ?assertMatch(overflow, incvec(Keymapper, Filter, [255, 100, 255])), - ?assertMatch([255, 100, 255], incvec(Keymapper, Filter, [254, 100, 255])), - ?assertMatch([0, 100, 255], incvec(Keymapper, Filter, [255, 100, 254])), - %% Nasty cases (shouldn't happen, hopefully): - ?assertMatch([1, 100, 30], incvec(Keymapper, Filter, [0, 101, 0])), - ?assertMatch([1, 100, 33], incvec(Keymapper, Filter, [0, 101, 33])), - ?assertMatch([0, 100, 255], incvec(Keymapper, Filter, [255, 101, 254])), - ?assertMatch(overflow, incvec(Keymapper, Filter, [255, 101, 255])). - make_bitmask0_test() -> Keymapper = make_keymapper([]), - ?assertMatch(0, mkbmask(Keymapper, [])). + ?assertMatch({0, 0}, mkbmask(Keymapper, [])). make_bitmask1_test() -> Keymapper = make_keymapper([{1, 0, 8}]), - ?assertEqual(0, mkbmask(Keymapper, [any])), - ?assertEqual(16#ff, mkbmask(Keymapper, [{'=', 1}])), - ?assertEqual(16#ff, mkbmask(Keymapper, [{'=', 255}])), - ?assertEqual(0, mkbmask(Keymapper, [{'>=', 0}])), - ?assertEqual(0, mkbmask(Keymapper, [{'>=', 1}])), - ?assertEqual(0, mkbmask(Keymapper, [{'>=', 16#f}])). + ?assertEqual({0, 0}, mkbmask(Keymapper, [any])), + ?assertEqual({16#ff, 1}, mkbmask(Keymapper, [{'=', 1}])), + ?assertEqual({16#ff, 255}, mkbmask(Keymapper, [{'=', 255}])), + ?assertEqual({0, 0}, mkbmask(Keymapper, [{'>=', 0}])), + ?assertEqual({0, 0}, mkbmask(Keymapper, [{'>=', 1}])), + ?assertEqual({0, 0}, mkbmask(Keymapper, [{'>=', 16#f}])). make_bitmask2_test() -> Keymapper = make_keymapper([{1, 0, 3}, {2, 0, 4}, {3, 0, 2}]), - ?assertEqual(2#00_0000_000, mkbmask(Keymapper, [any, any, any])), - ?assertEqual(2#11_0000_000, mkbmask(Keymapper, [any, any, {'=', 0}])), - ?assertEqual(2#00_1111_000, mkbmask(Keymapper, [any, {'=', 0}, any])), - ?assertEqual(2#00_0000_111, mkbmask(Keymapper, [{'=', 0}, any, any])). + ?assertEqual({2#00_0000_000, 2#00_0000_000}, mkbmask(Keymapper, [any, any, any])), + ?assertEqual({2#11_0000_000, 2#00_0000_000}, mkbmask(Keymapper, [any, any, {'=', 0}])), + ?assertEqual({2#00_1111_000, 2#00_0000_000}, mkbmask(Keymapper, [any, {'=', 0}, any])), + ?assertEqual({2#00_0000_111, 2#00_0000_000}, mkbmask(Keymapper, [{'=', 0}, any, any])). make_bitmask3_test() -> %% Key format of type |TimeOffset|Topic|Epoch|: - Keymapper = make_keymapper([{1, 8, 8}, {2, 0, 8}, {1, 0, 8}]), - ?assertEqual(2#00000000_00000000_00000000, mkbmask(Keymapper, [any, any])), - ?assertEqual(2#11111111_11111111_11111111, mkbmask(Keymapper, [{'=', 33}, {'=', 22}])), - ?assertEqual(2#11111111_11111111_11111111, mkbmask(Keymapper, [{'=', 33}, {'=', 22}])), - ?assertEqual(2#00000000_11111111_00000000, mkbmask(Keymapper, [{'>=', 255}, {'=', 22}])). + Keymapper = make_keymapper([{1, 0, 8}, {2, 0, 8}, {1, 8, 8}]), + ?assertEqual({2#00000000_00000000_00000000, 16#00_00_00}, mkbmask(Keymapper, [any, any])), + ?assertEqual( + {2#11111111_11111111_11111111, 16#aa_cc_bb}, + mkbmask(Keymapper, [{'=', 16#aabb}, {'=', 16#cc}]) + ), + ?assertEqual( + {2#00000000_11111111_00000000, 16#00_bb_00}, mkbmask(Keymapper, [{'>=', 255}, {'=', 16#bb}]) + ). -next_range0_test() -> - Keymapper = make_keymapper([]), +make_filter_test() -> + KeyMapper = make_keymapper([]), Filter = [], - PrevKey = 0, - ?assertMatch(undefined, next_range(Keymapper, Filter, PrevKey)). + ?assertMatch(#filter{size = 0, bitmask = 0, bitfilter = 0}, make_filter(KeyMapper, Filter)). -next_range1_test() -> - Keymapper = make_keymapper([{1, 0, 8}, {2, 0, 8}]), - ?assertMatch(undefined, next_range(Keymapper, [{'=', 0}, {'=', 0}], 0)), - ?assertMatch({1, 16#ffff, 1}, next_range(Keymapper, [{'=', 1}, {'=', 0}], 0)), - ?assertMatch({16#100, 16#ffff, 16#100}, next_range(Keymapper, [{'=', 0}, {'=', 1}], 0)), - %% Now with any: - ?assertMatch({1, 0, 0}, next_range(Keymapper, [any, any], 0)), - ?assertMatch({2, 0, 0}, next_range(Keymapper, [any, any], 1)), - ?assertMatch({16#fffb, 0, 0}, next_range(Keymapper, [any, any], 16#fffa)), - %% Now with >=: +ratchet1_test() -> + Bitsources = [{1, 0, 8}], + M = make_keymapper(Bitsources), + F = make_filter(M, [any]), + #filter{bitsource_ranges = Rarr} = F, ?assertMatch( - {16#42_30, 16#ff00, 16#42_00}, next_range(Keymapper, [{'>=', 16#30}, {'=', 16#42}], 0) - ), - ?assertMatch( - {16#42_31, 16#ff00, 16#42_00}, - next_range(Keymapper, [{'>=', 16#30}, {'=', 16#42}], 16#42_30) + [ + #filter_scan_action{ + offset = 0, + size = 8, + min = 0, + max = 16#ff + } + ], + array:to_list(Rarr) ), + ?assertEqual(0, ratchet(F, 0)), + ?assertEqual(16#fa, ratchet(F, 16#fa)), + ?assertEqual(16#ff, ratchet(F, 16#ff)), + ?assertEqual(overflow, ratchet(F, 16#100), "TBD: filter must store the upper bound"). - ?assertMatch( - {16#30_42, 16#00ff, 16#00_42}, next_range(Keymapper, [{'=', 16#42}, {'>=', 16#30}], 0) - ), - ?assertMatch( - {16#31_42, 16#00ff, 16#00_42}, - next_range(Keymapper, [{'=', 16#42}, {'>=', 16#30}], 16#00_43) - ). +%% erlfmt-ignore +ratchet2_test() -> + Bitsources = [{1, 0, 8}, %% Static topic index + {2, 8, 8}, %% Epoch + {3, 0, 8}, %% Varying topic hash + {2, 0, 8}], %% Timestamp offset + M = make_keymapper(lists:reverse(Bitsources)), + F1 = make_filter(M, [{'=', 16#aa}, any, {'=', 16#cc}]), + ?assertEqual(16#aa00cc00, ratchet(F1, 0)), + ?assertEqual(16#aa01cc00, ratchet(F1, 16#aa00cd00)), + ?assertEqual(16#aa01cc11, ratchet(F1, 16#aa01cc11)), + ?assertEqual(16#aa11cc00, ratchet(F1, 16#aa10cd00)), + ?assertEqual(16#aa11cc00, ratchet(F1, 16#aa10dc11)), + ?assertEqual(overflow, ratchet(F1, 16#ab000000)), + F2 = make_filter(M, [{'=', 16#aa}, {'>=', 16#dddd}, {'=', 16#cc}]), + ?assertEqual(16#aaddcc00, ratchet(F2, 0)), + ?assertEqual(16#aa_de_cc_00, ratchet(F2, 16#aa_dd_cd_11)). -%% Bunch of tests that verifying that next_range doesn't skip over keys: +ratchet3_test() -> + ?assert(proper:quickcheck(ratchet1_prop(), 100)). --define(assertIterComplete(A, B), - ?assertEqual(A -- [0], B) -). +%% erlfmt-ignore +ratchet1_prop() -> + EpochBits = 4, + Bitsources = [{1, 0, 2}, %% Static topic index + {2, EpochBits, 4}, %% Epoch + {3, 0, 2}, %% Varying topic hash + {2, 0, EpochBits}], %% Timestamp offset + M = make_keymapper(lists:reverse(Bitsources)), + F1 = make_filter(M, [{'=', 2#10}, any, {'=', 2#01}]), + ?FORALL(N, integer(0, ones(12)), + ratchet_prop(F1, N)). --define(assertSameSet(A, B), - ?assertIterComplete(lists:sort(A), lists:sort(B)) -). - -iterate1_test() -> - SizeX = 3, - SizeY = 3, - Keymapper = make_keymapper([{1, 0, SizeX}, {2, 0, SizeY}]), - Keys = test_iteration(Keymapper, [any, any]), - Expected = [ - X bor (Y bsl SizeX) - || Y <- lists:seq(0, ones(SizeY)), X <- lists:seq(0, ones(SizeX)) - ], - ?assertIterComplete(Expected, Keys). - -iterate2_test() -> - SizeX = 64, - SizeY = 3, - Keymapper = make_keymapper([{1, 0, SizeX}, {2, 0, SizeY}]), - X = 123456789, - Keys = test_iteration(Keymapper, [{'=', X}, any]), - Expected = [ - X bor (Y bsl SizeX) - || Y <- lists:seq(0, ones(SizeY)) - ], - ?assertIterComplete(Expected, Keys). - -iterate3_test() -> - SizeX = 3, - SizeY = 64, - Y = 42, - Keymapper = make_keymapper([{1, 0, SizeX}, {2, 0, SizeY}]), - Keys = test_iteration(Keymapper, [any, {'=', Y}]), - Expected = [ - X bor (Y bsl SizeX) - || X <- lists:seq(0, ones(SizeX)) - ], - ?assertIterComplete(Expected, Keys). - -iterate4_test() -> - SizeX = 8, - SizeY = 4, - MinX = 16#fa, - MinY = 16#a, - Keymapper = make_keymapper([{1, 0, SizeX}, {2, 0, SizeY}]), - Keys = test_iteration(Keymapper, [{'>=', MinX}, {'>=', MinY}]), - Expected = [ - X bor (Y bsl SizeX) - || Y <- lists:seq(MinY, ones(SizeY)), X <- lists:seq(MinX, ones(SizeX)) - ], - ?assertIterComplete(Expected, Keys). - -iterate1_prop() -> - Size = 4, - ?FORALL( - {SizeX, SizeY}, - {integer(1, Size), integer(1, Size)}, - ?FORALL( - {SplitX, MinX, MinY}, - {integer(0, SizeX), integer(0, SizeX), integer(0, SizeY)}, - begin - Keymapper = make_keymapper([ - {1, 0, SplitX}, {2, 0, SizeY}, {1, SplitX, SizeX - SplitX} - ]), - Keys = test_iteration(Keymapper, [{'>=', MinX}, {'>=', MinY}]), - Expected = [ - vector_to_key(Keymapper, [X, Y]) - || X <- lists:seq(MinX, ones(SizeX)), - Y <- lists:seq(MinY, ones(SizeY)) - ], - ?assertSameSet(Expected, Keys), - true - end - ) - ). - -iterate5_test() -> - ?assert(proper:quickcheck(iterate1_prop(), 100)). - -iterate2_prop() -> - Size = 4, - ?FORALL( - {SizeX, SizeY}, - {integer(1, Size), integer(1, Size)}, - ?FORALL( - {SplitX, MinX, MinY}, - {integer(0, SizeX), integer(0, SizeX), integer(0, SizeY)}, - begin - Keymapper = make_keymapper([ - {1, SplitX, SizeX - SplitX}, {2, 0, SizeY}, {1, 0, SplitX} - ]), - Keys = test_iteration(Keymapper, [{'>=', MinX}, {'>=', MinY}]), - Expected = [ - vector_to_key(Keymapper, [X, Y]) - || X <- lists:seq(MinX, ones(SizeX)), - Y <- lists:seq(MinY, ones(SizeY)) - ], - ?assertSameSet(Expected, Keys), - true - end - ) - ). - -iterate6_test() -> - ?assert(proper:quickcheck(iterate2_prop(), 1000)). - -test_iteration(Keymapper, Filter) -> - test_iteration(Keymapper, Filter, 0). - -test_iteration(Keymapper, Filter, PrevKey) -> - case next_range(Keymapper, Filter, PrevKey) of - undefined -> - []; - {Key, Bitmask, Bitfilter} -> - ?assert((Key band Bitmask) =:= Bitfilter), - [Key | test_iteration(Keymapper, Filter, Key)] - end. +ratchet_prop(Filter = #filter{bitfilter = Bitfilter, bitmask = Bitmask, size = Size}, Key0) -> + Key = ratchet(Filter, Key0), + ?assert(Key =:= overflow orelse (Key band Bitmask =:= Bitfilter)), + ?assert(Key >= Key0, {Key, '>=', Key}), + IMax = ones(Size), + CheckGaps = fun + F(I) when I >= Key; I > IMax -> + true; + F(I) -> + ?assertNot( + I band Bitmask =:= Bitfilter, + {found_gap, Key0, I, Key} + ), + F(I + 1) + end, + CheckGaps(Key0). mkbmask(Keymapper, Filter0) -> - Filter = desugar_filter(Keymapper, Filter0), - make_bitmask(Keymapper, Filter). - -incvec(Keymapper, Filter0, Vector) -> - Filter = desugar_filter(Keymapper, Filter0), - inc_vector(Filter, Vector). + Filter = inequations_to_ranges(Keymapper, Filter0), + make_bitfilter(Keymapper, Filter). key2vec(Schema, Vector) -> Keymapper = make_keymapper(Schema), diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl index 7b8fbab0d..8d406c93e 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl @@ -30,7 +30,7 @@ -export([create/4, open/5, store_batch/4, get_streams/4, make_iterator/5, next/4]). %% internal exports: --export([]). +-export([format_key/2, format_keyfilter/1]). -export_type([options/0]). @@ -73,8 +73,7 @@ topic_filter :: emqx_ds:topic_filter(), start_time :: emqx_ds:time(), storage_key :: emqx_ds_lts:msg_storage_key(), - last_seen_key = 0 :: emqx_ds_bitmask_keymapper:key(), - key_filter :: [emqx_ds_bitmask_keymapper:scalar_range()] + last_seen_key = <<>> :: binary() }). -define(QUICKCHECK_KEY(KEY, BITMASK, BITFILTER), @@ -83,6 +82,8 @@ -define(COUNTER, emqx_ds_storage_bitfield_lts_counter). +-include("emqx_ds_bitmask.hrl"). + %%================================================================================ %% API funcions %%================================================================================ @@ -95,7 +96,8 @@ create(_ShardId, DBHandle, GenId, Options) -> %% Get options: BitsPerTopicLevel = maps:get(bits_per_wildcard_level, Options, 64), TopicIndexBytes = maps:get(topic_index_bytes, Options, 4), - TSOffsetBits = maps:get(epoch_bits, Options, 8), %% TODO: change to 10 to make it around ~1 sec + %% 10 bits -> 1024 ms -> ~1 sec + TSOffsetBits = maps:get(epoch_bits, Options, 10), %% Create column families: DataCFName = data_cf(GenId), TrieCFName = trie_cf(GenId), @@ -120,17 +122,17 @@ open(_Shard, DBHandle, GenId, CFRefs, Schema) -> {_, DataCF} = lists:keyfind(data_cf(GenId), 1, CFRefs), {_, TrieCF} = lists:keyfind(trie_cf(GenId), 1, CFRefs), Trie = restore_trie(TopicIndexBytes, DBHandle, TrieCF), - %% If user's topics have more than learned 10 wildcard levels, - %% then it's total carnage; learned topic structure won't help - %% much: + %% If user's topics have more than learned 10 wildcard levels + %% (more than 2, really), then it's total carnage; learned topic + %% structure won't help. MaxWildcardLevels = 10, - Keymappers = array:from_list( + KeymapperCache = array:from_list( [ make_keymapper(TopicIndexBytes, BitsPerTopicLevel, TSBits, TSOffsetBits, N) || N <- lists:seq(0, MaxWildcardLevels) ] ), - #s{db = DBHandle, data = DataCF, trie = Trie, keymappers = Keymappers}. + #s{db = DBHandle, data = DataCF, trie = Trie, keymappers = KeymapperCache}. store_batch(_ShardId, S = #s{db = DB, data = Data}, Messages, _Options) -> lists:foreach( @@ -144,16 +146,26 @@ store_batch(_ShardId, S = #s{db = DB, data = Data}, Messages, _Options) -> get_streams(_Shard, #s{trie = Trie}, TopicFilter, _StartTime) -> Indexes = emqx_ds_lts:match_topics(Trie, TopicFilter), - [ - #stream{ - storage_key = I - } - || I <- Indexes - ]. + [#stream{storage_key = I} || I <- Indexes]. make_iterator(_Shard, _Data, #stream{storage_key = StorageKey}, TopicFilter, StartTime) -> + %% Note: it's a good idea to keep the iterator structure lean, + %% since it can be stored on a remote node that could update its + %% code independently from us. + {ok, #it{ + topic_filter = TopicFilter, + start_time = StartTime, + storage_key = StorageKey + }}. + +next(_Shard, #s{db = DB, data = CF, keymappers = Keymappers}, It0, BatchSize) -> + #it{ + start_time = StartTime, + storage_key = StorageKey + } = It0, + %% Make filter: {TopicIndex, Varying} = StorageKey, - Filter = [ + Inequations = [ {'=', TopicIndex}, {'>=', StartTime} | lists:map( @@ -166,29 +178,22 @@ make_iterator(_Shard, _Data, #stream{storage_key = StorageKey}, TopicFilter, Sta Varying ) ], - {ok, #it{ - topic_filter = TopicFilter, - start_time = StartTime, - storage_key = StorageKey, - key_filter = Filter - }}. - -next(_Shard, #s{db = DB, data = CF, keymappers = Keymappers}, It0, BatchSize) -> - #it{ - key_filter = KeyFilter - } = It0, - % TODO: ugh, so ugly - NVarying = length(KeyFilter) - 2, + %% Obtain a keymapper for the current number of varying + %% levels. Magic constant 2: we have two extra dimensions of topic + %% index and time; the rest of dimensions are varying levels. + NVarying = length(Inequations) - 2, Keymapper = array:get(NVarying, Keymappers), - %% Calculate lower and upper bounds for iteration: - LowerBound = lower_bound(Keymapper, KeyFilter), - UpperBound = upper_bound(Keymapper, KeyFilter), + Filter = + #filter{range_min = LowerBound, range_max = UpperBound} = emqx_ds_bitmask_keymapper:make_filter( + Keymapper, Inequations + ), {ok, ITHandle} = rocksdb:iterator(DB, CF, [ - {iterate_lower_bound, LowerBound}, {iterate_upper_bound, UpperBound} + {iterate_lower_bound, emqx_ds_bitmask_keymapper:key_to_bitstring(Keymapper, LowerBound)}, + {iterate_upper_bound, emqx_ds_bitmask_keymapper:key_to_bitstring(Keymapper, UpperBound)} ]), try put(?COUNTER, 0), - next_loop(ITHandle, Keymapper, It0, [], BatchSize) + next_loop(ITHandle, Keymapper, Filter, It0, [], BatchSize) after rocksdb:iterator_close(ITHandle), erase(?COUNTER) @@ -198,100 +203,64 @@ next(_Shard, #s{db = DB, data = CF, keymappers = Keymappers}, It0, BatchSize) -> %% Internal functions %%================================================================================ -next_loop(_, _, It, Acc, 0) -> +next_loop(ITHandle, KeyMapper, Filter, It, Acc, 0) -> {ok, It, lists:reverse(Acc)}; -next_loop(ITHandle, KeyMapper, It0 = #it{last_seen_key = Key0, key_filter = KeyFilter}, Acc0, N0) -> +next_loop(ITHandle, KeyMapper, Filter, It0, Acc0, N0) -> inc_counter(), - case next_range(KeyMapper, It0) of - {Key1, Bitmask, Bitfilter} when Key1 > Key0 -> - case iterator_move(KeyMapper, ITHandle, {seek, Key1}) of - {ok, Key, Val} when ?QUICKCHECK_KEY(Key, Bitmask, Bitfilter) -> - assert_progress(bitmask_match, KeyMapper, KeyFilter, Key0, Key1), - Msg = deserialize(Val), + #it{last_seen_key = Key0} = It0, + case emqx_ds_bitmask_keymapper:bin_increment(Filter, Key0) of + overflow -> + {ok, It0, lists:reverse(Acc0)}; + Key1 -> + %% assert + true = Key1 > Key0, + case rocksdb:iterator_move(ITHandle, {seek, Key1}) of + {ok, Key, Val} -> It1 = It0#it{last_seen_key = Key}, - case check_message(It1, Msg) of - true -> + case check_message(Filter, It1, Val) of + {true, Msg} -> N1 = N0 - 1, Acc1 = [Msg | Acc0]; false -> N1 = N0, Acc1 = Acc0 end, - {N, It, Acc} = traverse_interval( - ITHandle, KeyMapper, Bitmask, Bitfilter, It1, Acc1, N1 - ), - next_loop(ITHandle, KeyMapper, It, Acc, N); - {ok, Key, _Val} -> - assert_progress(bitmask_miss, KeyMapper, KeyFilter, Key0, Key1), - It = It0#it{last_seen_key = Key}, - next_loop(ITHandle, KeyMapper, It, Acc0, N0); + {N, It, Acc} = traverse_interval(ITHandle, KeyMapper, Filter, It1, Acc1, N1), + next_loop(ITHandle, KeyMapper, Filter, It, Acc, N); {error, invalid_iterator} -> {ok, It0, lists:reverse(Acc0)} - end; - _ -> - {ok, It0, lists:reverse(Acc0)} + end end. -traverse_interval(_, _, _, _, It, Acc, 0) -> +traverse_interval(_ITHandle, _KeyMapper, _Filter, It, Acc, 0) -> {0, It, Acc}; -traverse_interval(ITHandle, KeyMapper, Bitmask, Bitfilter, It0, Acc, N) -> +traverse_interval(ITHandle, KeyMapper, Filter, It0, Acc, N) -> inc_counter(), - case iterator_move(KeyMapper, ITHandle, next) of - {ok, Key, Val} when ?QUICKCHECK_KEY(Key, Bitmask, Bitfilter) -> - Msg = deserialize(Val), + case rocksdb:iterator_move(ITHandle, next) of + {ok, Key, Val} -> It = It0#it{last_seen_key = Key}, - case check_message(It, Msg) of - true -> - traverse_interval( - ITHandle, KeyMapper, Bitmask, Bitfilter, It, [Msg | Acc], N - 1 - ); + case check_message(Filter, It, Val) of + {true, Msg} -> + traverse_interval(ITHandle, KeyMapper, Filter, It, [Msg | Acc], N - 1); false -> - traverse_interval(ITHandle, KeyMapper, Bitmask, Bitfilter, It, Acc, N) + traverse_interval(ITHandle, KeyMapper, Filter, It, Acc, N) end; - {ok, Key, _Val} -> - It = It0#it{last_seen_key = Key}, - {N, It, Acc}; {error, invalid_iterator} -> {0, It0, Acc} end. -next_range(KeyMapper, #it{key_filter = KeyFilter, last_seen_key = PrevKey}) -> - emqx_ds_bitmask_keymapper:next_range(KeyMapper, KeyFilter, PrevKey). - -check_message(_Iterator, _Msg) -> - %% TODO. - true. - -iterator_move(KeyMapper, ITHandle, Action0) -> - Action = - case Action0 of - next -> - next; - {seek, Int} -> - {seek, emqx_ds_bitmask_keymapper:key_to_bitstring(KeyMapper, Int)} - end, - case rocksdb:iterator_move(ITHandle, Action) of - {ok, KeyBin, Val} -> - {ok, emqx_ds_bitmask_keymapper:bitstring_to_key(KeyMapper, KeyBin), Val}; - {ok, KeyBin} -> - {ok, emqx_ds_bitmask_keymapper:bitstring_to_key(KeyMapper, KeyBin)}; - Other -> - Other +-spec check_message(emqx_ds_bitmask_keymapper:filter(), #it{}, binary()) -> + {true, #message{}} | false. +check_message(Filter, #it{last_seen_key = Key}, Val) -> + case emqx_ds_bitmask_keymapper:bin_checkmask(Filter, Key) of + true -> + Msg = deserialize(Val), + %% TODO: check strict time and hash collisions + {true, Msg}; + false -> + false end. -assert_progress(_Msg, _KeyMapper, _KeyFilter, Key0, Key1) when Key1 > Key0 -> - ?tp_ignore_side_effects_in_prod( - emqx_ds_storage_bitfield_lts_iter_move, - #{ location => _Msg - , key0 => format_key(_KeyMapper, Key0) - , key1 => format_key(_KeyMapper, Key1) - }), - ok; -assert_progress(Msg, KeyMapper, KeyFilter, Key0, Key1) -> - Str0 = format_key(KeyMapper, Key0), - Str1 = format_key(KeyMapper, Key1), - error(#{'$msg' => Msg, key0 => Str0, key1 => Str1, step => get(?COUNTER), keyfilter => lists:map(fun format_keyfilter/1, KeyFilter)}). - format_key(KeyMapper, Key) -> Vec = [integer_to_list(I, 16) || I <- emqx_ds_bitmask_keymapper:key_to_vector(KeyMapper, Key)], lists:flatten(io_lib:format("~.16B (~s)", [Key, string:join(Vec, ",")])). @@ -357,16 +326,6 @@ make_keymapper(TopicIndexBytes, BitsPerTopicLevel, TSBits, TSOffsetBits, N) -> end, Keymapper. -upper_bound(Keymapper, [TopicIndex | Rest]) -> - filter_to_key(Keymapper, [TopicIndex | [{'=', infinity} || _ <- Rest]]). - -lower_bound(Keymapper, [TopicIndex | Rest]) -> - filter_to_key(Keymapper, [TopicIndex | [{'=', 0} || _ <- Rest]]). - -filter_to_key(KeyMapper, KeyFilter) -> - {Key, _, _} = emqx_ds_bitmask_keymapper:next_range(KeyMapper, KeyFilter, 0), - emqx_ds_bitmask_keymapper:key_to_bitstring(KeyMapper, Key). - -spec restore_trie(pos_integer(), rocksdb:db_handle(), rocksdb:cf_handle()) -> emqx_ds_lts:trie(). restore_trie(TopicIndexBytes, DB, CF) -> PersistCallback = fun(Key, Val) -> diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl_ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl_ deleted file mode 100644 index 32f18d18b..000000000 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl_ +++ /dev/null @@ -1,714 +0,0 @@ -%%-------------------------------------------------------------------- -%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved. -%%-------------------------------------------------------------------- --module(emqx_ds_storage_layer). - --behaviour(gen_server). - -%% API: --export([start_link/2]). --export([create_generation/3]). - --export([open_shard/2, get_streams/3]). --export([message_store/3]). --export([delete/4]). - --export([make_iterator/3, next/1, next/2]). - --export([ - preserve_iterator/2, - restore_iterator/2, - discard_iterator/2, - ensure_iterator/3, - discard_iterator_prefix/2, - list_iterator_prefix/2, - foldl_iterator_prefix/4 -]). - -%% gen_server callbacks: --export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). - --export_type([stream/0, cf_refs/0, gen_id/0, options/0, state/0, iterator/0]). --export_type([db_options/0, db_write_options/0, db_read_options/0]). - --compile({inline, [meta_lookup/2]}). - --include_lib("emqx/include/emqx.hrl"). - -%%================================================================================ -%% Type declarations -%%================================================================================ - --type options() :: #{ - dir => file:filename() -}. - -%% see rocksdb:db_options() --type db_options() :: proplists:proplist(). -%% see rocksdb:write_options() --type db_write_options() :: proplists:proplist(). -%% see rocksdb:read_options() --type db_read_options() :: proplists:proplist(). - --type cf_refs() :: [{string(), rocksdb:cf_handle()}]. - -%% Message storage generation -%% Keep in mind that instances of this type are persisted in long-term storage. --type generation() :: #{ - %% Module that handles data for the generation - module := module(), - %% Module-specific data defined at generation creation time - data := term(), - %% When should this generation become active? - %% This generation should only contain messages timestamped no earlier than that. - %% The very first generation will have `since` equal 0. - since := emqx_ds:time() -}. - --record(s, { - shard :: emqx_ds:shard(), - keyspace :: emqx_ds_conf:keyspace(), - db :: rocksdb:db_handle(), - cf_iterator :: rocksdb:cf_handle(), - cf_generations :: cf_refs() -}). - --record(stream, - { generation :: gen_id() - , topic_filter :: emqx_ds:topic_filter() - , since :: emqx_ds:time() - , enc :: _EncapsultatedData - }). - --opaque stream() :: #stream{}. - --record(it, { - shard :: emqx_ds:shard(), - gen :: gen_id(), - replay :: emqx_ds:replay(), - module :: module(), - data :: term() -}). - --type gen_id() :: 0..16#ffff. - --opaque state() :: #s{}. --opaque iterator() :: #it{}. - -%% Contents of the default column family: -%% -%% [{<<"genNN">>, #generation{}}, ..., -%% {<<"current">>, GenID}] - --define(DEFAULT_CF, "default"). --define(DEFAULT_CF_OPTS, []). - --define(ITERATOR_CF, "$iterators"). - -%% TODO -%% 1. CuckooTable might be of use here / `OptimizeForPointLookup(...)`. -%% 2. Supposedly might be compressed _very_ effectively. -%% 3. `inplace_update_support`? --define(ITERATOR_CF_OPTS, []). - --define(REF(ShardId), {via, gproc, {n, l, {?MODULE, ShardId}}}). - -%%================================================================================ -%% Callbacks -%%================================================================================ - --callback create_new(rocksdb:db_handle(), gen_id(), _Options :: term()) -> - {_Schema, cf_refs()}. - --callback open( - emqx_ds:shard(), - rocksdb:db_handle(), - gen_id(), - cf_refs(), - _Schema -) -> - _DB. - --callback store( - _DB, - _MessageID :: binary(), - emqx_ds:time(), - emqx_ds:topic(), - _Payload :: binary() -) -> - ok | {error, _}. - --callback delete(_DB, _MessageID :: binary(), emqx_ds:time(), emqx_ds:topic()) -> - ok | {error, _}. - --callback get_streams(_DB, emqx_ds:topic_filter(), emqx_ds:time()) -> - [{_TopicRankX, _Stream}]. - --callback make_iterator(_DB, emqx_ds:replay()) -> - {ok, _It} | {error, _}. - --callback restore_iterator(_DB, _Serialized :: binary()) -> {ok, _It} | {error, _}. - --callback preserve_iterator(_It) -> term(). - --callback next(It) -> {value, binary(), It} | none | {error, closed}. - -%%================================================================================ -%% Replication layer API -%%================================================================================ - --spec open_shard(emqx_ds_replication_layer:shard(), emqx_ds_storage_layer:options()) -> ok. -open_shard(Shard, Options) -> - emqx_ds_storage_layer_sup:ensure_shard(Shard, Options). - --spec get_streams(emqx_ds:shard_id(), emqx_ds:topic_filter(), emqx_ds:time()) -> [{emqx_ds:stream_rank(), _Stream}]. -get_streams(Shard, TopicFilter, StartTime) -> - %% TODO: lookup ALL generations - {GenId, #{module := Mod, data := ModState}} = meta_lookup_gen(Shard, StartTime), - lists:map( - fun({RankX, ModStream}) -> - Stream = #stream{ generation = GenId - , topic_filter = TopicFilter - , since = StartTime - , enc = ModStream - }, - Rank = {RankX, GenId}, - {Rank, Stream} - end, - Mod:get_streams(ModState, TopicFilter, StartTime)). - --spec message_store(emqx_ds:shard(), [emqx_types:message()], emqx_ds:message_store_opts()) -> - {ok, _MessageId} | {error, _}. -message_store(Shard, Msgs, _Opts) -> - {ok, lists:map( - fun(Msg) -> - GUID = emqx_message:id(Msg), - Timestamp = Msg#message.timestamp, - {_GenId, #{module := Mod, data := ModState}} = meta_lookup_gen(Shard, Timestamp), - Topic = emqx_topic:words(emqx_message:topic(Msg)), - Payload = serialize(Msg), - Mod:store(ModState, GUID, Timestamp, Topic, Payload), - GUID - end, - Msgs)}. - --spec next(iterator()) -> {ok, iterator(), [binary()]} | end_of_stream. -next(It = #it{}) -> - next(It, _BatchSize = 1). - --spec next(iterator(), pos_integer()) -> {ok, iterator(), [binary()]} | end_of_stream. -next(#it{data = {?MODULE, end_of_stream}}, _BatchSize) -> - end_of_stream; -next( - It = #it{shard = Shard, module = Mod, gen = Gen, data = {?MODULE, retry, Serialized}}, BatchSize -) -> - #{data := DBData} = meta_get_gen(Shard, Gen), - {ok, ItData} = Mod:restore_iterator(DBData, Serialized), - next(It#it{data = ItData}, BatchSize); -next(It = #it{}, BatchSize) -> - do_next(It, BatchSize, _Acc = []). - -%%================================================================================ -%% API functions -%%================================================================================ - --spec create_generation( - emqx_ds:shard(), emqx_ds:time(), emqx_ds_conf:backend_config() -) -> - {ok, gen_id()} | {error, nonmonotonic}. -create_generation(ShardId, Since, Config = {_Module, _Options}) -> - gen_server:call(?REF(ShardId), {create_generation, Since, Config}). - --spec delete(emqx_ds:shard(), emqx_guid:guid(), emqx_ds:time(), emqx_ds:topic()) -> - ok | {error, _}. -delete(Shard, GUID, Time, Topic) -> - {_GenId, #{module := Mod, data := Data}} = meta_lookup_gen(Shard, Time), - Mod:delete(Data, GUID, Time, Topic). - --spec make_iterator(emqx_ds:shard(), stream(), emqx_ds:time()) -> - {ok, iterator()} | {error, _TODO}. -make_iterator(Shard, Stream, StartTime) -> - #stream{ topic_filter = TopicFilter - , since = Since - , enc = Enc - } = Stream, - {GenId, Gen} = meta_lookup_gen(Shard, StartTime), - Replay = {TopicFilter, Since}, - case Mod:make_iterator(Data, Replay, Options) of - #it{ gen = GenId, - replay = {TopicFilter, Since} - }. - --spec do_next(iterator(), non_neg_integer(), [binary()]) -> - {ok, iterator(), [binary()]} | end_of_stream. -do_next(It, N, Acc) when N =< 0 -> - {ok, It, lists:reverse(Acc)}; -do_next(It = #it{module = Mod, data = ItData}, N, Acc) -> - case Mod:next(ItData) of - {value, Bin, ItDataNext} -> - Val = deserialize(Bin), - do_next(It#it{data = ItDataNext}, N - 1, [Val | Acc]); - {error, _} = _Error -> - %% todo: log? - %% iterator might be invalid now; will need to re-open it. - Serialized = Mod:preserve_iterator(ItData), - {ok, It#it{data = {?MODULE, retry, Serialized}}, lists:reverse(Acc)}; - none -> - case open_next_iterator(It) of - {ok, ItNext} -> - do_next(ItNext, N, Acc); - {error, _} = _Error -> - %% todo: log? - %% fixme: only bad options may lead to this? - %% return an "empty" iterator to be re-opened when retrying? - Serialized = Mod:preserve_iterator(ItData), - {ok, It#it{data = {?MODULE, retry, Serialized}}, lists:reverse(Acc)}; - none -> - case Acc of - [] -> - end_of_stream; - _ -> - {ok, It#it{data = {?MODULE, end_of_stream}}, lists:reverse(Acc)} - end - end - end. - --spec preserve_iterator(iterator(), emqx_ds:iterator_id()) -> - ok | {error, _TODO}. -preserve_iterator(It = #it{}, IteratorID) -> - iterator_put_state(IteratorID, It). - --spec restore_iterator(emqx_ds:shard(), emqx_ds:replay_id()) -> - {ok, iterator()} | {error, _TODO}. -restore_iterator(Shard, ReplayID) -> - case iterator_get_state(Shard, ReplayID) of - {ok, Serial} -> - restore_iterator_state(Shard, Serial); - not_found -> - {error, not_found}; - {error, _Reason} = Error -> - Error - end. - --spec ensure_iterator(emqx_ds:shard(), emqx_ds:iterator_id(), emqx_ds:replay()) -> - {ok, iterator()} | {error, _TODO}. -ensure_iterator(Shard, IteratorID, Replay = {_TopicFilter, _StartMS}) -> - case restore_iterator(Shard, IteratorID) of - {ok, It} -> - {ok, It}; - {error, not_found} -> - {ok, It} = make_iterator(Shard, Replay), - ok = emqx_ds_storage_layer:preserve_iterator(It, IteratorID), - {ok, It}; - Error -> - Error - end. - --spec discard_iterator(emqx_ds:shard(), emqx_ds:replay_id()) -> - ok | {error, _TODO}. -discard_iterator(Shard, ReplayID) -> - iterator_delete(Shard, ReplayID). - --spec discard_iterator_prefix(emqx_ds:shard(), binary()) -> - ok | {error, _TODO}. -discard_iterator_prefix(Shard, KeyPrefix) -> - case do_discard_iterator_prefix(Shard, KeyPrefix) of - {ok, _} -> ok; - Error -> Error - end. - --spec list_iterator_prefix( - emqx_ds:shard(), - binary() -) -> {ok, [emqx_ds:iterator_id()]} | {error, _TODO}. -list_iterator_prefix(Shard, KeyPrefix) -> - do_list_iterator_prefix(Shard, KeyPrefix). - --spec foldl_iterator_prefix( - emqx_ds:shard(), - binary(), - fun((_Key :: binary(), _Value :: binary(), Acc) -> Acc), - Acc -) -> {ok, Acc} | {error, _TODO} when - Acc :: term(). -foldl_iterator_prefix(Shard, KeyPrefix, Fn, Acc) -> - do_foldl_iterator_prefix(Shard, KeyPrefix, Fn, Acc). - -%%================================================================================ -%% gen_server -%%================================================================================ - --spec start_link(emqx_ds:shard(), emqx_ds_storage_layer:options()) -> - {ok, pid()}. -start_link(Shard, Options) -> - gen_server:start_link(?REF(Shard), ?MODULE, {Shard, Options}, []). - -init({Shard, Options}) -> - process_flag(trap_exit, true), - {ok, S0} = do_open_db(Shard, Options), - S = ensure_current_generation(S0), - ok = populate_metadata(S), - {ok, S}. - -handle_call({create_generation, Since, Config}, _From, S) -> - case create_new_gen(Since, Config, S) of - {ok, GenId, NS} -> - {reply, {ok, GenId}, NS}; - {error, _} = Error -> - {reply, Error, S} - end; -handle_call(_Call, _From, S) -> - {reply, {error, unknown_call}, S}. - -handle_cast(_Cast, S) -> - {noreply, S}. - -handle_info(_Info, S) -> - {noreply, S}. - -terminate(_Reason, #s{db = DB, shard = Shard}) -> - meta_erase(Shard), - ok = rocksdb:close(DB). - -%%================================================================================ -%% Internal functions -%%================================================================================ - --record(db, {handle :: rocksdb:db_handle(), cf_iterator :: rocksdb:cf_handle()}). - --spec populate_metadata(state()) -> ok. -populate_metadata(S = #s{shard = Shard, db = DBHandle, cf_iterator = CFIterator}) -> - ok = meta_put(Shard, db, #db{handle = DBHandle, cf_iterator = CFIterator}), - Current = schema_get_current(DBHandle), - lists:foreach(fun(GenId) -> populate_metadata(GenId, S) end, lists:seq(0, Current)). - --spec populate_metadata(gen_id(), state()) -> ok. -populate_metadata(GenId, S = #s{shard = Shard, db = DBHandle}) -> - Gen = open_gen(GenId, schema_get_gen(DBHandle, GenId), S), - meta_register_gen(Shard, GenId, Gen). - --spec ensure_current_generation(state()) -> state(). -ensure_current_generation(S = #s{shard = _Shard, keyspace = Keyspace, db = DBHandle}) -> - case schema_get_current(DBHandle) of - undefined -> - Config = emqx_ds_conf:keyspace_config(Keyspace), - {ok, _, NS} = create_new_gen(0, Config, S), - NS; - _GenId -> - S - end. - --spec create_new_gen(emqx_ds:time(), emqx_ds_conf:backend_config(), state()) -> - {ok, gen_id(), state()} | {error, nonmonotonic}. -create_new_gen(Since, Config, S = #s{shard = Shard, db = DBHandle}) -> - GenId = get_next_id(meta_get_current(Shard)), - GenId = get_next_id(schema_get_current(DBHandle)), - case is_gen_valid(Shard, GenId, Since) of - ok -> - {ok, Gen, NS} = create_gen(GenId, Since, Config, S), - %% TODO: Transaction? Column family creation can't be transactional, anyway. - ok = schema_put_gen(DBHandle, GenId, Gen), - ok = schema_put_current(DBHandle, GenId), - ok = meta_register_gen(Shard, GenId, open_gen(GenId, Gen, NS)), - {ok, GenId, NS}; - {error, _} = Error -> - Error - end. - --spec create_gen(gen_id(), emqx_ds:time(), emqx_ds_conf:backend_config(), state()) -> - {ok, generation(), state()}. -create_gen(GenId, Since, {Module, Options}, S = #s{db = DBHandle, cf_generations = CFs}) -> - % TODO: Backend implementation should ensure idempotency. - {Schema, NewCFs} = Module:create_new(DBHandle, GenId, Options), - Gen = #{ - module => Module, - data => Schema, - since => Since - }, - {ok, Gen, S#s{cf_generations = NewCFs ++ CFs}}. - --spec do_open_db(emqx_ds:shard(), options()) -> {ok, state()} | {error, _TODO}. -do_open_db(Shard, Options) -> - DefaultDir = binary_to_list(Shard), - DBDir = unicode:characters_to_list(maps:get(dir, Options, DefaultDir)), - %% TODO: properly forward keyspace - Keyspace = maps:get(keyspace, Options, default_keyspace), - DBOptions = [ - {create_if_missing, true}, - {create_missing_column_families, true} - | emqx_ds_conf:db_options(Keyspace) - ], - _ = filelib:ensure_dir(DBDir), - ExistingCFs = - case rocksdb:list_column_families(DBDir, DBOptions) of - {ok, CFs} -> - [{Name, []} || Name <- CFs, Name /= ?DEFAULT_CF, Name /= ?ITERATOR_CF]; - % DB is not present. First start - {error, {db_open, _}} -> - [] - end, - ColumnFamilies = [ - {?DEFAULT_CF, ?DEFAULT_CF_OPTS}, - {?ITERATOR_CF, ?ITERATOR_CF_OPTS} - | ExistingCFs - ], - case rocksdb:open(DBDir, DBOptions, ColumnFamilies) of - {ok, DBHandle, [_CFDefault, CFIterator | CFRefs]} -> - {CFNames, _} = lists:unzip(ExistingCFs), - {ok, #s{ - shard = Shard, - keyspace = Keyspace, - db = DBHandle, - cf_iterator = CFIterator, - cf_generations = lists:zip(CFNames, CFRefs) - }}; - Error -> - Error - end. - --spec open_gen(gen_id(), generation(), state()) -> generation(). -open_gen( - GenId, - Gen = #{module := Mod, data := Data}, - #s{shard = Shard, db = DBHandle, cf_generations = CFs} -) -> - DB = Mod:open(Shard, DBHandle, GenId, CFs, Data), - Gen#{data := DB}. - --spec open_next_iterator(iterator()) -> {ok, iterator()} | {error, _Reason} | none. -open_next_iterator(It = #it{shard = Shard, gen = GenId}) -> - open_next_iterator(meta_get_gen(Shard, GenId + 1), It#it{gen = GenId + 1}). - -open_next_iterator(undefined, _It) -> - none; -open_next_iterator(Gen = #{}, It) -> - open_iterator(Gen, It). - --spec open_restore_iterator(generation(), iterator(), binary()) -> - {ok, iterator()} | {error, _Reason}. -open_restore_iterator(#{module := Mod, data := Data}, It = #it{}, Serial) -> - case Mod:restore_iterator(Data, Serial) of - {ok, ItData} -> - {ok, It#it{module = Mod, data = ItData}}; - Err -> - Err - end. - -%% - --define(KEY_REPLAY_STATE(IteratorId), <<(IteratorId)/binary, "rs">>). --define(KEY_REPLAY_STATE_PAT(KeyReplayState), begin - <> = (KeyReplayState), - IteratorId -end). - --define(ITERATION_WRITE_OPTS, []). --define(ITERATION_READ_OPTS, []). - -iterator_get_state(Shard, ReplayID) -> - #db{handle = Handle, cf_iterator = CF} = meta_lookup(Shard, db), - rocksdb:get(Handle, CF, ?KEY_REPLAY_STATE(ReplayID), ?ITERATION_READ_OPTS). - -iterator_put_state(ID, It = #it{shard = Shard}) -> - #db{handle = Handle, cf_iterator = CF} = meta_lookup(Shard, db), - Serial = preserve_iterator_state(It), - rocksdb:put(Handle, CF, ?KEY_REPLAY_STATE(ID), Serial, ?ITERATION_WRITE_OPTS). - -iterator_delete(Shard, ID) -> - #db{handle = Handle, cf_iterator = CF} = meta_lookup(Shard, db), - rocksdb:delete(Handle, CF, ?KEY_REPLAY_STATE(ID), ?ITERATION_WRITE_OPTS). - -preserve_iterator_state(#it{ - gen = Gen, - replay = {TopicFilter, StartTime}, - module = Mod, - data = ItData -}) -> - term_to_binary(#{ - v => 1, - gen => Gen, - filter => TopicFilter, - start => StartTime, - st => Mod:preserve_iterator(ItData) - }). - -restore_iterator_state(Shard, Serial) when is_binary(Serial) -> - restore_iterator_state(Shard, binary_to_term(Serial)); -restore_iterator_state( - Shard, - #{ - v := 1, - gen := Gen, - filter := TopicFilter, - start := StartTime, - st := State - } -) -> - It = #it{shard = Shard, gen = Gen, replay = {TopicFilter, StartTime}}, - open_restore_iterator(meta_get_gen(Shard, Gen), It, State). - -do_list_iterator_prefix(Shard, KeyPrefix) -> - Fn = fun(K0, _V, Acc) -> - K = ?KEY_REPLAY_STATE_PAT(K0), - [K | Acc] - end, - do_foldl_iterator_prefix(Shard, KeyPrefix, Fn, []). - -do_discard_iterator_prefix(Shard, KeyPrefix) -> - #db{handle = DBHandle, cf_iterator = CF} = meta_lookup(Shard, db), - Fn = fun(K, _V, _Acc) -> ok = rocksdb:delete(DBHandle, CF, K, ?ITERATION_WRITE_OPTS) end, - do_foldl_iterator_prefix(Shard, KeyPrefix, Fn, ok). - -do_foldl_iterator_prefix(Shard, KeyPrefix, Fn, Acc) -> - #db{handle = Handle, cf_iterator = CF} = meta_lookup(Shard, db), - case rocksdb:iterator(Handle, CF, ?ITERATION_READ_OPTS) of - {ok, It} -> - NextAction = {seek, KeyPrefix}, - do_foldl_iterator_prefix(Handle, CF, It, KeyPrefix, NextAction, Fn, Acc); - Error -> - Error - end. - -do_foldl_iterator_prefix(DBHandle, CF, It, KeyPrefix, NextAction, Fn, Acc) -> - case rocksdb:iterator_move(It, NextAction) of - {ok, K = <>, V} -> - NewAcc = Fn(K, V, Acc), - do_foldl_iterator_prefix(DBHandle, CF, It, KeyPrefix, next, Fn, NewAcc); - {ok, _K, _V} -> - ok = rocksdb:iterator_close(It), - {ok, Acc}; - {error, invalid_iterator} -> - ok = rocksdb:iterator_close(It), - {ok, Acc}; - Error -> - ok = rocksdb:iterator_close(It), - Error - end. - -%% Functions for dealing with the metadata stored persistently in rocksdb - --define(CURRENT_GEN, <<"current">>). --define(SCHEMA_WRITE_OPTS, []). --define(SCHEMA_READ_OPTS, []). - --spec schema_get_gen(rocksdb:db_handle(), gen_id()) -> generation(). -schema_get_gen(DBHandle, GenId) -> - {ok, Bin} = rocksdb:get(DBHandle, schema_gen_key(GenId), ?SCHEMA_READ_OPTS), - binary_to_term(Bin). - --spec schema_put_gen(rocksdb:db_handle(), gen_id(), generation()) -> ok | {error, _}. -schema_put_gen(DBHandle, GenId, Gen) -> - rocksdb:put(DBHandle, schema_gen_key(GenId), term_to_binary(Gen), ?SCHEMA_WRITE_OPTS). - --spec schema_get_current(rocksdb:db_handle()) -> gen_id() | undefined. -schema_get_current(DBHandle) -> - case rocksdb:get(DBHandle, ?CURRENT_GEN, ?SCHEMA_READ_OPTS) of - {ok, Bin} -> - binary_to_integer(Bin); - not_found -> - undefined - end. - --spec schema_put_current(rocksdb:db_handle(), gen_id()) -> ok | {error, _}. -schema_put_current(DBHandle, GenId) -> - rocksdb:put(DBHandle, ?CURRENT_GEN, integer_to_binary(GenId), ?SCHEMA_WRITE_OPTS). - --spec schema_gen_key(integer()) -> binary(). -schema_gen_key(N) -> - <<"gen", N:32>>. - --undef(CURRENT_GEN). --undef(SCHEMA_WRITE_OPTS). --undef(SCHEMA_READ_OPTS). - -%% Functions for dealing with the runtime shard metadata: - --define(PERSISTENT_TERM(SHARD, GEN), {emqx_ds_storage_layer, SHARD, GEN}). - --spec meta_register_gen(emqx_ds:shard(), gen_id(), generation()) -> ok. -meta_register_gen(Shard, GenId, Gen) -> - Gs = - case GenId > 0 of - true -> meta_lookup(Shard, GenId - 1); - false -> [] - end, - ok = meta_put(Shard, GenId, [Gen | Gs]), - ok = meta_put(Shard, current, GenId). - --spec meta_lookup_gen(emqx_ds:shard(), emqx_ds:time()) -> {gen_id(), generation()}. -meta_lookup_gen(Shard, Time) -> - %% TODO - %% Is cheaper persistent term GC on update here worth extra lookup? I'm leaning - %% towards a "no". - Current = meta_lookup(Shard, current), - Gens = meta_lookup(Shard, Current), - find_gen(Time, Current, Gens). - -find_gen(Time, GenId, [Gen = #{since := Since} | _]) when Time >= Since -> - {GenId, Gen}; -find_gen(Time, GenId, [_Gen | Rest]) -> - find_gen(Time, GenId - 1, Rest). - --spec meta_get_gen(emqx_ds:shard(), gen_id()) -> generation() | undefined. -meta_get_gen(Shard, GenId) -> - case meta_lookup(Shard, GenId, []) of - [Gen | _Older] -> Gen; - [] -> undefined - end. - --spec meta_get_current(emqx_ds:shard()) -> gen_id() | undefined. -meta_get_current(Shard) -> - meta_lookup(Shard, current, undefined). - --spec meta_lookup(emqx_ds:shard(), _K) -> _V. -meta_lookup(Shard, Key) -> - persistent_term:get(?PERSISTENT_TERM(Shard, Key)). - --spec meta_lookup(emqx_ds:shard(), _K, Default) -> _V | Default. -meta_lookup(Shard, K, Default) -> - persistent_term:get(?PERSISTENT_TERM(Shard, K), Default). - --spec meta_put(emqx_ds:shard(), _K, _V) -> ok. -meta_put(Shard, K, V) -> - persistent_term:put(?PERSISTENT_TERM(Shard, K), V). - --spec meta_erase(emqx_ds:shard()) -> ok. -meta_erase(Shard) -> - [ - persistent_term:erase(K) - || {K = ?PERSISTENT_TERM(Z, _), _} <- persistent_term:get(), Z =:= Shard - ], - ok. - --undef(PERSISTENT_TERM). - -get_next_id(undefined) -> 0; -get_next_id(GenId) -> GenId + 1. - -is_gen_valid(Shard, GenId, Since) when GenId > 0 -> - [GenPrev | _] = meta_lookup(Shard, GenId - 1), - case GenPrev of - #{since := SincePrev} when Since > SincePrev -> - ok; - #{} -> - {error, nonmonotonic} - end; -is_gen_valid(_Shard, 0, 0) -> - ok. - -serialize(Msg) -> - %% TODO: remove topic, GUID, etc. from the stored - %% message. Reconstruct it from the metadata. - term_to_binary(emqx_message:to_map(Msg)). - -deserialize(Bin) -> - emqx_message:from_map(binary_to_term(Bin)). - - -%% -spec store_cfs(rocksdb:db_handle(), [{string(), rocksdb:cf_handle()}]) -> ok. -%% store_cfs(DBHandle, CFRefs) -> -%% lists:foreach( -%% fun({CFName, CFRef}) -> -%% persistent_term:put({self(), CFName}, {DBHandle, CFRef}) -%% end, -%% CFRefs). diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer_bitmask.erl_ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer_bitmask.erl_ deleted file mode 100644 index bdf5a1453..000000000 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer_bitmask.erl_ +++ /dev/null @@ -1,748 +0,0 @@ -%%-------------------------------------------------------------------- -%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved. -%%-------------------------------------------------------------------- - --module(emqx_ds_message_storage_bitmask). - -%%================================================================================ -%% @doc Description of the schema -%% -%% Let us assume that `T' is a topic and `t' is time. These are the two -%% dimensions used to index messages. They can be viewed as -%% "coordinates" of an MQTT message in a 2D space. -%% -%% Oftentimes, when wildcard subscription is used, keys must be -%% scanned in both dimensions simultaneously. -%% -%% Rocksdb allows to iterate over sorted keys very fast. This means we -%% need to map our two-dimentional keys to a single index that is -%% sorted in a way that helps to iterate over both time and topic -%% without having to do a lot of random seeks. -%% -%% == Mapping of 2D keys to rocksdb keys == -%% -%% We use "zigzag" pattern to store messages, where rocksdb key is -%% composed like like this: -%% -%% |ttttt|TTTTTTTTT|tttt| -%% ^ ^ ^ -%% | | | -%% +-------+ | +---------+ -%% | | | -%% most significant topic hash least significant -%% bits of timestamp bits of timestamp -%% (a.k.a epoch) (a.k.a time offset) -%% -%% Topic hash is level-aware: each topic level is hashed separately -%% and the resulting hashes are bitwise-concatentated. This allows us -%% to map topics to fixed-length bitstrings while keeping some degree -%% of information about the hierarchy. -%% -%% Next important concept is what we call "epoch". Duration of the -%% epoch is determined by maximum time offset. Epoch is calculated by -%% shifting bits of the timestamp right. -%% -%% The resulting index is a space-filling curve that looks like -%% this in the topic-time 2D space: -%% -%% T ^ ---->------ |---->------ |---->------ -%% | --/ / --/ / --/ -%% | -<-/ | -<-/ | -<-/ -%% | -/ | -/ | -/ -%% | ---->------ | ---->------ | ---->------ -%% | --/ / --/ / --/ -%% | ---/ | ---/ | ---/ -%% | -/ ^ -/ ^ -/ -%% | ---->------ | ---->------ | ---->------ -%% | --/ / --/ / --/ -%% | -<-/ | -<-/ | -<-/ -%% | -/ | -/ | -/ -%% | ---->------| ---->------| ----------> -%% | -%% -+------------+-----------------------------> t -%% epoch -%% -%% This structure allows to quickly seek to a the first message that -%% was recorded in a certain epoch in a certain topic or a -%% group of topics matching filter like `foo/bar/#`. -%% -%% Due to its structure, for each pair of rocksdb keys K1 and K2, such -%% that K1 > K2 and topic(K1) = topic(K2), timestamp(K1) > -%% timestamp(K2). -%% That is, replay doesn't reorder messages published in each -%% individual topic. -%% -%% This property doesn't hold between different topics, but it's not deemed -%% a problem right now. -%% -%%================================================================================ - --behaviour(emqx_ds_storage_layer). - -%% API: --export([create_new/3, open/5]). --export([make_keymapper/1]). - --export([store/5, delete/4]). - --export([get_streams/3, make_iterator/3, next/1]). - --export([preserve_iterator/1, restore_iterator/2, refresh_iterator/1]). - -%% Debug/troubleshooting: -%% Keymappers --export([ - keymapper_info/1, - compute_bitstring/3, - compute_topic_bitmask/2, - compute_time_bitmask/1, - hash/2 -]). - -%% Keyspace filters --export([ - make_keyspace_filter/2, - compute_initial_seek/1, - compute_next_seek/2, - compute_time_seek/3, - compute_topic_seek/4 -]). - --export_type([db/0, stream/0, iterator/0, schema/0]). - --export_type([options/0]). --export_type([iteration_options/0]). - --compile( - {inline, [ - bitwise_concat/3, - ones/1, - successor/1, - topic_hash_matches/3, - time_matches/3 - ]} -). - -%%================================================================================ -%% Type declarations -%%================================================================================ - --opaque stream() :: emqx_ds:topic_filter(). - --type topic() :: emqx_ds:topic(). --type topic_filter() :: emqx_ds:topic_filter(). --type time() :: emqx_ds:time(). - -%% Number of bits --type bits() :: non_neg_integer(). - -%% Key of a RocksDB record. --type key() :: binary(). - -%% Distribution of entropy among topic levels. -%% Example: [4, 8, 16] means that level 1 gets 4 bits, level 2 gets 8 bits, -%% and _rest of levels_ (if any) get 16 bits. --type bits_per_level() :: [bits(), ...]. - --type options() :: #{ - %% Number of bits in a message timestamp. - timestamp_bits := bits(), - %% Number of bits in a key allocated to each level in a message topic. - topic_bits_per_level := bits_per_level(), - %% Maximum granularity of iteration over time. - epoch := time(), - - iteration => iteration_options(), - - cf_options => emqx_ds_storage_layer:db_cf_options() -}. - --type iteration_options() :: #{ - %% Request periodic iterator refresh. - %% This might be helpful during replays taking a lot of time (e.g. tens of seconds). - %% Note that `{every, 1000}` means 1000 _operations_ with the iterator which is not - %% the same as 1000 replayed messages. - iterator_refresh => {every, _NumOperations :: pos_integer()} -}. - -%% Persistent configuration of the generation, it is used to create db -%% record when the database is reopened --record(schema, {keymapper :: keymapper()}). - --opaque schema() :: #schema{}. - --record(db, { - shard :: emqx_ds:shard(), - handle :: rocksdb:db_handle(), - cf :: rocksdb:cf_handle(), - keymapper :: keymapper(), - write_options = [{sync, true}] :: emqx_ds_storage_layer:db_write_options(), - read_options = [] :: emqx_ds_storage_layer:db_read_options() -}). - --record(it, { - handle :: rocksdb:itr_handle(), - filter :: keyspace_filter(), - cursor :: binary() | undefined, - next_action :: {seek, binary()} | next, - refresh_counter :: {non_neg_integer(), pos_integer()} | undefined -}). - --record(filter, { - keymapper :: keymapper(), - topic_filter :: topic_filter(), - start_time :: integer(), - hash_bitfilter :: integer(), - hash_bitmask :: integer(), - time_bitfilter :: integer(), - time_bitmask :: integer() -}). - -% NOTE -% Keymapper decides how to map messages into RocksDB column family keyspace. --record(keymapper, { - source :: [bitsource(), ...], - bitsize :: bits(), - epoch :: non_neg_integer() -}). - --type bitsource() :: - %% Consume `_Size` bits from timestamp starting at `_Offset`th bit. - %% TODO consistency - {timestamp, _Offset :: bits(), _Size :: bits()} - %% Consume next topic level (either one or all of them) and compute `_Size` bits-wide hash. - | {hash, level | levels, _Size :: bits()}. - --opaque db() :: #db{}. --opaque iterator() :: #it{}. --type serialized_iterator() :: binary(). --type keymapper() :: #keymapper{}. --type keyspace_filter() :: #filter{}. - -%%================================================================================ -%% API funcions -%%================================================================================ - -%% Create a new column family for the generation and a serializable representation of the schema --spec create_new(rocksdb:db_handle(), emqx_ds_storage_layer:gen_id(), options()) -> - {schema(), emqx_ds_storage_layer:cf_refs()}. -create_new(DBHandle, GenId, Options) -> - CFName = data_cf(GenId), - CFOptions = maps:get(cf_options, Options, []), - {ok, CFHandle} = rocksdb:create_column_family(DBHandle, CFName, CFOptions), - Schema = #schema{keymapper = make_keymapper(Options)}, - {Schema, [{CFName, CFHandle}]}. - -%% Reopen the database --spec open( - emqx_ds:shard(), - rocksdb:db_handle(), - emqx_ds_storage_layer:gen_id(), - emqx_ds_storage_layer:cf_refs(), - schema() -) -> - db(). -open(Shard, DBHandle, GenId, CFs, #schema{keymapper = Keymapper}) -> - {value, {_, CFHandle}} = lists:keysearch(data_cf(GenId), 1, CFs), - #db{ - shard = Shard, - handle = DBHandle, - cf = CFHandle, - keymapper = Keymapper - }. - --spec make_keymapper(options()) -> keymapper(). -make_keymapper(#{ - timestamp_bits := TimestampBits, - topic_bits_per_level := BitsPerLevel, - epoch := MaxEpoch -}) -> - TimestampLSBs = min(TimestampBits, floor(math:log2(MaxEpoch))), - TimestampMSBs = TimestampBits - TimestampLSBs, - NLevels = length(BitsPerLevel), - {LevelBits, [TailLevelsBits]} = lists:split(NLevels - 1, BitsPerLevel), - Source = lists:flatten([ - [{timestamp, TimestampLSBs, TimestampMSBs} || TimestampMSBs > 0], - [{hash, level, Bits} || Bits <- LevelBits], - {hash, levels, TailLevelsBits}, - [{timestamp, 0, TimestampLSBs} || TimestampLSBs > 0] - ]), - #keymapper{ - source = Source, - bitsize = lists:sum([S || {_, _, S} <- Source]), - epoch = 1 bsl TimestampLSBs - }. - --spec store(db(), emqx_guid:guid(), emqx_ds:time(), topic(), binary()) -> - ok | {error, _TODO}. -store(DB = #db{handle = DBHandle, cf = CFHandle}, MessageID, PublishedAt, Topic, MessagePayload) -> - Key = make_message_key(Topic, PublishedAt, MessageID, DB#db.keymapper), - Value = make_message_value(Topic, MessagePayload), - rocksdb:put(DBHandle, CFHandle, Key, Value, DB#db.write_options). - --spec delete(db(), emqx_guid:guid(), emqx_ds:time(), topic()) -> - ok | {error, _TODO}. -delete(DB = #db{handle = DBHandle, cf = CFHandle}, MessageID, PublishedAt, Topic) -> - Key = make_message_key(Topic, PublishedAt, MessageID, DB#db.keymapper), - rocksdb:delete(DBHandle, CFHandle, Key, DB#db.write_options). - --spec get_streams(db(), emqx_ds:topic_filter(), emqx_ds:time()) -> - [stream()]. -get_streams(_, TopicFilter, _) -> - [{0, TopicFilter}]. - --spec make_iterator(db(), emqx_ds:replay(), iteration_options()) -> - % {error, invalid_start_time}? might just start from the beginning of time - % and call it a day: client violated the contract anyway. - {ok, iterator()} | {error, _TODO}. -make_iterator(DB = #db{handle = DBHandle, cf = CFHandle}, Replay, Options) -> - case rocksdb:iterator(DBHandle, CFHandle, DB#db.read_options) of - {ok, ITHandle} -> - Filter = make_keyspace_filter(Replay, DB#db.keymapper), - InitialSeek = combine(compute_initial_seek(Filter), <<>>, DB#db.keymapper), - RefreshCounter = make_refresh_counter(maps:get(iterator_refresh, Options, undefined)), - {ok, #it{ - handle = ITHandle, - filter = Filter, - next_action = {seek, InitialSeek}, - refresh_counter = RefreshCounter - }}; - Err -> - Err - end. - --spec next(iterator()) -> {value, binary(), iterator()} | none | {error, closed}. -next(It0 = #it{filter = #filter{keymapper = Keymapper}}) -> - It = maybe_refresh_iterator(It0), - case rocksdb:iterator_move(It#it.handle, It#it.next_action) of - % spec says `{ok, Key}` is also possible but the implementation says it's not - {ok, Key, Value} -> - % Preserve last seen key in the iterator so it could be restored / refreshed later. - ItNext = It#it{cursor = Key}, - Bitstring = extract(Key, Keymapper), - case match_next(Bitstring, Value, It#it.filter) of - {_Topic, Payload} -> - {value, Payload, ItNext#it{next_action = next}}; - next -> - next(ItNext#it{next_action = next}); - NextBitstring when is_integer(NextBitstring) -> - NextSeek = combine(NextBitstring, <<>>, Keymapper), - next(ItNext#it{next_action = {seek, NextSeek}}); - none -> - stop_iteration(ItNext) - end; - {error, invalid_iterator} -> - stop_iteration(It); - {error, iterator_closed} -> - {error, closed} - end. - --spec preserve_iterator(iterator()) -> serialized_iterator(). -preserve_iterator(#it{ - cursor = Cursor, - filter = #filter{ - topic_filter = TopicFilter, - start_time = StartTime - } -}) -> - State = #{ - v => 1, - cursor => Cursor, - replay => {TopicFilter, StartTime} - }, - term_to_binary(State). - --spec restore_iterator(db(), serialized_iterator()) -> - {ok, iterator()} | {error, _TODO}. -restore_iterator(DB, Serial) when is_binary(Serial) -> - State = binary_to_term(Serial), - restore_iterator(DB, State); -restore_iterator(DB, #{ - v := 1, - cursor := Cursor, - replay := Replay = {_TopicFilter, _StartTime} -}) -> - Options = #{}, % TODO: passthrough options - case make_iterator(DB, Replay, Options) of - {ok, It} when Cursor == undefined -> - % Iterator was preserved right after it has been made. - {ok, It}; - {ok, It} -> - % Iterator was preserved mid-replay, seek right past the last seen key. - {ok, It#it{cursor = Cursor, next_action = {seek, successor(Cursor)}}}; - Err -> - Err - end. - --spec refresh_iterator(iterator()) -> iterator(). -refresh_iterator(It = #it{handle = Handle, cursor = Cursor, next_action = Action}) -> - case rocksdb:iterator_refresh(Handle) of - ok when Action =:= next -> - % Now the underlying iterator is invalid, need to seek instead. - It#it{next_action = {seek, successor(Cursor)}}; - ok -> - % Now the underlying iterator is invalid, but will seek soon anyway. - It; - {error, _} -> - % Implementation could in theory return an {error, ...} tuple. - % Supposedly our best bet is to ignore it. - % TODO logging? - It - end. - -%%================================================================================ -%% Internal exports -%%================================================================================ - --spec keymapper_info(keymapper()) -> - #{source := [bitsource()], bitsize := bits(), epoch := time()}. -keymapper_info(#keymapper{source = Source, bitsize = Bitsize, epoch = Epoch}) -> - #{source => Source, bitsize => Bitsize, epoch => Epoch}. - -make_message_key(Topic, PublishedAt, MessageID, Keymapper) -> - combine(compute_bitstring(Topic, PublishedAt, Keymapper), MessageID, Keymapper). - -make_message_value(Topic, MessagePayload) -> - term_to_binary({Topic, MessagePayload}). - -unwrap_message_value(Binary) -> - binary_to_term(Binary). - --spec combine(_Bitstring :: integer(), emqx_guid:guid() | <<>>, keymapper()) -> - key(). -combine(Bitstring, MessageID, #keymapper{bitsize = Size}) -> - <>. - --spec extract(key(), keymapper()) -> - _Bitstring :: integer(). -extract(Key, #keymapper{bitsize = Size}) -> - <> = Key, - Bitstring. - --spec compute_bitstring(topic_filter(), time(), keymapper()) -> integer(). -compute_bitstring(TopicFilter, Timestamp, #keymapper{source = Source}) -> - compute_bitstring(TopicFilter, Timestamp, Source, 0). - --spec compute_topic_bitmask(topic_filter(), keymapper()) -> integer(). -compute_topic_bitmask(TopicFilter, #keymapper{source = Source}) -> - compute_topic_bitmask(TopicFilter, Source, 0). - --spec compute_time_bitmask(keymapper()) -> integer(). -compute_time_bitmask(#keymapper{source = Source}) -> - compute_time_bitmask(Source, 0). - --spec hash(term(), bits()) -> integer(). -hash(Input, Bits) -> - % at most 32 bits - erlang:phash2(Input, 1 bsl Bits). - --spec make_keyspace_filter(emqx_ds:replay(), keymapper()) -> keyspace_filter(). -make_keyspace_filter({TopicFilter, StartTime}, Keymapper) -> - Bitstring = compute_bitstring(TopicFilter, StartTime, Keymapper), - HashBitmask = compute_topic_bitmask(TopicFilter, Keymapper), - TimeBitmask = compute_time_bitmask(Keymapper), - HashBitfilter = Bitstring band HashBitmask, - TimeBitfilter = Bitstring band TimeBitmask, - #filter{ - keymapper = Keymapper, - topic_filter = TopicFilter, - start_time = StartTime, - hash_bitfilter = HashBitfilter, - hash_bitmask = HashBitmask, - time_bitfilter = TimeBitfilter, - time_bitmask = TimeBitmask - }. - --spec compute_initial_seek(keyspace_filter()) -> integer(). -compute_initial_seek(#filter{hash_bitfilter = HashBitfilter, time_bitfilter = TimeBitfilter}) -> - % Should be the same as `compute_initial_seek(0, Filter)`. - HashBitfilter bor TimeBitfilter. - --spec compute_next_seek(integer(), keyspace_filter()) -> integer(). -compute_next_seek( - Bitstring, - Filter = #filter{ - hash_bitfilter = HashBitfilter, - hash_bitmask = HashBitmask, - time_bitfilter = TimeBitfilter, - time_bitmask = TimeBitmask - } -) -> - HashMatches = topic_hash_matches(Bitstring, HashBitfilter, HashBitmask), - TimeMatches = time_matches(Bitstring, TimeBitfilter, TimeBitmask), - compute_next_seek(HashMatches, TimeMatches, Bitstring, Filter). - -%%================================================================================ -%% Internal functions -%%================================================================================ - -compute_bitstring(Topic, Timestamp, [{timestamp, Offset, Size} | Rest], Acc) -> - I = (Timestamp bsr Offset) band ones(Size), - compute_bitstring(Topic, Timestamp, Rest, bitwise_concat(Acc, I, Size)); -compute_bitstring([], Timestamp, [{hash, level, Size} | Rest], Acc) -> - I = hash(<<"/">>, Size), - compute_bitstring([], Timestamp, Rest, bitwise_concat(Acc, I, Size)); -compute_bitstring([Level | Tail], Timestamp, [{hash, level, Size} | Rest], Acc) -> - I = hash(Level, Size), - compute_bitstring(Tail, Timestamp, Rest, bitwise_concat(Acc, I, Size)); -compute_bitstring(Tail, Timestamp, [{hash, levels, Size} | Rest], Acc) -> - I = hash(Tail, Size), - compute_bitstring(Tail, Timestamp, Rest, bitwise_concat(Acc, I, Size)); -compute_bitstring(_, _, [], Acc) -> - Acc. - -compute_topic_bitmask(Filter, [{timestamp, _, Size} | Rest], Acc) -> - compute_topic_bitmask(Filter, Rest, bitwise_concat(Acc, 0, Size)); -compute_topic_bitmask(['#'], [{hash, _, Size} | Rest], Acc) -> - compute_topic_bitmask(['#'], Rest, bitwise_concat(Acc, 0, Size)); -compute_topic_bitmask(['+' | Tail], [{hash, _, Size} | Rest], Acc) -> - compute_topic_bitmask(Tail, Rest, bitwise_concat(Acc, 0, Size)); -compute_topic_bitmask([], [{hash, level, Size} | Rest], Acc) -> - compute_topic_bitmask([], Rest, bitwise_concat(Acc, ones(Size), Size)); -compute_topic_bitmask([_ | Tail], [{hash, level, Size} | Rest], Acc) -> - compute_topic_bitmask(Tail, Rest, bitwise_concat(Acc, ones(Size), Size)); -compute_topic_bitmask(Tail, [{hash, levels, Size} | Rest], Acc) -> - Mask = - case lists:member('+', Tail) orelse lists:member('#', Tail) of - true -> 0; - false -> ones(Size) - end, - compute_topic_bitmask([], Rest, bitwise_concat(Acc, Mask, Size)); -compute_topic_bitmask(_, [], Acc) -> - Acc. - -compute_time_bitmask([{timestamp, _, Size} | Rest], Acc) -> - compute_time_bitmask(Rest, bitwise_concat(Acc, ones(Size), Size)); -compute_time_bitmask([{hash, _, Size} | Rest], Acc) -> - compute_time_bitmask(Rest, bitwise_concat(Acc, 0, Size)); -compute_time_bitmask([], Acc) -> - Acc. - -bitwise_concat(Acc, Item, ItemSize) -> - (Acc bsl ItemSize) bor Item. - -ones(Bits) -> - 1 bsl Bits - 1. - --spec successor(key()) -> key(). -successor(Key) -> - <>. - -%% |123|345|678| -%% foo bar baz - -%% |123|000|678| - |123|fff|678| - -%% foo + baz - -%% |fff|000|fff| - -%% |123|000|678| - -%% |123|056|678| & |fff|000|fff| = |123|000|678|. - -match_next( - Bitstring, - Value, - Filter = #filter{ - topic_filter = TopicFilter, - hash_bitfilter = HashBitfilter, - hash_bitmask = HashBitmask, - time_bitfilter = TimeBitfilter, - time_bitmask = TimeBitmask - } -) -> - HashMatches = topic_hash_matches(Bitstring, HashBitfilter, HashBitmask), - TimeMatches = time_matches(Bitstring, TimeBitfilter, TimeBitmask), - case HashMatches and TimeMatches of - true -> - Message = {Topic, _Payload} = unwrap_message_value(Value), - case emqx_topic:match(Topic, TopicFilter) of - true -> - Message; - false -> - next - end; - false -> - compute_next_seek(HashMatches, TimeMatches, Bitstring, Filter) - end. - -%% `Bitstring` is out of the hash space defined by `HashBitfilter`. -compute_next_seek( - _HashMatches = false, - _TimeMatches, - Bitstring, - Filter = #filter{ - keymapper = Keymapper, - hash_bitfilter = HashBitfilter, - hash_bitmask = HashBitmask, - time_bitfilter = TimeBitfilter, - time_bitmask = TimeBitmask - } -) -> - NextBitstring = compute_topic_seek(Bitstring, HashBitfilter, HashBitmask, Keymapper), - case NextBitstring of - none -> - none; - _ -> - TimeMatches = time_matches(NextBitstring, TimeBitfilter, TimeBitmask), - compute_next_seek(true, TimeMatches, NextBitstring, Filter) - end; -%% `Bitstring` is out of the time range defined by `TimeBitfilter`. -compute_next_seek( - _HashMatches = true, - _TimeMatches = false, - Bitstring, - #filter{ - time_bitfilter = TimeBitfilter, - time_bitmask = TimeBitmask - } -) -> - compute_time_seek(Bitstring, TimeBitfilter, TimeBitmask); -compute_next_seek(true, true, Bitstring, _It) -> - Bitstring. - -topic_hash_matches(Bitstring, HashBitfilter, HashBitmask) -> - (Bitstring band HashBitmask) == HashBitfilter. - -time_matches(Bitstring, TimeBitfilter, TimeBitmask) -> - (Bitstring band TimeBitmask) >= TimeBitfilter. - -compute_time_seek(Bitstring, TimeBitfilter, TimeBitmask) -> - % Replace the bits of the timestamp in `Bistring` with bits from `Timebitfilter`. - (Bitstring band (bnot TimeBitmask)) bor TimeBitfilter. - -%% Find the closest bitstring which is: -%% * greater than `Bitstring`, -%% * and falls into the hash space defined by `HashBitfilter`. -%% Note that the result can end up "back" in time and out of the time range. -compute_topic_seek(Bitstring, HashBitfilter, HashBitmask, Keymapper) -> - Sources = Keymapper#keymapper.source, - Size = Keymapper#keymapper.bitsize, - compute_topic_seek(Bitstring, HashBitfilter, HashBitmask, Sources, Size). - -compute_topic_seek(Bitstring, HashBitfilter, HashBitmask, Sources, Size) -> - % NOTE - % We're iterating through `Substring` here, in lockstep with `HashBitfilter` - % and `HashBitmask`, starting from least signigicant bits. Each bitsource in - % `Sources` has a bitsize `S` and, accordingly, gives us a sub-bitstring `S` - % bits long which we interpret as a "digit". There are 2 flavors of those - % "digits": - % * regular digit with 2^S possible values, - % * degenerate digit with exactly 1 possible value U (represented with 0). - % Our goal here is to find a successor of `Bistring` and perform a kind of - % digit-by-digit addition operation with carry propagation. - NextSeek = zipfoldr3( - fun(Source, Substring, Filter, LBitmask, Offset, Acc) -> - case Source of - {hash, _, S} when LBitmask =:= 0 -> - % Regular case - bitwise_add_digit(Substring, Acc, S, Offset); - {hash, _, _} when LBitmask =/= 0, Substring < Filter -> - % Degenerate case, I_digit < U, no overflow. - % Successor is `U bsl Offset` which is equivalent to 0. - 0; - {hash, _, S} when LBitmask =/= 0, Substring > Filter -> - % Degenerate case, I_digit > U, overflow. - % Successor is `(1 bsl Size + U) bsl Offset`. - overflow_digit(S, Offset); - {hash, _, S} when LBitmask =/= 0 -> - % Degenerate case, I_digit = U - % Perform digit addition with I_digit = 0, assuming "digit" has - % 0 bits of information (but is `S` bits long at the same time). - % This will overflow only if the result of previous iteration - % was an overflow. - bitwise_add_digit(0, Acc, 0, S, Offset); - {timestamp, _, S} -> - % Regular case - bitwise_add_digit(Substring, Acc, S, Offset) - end - end, - 0, - Bitstring, - HashBitfilter, - HashBitmask, - Size, - Sources - ), - case NextSeek bsr Size of - _Carry = 0 -> - % Found the successor. - % We need to recover values of those degenerate digits which we - % represented with 0 during digit-by-digit iteration. - NextSeek bor (HashBitfilter band HashBitmask); - _Carry = 1 -> - % We got "carried away" past the range, time to stop iteration. - none - end. - -bitwise_add_digit(Digit, Number, Width, Offset) -> - bitwise_add_digit(Digit, Number, Width, Width, Offset). - -%% Add "digit" (represented with integer `Digit`) to the `Number` assuming -%% this digit starts at `Offset` bits in `Number` and is `Width` bits long. -%% Perform an overflow if the result of addition would not fit into `Bits` -%% bits. -bitwise_add_digit(Digit, Number, Bits, Width, Offset) -> - Sum = (Digit bsl Offset) + Number, - case (Sum bsr Offset) < (1 bsl Bits) of - true -> Sum; - false -> overflow_digit(Width, Offset) - end. - -%% Constuct a number which denotes an overflow of digit that starts at -%% `Offset` bits and is `Width` bits long. -overflow_digit(Width, Offset) -> - (1 bsl Width) bsl Offset. - -%% Iterate through sub-bitstrings of 3 integers in lockstep, starting from least -%% significant bits first. -%% -%% Each integer is assumed to be `Size` bits long. Lengths of sub-bitstring are -%% specified in `Sources` list, in order from most significant bits to least -%% significant. Each iteration calls `FoldFun` with: -%% * bitsource that was used to extract sub-bitstrings, -%% * 3 sub-bitstrings in integer representation, -%% * bit offset into integers, -%% * current accumulator. --spec zipfoldr3(FoldFun, Acc, integer(), integer(), integer(), _Size :: bits(), [bitsource()]) -> - Acc -when - FoldFun :: fun((bitsource(), integer(), integer(), integer(), _Offset :: bits(), Acc) -> Acc). -zipfoldr3(_FoldFun, Acc, _, _, _, 0, []) -> - Acc; -zipfoldr3(FoldFun, Acc, I1, I2, I3, Offset, [Source = {_, _, S} | Rest]) -> - OffsetNext = Offset - S, - AccNext = zipfoldr3(FoldFun, Acc, I1, I2, I3, OffsetNext, Rest), - FoldFun( - Source, - substring(I1, OffsetNext, S), - substring(I2, OffsetNext, S), - substring(I3, OffsetNext, S), - OffsetNext, - AccNext - ). - -substring(I, Offset, Size) -> - (I bsr Offset) band ones(Size). - -%% @doc Generate a column family ID for the MQTT messages --spec data_cf(emqx_ds_storage_layer:gen_id()) -> [char()]. -data_cf(GenId) -> - ?MODULE_STRING ++ integer_to_list(GenId). - -make_refresh_counter({every, N}) when is_integer(N), N > 0 -> - {0, N}; -make_refresh_counter(undefined) -> - undefined. - -maybe_refresh_iterator(It = #it{refresh_counter = {N, N}}) -> - refresh_iterator(It#it{refresh_counter = {0, N}}); -maybe_refresh_iterator(It = #it{refresh_counter = {M, N}}) -> - It#it{refresh_counter = {M + 1, N}}; -maybe_refresh_iterator(It = #it{refresh_counter = undefined}) -> - It. - -stop_iteration(It) -> - ok = rocksdb:iterator_close(It#it.handle), - none. diff --git a/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl b/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl index 957383f30..ac037e861 100644 --- a/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl +++ b/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl @@ -129,7 +129,7 @@ t_get_streams(_Config) -> t_replay(_Config) -> %% Create concrete topics: Topics = [<<"foo/bar">>, <<"foo/bar/baz">>], - Timestamps = lists:seq(1, 10), + Timestamps = lists:seq(1, 10_000, 100), Batch1 = [ make_message(PublishedAt, Topic, integer_to_binary(PublishedAt)) || Topic <- Topics, PublishedAt <- Timestamps @@ -140,10 +140,10 @@ t_replay(_Config) -> begin B = integer_to_binary(I), make_message( - TS, <<"wildcard/", B/binary, "/suffix/", Suffix/binary>>, integer_to_binary(TS) + TS, <<"wildcard/", B/binary, "/suffix/", Suffix/binary>>, integer_to_binary(TS) ) end - || I <- lists:seq(1, 200), TS <- lists:seq(1, 10), Suffix <- [<<"foo">>, <<"bar">>] + || I <- lists:seq(1, 200), TS <- Timestamps, Suffix <- [<<"foo">>, <<"bar">>] ], ok = emqx_ds_storage_layer:store_batch(?SHARD, Batch2, []), %% Check various topic filters: @@ -158,6 +158,9 @@ t_replay(_Config) -> ?assert(check(?SHARD, <<"foo/+/+">>, 0, Messages)), ?assert(check(?SHARD, <<"+/+/+">>, 0, Messages)), ?assert(check(?SHARD, <<"+/+/baz">>, 0, Messages)), + %% Restart shard to make sure trie is persisted and restored: + ok = emqx_ds_storage_layer_sup:stop_shard(?SHARD), + {ok, _} = emqx_ds_storage_layer_sup:start_shard(?SHARD, #{}), %% Learned wildcard topics: ?assertNot(check(?SHARD, <<"wildcard/1000/suffix/foo">>, 0, [])), ?assert(check(?SHARD, <<"wildcard/1/suffix/foo">>, 0, Messages)), @@ -179,23 +182,24 @@ check(Shard, TopicFilter, StartTime, ExpectedMessages) -> ExpectedMessages ), ?check_trace( - #{timetrap => 10_000}, - begin - Dump = dump_messages(Shard, TopicFilter, StartTime), - verify_dump(TopicFilter, StartTime, Dump), - Missing = ExpectedFiltered -- Dump, - Extras = Dump -- ExpectedFiltered, - ?assertMatch( - #{missing := [], unexpected := []}, - #{ - missing => Missing, - unexpected => Extras, - topic_filter => TopicFilter, - start_time => StartTime - } - ) - end, - []), + #{timetrap => 10_000}, + begin + Dump = dump_messages(Shard, TopicFilter, StartTime), + verify_dump(TopicFilter, StartTime, Dump), + Missing = ExpectedFiltered -- Dump, + Extras = Dump -- ExpectedFiltered, + ?assertMatch( + #{missing := [], unexpected := []}, + #{ + missing => Missing, + unexpected => Extras, + topic_filter => TopicFilter, + start_time => StartTime + } + ) + end, + [] + ), length(ExpectedFiltered) > 0. verify_dump(TopicFilter, StartTime, Dump) -> @@ -227,78 +231,26 @@ dump_messages(Shard, TopicFilter, StartTime) -> ). dump_stream(Shard, Stream, TopicFilter, StartTime) -> - BatchSize = 3, + BatchSize = 100, {ok, Iterator} = emqx_ds_storage_layer:make_iterator( Shard, Stream, parse_topic(TopicFilter), StartTime ), - Loop = fun F(It, 0) -> - error({too_many_iterations, It}); - F(It, N) -> - case emqx_ds_storage_layer:next(Shard, It, BatchSize) of - end_of_stream -> - []; - {ok, _NextIt, []} -> - []; - {ok, NextIt, Batch} -> - Batch ++ F(NextIt, N - 1) - end + Loop = fun + F(It, 0) -> + error({too_many_iterations, It}); + F(It, N) -> + case emqx_ds_storage_layer:next(Shard, It, BatchSize) of + end_of_stream -> + []; + {ok, _NextIt, []} -> + []; + {ok, NextIt, Batch} -> + Batch ++ F(NextIt, N - 1) + end end, - MaxIterations = 1000, + MaxIterations = 1000000, Loop(Iterator, MaxIterations). -%% Smoke test for iteration with wildcard topic filter -%% t_iterate_wildcard(_Config) -> -%% %% Prepare data: -%% Topics = ["foo/bar", "foo/bar/baz", "a", "a/bar"], -%% Timestamps = lists:seq(1, 10), -%% _ = [ -%% store(?SHARD, PublishedAt, Topic, term_to_binary({Topic, PublishedAt})) -%% || Topic <- Topics, PublishedAt <- Timestamps -%% ], -%% ?assertEqual( -%% lists:sort([{Topic, PublishedAt} || Topic <- Topics, PublishedAt <- Timestamps]), -%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "#", 0)]) -%% ), -%% ?assertEqual( -%% [], -%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "#", 10 + 1)]) -%% ), -%% ?assertEqual( -%% lists:sort([{Topic, PublishedAt} || Topic <- Topics, PublishedAt <- lists:seq(5, 10)]), -%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "#", 5)]) -%% ), -%% ?assertEqual( -%% lists:sort([ -%% {Topic, PublishedAt} -%% || Topic <- ["foo/bar", "foo/bar/baz"], PublishedAt <- Timestamps -%% ]), -%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "foo/#", 0)]) -%% ), -%% ?assertEqual( -%% lists:sort([{"foo/bar", PublishedAt} || PublishedAt <- Timestamps]), -%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "foo/+", 0)]) -%% ), -%% ?assertEqual( -%% [], -%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "foo/+/bar", 0)]) -%% ), -%% ?assertEqual( -%% lists:sort([ -%% {Topic, PublishedAt} -%% || Topic <- ["foo/bar", "foo/bar/baz", "a/bar"], PublishedAt <- Timestamps -%% ]), -%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "+/bar/#", 0)]) -%% ), -%% ?assertEqual( -%% lists:sort([{Topic, PublishedAt} || Topic <- ["a", "a/bar"], PublishedAt <- Timestamps]), -%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "a/#", 0)]) -%% ), -%% ?assertEqual( -%% [], -%% lists:sort([binary_to_term(Payload) || Payload <- iterate(?SHARD, "a/+/+", 0)]) -%% ), -%% ok. - %% t_create_gen(_Config) -> %% {ok, 1} = emqx_ds_storage_layer:create_generation(?SHARD, 5, ?DEFAULT_CONFIG), %% ?assertEqual( From 465e8a90ddb8279dd6992edb0ef9204cb96e0744 Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Mon, 16 Oct 2023 10:17:44 +0200 Subject: [PATCH 18/31] revert(ds): Remove change from the old protocol file --- .../emqx_persistent_session_ds_proto_v1.erl | 19 +------------------ scripts/check-elixir-applications.exs | 2 +- scripts/check-elixir-deps-discrepancies.exs | 2 +- ...elixir-emqx-machine-boot-discrepancies.exs | 2 +- scripts/check_missing_reboot_apps.exs | 2 +- 5 files changed, 5 insertions(+), 22 deletions(-) diff --git a/apps/emqx/src/proto/emqx_persistent_session_ds_proto_v1.erl b/apps/emqx/src/proto/emqx_persistent_session_ds_proto_v1.erl index d9b882f3d..d35ccd963 100644 --- a/apps/emqx/src/proto/emqx_persistent_session_ds_proto_v1.erl +++ b/apps/emqx/src/proto/emqx_persistent_session_ds_proto_v1.erl @@ -23,8 +23,7 @@ open_iterator/4, close_iterator/2, - close_all_iterators/2, - get_streams/5 + close_all_iterators/2 ]). -include_lib("emqx/include/bpapi.hrl"). @@ -51,22 +50,6 @@ open_iterator(Nodes, TopicFilter, StartMS, IteratorID) -> ?TIMEOUT ). --spec get_streams( - node(), - emqx_ds:keyspace(), - emqx_ds:shard_id(), - emqx_ds:topic_filter(), - emqx_ds:time() -) -> - [emqx_ds_storage_layer:stream()]. -get_streams(Node, Keyspace, ShardId, TopicFilter, StartTime) -> - erpc:call( - Node, - emqx_ds_storage_layer, - get_streams, - [Keyspace, ShardId, TopicFilter, StartTime] - ). - -spec close_iterator( [node()], emqx_ds:iterator_id() diff --git a/scripts/check-elixir-applications.exs b/scripts/check-elixir-applications.exs index 1e604c69f..42c838199 100755 --- a/scripts/check-elixir-applications.exs +++ b/scripts/check-elixir-applications.exs @@ -1,4 +1,4 @@ -#! /usr/bin/env elixir +#!/usr/bin/env elixir defmodule CheckElixirApplications do alias EMQXUmbrella.MixProject diff --git a/scripts/check-elixir-deps-discrepancies.exs b/scripts/check-elixir-deps-discrepancies.exs index 1363219ed..408079d7d 100755 --- a/scripts/check-elixir-deps-discrepancies.exs +++ b/scripts/check-elixir-deps-discrepancies.exs @@ -1,4 +1,4 @@ -#! /usr/bin/env elixir +#!/usr/bin/env elixir # ensure we have a fresh rebar.lock diff --git a/scripts/check-elixir-emqx-machine-boot-discrepancies.exs b/scripts/check-elixir-emqx-machine-boot-discrepancies.exs index 9ffdc47bf..d07e6978f 100755 --- a/scripts/check-elixir-emqx-machine-boot-discrepancies.exs +++ b/scripts/check-elixir-emqx-machine-boot-discrepancies.exs @@ -1,4 +1,4 @@ -#! /usr/bin/env elixir +#!/usr/bin/env elixir defmodule CheckElixirEMQXMachineBootDiscrepancies do alias EMQXUmbrella.MixProject diff --git a/scripts/check_missing_reboot_apps.exs b/scripts/check_missing_reboot_apps.exs index 7f2178ec1..91d4b39ea 100755 --- a/scripts/check_missing_reboot_apps.exs +++ b/scripts/check_missing_reboot_apps.exs @@ -1,4 +1,4 @@ -#! /usr/bin/env elixir +#!/usr/bin/env elixir alias EMQXUmbrella.MixProject From 87689890ff8dbb68c96fe755b4e1f6ce6f801092 Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Mon, 16 Oct 2023 10:47:51 +0200 Subject: [PATCH 19/31] chore(ds): Fix linter and compilation warnings --- apps/emqx_durable_storage/src/emqx_ds.erl | 1 + .../src/emqx_ds_bitmask_keymapper.erl | 18 ++++++--- apps/emqx_durable_storage/src/emqx_ds_lts.erl | 8 +++- .../src/emqx_ds_storage_bitfield_lts.erl | 37 ++++++++++++++----- .../src/emqx_ds_storage_reference.erl | 2 +- .../emqx_ds_storage_bitfield_lts_SUITE.erl | 30 --------------- 6 files changed, 49 insertions(+), 47 deletions(-) diff --git a/apps/emqx_durable_storage/src/emqx_ds.erl b/apps/emqx_durable_storage/src/emqx_ds.erl index b1a003e93..941573bf8 100644 --- a/apps/emqx_durable_storage/src/emqx_ds.erl +++ b/apps/emqx_durable_storage/src/emqx_ds.erl @@ -43,6 +43,7 @@ stream/0, stream_rank/0, iterator/0, + message_id/0, next_result/1, next_result/0, store_batch_result/0, make_iterator_result/1, make_iterator_result/0 diff --git a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl index a512a141c..e18c8498d 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl @@ -105,6 +105,8 @@ ]} ). +-elvis([{elvis_style, no_if_expression, disable}]). + -ifdef(TEST). -include_lib("proper/include/proper.hrl"). -include_lib("eunit/include/eunit.hrl"). @@ -139,7 +141,9 @@ dst_offset :: offset() }). --type scanner() :: [[#scan_action{}]]. +-type scan_action() :: #scan_action{}. + +-type scanner() :: [[scan_action()]]. -record(keymapper, { schema :: [bitsource()], @@ -259,7 +263,9 @@ key_to_bitstring(#keymapper{size = Size}, Key) -> %% @doc Create a filter object that facilitates range scans. -spec make_filter(keymapper(), [scalar_range()]) -> filter(). -make_filter(KeyMapper = #keymapper{schema = Schema, dim_sizeof = DimSizeof, size = Size}, Filter0) -> +make_filter( + KeyMapper = #keymapper{schema = Schema, dim_sizeof = DimSizeof, size = TotalSize}, Filter0 +) -> NDim = length(DimSizeof), %% Transform "symbolic" inequations to ranges: Filter1 = inequations_to_ranges(KeyMapper, Filter0), @@ -326,7 +332,7 @@ make_filter(KeyMapper = #keymapper{schema = Schema, dim_sizeof = DimSizeof, size end, %% Final value #filter{ - size = Size, + size = TotalSize, bitmask = Bitmask, bitfilter = Bitfilter, bitsource_ranges = Ranges, @@ -420,7 +426,7 @@ ratchet_scan(Ranges, NDim, Key, I, Pivot0, Carry) -> %% Note: this function operates in bitsource basis, scanning it from %% NDim to 0. It applies the transformation specified by %% `ratchet_scan'. -ratchet_do(Ranges, Key, I, _Pivot, _Increment) when I < 0 -> +ratchet_do(_Ranges, _Key, I, _Pivot, _Increment) when I < 0 -> 0; ratchet_do(Ranges, Key, I, Pivot, Increment) -> #filter_scan_action{offset = Offset, size = Size, min = Min} = array:get(I, Ranges), @@ -495,12 +501,12 @@ do_vector_to_key([Action | Actions], Scanner, Coord, Vector, Acc0) -> Acc = Acc0 bor extract(Coord, Action), do_vector_to_key(Actions, Scanner, Coord, Vector, Acc). --spec extract(_Source :: scalar(), #scan_action{}) -> integer(). +-spec extract(_Source :: scalar(), scan_action()) -> integer(). extract(Src, #scan_action{src_bitmask = SrcBitmask, src_offset = SrcOffset, dst_offset = DstOffset}) -> ((Src bsr SrcOffset) band SrcBitmask) bsl DstOffset. %% extract^-1 --spec extract_inv(_Dest :: scalar(), #scan_action{}) -> integer(). +-spec extract_inv(_Dest :: scalar(), scan_action()) -> integer(). extract_inv(Dest, #scan_action{ src_bitmask = SrcBitmask, src_offset = SrcOffset, dst_offset = DestOffset }) -> diff --git a/apps/emqx_durable_storage/src/emqx_ds_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_lts.erl index c9a73e3e0..d06854fd0 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_lts.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_lts.erl @@ -32,6 +32,8 @@ -include_lib("eunit/include/eunit.hrl"). -endif. +-elvis([{elvis_style, variable_naming_convention, disable}]). + %%================================================================================ %% Type declarations %%================================================================================ @@ -601,7 +603,11 @@ test_key(Trie, Threshold, Topic0) -> fun(Old) -> case Old =:= Topic of true -> Old; - false -> error(#{'$msg' => "Duplicate key!", key => Ret, old_topic => Old, new_topic => Topic}) + false -> error(#{ '$msg' => "Duplicate key!" + , key => Ret + , old_topic => Old + , new_topic => Topic + }) end end, Topic, diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl index 8d406c93e..b85fb48b0 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl @@ -21,7 +21,7 @@ %% used for testing. -module(emqx_ds_storage_bitfield_lts). --behavior(emqx_ds_storage_layer). +-behaviour(emqx_ds_storage_layer). %% API: -export([]). @@ -65,6 +65,8 @@ keymappers :: array:array(emqx_ds_bitmask_keymapper:keymapper()) }). +-type s() :: #s{}. + -record(stream, { storage_key :: emqx_ds_lts:msg_storage_key() }). @@ -76,9 +78,7 @@ last_seen_key = <<>> :: binary() }). --define(QUICKCHECK_KEY(KEY, BITMASK, BITFILTER), - ((KEY band BITMASK) =:= BITFILTER) -). +-type iterator() :: #it{}. -define(COUNTER, emqx_ds_storage_bitfield_lts_counter). @@ -92,6 +92,13 @@ %% behavior callbacks %%================================================================================ +-spec create( + emqx_ds_replication_layer:shard_id(), + rocksdb:db_handle(), + emqx_ds_storage_layer:gen_id(), + options() +) -> + {schema(), emqx_ds_storage_layer:cf_refs()}. create(_ShardId, DBHandle, GenId, Options) -> %% Get options: BitsPerTopicLevel = maps:get(bits_per_wildcard_level, Options, 64), @@ -112,6 +119,14 @@ create(_ShardId, DBHandle, GenId, Options) -> }, {Schema, [{DataCFName, DataCFHandle}, {TrieCFName, TrieCFHandle}]}. +-spec open( + emqx_ds_replication_layer:shard_id(), + rocksdb:db_handle(), + emqx_ds_storage_layer:gen_id(), + emqx_ds_storage_layer:cf_refs(), + schema() +) -> + s(). open(_Shard, DBHandle, GenId, CFRefs, Schema) -> #{ bits_per_wildcard_level := BitsPerTopicLevel, @@ -134,6 +149,10 @@ open(_Shard, DBHandle, GenId, CFRefs, Schema) -> ), #s{db = DBHandle, data = DataCF, trie = Trie, keymappers = KeymapperCache}. +-spec store_batch( + emqx_ds_replication_layer:shard_id(), s(), [emqx_types:message()], emqx_ds:message_store_opts() +) -> + emqx_ds:store_batch_result(). store_batch(_ShardId, S = #s{db = DB, data = Data}, Messages, _Options) -> lists:foreach( fun(Msg) -> @@ -203,7 +222,7 @@ next(_Shard, #s{db = DB, data = CF, keymappers = Keymappers}, It0, BatchSize) -> %% Internal functions %%================================================================================ -next_loop(ITHandle, KeyMapper, Filter, It, Acc, 0) -> +next_loop(_ITHandle, _KeyMapper, _Filter, It, Acc, 0) -> {ok, It, lists:reverse(Acc)}; next_loop(ITHandle, KeyMapper, Filter, It0, Acc0, N0) -> inc_counter(), @@ -249,8 +268,8 @@ traverse_interval(ITHandle, KeyMapper, Filter, It0, Acc, N) -> {0, It0, Acc} end. --spec check_message(emqx_ds_bitmask_keymapper:filter(), #it{}, binary()) -> - {true, #message{}} | false. +-spec check_message(emqx_ds_bitmask_keymapper:filter(), iterator(), binary()) -> + {true, emqx_types:message()} | false. check_message(Filter, #it{last_seen_key = Key}, Val) -> case emqx_ds_bitmask_keymapper:bin_checkmask(Filter, Key) of true -> @@ -270,7 +289,7 @@ format_keyfilter(any) -> format_keyfilter({Op, Val}) -> {Op, integer_to_list(Val, 16)}. --spec make_key(#s{}, #message{}) -> {binary(), [binary()]}. +-spec make_key(s(), emqx_types:message()) -> {binary(), [binary()]}. make_key(#s{keymappers = KeyMappers, trie = Trie}, #message{timestamp = Timestamp, topic = TopicBin}) -> Tokens = emqx_topic:tokens(TopicBin), {TopicIndex, Varying} = emqx_ds_lts:topic_key(Trie, fun threshold_fun/1, Tokens), @@ -345,7 +364,7 @@ read_persisted_trie(IT, {ok, KeyB, ValB}) -> {binary_to_term(KeyB), binary_to_term(ValB)} | read_persisted_trie(IT, rocksdb:iterator_move(IT, next)) ]; -read_persisted_trie(IT, {error, invalid_iterator}) -> +read_persisted_trie(_IT, {error, invalid_iterator}) -> []. inc_counter() -> diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl index 9c7fc3158..ec00f1310 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl @@ -21,7 +21,7 @@ %% used for testing. -module(emqx_ds_storage_reference). --behavior(emqx_ds_storage_layer). +-behaviour(emqx_ds_storage_layer). %% API: -export([]). diff --git a/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl b/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl index ac037e861..6dc24a269 100644 --- a/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl +++ b/apps/emqx_durable_storage/test/emqx_ds_storage_bitfield_lts_SUITE.erl @@ -354,36 +354,6 @@ store(Shard, PublishedAt, Topic, Payload) -> }, emqx_ds_storage_layer:message_store(Shard, [Msg], #{}). -%% iterate(Shard, TopicFilter, StartTime) -> -%% Streams = emqx_ds_storage_layer:get_streams(Shard, TopicFilter, StartTime), -%% lists:flatmap( -%% fun(Stream) -> -%% iterate(Shard, iterator(Shard, Stream, TopicFilter, StartTime)) -%% end, -%% Streams). - -%% iterate(Shard, It) -> -%% case emqx_ds_storage_layer:next(Shard, It) of -%% {ok, ItNext, [#message{payload = Payload}]} -> -%% [Payload | iterate(Shard, ItNext)]; -%% end_of_stream -> -%% [] -%% end. - -%% iterate(_Shard, end_of_stream, _N) -> -%% {end_of_stream, []}; -%% iterate(Shard, It, N) -> -%% case emqx_ds_storage_layer:next(Shard, It, N) of -%% {ok, ItFinal, Messages} -> -%% {ItFinal, [Payload || #message{payload = Payload} <- Messages]}; -%% end_of_stream -> -%% {end_of_stream, []} -%% end. - -%% iterator(Shard, Stream, TopicFilter, StartTime) -> -%% {ok, It} = emqx_ds_storage_layer:make_iterator(Shard, Stream, parse_topic(TopicFilter), StartTime), -%% It. - payloads(Messages) -> lists:map( fun(#message{payload = P}) -> From e745e42093656e4f123ae6780d3257649d1d5001 Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Wed, 18 Oct 2023 01:07:21 +0200 Subject: [PATCH 20/31] test(ds): Explore full range of keys when testing ratchet function --- apps/emqx_durable_storage/src/emqx_ds.erl_ | 189 ------- .../src/emqx_ds_bitmask_keymapper.erl | 42 +- .../emqx_ds_message_storage_bitmask_shim.erl | 17 +- .../props/prop_replay_message_storage.erl | 463 ------------------ 4 files changed, 40 insertions(+), 671 deletions(-) delete mode 100644 apps/emqx_durable_storage/src/emqx_ds.erl_ delete mode 100644 apps/emqx_durable_storage/test/props/prop_replay_message_storage.erl diff --git a/apps/emqx_durable_storage/src/emqx_ds.erl_ b/apps/emqx_durable_storage/src/emqx_ds.erl_ deleted file mode 100644 index 1acbcc7c7..000000000 --- a/apps/emqx_durable_storage/src/emqx_ds.erl_ +++ /dev/null @@ -1,189 +0,0 @@ -%%-------------------------------------------------------------------- -%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. -%% -%% Licensed under the Apache License, Version 2.0 (the "License"); -%% you may not use this file except in compliance with the License. -%% You may obtain a copy of the License at -%% -%% http://www.apache.org/licenses/LICENSE-2.0 -%% -%% Unless required by applicable law or agreed to in writing, software -%% distributed under the License is distributed on an "AS IS" BASIS, -%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -%% See the License for the specific language governing permissions and -%% limitations under the License. -%%-------------------------------------------------------------------- --module(emqx_ds). - --include_lib("stdlib/include/ms_transform.hrl"). --include_lib("snabbkaffe/include/snabbkaffe.hrl"). - -%% API: --export([ensure_shard/2]). -%% Messages: --export([message_store/2, message_store/1, message_stats/0]). -%% Iterator: --export([get_streams/3, open_iterator/1, next/2]). - -%% internal exports: --export([]). - --export_type([ - stream/0, - keyspace/0, - message_id/0, - message_stats/0, - message_store_opts/0, - replay/0, - replay_id/0, - %iterator_id/0, - iterator/0, - topic/0, - topic_filter/0, - time/0 -]). - -%%================================================================================ -%% Type declarations -%%================================================================================ - -%% This record enapsulates the stream entity from the storage level. -%% -%% TODO: currently the stream is hardwired to only support the -%% internal rocksdb storage. In t he future we want to add another -%% implementations for emqx_ds, so this type has to take this into -%% account. --record(stream, - { shard :: emqx_ds:shard() - , :: emqx_ds_storage_layer:stream() - }). - --opaque stream() :: #stream{}. - --type iterator() :: term(). - -%-type iterator_id() :: binary(). - --type message_store_opts() :: #{}. - --type message_stats() :: #{}. - --type message_id() :: binary(). - -%% Parsed topic. --type topic() :: list(binary()). - -%% Parsed topic filter. --type topic_filter() :: list(binary() | '+' | '#' | ''). - --type keyspace() :: atom(). --type shard_id() :: binary(). --type shard() :: {keyspace(), shard_id()}. - -%% Timestamp -%% Earliest possible timestamp is 0. -%% TODO granularity? Currently, we should always use micro second, as that's the unit we -%% use in emqx_guid. Otherwise, the iterators won't match the message timestamps. --type time() :: non_neg_integer(). - --type replay_id() :: binary(). - --type replay() :: { - _TopicFilter :: topic_filter(), - _StartTime :: time() -}. - -%%================================================================================ -%% API funcions -%%================================================================================ - -%% @doc Get a list of streams needed for replaying a topic filter. -%% -%% Motivation: under the hood, EMQX may store different topics at -%% different locations or even in different databases. A wildcard -%% topic filter may require pulling data from any number of locations. -%% -%% Stream is an abstraction exposed by `emqx_ds' that reflects the -%% notion that different topics can be stored differently, but hides -%% the implementation details. -%% -%% Rules: -%% -%% 1. New streams matching the topic filter can appear without notice, -%% so the replayer must periodically call this function to get the -%% updated list of streams. -%% -%% 2. Streams may depend on one another. Therefore, care should be -%% taken while replaying them in parallel to avoid out-of-order -%% replay. This function returns stream together with its -%% "coordinates": `{X, T, Stream}'. If X coordinate of two streams is -%% different, then they can be replayed in parallel. If it's the -%% same, then the stream with smaller T coordinate should be replayed -%% first. --spec get_streams(keyspace(), topic_filter(), time()) -> [{integer(), integer(), stream()}]. -get_streams(Keyspace, TopicFilter, StartTime) -> - ShardIds = emqx_ds_replication_layer:get_all_shards(Keyspace), - lists:flatmap( - fun(Shard) -> - Node = emqx_ds_replication_layer:shard_to_node(Shard), - try - Streams = emqx_persistent_session_ds_proto_v1:get_streams(Node, Keyspace, Shard, TopicFilter, StartTime), - [#stream{ shard = {Keyspace, ShardId} - , stream = Stream - } || Stream <- Streams] - catch - error:{erpc, _} -> - %% The caller has to periodically refresh the - %% list of streams anyway, so it's ok to ignore - %% transient errors. - [] - end - end, - ShardIds). - --spec ensure_shard(shard(), emqx_ds_storage_layer:options()) -> - ok | {error, _Reason}. -ensure_shard(Sharzd, Options) -> - case emqx_ds_storage_layer_sup:start_shard(Shard, Options) of - {ok, _Pid} -> - ok; - {error, {already_started, _Pid}} -> - ok; - {error, Reason} -> - {error, Reason} - end. - -%%-------------------------------------------------------------------------------- -%% Message -%%-------------------------------------------------------------------------------- - --spec message_store([emqx_types:message()], message_store_opts()) -> - {ok, [message_id()]} | {error, _}. -message_store(Msg, Opts) -> - message_store(Msg, Opts). - --spec message_store([emqx_types:message()]) -> {ok, [message_id()]} | {error, _}. -message_store(Msg) -> - message_store(Msg, #{}). - --spec message_stats() -> message_stats(). -message_stats() -> - #{}. - -%%-------------------------------------------------------------------------------- -%% Iterator (pull API) -%%-------------------------------------------------------------------------------- - --spec open_iterator(stream()) -> {ok, iterator()}. -open_iterator(#stream{shard = {_Keyspace, _ShardId}, stream = _StorageSpecificStream}) -> - error(todo). - --spec next(iterator(), non_neg_integer()) -> - {ok, iterator(), [emqx_types:message()]} - | end_of_stream. -next(_Iterator, _BatchSize) -> - error(todo). - -%%================================================================================ -%% Internal functions -%%================================================================================ diff --git a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl index e18c8498d..90c381104 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl @@ -699,26 +699,44 @@ ratchet2_test() -> ?assertEqual(16#aaddcc00, ratchet(F2, 0)), ?assertEqual(16#aa_de_cc_00, ratchet(F2, 16#aa_dd_cd_11)). -ratchet3_test() -> - ?assert(proper:quickcheck(ratchet1_prop(), 100)). - %% erlfmt-ignore -ratchet1_prop() -> +ratchet3_test_() -> EpochBits = 4, Bitsources = [{1, 0, 2}, %% Static topic index {2, EpochBits, 4}, %% Epoch {3, 0, 2}, %% Varying topic hash {2, 0, EpochBits}], %% Timestamp offset - M = make_keymapper(lists:reverse(Bitsources)), - F1 = make_filter(M, [{'=', 2#10}, any, {'=', 2#01}]), - ?FORALL(N, integer(0, ones(12)), - ratchet_prop(F1, N)). + Keymapper = make_keymapper(lists:reverse(Bitsources)), + Filter1 = make_filter(Keymapper, [{'=', 2#10}, any, {'=', 2#01}]), + Filter2 = make_filter(Keymapper, [{'=', 2#01}, any, any]), + Filter3 = make_filter(Keymapper, [{'=', 2#01}, {'>=', 16#aa}, any]), + {timeout, 15, + [?_assert(test_iterate(Filter1, 0)), + ?_assert(test_iterate(Filter2, 0)), + %% Not starting from 0 here for simplicity, since the beginning + %% of a >= interval can't be properly checked with a bitmask: + ?_assert(test_iterate(Filter3, ratchet(Filter3, 1))) + ]}. -ratchet_prop(Filter = #filter{bitfilter = Bitfilter, bitmask = Bitmask, size = Size}, Key0) -> - Key = ratchet(Filter, Key0), +%% Note: this function iterates through the full range of keys, so its +%% complexity grows _exponentially_ with the total size of the +%% keymapper. +test_iterate(Filter, overflow) -> + true; +test_iterate(Filter, Key0) -> + Key = ratchet(Filter, Key0 + 1), + ?assert(ratchet_prop(Filter, Key0, Key)), + test_iterate(Filter, Key). + +ratchet_prop(Filter = #filter{bitfilter = Bitfilter, bitmask = Bitmask, size = Size}, Key0, Key) -> + %% Validate basic properties of the generated key. It must be + %% greater than the old key, and match the bitmask: ?assert(Key =:= overflow orelse (Key band Bitmask =:= Bitfilter)), - ?assert(Key >= Key0, {Key, '>=', Key}), + ?assert(Key > Key0, {Key, '>=', Key}), IMax = ones(Size), + %% Iterate through all keys between `Key0 + 1' and `Key' and + %% validate that none of them match the bitmask. Ultimately, it + %% means that `ratchet' function doesn't skip over any valid keys: CheckGaps = fun F(I) when I >= Key; I > IMax -> true; @@ -729,7 +747,7 @@ ratchet_prop(Filter = #filter{bitfilter = Bitfilter, bitmask = Bitmask, size = S ), F(I + 1) end, - CheckGaps(Key0). + CheckGaps(Key0 + 1). mkbmask(Keymapper, Filter0) -> Filter = inequations_to_ranges(Keymapper, Filter0), diff --git a/apps/emqx_durable_storage/test/props/emqx_ds_message_storage_bitmask_shim.erl b/apps/emqx_durable_storage/test/props/emqx_ds_message_storage_bitmask_shim.erl index e9daf2581..9b5af9428 100644 --- a/apps/emqx_durable_storage/test/props/emqx_ds_message_storage_bitmask_shim.erl +++ b/apps/emqx_durable_storage/test/props/emqx_ds_message_storage_bitmask_shim.erl @@ -4,9 +4,11 @@ -module(emqx_ds_message_storage_bitmask_shim). +-include_lib("emqx/include/emqx.hrl"). + -export([open/0]). -export([close/1]). --export([store/5]). +-export([store/2]). -export([iterate/2]). -type topic() :: list(binary()). @@ -25,20 +27,21 @@ close(Tab) -> true = ets:delete(Tab), ok. --spec store(t(), emqx_guid:guid(), time(), topic(), binary()) -> +-spec store(t(), emqx_types:message()) -> ok | {error, _TODO}. -store(Tab, MessageID, PublishedAt, Topic, Payload) -> - true = ets:insert(Tab, {{PublishedAt, MessageID}, Topic, Payload}), +store(Tab, Msg = #message{id = MessageID, timestamp = PublishedAt}) -> + true = ets:insert(Tab, {{PublishedAt, MessageID}, Msg}), ok. -spec iterate(t(), emqx_ds:replay()) -> [binary()]. -iterate(Tab, {TopicFilter, StartTime}) -> +iterate(Tab, {TopicFilter0, StartTime}) -> + TopicFilter = iolist_to_binary(lists:join("/", TopicFilter0)), ets:foldr( - fun({{PublishedAt, _}, Topic, Payload}, Acc) -> + fun({{PublishedAt, _}, Msg = #message{topic = Topic}}, Acc) -> case emqx_topic:match(Topic, TopicFilter) of true when PublishedAt >= StartTime -> - [Payload | Acc]; + [Msg | Acc]; _ -> Acc end diff --git a/apps/emqx_durable_storage/test/props/prop_replay_message_storage.erl b/apps/emqx_durable_storage/test/props/prop_replay_message_storage.erl deleted file mode 100644 index d96996534..000000000 --- a/apps/emqx_durable_storage/test/props/prop_replay_message_storage.erl +++ /dev/null @@ -1,463 +0,0 @@ -%%-------------------------------------------------------------------- -%% Copyright (c) 2020-2023 EMQ Technologies Co., Ltd. All Rights Reserved. -%%-------------------------------------------------------------------- - --module(prop_replay_message_storage). - --include_lib("proper/include/proper.hrl"). --include_lib("eunit/include/eunit.hrl"). - --define(WORK_DIR, ["_build", "test"]). --define(RUN_ID, {?MODULE, testrun_id}). - --define(KEYSPACE, ?MODULE). --define(SHARD_ID, <<"shard">>). --define(SHARD, {?KEYSPACE, ?SHARD_ID}). --define(GEN_ID, 42). - -%%-------------------------------------------------------------------- -%% Properties -%%-------------------------------------------------------------------- - -prop_bitstring_computes() -> - ?FORALL( - Keymapper, - keymapper(), - ?FORALL({Topic, Timestamp}, {topic(), integer()}, begin - BS = emqx_ds_message_storage_bitmask:compute_bitstring(Topic, Timestamp, Keymapper), - is_integer(BS) andalso (BS < (1 bsl get_keymapper_bitsize(Keymapper))) - end) - ). - -prop_topic_bitmask_computes() -> - Keymapper = make_keymapper(16, [8, 12, 16], 100), - ?FORALL(TopicFilter, topic_filter(), begin - Mask = emqx_ds_message_storage_bitmask:compute_topic_bitmask(TopicFilter, Keymapper), - % topic bits + timestamp LSBs - is_integer(Mask) andalso (Mask < (1 bsl (36 + 6))) - end). - -prop_next_seek_monotonic() -> - ?FORALL( - {TopicFilter, StartTime, Keymapper}, - {topic_filter(), pos_integer(), keymapper()}, - begin - Filter = emqx_ds_message_storage_bitmask:make_keyspace_filter( - {TopicFilter, StartTime}, - Keymapper - ), - ?FORALL( - Bitstring, - bitstr(get_keymapper_bitsize(Keymapper)), - emqx_ds_message_storage_bitmask:compute_next_seek(Bitstring, Filter) >= Bitstring - ) - end - ). - -prop_next_seek_eq_initial_seek() -> - ?FORALL( - Filter, - keyspace_filter(), - emqx_ds_message_storage_bitmask:compute_initial_seek(Filter) =:= - emqx_ds_message_storage_bitmask:compute_next_seek(0, Filter) - ). - -prop_iterate_messages() -> - TBPL = [4, 8, 12], - Options = #{ - timestamp_bits => 32, - topic_bits_per_level => TBPL, - epoch => 200 - }, - % TODO - % Shrinking is too unpredictable and leaves a LOT of garbage in the scratch dit. - ?FORALL(Stream, noshrink(non_empty(messages(topic(TBPL)))), begin - Filepath = make_filepath(?FUNCTION_NAME, erlang:system_time(microsecond)), - {DB, Handle} = open_db(Filepath, Options), - Shim = emqx_ds_message_storage_bitmask_shim:open(), - ok = store_db(DB, Stream), - ok = store_shim(Shim, Stream), - ?FORALL( - { - {Topic, _}, - Pattern, - StartTime - }, - { - nth(Stream), - topic_filter_pattern(), - start_time() - }, - begin - TopicFilter = make_topic_filter(Pattern, Topic), - Iteration = {TopicFilter, StartTime}, - Messages = iterate_db(DB, Iteration), - Reference = iterate_shim(Shim, Iteration), - ok = close_db(Handle), - ok = emqx_ds_message_storage_bitmask_shim:close(Shim), - ?WHENFAIL( - begin - io:format(user, " *** Filepath = ~s~n", [Filepath]), - io:format(user, " *** TopicFilter = ~p~n", [TopicFilter]), - io:format(user, " *** StartTime = ~p~n", [StartTime]) - end, - is_list(Messages) andalso equals(Messages -- Reference, Reference -- Messages) - ) - end - ) - end). - -prop_iterate_eq_iterate_with_preserve_restore() -> - TBPL = [4, 8, 16, 12], - Options = #{ - timestamp_bits => 32, - topic_bits_per_level => TBPL, - epoch => 500 - }, - {DB, _Handle} = open_db(make_filepath(?FUNCTION_NAME), Options), - ?FORALL(Stream, non_empty(messages(topic(TBPL))), begin - % TODO - % This proptest is impure because messages from testruns assumed to be - % independent of each other are accumulated in the same storage. This - % would probably confuse shrinker in the event a testrun fails. - ok = store_db(DB, Stream), - ?FORALL( - { - {Topic, _}, - Pat, - StartTime, - Commands - }, - { - nth(Stream), - topic_filter_pattern(), - start_time(), - shuffled(flat([non_empty(list({preserve, restore})), list(iterate)])) - }, - begin - Replay = {make_topic_filter(Pat, Topic), StartTime}, - Iterator = make_iterator(DB, Replay), - Ctx = #{db => DB, replay => Replay}, - Messages = run_iterator_commands(Commands, Iterator, Ctx), - equals(Messages, iterate_db(DB, Replay)) - end - ) - end). - -prop_iterate_eq_iterate_with_refresh() -> - TBPL = [4, 8, 16, 12], - Options = #{ - timestamp_bits => 32, - topic_bits_per_level => TBPL, - epoch => 500 - }, - {DB, _Handle} = open_db(make_filepath(?FUNCTION_NAME), Options), - ?FORALL(Stream, non_empty(messages(topic(TBPL))), begin - % TODO - % This proptest is also impure, see above. - ok = store_db(DB, Stream), - ?FORALL( - { - {Topic, _}, - Pat, - StartTime, - RefreshEvery - }, - { - nth(Stream), - topic_filter_pattern(), - start_time(), - pos_integer() - }, - ?TIMEOUT(5000, begin - Replay = {make_topic_filter(Pat, Topic), StartTime}, - IterationOptions = #{iterator_refresh => {every, RefreshEvery}}, - Iterator = make_iterator(DB, Replay, IterationOptions), - Messages = iterate_db(Iterator), - equals(Messages, iterate_db(DB, Replay)) - end) - ) - end). - -% store_message_stream(DB, [{Topic, {Payload, ChunkNum, _ChunkCount}} | Rest]) -> -% MessageID = emqx_guid:gen(), -% PublishedAt = ChunkNum, -% MessageID, PublishedAt, Topic -% ]), -% ok = emqx_ds_message_storage_bitmask:store(DB, MessageID, PublishedAt, Topic, Payload), -% store_message_stream(DB, payload_gen:next(Rest)); -% store_message_stream(_Zone, []) -> -% ok. - -store_db(DB, Messages) -> - lists:foreach( - fun({Topic, Payload = {MessageID, Timestamp, _}}) -> - Bin = term_to_binary(Payload), - emqx_ds_message_storage_bitmask:store(DB, MessageID, Timestamp, Topic, Bin) - end, - Messages - ). - -iterate_db(DB, Iteration) -> - iterate_db(make_iterator(DB, Iteration)). - -iterate_db(It) -> - case emqx_ds_message_storage_bitmask:next(It) of - {value, Payload, ItNext} -> - [binary_to_term(Payload) | iterate_db(ItNext)]; - none -> - [] - end. - -make_iterator(DB, Replay) -> - {ok, It} = emqx_ds_message_storage_bitmask:make_iterator(DB, Replay), - It. - -make_iterator(DB, Replay, Options) -> - {ok, It} = emqx_ds_message_storage_bitmask:make_iterator(DB, Replay, Options), - It. - -run_iterator_commands([iterate | Rest], It, Ctx) -> - case emqx_ds_message_storage_bitmask:next(It) of - {value, Payload, ItNext} -> - [binary_to_term(Payload) | run_iterator_commands(Rest, ItNext, Ctx)]; - none -> - [] - end; -run_iterator_commands([{preserve, restore} | Rest], It, Ctx) -> - #{db := DB} = Ctx, - Serial = emqx_ds_message_storage_bitmask:preserve_iterator(It), - {ok, ItNext} = emqx_ds_message_storage_bitmask:restore_iterator(DB, Serial), - run_iterator_commands(Rest, ItNext, Ctx); -run_iterator_commands([], It, _Ctx) -> - iterate_db(It). - -store_shim(Shim, Messages) -> - lists:foreach( - fun({Topic, Payload = {MessageID, Timestamp, _}}) -> - Bin = term_to_binary(Payload), - emqx_ds_message_storage_bitmask_shim:store(Shim, MessageID, Timestamp, Topic, Bin) - end, - Messages - ). - -iterate_shim(Shim, Iteration) -> - lists:map( - fun binary_to_term/1, - emqx_ds_message_storage_bitmask_shim:iterate(Shim, Iteration) - ). - -%%-------------------------------------------------------------------- -%% Setup / teardown -%%-------------------------------------------------------------------- - -open_db(Filepath, Options) -> - {ok, Handle} = rocksdb:open(Filepath, [{create_if_missing, true}]), - {Schema, CFRefs} = emqx_ds_message_storage_bitmask:create_new(Handle, ?GEN_ID, Options), - DB = emqx_ds_message_storage_bitmask:open(?SHARD, Handle, ?GEN_ID, CFRefs, Schema), - {DB, Handle}. - -close_db(Handle) -> - rocksdb:close(Handle). - -make_filepath(TC) -> - make_filepath(TC, 0). - -make_filepath(TC, InstID) -> - Name = io_lib:format("~0p.~0p", [TC, InstID]), - Path = filename:join(?WORK_DIR ++ ["proper", "runs", get_run_id(), ?MODULE_STRING, Name]), - ok = filelib:ensure_dir(Path), - Path. - -get_run_id() -> - case persistent_term:get(?RUN_ID, undefined) of - RunID when RunID /= undefined -> - RunID; - undefined -> - RunID = make_run_id(), - ok = persistent_term:put(?RUN_ID, RunID), - RunID - end. - -make_run_id() -> - calendar:system_time_to_rfc3339(erlang:system_time(second), [{offset, "Z"}]). - -%%-------------------------------------------------------------------- -%% Type generators -%%-------------------------------------------------------------------- - -topic() -> - non_empty(list(topic_level())). - -topic(EntropyWeights) -> - ?LET(L, scaled(1 / 4, list(1)), begin - EWs = lists:sublist(EntropyWeights ++ L, length(L)), - ?SIZED(S, [oneof([topic_level(S * EW), topic_level_fixed()]) || EW <- EWs]) - end). - -topic_filter() -> - ?SUCHTHAT( - L, - non_empty( - list( - frequency([ - {5, topic_level()}, - {2, '+'}, - {1, '#'} - ]) - ) - ), - not lists:member('#', L) orelse lists:last(L) == '#' - ). - -topic_level_pattern() -> - frequency([ - {5, level}, - {2, '+'}, - {1, '#'} - ]). - -topic_filter_pattern() -> - list(topic_level_pattern()). - -topic_filter(Topic) -> - ?LET({T, Pat}, {Topic, topic_filter_pattern()}, make_topic_filter(Pat, T)). - -make_topic_filter([], _) -> - []; -make_topic_filter(_, []) -> - []; -make_topic_filter(['#' | _], _) -> - ['#']; -make_topic_filter(['+' | Rest], [_ | Levels]) -> - ['+' | make_topic_filter(Rest, Levels)]; -make_topic_filter([level | Rest], [L | Levels]) -> - [L | make_topic_filter(Rest, Levels)]. - -% topic() -> -% ?LAZY(?SIZED(S, frequency([ -% {S, [topic_level() | topic()]}, -% {1, []} -% ]))). - -% topic_filter() -> -% ?LAZY(?SIZED(S, frequency([ -% {round(S / 3 * 2), [topic_level() | topic_filter()]}, -% {round(S / 3 * 1), ['+' | topic_filter()]}, -% {1, []}, -% {1, ['#']} -% ]))). - -topic_level() -> - ?LET(L, list(oneof([range($a, $z), range($0, $9)])), iolist_to_binary(L)). - -topic_level(Entropy) -> - S = floor(1 + math:log2(Entropy) / 4), - ?LET(I, range(1, Entropy), iolist_to_binary(io_lib:format("~*.16.0B", [S, I]))). - -topic_level_fixed() -> - oneof([ - <<"foo">>, - <<"bar">>, - <<"baz">>, - <<"xyzzy">> - ]). - -keymapper() -> - ?LET( - {TimestampBits, TopicBits, Epoch}, - { - range(0, 128), - non_empty(list(range(1, 32))), - pos_integer() - }, - make_keymapper(TimestampBits, TopicBits, Epoch * 100) - ). - -keyspace_filter() -> - ?LET( - {TopicFilter, StartTime, Keymapper}, - {topic_filter(), pos_integer(), keymapper()}, - emqx_ds_message_storage_bitmask:make_keyspace_filter({TopicFilter, StartTime}, Keymapper) - ). - -messages(Topic) -> - ?LET( - Ts, - list(Topic), - interleaved( - ?LET(Messages, vector(length(Ts), scaled(4, list(message()))), lists:zip(Ts, Messages)) - ) - ). - -message() -> - ?LET({Timestamp, Payload}, {timestamp(), binary()}, {emqx_guid:gen(), Timestamp, Payload}). - -message_streams(Topic) -> - ?LET(Topics, list(Topic), [{T, payload_gen:binary_stream_gen(64)} || T <- Topics]). - -timestamp() -> - scaled(20, pos_integer()). - -start_time() -> - scaled(10, pos_integer()). - -bitstr(Size) -> - ?LET(B, binary(1 + (Size div 8)), binary:decode_unsigned(B) band (1 bsl Size - 1)). - -nth(L) -> - ?LET(I, range(1, length(L)), lists:nth(I, L)). - -scaled(Factor, T) -> - ?SIZED(S, resize(ceil(S * Factor), T)). - -interleaved(T) -> - ?LET({L, Seed}, {T, integer()}, interleave(L, rand:seed_s(exsss, Seed))). - -shuffled(T) -> - ?LET({L, Seed}, {T, integer()}, shuffle(L, rand:seed_s(exsss, Seed))). - -flat(T) -> - ?LET(L, T, lists:flatten(L)). - -%%-------------------------------------------------------------------- -%% Internal functions -%%-------------------------------------------------------------------- - -make_keymapper(TimestampBits, TopicBits, MaxEpoch) -> - emqx_ds_message_storage_bitmask:make_keymapper(#{ - timestamp_bits => TimestampBits, - topic_bits_per_level => TopicBits, - epoch => MaxEpoch - }). - -get_keymapper_bitsize(Keymapper) -> - maps:get(bitsize, emqx_ds_message_storage_bitmask:keymapper_info(Keymapper)). - --spec interleave(list({Tag, list(E)}), rand:state()) -> list({Tag, E}). -interleave(Seqs, Rng) -> - interleave(Seqs, length(Seqs), Rng). - -interleave(Seqs, L, Rng) when L > 0 -> - {N, RngNext} = rand:uniform_s(L, Rng), - {SeqHead, SeqTail} = lists:split(N - 1, Seqs), - case SeqTail of - [{Tag, [M | Rest]} | SeqRest] -> - [{Tag, M} | interleave(SeqHead ++ [{Tag, Rest} | SeqRest], L, RngNext)]; - [{_, []} | SeqRest] -> - interleave(SeqHead ++ SeqRest, L - 1, RngNext) - end; -interleave([], 0, _) -> - []. - --spec shuffle(list(E), rand:state()) -> list(E). -shuffle(L, Rng) -> - {Rands, _} = randoms(length(L), Rng), - [E || {_, E} <- lists:sort(lists:zip(Rands, L))]. - -randoms(N, Rng) when N > 0 -> - {Rand, RngNext} = rand:uniform_s(Rng), - {Tail, RngFinal} = randoms(N - 1, RngNext), - {[Rand | Tail], RngFinal}; -randoms(_, Rng) -> - {[], Rng}. From 2de79dd9ac42d3a9e825fd34ccce865db785ad64 Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Wed, 25 Oct 2023 08:48:20 +0200 Subject: [PATCH 21/31] feat(ds): Replay QoS1 messages --- Makefile | 2 +- .../emqx_persistent_message_ds_replayer.erl | 207 ++++++++++++++++++ apps/emqx/src/emqx_persistent_session_ds.erl | 165 ++++++++++---- apps/emqx/src/emqx_persistent_session_ds.hrl | 56 +++++ .../test/emqx_persistent_messages_SUITE.erl | 20 +- .../test/emqx_persistent_session_SUITE.erl | 42 ++++ apps/emqx_durable_storage/src/emqx_ds.erl | 18 +- .../src/emqx_ds_helper.erl | 73 ++++++ .../src/emqx_ds_replication_layer.erl | 24 +- .../src/emqx_ds_storage_layer.erl | 2 +- tdd | 13 ++ topic_match_test.png | Bin 0 -> 176221 bytes 12 files changed, 563 insertions(+), 59 deletions(-) create mode 100644 apps/emqx/src/emqx_persistent_message_ds_replayer.erl create mode 100644 apps/emqx/src/emqx_persistent_session_ds.hrl create mode 100644 apps/emqx_durable_storage/src/emqx_ds_helper.erl create mode 100755 tdd create mode 100644 topic_match_test.png diff --git a/Makefile b/Makefile index 254a4b0f9..ed10a09fd 100644 --- a/Makefile +++ b/Makefile @@ -85,7 +85,7 @@ $(REL_PROFILES:%=%-compile): $(REBAR) merge-config .PHONY: ct ct: $(REBAR) merge-config - @$(REBAR) ct --name $(CT_NODE_NAME) -c -v --cover_export_name $(CT_COVER_EXPORT_PREFIX)-ct + ENABLE_COVER_COMPILE=1 $(REBAR) ct --name $(CT_NODE_NAME) -c -v --cover_export_name $(CT_COVER_EXPORT_PREFIX)-ct ## only check bpapi for enterprise profile because it's a super-set. .PHONY: static_checks diff --git a/apps/emqx/src/emqx_persistent_message_ds_replayer.erl b/apps/emqx/src/emqx_persistent_message_ds_replayer.erl new file mode 100644 index 000000000..ce57eaa80 --- /dev/null +++ b/apps/emqx/src/emqx_persistent_message_ds_replayer.erl @@ -0,0 +1,207 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +%% @doc This module implements the routines for replaying streams of +%% messages. +-module(emqx_persistent_message_ds_replayer). + +%% API: +-export([new/0, next_packet_id/1, replay/2, commit_offset/3, poll/3]). + +%% internal exports: +-export([]). + +-export_type([inflight/0]). + +-include("emqx_persistent_session_ds.hrl"). + +%%================================================================================ +%% Type declarations +%%================================================================================ + +%% Note: sequence numbers are monotonic; they don't wrap around: +-type seqno() :: non_neg_integer(). + +-record(range, { + stream :: emqx_ds:stream(), + first :: seqno(), + last :: seqno(), + iterator_next :: emqx_ds:iterator() | undefined +}). + +-type range() :: #range{}. + +-record(inflight, { + next_seqno = 0 :: seqno(), + acked_seqno = 0 :: seqno(), + offset_ranges = [] :: [range()] +}). + +-opaque inflight() :: #inflight{}. + +%%================================================================================ +%% API funcions +%%================================================================================ + +-spec new() -> inflight(). +new() -> + #inflight{}. + +-spec next_packet_id(inflight()) -> {emqx_types:packet_id(), inflight()}. +next_packet_id(Inflight0 = #inflight{next_seqno = LastSeqno}) -> + Inflight = Inflight0#inflight{next_seqno = LastSeqno + 1}, + {seqno_to_packet_id(LastSeqno), Inflight}. + +-spec replay(emqx_persistent_session_ds:id(), inflight()) -> + emqx_session:replies(). +replay(_SessionId, _Inflight = #inflight{offset_ranges = _Ranges}) -> + []. + +-spec commit_offset(emqx_persistent_session_ds:id(), emqx_types:packet_id(), inflight()) -> + {_IsValidOffset :: boolean(), inflight()}. +commit_offset(SessionId, PacketId, Inflight0 = #inflight{acked_seqno = AckedSeqno0, next_seqno = NextSeqNo, offset_ranges = Ranges0}) -> + AckedSeqno = packet_id_to_seqno(NextSeqNo, PacketId), + true = AckedSeqno0 < AckedSeqno, + Ranges = lists:filter( + fun(#range{stream = Stream, last = LastSeqno, iterator_next = ItNext}) -> + case LastSeqno =< AckedSeqno of + true -> + %% This range has been fully + %% acked. Remove it and replace saved + %% iterator with the trailing iterator. + update_iterator(SessionId, Stream, ItNext), + false; + false -> + %% This range still has unacked + %% messages: + true + end + end, + Ranges0 + ), + Inflight = Inflight0#inflight{acked_seqno = AckedSeqno, offset_ranges = Ranges}, + {true, Inflight}. + +-spec poll(emqx_persistent_session_ds:id(), inflight(), pos_integer()) -> + {emqx_session:replies(), inflight()}. +poll(SessionId, Inflight0, WindowSize) when WindowSize > 0, WindowSize < 16#7fff -> + #inflight{next_seqno = NextSeqNo0, acked_seqno = AckedSeqno} = + Inflight0, + FetchThreshold = max(1, WindowSize div 2), + FreeSpace = AckedSeqno + WindowSize - NextSeqNo0, + case FreeSpace >= FetchThreshold of + false -> + %% TODO: this branch is meant to avoid fetching data from + %% the DB in chunks that are too small. However, this + %% logic is not exactly good for the latency. Can the + %% client get stuck even? + {[], Inflight0}; + true -> + Streams = shuffle(get_streams(SessionId)), + fetch(SessionId, Inflight0, Streams, FreeSpace, []) + end. + +%%================================================================================ +%% Internal exports +%%================================================================================ + +%%================================================================================ +%% Internal functions +%%================================================================================ + +fetch(_SessionId, Inflight, _Streams = [], _N, Acc) -> + {lists:reverse(Acc), Inflight}; +fetch(_SessionId, Inflight, _Streams, 0, Acc) -> + {lists:reverse(Acc), Inflight}; +fetch(SessionId, Inflight0, [#ds_stream{stream = Stream} | Streams], N, Publishes0) -> + #inflight{next_seqno = FirstSeqNo, offset_ranges = Ranges0} = Inflight0, + ItBegin = get_last_iterator(SessionId, Stream, Ranges0), + {ok, ItEnd, Messages} = emqx_ds:next(ItBegin, N), + {Publishes, Inflight1} = + lists:foldl( + fun(Msg, {PubAcc0, InflightAcc0}) -> + {PacketId, InflightAcc} = next_packet_id(InflightAcc0), + PubAcc = [{PacketId, Msg} | PubAcc0], + {PubAcc, InflightAcc} + end, + {Publishes0, Inflight0}, + Messages + ), + #inflight{next_seqno = LastSeqNo} = Inflight1, + NMessages = LastSeqNo - FirstSeqNo, + case NMessages > 0 of + true -> + Range = #range{ + first = FirstSeqNo, + last = LastSeqNo - 1, + stream = Stream, + iterator_next = ItEnd + }, + Inflight = Inflight1#inflight{offset_ranges = Ranges0 ++ [Range]}, + fetch(SessionId, Inflight, Streams, N - NMessages, Publishes); + false -> + fetch(SessionId, Inflight1, Streams, N, Publishes) + end. + +update_iterator(SessionId, Stream, Iterator) -> + mria:dirty_write(?SESSION_ITER_TAB, #ds_iter{id = {SessionId, Stream}, iter = Iterator}). + +get_last_iterator(SessionId, Stream, Ranges) -> + case lists:keyfind(Stream, #range.stream, lists:reverse(Ranges)) of + false -> + get_iterator(SessionId, Stream); + #range{iterator_next = Next} -> + Next + end. + +get_iterator(SessionId, Stream) -> + Id = {SessionId, Stream}, + [#ds_iter{iter = It}] = mnesia:dirty_read(?SESSION_ITER_TAB, Id), + It. + +get_streams(SessionId) -> + mnesia:dirty_read(?SESSION_STREAM_TAB, SessionId). + +%% Packet ID as defined by MQTT protocol is a 16-bit integer in range +%% 1..FFFF. This function translates internal session sequence number +%% to MQTT packet ID by chopping off most significant bits and adding +%% 1. This assumes that there's never more FFFF in-flight packets at +%% any time: +-spec seqno_to_packet_id(non_neg_integer()) -> emqx_types:packet_id(). +seqno_to_packet_id(Counter) -> + Counter rem 16#ffff + 1. + +%% Reconstruct session counter by adding most significant bits from +%% the current counter to the packet id. +-spec packet_id_to_seqno(non_neg_integer(), emqx_types:packet_id()) -> non_neg_integer(). +packet_id_to_seqno(NextSeqNo, PacketId) -> + N = ((NextSeqNo bsr 16) bsl 16) + PacketId, + case N > NextSeqNo of + true -> N - 16#10000; + false -> N + end. + +-spec shuffle([A]) -> [A]. +shuffle(L0) -> + L1 = lists:map( + fun(A) -> + {rand:uniform(), A} + end, + L0 + ), + L2 = lists:sort(L1), + {_, L} = lists:unzip(L2), + L. diff --git a/apps/emqx/src/emqx_persistent_session_ds.erl b/apps/emqx/src/emqx_persistent_session_ds.erl index 9bc9e0b91..b8afc771f 100644 --- a/apps/emqx/src/emqx_persistent_session_ds.erl +++ b/apps/emqx/src/emqx_persistent_session_ds.erl @@ -18,9 +18,12 @@ -include("emqx.hrl"). -include_lib("snabbkaffe/include/snabbkaffe.hrl"). +-include_lib("stdlib/include/ms_transform.hrl"). -include("emqx_mqtt.hrl"). +-include("emqx_persistent_session_ds.hrl"). + %% Session API -export([ create/3, @@ -50,7 +53,7 @@ -export([ deliver/3, replay/3, - % handle_timeout/3, + handle_timeout/3, disconnect/1, terminate/2 ]). @@ -81,10 +84,14 @@ expires_at := timestamp() | never, %% Client’s Subscriptions. iterators := #{topic() => subscription()}, + %% Inflight messages + inflight := emqx_persistent_message_ds_replayer:inflight(), %% props := map() }. +%% -type session() :: #session{}. + -type timestamp() :: emqx_utils_calendar:epoch_millisecond(). -type topic() :: emqx_types:topic(). -type clientinfo() :: emqx_types:clientinfo(). @@ -113,6 +120,8 @@ open(#{clientid := ClientID}, _ConnInfo) -> %% somehow isolate those idling not-yet-expired sessions into a separate process %% space, and move this call back into `emqx_cm` where it belongs. ok = emqx_cm:discard_session(ClientID), + ensure_timer(pull), + ensure_timer(get_streams), case open_session(ClientID) of Session = #{} -> {true, Session, []}; @@ -259,8 +268,8 @@ get_subscription(TopicFilter, #{iterators := Iters}) -> {ok, emqx_types:publish_result(), replies(), session()} | {error, emqx_types:reason_code()}. publish(_PacketId, Msg, Session) -> - % TODO: stub - {ok, emqx_broker:publish(Msg), [], Session}. + ok = emqx_ds:store_batch(?PERSISTENT_MESSAGE_DB, [Msg]), + {ok, persisted, [], Session}. %%-------------------------------------------------------------------- %% Client -> Broker: PUBACK @@ -269,9 +278,14 @@ publish(_PacketId, Msg, Session) -> -spec puback(clientinfo(), emqx_types:packet_id(), session()) -> {ok, emqx_types:message(), replies(), session()} | {error, emqx_types:reason_code()}. -puback(_ClientInfo, _PacketId, _Session = #{}) -> - % TODO: stub - {error, ?RC_PACKET_IDENTIFIER_NOT_FOUND}. +puback(_ClientInfo, PacketId, Session = #{id := Id, inflight := Inflight0}) -> + case emqx_persistent_message_ds_replayer:commit_offset(Id, PacketId, Inflight0) of + {true, Inflight} -> + Msg = #message{}, %% TODO + {ok, Msg, [], Session#{inflight => Inflight}}; + {false, _} -> + {error, ?RC_PACKET_IDENTIFIER_NOT_FOUND} + end. %%-------------------------------------------------------------------- %% Client -> Broker: PUBREC @@ -308,10 +322,23 @@ pubcomp(_ClientInfo, _PacketId, _Session = #{}) -> %%-------------------------------------------------------------------- -spec deliver(clientinfo(), [emqx_types:deliver()], session()) -> - no_return(). -deliver(_ClientInfo, _Delivers, _Session = #{}) -> - % TODO: ensure it's unreachable somehow - error(unexpected). + {ok, emqx_types:message(), replies(), session()}. +deliver(_ClientInfo, _Delivers, Session) -> + %% This may be triggered for the system messages. FIXME. + {ok, [], Session}. + +-spec handle_timeout(clientinfo(), emqx_session:common_timer_name(), session()) -> + {ok, replies(), session()} | {ok, replies(), timeout(), session()}. +handle_timeout(_ClientInfo, pull, Session = #{id := Id, inflight := Inflight0}) -> + WindowSize = 100, + {Publishes, Inflight} = emqx_persistent_message_ds_replayer:poll(Id, Inflight0, WindowSize), + %%logger:warning("Inflight: ~p", [Inflight]), + ensure_timer(pull), + {ok, Publishes, Session#{inflight => Inflight}}; +handle_timeout(_ClientInfo, get_streams, Session = #{id := Id}) -> + renew_streams(Id), + ensure_timer(get_streams), + {ok, [], Session}. -spec replay(clientinfo(), [], session()) -> {ok, replies(), session()}. @@ -390,29 +417,11 @@ del_subscription(TopicFilterBin, DSSessionId) -> %% Session tables operations %%-------------------------------------------------------------------- --define(SESSION_TAB, emqx_ds_session). --define(SESSION_SUBSCRIPTIONS_TAB, emqx_ds_session_subscriptions). --define(DS_MRIA_SHARD, emqx_ds_session_shard). - --record(session, { - %% same as clientid - id :: id(), - %% creation time - created_at :: _Millisecond :: non_neg_integer(), - expires_at = never :: _Millisecond :: non_neg_integer() | never, - %% for future usage - props = #{} :: map() -}). - --record(ds_sub, { - id :: subscription_id(), - start_time :: emqx_ds:time(), - props = #{} :: map(), - extra = #{} :: map() -}). --type ds_sub() :: #ds_sub{}. - create_tables() -> + ok = emqx_ds:open_db(?PERSISTENT_MESSAGE_DB, #{ + backend => builtin, + storage => {emqx_ds_storage_bitfield_lts, #{}} + }), ok = mria:create_table( ?SESSION_TAB, [ @@ -433,7 +442,29 @@ create_tables() -> {attributes, record_info(fields, ds_sub)} ] ), - ok = mria:wait_for_tables([?SESSION_TAB, ?SESSION_SUBSCRIPTIONS_TAB]), + ok = mria:create_table( + ?SESSION_STREAM_TAB, + [ + {rlog_shard, ?DS_MRIA_SHARD}, + {type, bag}, + {storage, storage()}, + {record_name, ds_stream}, + {attributes, record_info(fields, ds_stream)} + ] + ), + ok = mria:create_table( + ?SESSION_ITER_TAB, + [ + {rlog_shard, ?DS_MRIA_SHARD}, + {type, set}, + {storage, storage()}, + {record_name, ds_iter}, + {attributes, record_info(fields, ds_iter)} + ] + ), + ok = mria:wait_for_tables([ + ?SESSION_TAB, ?SESSION_SUBSCRIPTIONS_TAB, ?SESSION_STREAM_TAB, ?SESSION_ITER_TAB + ]), ok. -dialyzer({nowarn_function, storage/0}). @@ -482,7 +513,8 @@ session_create(SessionId, Props) -> id = SessionId, created_at = erlang:system_time(millisecond), expires_at = never, - props = Props + props = Props, + inflight = emqx_persistent_message_ds_replayer:new() }, ok = mnesia:write(?SESSION_TAB, Session, write), Session. @@ -555,12 +587,12 @@ session_del_subscription(#ds_sub{id = DSSubId}) -> mnesia:delete(?SESSION_SUBSCRIPTIONS_TAB, DSSubId, write). session_read_subscriptions(DSSessionId) -> - % NOTE: somewhat convoluted way to trick dialyzer - Pat = erlang:make_tuple(record_info(size, ds_sub), '_', [ - {1, ds_sub}, - {#ds_sub.id, {DSSessionId, '_'}} - ]), - mnesia:match_object(?SESSION_SUBSCRIPTIONS_TAB, Pat, read). + MS = ets:fun2ms( + fun(Sub = #ds_sub{id = {Sess, _}}) when Sess =:= DSSessionId -> + Sub + end + ), + mnesia:select(?SESSION_SUBSCRIPTIONS_TAB, MS, read). -spec new_subscription_id(id(), topic_filter()) -> {subscription_id(), emqx_ds:time()}. new_subscription_id(DSSessionId, TopicFilter) -> @@ -568,12 +600,58 @@ new_subscription_id(DSSessionId, TopicFilter) -> DSSubId = {DSSessionId, TopicFilter}, {DSSubId, NowMS}. +%%-------------------------------------------------------------------- +%% Reading batches +%%-------------------------------------------------------------------- + +renew_streams(Id) -> + Subscriptions = ro_transaction(fun() -> session_read_subscriptions(Id) end), + ExistingStreams = ro_transaction(fun() -> mnesia:read(?SESSION_STREAM_TAB, Id) end), + lists:foreach( + fun(#ds_sub{id = {_, TopicFilter}, start_time = StartTime}) -> + renew_streams(Id, ExistingStreams, TopicFilter, StartTime) + end, + Subscriptions + ). + +renew_streams(Id, ExistingStreams, TopicFilter, StartTime) -> + AllStreams = emqx_ds:get_streams(?PERSISTENT_MESSAGE_DB, TopicFilter, StartTime), + transaction( + fun() -> + lists:foreach( + fun({Rank, Stream}) -> + Rec = #ds_stream{ + session = Id, + topic_filter = TopicFilter, + stream = Stream, + rank = Rank + }, + case lists:member(Rec, ExistingStreams) of + true -> + ok; + false -> + mnesia:write(?SESSION_STREAM_TAB, Rec, write), + % StartTime), + {ok, Iterator} = emqx_ds:make_iterator(Stream, TopicFilter, 0), + IterRec = #ds_iter{id = {Id, Stream}, iter = Iterator}, + mnesia:write(?SESSION_ITER_TAB, IterRec, write) + end + end, + AllStreams + ) + end + ). + %%-------------------------------------------------------------------------------- transaction(Fun) -> {atomic, Res} = mria:transaction(?DS_MRIA_SHARD, Fun), Res. +ro_transaction(Fun) -> + {atomic, Res} = mria:ro_transaction(?DS_MRIA_SHARD, Fun), + Res. + %%-------------------------------------------------------------------------------- export_subscriptions(DSSubs) -> @@ -586,7 +664,7 @@ export_subscriptions(DSSubs) -> ). export_session(#session{} = Record) -> - export_record(Record, #session.id, [id, created_at, expires_at, props], #{}). + export_record(Record, #session.id, [id, created_at, expires_at, inflight, props], #{}). export_subscription(#ds_sub{} = Record) -> export_record(Record, #ds_sub.start_time, [start_time, props, extra], #{}). @@ -595,3 +673,8 @@ export_record(Record, I, [Field | Rest], Acc) -> export_record(Record, I + 1, Rest, Acc#{Field => element(I, Record)}); export_record(_, _, [], Acc) -> Acc. + +-spec ensure_timer(pull | get_streams) -> ok. +ensure_timer(Type) -> + emqx_utils:start_timer(100, {emqx_session, Type}), + ok. diff --git a/apps/emqx/src/emqx_persistent_session_ds.hrl b/apps/emqx/src/emqx_persistent_session_ds.hrl new file mode 100644 index 000000000..54b077795 --- /dev/null +++ b/apps/emqx/src/emqx_persistent_session_ds.hrl @@ -0,0 +1,56 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- +-ifndef(EMQX_PERSISTENT_SESSION_DS_HRL_HRL). +-define(EMQX_PERSISTENT_SESSION_DS_HRL_HRL, true). + +-define(SESSION_TAB, emqx_ds_session). +-define(SESSION_SUBSCRIPTIONS_TAB, emqx_ds_session_subscriptions). +-define(SESSION_STREAM_TAB, emqx_ds_stream_tab). +-define(SESSION_ITER_TAB, emqx_ds_iter_tab). +-define(DS_MRIA_SHARD, emqx_ds_session_shard). + +-record(ds_sub, { + id :: emqx_persistent_session_ds:subscription_id(), + start_time :: emqx_ds:time(), + props = #{} :: map(), + extra = #{} :: map() +}). +-type ds_sub() :: #ds_sub{}. + +-record(ds_stream, { + session :: emqx_persistent_session_ds:id(), + topic_filter :: emqx_ds:topic_filter(), + stream :: emqx_ds:stream(), + rank :: emqx_ds:stream_rank() +}). + +-record(ds_iter, { + id :: {emqx_persistent_session_ds:id(), emqx_ds:stream()}, + iter :: emqx_ds:iterator() +}). + +-record(session, { + %% same as clientid + id :: emqx_persistent_session_ds:id(), + %% creation time + created_at :: _Millisecond :: non_neg_integer(), + expires_at = never :: _Millisecond :: non_neg_integer() | never, + inflight :: emqx_persistent_message_ds_replayer:inflight(), + %% for future usage + props = #{} :: map() +}). + +-endif. diff --git a/apps/emqx/test/emqx_persistent_messages_SUITE.erl b/apps/emqx/test/emqx_persistent_messages_SUITE.erl index 32e59a114..db025a457 100644 --- a/apps/emqx/test/emqx_persistent_messages_SUITE.erl +++ b/apps/emqx/test/emqx_persistent_messages_SUITE.erl @@ -103,8 +103,8 @@ t_messages_persisted(_Config) -> ct:pal("Persisted = ~p", [Persisted]), ?assertEqual( - [M1, M2, M5, M7, M9, M10], - [{emqx_message:topic(M), emqx_message:payload(M)} || M <- Persisted] + lists:sort([M1, M2, M5, M7, M9, M10]), + lists:sort([{emqx_message:topic(M), emqx_message:payload(M)} || M <- Persisted]) ), ok. @@ -146,11 +146,11 @@ t_messages_persisted_2(_Config) -> ct:pal("Persisted = ~p", [Persisted]), ?assertEqual( - [ + lists:sort([ {T(<<"client/1/topic">>), <<"4">>}, {T(<<"client/2/topic">>), <<"5">>} - ], - [{emqx_message:topic(M), emqx_message:payload(M)} || M <- Persisted] + ]), + lists:sort([{emqx_message:topic(M), emqx_message:payload(M)} || M <- Persisted]) ), ok. @@ -252,9 +252,13 @@ connect(Opts0 = #{}) -> Client. consume(TopicFiler, StartMS) -> - [{_, Stream}] = emqx_ds:get_streams(?PERSISTENT_MESSAGE_DB, TopicFiler, StartMS), - {ok, It} = emqx_ds:make_iterator(Stream, StartMS), - consume(It). + lists:flatmap( + fun({_Rank, Stream}) -> + {ok, It} = emqx_ds:make_iterator(Stream, StartMS, 0), + consume(It) + end, + emqx_ds:get_streams(?PERSISTENT_MESSAGE_DB, TopicFiler, StartMS) + ). consume(It) -> case emqx_ds:next(It, 100) of diff --git a/apps/emqx/test/emqx_persistent_session_SUITE.erl b/apps/emqx/test/emqx_persistent_session_SUITE.erl index be3bf6e6a..008305671 100644 --- a/apps/emqx/test/emqx_persistent_session_SUITE.erl +++ b/apps/emqx/test/emqx_persistent_session_SUITE.erl @@ -510,6 +510,48 @@ t_process_dies_session_expires(Config) -> emqtt:disconnect(Client2). +t_publish_while_client_is_gone_qos1(Config) -> + %% A persistent session should receive messages in its + %% subscription even if the process owning the session dies. + ConnFun = ?config(conn_fun, Config), + Topic = ?config(topic, Config), + STopic = ?config(stopic, Config), + Payload1 = <<"hello1">>, + Payload2 = <<"hello2">>, + ClientId = ?config(client_id, Config), + {ok, Client1} = emqtt:start_link([ + {proto_ver, v5}, + {clientid, ClientId}, + {properties, #{'Session-Expiry-Interval' => 30}}, + {clean_start, true} + | Config + ]), + {ok, _} = emqtt:ConnFun(Client1), + {ok, _, [1]} = emqtt:subscribe(Client1, STopic, qos1), + + ok = emqtt:disconnect(Client1), + maybe_kill_connection_process(ClientId, Config), + + ok = publish(Topic, [Payload1, Payload2]), + + {ok, Client2} = emqtt:start_link([ + {proto_ver, v5}, + {clientid, ClientId}, + {properties, #{'Session-Expiry-Interval' => 30}}, + {clean_start, false} + | Config + ]), + {ok, _} = emqtt:ConnFun(Client2), + Msgs = receive_messages(2), + ?assertMatch([_, _], Msgs), + [Msg2, Msg1] = Msgs, + ?assertEqual({ok, iolist_to_binary(Payload1)}, maps:find(payload, Msg1)), + ?assertEqual({ok, 1}, maps:find(qos, Msg1)), + ?assertEqual({ok, iolist_to_binary(Payload2)}, maps:find(payload, Msg2)), + ?assertEqual({ok, 1}, maps:find(qos, Msg2)), + + ok = emqtt:disconnect(Client2). + t_publish_while_client_is_gone(init, Config) -> skip_ds_tc(Config); t_publish_while_client_is_gone('end', _Config) -> ok. t_publish_while_client_is_gone(Config) -> diff --git a/apps/emqx_durable_storage/src/emqx_ds.erl b/apps/emqx_durable_storage/src/emqx_ds.erl index 941573bf8..c8199239f 100644 --- a/apps/emqx_durable_storage/src/emqx_ds.erl +++ b/apps/emqx_durable_storage/src/emqx_ds.erl @@ -30,6 +30,9 @@ %% Message replay API: -export([get_streams/3, make_iterator/3, next/2]). +%% Iterator storage API: +-export([save_iterator/3, get_iterator/2]). + %% Misc. API: -export([]). @@ -46,7 +49,8 @@ message_id/0, next_result/1, next_result/0, store_batch_result/0, - make_iterator_result/1, make_iterator_result/0 + make_iterator_result/1, make_iterator_result/0, + get_iterator_result/1 ]). %%================================================================================ @@ -97,6 +101,10 @@ -type message_id() :: emqx_ds_replication_layer:message_id(). +-type iterator_id() :: term(). + +-type get_iterator_result(Iterator) :: {ok, Iterator} | undefined. + %%================================================================================ %% API funcions %%================================================================================ @@ -174,6 +182,14 @@ make_iterator(Stream, TopicFilter, StartTime) -> next(Iter, BatchSize) -> emqx_ds_replication_layer:next(Iter, BatchSize). +-spec save_iterator(db(), iterator_id(), iterator()) -> ok. +save_iterator(DB, ITRef, Iterator) -> + emqx_ds_replication_layer:save_iterator(DB, ITRef, Iterator). + +-spec get_iterator(db(), iterator_id()) -> get_iterator_result(iterator()). +get_iterator(DB, ITRef) -> + emqx_ds_replication_layer:get_iterator(DB, ITRef). + %%================================================================================ %% Internal exports %%================================================================================ diff --git a/apps/emqx_durable_storage/src/emqx_ds_helper.erl b/apps/emqx_durable_storage/src/emqx_ds_helper.erl new file mode 100644 index 000000000..5b55831d1 --- /dev/null +++ b/apps/emqx_durable_storage/src/emqx_ds_helper.erl @@ -0,0 +1,73 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- +-module(emqx_ds_helper). + +%% API: +-export([create_rr/1]). + +%% internal exports: +-export([]). + +-export_type([rr/0]). + +%%================================================================================ +%% Type declarations +%%================================================================================ + +-type item() :: {emqx_ds:stream_rank(), emqx_ds:stream()}. + +-type rr() :: #{ + queue := #{term() => [{integer(), emqx_ds:stream()}]}, + active_ring := {[item()], [item()]} +}. + +%%================================================================================ +%% API funcions +%%================================================================================ + +-spec create_rr([item()]) -> rr(). +create_rr(Streams) -> + RR0 = #{latest_rank => #{}, active_ring => {[], []}}, + add_streams(RR0, Streams). + +-spec add_streams(rr(), [item()]) -> rr(). +add_streams(#{queue := Q0, active_ring := R0}, Streams) -> + Q1 = lists:foldl( + fun({{RankX, RankY}, Stream}, Acc) -> + maps:update_with(RankX, fun(L) -> [{RankY, Stream} | L] end, Acc) + end, + Q0, + Streams + ), + Q2 = maps:map( + fun(_RankX, Streams1) -> + lists:usort(Streams1) + end, + Q1 + ), + #{queue => Q2, active_ring => R0}. + +%%================================================================================ +%% behavior callbacks +%%================================================================================ + +%%================================================================================ +%% Internal exports +%%================================================================================ + +%%================================================================================ +%% Internal functions +%%================================================================================ diff --git a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl index 06cead725..9b1ff5c7c 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl @@ -22,7 +22,9 @@ store_batch/3, get_streams/3, make_iterator/3, - next/2 + next/2, + save_iterator/3, + get_iterator/2 ]). %% internal exports: @@ -42,7 +44,7 @@ -type db() :: emqx_ds:db(). --type shard_id() :: {emqx_ds:db(), atom()}. +-type shard_id() :: {db(), atom()}. %% This record enapsulates the stream entity from the replication %% level. @@ -71,7 +73,7 @@ %% API functions %%================================================================================ --spec list_shards(emqx_ds:db()) -> [shard_id()]. +-spec list_shards(db()) -> [shard_id()]. list_shards(DB) -> %% TODO: milestone 5 lists:map( @@ -81,7 +83,7 @@ list_shards(DB) -> list_nodes() ). --spec open_db(emqx_ds:db(), emqx_ds:create_db_opts()) -> ok | {error, _}. +-spec open_db(db(), emqx_ds:create_db_opts()) -> ok | {error, _}. open_db(DB, Opts) -> %% TODO: improve error reporting, don't just crash lists:foreach( @@ -92,7 +94,7 @@ open_db(DB, Opts) -> list_nodes() ). --spec drop_db(emqx_ds:db()) -> ok | {error, _}. +-spec drop_db(db()) -> ok | {error, _}. drop_db(DB) -> lists:foreach( fun(Node) -> @@ -102,7 +104,7 @@ drop_db(DB) -> list_nodes() ). --spec store_batch(emqx_ds:db(), [emqx_types:message()], emqx_ds:message_store_opts()) -> +-spec store_batch(db(), [emqx_types:message()], emqx_ds:message_store_opts()) -> emqx_ds:store_batch_result(). store_batch(DB, Msg, Opts) -> %% TODO: Currently we store messages locally. @@ -112,7 +114,7 @@ store_batch(DB, Msg, Opts) -> -spec get_streams(db(), emqx_ds:topic_filter(), emqx_ds:time()) -> [{emqx_ds:stream_rank(), stream()}]. get_streams(DB, TopicFilter, StartTime) -> - Shards = emqx_ds_replication_layer:list_shards(DB), + Shards = list_shards(DB), lists:flatmap( fun(Shard) -> Node = node_of_shard(Shard), @@ -164,6 +166,14 @@ next(Iter0, BatchSize) -> Other end. +-spec save_iterator(db(), emqx_ds:iterator_id(), iterator()) -> ok. +save_iterator(_DB, _ITRef, _Iterator) -> + error(todo). + +-spec get_iterator(db(), emqx_ds:iterator_id()) -> emqx_ds:get_iterator_result(iterator()). +get_iterator(_DB, _ITRef) -> + error(todo). + %%================================================================================ %% behavior callbacks %%================================================================================ diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl index bce976559..8b2e3cc61 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl @@ -368,7 +368,7 @@ rocksdb_open(Shard, Options) -> -spec db_dir(shard_id()) -> file:filename(). db_dir({DB, ShardId}) -> - lists:flatten([atom_to_list(DB), $:, atom_to_list(ShardId)]). + filename:join("data", lists:flatten([atom_to_list(DB), $:, atom_to_list(ShardId)])). %%-------------------------------------------------------------------------------- %% Schema access diff --git a/tdd b/tdd new file mode 100755 index 000000000..197891df6 --- /dev/null +++ b/tdd @@ -0,0 +1,13 @@ +#!/bin/bash + +make fmt > /dev/null &>1 & + +./rebar3 ct --name ct@127.0.0.1 --readable=true --suite ./_build/test/lib/emqx/test/emqx_persistent_session_SUITE.beam --case t_publish_while_client_is_gone_qos1 --group tcp + +suites=$(cat <;NBzUP%>Hf`Lwk&23HlkB-ODpXW7 z4OCSBI8pzDzsYFt$ie@tGm@7%L$yr&UrbSA5Ea#KD%mrq)tn-RI-KoPuY6w~3m51J zAEDv)d2KJ>nop%i6~C7|%IDzDgk+58zJS-Ie^#f#e7+W1vfRaISG9hbrfzt_|2s3AUv{Gn}aZF9z4p$fhI{r&jXk&%(X!NF1T z2fxAR*Doe!X69YQXQ;co=HPqEqtPDZPi^G1Uq|`UhpEZQ$w^5`uU`FbNLD>?;6S`Y z=63VPa#v;q@0XO6goK17CMNp%`K6|&7LY&q4W6Q?s3<%kH{bV=KJ^W zb8{}I=^x9*x4iSce?Nl%ikyPNm#0TB@291ud|Bz=mpi#HsKRdc_qUxP2oZEFkg8&22Hun)~yoe1S_`JSzSM z`MRaQrTFVgsjaQBuuzRteW+TEGgyHmNcd`(xs}z| zs;a8bC5k(HJ0 z?CexYWH}=&EF8clmhZW|^ybZ*`}b*2)8kg%JM5deFTOUedoHVzNO`NdHjk~z*Qesa zwkEyQD-g73dF4FO+tk!FJL}{xeav%dp`}GDId0P`j-YGRQDVZ8N~w(+YHCSpDcg@| zywJ+n%g1+qz?5>45dXh{bLCT2mHSMGqmV^QO-suO$B}l4g>Q87Av{y#dv_k;&t;+4pqmSKN<5U+ip_E`}tGtxio>Fd0N$rue9y?9DMrp zX_Y&aOZ9E)VIg1B%WrUCU~asU*TCtUPhDNzJl>=4!KkMvZaiytrcP{Oj)e&vj0?O!g7Wh3$+Dxfma!zf;I6qQO^sS7z%%r|anA z5BBB0p&{KOm#O!35?^jgk-y)N{*gQ@`FB)?*RNSy8|2wsk(9idljCS*HS&?&Q(;1S ztCEtEviKk48#l(J=)@+=wkhvFcraSrU3=g>_@M}e!(l7$K%n%hYz1V?fd!jad>zohs-TG z3yUFy6uV1bEY@$LC&b{1@MiKCmU>AC32sCJjg(~bx8+@>G7V*<=KxCR&8&t-4(Q+ zbXi0OI;a>nStrk1guudEjPB^@(0r-e+Vw+xszDWn!M69ypZTukVzb7SPoK_NTaR+< zWKY&#CpJ`5noe_?&J6zXR6RZAOG3_G&C-tlO8LX?`276*+}vE>Z3jnMGTwjvsx)vy z^U%qYfAFmmOS5rEyt7}J4BRg9hmPd^it=D}Y|p(xq{Ua4Zp~gcH#bL`OIA$`V3%NH zW9wADt{YG9zU#HI|Zj(m2{g$wlFM1~3%QckRo6t@42d^`5Nd}(Rv z`$rQ^=-5O}1$L@)HdYCBaYdmpyfUedN=Qh^xcm{nw*BLm zZh7t6;Uh;1t{+QS&+b;!)wPVhkS%6An3l6{{rdEanbHe41{#uuI-P!OFDXz>RGK=( zy>{KYjW^G0YC4uT7c!)mEX_UP*3owz&cYAH8LVH4LDnOb^6$09a=0yvGgvN7W2TE9 zwcu)|b7P7+yT|M$UEOw*X!omp%=&I))spKtLXw)>=aMLhDi_Q7xL%io_&-VIAj*Wd}4`dPgnq40h85t=V`=G+uCQrAv z>g}!dnrYhV7cU+Ts$key-dhz;{PFzx-654%_(OM7rx#7P{h9dX`O8Iuskrd@^E33` zeSLjhwyj-1dwZu7B^LYR0>uaLSIz%Yc9+Y+Ozkuk6UrYRD43e|VRsks($=$c9ywAz zWmx>DH~g$0qi^+>h?DhLcG`6k5)xwqD75h^&L3GF3&%dN@P`gzt27Kwu?Sh|W|=i2 z3?4su@+2&bGs+n|>{?b^dwcucyVNA}U)9m+_5BvNUg{Uk{rMGLT~QG#?w-G!8b$Qz z(W93?2B7NM*xTzNryzOEO%1yG>Zz*QA~UR8ySCp>IB!9vq-;AAljg;Xe|`i?2%kKu z*O}hWzk~Mn-Me?C?rg3KJD&QVx3%`~+j^LaEed>7pP&&3npv# z?cIyras1ZX-?KjgN4rWqJM!&Ei|4IIc@G~tgfi5TWgb#%T3B`g(H#;Qxri-1?MPhZ z)hj+GrjU2<-Yv0Fifjzi#$-z6p*oikA75^km7kZl>C5#C7cO*oGRRAWhK6=T-@kwV zO{0b=`GW}XmyT25&?$1s8h$hJtD5HKb%2%1jlc1FWfSY&f4^NHE@;`7dm}C}(PZ>j zb)?7)^7pReW{(RB=HqoGczH*aJ?H9mtc$V?-`=d;=x_ySEj4v8p6M248_cauX1um$J$C3hpoSF=ZRF{m`4Km z5XjCOR8dxxBw;_$@Xm*Z-E}Af6=LDFie5%`b~f-BDM63&m5*XpAfGf( zr~)Gki)(l3zbN&zrPmb|6|oMQ>0{&L1B>c_8aH>(&d;ao=8eyo%nqccOG-)6Y~0A^ z)cx-!7QuxjntS*B{C-u1I}&?yVZ74azN+hSXejc|4kjic!+n%U);-7b7q=hoxc+_< zHm*RZ!fHMZ4-bzSb|6j;YDqUYx6$-_0RbAE!7fF`K)v%5bP@}{3r34wFRnt;N6jp9 z9PM-+$us%!!9Ut#uK!BWLUY7P+pC?0&N&?pB#EUly0K7f{e#hNua$W^{Q@PHoaQF` zfn57W|Ayb`6>NCl6j?K;+~{KF)hep{_TS5V0pol>1_uYDz85DZ{)%+$Y@JVyh=_>7 zHvt-=z?Mz_oayo`aTsp%_V$)%FTySl^j!E+vb;Fz<>i&r<>89U00Wp_yLOG27a%-J z)Oj55p&&osV>klH7?`UAcqYAgE@ikPEG&$GgmsyE|K7ZLvs!$92(Y5B`2ytyd2Ayo zkr9N~ma69F83Zp1f6w=FKUD6{^vfL{^TXy%FD@21q=keu1Ki)2#-gw+E-tP;{pr)E zS-?#$#!8eSefKH#V7V;Q+Px&2<+r_@}Pst;?CngQI@8b=0{~@WN2D5|4!+_#3>3DoxVStKy?l2n1W$B`hv%z>py&K>&eYTtI;5Ms8MbdfDqZUHC&4 za@uX=?d&FpbGkY`<0ucVyYaIS`FDoTT~RrK^&H-B5;DGeb=|sk(_Nx`^ z2L=Wj6gu5Fb;@g223h-R=AU%W#nj!@S2Od_G0oEq4-KL2M#`|e+s%x0`1|{B*sy`O zc=lOzbo6W@D=TYs*~z$iHPVcp5@y-zKv!@faobHDFM4q8kv|hEB}3< zy{+l)*_1fovx$)K^Z+Pk^gF=KZHevT?y-o?Ra#mqgL2fhyyO^d;C80JuMhFHp2%fU zQ67!znzF3x$!l`z!Cx$R%0*mU95rs@_wT32&6`lC;sb8^9oy+I)PDUv-f5Qe#MQ8s zg?#Wg*mqOkq^GBUg(#Yxon1@ASUB-jgsDTW|FhfDpI@k)*<|RUSL9-kEMTDLeCDLh z&rdHdX1>nMoEh!%T%2l_U%8R)gMX2>$M@de>?@T|5)qNfiBWP&O5*kdYSFq)Mj0ng zo-CUE?uV8aZ^z;vk32NdQHbBNvNAmI`#W?WKYWmjq@?BU5@WY^N?xv!Qd0Vg@N))c zNQ`p9#)`6WbPN%$rM{A-onz5D-foK=>o_t60EtyXGHr-m37UWZf>jqJ5^>!8=inpD zQ|X?H5hupdayt3Lt5ojnyn*!(dhppqC>hop!PXa@XxP&sO7U|@V=LMPuYIXs-ymSv9K)C!P`*segjDtB0K zBjY!{)FXPwC&9sbSbt@S#mV+O+xX3E$q)RSWu;;CdYGD#F*ZB)4P8MI}~ zmcLC^?*5fmT}oY7Q*-@$*#;d_LQG7lSr#I)EkzK8lgO|=8#PkxmzNfa#GIfQ4k7_* zZh&M}pqQpHKR-{`QoziHFC#i(V6gGQ^b#vGvoIm+PD|u&v*?Sq=AVrNcUWpP=*IWOt;HCJ&!gh_Uit1`!9v;Ru zv96@6$gl1Na{d*@ZAGp##CKYE5wVM0k52X^a$2NFbM}=+YE5-@d(=+?k6;0uot@Eu zR8&+DO{aac-VCzaQrGfg7h-h##&UH{Jt7-tX|rzKy4T*A{2smJ&#vq`TFVZSAmD%6 zix&(Yt=YbTfin{mEKm9J^78O3^E0CoPQMLpZ8HM{tI=8i`VzVgRn~pZj*c$yU*A1> z_~M0yg`uG#WH6$Ry?Ryi^()ag;bvtO6^qcOMn**BM;mBr=D5ySrD>!#nz8KM$)WHx zMk6(ZTj$Bsr=R#G$@gUVAZ`UB+edxgYt^l0tJk1}l!82x(C5@AUf zmlo%b@$;vore5K1E%V)W*@JP%jvc#paan#6Z)j*R=3}<8u|W>OCy-sjN-!MvV08KN zudk6JXv3dB-|y%+(^VKyZdCE*OGCe%PPWBSW?G;g8YZ3wQvsw83pvVuUw80tZFn`< znSIi27HGvkGE&dUDOW4Y7pdCcpG-Ymwf-6Qs_*u8b`4-{u&50U0Evwa4ZuGMFUX%aZ`9gbn|ia_^^%z@ff+b^b-$&tJbxu3TvZ z{xBt#efS_OA|fC8h8?{oT7r-#PwuS^dGu)OST1s3R#u}!7>LXC^mJ5Lkd+`V@d2Sp zVKZ}&1gRvy;THS>Ud!5=mrY1OphbMG85o3t0W)*+W1$M0HfdbxK{gkd*3aOL4w`1(5a84R{HO1?KFpp-YQT$=w3mp?exF; z?VXR6mDTE96ei)+-+WH{2A%w^w}v_-j)Mmi0sK2V#RUW!G_rjC{A_J(9troyfaWdR zZi?F}UwaL|0>Eg6C8ww7WSMyT1zVK;ppicbBGkxHhHET0FVES<1)T$4(b_d@?rvwt zvg0G$Ib~fpI7KGVt{NMo0)pgCZX7^?T)TGd-voD`YbB+gDjBTJu0V)dTH0T~{#4-F zqckGZ(E^Pn2O0HY>&wc^*Z12Y_Q4z0*4CP3X~)%~e!e4E3W;@Ubo=;f0d&CqTSf~CZAK7alk8v#(DW{`qV z7VoT3MSZJ>hlf$6-l$uGWo&C|N>JlWREjji`tkFt@Px=mGVGI_QB01Aj0Dwwd}r0O zvwqV4I}g|kkRySw{cjG}d}k`aCgPw#-cDdi3yX@t*0_IueE5wepA#Bauq;xkjR}c~ zj&^n}g7FCAVBwwQi!gKi&Cc?5?d=ia;pQkkX!1c3*ViAz!!~7KNLB-m9C%)O79{9U zXOWKX=EP^ujKHxq>$|3Drzy$GUYb8fzS?iszqiEH+|ckCxH-=9M^8_@O0p@*un)EN zb43LfSU8gB$dSs*N^_8{2=CfwXMu|Ua;Z{uE3Mk^pxd`^Z#$^e(k6%(tC4yf4CzoS z)*B3PQqrO1%m29dPWIQM`e27PztjWZ6BpNytL4&ua|r9VLZ%viT7hPzpVSQu(whf8 zktSGJL@32hmUUa7<6lO0B;2|sd z>JGIcbO)#A78cx2)92?{Ui})egPwl3y}~Je+{m$Gs)_Yr3GiRp*`t5{G|tj4avC$D zqoYf%r}P|e@2>2E_n~Q)LBYZHD4w4`%Y6&63%xs287LtAb|_0Z+G#)+ zb*2Jn1vb#o1pR%1XI5Sy-wQiy(yo&>&r?$5{;P%zJS{!1^0yQf0BAwX36_hmHEl4n zH!^yr%M&6@VX_`DuPpoRY}6tkN4&t6)>a4^fRNJUMMp9tXxGdCy#LoyH3f7Z#0vw1 zE*}~uGl4K<%XpQ;_QK?~+P!Hd(a|17u|+2YmPGRq!=+1?va>9o#l<1<#0PAkwar#c zo*pJ8o<9Uy9hEYKM{g%;+TZ;h^Y_V;E^<8qzknQ!^(@kle)tfCJ+>C!4XN3nZ>QX(nO)&vR-ZjhXNIacndX7umHa_t-(!1<8c1W}-n6{n}CQLVC56m@kY z5M~Xss}^pyT8Llo_R>i0D0H^Hcrmo3#8Xc0o^;&B-0QJOyek{xl;?`5<5lMJ#uCLB z)W7Q+OB9xG$P@M|R3UQMZg#?H8sVsk;B+59eolq}j0_C>SDvxvh#f!R*?aeZF;Tzlu3vv3eAufi6;c#8w_;*_e0+S?n>S}U$7B>El9fw0ILa8!UI0w63(r?Ao=~BG()!J7!tiw{QRP zF_7TJ8_n$T3UA%I_4nmjuPpp^S*UshX2R_-FlePUp>JwwX+eU?u-&$O`vH3eDk&XJ zO-&`G0CFUvw}3_njta%zkCA(Pa`N#?1m#lyy)9ibq%JWfNi%?pXJ$Txi@a~bbJW+@ zXK4>Z9^zt@cXKPmjzV*N=8ShkEhWWxUZ9}z?t|!=@J%RvM!z^DBn;@i5tJa5*R5G| zj&)n<-OK!;fJSlk{n-|+DtdZim)@<((oU;2eOWwb<|DahWz$OwQ|`#?p}YmDMNLhO zy2OH1_VFW74-y}hhf#(Ws5g7z4z$5woInpf3JPj8n*s*{MfW)cz?V|5AZNat|Mvt# z^_v8O3SW=lEAcF8G>bfL{sfQq+E`Xg>*>m)F<)F+@7tOG&-79n3|vFPJ468H+nZm$ z948l#yob7R^ymemERux}uWx~r3~H$Le*uE5tiuBXZS!a4FJ~3l8*0GG&4(4Nq#Kc(Q}xSGau~~YPyz& z1|yN1c9Gwdixj1s;Pd|9QWF`8+USq99R2GBxEK=2cS+LLcH&pGXH-OlMTQm#sZ63- z+q35qB*Be7&!RhNs3!N264cOGsUBj;G^xg(gWAWsaF)@pe7qM03b+D4n3A%h;~V~7 z=^>cqfE_ojyJcw^4&)uGz~SmT4_&>*3h2SJ`Vgoq*WOSQLX9FFqZ9pIO2{0LDhN5T zWL}Y~v^4pD&7k>yv(d|cqS2kD=@m@J-eGIYvpqS9HDF_4Fm~+?QWnqq5g@|mK3VIzIB4L0f}V<&IkVNRyL45WMge%c*RT0K zJ(nLpX7HD`MYFlsd(~}qGAivtm$jK0=gRjzDAjHy-hkKJ__(+@L}@BZX6sOIFWBKX zQ2hY{ktiSx*S~!myUZ^NsY)ygk{;8VwAt@y&e9fDIy+l1Ugabh7?cn+@z6@P-Y%T!bd>IaNQq1W$+RMec!F2F8LRRg$ zz?&4*IXPVH5fZQxDM>3>sLv5lAjrO4y`?M(o0A|j;L){`esUL=Dp(}tuAt9RV`pI_mM=SQQSovnzn zuc-;!g^8M4L$@_)k`Nd?u{F@a5^}J~Dz8=QDk`sSe<`6OcxrU2Wr}%;G z3miwA(E8?Bq4STx-?zNW8%Rk7qdI@>{7RmTy-E49ZaDz~frt|~zCW{MxuC17YiJnL zaMUw|N#E%k#HO|!>n`-*Yfqb6S&6$&TNoQZZ^*-U0M%eow9_;a>(MRY*{h-@4AL}s zwcmWIug|pp_>9u+Gjpz_LLOQCjD`jn`eOZbCb~7J`#Q5jLAHZ!Z8$pr1)9?v5J&Fw z(~4mNvTnU#=iR4UO#<1(I4$|8nD;A1i$_L9y=*jd9B#Xkq4oX853~qK-4Jr-e$K6+ z0I9Vrfm7~l$r66=-p2n9FsJ7F8DSL}6h&MMMgI|Nf0+5G=Qyg(dv)e4k=pNuc}A&16@_O5H(Qx_#qWR4!!FzCKfw zejr||+kv>Bjsi#XthO|r96>=rFkI+fHBte+dTjDmP(|Nr%CZ}_yw>7^0*LzUQY3!l z$dP^f;$FUdDMLc^e<&|MJMf&!>s$4I>P~dD0VoLpccK%>)mf!rj0^a20z~QBg?z|}*{;0>5J><)I!b&xYhWLL6 z(Oz#?ve~&8d3h&=gqpKo`M))MrNxC!3QZ@$ay6CLZtAqOwDojs4Q5%8+PHXm*^zc( zHbP$|C$~|Ao80T~U#X7kv|)AA*N;lGRMpaIX>9E2@87X~`xSFik{V}mJsnl457f8Y zn^^3G^&A|s{roBmohPtS|BrtEKR9eo@ABmzV42Zt{Ihy>+_Pt}S+!(?n|mb8K}976 z6kh?_FH76f#l;1F2DHfKipd}Y<0%{dFA_V|Wo~MU6z7ebf)g!Ed-dwomNcZY@^Tml zD+;hndeKDw7Wo6}+EpeNhvL&t`_*_|;nU88mkBkxu zL<#d=!_ie#%(4GJd2c1AnXOiG!~q~f2rTE+)q{UmW-G$f1NMx#N-6JCg#wH))NI?=uRQzrpCNDd|MbFCUh@ANjNT;F-aRtH zLjmKZ^egm%M;-E3UrlUVo3@YS7DmRah$Ug+w#G*Ffu~ed-827TKtXha?*~I(33U3} zxB}ee-&g2Dc|PV?^bxYMzNj7~K~z($Fyb1v10Eh8Vyjav#=m@Ng*OO-1Q{1~^Di%1 zR(2*&_wSw6oFWHz>ABk0)@xu)%}KjN9KIOyw~|PAZr}dk@PpiAQ&I8NScI!sb{6I( zC4B;ifBd+5TN@*8S+tz zq~R&cm89TjE!-O@&@Y-RP* z@)8S})eJ58B{-C$6iBFD=aTaCUBF=gka2UT#m65I76$e(7YKX$^eJ1o3Y;GHW#2lfOXKr zP`~gix7MpufIH8hpEz*>!Y3=M`%uf#)AS4*eHzW+cnimsU@PhPEVw=fH zG%$w`93ZmmcZFn;Jl)R5#;Lxzz?X1xwMUW#O};ba`d><4-`Q!!9}08`9^b-(AsS#8 zl>XL0ylW{bDPB#!o&HeTiz2#V2=42fHTO(8e+XE3SO!st#b&E^b#eJ@{94{cLP`To zA+h*Fnbq1{Pew|VVN!+;0KTkI@$6Y2G5fT!@H_|9)i;Y4t z`}xyO)|$Kn*HM;kk|wU!6tT*o3c{q;)B^0HsVV&+2viu%WfuetEb87s3VHM9F8gFo zc6M0RRf;p4S`v3W%l!@cMc+!BRy#X-D;M=h(! zw*1VklwJNzVWuMcL~^!bw&ivQ(lM#KC)63&oQ^n4in?KEXLsYqmc%ZjRQ(7^$LN3k zBH2J^jVX(XiGivG7XpE&f$StNeL&G#N@Af~fVC_x?woExm*3uQaiqzPx3wj}bJ(m% zO#!;PuAW}Ga)o7=NtV50S#{0Y)5vwUj*d*}4&RtAoh=elvtX}Z4(lvQBmMa5owmJU z{*kEIz?tt8Vj~ZAwd4m5lxUT0kv|Du-bhA96ow)E4zbz6K0h%3_UzfSfnw7wKpEx! zTX{rJ^Mnu{Vmk3zLp;jNfty$(>6rB}Fx{dP&owo;`274KSO{_hy3uu87fm%idBTWQ z!Usr`KlDCfsmCYiL@V^_f#SZ)blFP2Ct4Xzz7{ED^Xe*n5L9PO->t8~8=ZyAjn)8&8-{jrn`!cF{zt)u>=fzeO5fO3yQp&sYlv}f0yh?63-NAicHP%v*C{+Uc7krUxQHFtM+)J{uFOZ2RYidEnS3YyLHC8l%LYZm=t@!Z+= z7hUX5m#rpcz>##W#W%UycZRMk1yccL@E3Mo-)jA#C7_(W+)NE2892%#a`L2oiBwHd z$d6x83CN=h^z_|>gY)J>bP62xkmi>@A4#XaSyNf82hfhC>y}vlQ(e5MjL?@mGU*z)!CrcgGmOM zWb6V|Pjyw*eQb`r%1%yBvvYI$vzxy9i8~k=uDcbg)$}JzDR?*o+HrQa#zNhzPvzz1 z&CQyHVt6{@Gdk*t!2-f=+FZQ1{r#8FV|47|?bf51NH`^jz(yMd7l2?rV`A%f1s&0m z;#@z_#pr-$LZ{QW9|wVQ$b@zS7N8@sUhHaf_N}?OIrz9n6j{+X==A7u_1xciTuyW| zH8nNbe`@NOGlXzWdzPH>Z$F3FOmMSc142R!CTkrMaywL^U_@leKTi?O- z9rd10iKO&&rsWN-BaC+??j70hYtS}=eO%cy4#s&z895EP%a%l3S`SIQM6e!3HcQc? ztYZ3XVr%w~($Z6mUb`xIgzHBPBjFy%zMVZ)^W!ev3LM82nWSy{!a}@edxco5~0p)88yNv(NJ%a@@!?h6>v}515_8|R!Z{7uphJEW+ zewRtpu^%2!4t(sc#qLs3c?5kRB*cV|xxJ$U4fJea9`=#wM-oY=P|C3268+Ad!Z*G{ z*O0mP9$nVADzmI8G1u&*q&~nTqiShUkbEeFTi3kBpo+#7%*$-pxN&R25FYK#ojt@% zN5)-S6l5fGJozz+ZLTNzL%9weN^)g2fno_N3XgxO@c$J>~kzQDOzpEr8Bj9;}g9KXxK-tjJJ{CTX8|I@WBZ58yj-Y<Wf(+UJv84vZmnF#t(guduw>YdHi{7`K3X81(PtSQQ{?!j}rii7*D!oDZJhD_VH zxAu~dG+|$zo4f7fbN0*`LJcRJx^J#w*;IZJp?O=BoMhd>^F*Drn3vv1>P_izlhtk!&y zmZk`z!AEdRNe3``H#OcEJTB44%{7dSzQbUGa3orkQzck+cq@BBEy3EjzDVC%FdRE> z-@bj8LBnvz!P@{yhuH-&U$!B8Rtsjw+FB@x%-gpgNBayPES!Y!SQAY&Zjsy=;O>Gw@uFyV$`azcn=@1c$ zFh}fUi>ztrK>w4Eq69k(2$r(1$T9wLahHKIfdvzkqY1NNuv|}fH^gnjT#)I!@Pk)Y z62ora-rvc5{Tfq4>FL%`kI-LhIb&8Wm>?pk_?XGtHFrNKz)uhN;pfjOD_4L146bRL z#LfGGflLxNpIfdz2UnP`#XDAEyK{w7-9LUDbDbW7REp1rZ5-!VF+Z}9jhuXIJ>9*D zVIV8~9(U?DGymhqkIP0zEt%Vy4Y8izo0ZBq+Tlfl0Sr+@Obx)ngEk$5X358hrMpF@ zLcKbngCS4|ZxpNpsIEu|Xc-}{ZT7QXeM=QT4|62m)-)VFaB#)dGBYwhfhHLi<}%f& zS&*AcxhYAQ3|B_}U_TQ)ew<)w3zMUtK0VU_IeF+vo8-NhKMCMz;zac`0K(H z5@1}$=tGCgU>f#n2$>#S(dw7gFI*r-G|~-9mXKU9Sf>xp0dm+y?$yleI5|0y?()Dz zQyhWS(Gs5J_qL-Y-p4`&;{*HvCbQz;vF1XO*uVcLM$mL~uYZGi3Pb?$7;=E}#0V0M zHpmddw!I2x&Xln){k{YHxyYeI=g5AIQZI7<4;Sq#>#kxLRMGb#55h7@AWXa_BBFJf zYTMfj9Y^(Vy{&0%JbvSQ8O#SlLOJ?h3Hvm9JHk)Smbln!kFFlBOqXwK;nP2g?t&DE z>T`#VU67d;`0C$(|3wHO>o66kym-MSCRX(NbsdO}0|$DV1-BgH;k!q3)> zo*7nG0SGk%x(I+$svY-dFq%xYt`1hqt=qO?s6bOskC^0$LJJLR^bB6tI+KZB_<_e~ zW;|ggZzGYau1-u;OGqHxVfk2S`$IUi2{F&CkrSF#UNZ~}sXPfUUw(D?iu??7q!lJ? z;5PuCNz-82vBMb+ClZcBNdxErKD@2TNjr3f*pNzL0{t+H4s2;O%Mu3@0Ez$eWoO? z(xoJ1Hm7FZdiI{)_r6d!K4yK9O>$~TTdDxymO%Gqd-hxxtufH1hM#6zbhm+wR#$)U;PDBjzp{vJ)?xjoLdX_$5 zJeGGC`sBmGa-=s9n4)2UjDSu~><3MKXPBxwJ31CI);E0g33-~sAN?qXb#kmad`43- zKZFU5IQ);;O#1{BY95{JFPIF)O%2{4Y{a;{H8w;}r^{fQc{;0@i=Bo>Q}ANEuF-g< zeSgXFX-STt1N-+EE>4pGWZ-SS=I*|Tfd;}q4rY(S@vnXd(8;H=oslu&`E$&d`b!5r ze7MIxLVoMktu`3QYW1*y#0o+$Cx@4dtMKB*F z{N{}}MKrdUs-HOx1SMzf3C zih0_7{n;nyS|0sgI$H8oM3L&phgZ5?5)1p^8~1))(`Lfa+#VQzU=K`T3)gbc$6kg3 z#aRr5fF&5*P?lvdT^sg*i2B8s2U`v0T!8oxHw;R8Kfr)bjXdzu8EWthm#ob2deFgO z9VQ_Yan$fV3P|FyPd*vH zM&|-KEFiB9VU?%*vMweAw)LtifBL-)8-oR12O17TYm)r*=30GQ$0$H2^&UWLxk@H3 ztyh3PiQLMOkx>|zMfW+(CPG5G6=Upge^v+scLvjZICRU`<%HdY!1c zW#p^ZueFQaoDUuWiT*+-X9Ve+805ip-N433PwA94_mTm(jn!`6(dQzI98Gp@HhFt< ztx?7-%)S7;?=eEZJX5lqp~rwe&-yx?2wV0^W7rZ!LTUfjsmEi}zHhJT)C*RKLh_xsy>DCjLO$wM>U2P9|F#Zk>YBL>eg zD5(QAIvP{1kiuW?Q$)lrvK+k6B@z#MQAY>lc}>@LGyy4HIgtRn&`e%uW zGQ7kLeSKZsII48Y%NObCB{n^BXpg7N2R~sP4ctVdZ~qIuf~J-hC*&J=p}+Lt9kX6= zFHv4#_%y-#?Q3%8%!o2(PEk_9C!t${ju%s$397)$WE%5a=6L%E6nRC(Nd7AuNj;)I z0NWTuMU!@v30ZX0PJz2FIOT)bYTvFueY>TtuQm>21=q~XKI_6n&dS7;14xP<5Z zv1|&#NT96+7*}1?Y~UI_>G&Dep#8yeq3@OIIxpT5K7f&Exog*FQB?lS&faG}dQ<;3 zc0K16B~@1S&8)(cAP)Q7-tngx`beJUjb+360Cn<8An`9>mdZN(tPqAioF;-YZhlaQ zgVpUnq1$DiUIeH|MM%Z6#g)GC zCIWWD9wb>~o)<|;S{V0*6hymeQ?!YQ_XD(L@E{{A&7)^P>l7JsFr*UWU6!V%xfZSb zy*8Y@@1COB=1qC@-S$g?) z*G4;wP(c{{%D-$^QBjeTmj?=XyjAcG=1V#->X-Ku!9lb*80ur>(mF-`2T-Ol+559I zUx>i*<6;?wi5=IVDZ|ABO2%7q&-NXc%w}GkmXf-)xk^-BR>1enI5JI6xc)OJ?Cd9P zj4`i|HapTwfFZ)ehJEmaC`h|3vndLX3fTL>=Jc*Y=VR`}S&4p_<MG%=SRU&&|(Q zsYF2O>FR=7M4PIpppfJP?q7}WDYO?e*{fF*{{8nY@)cB&x4$O9b~eAb_+wze1@9F~ zO1yg{M!#cXV!&R)%qA#^CY{h-?bgSA|8C@a#>m1#Vf5ubvnFW~bU$a$u3c0+o>lLQ zp`~q-b@lcA2L?Q)BTEbt0>syjKbCnq-Tn3!6cXMTGP~N;Km_dxH{(-EM^hG8RXT1O{Kp8Yg4?j2iIWfqg@UmUHw1?4e+t#g5-#*4vH;NJd`)&Q#zXH{GW|A7_-D*q7s^6dJl`ox`u8xvcZI=QwN(;2(qsK|+z;jAa`xZP?yTr5OumHWC5qE+_1I1TyT%+2NvnWhYx6Nk=S3n5IAo)8vTd|a8Y%i1+bfrgnnEH{ z-=t`l`%GkL=nj&)sK~&hYgSe~MbBZsYH!yyFem}8PfHuV!|vhq-SGaksewl9+2_$$ zuUy$8iI7N2t4&&U>BfztMaGbu}d@|YgSnPb-MG1ZlM!qQyej?YQY53v1S6TJ@SCW4eI8|8z*)_lAlXP zhX+wtjT311fH&<9esB6`y&HUVd6wZyN8#%Gw+jd3%*~7%=;?89^%eFUhG7w{;8$Hq z|2|>fQD+BXyT0?1pRgEep8FX#66O&IiUBM_BRk}b<{(C|{^uX4D0udsR;j z4#`iL>VYed&#teAm_?w$KeRG7K4XmmpWF1~eYN}In>92wZS&f{CQ7L+BIs;jTSUgZ zP@<&VP&+nygP)%tuB_xP0V210>96}noZr-^1J*|nXMe$ajYHD1&7BU-G1vV#fxb349m&{@e4!M9Ahi9q7D7 zJdf}Z_d{V}1DiFVGHlob`r4Zw{UlqU40WE%lv%-%Xhb7~v4Q5MEog-XOhq4_*_-Dy zw)f8aSQReisU2B_B*7v3J}?px$uVCYR^ju3KEnlWH8MtH6Ty7X`#Mcm?Qt8RRmiY(F!iw{2Tc zcekPMnRgD{%G?41j~CokFj;#Qa+jguDdlHpszkHw2Q?Z)Jjt|*xA&TX2?|w$L5atgvCfD4WS$0v zrl@YUL=uAxJK4Hs7&rw|G??QrK57~tnwqi|&&t#*U3eIV+;TVOUq>RBk|ttBEqjy)eLt2YI-z2trl2Mut(7})lV2V0S_(>uhNOW@*n*mX z1_y8zGHes21eOkjJZNWt5)GGc=*>`Y#`&{he!i5fCMN-xk_?Vf#90|9c5N=Yj!8&F zmIp9tGH8GBco?L;Adi2-4~PhEdf*yPs2gZZg;_^pe(2boIx)o#`VL>m!ore#0IC-p zgg7+@3SMMM2T2I$}^b5}OR zCm`=UCJ_*k=+Fk4UMn`v$T!R~L=6*T{F`?08xsV;`=kM1*U>H5`~;g4$ktKfxs0%a zES7x)iL);mc31}#AT*A6hW31WKdi-9KpbV5-EbISy*&OMnlrcp`(HZYV1NtBVa=K~^pa*~X2di+j%s>C1M@7VzrkH$Bli1F8Jm~@r0FaY2p3d) z9DAiPKj$Asy5J3a@bh4D9iSIozs^_0w{PFA*7~!N`=rs=>Ao@DGGTEu;rt=Qc!lmb zSlO+To$vrT1jw(4^#>aO8 z&fUAaLAOBUnVOy^BpVb>E-tPX9y#tH4n@vGhi36Af*`HN8lf!`)y_y-vzQ-KzR^c0x0kczla9dq^k5IphDluK zO-zODv?GoZEA?7s_5^60iCcRTRMZmaHB>e<+ctTk0L9$g!%)>Q()UasehVCfQP<_r_&vAW_W7`vIN+01<*pGMfXGFT75xmREZYAHIeJ1rWl-qnis(2QAh|(fODF zH+%cz!D+D{D!{Qp!FZ+#<9HeJ7#11hb?(U6Xna`F^5X>%m7ypAr*Gj95`I)1>fAn&!^a_q7IWH9CA+C{MWTd45O3_F( zb#_t+X(}pxMt{LzSvR82&D{l%g7M@AXdZO!#7R#`Yv@l}cm_5ITD7-W(QQRWYfNo{ zG<117ZIAbtaG^jm>1VwTXpedI^3`Ar0$qRU(iV8LzJGr<2{af+oIi+>eO%gec?r_+ z1-XAv_I%5~qcO;Q_Ds^iU^!S*e{5{57JOHStEy@}f(WoNPj>_d*L)uxRbEbd{`@C; zcez2^6T6Nb%S6})>^PRtu?ZXpu8gRck5_?SkC-bcD3DOMOqs{+gL=DH7oetzQ#Fqs zKMrQ14+pubS^tT*pWGofJn#NJ@l^j$YR8{I`Ji{h1TD1>4kqQzjPzsT2d%6aPQieQ z3b2C}$Q-@lQY@;H(K8coP~H1`Y07P0Z=xmrXS_ z55Q#y2gk?8!hSFdv8Fck{?n(-`GeKS15g6`G+9NR9^BJVB~mkp6Qz4Q1TC0BVQi~Z z^-KhVnEC2eQc6lb2ucv4I6@^ebK>z~)xHP9d!D2JC&AY~G{sJnpx_|sgvvwmM+f~;w+>_qj?uwTlN7KUaN1ZWt=IH+kj>NT;ks3vz(lUW0UgTZoititFjrlIeF*ulXFAm#ORST_B$ z$|V!S)kt2fC$8_tK~o4D)Ynj9drs0>Tx3!SJ9^T|6{QXs1!B8(&f(oulXfTZ3(Z(u0xR@X*i=Yi?qz;h-Pz->>y{ zQWf$6u>q-xdo0Yt{F19XLY#l+H1^$L)Em997n9#kYlINT{{6Jfe1@1K051Z!*sick zRFnSzPy_VzVqJU8nFsgdTYo4->U?|3?cslIziq-froEr zCj)~3d@V4iXUnhlGI{*);T9$)1bZINAw^Ncxkbe|Rue%#)SS*viYHct;FEE&wI)d5N#3+|o1x z5}OspO0UF2k0%7C4b{^TNMc4RoL(d8FJJQP+4Bt?DY4JExKN;Qu#^hI{oFa~*Ti5z z$6LtHknUlb#>n$=0RdE5`L#EI_kE^=s5&$Nk+3IVxWN2&$m7Rwdrd zP{sd8*L%n1{I~!AXHt<$5mJewB1uR|MN1{22pK6E8D%6%ibyIVAuSq|hD}1^9Yr!* zMrK(JqLjUU_g7ul=X+h>@8@^^(ak*1*ZCaBcpQ)8aj?dqL$0l&n^8*8EkkBu5T_5w*hG8zu=! zm$Q7BvI=S6Dc3>f_Z*2KKMt)l_L@4idsrd;xha1Vh?+v?mj)o%9xpPKlk8Hk9Z+lkH_DfnbUWdMb|{Qmajuu!$#?AW&81=}~b-E@(OqVzbR-x%YR5a$(M-PVf-%7S$;hAp!fu>i^9J$lQon zkCd$P0z}gNYWNbRDh5VIo}QjqG#&+=F?=s~V9MA&`tBmL>xciOt10#FeQLI0@aO^E z#^YYDxMixZ*->lKI7^@vEWUQOacwllgsBf>PQK9 zAE#y*oNbIPx|bZk@L6}zBT`%R0uw}7w?&@B7@l(MKdb%x2vyuan_)CLpNraDwa9m< zv#o0O>@g!JPGjE@OIEj@J^Lqp!}RuL=v|l+5e5r7-ofTlF6lit?MFZX)fr+7UeF(i zr|Aem4d=d*lv$Lu1}F@@PFDy(VBVPri8;=iW&eA&las}rB%4lU6%~gzHnTOrHg&sX z69hp5b0Y`?=Gs0K!s}A3phr*^J-d@{N{TSDGS#w)z@}0^QQ3!x^vqAa6i-yIPGn~K ze~YD3q;q#t*yH-{W+cA>Y2E^T= zKv|g+mhiw=x^>v#i|s|ug2QY&XR;wX(?u5tbLhklLcrlCwt=bvM`nHLoUuGSZS$zx zPc*WMVdtmKdh3WUp`>9EWTo%UT8K*`(dHOMJcetAT)~TdyVyEVq>?se3BSWpPn;;* z=VNgb!(lR{J5tJ1Z)bv_3w@QHx3UE1xev+%7tG9mRFd2r2coi80G z7M=3tfU*ym6ZlhNLc)|+-Z@9!e>WX?jJ9@|eGKR-=QdrS`Jz9G0uP;JIhdz7^Ox_SVSXUCi>P_wiy0C|W8hD{Dwz8ND>_H((nDn zPUDRef#U#*oM%1uCywy;s%Md?0)WUi^aJ z;%kWc*g4Xi;qgavbN8*gmK>lPZ3p=~vzR~3o^5Dg@E*x&?an$4n*=Jxg$p305C2)q z_X)n7a`tSDve&u9`|e|Slb~PUPBrw53t5UmXc*Qf)JR`nfB5j1J{K_PmAjtM&R}aB zrtAgrkX!h<>WUw9X3NyfBS&b~fr_!_{g7z8Oofqan&5Lgj6J*$ovX~{`$kvPn_ArO zZu^)s&zG?G_JKwC#>e>kdX2ER@83T)+Srb=?rpzfP5BFk&xHS1E^)KX?QxjNvsX7{ z61mKDaXIPwX{ykc%H@F^3R*>^4z{2QBC7bu-EVHH-aHuDaT|1Lopl(my`JR*Rw-jC z+ls~?h?&0+uCh92zR}Zs;o45>+-GN#o&iD1VTBa3-!FD^mdt$C*C{J@=iE%+<^y?q zCz~BVas@_v+EhCKv8o^TdGGhQZM%I>FI;+Z z;*R6>S4ON>^j^Bu-Yn(Q=qKh*zL%_vEt01KD$F{~T(M~UR{*xv3 zW=WD8*AzXpDN(8cc? zi|!sa$;nwqPoOEf1_QeNrb+_RCN3!h$*WtZj!@d_2j(7>dn>U@=h&2~Q>|P>GmfW? z_&8p3OKImGZ({-)-lSc+)ogFlkQwQ>!ad#8d#UXmJNe%Ww{pZAjQY~=iS@hvGsYxB zD(euH0$i45Y7>IhgNF|;@QN@X)*DOu`ku5xE%H-53)y_(x%3cp2Jecx#c*~2{ z>X{>TC;d@dx~{2u6Z(v?x2D;D@$KeaZ|X5=&6_1(Z7ci5Ur1|Nb#|#;c-6u^KL*`j z2=i@k@8%4tU!YGY%1$QmK|5UNSpc!qR&{fCpEPIOfj%@GJFe9#?LK$}Qd{nFLMw@= z?pnK*sFM#0>wDmg!uD%~e%9&fJJ97OQm&I6Vm1r-jueSl%yA&4yU(qyuUo#Gs-X&f z;#_WbhQ9c7T&Vp#(tEv-|BrMcPG3{=8mCxae^>DD#yOnB=h} zwiIx4C#P~lx})RDKfl(cPc}9%@D*xxu|n}!^z0T>RLF!khlG&imEXD>^f(6j zO(@K-Zz$-+nt*##4x97J%X3@Zp4+W8QXTz&|5yVC7aT0oSq&U@#czpg z#>Xuy7Pn`FmZ|#S8?3?5RwJR0LG+m#G6?b!NWfg(#;JSu5$;lj@+URIb^$g(se-pl-F+c^k+J~=CWhl}pr+qaR) z*dov6;>yg(&2iF`9HE-T9`aw3C>(k{hn1XqgfyOz2dz zauk(Udov#*Z|r5&@pgsEwhgayh515$6K9-A8VjQM>FYj2=evhbxTc|EVTUU5&Yd=A zx@!g*2NqT?S4l%eh6omDf~N}BgP-ynIBUt`#V|+uw&lsH`;ZI#i3rx%fY!|1+%Mn^ z(~r%1Q&V%r&nZY@M6?y~6zVnB3t#Hcw4!dc6|~``YunXn9ny{tb)Al#sP!7Xbcxj) zuOFZGHv^`EH*}W{rEq{jL)|8D%tlHWA(FBl7jr&zs8P`*S|%OCGEl6*`29ElP2h@A zTX=pc(OE|r9YeN8dQE?4m0rfh%wxxX^T_`|j>ga-iwuS2lYjo@b$(UHND`>b=$tJ< zq-=M|0&C4^tNJbnmbcxLe?8*8Qkdxn4&1q2_4MM}$r{n}miQye6Y*1RWsx*K7C{RMvC5m5FR#98?$}||i4#MQ z&lHxmbvW(;LJxi|-{+0=l!w`*Mi7o52SCautnR5dht8QrkOci)n~eC!0`=F-2S`Hj zy#ESjakOkV!DW?R8_hPrv<)3dG4aUFNmX-{P3N!rsH(E>m!Fo~FivVh8~-0!eP;2T zf==t`DBosSvth_TxxdD((+kt+IeL@r?GH{FMJbD@9!9~dmhb$0H+6NJIcgQk$wI{l zcGb|i`E`wrcl?Z>&7wz(BLOKaEelT3>=no6TqwVFYkT9t>dt$$j(6LB4QeN`i~QA^ zmF!MT!0XntGqZ)&uKwPhSm_F5u1ZSgz4fX~EUz;x^Syh$qGIag$x)5FFCM@+q3fCI zsMNjt)EP5s>+4HuTh^H-B`5dw7~f$2?QPcUH*c_6Fm}OrFch`5#jl!~)v?gq`%XI^ z*A&SKJ-gz?m{iA6T)y08^}QS3y~8{Iq5p`a2PAh8j?=)vS-_O?ss|0!^ch%rVC?@n z!znSk!Y%b}$Mx!_z%##{b;x=4<5FW4=_jB4mi@mcxWlG?Ncal)dwGD;mK8~NNVo;% z=WY?g&lr0=Psyw1Z-HZ$#|S30ZYH3SK&AkqD9?ze5 z$+DGuc9uI%`H~B`YVBIQ_@av|h1IPa-DVyNwp*ObN%b)V&Qt@fV1eMXAA&HlgDgfHJ z+FLAZppQU)gedVna7K6Zepmx%QeU0V9 zCk4w}(tHGReR*-&LCRkzT_>CX-QS1N?%n%4Hkp)AZ*IOn4Q5)P+$f#{M27*~#SX7LvJ>~z@!XE3w^JSWuXG`})zM*_3l z3*&tE0D&^b`9F`g_LdcMvq?wbg5r*$b4FOdtgjy=-|1W6AEr_4QWuVj`+tQ82Zb4k z#+Rl-Gor*Nr#gy+P@fV2^(_CQa0Rbk67SK6(;G5mn)e<8HS5O{UNZ^_x$2q~-FdSV zDT_8{`Jb^rzUYqj+yj3P-oWE!OJ3E~kTG5JE4j4IMVGvyXM{C)G2kfKn5o*(0RxI2 zJ&NNt8gE&VX9Es)|HzC`%Ni(5(`U`vR#hP^@FQ$~0i_0TU6V(1xGIPapKd*7A~+OvRQtTiCyq7zV6m6clpR<*GGKrfbRyN5x} zZSnW85uR3)9VWTHz|2txLV>%8NA3Qy(=)NFMy_3*MhFj(yLaC{h6aMx(b+>zd(w4M zUmuFtG|u7T0LKEMoWn*{KfEB+FN+}6?Gz*zzm+Q;?O)3%8)SmfwBvcD58Jl4RNJxF z^0*e8VTmduRtuNE&g-d!npwbJJd~m|B@kCPx2OpJN1;u!+nu#%V(v*h*^~tr1*%f; zT&OV&WDW$qQ7HZW!KjR=L9&f>d*TBl0WmMr|68!%4>ye&xfat#UBtFE#Vv}WR zzdjCg1n1``prN9mPHk&(X~)tDiEw1m8IZR;KZo3FaLk$1gNEQm#m}5v2hjqF%;SJ= zuJDZLLZJ@OVN*4=b1<;^7jwqQ)+ByNfewUR^+3hzMMW4mL`pt=4^)dnE+WiY2z)@P zW9$(JrxFqfzXoU{_f~xcNXg{=qo{N2q=rm`7^5aHQMeMy0}mz49Ef=+n@rKLUD9Ed z=J^_F-ltl#?VP1}PF=bLE+>^rvRd^bE>Qd#iwP>Z?e?WhsO9aG72f#$V>jBWl$--v zh%()_OBN#TuAQq>7ny0`v_GuzZU!>tZ7pBwly`ATr;-&2+!3t zF;LNKkMFL7j8c|t-2F3$3qW_1bm&zYtB&R5RF;%1LEoGUF>qjbVa-X`KEGtI8a>;G z&_9StY7=-CivqDZa17n3MT9}^wkM!lsO2!~R8*#*>XkitlGx~DJ`*DitDS}f~nQqWeE&Txo_?^K!!HbYQPrJ{we^&h#?i4*tyTuT$rCf;B~7tv~3NW>A&%cHi) zImM(c0|^ew9^y?#%Ng&i4y}>)4~!%{9vK;#M|!>Yz9G;kDhjtg$Yvo^`u)Gnb#;mS z<$4P+Kf-yt0Z>8^2Z`Cfy-QSa_f8#wiQ=XUKwFAyI~Bi$gW0iz*psut2t7AArZ;i-7*(@Gj46FLs|n7Vk7R-E&d~O*?`a zeg6RA6;adh_D8*{ze2S1oZuX<$W)m*L2)H0vl{>|yT@(-7+;4?!S`x0HD_FvO^0q- z5wHUQf4|xK=>xhQ;HORubC-4(zpD92W0>g2VTL5IXvmu`M!neUVTYjw)cpi{SR4KS z43pSsnz~U{72kMJ^jMe&z|4wDt*|_Mo6&LSrF-4vO;R$f1TR=UGcz}*B6 zJexzgs$6hC3;b=IZDon=>dQ4b5H!H5MSX>dPt-*LJYT=li`Fs!j_(Gxa{#Vg3$?FF zvFgElw#;$hcmD zIfj6KSu?#jSJaf~GW0inF3|oWBOe`?n@f%pFzlBxF@<^Gy=|`gFV*G!rt7+uyqY^c z)e&;WhsS3JtE*qRbH^JpAWei&@^IC`o>$%vn8-AX<0npN|I1eo8hPVNek0$v)U3@{ zHW?A30-)aBKauen&N`YK&*r4AHe`26zFE136~CZG3(*%9WFk5WwUmFj62HL2#@@H7 zezU~OE8nkC`LkvaDjjV{_G8*K>WTeD-;+`@X&6`Gr8a9Zj?|O+J>FP?AnD;N`aqolh49Mi#|L#_YusI^oW3D zI?a(88QR-FR|iVZg|nTI|HW}ED9E&gCqck8=l?l4xF$gf`>P{li76jRdkOj~v({f6 z;vJXK3?RZb1>QA70?%dkY!neZXhEWM0MPjvn2EMiVXk1W=B?Ge&1ffN9Wtu-(d>tn*pn$VO&BWk4r z?TDPnXZ30*Q{mgMU7Gg4->N9(kgE5f!V0!&9TR_vIfz%nl%wNXzCn2?Z}Ag`27bCtlIZ=c=dlQ$cdDX zaqM;L(Q=GFHh%8)X*w`xA!FmiW9AO`C<=$C1!X`^NxwSuqul6+%e%^KGIys7;%!eT zYizqPvLbINY=8)4nUD-)IP8SMs&h?82Qql|C%uTPSt%_4d6%cX;8ifmY}!D@KVP1# zAU^?v14ylD_NSHeep%VPi+S3KBRu)jyEKKA_^AtrbO^1O{TRCe zonH9L{@cHNBSt3afon4{dCYHmROF2UOB9<FkfE@ zY8~U5s6ze8GtI2*?@85CsBGtlsC9l=#D_Siwg4}+`a4fhd0U_3qKm|u#>Ql^gn-gx z%NZ)WgkBlgI5+RyVbalpi~FA+J#}grap{0=*NTc@zLK&ycAqdYPTykoPYKUxS_5&K z(8^mnbo5>ATdUHr1PTWw+LalLk{Q}6psFNLG+Pn zpb0@|rnDIN^4En@Te|_f320FdN=r{CR}KveySLxCB{aFik*nVoeLa2Wduc6|3piM= z@&tVo%z&%ky}3h!g^e#QZvS=zIPSDBxly1Ma3!!(!gt>ZiIlj;S`FQE9Ohko=xj}I#6Ogc4>4;Qu0;RI7<=k;$ub|rX$TVQi2 zwhDjV>;7?`L z?zH_k7vPAvdl+aj1CYx%>Ht1K?}+>QnY8H`)1vfZ_x#h%*j~8z(=$7^?XD0t1%H_E zZ|pYh-ukR3j?itg1U%eK^C|?$pXf^f6-FrydkJ^#b93{&iwB=;{a45_hjIbP_q~LX zB-TF`egu`b#2nG%HFUi#{q9s+B|2;}?}yF^EC+yoxR5POKwS8LgTe6}xYCEhS0*BW zT$XIQZupb4=I0L4_Hk!iOk$!i1Qiu+xbK3nEdf2S^{6NJ#eBN}=QyWry;oZyXmZuvN+A>PD%TwJ(A zZ$5ljzIgEszmj{x7{e7b+(Kpm)uBZ=7jiG`pwq6-Q8e=sSNB(x-pC|l>ZFWk0KMV} zPA1&CwP7~AKc;*U%0j5fTNEWK9f^z$GeCLKC0^ul%5Fe6=}=(M|IRi}chN!l04i^q zK+l7LZ%mDP?m>JewM2;+mdV_xneJzA=u0F#LR+k`PH!6;pl?6GfeMUXQarU`9$U_x z`^t>P7Xj~vt56k;QFe9$qZha8E8|g93 zv|1Fc{Mrt2r4l2mMi3TrLxCY1XdJYi@uAn;aPF9tloTyc5djJN_nX2lgKWxZnO)DW zrkIfT5nKX^;~Y6Xpf1kRwdr%0wtoHk54TZ!FnlM$6TcAxHqo=2cm|0MSEvL1^X+Aq z++~~8-cm@T(`+=M2egE@WD@kj6UAKKHRM&G^pAcz(T4^gSpTEL&V=*ZsUB6aIPXNO z0C*4=bR-7KcM1#&a{Cq-PRYY#(K1>EX3}=ZaO|=P=jZ61nJNrRYW1MrO?Jna)n-m{ z)p3b{Y{+Q{HL)>R#$CI12~jkPs>bIcS_X;isb-(pKzK}X9C*WRh&NB$v4e%%;w6XR_BFD;!jvDq%8H8qTTJ2F zq#q23qmB(ajRMHty+v<)82+Avyu+Ae=xKL6rm^V+5&Ml+s0GESj~G#L^Jccuv%-#| z1EmiilFv}*poszop|i9ng`Ub^YXispmAJdn2pNaB6+~ZWv$8s`7WjFs7D7I zd)j0cBm(r2?1u!%5c9Y6xzLl^1fclki(Hp3iDNfjhRTV3X!UBd4_Xu!=h{6xe8X#3 zcN||*k{$*0SXv|6BLajhJw68cO17Q-xHjA9SqY11&z{2DRqfzI8$+#4QuUV5;Cf)- zm+1N(Ce%@KA;vSQ*lClBy@!W}`T|Io@9*D#R`SND;nsWh$8*5~N4G^}i2oD}J(FzP z-jst**O`U4dIk`qcM(o3cg16#sPr=-3$#gIXyn+3N$}6CLM5_-2nAkLa%zU=<_oAgxkG2cjr1`jR_aFuts5F6&0@q0cwQGDh<{Jj(H&fEy``tFM|c* znKeQw0IZ&lr6+I9PrPhw?9-zMF|k`CED(;8pq^uAXXoOgbY_K=C|?HjhtS-O0{ayOi)-qLo?f2Gx;C)@?LuF{hQej(+yjvK;wC8XXCtQ~p%yP|oocBFY>Zb`|lTM(Xw8t$s*eIF9$)YRN-fyp;Ik#ED>7cUvD)m{I#czaN%xdcm^MEl@GZ^z!W8+hTEs(F zh1MztUfzPcg$aG4X|+PD4fe6tCuo8u$I*|-s4-3#ZKiR2jnEtK`i-R-;qld zFSpfC+7V1{Ej5x=WHV@-1kIqA5ikZgj2=QzQ#?){gB1d&b#40l=;Ft3Q07+sGP+1V z&sX<~ed;-ndM+NMm>s-1T>9JetvKo z!SK$v->WV^fALvs;))41ha2C0x9z(mAmQ?Et=h|XjZduqP`7!;Yx&f5^CMT*=mdUw z#C#J9%O5g16~RuTZ6xaMx{jt1)P+uDWJpbYRDPWTPGO<=7e$H5Zjc>GiQv{TzSdf! zbEi&($dTK&eS+|8VjxQSCYc${#V7S@{{9Qhmf}D3g)MCj-djRLY0t_#B$Q@EW*~b$ zTtKMSp2!!gOby zYn^%+Tl04-+k}72J6SLe(g122m;kglVkVMxS-yJV;{mz7~r^u??!SLb>4j|b9^BPavXa)9t6_%@;E_j(6~5kPl^@8sb&_45YG~0M_W))a~pdYD$wIIww@?_yl`< zE}_26tCgd8zce!+aEj_5LQ}?daTZP_TC*v6d~obzilcN@%vXZ9f*?!y8bj_aL4a?V zOu;|CYuCD6@X<6h+;^`Q1`W3m?0|@sx@f_=73Oy=nq`%SY_XijB$niRQQNmuxk6ZT z8%HZVJUFQ*pQW^icJuF7y9^D)@`B-vd3H*qf1afkN_nGw!%G$%cRA%j1|%es92bB4 z@E*qJ;nU|xkI!G(V{~d|RaNioR^BOc7)wTA)6aX!9+)i7&I6+DPHtSWBxE@!RX^S# z&q~tVc#}DCTtRIMn2Kh%@#f~{6rLzHb5ZN%WJTGW^T^TNB7_zKL&Goht-!dTqK}t8 zSq$7I(%*+x*g~QVLDrOlRsBwhV0B15CbSGPaodf6P*pXbac-2tA7%FrZWh?ZMEp4u zcbfMr$hdMq5k0}H7X*%+%^+csgS98=_JqCKeP|z;Ni>&+&r^yj2JF##P=!`HgKkUv z3>Sr<$g(3UiU5y_3G74}IXQHvxt_y~j54^MTV=MbD9DuLU-F1w7A#nRSS<>`ea3E3 zy?b5kP_%>AMWQMmaAWh!acmOvVjC&L(Q|pwM33ntO|sPyv}>@d9EWCqAn!D9AHa`C zUWU&$3D-I#_$3kpO7kpJce!{9tLx&zjHh8TXL51|Jg72PAFH$mV@eT*HWPZIsKCrS zB%!~^ob6ZHyL?>F9z9_6Sgc*lTZxo5!=ORsbrO^2S>clpI@w&Lg%}45l})MeB+MFe z7v^q8YJ7y+Bdt|kI_Y2S2vZ9?Z|1pzl@YWi zKth5ukA{{xDN>PUb+eoW-v9b9M?*ueA-~FB*kI=KUUd*f4yQ{P5QS}qN@T5ZMN!8` zp6qc{FW|d^J(6wa%UT%nJic>txlo`5npWD{8<}(ylkV@l@P(kJb#lFf6*vtlefv^O zA$^B%$@y39-u(8ZVS9)w81B`(^< zizZ%I_!{mV+t$||6uxM*O5g+)!$faSPurtsEb4al3XTrTwF%#`!))z`IaJb!q#)YE z$pUGLDn4W~T4eK_n39mEd4)llRh$3ncj?Umso@35=RZz9G_83VF1&rAINKE59C|)oHv7z#?Jt@e2R2U; z2C!1?!q`=J1`vU)7M&h|+qQ_wwRYswya)V)Qd)nf6fSdID&+5Tjgi z7qp7LgJ;A!yk*1_6cTx8vWR$<_vs_b83bGf1c6xX{V^z7*Rw_2O7z1D1%5FSi2Y|B>=5k7VzIqr#{9v( zdl{~WU{o-a|p5rm^uieWrrcC%Pp<4DlO~S`eq^ z6w-r%Av-~g2SY?ZN=lS6;oq^2YM@b&T9hZvCXcm>pFKNpXHi8Tc21;>g%`QEq832K z(#6y2arssk&sXo>LBN}|_J*j`Mk5+5nMt6Srrf+dVQ?2w1~?wP@>_3&<@J3`{EQA< zY1M=Q@DhnuCXXE;szem;7S#t1BGO*uBj6^G*RUJlAsu72gkF}d$7nUi`McilP^gb; z?w%4sQjp_Ts-h%n&>AtKg+lXRgz&(OkcSf_aVc4pau7wo#%}9%9H{-+kt2ork#`n7 zsj7ii+ape1vfXD+ybafI-*$Q1m&>X340bK_~VipE6 zdD3h4^RWd@g8VM>0*vn{{y1-VJh2x3fTM39DH$99pW!FCfKWFjWJA3Vj1qSfB^*U_ z<7?aY16TnT8vIPdX>z)tp|rai4+0xeicQlH@21fz|63F-s+{mYv8KKKZu|8AE7lAi zoj}x2SvhUWpbcFU?+AUMn5)H+(xNr{@BqBPf#Tz#_Kt#wiq~WcBtE9y5ZY|w(0A#A zIG|klZ&kVC=g$R&0DV7k^DKgYunI_^dNik@mJa^pc6MnDC_wr3zh5{0J-6X^z&8Ui zQKjIGgqs#6+_aqDXFSI3B#5Kebx%o}i9qK6$YFaE6PDid7R!F78BgB@t*KxN?*s&{>9)bj`dcLctG6$Z49Q7!MG zjWO7o9OmH6+`+1h|iZHLPC){o!sd7NULd(h{G zX#s~ixce^g_Pzv3=;Tn}8?`N-5L(^cn-nc^lo-p{Tg-Li&c)mg8A^cdiP0l(S0B+Ht!$onh55Qh~jAvL7dW4Ou+X-*%djGZ^?U|DH!qY5WJsK?#{J&~L7Rpf3>lSTk4#{)Ir zt{jC$J1H(DiW1~k#*9h2SNr0^Y7W~75}h`n=O$R|BP3LBV5*SYsk@+`~tYNP!aXEMiBR z5M=SS?N|_}A>Rq=>$kuSXlps^p!sV)Z4@S1X=~8$&ky?swJMDMH7uS&fhRr=(#P(0 z_#=7-q=$xvZTE_NMybX-s>i)+cv;C1Lyn?x;>0x=xaE zgf*XM*TWdw*cJeTb71MFjN3_rEWAM>IVw;R6Qboej?WBE-4%whUb@t$P#@Dr$;RWr z4j|avT*GJT;`%zIW539{Xn(8AHd5NUYSex_97Q%k>+0~~>(8H$fi?a=zENd}Xny(j zp_c^`Y?ia2A|*(G6pFcf@kR1b)}WALV2Z8go+H_&J zBhM4BETgu?OI6NRBU*Rn>DQEADXpfG{9WHk1bC=>yy7sWNba)WGsA*UYpwVj~=E_eyi^H)B)N*QPq!9X$iqS$INeoA%tyyVu+5k_tB2 zB2(s4#*aZ#}Pq=xqs%Ua0VKbcxnORv?<>eh}e}GHS0S>Ojfxy)e zo9)_gbHQ)~`USr2x-hih`X|{`u!Gycatg8w%CuhZf{)^ntUT85tQ6gzZc_iwgcB&(?L#%Slb0mohUIwubL`IYQ(I zFJ7!zzFe)1f4K@c#$6Sm_&OwMHfw#hbJ5+Gocw~cMxrHvuX24WSZp(V)Hq~(SpDv* zst#*Mi6SkiB+xMb?eJCmQ&sQbGecYJGAR$^x+!IX#x5Nn#scA0PKxRH5Z4m5cHdz? zpc~nlY1vo|T>s4z*NUpg*VomVhwK1)@^xeePF;dUxQnih_EVX(OP5@AVzL*fz|9Rv z@mRb#{KcZ-@x+03xk*WC+pk^KcOWdWvYOwdJz{6cn@^BPf|{;KQFzvi?bWlV+}Ur7 zQigax^2%i&aKX&paBz^hoK@XDK*N21!OtDgn`lsVNIi1pt|a3j zz0*kRdDPdj2D@*)5t0NFx{!H~%%ACo6<%0b(*fI|FQ0#r@+8qz-CeFtU=KIFSa78t zQKeN?<8_1R+slSz!O7nc1-u_stWx)|%FA%oS69Rv`>NO}d>aOI$fCUtf5f5lu!8EYE*MnE>N3fKfrxdcYR%SK-rkL{ zt{SPUm+aPrS818*`1w&!#zo=L&RA!Wd@4A%%wM<{{oThM&a_O`w)VLf^2_S|i#;_rv@5*4;ZU z_U;^WBSl7L$KSF*&F4es{Y@HGkTKF$A+TI5xyQJ#DaXY>n+}{xzpAxy--hksmv(_4 z^I~t{>VwfHn)#0hHhu*hdcyVMVUMIDCb+TU5(A`#p(^GH1#D;>(2W{mKX z;UU9&dko!T6Yy=63d}sU4A7=*#y# zW*xKmdPvUt)L)B3`uV+HHv5zDa38_r7H=8%l|i(T69-OU)@Z}~_mEizst-84Idia0 zRlfv1D*8o%-TEK(>zbE;Y-@NInM|wcx5dj+VjjhJ-!mRB-^Vi{G_1*37^GdI52iZ!!0namaUr+RwFPt#q%cjZ?gJzK7?t zh?d5pyJ2yY4@M7aSY3U)yXg9m^j^ndAz6(jh$_1GL+}#e+Gm_RmoI-_Sy|++510WB zaS}u=g&?)19n-IHuW_RoZjReFsX^s6uOH$wSWuekI#jmTB7F+g6F70<*_t2q4 zJR&h*11u|~zRBi%+Oj;wICRt8z-v3L>H`NR z_3ggBs`;R0;53_YS5g|Jt;?5;-sYSrdg%%U&)Yp_ zymfZ=I9NMLxpT^s%p>Ez()h0|pIJCSVD?h=UH|I3Y|rhX$w#g%7*2h_4|m);WbWd~ z67$dVQrxc>C~YHn|NE zB_*-BQ)Yw&sN|pA>arl@g<4&i^WM0DUo7KJUmjNjG64N@q9545$hfSP$&>S@E!N7HtQ+6{gPAB#j zbiWx#U631yh)Q!D8*($e`H6jB+fzF?rkw4VU*Wj_Me6ATNe$&2104l zm^QlPO5&ggN?CCyrz(pAUKX96?-3UAq1bksfAWa@@zX>F14AcoA0#))Y2t#~u}u-f z0~5lxJaQU*Y^3@=-TC6J<=YN9mq+AgpIfc9W5IYyv+p}{;&WbpF!@&3<73JA2|tU* zpWKuk?W}4-*`-5?4NAP4iB>VS7{;KX4s)Hm`g}w`3pN z16`?r&z?;a1+})g!5TWbi4vm7xu9h5FpIo}$Jyx;59U@__kmRAsqDj&R4?6MgwNiWc7^z&&zQ3Dgl=M zbbG`-WMg=xeqT$A3wS9#DP$?Wg%tR2eo-+$z}>BEga=1aE;lEqx*|3$%VCO`>htd@ zQ@VOog>8E>M{mX&3mL~PTK!9N+ts5uUWnsRo7RCGm}hR{b4gJ{#KbLv5wvcH-l*6)2TKef-4D}xj2Y|r7a z_jfgtSuD5LV6B7hdz%-#x8*lqx)a_N*$fNaV)f(EhmKz-7{#O&6?L5MRuB#%V|!sD z??|db>jK&Eodv_oEaVIIa~>XIa`OICnDywB<(@p*&u`GSLptT}(G;0x=Gk$t<%sFv zN8d7+ri|0|u&pdBQ&*H8FL4tXsj{XzXeXqEq#7_%1Xn6Pqo|l2VSSJW5%PMT8enxc1nXI6;NxMu^U@_Vo!Pf<-GV}-HAXq_Mm5CmvEN^JU$*RB#&>H; zGxr)JJ1JfEEnjBVHD}4ZiF0O74+>CM>ZCKIPhFGs9DA)v0hmuBkn%BiybKe4b2gXA zrd%C0f#T(-x;_vW(1Br?v^ z$IS|`E_V9Wt4u}w-Joha%Py4%yo3WLD_jhHsk~} z?VhGTiwKI?h}XfZV=DiqESGZYpnX}4$O@c0=I%y?sOCCJMbAj-#d(_yJSV>QE*0j- zQj>alwBya!j>B<^J5TIABv*X%ru68J<^){0LFmFSo<5Dd{8IDUH~HvCPng1y7#BCN zah~EomdBbFZ`$V~nVqHKX96Hlv?-m4_tX*fH6~QEX~%3b6j}5Ac?~RddhgBQiMTla zl#4EBpVzhcF6qzY6dQN+toy`p$MU`HZ=zt7c;~w96ux+o6Ge}B+7r;5K3XiU|B`rv z0E3I>XgkqVyyMXG>ODW{$W59i<2-MX!*4Wgb^7en+37$HCp=~}K#;$>+}uk$mQx8t zKMGdNkw;5ZBCUw6?-aiJEz_{H$#_`#d#P0`vt9OB1EXK8 z_5PHS(*Ugjr)U>`>uvq5dArM$L!%N$m(IIYS}OnLhw?bS%wDy$aHCT19(z=Zyn6RJ za~rhx*s&UiZ^KsT_IlAN^eSHgH+k(uhfSi7eVx{r-o;YmzkQ!PeX6=hRwQ@nY}4xe z(x2P>6VLCbvzl^i>H6`_g9DaPHUmxhOPli8t@CvBvO-{IKYH?HY_oP}HSxzr6PDD5 zyj5;$TjJShYJYv42EaV@>A7M+KS#MLM6R4`eow7)~cUaf6KPk8X6P^l>g>t4U!vT@nG zd1C3t^Scgn8W1E*2w1VAcs3)n^c^cKI8Wd9XS`F-nRN+(12oSGP;@&wb zpRXBlkUMNj0j1_e&G+=_DgESAZd&K>z^l9&+w@g$%=3^wU9Rj2p7iBf)YDEI6pL?t zDSNYly()cdy#1PT-R7O=X;8qEY5(acl3?6u=957~huRtez}fxoI(i!I-#rhUmQEJ&5iQw zRRYav3FLX34f>pdMo7HO?AMVaL&q)GpTcG~$-z{UOin70+LzfoU^113l;e`04eJc& zXwAF>_ji!rD8A|C%W6YUFqX^mS(oO)kGqy|`L)ho=|p0>_^+YqJV2A3y zgC`b;Xa?$#UIAC6UVdCwrf{ztq-Xa>2jB92uyA%44x0Bvd#UiN4e7;V7yFw%IGANx zn)-i5HZ*I}jbM2}V@aP&jGXgVWvkH+wdo_5WNcPnP{#Tdy%>W z3|$`hVqRW?f6mp1JDZnjuD&n_mv7ovD@a)SO#_rHhR4esQ+`#UoOUA3*vNN3Y)eE1 zt)+DBVr-+GrUt_HhAF`DaOxHDHEwasFquLa`OP^cq_i$tl0RiTtO@a(JCeAwQg*))y`u9Gq4(KglvSs_Mp}|l$b|_nrfNmo zkr(mZAXr5N$z8r6gy+v|$aGUb&#J67UnlF>-RDAnYO2@?iKAIrS8Aux?^=4B(Oz60 z&FQZpiR4|p*fqd%fT&@-PRw*?XM(EK5#~)#FP}Q4FcO(9jr&!vVyg6wCF?>DU)_b> z-eey9&!#f}Fp61^9#s!I2#XFq%+^Oa{kZyP=F+cIsw;SqxmVmhJ;N3il$z!fcRZ^R zZ~9BiA!qAbRewI*KQH{_!7Hztn|u|9tR6jTlzFXq@GMwK6DLhltIJ>r^)-*w$p#26 ztQWA%O`5Y~? zmsg(p20sH*3=^o0prS>JelzvDG(vQzKWJ~EMD>+N!G4#>%b$~t_B9LGqE!~&>pZL| zI+~gp2r^cJdVzj~;bsP3o%&mH=gtsaU2BtWHNU%F_C5_9MGN?xLkf{oEK^Uue^F}C zakRK-W8wOB>r%%Q!*$Eut}+PWg}glFHN*YG{wITj;_~xDuXYZ|8rC6 zVlFG&^e?pk`o%s}WVi=Zd7^Ihenk#Nk_VerH3QNh&<0TCvp7+-)0|((|l=` zi{@0q&#O`y)KE^wzwETxS|M_F+LB?F60&>smk2pPtzaCT zV<^dJ2ICFcTx3W0kD0_Im7#+NucrwWpN9fNz_hYon=$lloCcMKjYL+|5dOs@(VnCe zLvt-offq`h{A5b3V?7OQmcvU(S$37d&9`aVaPtHWSsA4OXRdo0NKnUae_X~M>S2N~2atZVERd<|p z4467+&QpM>d2v9X4J*U@>$)74cr_6r&mVJAk}^)7JjuUnZ1O^z26&V3>(#4}DSbu- z^)=<+tikHizkIu-#G-0c-gQtq!=!-+&t*~$MmXy3#Cd0`+L4*e8Mr#~)-)5Bv#8xhgPz62eC9YI$qmj9{sHk$Xfbqi ztNfl)CdA%|2flWNvjDj$FC^U!h;B?i zVcpdV73TezUb5&d=anrJJf-fX^0?QBWzMfn%OjKxYx@uUP z)az`a+9*t@e*bp`CdSW<)FpCTKsflFLva_Y#M(WW~SyGafnOUHJyP!aHtk)s0 zs^VfPorlT$wdV%*kG5l|4T=NsWbw;gx^&^>gK zcQRC5E8J~KCUr?Qe-H70arJ#G7?~VC5aUyDR@i*S;+qZ&!h9ViHpau^b zWbv+J<~;_#771s^|LT^`@CP!_^&VThos*ZwDLi}_g%Vmnn4vx*-htY9zHZ5j7k$61 z@96%Bt3casS%TQC4C}#riG!Jo8ArL((Xo^X^2H?*o$!Wy1*d=(vNX7#?~|Pj?($9b z6gw5XLe02%*<&^XW@(Yfu-IFqe6(=;&~#&N9?Z_YpC^~O=hhp9XW-ll(&$Wm;I(n% zSR`}4e6i`>Upd(lO=zpoFTV;WR5iu-

%$KK99LqKYcxeyZE0K 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 literal 0 HcmV?d00001 From 99329e1243d373c426d17620e1b6aef932509ff8 Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Mon, 30 Oct 2023 21:43:16 +0100 Subject: [PATCH 22/31] refactor(ds): Address review remarks --- .../emqx_persistent_message_ds_replayer.erl | 8 +- apps/emqx/src/emqx_persistent_session_ds.erl | 3 +- .../src/emqx_ds_bitmask_keymapper.erl | 120 +++++++++++++----- .../src/emqx_ds_helper.erl | 73 ----------- .../src/emqx_ds_replication_layer.erl | 3 + .../src/emqx_ds_storage_bitfield_lts.erl | 17 ++- .../src/emqx_ds_storage_layer.erl | 8 +- .../src/proto/emqx_ds_proto_v1.erl | 3 +- tdd | 13 -- 9 files changed, 114 insertions(+), 134 deletions(-) delete mode 100644 apps/emqx_durable_storage/src/emqx_ds_helper.erl delete mode 100755 tdd diff --git a/apps/emqx/src/emqx_persistent_message_ds_replayer.erl b/apps/emqx/src/emqx_persistent_message_ds_replayer.erl index ce57eaa80..d137891a2 100644 --- a/apps/emqx/src/emqx_persistent_message_ds_replayer.erl +++ b/apps/emqx/src/emqx_persistent_message_ds_replayer.erl @@ -72,7 +72,13 @@ replay(_SessionId, _Inflight = #inflight{offset_ranges = _Ranges}) -> -spec commit_offset(emqx_persistent_session_ds:id(), emqx_types:packet_id(), inflight()) -> {_IsValidOffset :: boolean(), inflight()}. -commit_offset(SessionId, PacketId, Inflight0 = #inflight{acked_seqno = AckedSeqno0, next_seqno = NextSeqNo, offset_ranges = Ranges0}) -> +commit_offset( + SessionId, + PacketId, + Inflight0 = #inflight{ + acked_seqno = AckedSeqno0, next_seqno = NextSeqNo, offset_ranges = Ranges0 + } +) -> AckedSeqno = packet_id_to_seqno(NextSeqNo, PacketId), true = AckedSeqno0 < AckedSeqno, Ranges = lists:filter( diff --git a/apps/emqx/src/emqx_persistent_session_ds.erl b/apps/emqx/src/emqx_persistent_session_ds.erl index b8afc771f..c99b8c947 100644 --- a/apps/emqx/src/emqx_persistent_session_ds.erl +++ b/apps/emqx/src/emqx_persistent_session_ds.erl @@ -281,7 +281,8 @@ publish(_PacketId, Msg, Session) -> puback(_ClientInfo, PacketId, Session = #{id := Id, inflight := Inflight0}) -> case emqx_persistent_message_ds_replayer:commit_offset(Id, PacketId, Inflight0) of {true, Inflight} -> - Msg = #message{}, %% TODO + %% TODO + Msg = #message{}, {ok, Msg, [], Session#{inflight => Inflight}}; {false, _} -> {error, ?RC_PACKET_IDENTIFIER_NOT_FOUND} diff --git a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl index 90c381104..a67dbc0eb 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl @@ -168,6 +168,10 @@ %% transformation from a list of bitsources. %% %% Note: Dimension is 1-based. +%% +%% Note: order of bitsources is important. First element of the list +%% is mapped to the _least_ significant bits of the key, and the last +%% element becomes most significant bits. -spec make_keymapper([bitsource()]) -> keymapper(). make_keymapper(Bitsources) -> Arr0 = array:new([{fixed, false}, {default, {0, []}}]), @@ -207,12 +211,13 @@ vector_to_key(#keymapper{scanner = [Actions | Scanner]}, [Coord | Vector]) -> %% @doc Same as `vector_to_key', but it works with binaries, and outputs a binary. -spec bin_vector_to_key(keymapper(), [binary()]) -> binary(). bin_vector_to_key(Keymapper = #keymapper{dim_sizeof = DimSizeof, size = Size}, Binaries) -> - Vec = lists:map( - fun({Bin, SizeOf}) -> + Vec = lists:zipwith( + fun(Bin, SizeOf) -> <> = Bin, Int end, - lists:zip(Binaries, DimSizeof) + Binaries, + DimSizeof ), Key = vector_to_key(Keymapper, Vec), <>. @@ -241,13 +246,15 @@ key_to_vector(#keymapper{scanner = Scanner}, Key) -> bin_key_to_vector(Keymapper = #keymapper{dim_sizeof = DimSizeof, size = Size}, BinKey) -> <> = BinKey, Vector = key_to_vector(Keymapper, Key), - lists:map( - fun({Elem, SizeOf}) -> + lists:zipwith( + fun(Elem, SizeOf) -> <> end, - lists:zip(Vector, DimSizeof) + Vector, + DimSizeof ). +%% @doc Transform a bitstring to a key -spec bitstring_to_key(keymapper(), bitstring()) -> key(). bitstring_to_key(#keymapper{size = Size}, Bin) -> case Bin of @@ -257,6 +264,7 @@ bitstring_to_key(#keymapper{size = Size}, Bin) -> error({invalid_key, Bin, Size}) end. +%% @doc Transform key to a fixed-size bistring -spec key_to_bitstring(keymapper(), key()) -> bitstring(). key_to_bitstring(#keymapper{size = Size}, Key) -> <>. @@ -267,13 +275,15 @@ make_filter( KeyMapper = #keymapper{schema = Schema, dim_sizeof = DimSizeof, size = TotalSize}, Filter0 ) -> NDim = length(DimSizeof), - %% Transform "symbolic" inequations to ranges: - Filter1 = inequations_to_ranges(KeyMapper, Filter0), + %% Transform "symbolic" constraints to ranges: + Filter1 = constraints_to_ranges(KeyMapper, Filter0), {Bitmask, Bitfilter} = make_bitfilter(KeyMapper, Filter1), %% Calculate maximum source offset as per bitsource specification: MaxOffset = lists:foldl( fun({Dim, Offset, _Size}, Acc) -> - maps:update_with(Dim, fun(OldVal) -> max(OldVal, Offset) end, 0, Acc) + maps:update_with( + Dim, fun(OldVal) -> max(OldVal, Offset) end, maps:merge(#{Dim => 0}, Acc) + ) end, #{}, Schema @@ -288,11 +298,11 @@ make_filter( %% %% This is needed so when we increment the vector, we always scan %% the full range of least significant bits. - Filter2 = lists:map( + Filter2 = lists:zipwith( fun - ({{Val, Val}, _Dim}) -> + ({Val, Val}, _Dim) -> {Val, Val}; - ({{Min0, Max0}, Dim}) -> + ({Min0, Max0}, Dim) -> Offset = maps:get(Dim, MaxOffset, 0), %% Set least significant bits of Min to 0: Min = (Min0 bsr Offset) bsl Offset, @@ -300,7 +310,8 @@ make_filter( Max = Max0 bor ones(Offset), {Min, Max} end, - lists:zip(Filter1, lists:seq(1, NDim)) + Filter1, + lists:seq(1, NDim) ), %% Project the vector into "bitsource coordinate system": {_, Filter} = fold_bitsources( @@ -340,10 +351,37 @@ make_filter( range_max = RangeMax }. +%% @doc Given a filter `F' and key `K0', return the smallest key `K' +%% that satisfies the following conditions: +%% +%% 1. `K >= K0' +%% +%% 2. `K' satisfies filter `F'. +%% +%% If these conditions cannot be satisfied, return `overflow'. +%% +%% Corollary: `K' may be equal to `K0'. -spec ratchet(filter(), key()) -> key() | overflow. ratchet(#filter{bitsource_ranges = Ranges, range_max = Max}, Key) when Key =< Max -> + %% This function works in two steps: first, it finds the position + %% of bitsource ("pivot point") corresponding to the part of the + %% key that should be incremented (or set to the _minimum_ value + %% of the range, in case the respective part of the original key + %% is less than the minimum). It also returns "increment": value + %% that should be added to the part of the key at the pivot point. + %% Increment can be 0 or 1. + %% + %% Then it transforms the key using the following operation: + %% + %% 1. Parts of the key that are less than the pivot point are + %% reset to their minimum values. + %% + %% 2. `Increment' is added to the part of the key at the pivot + %% point. + %% + %% 3. The rest of key stays the same NDim = array:size(Ranges), - case ratchet_scan(Ranges, NDim, Key, 0, _Pivot = {-1, 0}, _Carry = 0) of + case ratchet_scan(Ranges, NDim, Key, 0, {_Pivot0 = -1, _Increment0 = 0}, _Carry = 0) of overflow -> overflow; {Pivot, Increment} -> @@ -352,16 +390,21 @@ ratchet(#filter{bitsource_ranges = Ranges, range_max = Max}, Key) when Key =< Ma ratchet(_, _) -> overflow. +%% @doc Given a binary representing a key and a filter, return the +%% next key matching the filter, or `overflow' if such key doesn't +%% exist. -spec bin_increment(filter(), binary()) -> binary() | overflow. bin_increment(Filter = #filter{size = Size}, <<>>) -> Key = ratchet(Filter, 0), <>; -bin_increment(Filter = #filter{size = Size, bitmask = Bitmask, bitfilter = Bitfilter}, KeyBin) -> +bin_increment( + Filter = #filter{size = Size, bitmask = Bitmask, bitfilter = Bitfilter, range_max = RangeMax}, + KeyBin +) -> <> = KeyBin, Key1 = Key0 + 1, if - Key1 band Bitmask =:= Bitfilter -> - %% TODO: check overflow + Key1 band Bitmask =:= Bitfilter, Key1 =< RangeMax -> <>; true -> case ratchet(Filter, Key1) of @@ -372,6 +415,10 @@ bin_increment(Filter = #filter{size = Size, bitmask = Bitmask, bitfilter = Bitfi end end. +%% @doc Given a filter and a binary representation of a key, return +%% `false' if the key _doesn't_ match the fitler. This function +%% returning `true' is necessary, but not sufficient condition that +%% the key satisfies the filter. -spec bin_checkmask(filter(), binary()) -> boolean(). bin_checkmask(#filter{size = Size, bitmask = Bitmask, bitfilter = Bitfilter}, Key) -> case Key of @@ -449,35 +496,37 @@ ratchet_do(Ranges, Key, I, Pivot, Increment) -> -spec make_bitfilter(keymapper(), [{non_neg_integer(), non_neg_integer()}]) -> {non_neg_integer(), non_neg_integer()}. make_bitfilter(Keymapper = #keymapper{dim_sizeof = DimSizeof}, Ranges) -> - L = lists:map( + L = lists:zipwith( fun - ({{N, N}, Bits}) -> + ({N, N}, Bits) -> %% For strict equality we can employ bitmask: {ones(Bits), N}; - (_) -> + (_, _) -> {0, 0} end, - lists:zip(Ranges, DimSizeof) + Ranges, + DimSizeof ), {Bitmask, Bitfilter} = lists:unzip(L), {vector_to_key(Keymapper, Bitmask), vector_to_key(Keymapper, Bitfilter)}. %% Transform inequalities into a list of closed intervals that the %% vector elements should lie in. -inequations_to_ranges(#keymapper{dim_sizeof = DimSizeof}, Filter) -> - lists:map( +constraints_to_ranges(#keymapper{dim_sizeof = DimSizeof}, Filter) -> + lists:zipwith( fun - ({any, Bitsize}) -> + (any, Bitsize) -> {0, ones(Bitsize)}; - ({{'=', infinity}, Bitsize}) -> + ({'=', infinity}, Bitsize) -> Val = ones(Bitsize), {Val, Val}; - ({{'=', Val}, _Bitsize}) -> + ({'=', Val}, _Bitsize) -> {Val, Val}; - ({{'>=', Val}, Bitsize}) -> + ({'>=', Val}, Bitsize) -> {Val, ones(Bitsize)} end, - lists:zip(Filter, DimSizeof) + Filter, + DimSizeof ). -spec fold_bitsources(fun((_DstOffset :: non_neg_integer(), bitsource(), Acc) -> Acc), Acc, [ @@ -679,7 +728,7 @@ ratchet1_test() -> ?assertEqual(0, ratchet(F, 0)), ?assertEqual(16#fa, ratchet(F, 16#fa)), ?assertEqual(16#ff, ratchet(F, 16#ff)), - ?assertEqual(overflow, ratchet(F, 16#100), "TBD: filter must store the upper bound"). + ?assertEqual(overflow, ratchet(F, 16#100)). %% erlfmt-ignore ratchet2_test() -> @@ -696,6 +745,11 @@ ratchet2_test() -> ?assertEqual(16#aa11cc00, ratchet(F1, 16#aa10dc11)), ?assertEqual(overflow, ratchet(F1, 16#ab000000)), F2 = make_filter(M, [{'=', 16#aa}, {'>=', 16#dddd}, {'=', 16#cc}]), + %% TODO: note that it's `16#aaddcc00` instead of + %% `16#aaddccdd'. That is because currently ratchet function + %% doesn't take LSBs of an '>=' interval if it has a hole in the + %% middle (see `make_filter/2'). This only adds extra keys to the + %% very first interval, so it's not deemed a huge problem. ?assertEqual(16#aaddcc00, ratchet(F2, 0)), ?assertEqual(16#aa_de_cc_00, ratchet(F2, 16#aa_dd_cd_11)). @@ -721,18 +775,18 @@ ratchet3_test_() -> %% Note: this function iterates through the full range of keys, so its %% complexity grows _exponentially_ with the total size of the %% keymapper. -test_iterate(Filter, overflow) -> +test_iterate(_Filter, overflow) -> true; test_iterate(Filter, Key0) -> Key = ratchet(Filter, Key0 + 1), ?assert(ratchet_prop(Filter, Key0, Key)), test_iterate(Filter, Key). -ratchet_prop(Filter = #filter{bitfilter = Bitfilter, bitmask = Bitmask, size = Size}, Key0, Key) -> +ratchet_prop(#filter{bitfilter = Bitfilter, bitmask = Bitmask, size = Size}, Key0, Key) -> %% Validate basic properties of the generated key. It must be %% greater than the old key, and match the bitmask: ?assert(Key =:= overflow orelse (Key band Bitmask =:= Bitfilter)), - ?assert(Key > Key0, {Key, '>=', Key}), + ?assert(Key > Key0, {Key, '>=', Key0}), IMax = ones(Size), %% Iterate through all keys between `Key0 + 1' and `Key' and %% validate that none of them match the bitmask. Ultimately, it @@ -750,7 +804,7 @@ ratchet_prop(Filter = #filter{bitfilter = Bitfilter, bitmask = Bitmask, size = S CheckGaps(Key0 + 1). mkbmask(Keymapper, Filter0) -> - Filter = inequations_to_ranges(Keymapper, Filter0), + Filter = constraints_to_ranges(Keymapper, Filter0), make_bitfilter(Keymapper, Filter). key2vec(Schema, Vector) -> diff --git a/apps/emqx_durable_storage/src/emqx_ds_helper.erl b/apps/emqx_durable_storage/src/emqx_ds_helper.erl deleted file mode 100644 index 5b55831d1..000000000 --- a/apps/emqx_durable_storage/src/emqx_ds_helper.erl +++ /dev/null @@ -1,73 +0,0 @@ -%%-------------------------------------------------------------------- -%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. -%% -%% Licensed under the Apache License, Version 2.0 (the "License"); -%% you may not use this file except in compliance with the License. -%% You may obtain a copy of the License at -%% -%% http://www.apache.org/licenses/LICENSE-2.0 -%% -%% Unless required by applicable law or agreed to in writing, software -%% distributed under the License is distributed on an "AS IS" BASIS, -%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -%% See the License for the specific language governing permissions and -%% limitations under the License. -%%-------------------------------------------------------------------- --module(emqx_ds_helper). - -%% API: --export([create_rr/1]). - -%% internal exports: --export([]). - --export_type([rr/0]). - -%%================================================================================ -%% Type declarations -%%================================================================================ - --type item() :: {emqx_ds:stream_rank(), emqx_ds:stream()}. - --type rr() :: #{ - queue := #{term() => [{integer(), emqx_ds:stream()}]}, - active_ring := {[item()], [item()]} -}. - -%%================================================================================ -%% API funcions -%%================================================================================ - --spec create_rr([item()]) -> rr(). -create_rr(Streams) -> - RR0 = #{latest_rank => #{}, active_ring => {[], []}}, - add_streams(RR0, Streams). - --spec add_streams(rr(), [item()]) -> rr(). -add_streams(#{queue := Q0, active_ring := R0}, Streams) -> - Q1 = lists:foldl( - fun({{RankX, RankY}, Stream}, Acc) -> - maps:update_with(RankX, fun(L) -> [{RankY, Stream} | L] end, Acc) - end, - Q0, - Streams - ), - Q2 = maps:map( - fun(_RankX, Streams1) -> - lists:usort(Streams1) - end, - Q1 - ), - #{queue => Q2, active_ring => R0}. - -%%================================================================================ -%% behavior callbacks -%%================================================================================ - -%%================================================================================ -%% Internal exports -%%================================================================================ - -%%================================================================================ -%% Internal functions -%%================================================================================ diff --git a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl index 9b1ff5c7c..34bb66031 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl @@ -13,6 +13,9 @@ %% See the License for the specific language governing permissions and %% limitations under the License. %%-------------------------------------------------------------------- + +%% @doc Replication layer for DS backends that don't support +%% replication on their own. -module(emqx_ds_replication_layer). -export([ diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl index b85fb48b0..85f4f5aa7 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl @@ -14,11 +14,8 @@ %% limitations under the License. %%-------------------------------------------------------------------- -%% @doc Reference implementation of the storage. -%% -%% Trivial, extremely slow and inefficient. It also doesn't handle -%% restart of the Erlang node properly, so obviously it's only to be -%% used for testing. +%% @doc A storage layout based on learned topic structure and using +%% bitfield mapping for the varying topic layers. -module(emqx_ds_storage_bitfield_lts). -behaviour(emqx_ds_storage_layer). @@ -82,6 +79,9 @@ -define(COUNTER, emqx_ds_storage_bitfield_lts_counter). +%% Limit on the number of wildcard levels in the learned topic trie: +-define(WILDCARD_LIMIT, 10). + -include("emqx_ds_bitmask.hrl"). %%================================================================================ @@ -140,7 +140,7 @@ open(_Shard, DBHandle, GenId, CFRefs, Schema) -> %% If user's topics have more than learned 10 wildcard levels %% (more than 2, really), then it's total carnage; learned topic %% structure won't help. - MaxWildcardLevels = 10, + MaxWildcardLevels = ?WILDCARD_LIMIT, KeymapperCache = array:from_list( [ make_keymapper(TopicIndexBytes, BitsPerTopicLevel, TSBits, TSOffsetBits, N) @@ -201,6 +201,9 @@ next(_Shard, #s{db = DB, data = CF, keymappers = Keymappers}, It0, BatchSize) -> %% levels. Magic constant 2: we have two extra dimensions of topic %% index and time; the rest of dimensions are varying levels. NVarying = length(Inequations) - 2, + %% Assert: + NVarying =< ?WILDCARD_LIMIT orelse + error({too_many_varying_topic_levels, NVarying}), Keymapper = array:get(NVarying, Keymappers), Filter = #filter{range_min = LowerBound, range_max = UpperBound} = emqx_ds_bitmask_keymapper:make_filter( @@ -208,7 +211,7 @@ next(_Shard, #s{db = DB, data = CF, keymappers = Keymappers}, It0, BatchSize) -> ), {ok, ITHandle} = rocksdb:iterator(DB, CF, [ {iterate_lower_bound, emqx_ds_bitmask_keymapper:key_to_bitstring(Keymapper, LowerBound)}, - {iterate_upper_bound, emqx_ds_bitmask_keymapper:key_to_bitstring(Keymapper, UpperBound)} + {iterate_upper_bound, emqx_ds_bitmask_keymapper:key_to_bitstring(Keymapper, UpperBound + 1)} ]), try put(?COUNTER, 0), diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl index 8b2e3cc61..32ca85935 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl @@ -45,7 +45,7 @@ %% Note: this record might be stored permanently on a remote node. -record(stream, { generation :: gen_id(), - enc :: _EncapsultatedData, + enc :: _EncapsulatedData, misc = #{} :: map() }). @@ -54,7 +54,7 @@ %% Note: this record might be stored permanently on a remote node. -record(it, { generation :: gen_id(), - enc :: _EncapsultatedData, + enc :: _EncapsulatedData, misc = #{} :: map() }). @@ -83,10 +83,10 @@ %%%% Shard: -type shard(GenData) :: #{ - %% ID of the current generation (where the new data is written:) + %% ID of the current generation (where the new data is written): current_generation := gen_id(), %% This data is used to create new generation: - prototype := {module(), term()}, + prototype := prototype(), %% Generations: {generation, gen_id()} => GenData }. diff --git a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl index df9115a78..c79f94377 100644 --- a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl +++ b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl @@ -68,5 +68,4 @@ next(Node, Shard, Iter, BatchSize) -> %%================================================================================ introduced_in() -> - %% FIXME - "5.3.0". + "5.4.0". diff --git a/tdd b/tdd deleted file mode 100755 index 197891df6..000000000 --- a/tdd +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -make fmt > /dev/null &>1 & - -./rebar3 ct --name ct@127.0.0.1 --readable=true --suite ./_build/test/lib/emqx/test/emqx_persistent_session_SUITE.beam --case t_publish_while_client_is_gone_qos1 --group tcp - -suites=$(cat < Date: Tue, 31 Oct 2023 16:15:54 +0100 Subject: [PATCH 23/31] fix(ds): Fix static checks --- Makefile | 2 +- apps/emqx/priv/bpapi.versions | 1 + apps/emqx/src/emqx_persistent_session_ds.erl | 44 +++++++++++++++--- .../emqx_persistent_session_ds_proto_v1.erl | 5 +- apps/emqx_durable_storage/src/emqx_ds.erl | 13 ------ .../src/emqx_ds_replication_layer.erl | 19 +++----- .../src/proto/emqx_ds_proto_v1.erl | 16 ++++--- topic_match_test.png | Bin 176221 -> 0 bytes 8 files changed, 59 insertions(+), 41 deletions(-) delete mode 100644 topic_match_test.png diff --git a/Makefile b/Makefile index ed10a09fd..8e8f4b493 100644 --- a/Makefile +++ b/Makefile @@ -85,7 +85,7 @@ $(REL_PROFILES:%=%-compile): $(REBAR) merge-config .PHONY: ct ct: $(REBAR) merge-config - ENABLE_COVER_COMPILE=1 $(REBAR) ct --name $(CT_NODE_NAME) -c -v --cover_export_name $(CT_COVER_EXPORT_PREFIX)-ct + @ENABLE_COVER_COMPILE=1 $(REBAR) ct --name $(CT_NODE_NAME) -c -v --cover_export_name $(CT_COVER_EXPORT_PREFIX)-ct ## only check bpapi for enterprise profile because it's a super-set. .PHONY: static_checks diff --git a/apps/emqx/priv/bpapi.versions b/apps/emqx/priv/bpapi.versions index 47967cb1e..f647c660f 100644 --- a/apps/emqx/priv/bpapi.versions +++ b/apps/emqx/priv/bpapi.versions @@ -18,6 +18,7 @@ {emqx_dashboard,1}. {emqx_delayed,1}. {emqx_delayed,2}. +{emqx_ds,1}. {emqx_eviction_agent,1}. {emqx_eviction_agent,2}. {emqx_exhook,1}. diff --git a/apps/emqx/src/emqx_persistent_session_ds.erl b/apps/emqx/src/emqx_persistent_session_ds.erl index c99b8c947..abecb72a2 100644 --- a/apps/emqx/src/emqx_persistent_session_ds.erl +++ b/apps/emqx/src/emqx_persistent_session_ds.erl @@ -61,6 +61,13 @@ %% session table operations -export([create_tables/0]). +%% Remove me later (satisfy checks for an unused BPAPI) +-export([ + do_open_iterator/3, + do_ensure_iterator_closed/1, + do_ensure_all_iterators_closed/1 +]). + -ifdef(TEST). -export([session_open/1]). -endif. @@ -268,13 +275,17 @@ get_subscription(TopicFilter, #{iterators := Iters}) -> {ok, emqx_types:publish_result(), replies(), session()} | {error, emqx_types:reason_code()}. publish(_PacketId, Msg, Session) -> - ok = emqx_ds:store_batch(?PERSISTENT_MESSAGE_DB, [Msg]), - {ok, persisted, [], Session}. + %% TODO: + Result = emqx_broker:publish(Msg), + {ok, Result, [], Session}. %%-------------------------------------------------------------------- %% Client -> Broker: PUBACK %%-------------------------------------------------------------------- +%% FIXME: parts of the commit offset function are mocked +-dialyzer({nowarn_function, puback/3}). + -spec puback(clientinfo(), emqx_types:packet_id(), session()) -> {ok, emqx_types:message(), replies(), session()} | {error, emqx_types:reason_code()}. @@ -323,17 +334,16 @@ pubcomp(_ClientInfo, _PacketId, _Session = #{}) -> %%-------------------------------------------------------------------- -spec deliver(clientinfo(), [emqx_types:deliver()], session()) -> - {ok, emqx_types:message(), replies(), session()}. + {ok, replies(), session()}. deliver(_ClientInfo, _Delivers, Session) -> - %% This may be triggered for the system messages. FIXME. + %% TODO: QoS0 and system messages end up here. {ok, [], Session}. --spec handle_timeout(clientinfo(), emqx_session:common_timer_name(), session()) -> +-spec handle_timeout(clientinfo(), _Timeout, session()) -> {ok, replies(), session()} | {ok, replies(), timeout(), session()}. handle_timeout(_ClientInfo, pull, Session = #{id := Id, inflight := Inflight0}) -> WindowSize = 100, {Publishes, Inflight} = emqx_persistent_message_ds_replayer:poll(Id, Inflight0, WindowSize), - %%logger:warning("Inflight: ~p", [Inflight]), ensure_timer(pull), {ok, Publishes, Session#{inflight => Inflight}}; handle_timeout(_ClientInfo, get_streams, Session = #{id := Id}) -> @@ -601,6 +611,26 @@ new_subscription_id(DSSessionId, TopicFilter) -> DSSubId = {DSSessionId, TopicFilter}, {DSSubId, NowMS}. +%%-------------------------------------------------------------------- +%% RPC targets (v1) +%%-------------------------------------------------------------------- + +%% RPC target. +-spec do_open_iterator(emqx_types:words(), emqx_ds:time(), emqx_ds:iterator_id()) -> + {ok, emqx_ds_storage_layer:iterator()} | {error, _Reason}. +do_open_iterator(_TopicFilter, _StartMS, _IteratorID) -> + {error, not_implemented}. + +%% RPC target. +-spec do_ensure_iterator_closed(emqx_ds:iterator_id()) -> ok. +do_ensure_iterator_closed(_IteratorID) -> + ok. + +%% RPC target. +-spec do_ensure_all_iterators_closed(id()) -> ok. +do_ensure_all_iterators_closed(_DSSessionID) -> + ok. + %%-------------------------------------------------------------------- %% Reading batches %%-------------------------------------------------------------------- @@ -677,5 +707,5 @@ export_record(_, _, [], Acc) -> -spec ensure_timer(pull | get_streams) -> ok. ensure_timer(Type) -> - emqx_utils:start_timer(100, {emqx_session, Type}), + _ = emqx_utils:start_timer(100, {emqx_session, Type}), ok. diff --git a/apps/emqx/src/proto/emqx_persistent_session_ds_proto_v1.erl b/apps/emqx/src/proto/emqx_persistent_session_ds_proto_v1.erl index d35ccd963..e879b495c 100644 --- a/apps/emqx/src/proto/emqx_persistent_session_ds_proto_v1.erl +++ b/apps/emqx/src/proto/emqx_persistent_session_ds_proto_v1.erl @@ -20,6 +20,7 @@ -export([ introduced_in/0, + deprecated_since/0, open_iterator/4, close_iterator/2, @@ -31,9 +32,11 @@ -define(TIMEOUT, 30_000). introduced_in() -> - %% FIXME "5.3.0". +deprecated_since() -> + "5.4.0". + -spec open_iterator( [node()], emqx_types:words(), diff --git a/apps/emqx_durable_storage/src/emqx_ds.erl b/apps/emqx_durable_storage/src/emqx_ds.erl index c8199239f..1e7f88367 100644 --- a/apps/emqx_durable_storage/src/emqx_ds.erl +++ b/apps/emqx_durable_storage/src/emqx_ds.erl @@ -30,9 +30,6 @@ %% Message replay API: -export([get_streams/3, make_iterator/3, next/2]). -%% Iterator storage API: --export([save_iterator/3, get_iterator/2]). - %% Misc. API: -export([]). @@ -101,8 +98,6 @@ -type message_id() :: emqx_ds_replication_layer:message_id(). --type iterator_id() :: term(). - -type get_iterator_result(Iterator) :: {ok, Iterator} | undefined. %%================================================================================ @@ -182,14 +177,6 @@ make_iterator(Stream, TopicFilter, StartTime) -> next(Iter, BatchSize) -> emqx_ds_replication_layer:next(Iter, BatchSize). --spec save_iterator(db(), iterator_id(), iterator()) -> ok. -save_iterator(DB, ITRef, Iterator) -> - emqx_ds_replication_layer:save_iterator(DB, ITRef, Iterator). - --spec get_iterator(db(), iterator_id()) -> get_iterator_result(iterator()). -get_iterator(DB, ITRef) -> - emqx_ds_replication_layer:get_iterator(DB, ITRef). - %%================================================================================ %% Internal exports %%================================================================================ diff --git a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl index 34bb66031..d61dfa906 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl @@ -25,9 +25,7 @@ store_batch/3, get_streams/3, make_iterator/3, - next/2, - save_iterator/3, - get_iterator/2 + next/2 ]). %% internal exports: @@ -169,14 +167,6 @@ next(Iter0, BatchSize) -> Other end. --spec save_iterator(db(), emqx_ds:iterator_id(), iterator()) -> ok. -save_iterator(_DB, _ITRef, _Iterator) -> - error(todo). - --spec get_iterator(db(), emqx_ds:iterator_id()) -> emqx_ds:get_iterator_result(iterator()). -get_iterator(_DB, _ITRef) -> - error(todo). - %%================================================================================ %% behavior callbacks %%================================================================================ @@ -198,12 +188,15 @@ do_drop_shard_v1(Shard) -> do_get_streams_v1(Shard, TopicFilter, StartTime) -> emqx_ds_storage_layer:get_streams(Shard, TopicFilter, StartTime). --spec do_make_iterator_v1(shard_id(), _Stream, emqx_ds:topic_filter(), emqx_ds:time()) -> +-spec do_make_iterator_v1( + shard_id(), emqx_ds_storage_layer:stream(), emqx_ds:topic_filter(), emqx_ds:time() +) -> {ok, iterator()} | {error, _}. do_make_iterator_v1(Shard, Stream, TopicFilter, StartTime) -> emqx_ds_storage_layer:make_iterator(Shard, Stream, TopicFilter, StartTime). --spec do_next_v1(shard_id(), Iter, pos_integer()) -> emqx_ds:next_result(Iter). +-spec do_next_v1(shard_id(), emqx_ds_storage_layer:iterator(), pos_integer()) -> + emqx_ds:next_result(emqx_ds_storage_layer:iterator()). do_next_v1(Shard, Iter, BatchSize) -> emqx_ds_storage_layer:next(Shard, Iter, BatchSize). diff --git a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl index c79f94377..c974b253f 100644 --- a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl +++ b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl @@ -28,25 +28,29 @@ %% API funcions %%================================================================================ --spec open_shard(node(), emqx_ds_replication_layer:shard(), emqx_ds:create_db_opts()) -> +-spec open_shard(node(), emqx_ds_replication_layer:shard_id(), emqx_ds:create_db_opts()) -> ok. open_shard(Node, Shard, Opts) -> erpc:call(Node, emqx_ds_replication_layer, do_open_shard_v1, [Shard, Opts]). --spec drop_shard(node(), emqx_ds_replication_layer:shard()) -> +-spec drop_shard(node(), emqx_ds_replication_layer:shard_id()) -> ok. drop_shard(Node, Shard) -> erpc:call(Node, emqx_ds_replication_layer, do_drop_shard_v1, [Shard]). -spec get_streams( - node(), emqx_ds_replication_layer:shard(), emqx_ds:topic_filter(), emqx_ds:time() + node(), emqx_ds_replication_layer:shard_id(), emqx_ds:topic_filter(), emqx_ds:time() ) -> [{integer(), emqx_ds_replication_layer:stream()}]. get_streams(Node, Shard, TopicFilter, Time) -> erpc:call(Node, emqx_ds_replication_layer, do_get_streams_v1, [Shard, TopicFilter, Time]). -spec make_iterator( - node(), emqx_ds_replication_layer:shard(), _Stream, emqx_ds:topic_filter(), emqx_ds:time() + node(), + emqx_ds_replication_layer:shard_id(), + emqx_ds_storage_layer:stream(), + emqx_ds:topic_filter(), + emqx_ds:time() ) -> {ok, emqx_ds_replication_layer:iterator()} | {error, _}. make_iterator(Node, Shard, Stream, TopicFilter, StartTime) -> @@ -55,9 +59,9 @@ make_iterator(Node, Shard, Stream, TopicFilter, StartTime) -> ]). -spec next( - node(), emqx_ds_replication_layer:shard(), emqx_ds_replication_layer:iterator(), pos_integer() + node(), emqx_ds_replication_layer:shard_id(), emqx_ds_storage_layer:iterator(), pos_integer() ) -> - {ok, emqx_ds_replication_layer:iterator(), [emqx_types:messages()]} + {ok, emqx_ds_storage_layer:iterator(), [emqx_types:messages()]} | {ok, end_of_stream} | {error, _}. next(Node, Shard, Iter, BatchSize) -> diff --git a/topic_match_test.png b/topic_match_test.png deleted file mode 100644 index 6ff1a8911a6eb5a204c7e72271e3342f0a70a473..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 176221 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

$*LYGw?t9b)0J&0U zIGE*+O@DN?Ct+B5`R02CV;Kj`>~jj1*E}wqKkq>Okt@hDP@CFP?k(C&8QrRj5e*OU z5U`7dljm4R44Y-KwNXvX-=S#rmr_~ZyGe%*y@E+ zm%tPKEXp7{H$QP{s=U60|8*4w9|S7JdSk=U$vwIu$gm7*C+jnPQ6~&)=K>pi8T#ixS_$T2+2uQ#u@ef>{*|QYZ2>#`?4eb z-L+~fYv*tu87qG2m8aKZUIwl$16J(sNS)8ZC0#kS9TXG%%OISU} z5n_s17Y_vzxvT6WORL2nl#)XI3Y&Lm+D3z}h8vbfiBcfOA`3ZgLL&3nSS^MH-!CmKh&4od z5Lx_sx$dIT^k{Q-56*u;Z#jh`BJWuTul8Dgz1%@gw6v4!F|{612M$x6nGkSa`M5e5Du4+P3JK0 zL6aDvpo|Pe?~&*3siyWppxOo(B3najoCx2UqY)op_3)u5*oc;6>2VcDZ*h?;0t`-? zs&fMvPHDXqdCxDbKXN2MzxW38VbAvg$|P_uFk7m?(*NY-U%e6R!A5bt>== zOa4fw*_WmoD@It*JIho-gAD0j7HV6IbuJ^JNro#3@Y5Iltw}ws5c;n)q&^v${f6EN zNlDK;NlJ_SyLId!HcJ_OyPNm!VdDbd#(S2E3ec(hP$!A5JsmLY951AYd&}71)tR)( z8XSgKF-gx2HlgYPT+cT@xR?BOJk$T*$V zsQe<9lEm_I7$G-sF8rWi(sD{D?fl)y^RDX1e;zDAClLm~jzx!wRasz2fz*{%<>mFM zcR9@(AX^Y8L<2U{DAjXm-Op4puBN7@1|lu4Xyu2uqu~3-@7lGJp-H}Vkm7`X8-ybf zT_&1;RwhP4vk=u?^tRwoK-G?BE&~mJ%a*C|aQ^+W5FVlO3@HdV8W=w&C zswCH`Q`46(Y7oFy6DEaUxRS3*JnBT1O_03PSRe*Gbx{g8ADa|Vnn z9CR7@nx@xL>%Y85p^N*Efhn8vn`OVh@wbdfpTWX9&J)(Mjsz2Mlp#2QFx)<1GUJ$J zlgBBrl<%$e6~c%e55-J1wQFjbsECVl?~eS%ONT|U5|^6rO?QPq0Pf> z&OaI^S}OPAK`Xd8mTmqi7buA?6m5hDb4|-Sd9sPG1Zxpbv zqN${-v}BagqEJcP&-eBH{qFnmxclRJTniuX_v>|z<2;VzBr4NyGtq{(!$K>;iJP~t zy8~Azo*sKsSYYYrT6^Q>G{vUf?J5#6!cv;>s4k=_cwvy`L;iJTHuun>mzp7gs)50wssKq)f$y z>Yb%kDD^-Ni19+FP*_OVY>4eG(p^gvD}3qLCD`NAFrWn=d%L9!tyzOn4LRy7Whrb6 zOAZmcD1dkoF=ut&TqzRuWHIDHf*r4c0eVzXHBT1NBtzK4cc-OiEr~Cj_J6qmS>GTc zxrbsw$yeukRhhngrWlkxeat~)-zHvZGx}4A$tVj1cUAP#JZCuWF+m_$yrwRkU3jX* z68`fsvZHh%8RhQLvH-W6Ehsm6U5#DU&!ai*M|EH?W>`2sQ36!&~y_#S0f) zP@DPsV*0E5xkW0^g?{JzuJDaHnmPlYfVd3G%Ki_OZCdE&;*WmZR z4Ly+9727|BgblI#AF@>XEwQ(l*kLq=S_dD8C#9tu*rf_t|LDL|>uC+Z)%TXEQ_bJP zCO6dp=!JFl3+BuLh5?>BWt(`SrF~Lh*I!|20`Gw8=ThmkGy3=T(ABXK^v#>K@JVr( zq99Y8J`EN&&lemZG9tpE!-E41i;V-An;U3lRbpbGHe}c^G^>BG;hcM8*d{_ImG;V& zLykOxOd}&LJ;ysPE6O*+@Nsgf5vPM?SDVuTl{lLXc8Xa)xB&$HrVV5?OnRgf#07MC?Ifu(a3Dc@sH_BVGP}hmVxN`vh^&h6br@_z7>*;>q2%^~=tnkX z(u9Qn^tDk1d^pe9f6%WM1QE%%AUeJ0#`Y8< zy3J4bv!_qn*x6CEPo6Ys$Si58fvk&pMzjqtcN8`Qqt=A5-`q4jMjxQ^7=OowBStDL)z?3UXxPpW&-Fw>jtbU^-zn~JZYwM z_zx|Ez82n7d%Qh0UQsVq+bwy=s*F;(lLw}aSa-5Tkuc@8apPfh@KM`zX54wYloe6OOthJ0Wo2@*9Ii0X*^@y75R@fKU*2j+Qlv)@;1PKKnh$t7 z*TyC)s%L(FzChkxdvBR820rY^J!N;S@xzB1_kU*^kD86&50?xQ45b@Y)4s1O<}F&( zQLG*afA3CdseOaiKbJ2z{qBG!409tQLZUjfZufDqNQdDaC>ij!fy_6nDCy&e4=df= zpvKVBKg3))*<+$&KBXcZ(w@!f6fdzfRFf)kM#Rt()lBsnqg3N?hu=S?L!tsRZa_ARh>kX3S0N9z%6-!A>F@f{$e)>e z4h(_Fj62T?L9MW(gVSd@SrMQtju@9cfgpb=G<&q89iZ~w`nbR-9$`$By zE6Zpp7ZLW;*8oLa=EzJsdn;e}Y)(!3+dj`o0)|?_`tMO zP2Vf;e=JzQ^Iz_%cePn?cE6-V>2d|y#j&yy`*PgPOg{vk9!gnzeEU~ntr-28u;7fq zMR8AfrdRi$+6_y|al?nZ(;jj3_;s0ARZ!HkKfe6x_nxi7zk>1z1Vrf@-G8)Whx3vf zoA14T{Res)J&jYlKBybjaMz#8E*Rf1`luS`sP6CNpZTVn(4rU0SZ`4ox|h8g35O5g z;FGXHt*lOPmO*NFLhU!?#uckpwGpJFi>iUFVM@HDQg3!AL2ZQ+Ra@&O6TS&YQGqA? zF?nh#a45OA@0B69%*cD;Uvb)usJy6BLdpDk$Kuz`k-p z(mnj|5nWePpxt+()`xKH$n5R-rosGP**`i>Kkv=OmC@DZm3(sj@)y0C*_jSF{DusS zRtzu!UR!79pAC(Ham!zZO!JRg7xqQiZuIZJ0T@cJ}q>_foSXdOy)PAkUUAR`sIfn7xB@(bU4sx)lVje0ghAw48)UuZ_j z>I`GcP@DFN4k|_SOor|yn8T_o0Bcn;EPCOYkrmSTKK!SBYNtX^&)@nM^KN9Mr~gDw zO=bZ39KG-=-}UbE=UXVt2*)VCg5&{sD2(O3zZ@#9!z?A*(`8$TtJ0B2Y;*d>1txHf z=pSwUc4$&*aX($hnQHOFfBvk@UeU|rYBwK5#~gS{-e%uhNN%p*yt#VSDj9|Sjp-&r6T`Y# z*&lPvNA=G_X;E3(g)7cly+731;Mc@wjm(NCKfvSJP`x8x_@z63d1t#AI?QZ!Uc7i6gz5877tWh! z+1|jB2y=ajl}T9TGiIh3^*}2n>H+I&7%gK6#F;ep?D~518|!}FSGN893BJLfpDiuP z+sSbG%d-J!!w0;igu~dzJf@ZaRH(Za*flDjl8pf|qaEr%7!O4~k!S={mabbjI-nF3 z@c6cm%FnYwJJ}|*TuE5DTV>RzQO?n0>-)TJQiXVwSVw7(86%U6-YNjO->-jY?f%2M z)02~zS*;5TuYPTE45KOGQq*5+Vt2hwTG_Cl|DZDdR$mXj@Dh2a`iRpyBj2Vx#I>7O zuUT`31{`W@WTlpTBp4frJMDVpXdFAJF?6M%kDB~Uo_dv>o3deu{5>icUqiL2x#nXI zs(JE|Z)B|_p;j~N%rytKWImGhM%gW>nFJL{iH=u<&N#6J>tRNa<`b6!k1bLxf z6D+PD9%7=V))UvmPHbKx0v9e_%CKp}%9YP6D~(hCmuLJbUT_X@X!m^I)Bl)U|7eW9 zY;WOekJAMdT`lA?Er+6F6DX{4dGdL(3Ijr%kemm#C1-TlKW7v^ZO5+*Om57>KjCh2TS`7`Wn4m2vdd3-NL4L0>^Yiqmtj!sDVc!o${wn}S{ zk<2~!QZGOrf-(Ff)``MGeNDCx?R4N!Q>sv$6aLUhS-h24gLKEest!Sib4P>K2a$8! z1Lw~E`&EPd<_=9i`d_$C_bPYl5j8q9b(6kg*U&c-IU~c|tnAID0cM2w>)s~~h#IoP z4K1+{VFPeX%pKje_->LgPZ=Cdnf-VE+|Nc1zlEg%$)n|>R;VtY5M`!ozQx!qZ9YpC zz$`N}J=h?4BOMd{*}H#yw{UlFboU z6%{QfHdG5F!pJyV3Y`}CgYZ{}%shboqp|R$Eq18c+EvwWox- z`%4SeG8O)g>xwK-Dli!x?s_>rng~FEI1(Q(=o86oYu1c>E7z^53!2AmRF&uspFP7Y z^5jYPRa)BG<{lfG%4UugG|;TQ($mA}9`gY{imz{9_9>94lm`xs%3UrW>4q#ZX~%cT zcc+kgutyfKbQ|^w`|KoHBuouT5Z`fXp3Z)ZM^N+Z`FJs`MPWA$1{B1hd$AO-YkxXm z>5gDm;gW|}hFrZH85=vuv5CTIJDwN&JGVT~*P$FRv|cUBZ#)a?i|(dJ0M-wAAlDI5X8W>JeJJL$<4!%mvspPz*0b_dY%#p0)RA zbs}ROy;} zJYEW6LpA&C`@FeVnF7ZRONvXKUD z_f4{I-(p?c?5(r57=}!fDXN=R?>1!r3+35UGiS5S8bjxkC&5(4xv#jsD1e3T=pdwD zeMXNw%;>Lc@^|Aon>esweNXB4eSAvq-}jRbtcKtQ=EnT%7ecJ0J2l1{eBwOa&sdcG zGUET|5R>{}uBA$2)|yc}!=w4p{}*H0Oy--Z-1o zhaJq4U)o#ok=lgqs(Z2%s+c{~qO*G%1cG@&?5`T~GP?<(cz&(8RM$MR@9`Xzrk;Cjj%|@JwSAvMRAL;Gl zFncu@O88ATZJKFim27Z8L#5@?*_YcfkU9BQ-TL0W6>;88tIETdZS4J5YX7R?vny_F z8QE+%QNQ}f@A}sX1D9z!FFC``Crb8&Q=J>$Tnj#!>+pM%bHS2arC%#?L*)P+0 zSMUOv!k1P9(3(+}!W0CXvUu@OQRxgq$WNa?$E?z`c>Ai^WKw_JM3HBz-Sr&H1AaEN^~9p{6S(ux5;aUwP%|^wk`j;^*&RKCorStqa^v3deqdH`cts zbSMDCsAU~B?ZF3MU`Ah^f7x9w%(cE=H`UnkL*-;cg{~A&nMaw7p_x(37l+jx*i(Ik z0x7m=`DpB_S{`>|Hu*3=Kjq}f6&caW942l8Q}C8?DlOftJAc*mB?njcVfVbFR3X4i z#3RhJ@q@{hU7Has2hc`}qi7!4=}z4NtlFz_5U{jKdqXev9jIJAKDZ>5y?he1jr0_9 z)%ouzXG^Oz%Cx-SeeR8!G_r6r0E8%C(F&$00v(bIEi;Ai@~yN+Sd7}e$FPo$)byEyW! z`;V7Y&t&oV_l47D0hxPk+O!v;-|2Ti&>@}obvKF{i$6^7`ZsxssP(Lrvg1$_QyYm) zp&Sspx6{w2L}k9Rx;;{@IAh7I=?rpQ5wLyy*!C;h_PzBl>@Q$KV~4SCef61-89NT_ zAG6~`ACH7-rwi3;b|03`Jd^&iG-EyGh@MoTjGCd^H53hSW{f% z!ou#d4aeD7W`FbNWLKwc?ar^HN?isfLiXYPQI;o+R)G%*!lQ|~1&9XoM)$(wz05LN zG!%Xe-xIuNw`=W$)Vc_VhK(K#2du*qP8fF@C$u{$Wqhbn>fW%N!7co_Bai0ZsFWAk zKLT#NzGyx8I5XL|*;3)lV`pH$UKan{HLBKjgH_um@8S6g=rD7 zrl91cY(db@Znp{Lm2{Fal9C*mTp;}#!}kIN1Fq9=X&wgX|D#SUp8#fn*+|b5g%UAV zI{O9FT}w;B!=!J6Wkp2=T_%`xTx2B7Q-+vl@u=4m@(CM5p$Gu&@w$ZlE5cZM=w9i8 zbC?+N*r;gZ;?yo@N39;`9$J0`$IUXMwR$Rr7g%B2a;|#P91NE$W6D>QeV}(oKkYt1U3`uL(8ElR?HCNe04Kmm z6>`RyQt_RiUKLjgYyJ6?QRCf`5{Z@FGo+^u5+AnThP)2@%zDr-noMjN6#n$8MSr0b z2FXJ9n|UQufn~!>4~=5fYh)yVl*F;}nw0PQQj7ON57u!Uo4`nOX0;Pii&Fxe2e!!C4oNMR6xJRSn+7@7D#U*bStVmb zuXWNj+$~tN5N;xaX*q`iy@-M@SFxyn*`0S7xo;rIim3+@?L}Kh*22 zIQf)zV5wijkfD38dp_4`IoH@$_@G;u&my(@4VjO-WvpI#TKcf$@S&mQ&y;3x!HcbR zhM)4(YCW%1hY7^7#6(bLhGj*|;d}-K{$@mip+hGh(EsOKANDPO^$J^?Q8-3?v?AVN zQth+T^zUj+QgNJTpPiIa7PGH4!7+y{EEC$I$(x0~#rT7&CCNB2K-dG%SZ(p*V`UfS z#IMc_KUdx0v|)nt3J*6xB;<3kv~U1YvazEq?g+kzz^2SRJt$!^J}z_KestS~;Nfi= zg(WAQwKdEhtyW0VGEIAsc6*`PlBmvK0>mEd_rSY|OaSn9Wby{wOk3#{8)SOLI1%Us zy9m_PXlaK4mfk4jUnwh-r&HerF!=@^VBp64HuHN42nV)$;=}=7Elx~bd=B{CgFN#f zYNw^`+BY9=&in-nD7c;O&D=k27h_c(!jZS&Gql}Ha zD9bn$tT|%%;v6soGW(!My_n2@2UQ1dNs)dho3^)5s^38H;thr{zHSZc6=|+qhmXVJ8gt?_J<|dl6F4|(jpZoBfOrh3;rF! zu&WqsZ^o9*U4brst9vxeii#LHe0jGbCAkaQo-x@Lk$2ltJ))LJ_B;_e?rE{OlbYNt zO}+AAvqNm0vO{`X?H_Y{R8gAG%S!bhn{s|u++FN)E${eG^;=sHR%|bQdwcsw0VI?g zmgBKxMtuJ=525K=vBC*9*TRKo($mA2B42`kI$eU{a>N9to-+~iT=QME^c5>{wr$^j zS_4B{%)S`qc2&=iN z%D+2)Zwi%NV*5->P>a(cFjvMexNy}as{WU2w^~_Awj9Llo*))@I-3rW`H?>d@S{T@J#%Tm%-eEX+>-I?53Pp4czmhJ9j=4i$N)P z!lOovV2b)u;91|^o;FqH?~=^ov{9TWW#ym|)2=h>QkYj+BMw`jo!lS$TY2QD~| zZg?>Bp875~zjJDtB`F27ot+KYRmq$R6m09Kf6wKpHxW-@j7qc0_h&bm002-z4&DiJ zros*nK1#oV19dj)-1s&yC?NOENxR)=yQMm=l$T8xmnhijpyWNir?r-%-!Prflo8L} zw#<`wcJi@ufReKE40CfY{i8yU%y@XkE7kX1&+rDJAO_!*D7_#fS>N}juc7b*LS%8S z72IIr8dj{erCP%u0ZGB>?=V z=<72JqylZ{3GxdWc)ib~bAKBB%v$%z;}1W-YMFlj`RB#|wMvtWyBgAy*j&4PP_f$DmT@v+DGi zG`Q_olPel!5RC?PJ(%>ciRieXoKPCmcNK0s$|Og~^IQy%hTnP7Y*UIuMM=jkbPv@$hKC z5-n=)cJbtZRAQ`VJdvo4j zbc3uPxb`qzcl>YDUW#BFsP$n3gSA*QCv@-`aQkCmbuLe|w4_AVr=OH0E&RdVyG<02 zmF;|?zIVLZZT;Q!3CxG5Nv|U4nd>~2opPYX(IXRhuOMShIqvOsO zpPpP7Pnb8o*!1wDX>>`9+X5L9)$RuqRn8iu;G;qCvbMGD07xf5-Knrmmo^;^ADQ1v zN9S~Q_H4KAiEny_?3UBAfWq@3x9e|~ZQNGo{emG5n|+5`t~fwoAm8l3bEAAf^CSD! zijz#SNnvw<#eWm4;L|RU$bWBiNPBcPkK!_JR_Cq?A%3*V$K*ur5firt%4+i8!s?0;BWn(r_2Qy zw}iFS&gJ}o@q9hRi2zh&hL~#eAh~kn)6ba=hs-NK!*FP{4|#RU=Wk}oyG1&L8OhRv z5hsfeINqBnsXimV#m=br;H2J`e0!lHkBnrYjlZGumMy~R!EbXyYT-%Yd^R;f9W;@k z@D;YjFn$J((p335ySvr%u`{1Y8hMH`ofF0HjOZ3)HGO!HzH&&)*#Ck|tPo{3&WlL3 zJT_qz>ljEIUx+b9mo@Ubnz>attuMO0qeSD&g+6d*Jl)*jF=OKt+i$FR{aYo=Ys$0c z**`07dHI=hf|r7{q$Ca>zZBKt!+o3-(|5Ly&rAKGv21`)UUIc@s`<={C2Dn{+^22I zNGq>TvfEj)c}(sAbvX+eOJRu}MbS-``2I}}7RLb_ZsPQ?1!l)`it}|RE|#{q7-wn} zDVy0MCBG?_C~|4Na_g)UKWFIu^p(8r9$7nQ3_vy1Y_J^3S{t!Ek%Ob-cS;myS2AxW z%?M?!MztR8QkwtGYYsx)=7OG&qPo>$R=s&mgEwZ-50JS99( zhp~F1b1!X_iI*xqGm{H`!H}GJiTIK`9v{n%&Up+RD*t&rW0>jw<1i$=vB?ujyqXb=jDjHby%x?lGv3hu){JU*BC^*(bV)L9MWBEtlae zJPepZgF|VhHP+fb}*!B5YC?ACVlFV@FP85G1K!8{A`gNbR*Ke#YRfGL` zQT=Mkqa)3FN5y_lIldmcox(J^zmN9NJ6Xi5;p6pyh#4TZf#B20YgdxBArZC zdW-Sus@RNsa$bQ8=FJb*mu`mn2Cy4tP;`&}WQich7b zwKnU+RFz1HiIGZlDN1{nESnr39KEpQ$Z2Fepc-&?-$_LbFGT zNZw7#KXdT84B26I^)n|WzERX{m|gee`tFnxDnMh;^5Lh>i8_VRgBg9p(3&^F;hh`i zzuXQLXNrNaFQrX?@QEpR<=ibQpFeAvu=((ip+mu=ks)!!{aNb^r%W)7GIeL(QII5= z+qb*zVRf-Fjms!NZs#}Y*%DF@v~4yzJge?|+TRG>?^DJ@q_mKD`Z-Qt^!$?BI?rA2^*1B{*A^i?qy%Hk z0_8gR(y|^yR6iBEzQYu3=KEHXyjCdVq{|1reteu|WOSQx#`mSPOSf|66&Lm1H}D}_ zNrs!&6AcTB2dutj<+VR~@;l#<_7fMHxfYWrOxWvn6ut>8x8ZEQj7|N{{0?y~UuE@c zsnKz}S6T56Y#k7mx|KV7OVE^hC`Z?<|c*0b35AyR}sq~H1#EUC<@~>}p$9M;jUh+i=*6A=}fi+V5*A~y5gBZVI z(V*wL2|HSzTEg;RnAb$Qvf6SM*balN_tmjVj8hqB^UsLDs^_(|wxZJP5tSONJV97; zL!#ZYX1}mC73BRIBTBscSZ_Y|{LZ*t6OUB+%_XW-5K*aVSh3yI{wb^Gt!Iz(G8+31 z9u!uvQ<7Q@-_G90{oGOO^-q4jUgoutt(h?E6BT4Bx8A^I##S%fPX=Y3kC#^yqg?R= z*W*A3u6duth&1Q#lAImZM#;8cA5v@xl|R)MY=bGA>t$zc_cN3^wq`)D z^XaUa2~84meaKrDN+j|!Ls#*eCtuS23OjYPjaH1B(SqcG*F}GS9vg23gP0;MGSa%T zZ&8&Oy$ospPtI!zt@q;|g3pu)cJs`~uH%fnapU;8b(@nbMm}xW*>I?Qz$G-O%!pI3 zsA{E5K4>$=LD^vIs7FQP3P%6QnAc-k?|VYN*GlS*UczG5EH={R#1|{WeTs^@4-=;8 zqq$iE&joaXoZrvb0YSGc?%eM;Ue`2OU%pWK$<2WI7wra1xQp6P+m4st7og+yWS>mZ-fsZYxf>JV3%~q`;fkrH3Al&uqSKme*xg#0G#}e546g6 z)WBcKCw=cSy<-m!Hfm{>)RnYQz|yT2sED`h^sc#kY?}Y!;u;*@a@1qzx2yH)7cM@} ztm}o2W2n`c)#gjI1|57|Q8C5p&BkxqewDfA?FUURrM?#WxRlEOZkf=*eqen&cWm-q?O~ro@#S>#nL3sS8drJ z|1Q&B!(hN;fl^qdyG~+7M`_DdT*~RYSb8%TozcGO{`n6cu;itE-qJYOm$Lo(ounB3 zr_Yz7Zh{*r7jx0o==;DJ+@&5-qgVpLLYt?vXNrYI!j{N6b0%892k;z#An=pp>-Xu* zTea3q%nrg*ELHDhQhQM05E`1>*LolBU$!=k`me3E^)D|J%W2rhc;h2^!wEiP9pe%d zB4;r!6C!{0*|cXC3)+2Er0mx7kF;ALP>Y6I{%I=gOXtu1(%gG;oE0dUw|Dl1%?q!2 zX<(`M$trY&j!tiB>Ct1x5V_Saq9(r5%}zn~-5oc251NwL$jFqfT@o5-&tW^?%O4;5 zgkLV8M)V66_gE>l$3myY)cMIl&+*qsigdu7a1d51+rC`J7@9O2U7Fd^@qYPx zvxpq2o4TN-5f(?oKPp)!|E~sr;$z7X$4^{MZU;4GD!fO_MVP@a_#L&dU$*_TW%O%_H;nw_ z-`wjGprwbj*{O+c>$sYzl*vY-s!EqbUOel+Eq)- zUah0qreH2HNZ(6joYplZ`pw!C*RCCUw2EcjxJ$IXbW!qtltV9*b8z?mMuS1Mbc;-1 zxBL3ie>`YB1Vfek2E>1`YFsZV)hKK@$_IE?6oA4?PCRhoi^8!bk*fyS7Hc0~HS?vp zwa$az6a6+FzCZn_H8f%XizVL{I5rgNWo|y)YG>3I=X%Num=KjB9F5@{2c#?k9yLIS z3`6Mi=e3EdUV|Gxf1WsQT#8I@UF=~cG?;vLIL{pzWS47Ow`lW3QPS1_N@ZPnagC(o zp(n>GrYb5T>pvM7=+o6CWs?dKYaIayuFl}svS z<(7p62`8)gz{I{*yW#|p&0P;P934Wxm&e>d%EbbXkB#m&^ikiGOP4V0dBXDPwXFqp zKfE8QoVeYPbiP<-*W(Y*IwJh?y#^*C7E3&I2nY^}@tnEEx4dh@NdCE;4cn&zrghh_I<2EF$)juH?Mn8Ju@{>QgdP3t}2ZIkhAW_t8ZAn zdfx2W`Ikor9fh)0Smnh6=}d!fQlsyDxuY;}Ag8oSZ6Sqx^^FnQs?^NXS?fX_R;?P^ z8bpQ3`)c{;7z_mcUi)5kM-rnInnPWGYPF8(qXe86y_jGP7RICM7=BQzYYeHPB9E?A%!(xT|+u z#x#f@nwOV+JZ(f_-LhhF}rQOcA$6*Da zQ@rh!wv|%;NCq(7S>lSziwdOA1T{T*Kki5xX{ld*)5p8O!D63&3;1;FV-Ej*ZuIUVKo$1 zoBcLTy+3`Yb-^F$XB(_%e7rZ=e|CI9$^HT;Wz)7a?xSD8VX)rVb3UU%1~j)lsbDxVXkFNz0`=RwH%V z_w0BU=+h%C?r}n!Y0uG2rKtzAdFTMA;RiPDRzGgIjg6hW33u-YDq-S|kG^ByXM0Gu zn+AI}N8B4dqin^5sppTMN=m7SDXoKaqQ>gciNtjlY7c1oFSBYn0nAYJy!Sogzn;&^BMUvdXmWCwNy1n)DiIzYwe+btz;r%`)28XiE3Hy2#HZ|kw4 z4fV)+SEnJhS?m|0vE5w*62m6K*K}ITt^_$BG?}6QJFLr7?jt>lc+AqPYZIOCswgX? z1`9L%QeV$f>B+LD09Jcq2P7lB)2L;GjlVa;1qlsfW8?2Opo+Xml2_(`$wLsjoPO4v z*3m9Oc}%Z>fq?w0Cmp8Y;uO}HWUzuHH8s>@p^}C9BTa4`EPfcYTj-8~l9>DrjM`;_ zOc3c@%uh{W*9SQ8fw4=cd6s98FE}1&wXndQ>*!d9sgi?(tfJy`6h9D#`82_|J%*_7 zp6HI|4p3RRZ3%5u^^0%&ZK3ohF0qMg1`a3?6q#>imzqjguX#y4K;LE$!5h{l?qz~} zmBtC~HVR0_D(UGgajfD_f?ZQ|)Q*x!0bT5`pfEU;;ZR>yeO-PQ(mT-Cb<7O-Iyi`M ztOX4LLo#M@W4*6deWEO7?ZPWc3^H}-x|2dQ+CjTPQ|Z1$0*S@O>y()bZ~I;0%nt$+$SXZ|g%erqiUjyVnC2Ky^g< z=X_*&nHzmJ+}K5ElR-doe}6o}aa=l7rCCY|R^>^H*aH8nx^&^lsDHuHU_BZmXDGlf zKpxE1piS}Bx8T@bt6-pr3htKY^V481x%3hoa)5I21_r`F9qJN8NQT^(FJ0;*DVd^%^=bpi3!O#m44{I^ zL;@3+f5X zMG#8fZ&Ubthu>ncvon__3405qO}E35!vWII7(H%~vhr_+0E;@NFsflo6v!j!xt6r{ zPa8)MNqU)=7A`Yvk4+U8To)vXjtL#jyOSGbM+8fhC+Yk>;9UP{PJ;Tbwa-uL*IHH? zl8)hK1`_2VaCOinJ*lt^nMM`B%ViPnLWTppxp>tnPVwnhmUo{V?ClsCrS?H} z`kNQ!_C7rnr%ny;+$FVu2hZRbZ0W1;&gx1zFh@P2MNgRvO&K@N8;(WthW!Xu#TAj+Novo_{fl_ z9lxJk|FV5wbW7_)xMlT=v@AB(o-5WH@AKed?`J8~c0{&}oxW&hLP_Kza|uqCKy)P# z@=N!XlKSlZLQzyd4~!4eBsjrUB*?rHL8NuSaHXCv0z|>yWUKc}6^E|vXegk_N3B8J zz8g49QIU$KmHW#W^4fs#84|OO>Nj%?LF8CSF0i|pp9*qb3MtB%`1bSY0I2{4%E-KI zfe{2BC!r@UW)LA3UGbbleu7+yCXSUG@N3mKZp|DM?67!o>?fz{h+b_EfoX~_*1`W0 z<~>kX1f~&%3wRNFAXdEh8T&YOLkH7cK%sdK4nZ$|{pA1(^W)OMP88sayzdg~aFm^! zw}6=!B;)`-By9xKduIoSh`6{MRA;OyZyGow)WOc~G25a5sCQ-kWMH$qadS)Vv~B`3 z43Lm<3YdcTIzJ(g5Ilq_6Q>N|5$T5|6VOwmmn-$i`E{;b4GumXF#9x16DJfSGp(&H zPX2(}g2lF{axPNvp$Ua@{du|swF~(OSP1-^zb{kUZHUi15C_xW^iV6|cje)bbP&*? z2a5brKkl|J7KQYHS!ms_Ufp^pLJl#v#^S}8Xz+Am)v;;Y=IguL=Plw#SX;FXBogm} z?TFss^MS*jJ}vCp$=o1!RazsJbyffE`Ge-GaB;+>zh{y?gguki_r5io4z)ji6eBNn z^`*0ClQ(gCcVkErEG-bzU%Y)gy2F(>yPj3)0V)ZIU(|`FM(b6FSLrF$!f|1a)lON* zhY=P#Q(XR;^w|aGrL1fdgGf9}+gZK7@Sl=tb87xC7hrwHHDSU;LROpnfExB1${Fw< zP74A=^m!M4y02RY{QvmjLnNhnMX{L+=T~yH7@KJJ%^~sy>)zQr%$~{7bpIj-L4$pC zu&Q8UeEs@1$iSO zmJvO#hDHikfA(zeH`lxwM)Z%fwCX&{(kZjIK2DHFctIdsOucx(J)tan9Mt+<5Xn%diOiKy`21BOz ze-tIy-}iVVU86jV9bB~e4BaT4SyRI26Z>sV1r0kRJqON;03-(2M10!smomg}qV+}< zSH*GbUQHbTcW_Lt5UwBB{TMQ`SAu**6i<%)HfGLDotDszShsb|dg9Ly!T6)`Q3|8yv z!rjulY5#nnG_~%;$!@xP<|I1X1Pz`pTb3}cC2gnkiJhm8npNkX3b-*cavbUOQ3;sXsgnrMU+Ga>AZ!f001^C*@#hk&I%|03)09CNG1$Q0W$24 z{rT3aWa%~`MqC&nAJz93TPiWg#~A?vi45|>{{;6mFTFdyx=qU4*Jze!Dd@%_*@2RB zJ3^htXeY&;sxVqI?U7!A)SrX~VwNzpAb*3_2r?JVXX{qZAfZxQwS9eyt?ez>!Z2{T zH)UlPyX=N7q>w)O`$tOGn*D=?snf))1iRXGgWqn9PYStwSF7Rw6w+24zsmuKSNySIgk5Q7pHFME6kB3rKtv*b4&|Hvr2*#L!p93 zP<)k2gZcpn;3P0v2FQ9xg!o=%UdGHBjQMbT6f2S#!1OrG1c*3n%;O~w_US(=x&PqZ zjq(!I=z_i{FPaKd!*$`bq%H=N%me5@`Cbq+p+B`2`~nGtcrrjyQJ|v^oOB;{*P~T4 zb(i=akH)>OivYqKWEKV=f@0 zRVV?V`=&;KPd*qcZLX!Jx-lx}tNds%+?K5AB?_8e1h`~oYD#GiEpr-E7;a>%zU$zT z2*3s00uc?yqJ$W97z0kd>t>>6HFKsxK!?8C9JJh9`DmI0o_7m5z(W^)%GAvgBkxj; zk&yy3YT3MFqHZ`IYL_3q077b@u9^2*?cA)`tHBWi_ja5+SC8Tc(&8;DW!>>TM88u| z3zB1f_f3VNLWlGi{OFC_6p`*s_U;nlt=(3@2-fwH5^WT_@{b>FGyU-ta$o&92_x-F zSq6dj+zx(KoV1}h36xmW-bQ2QcNZhN8Ioa0^`@X;`TNn`L?$13A4@6eNH8>K92p80q+R{AR?ak-I+X$HKvmTp z9A&5l6t}`x78c@tDt&7l8;1MQjLgeg!pVl#0=a7O^^G5xCGC3gNlL^BoYsKjf|Cyj z=@M)LC;`IfE}qYxHO^lkixa@XP=Aw1c~rWSPK&vT&5a=*(`8UfEMS2)G;z*Y?-UD> zQX~D_qTB$%uSjt8u(5#|^af^Ih}JWD3)rFYO5>KZZU(IF2#OT9Ki%@Y(hx{;kpvVy zE^e-q(?UKUOj9N0G_jz|yGtzoqsNs(E$cseLF$<^4=yhjiAs)hjovKeE>ykqq{T2> z`R96Y==$%{r>xpilI}QKV}~T_DtlFb;U5)T}%a0N{(u zi+W=lbvGmuOqQzyde}wW0}~N?B>n}jn1t-oQ(R;gN4zU3DQRkI;@^^eAJci@;4$;S zD3;NzdNpw$4~--DA9(1jrF9ab6z)tfUcXl9?C!2M!TQjb{9rZF7sT%mW2eP)n2vtK2AP;r{!> zf-*7^R;V&Xn7Zq(V=0{fuF|@6QZfJ<%k_Mlf=cVKQ5=Ok3dfj=#CBmXQCYB-im%NQ zz;dXQmU=j-E&J77WOj`C`J07ra|3g4-el8=%FnrG?%U6Hv$#>KVhYI(=wC}y69oap z1&cV<$mh9~>lt7k8<(9^S5(|}B>?Fz_a#2QlN7qC_R%fJwCEA;|)$V(vMp!fu@D#pI49LC+!REb@rJ= z>$$FQ}%yHN>XR{eOiy5`$|r+~dEx;b#zEq!Zfz+v*?dUQ$ zWvyGm+U33Yqf+1Q`j5Yl=_JH%3MJS5?rYcb<(egrALlSJi+lyqqh^#U2NinDsdL>$ zCcT-huvNud_4vcHk2oi&VA(w2@kx}N)#Q7N$vk5zGAuI@8rIz5U_a}WDTxm&*a1UixxvVhfhD&4gADrXREzT7ry`Gq zF2&p&0G37S4?Yk|%N{)Fv-&B@yz%P>FJ_X?3jizuQXq{}Kh}c)QYYawzyLU?kC^B# z%rn3hn);~NSUOn`YCFK`=FmI%e}vsPclP%;N-P*Vd^mLIV|wcb_BVw7Fi2{RW;aou z!hiuQ!ut&v0J9RmaU*cVJ$jQRoT7HI5Q$$ ztx@@p;3oo#S^Z|Y>T(L+Rj*8zndEo7&xHmHA9HXG_A~zNn+^5pGbjaDzA_Q*hL_~& zCH|SlnTZT<$9v<(0xc(A1UdhdbZU?D^I|Pe!A8K%Pv{V|p5PeFn?E`S^#iIwOi=#^6(Qz`5(?VOYoB%o!-Xz}1uCC0FsSWtOURJT-F;9NANXZKP zKjZfuILl-N{ZlnnRpZJB%tJbar^F99T$}~&DSS+$ zfoK>}n2>&JY`n zSKE$t+T_Ru71vblvrrvUdhZ@HO=mVHd+C>NR?Uc&6x}{`=FBGmP3`B*4nOcWI9%|- zQAtB@0N4Irdyl|smbUM_RQ{zJkdJGYBe07XcAV_v#)W#y}~;8WMG>2o4sS-W&94j7;nbt~Wd3@^{LQ(a^= zbIu&a&iSsHdvW7>_3AYBfo1_)GHEt?ubnE{635%8gA&etT`!!YaAn5}H%?`8+$aU& z=D_obpG1Qfav`jb&mtPw*~xTHuSys(HxMs#pHijLjGUAuYGH`(p0KJQioPX^1Oah&$z zSe0IB-lF6I+{gD~(uWWfFii-3k}>;alfBf>xfgcZf0T0g9xLME@fzqLFusNL>q{L6 zh%9Rg_)ezKQy8q(OJrV9(+tHX;rQ{mz-fSUJU)s_3kd-dzHwut{!yM2C!0HT|8<8? zJi93SkC7o2?3g7+qhvh?EqXd<{?EvPKX2_%S>ow$8J? zZP@o!M9-!(a><05Nt5Qdyt`1b_H<$Xv|jRY-9*m-=rAXLztt;Dz4MFgu?c8mUVf0S z@9VWAX1IXS^G!5EQBEr#*rTCS>m>Hw77iAd@%dsr!2W&l;^e%1)z6nTWQNM0&5k^? zt?je2q=w@h!3hBKn%dFxBt_cKuzzuMG~FL9Jf70h;^-%|8_q54~#xSUOHmUKuAInKZ|DH8L9XCkZ|kJWXn)Q2bg4Zk_h4pV}nPedHND;LSCI zT&<}NsGj+hgdEZ$PC8fJ~%Q8Me-L`y}S%3lwK|(|C$gIn;_?zJ}NlZ+(_{W)qCiohpr6tJm z!}=e5FhhblJ_|Ht4>B+tZdkB0Ms=BQVNG*y6su8D7ar&5Mgx9^Vmg6C<#YGm7kW!9 z+f927v;M5oxL+2ykqN&OTgh`~QYZ0YSZI>s)kC*~-76>KY_ntd{$^QjQol6-ep!~w zQH%H38?MBq;sug1wPli!7@Vnrc(E)cLQ6z5?(5atRBm@K*qrm7o!K9b)ob{N<^1Z_ z){rpfIXg2FFYxv?v|%ZSP`MwG5)~#vCAigppZH|+P94kGfxG^UDhci$W<)M>(ZNgU zeUb$an1@X^HZ3VA*jejLX~7lx7y13wduq#i&io#$%ZH+BcasVlv<8>uobB8E?;0b>pL288CR|- zYH(n8}CO)a^m*PjsF z2v2PFy6VKQ%~!N_V2`Z*uk{6j2)kyox-03>%E^VZcO&%nEPgLi@a$GMTdOxRXY=ZZ z@jo|ZMCoIYzV?=zs)idr*x}C3;VV|I?D64Pa0*Rt4`o}qOq2&!>$i}IsyBH}ynpv@ zPd7u|Lmq3_YG0~xi-_9b!59&--z}TadW#Oxs{6tDeQ_w3v5GAI*z9`^Qmmx%UYL^a zGbg!7Kes1Y%BNTj)wlk}cd#{WzEjGcEYpwubZcp%wMSW0Hz5$^Cv$bLNhd_REvaO6 zQLUy(N}i_6+IYr|(YJOeXryLk*%u5<{RbF*rwL!1asK?!eCfuPabr}2WXoQ>7&bD( zDx$lNsdUet8@|U{St-iyI5Yd1LQ{b^-iFxaEZzV3 z5i4KqFo||XDV=z~V6c9|`Q2iZbU6AmR;?>ps#+Pe)F@8^7&d&4^*+e0XOojf z-vW0ov0mvpuSfKUo>R<5{k9PEbyPL@mw5Ai9-*>on|J7LS_2bDILMLtBWd*|yGbO! zAll8%Wu4YVC`fAab0Rs%oC9*5lHZERGzUi5+TQjNF-NPeaC!4T)?b@Nk zl_uXe%9>Jmi8bi^y4i$kZvNNgo4co{rkt;;dOxd+8vO*39{=5~If#2NS#TsVak`6? zD9q@Z+Jwv+6VptN51Gxaz>c+`zLJJF?z~cpc0-3@HLQnUH<|Ujt&cV~dV0}m$J@fjZ`df7X<#a?{JxlH=RD`( z^6ZS6gEBUa1}tMUVnaiIlZ{&R(d~*d_bqjNDY2klrtrjGs6$^j&XJ zJ-M0}lVNv6%J~c*_xbP`Mk(H}Tr_b`FMRL(j<>9#F(w(RNCl%5kyc6!!dsO^_<>2CQxhc~Xg zpoD)Q?(eZyO$e;|sBBj<<9n6gc(9;cILNX+i%ZB^p(K9dm5AG%}kS?z^V zYCJQL!VCL0ke9~DcPR$2dd|w4pGhT7h}JvyVe+sgI8PRvnBc>+gVmj8_K}Dg zCTGg`zw=yH{5T3}p8drNf$D8_e-$qU6@>8T<;#*kOD~*}^RT9QP_ z$f`7rhLVs`ltd|-B<-QlQbJ2n87(3tqMbG&r7{{sQ$tA^ZQczM-}}pXo!9mG{qcL> zuG@L*I?uel#`AeRkK=JX9*<){-IP-+`Fr$h=H6LrNF7=V*a;;XiN|WW)rP&BT|7Ke z&YW?488)I=>e6Bfk6eJfWI4OzjJE0}-rhLA^3&`$1}iXC6+S5`w;xGM zN_Mz)OE3TJdNa*o30*thU(>l}?5BNiKFWv^6Xa{orAc%#jvaVDFG~JNuEqh*j2?vo zcspCeQhJi=yuhJ01l(KRq~)0}?ou z)yG!FnB`mY2u;~H{RR+#{|T%-WcC5Ch^#p7(oyb)4~H2p{KfB{fvQl!5rA;|raI?8 z@!WxkD8tMXir;H{}1Fo&As_|5HRck-;7qh}1* ztQB#--ec=Q_Dq8da9W^Z)p^CG9}RcTRfvl6X;7mxvu{y9bd zrRbJKp~+v#m~FS`%{G(0vtHY6?d0nD-?b!K>(j#{Hm+3Pm3Swzo7L}rRvk9X*(yKm z5GHm<)6#ax-^_{jANtlcEh4Yemf6u)O*~Wk)GPVyc02yAHA-Lfb7u$bx#RjZ&bzUt zcW`bO&H0gzofnR99yQ49iKIqXS+#Q$ZhVpM8gzSfpD@R7Unf+FJ1U(xq3gfLEH}8L z_vab9wr%O$_0#R?`k&V9n0MpC)0+MB#&mdV*xRZ@NY?@>^;^mQ zeOGz>3cGD|>5=>oqfPz4LBXG3ZY+}RxKgPJ@2KFau5~IhAum?S?%Dd<|5Mqj`d+Kg zNM{#)y3u88K|3Xu6u8w^?viT!K1S(n~ND`pm^a#DX~>)_pK|PH~1{+e|c;7xhofL{X2Zs zgDRsnA8v*dVPX1Jt%;XWSpaA?PecEd41!kMJ(E{G>2UZ>*VS|OE*crRB;u;_+u;6V ze5@v&y_9sWvUlEOgd#pbwr zGD^K@k;06n<8CFlOPg}XYizW>S7iE>j%6lMVbjJ(zllh!+IDSrROVj77^b<3fzX*O5J+z-KknLa!)(@?LbLeQ@5t;CN=f0w6D>-q*WB%OLt_Z z_Q*u7G0!?))9CotHPYTna>Cnfu5O<0dfGiDMWTszW0Z9kwChptId06I13CenyIJVP z>zno1-#=t~=*8)yhECbMqkH!>qp_<;Jd3?isU(WfmR!1K+q+?IpCbz7)>|I7RC26S za_oG-%(i@Kf^OEF%~eL7yPi+e2^pHF+vn49o2FiM3a0kGQdB65VN22~Swp)IIWtoe zu%{XX$%|QLaVpH%2%0f#%vGI!!$ypiiB&i-XUDyO8xg~GBIS+t#x3t>I9uEJ&$xA0 z$Hy5??K0VS*N!3i@21twl@Qeodo&~HW;esP(Pj!MGRKpQEG!b*JR;uiKdFE{dT%*{ zxH8==hl6!SJ8Tr)X7wFh=XiPwp|A15sIN4aQ6SYrcoZnlr;MaTA{CWekJonFb6%m_ zfLT9+#Is!H4vbjSFK)b}txunbD!XGaW{EYF-tnbMsyXuUMcTYgrPxZz6OY!bXC@33 z74)>rkzi5C^n-~(M}e`4eag0rM27dK!zHGtj$k?OqP9pR>uWk{6bGEZ$@3~yIJUf& zKdtM=6N1nTW9RtElQbs=V;2QA*ymcIGG8hZ)#pL*JveCQPnc@Z6m~40*%Zl8K=^tf z{-`jRoAL-oBGep6IsARuHH2E)Ik+uS1|N~{X}BwR2BT1fPtdHg^78&inoRiPDa51z z$RwN&YNg?nq&WBdfTaKkf=pc3Y7u>Lj!rC;Y3y=NIjD7L`;HO=tj76@CfFH3T}$wf2InLrk=#~13)(#LJdX5TNek3O2RGcyR?0u zNb4zG|F-`pB@#d>RW-FC%L^6%{(=@>Y4*0Edrg3uc|9ul&@MmjHhKMwo;F|_jcDyMdgLPc zLP)>M7cPV&{dG+CP>-uR?lWlIwSC$JRjIM8WEdYn%5(bk{fpZ77_UhKZJS9#W?*Xz z0#f?mG^FOzlm7Totw)b1uU|j7$k{A>N(EaFe60i-liU#RG{$zfmyw|^{9JL}qJRG) z8ZIwhKzNfaJtbT|H``yZi-AFoTSD|@X;G7OnYWn><&LVJ* zz5UL`zR{R;Ekq+g(;6znm<~*5D7bM$+tBbF>B{0VelF;SWJn$s>wG3TIxZ;hA&M9s zv5~zidu9TB;w20sG-UpB*=CWsmCvzZC$J59L^n*4zJT@C9AovvH zJTNvhXZ*vz3%Mm6stgy2V2SxI6=Zo1u|w=`%=bpi0hyDzT}t%2vE6_a)BR@>@jSwp z))$nK>5NOko@>bR&Y}X9f8r4rRMi%mJUoJmO#GO%^Xtct>~-I{p7#)hMyNjkFn}b2 zPz)YY@MCTQ_m+CC9W$$hmvI*G7padsvv6Vn=XKj$$9r?ctP zpJUaNos|U_W)#dR=c8kp7BF()K(etdi`CjLDPk18KKjZi8KEc1o*YqQ{WFPveKcGN zL=4~NxPV$gG8B6^)#5AUmYF@a|UHBI@-q5+$o5c$( z_wBOFN2ohmT3XVLpEKvRt9!r?0vnDKcvuZ~JNJeLEFtJWu{if!N?KY0h5fT>L{AMBcG^ybOUEOe`e)p+!>FKx>e`FRPae_|bc6kW)VbD2_N9J7&ZhJnm1L(^k zS6Gd;1{%KMvG*^I%xecU1gQ*b3ParJ^WJWRfeg$K`w3IFx#dIoNsl0ge0^t-2Nn}f z2p5H=BlW+fkAS{QC_I2af9PLV_8dvy^WVo4 z5pwrEkp^@Ido*9@PCOpjRamxl>`2Amg#4YUG>exmy+)kk{Qc*h8OjUhdGwax8+w+@ ziy|cc7itVfG~(8Sj<&X6aV5 z7HKR9Oa$S%m(ani42pAVbm*?bNX&;EG4v z^GWQ5G0rI3yCed*`U?T4sndT0PJL$=9u6ZHeFTdPp)^-BZbqv8gAPONq}%!Vf0~*g zU|~)LG3!mwkz}s_y&ObZONjU6`<#W_6}C%lA>=S*0KpVnlAg|!v+mxD7puz4L-oJk zwstH7-+uo5_iW(bk6XH$2hC0nJ|g#jE=BmGp&?Huck6voo@Y7~DIB_>n-Ti>MGdTF zGJZsYl1~drcjlFkZCg1q1{~Pvb_A2yJ$ta1nqRJ-!5(|Z-zOh|goNfG0k#T=54^6J z%boeL8fCd&=F1i>y2@ZaU;rVh*zkW=4WYT4a5*Kd;Cat{L&tpkFa>oB{wu%Xz<0C4 z>e$fG5Z(?mOgWtA!PF5xGRlRNzyZJ#99EDH% zoQH<@;OqT{#7jc)Z+`QCFC|e(b)=*Y-rZJYvPA-i7q~Ip7PWR9EYUl67Ql5PQRcPZ z9Ofg?jfBq!V5=EKNRCAyUYgC(!`oZJB!WK+awGWB3>yX)GiCq&sW>0931Wr|2~~tD zzzFdM)x%xz0?x{s-v9p#*z$esnAcm7W)RZN_@5DNnJvl0w)ejmBywfT#WwNNm^<-2-+ZRWs%(w zf*fAZf|vMT2$cRO%kp?}d`uWP+Ggp_j11+Z!(U3U1Lg^<4p~5t`#+~acJ|@_oCYdd ztn}9#8}7rjtjJu=F#5>FzM_ZE+GLm{jJ#KZ&1O-zXif2e5;jXY7UU1<#?+F*2-%z1}+scj!hgi-jrX>vcfNY*Or%bJF*kyw9UesfzAjCogebu|jIhILtxBGHk- z<0i?Y&1JY;i|-bRjNv^7K$(RLvnw>zXVt2?{;duFE3m|6bfiL>(KrTb?be`WFuawM+5Bz3#Q+s`1y{{YZ{An0jQijm0U+B#TCIL)bpN%%g%1Sb!xZ=XXa3l>F zw0I%&!$2B?Or#{oq)6<7R+Kz>BCxYhx}HVcgV4rEL(Z%#s!iT9AO~bhGjsDz&(6)I zw1GXQuyboI6;k3-#5+_#G{2!BM(>eMTJ0%TqEhYC# zy+OezX?BdN5Lr&5G5VAbR5nxj8b_)l2Gt|c);1Q?^;vS~* zqWMRD3#p$-W^mk^P9lGagkt#Csb?8(N>auFf7UI1^NqAbHJ~Ouu4&1`>{|Cv$-$|^ zaC+?xRkQ>Q8TU*<5oM<*sMx52NDoxi)$jR@DA_`v4T}=k2+C?|@KV)PR7Nm;52Xk+ zR%11lCQ9$n>27Yg9(oha5#i4@?IB7@{P(&Pc|ZpG1_qdj=^jKm@}|1_7iOg#bw+AR z;&zznu_zGua86QjOi{dqYd<-tf4F+viWY??>F=~TPZ>h4Ka$QgnjD7^sqEmqw-LV1 zDaVeHkqtL9d->qO3WUL&ExT@Q*(4zfG8U$5LntTTN9MJ!)=gv@^6z32)^>JcR#x2W z#y(nF9$*g0DLY@#9y5st(vepbCTLHdQ?`>S3ih9DjlH9NsNwJX1@WAu%1LenuW=+T z^T}Eq@+GtHlib9G)sY5U_*CtH88T9NszFGwa4AunP~Tw^#ibZ)bg-=ccWcp4*-P81 z%voe#U45pJ((4yS>b=WGNVWL18K}fT6%lWkB8Y5msSMjor@zhF3e} z6R-`>2?l7Sl)Luqi8t}qEK3*76T0LXX*?D|eaqA}SWd#rGBNq(Nai3C=@&9P6EPDK zLrm7@nf3^B{P$&@p{V)9!x=rX>EaYJfO|4h4iD*mb?BhgH(~t5iL);*ddNg2!Es~7 z3df%XiD?_%SKl-l!-XIY3)j;>&#opP<7XziE+} zEM}jwipo`95=PTKl$4A#ItS_eyRSN@9+nK6lglG1bCiID#T`J%<|KkIE}TRL2HsR} z;5T03^r1|T%_H*FBnU@tvH71*=z3={C8I1oXvuf6m|ziyPVJj9s*84IdKg4z1>jOGYUns&-< zF1<|^6%{3=sq{D6md4+FTx+h)&D-AETx~%RUSQh8$(alXZx(0xI-*c0@uX>&ID11(}K3nhKH_lW6_lXK~v^R8sbPqq(i~vql+3EPbXUN`;clL4d-N zg8+T4OjpfM@88@0ESPI*$=k^JjEE}`)x3c7u%3?1lbD)%F`t?=_ z!2&1y=1qpXA^iNvcR{W?_D+&mZ~j+21!^un13PSVxb_Gso&g`jmBUO-=2feSYMj{z zinao8UZaE9h?EF?iXBV(A#5Lp1QWbDXRw92;{T(LFdWyy&s(a(V4s6X24(u?^$iP- z%?mbXkgd6~@%hBWH~2!KDtE@wNViG3vvkoy~lDrUw9WSv30sJv*DUpgBG;AJE8J=uaXi9mS zTAlxWIKoNb-xEW)k*SH-kfjsv(My4u1yFd>=_3In29voxXBhkQ1s%DC9h|d>josbR zB=J!0_zf%xB&yC|xKOxSW8=~%Pu3tuhoSF~FD)_*Vr^^!ZiRS7MZz!tX7PVwS@+9F zMM0WV=giqtsaCUwvm7B4B`M?)Sn|j8mtbE(NFmqbpa}f^O}k726vuGj@ZoEqi=x0~ zGOTi0x}^o9bre-qIZ*`Hb$1S5vX!b?E;^ArgjA);0wZ{PRMrr$Cz0ho{$bEF?g27i9&Wun z??TOy&$bEh<1rLx_Yy2D$awJipKscurWiD6+UXhlc_0xH9++2nzcxd9i6)cQx?j^3 zA-raFfOsw_pPr?^1LPtA1hwXIusY=Pz_s#`w{F6G0E*f%DT$y>Cv$Semx=FBC z9_aRs+~&e_w@tFyeF^vox`ACSV(-s6=W&AtP6}P*=OhySWgJf#G$JxW?ua!Vlyt7) zpS|u@bt;!YBl1^t93`EN85r;aN&>8}Xi76YZ3J7*&=7+kGmu>jEVSeeH2{e$C*g)2 zhkpeEHs|$H*~K^-k^vj=9-ndZ+{^FhtW~{AM?ba=F|Nb99K7^Frev_Cmb^!`nnxVopd{cVxCzCA@w@TVDj9*fxmhc85n5`tZ}*hCW%Wx zi!vzuU?(qooz;jBIzxzgf25;crh_@P-?2czoe&Q8E(;d@n+5b2QnpFDw@p|FE)mRS zfF8_Y1GeJ|1)B2mZ{EL0pxckO{INWVh&(n0y%YdA zVTd_I9f(n|R4<{rF&@kh8Qjdx<#dqxZziHr^Y7ie`1%gn#$jAzPpAiJ$55=+Qe+B2 z5J<+-(Ok&(fgZs41$gh?yLOkiXyp z1KNPpfBYyn_?HWSBP3AIYoaL)Dr)!Wn3((j#CwM0wLPGge9i<++-it!cqYedh_xX& z5ZCa0dkq|oW0^215DN$45Uk0g4kEiCBk)=Fu9K|Qe@1N*$XUY%Vj2Ln&Cas2RK4ij<_Rf*s1B|+hc!krM5Z`c2xV_YeuS~-BsGWu z_h^ou(dNJM_5FL{^_-u`t@-VQLryIK?wp18ljAb@zYhj7%c_i7Q0o1(L?zzL$>W3@ zq9L8zfD&SMTY@qRbeoj|bL`5p=bo%nz(Kb0-o1H3qGV`EL}5M?X+``<-?7l6YgdZm z6EZXXS!`&xmMl3zpJAqVw(SGksjx(a0x@70Mi5*tLPnYpcl`z$TK-%8q>)E|a*z{^ z4lS-GL#XK`)1{|SfFjDKhei=a3W>*XA;%yhDGCK>mCv3Nnax;L!b z7`gU|8dEUpbf}2;^5)#R zL53nx+71pFxbaPOEO1uQM*OFmdXPD?7>9K0iNadY;H_`y6MG*TsYkk7AYdmj`VZ1q zMjvQ~=f7RaixjlWe$k?mkU`VW<8ULe*f`voK>9jsz0h^T^4Phqy_5|@b4ax~D>*Tt zS|P1bR8TNx-hzM@5Nv~Tl6lN|FR`M<>$Gf8jbX zYEC9?>+QF0-v(hbRMEElns(xZDb00$8>)XNeew72$j=Vrsz9|BBge7b6a;4NPA7q+ zfKH|E6`l}{Vu;@iQ9>{`1zH1|8sXc+C~kH9ak}t*ufUaR#y;-hfr0J<@p5O_Dqk* zK5pK;vb)+6mVxz;UEP;IKc7OE^6y!ib;zw5=TP>?S;8C-WfEqgeo_0|GYc5YB>cUs5~ zeY$|2xV#qShQ*Fr3)Rm5&x?`j50dToI<1!y87x9c<1@f}2OR+SY*pEy{$On!?}EVj z#EDD_56(uG4krMIGdf;44*J>S6#05vXO1`+_KLO?flPpG07>T@hFc)?ecyKxh28Gd zp##1~ciX(0xOxbKf*%`d6`=@8J3xLw2DqNG7t!456DM@?hN;W-)z%KKElI3sI~~#@ zSje-#WOxu0z`ZtDXo_kA>5xC9wrpX+trXCRH?AgyWxAd^28>ND|Bv_@8(S%{!FYw$tXk#%ibHAQ-fYQ36yVNM8un_s(o~N_(M%i z0dczjEo>0~sj2V+K!II%Zf+ipMw+TCb13{P3E{NNX$~svWE%Khwdn(&04hXxk!>;( zKrE3<7)2BZ>MI2uw}I2<%!xmISi^dZgk>wqFyX*u=F9=Uqr&qA(5B+~g zk9Bqd0U@VG^WApE=$;1ySNtFvYHf|ATgV zKE)U5Z5=*v;K?878_fIqYKg@_dKeQlh{2 zNM#)!=sI=}yG?^?L4Qq9NlG$mSK6Sw77NTBDW~uRfExoa!MP75K}x{$f?h`eK`_KE z=9r?bGaH)fHXg5;KNB4!EDwc+bw2LV!bnQy$i4aUW$>?AE|S4-PM*zScZ^^n@suNy zB5|C+7y$-)`uOowh^nnK={@%9Rq=mrg6Be4p>(3oPMdlhjEq!-Acer3&U@^q@X0;&{m)9Z@_bI z$8Aran6@?u?KxvL?yfKQz%Bb-54-Itcv3!(Af@0aX!!LjwOHf9f(vkhwoch8<+mS` z<9~7zUX_rO&^X}?CMO|*!%mg>bm{WtqQ7}uG=M9n;%~wE>)uC8h~C(8N-$F{>F;F| zcK>b9=`&}3WqvR#Zr3fD5@ihT2`3o!kYQpNIv0GIgMm~VK?&&JX}5uW`g$?H_91cV zzTY_>TQO+_hIjOn7~z!JZk$e}Uhm!=+eymI+tSi0FOZ$&vw$&6-s81F%GbT?vfv$L zrG=bM;>`7k%1cU|gnzS1kZp?3?ZFKNYqaRy?Gq=V90q5=jRKZ9VmrZB8A<_CJOm?T z*|e%>ySp0%Y(%<80&1xh&0$K5A0R0@R9z&>tRiq7m>%m`w1P-3r(udW$#pCpe}+Ak zNSs2TD8I0964?e*4%b%R>PUouN#*ZiqZ26rBxINOWIBpUx^oJHio$9ex~N18T};Jm z;1tj|8L2HTp(*!PWOFiPQ<$yi2}9tX;_7<#)G1Vt3r%$>3~?u00-sJ{=Rffr5=_3I zji}@-1emfHF9^Pa4`LO?YP({r(akcj0q+9XYwzgrG;{R@Yo>4=?9O-|0ckT9Z3XKN z9y(Nx;TcuRTEv>itmz!s3cqIga#BHuGe`w?+EmumU}`1=vw&^k`Ae5AV=AtGn?uF|QysvOq@+q<4bDzHMtMRF zKY#YA_$rp57Yryz;Yj(n(9n@=VHppC*G;o$-z!w-L@Md|CZbngNHk&2`z(~<7^nQD zR>X_s`JZ5t_@M-cS8FRyyI+q_(m&!zTeNOq#|)MEk_sUl`GE?9PIRNj!XrR z$illoVJBIc^nL)@&%9eu@JY^(Aa9TylpLizcRl6yoniryOLNp32R(&CbuGnCP^e&4 zom=!UiA_ItGf4Gqq#gJ)jXL5~;F%8Ig!2*YJ&hhcwil@dtpK&H0Cq0?yWdVv$og<( zyf<>Rgs7=~k1!=o&G>!$Fgmeyb|Lvf{8%<-OyCv)vv#tbHCRZd!C^r_+JK}ofQ^2k zcrJILwzq5@3N2%J?B6ffz5;FJT4GE=(-ighvxgZ9g5w~M3U&KW9}hZl$JzQBXU?nu z&Lu@7)x%Eb#!hdMzZE?^aCDGkQ&UTQMb!BCg&cu8VIl>XK}&BJO4kHdosAB4j855G z%Rt@hIXuocBZuM&aPgv(;^Wd%s~-CS>S|LIKoKceHU6$gWngVB74W#IXbGbc$qA2n z6Re*;d{|pkjID1@j(GsFehR(hBq28;9)~93w1z2X!{}imCuu)9S`kVe+PPfQvtFp3 z!KOxCnc}@_<;s$>GCFSBt`MS4XSDQgmj|KoLE{Y@PS<)lcS2ik+chRK-=0 zgL>)BIQG!0VzBbzeaRiSS;0voo>2?Hs3A-5-ys*D$((_&l8+48RoPBjWW$&lSU%<6 zo2)x*yGhjKa1mNzw92#9V|O7B@;P$!;?b*9b$f&%s!+1nT6th6f(~f40cdN(;ygBg z8SUH}dq|X6;dG<<=&@sK*fuopXk`&mVTD)q3exo9cxC<2_X|xT%)Feo*GWGsur((k zp#mVk@PoX8qv`6B!v-k+>^K7kebC zXZHi~!R4f75!%+I`Dk$8w`ADpu41?ua_?Hy{LMLg+P+wI}eA-t=q30qg( z5^MXN+_B<2y%O3v^?NZRkW=gTOMAC=sSC;};ZNMg^$${cxe(OJJeUMFhQqZe07 zmaF*}%`&ZysEE2(X5Jd1V@3K=T5mLTsGw*O#Qjkn40E+xOCkbAnNKMOG|kq1BQyf; zade#m$~}KxFzV&0d|v8K2axL1bHReKsam%SLo|zzgsiGMGubJibZK9^L0b04RcoRq z+f9zr9D1NO5;77VE$pbM_2+aHEeA*0^US3s$8Z5ypNh4*#)M8bd}Q7AQ^`b)`TZBT zbkF`Bx}v$@;=+QoJ>pMO|0L@j_C2>Fte+6`Yx2lLxJ`V2_-Acr)+<-!mAu_yL)k(8 zrJ`a{NyYoBzA>@d6&0ngI+jKzCLicra;3ytTVwg~vf9@}LI-S9a&0U)b-?LdmvFz% zyM~r0r@k*&maucx=(kB?b?aOY56i(3-!o(E7w_WQdlJ6T$-5wL-5qizi^6_nd< zTbGy)eQUS5yojj%{L?N}%UU5>`ADMPwy2^(;w7RcSMhmK%&DYx89|j5rQI8fnh!ka zTe|m`SYfjNt?`*N(vKSJ)`m*>Hy(Uhkg`|SeqcN1dVF8E{j*2H8mH-WB34|nYZc~g z59%@5OE(~cZE*B{L;2H!@ssijhI*&9PLviW)%^_9nE&%ct)FV|(c^UvR_a*A*v9$X z-BQzRS)nxg;;Wn^R&vLNgp1`fUW#h;tdAW9iuW4KjDg7EyTp?oNpJ2n1Ak7SUOk15 zVYhX9Eva8_cPEFs!ba!F!u`Bs&i6ZSS0o?FSA3G=HC4i7Oc(v>DA|1&DhR|BNfj&eO;s;cSbhCPEQ=YZk$nsTqN_u z4zIDBjk?b~WL|dDFMzd`zw#+)*!{lz&R8xzR(>*iZS%~L$>eMUc z<7uV16`QmCOMR#MJ37JBLivLc6lK zs=YriX?Yc3oPY4%hp2#A(f;QHI@eAqT;@F1XNTWS?Va1wfKE=I?tSf`eZ~Hlq3Tg{ z7EyvcyV^N3%t`+gAV5Y6B!;0L<*v^ndhag{w38WSmynPw7bCs-bAAWA31h!E>W>+0 zJltmNwW>WEBkv|>Mddhb8Zbov7)cP9+^lzBg^qUVE@!jta^E(xcawb`k!g@y1K72b zlnA>`Ea<+|rtLCLf6n)X`pYV{vnW9G|JSBN_87^L+h6OB|2|owri1FhKHXjXQg^;C z-QqY72DoG}DIuRd4<>v*DP!Mx+=4lAv$Qnyqc$@XTS<1GR91(K#?%kJN)NtJ4vgrg z7PZFiY^TQ5aoVkx7S>;5Y5%LURgOo92`w&HPKxZ)U1oQJHDd36 zx3s5k8|b3TH_R#>eRfaI2(J&fYZGYgVPN3*!P%)t$8Rr@1Q!mR`q(15IyG6%!nk`c z(nqhjasJgKZ{I#k^ya){l)^rn{*r~7R;8y8?eURYEEjoC*Z0usL4!{J968SaVu)TZ zItj8xg+far=rsocBP?dknd8_eE2TQkuHrZ=$?*X;$&}f23RVPczc=3@@q~HpM4NKg zDGv|YyC3=_H?gKpn2^gS>i9sPn2W1HRrruNrpt6*gbtK-g80yHd6z=NXT6j(hUxk>2b+QBw=NMivfdQ4ssMWtE!tp-E)) zGp0?Waa8-H^XRuW;mxJ(K|HzsOo_6GlQN}>-!w?RkDNXrCU|&Ef(`R2#aCm=MhXX& z<_+~b3@%1{Q}vE`%ptddgAEM&jtp@$)@x*dfKus@?xiEgEwFO=vO_!PX_Nf<*ljPr z2gKav3<6D!ieFmweVvz6bVR%|edY@nFA4*t{sf9Rvx2raOJ(oUH0rZBM6-9FD-m}~ zudU+H0g?NumU8hrozxwh&zUD5Es}{j`FTj&>3T16PKPlO=^E@MnFQsuM2=q=LP**( zZ&)u>^UodUKm8atO>H3d*z>Z=dXuNdf?ifjB1aVsFO@R?JZ?ksi;-R)G4Xllxwj!h z)PmeC+fJxWJZP`YCoW!e`LK|p@P|cp?<4x-!CZl=F-RFYz1*EGeB0$#5cae-$66LpS*@79w`Ubn!i_D^3?ASVm)WoC^Z05khgJqY7@p4ORrPs@(1?HqG zdPOtxsn%Xvy7o4nTQ~YjzCw#-zv5#`8|l){KYRseY{qG2y&j3H%LT`ny(^UVn(Tk6 z$Gh5cWy*EEE4{5Z!5t~}oj}P@+WV%@hDp5S=$q{}V+w3MiPIHpnCzpYQVbhqHO*88 z6#N-b)vKmsfknx2mIS#$q3oLy*ZE_2jT%3y>O7)AQQU*AKNS~ho;RH2uV$AK6C(Tj zjoRt!QHy`tEsTfynYk$}`S2c@^#$`Cj6{!bEDFCj)jVO+(}WV=#Lq(}1^yi@FhX)0 zxQ+EXy)h4h{*R5qoJNF3xPY*jaknTq7)qqXj-qif{wu>KecQ~DJYoEJMhdK2Wz=Xt z10Vn1EOqhgkz`N2Rx(MNaaF0=+1bE|Jl@w^`#b6>;GeZu)_PNvOCBqE%5;u(iUf%6 zQQwt)eFfX_y<36$OATVrm2@Ul)$YcWE>djeGXX6QmC@)mpf^0oc}tg0IN#~;9MJRV zU7DuU(==l+4Y8Zn4?;8_u6pp`V^4>Gwqq1EbNckigyi1VO?P;L0B@c@`&nK`Qe05o zU_b3hh@`z5Hv3!87-F}|%7l%heP5;ar|j^f(*n3_z2cINA>!!d&fDO)+8g+l9|^=J z#pAY~-!%jo=g;nRcq^Syd@#Oe-&gBPcPGspRVDdz*~jh?ftJ$Rt+MSr)32U7wQHAB zSz#&-7jPo_GV0dlS3VEv`dlyf)~$BKdUMARihyz`!L zh_=LqNfM@0@p~}YP*a7!%4DKcFYC)!55z*Ib|}f%zJlfny*c}0w?eOYj6a&}&g*K~ z4}-7aSB`+f)38NZU_k{v00)9BI07zEd~2UUD*mn_>_bWoagxKpttWQeuiS5%&i0Uh z9=!A7GMtn66kfZ6i1^WRT#(B4x^0E9&R-^1E=%uf!?_3)0U0;>_%g1X+!lU*>G)9RYnV=)tDk&=3#EH86$vtXb3N z;}7SJqQ9|rdi}9B0>OZ~UN|x2%c_650EOBKJCc&Xo|%=n0BRe&MSNWG%EbB>*!it` z)p+PYKv6lu@R$w1gxc-G1z{cbu%Z!}*__AQ#3ZcjZHWP!CUPI`%)N&XPaGVvdL(7$ z#&+A;L_RpU=#KMzvEPyY-m%s#S?4Ir6>V8|=L;j~Be5{;r0$fLAF5p^;crj7ho$b@ zsyzMdam@Y78(`u!fJ`$|9pggN*EwUfSV zX*}={ffL>@F77+B`mF`vac_l=A$c6Xs%*-|b#P;3)rQNKy&Z4WB2lAQKdI|_>}i?0 zNN2Ke0T3|XSXpU{>Y;HyGXnvLPaa-(dwB7FmYbC#U&o7)R zUxbOvk=W5FSJr_-!-uxJ6im}@{Z#u0yofOu+ROATeWN{E()2Udwo?>si)VHLsg?Z= z^=-(8^Jc;;%`Jrl#%AD^9yl;|$qI_n=>rdN(1e79G+3~Vj2hZo0!)=QxQ!e+lI;Un z%yuK5CifavDU6G7Xn(RlhdSgAyGvaPhl8KM?!J9{KAbea!@KEM9MH|R1o0cITuDW) zHG1@6mk12N@b>jVbp=%1)5w!JSSE5G(Yr^O@RG00K?eni)XI4uh& zK3mBGeT<10CPRSWHx|f@gdw5Xs>1a3Yu85Aed-sg5O$kQ>2GP0YVqz6KySo=JxB%2c<2Y>?N-NoK$K-;WrAlT;1ZJKf z(v2GRN7$#Qe)CU`W_Xp+(Y#JOgS)rZuMwGAJ-9h`QYh=9l_mkd4XVbRR?B)k(dL=D z?Z+e4X8iI3r>-M^ZN`&}M8fs=kDU#lL}GKF9>b&7K=S0Y;6oNpLbY91)#}4(yT9qX zcTe<|*)9-#s&9F&;`HLAYNLHHKw^)RKCWt-!e%<(d>v*^xnPJ$ATej z9mKuV@y_D;^M~Qxha)g~2$-8}n)FmU^h7myU@%0k~&8-m#L7VUc)iC&$|$^u9F zOH8Awhwm~>1GwMB85Ic6^dOj+N-T`tll}*3sW1+UadrECF(MVfh9<+4{QQtO)BJ}I zyDcpc&9Q+1_S~k9x5WEI^QFAg2J#y6E_%D1)*M-1O%l$-Nd#+!fk)#gB{>=Ev{e}| z35;kTBc6n@X*&HD`Aa@u8vLj`a;X9QB}#UL#E|ue&O>MliV`h%VH5*RFzM{mx9{G_ z3esT4?6I`CG2;(m0YM`Faio!S;$!w@GC2#@ASd8N2M2zf8N%rD@^`706|X6>X_0X1 zCS};_;_ABh-d>guSIR*SPmYGW@vKjD!kBxva>-c<6=xg>PpstOkf4!{7K)5x76eu{x!e8%$~fGFA-7~D3B~ooR_(%$-Y~JD{`UueLijPMimY|SQn>Hf9BzG zHH9JRFD6yC?W?|I*9e3WIA?6#+AhkH>)=}X{B3S#O2GCdYC&_R;2B!y%=vT3YP@TJj!A32j-C|Fw0$u#{SAoun1a z>}On!$kG|cU<0(S9y1MxCJd59uUA5du;QTErP%azt*^)(m~yHBK6@b8X0|u>;Tz_3=k@G zz>mkSl(W#}D?U%?k?Cr0U&zltNU%tVh=3D1&)a*_3B?gPHO!98v&z{hK5LA2p9}VVH^x1rq|a4 z-AVl8_w*#bmpxo9*VsPsYh76*&0lL_Gs!t$#;E<9%+un74Ofxv6b zIcWpzKRt&PhgO`+6^QnFdNwICi#YN3kRZ_gK?y+?;%{LHcSRtsr4Um3>gnMd(ZY}s z5IkivYElEj zi9xow6s9^eGcJwUB4ecSs=NjE@F7w0S%Ygve7-;hrLMO~O)@Hz#yBV7xy*=}PXk z5VExA?N9=Wk%lr&P_{g=f(E@Xni_#!W^mNWlCVnz% z<3ccsGtChz-_YbadFqt~0C|WIlDq7h(6%3_f&Y=*XAz}d8Z*i8eyn;=h(DP^S3In>p$e0)b$sz#bmZGoQ zgbJ}nt%XJuhyq#|?1=u^=oK$s%pCUX;{=W;M@J}Zm@A4K8q`QwU**}^lR$R@DB;HFNISudG_&N+S9Bv#ZOxSCuhhGm5Zpn4MG@!?k zBQp{cEupkAs)@)uYq0B>xY%WDv^VM0VDM!ZN(WZHc_WWJYl2qyER$pH4lr|S`=l4?Hf6EjG2!`H{fn8+{v4Z*Cr1o z0+B7lot@_D3bG50l6;u5Q+ee1qp~HnsiaJQuuVujyQayzPTaXt-~c^%aNv)dch>jq z)VIcSuXlKV{ie&9xpUw0dN#o@BnlEVkd!cIDEZ*YkCb899%K;^5L3W==KwYwI@INZ zMam(^!>0QW91tdiNLQb$5o2?HLeSskDUI+ALCo2) zd!Jp*!djt1(E+E1pC=ReIJsHTmLXf9^Dd34F&ug=&FeElIMUk_kMHt$J~T8Mw|8Lr zXF$F4hmMmK_jB*j(Qa+y3mG&}_4)IlU%N`jzkcCcld<8;8VWo#1L$p;N_2;O9P!a5 z474)bjYg+-ec!8hMd?r(-w6jbvq#s@?R$`9kVWl^Oo)jZ@RrGEG6IvdV!IJKF$@T7 z{w+)@B)=zh-yETMU5g~!mopKHB@Y+W0H&)~o!(Zfw;aNJQ(yTGPy?{BAlrKXdjIBI zb|1yQr$@wB|LJSqzA9m`cH=_!Bheo?VI~I*C!))@p*gebR_IsAeVv>?>&BaoDSjcg zs|=1#u+}m3KE5|B7{RTyfMb{-@5NY+r+aeUare~IA04mz?*Cfu zcRVBI&CE%Se`+Upx7~ivWL@gnEym8Py;CP2+I;88MaR}xMSd0gzE+M-$SHjTlYsg{ z7$8C^!+J#*^%^lY5pQ+NK?RZMagScTCiOT)PS%S#+i)FfcbNKd)8skK&B0hjPUWN}3F zssqH$q5v^V+gb7UTOo(7(u68W;?E4_i*Nvuw(jy9C-4adOS?Nc87{b|LL{dygzYF) zPZ+_VV=xW}`_0CGtlrkTlV|yq4=T3}6tNAy1_2spCwRw!2!_2Gus6XV+eRn^U%_A3 zQ|$CUC%F_r$6juFF0wC3Au4>k`=-$WW&FLG!+}pgoE>NrP(mpfpAl&=!Tf| z%ANqdc`^O`#fuvSA0-Gk%r}GtF+qd6gQImkjf3=fV zwfvs?=kt{cP5wiDYoGZ!S^`7-p~!0d_|T}i7ACz3!A^{6C21j*p{Jvxs|y#zz-$q9 zMaib-T;G(fta7r5T1bA)c{uFwc0~U+S_)hs0m2jz16= zhmD&tnK*x!f$d5BE$+CuE$kZS|Jt;ow(jU!@!aCOcM&~7Yx8H!_1e$nKVUjyP)iAP z>Felz4;G+MfO_Z^{DifGDgbL-2!UF!(!P{-M)QI71Whwx=fTn@aTMk*FrEuE+x!g> zFSqMuGaq3W>|S;Q%lET;m)kp?lU`P5maU8bn((Z5fyM07k`j4U)x{vV$B$=lKE$lK zbfR|hTI<3!>kgWJ=p-{qZ~R&})6etuEyPE(PQ-t_wc4~f<(}_8eG><9W%%Qk=K&Df zDb6nAATxY;OZ{Dg7i*mpCb_G>jkE(<>5a-}+wR?64&KE8z?nes)$9w&JUD8<(4H2X zvg*Pcv2rH^A)YQ~Y{$2-c8&p?RT%5r0L0f049F$rPA!Qk0#wU&+ zXXfy@rXHb5H*eld?uu84imkI?-H<`9$@m2`rbxwXfc$GbuRU`5Bz=T{_Fs;+espiu znR*AH$h-9g*)gw^dDc+cX##^!Q54VaWUj9d_CRtUb<*m{N}xWW<2t`LX9M8w(kG|B zH#8hI)j#F@L1q4&G!9Dua;nr1RaJHsn;5xres!qdcc(uG_eC=lZ}!HcRe>RH*EfxR zzSQy9Z{A5EB-C5Cwrb8JF~MOD!Clk}dnV1{HWHCjRNb)NES|PWSn;6MwbRoMftFIv zxI-&`_|OR5g|a39&vNGJ`nQeKA^$zv-}QGlcH3?m72tJkMHx z0rb}|AoBuvQRGIQhGY1zWsOwikBT6INg)y@inkDw$7vF2lA~X{qd&K z0qiP8+r#DonG^C$$S?`h_V07<{f;#Lb@X0C-I;ZO8-#`O)}6Z?-L2>{4SHz zH2Yn7+k`NM`k#+j-rB~dT}U1j-%U3*e37Q(!pfz^-qX~thCsaGOE6$BMGT&U<*u%2 z1LvD}ql=kz!U6$*!1p z;W>vADm?LSavV$^lJo@&j=(*-`wK-H0h!*fGHsPB^c&9WH%JqRcKtlspNJETKubKR zsPCD<&h-Nv=6`Q&<`}JGfbg?t7hy(?rI0fBpk2f9$l;0S_!a@Fnfs z*qC{6d)b@z7BxKi&w(4LaDRF~q!y>sjUgM@?pptIHadmH#RpaU3j92wg{fRbuI>2u zYbTS8UtiyubceE*YKlWX>LkQ!?As3b)d0ly9t|Ht{>)3|Lwf{h)> z#;p2DdmWSu)qQ_{nl2Zj`znBP?G}5Z>P}fo-*ta}E+;1d9^#}eF$uLvBEfK0e9xVw zY}jBvdTnv`(X6cIqv7WCwCFI8;AAh}?fL6r^U(yE#0rRYaF+pd%ke_G9!`%3nBMH z3x$+xYDE|Gu01kxH494g3bBouvpveX7g419;j4}z*Y~a8GhVLy>bvs_1J)3o5n~SQ zF#sdEloZ=~&n9t)a2p-iz@s<*J{KFScJOWhhLZD(k~3ZDGFVz7wK`-pZ>;_)_C)4Up~8(Dk7x*>d>z&V9JN+>awS5W+XMygr}fgo&>3OkA94E#Ny0L zXBCB!GaFPce?0&)ftN*8VOLx)ex?g9pb$qEWVBWUwoY^n(eIw+q+hji1n_rhNC2)g zinjL3#eyFjRx7|;UhT~?^4yBlkKXHy;myX-q^buiFdwiktc<1kR`z)I_?EQ@IuWmi z=l{WFs9d>%?$p+#G?M%Y6?R|7WghZES7vPO1FV34>UQU!`vZIQKY8(@;Ks|j$UmWT zjs9f`2Z##0(kFw>jA30(M*F(4Xv_C~shgk|#T2H{Bn(9>Qs%L@S%57K39WX$2%mla z217m}DUA|KFpD=yzrT(SYaj7HD<|TuREBjfrS-DmGc)_na&^r|A{E&-Q*p#rDHxdx6o2mrWVh9@FJdZDloldJ2tNAM2eq zm{|6oGGg$r*9Gq~uU;*=!u&>>R55dB5y0Te)=FO@_f@JLW#<0YV*+K+Bq}nRU#u3zt)&b|BtOV0n4%N+Ws#YO44L1DjH>q3Pq(9DrG1l zcZE`hBs8f+(QGIcDszM~RfZ^I6HU}@4k3~dQide_e<#oLf4}eB-fMfe`+lG9x~}Uy z&*NCfTKm4QeKnxHXHBwfcV5Z4Q=C!x`k&Om-o^!SzhYfBb{tA4*iZr{BpjSDJHSG_ zrgr8L<)??^@-}wr^2%s<`DeZS*&(e;Mg2$Kj-9o*%_jAAM&&5GH70W`tK!ygqzcNd zFZmoUr$Tg>j}<%>0o&k`#4iW2o}HB9D09-Wc@U2MvfnAPEf9W6MVv z&Uw@8-%i)U&XG1VEPRCXg=VUcbfvTzLa2Om_72%>US1I00cL{+od-oVMm=yic|Ppx zXV>((p#WOb2b!xT%&97SWhPR)jC8>YwGHADu}XF!pP8vND1+z$Nlnm>I?nDBb( zS^zx3;uSaUDPy18{kqCoej4vk0X_Ap8onEL-EKP~lh0f0yEwqke;$D{Kra+)lNs{^ zy6UcZ+x6L^p(Jf;U@z29GH7^f8?XiO z2l-LiM0ORO`fPcU%P0>Cl2(!#L*J{)uiboHHhIeUHhy%qyv5MCT&xwCflT2MaA1Rk zsvm9-5wZ@K>2LvI;uOE&>@7Yl)D-X&1o0JB1Hc4{@DZ~?GZ(%BHx_JM=**^=qxmT7 z+AgC3S}VurniPA9609bT5vANMsvqKJA7#Dv(ZLbRH(8gFd(glofN6=5#6$iRa;Sbp ztirG`4I(k?$*@;{-HRh^$H%Z41h<5{dcYZTX(`>;cJI^TJ4NY{$7Y~Xkx?>~?ccwh zoi8(?&FsXpra>I^0Is+g1k(jcs}w2=F}^w+&c{dR-tpB*l9h*q%Z>-UrxA&T6D6Au zE`j*2Xl$2m-LNRpQB|#De)(rj?&$-1YVoGurVdLA>hCs1kW@aD%e0cLpyux?wjsrL zG^HwU+T6U##~ql`-bE%A3>q)N^&Ie@=SS6hOy562IsSvJq@*PH!5LonVbC~Q*MUwR%D}&?L$}gV%RrFe39H%;wy%tBLU&Y3e76rB*uAZu}P@t2cl<&RIC)2Wxyp1eiV2bwMXknLCe zJu!Wq>h?Ip)wYET^s*9MQ>;{9A6x=Hq$K?sM?`M7ZTb1h1~JWpA^?(cc}Dst01iNd zp3g781bC+_MCYHgOX;Sh8y+%7^2DVxuHG*CO}>ZKzUS-eo=^E4Z2R_2OsrZ>xPG3D z@~8Omjk9kLh^_<>JN#I|7-|v<ZFLrY);wAi1Oy zV2QnFPxhuz`pj3HekI{ux&XQCN=d<*KkZ?&2vZf z35zt)GEON76g8!FWa^cuq9yErmfTslrFMi2N!gD3*mnp&IJ{F?SR!^Bd`?^y z7lr^pEKm`bOMy(m+jc~wr&N_@&|9QmxgWQ=s%-cY1zQUEUvf0(u@x6`@vV9su6;5gcYm%yz$Eb6x5$cm>)E|p3n^l}H zmGsr>uz-nO{e68*sgwO6SwJ;$)w^@51=!Ir@a83V@tSkXlN~jzo<1&2?=?~4C*UH8 zmd4n+9}%uS9Fnlp1X1GZ<0rIRorqpxK(iiP0_=S}CYEnrx=5$Zyg+9o7Jkx-dgevgBC}pRygK|_^j_UX-NxOnxo@NrpFGdDMNf0w`i={Ll`#3maXdAEoFBNd(K(wq zx_gTApkE`;zN(qFYjpRIv6b1mU;o|vL_4Ayl#=c?x(x`URn2wVtqrON>OKli-*I?^ zvhl^rj1+WNNX*L=@)-F;KVz*{N)29|8D_E$#aPm2&&MTOT_Uj8(W zP(T!;$B+NV?%oG_tU-2(%MT2mg;Y^6ZnOuV+x?-e1_+3c=sD1}O%;ncgiPeo7z(37yZMFTAu*afeRdeoVT0483#- zdrgmuokQHs%-_(!4TD8vceD~#;pP(X`kVie8fh9HIvMdyFxa<58mJT!D?#_S2#hr0h956^8W>Ss zR}{_SBCO#G37r}8?*0g(17Gy?#<+tEeGZ`^kTt&>`%;gzvj&agcg$uct{7MJdpb6H z{4uFSiIkq$t$K*p!zz`frDhVGx10LhDlIjniZSvMzh zRLlIya|=pJUg0By2uiv~k40{!daqesKzMu{_`2kYuj_osq-gH|A(U1a^_;?;ddz2s zHG?b(9Z#(P+5W0tuw31C5j;HN zb>s8ojn;~S6Cg z+<1Ic-E*iqk~XyQPcbh(8AX|<*uVcKkDY#YLEu(KP-KldozwBJ;6ONlu{+5=N0l0t# zuxE2lpMHc3<=u=f>&h4efWVnE_RPzL^X=zntTafJ&@c0MO7T-mG2rwKYH{bilvkSe z%3NmI!+~KZyLtPyfq{EW*3R*IbKvVIFk%!BsJTd`{4R|>yozj;K_`YE zkcQKWs0O&_#13evRE&DrS3;FPil`<%Dvl#|+POp_<;oFzczHbh5a){ts9@jC{>{%P z>LfGEL^G%BHCc1WzK2H{E`r=*8PGPDnmIqE@Z_4DudiRd(jB4|lo4tju7rc1>i5^rkD>bG)Hjr^9Wi%py~A{=#u+z; z8N+I^@Y!Xb#&>b)*AUlz$}#0z)w)Mz29>_b9`#u-C1OHK^qAhS{)DE~2c@HI)3^DS zrJsz8s=lLUETKyA%br;5?ta4M;#e%7UiQI$4;p(dkJ2#mY{kEEt9yw}+du5iIXR1C zdH3Sx&+s(yN!5PeSm5zUW%P9oLeZL`9{r*gkS zBOIO+DLa162WKy@CKAwk7pFktH7+FJxoVaOV;I=JNqc11Bt0ZRF|n~}`8w~7`X#-y zpS>115gJBrzd+3Q!Dm@O@5euSOGyBnP_7X#Nh|k#^5_c7DPakilkv?`k<~ z)|rzJrh1y#?o(@+vFp-%y0ZzMD7DCL1MSR$Z1fC_AGsbJ7117msLN*l$W|dQDd-?A zA=6TyvZXQbhsV@uKmN&hfsh`o*uw5&s9-IK6Wg1AcCWGv;!bl$`eH#?_Q3g+UJBXF zw(Uo7OPw@h#>mm5w>v)9E0}dk^T9owHqx&wO({iq2(SEN}NHeX)+R3&IfuMNMntwMi&{QYYsB&*Bl zK2Gutak1$H2A4!dvuzJt6W(}?>b|bD&;4tDPhu^$6bTiHb&8eU>w`yy$k18+Z)Bi0 zW`jAa9%^G{Fer+pr<^r)$XO%pg6ygl75{fgX!-iw_Cx2bx@GlSD)&xJy`JX$+dK8c zfFeD&&yL|s;NdO@f036NxfxTG*|E^2Shb>A_Ar>#X}I%+>asFa+gpNVKQ0w20(%)` zCh8Fc*9>v!L|Q^JEFo+JuQ+Uu`}CP?^5M;!ARzLVFMx7p64O%kB9#h`Kfr7Upv*@$ zs~$&l!-lOZSFFH!45!|q+S=D>crAdl9|?<*qveW1P|lnLz<$QW1yN;N3&-BTug83r zX`eEub}Tvsp2TObA7|*(r%%N;h(alIhkS4=xz%UDfaL&fD^^@arAFzB0_er?H)JU1 z?_Hv`38g=I4ecSjj$7H-JiZzlN-LaG*A&x5E-|Rz1m1Sj$uqdfAOvvm+FSktF2q7Y zU?EQFJv@t`P2-%xzN$~3X%l8_g*=AC4-ZY&)YFq!{S5JK^$QPNJPW6wsz3<_flx~z zctjNe(+#n8)hAbZKebsz76vqP3+R z&b)!LdCa3BaDb}~mD;sj5!z^e=_|)F!-~6D`o))gZ2cb{k zr~f4thXW@KUQi84q_OT3)j@aqIwDHN`V8&o3%AT`Kl@scI# z5h<5uR@&I7+5MaQ4-uLoZsL{lA1e#1*QfzQ(~|*TuzbN8&4eLiT5_{(vcdl-9fqb4 zr5}$fYe@RBZwR(Jm~oR4uf0C|(}(=ZiiN2Cki8;zDU-X@@es5vvm`rD=Zjp=9z851 zf&v4p`>|WsU{Cu+VtyP0vGVSQ3&f&%Bd)oJvWWCsOWdG`kY>M7f2dmc#xH_1RXu_T zs#@bk@}zq->em)hYa_~(O1H%1A=ibB;YXAHF0lx5Gxdm==+b(zPot-`cN>*8Z75AG zhOn2PUiNl8v&pxkZFSI(L%EH&+tsa|;?K&TI~=|wIi#k9fQ{`0CON7j#Vp43kd}rc z8OW$0P-28YESz&-q!HEl06bc4!IpRUUilquYhp*AH1R2clho{x$KR8DFF`^_FWJXHlk$yu z{?j6lRCzQ$ic!7y#M3hC!Ooid-n}BPh%a$lTYYwF&UDRQFSFPE_THHCljR-);WYT{H=l;3uQ0YZNb9aqTGgO#e}g)?5I_}%leJ!aS4 za^_^sA0aD|G2|=28)X=!_{C=%lCS5j`KVI1zQpD{=dj(}Yeg5mtfoy`ar_f@*HfoY z-v{zJZ}=qk3Xz;#qeZ7~P%kY5Pb_I0Yd`FMzla|PPV65wc*OAGPsnm<<00#8t|jrG zB7v#!i>&&6q_03;wDX)MQAQxZeWuT(vw2LuXMfGTGi?ala-<+Lq5p33X{kBga+aJP;Q_Dro(k~6 ziFxY2OGamAWM(QOK_&XKnciV=NcMxzJ|K(5im*yOXmQ+5G_xMFqCZWwyNWG7{~xGJ zd55#@DI2@@34)0xPy;-$^P5c+9I6rM50)*in}wpr)#*o#E2=N3s~gCq{nUq-b#-zI3V|eXbQv`K|8;^}2mz9+hk9XKp3Zhh zYjP{xVqf_{)inOz7E~t9s?5|6d%VV}(I|m%kL_1i?@X;(o*J&VZ)WPLB{U*vvD>cs z5_{t_Vh1<*B32~fW~4>ZhbDHEL>e2AI5uEL_+tBRzHsnJ9;p}Y<*SS*U=>&NRb%jA zjtCc*H=AEIG)VU!9TPlBM}4Xk4}sIY;q~j6tNY#ke*Xuz5#Q`VXHx1t?90l_h3SHL zI;HH9Gks5y(02T9eoruGfamtbMHfH3p5lr+)~a>?j?FqJP2H-EZ%@6uQ+l6ELj^54 z)hL>9gA>Z(HVKO{7{PeYpZ}DMhzyfHkygwJ(a_rba&?NS6-=<9wAUfTcYmrMT{JJx zhXelu->&#qF2Id2B|~4|DD>B*OS`|SE&h1U`Mq5D{Bhk6m?|$}jZ(wLn;)|u(x{yD zzi!)%&eN6Lj`-W2O0d3Q&CwlgZFz^BK7~A#v-QP_yzwzSQ66W%T1oM9%b%nxuRE2# z+~V*EUz?}%N7vsYPx|0jpAME7vVwle%wNGtD++shhio z{24uUukhwg!9@8P7Nh>EPjqfjU?NEV-S{s>)7qQ`5cj=R>D{n4iMbja7+89uhTfQ_ zDw-FNmM+DLDeigCL1>-92|vj_`LRjyj?nn9=1}@FW0g4?Mt9v-%^y*;Vp>X3{gibm z*jSW?I~)_~vtsSMdj0y&p2;NQmX^`qtx-FyX?j>N>KcxZV`yi=y|EJ6`K?>hD~oN; zB7_sg&e&(18thIPjtv1?7>;n;N4^MPBV_k&w(Y$8W?%hnDFf{6WB=TvpYD~>ITb)&8BNA!3#uvd8d0^Oxj5=|0ou8?Jq)^w_@*_Z# z>8j`am;ICrZ%CMPFxRiS$##A~PD*FrF(AwPKqZKcFaQkvEY`R(QTC#0rMe9S%)oV@ z6Og!(VPvEYXc!fgWhDRR;N0mYxo0ZU9DfRcED3PL+|h3qR~u@rsHxMd)zs?LB~NK* zz*@!GNe9$jd)ofkV_0naE@grGx(n7huM?-bxYSbfQgqkvy*+4(!Db+LD1}@CJQQVQ zmx3eM^>Oxk-W#fABz0_1<;mr?7k@aH)eSXwYS?+{vdZPbwXIZ(DMqmg?C$`P9MJgQ%VKs6nQ1RL4uv^@7B%Vu69V1`aVxyd1?;&!WYAyZ`xw^=O_F&mUBOJUuq0 zR{K)x_J=2nD))?|S1KEG6QzV;o5!o|hpt#*Mms=I(V&__C?Zj9&aYfmTca+*$EZLcA?3q01>fH!GJzDXu1Z zS)6BBlr}&_E_j_6rQm=app zYuBzx-|yH5tbrM^ex8GGx5UQcy0r(12wL8QZ$tK%hN!PHo?JQhN8_P*S##*A z%6G0Uy>*8aWt*(HEN9sNN6RalARAC_9K#1|Yg=hdL`%shBuF%9c7OjcV5*eX`ABQ= ziPtV}M?P?S&+Mlwj=HPO?cqRh?3iU?v53AR$rHX5K&XV+hI1G0EkAg2#9{ZHsHYlM z8Itq5MPi}8QC6_>8+ZHubZvmQi4r{(^&;M?{p+>6u)L{X4O& zg$K7|bZ2lFS#xgNjRmRB3!$Qki1@-JSM)i461C&?>rrjHTb}AP_|e$$g7b}Y1i`8O z!o9T?p-WYkz4ElVQ8nG;-Z6(nqeOw2@$}HOJ45mgOuFfwJ_|BOTefw89+LftZ4YNf z^b;NV$C9tad_e{M#kQCMPEeDT(3-Jtc=9L^cg0-G`LDM3s2-^0_2?{^>${Hct&%5N z*A+g|EvSB2_kLC7;cl`iM#F6{N#&Uj9DP#We`Bv38XYC|{Zk`id;9C`I{x^=>O+o4 zt0xF3I~M|m97?A8*3nFy@;`RpzNe5Q7Z3KSSB{flE?>OIX1L>iRgJO5r$L%CQA6ME>2_z6RXAG5(<^I#ZI%S&Cwwkunr}f~Y z(Kyn`>S-d8hE~pM?4j^716%LeL^e3QA79=L9;+biy|OJmwaN^nej7#PCa$E4X2g`u z73Ri)LD@a_{qojn$Y^D(%^qlG#`_$9x6Vttfm z7HTQhWpIt?Nr6ZR_=tUZLL0!#*-9uO%gXYs}v)21Ptr43Tu1IZAH zZ%M$2%eWfSp5R`z^!fr0=f_89416J?JB>YVXlMxaS>`ZBDl?(K)|XKNOunR{G8GDG zus41Tg~Oy))|{SvI_ehNvHf!85^eFtXXa{P?W z(4oY4dCC!2 zed16b&1a($`se+VQ4S$6eEucJY17`D{{9~Cw!E)I7r3Xn%;sPs#k8!unoHNT+$3}& z+fhM!H)T3i;im_5Q-J&{3>c8S zbLZ^`5Zz^NIUH{p=SXR3ElgG_Fk0Tx_A=gnrPWcbXE82X4OH#J`=Yg;a=L?bl>>(! z;j#nIX)@)ZKkFyN5*1Cp$0iJD$^+EgrbFIN@D@97XAUaED8mjCbk1 zKR&N3?ZKcLm*IE0TbVwOR_90mB6TjmC#23>9FEUP-MxF(&q!LAq@yu#oM~@wE?qub z|I(gjbcCh*g>;1wqwz%JW6y#!C5%HWEutQO$TkKMqdo~qM`fg~y_8wZvkfG(bccK? zI3zbE^$!v6zd<3h+`8*$OH~#U6_|R&&XEgr#$Cv0AGO9{)aZafBc;|{F$pQ=|sC@Knnr0&;8;hzUmKb#v*^^CgyM853KyMOQAk?Ymf;(fuZ(F}l^nJOl z?&|K?AJ?l(lF}^|jVW0n;CWI6_k@m@k>UG=546|H{(bp=QJ_FC;4 z2B>%8FLcDhgiDuH6j+Gqt(P*Sp$WLmxEuwgbhbKbp+7k)ogT0Jo8 zK^Mymv5x(}JH%gKBv)%fqQR<%p?fCC?R__xg|+JQi<~=pJYkx!VCCtLUqYA@dS!fk zf@viKHI2s{*tM`r!&YXpUWTARAtGQ6=n$o&vDE(xiw5#;-%lEo^lL-%1MA9(M@)2P z;0U9iolfcLt?X@X#RMceQ&1Ha?G`j^Al`zkaQpT>hO6CBAFimq%~RtMoO^lIA_gIP zj~+!%%ZO9-C_TV37PzMEJQV@x12a%gQq?S8+UZVnziM-Lxz^I%14TAkUbnp7_?CGG;qlQP(@5MuxJ3$19kn5 z_we2|I$d9&^rh^k8Mf}#X#DNzac_M8ep;>SZfixxR8h7IivGusegXq{V_=*gZ|6335=pB?y6({w`P7 zyG)xl!q|9E{(8M6y$EDO#M2l3goe^P4;~;FS`0U%c)zGVzxO>A)J9BarXQ61i`gmX z{x86Nw$ydk-VNVoHsnDD(k-47zJ@QmaiiGV1!YgZ@hr#i-RN?=a^)#rcb-EL1@ z$WjsL=g*CHWxaHl<<3sA>e<@&*l@H9Y=ht5t3pA7S#Ta7jMYZ+yW~S(zI|g7*%#7X zeBdD!l-|yIq}fJ_ML*1^Z~;Fea19)|p9$_|!}WmyAOpn3#n~#q&>P*Y?aaYV{Cg^7 zKbtnO4pBgn#ZVDrFm$YTd99)3Y_UrVmW;jiB~z7^~Q|(^6AsO z?1dX}yCBR!Yax#*neV98ukpi&U$EO~XN(LDg-Mi%w$S>sG63NiqcZd!I4yo~%R{IH|VwcUjnf{OV|(ky&MC*{IU4{w3km@#C|o0PCAp zTy7%&$@?=t><0-Nl>rbgm;rM@!o=gPbyGc~)+nYxEz__>TLN&JilQPc(2BPoL~akP zLM?#tg+ZNEmso-zWCa}f!tc_=3*CPG$H+kuAgPhQZjI-q6NYSwDOh_m-Q@ku>0*uX z2QfhapTBzk{I}B{6+-*8$=a`#dySIIu56lWIe$&(veUp>mCH8viODG{EUs9n(0*X0 z!{Bj(fJ?w!1OZp#rF@i1d-m+1uV%vjt2`QNxPRANF;OiHr>|umI3!#VhtV%MINh3y zb9Cs}kR33<2+0DyuOJs*&#oX7n9ZIs=1j3|cg}MjAhlI|L;agCOW!Vwj#Zn}`JDKY zrE5#Qb{yO@HE)a1^{dyhMt}7)h5SIb&r0D(@la=-nxY^j^}`cy@18vx6&e3JMN@2+ zze0CF^#il$h%vv=&G7#Hj#{nt*_?n1`vXm7*1a&&YQ6d48K^n+&fZ6zBm z9y@{TVM&f7;g!WZR#&$`i2cmMh?Ip&KucMS2ox ztOxct0yIu&9J6ejl%Jr%V!}qpF!~`mv=Xy;I|%M0eP!=xGGK+k!nguPV7cXB+tNUT za6nS(c2zvavvP70g56>89^P4GFY|}gm1T;{G=M=G`ltK*pQFy<7L^4&?~*FSda~T+ zhOiaPbGzVOJARi~=&$dTE_szMMQ{epcBAzSvD8s;q<;U*T8+k)L_)Y~LKgT68X<=n zGqRFyY0AfEzi(4sv(kmK|6c>V(NbYt0N}% z&m`y{L_Yv%y{=2a(bz55JzRaaqhN7gO-$`WY4ePNSrZ zVmN95i~dX%+IuUtN54K=_C?u|L#=p3#3jM$0j8aO69S=qclfZ#S@Y*#fsuurhYJyE z5d;T&2YXAtCM>g>?y4gj0Bg~!;L9j|?6y(X$Iq|$apXcFZ*Kpt08mZ`03MieoXmf% zo-kD@oB4)3EHhFQ@pjL9VptOi9xR(Sy=eUonT@}}-Q>xq$5LzqNt1XZrMmA2yA#=wtLxg9Z&Uvn?(@mGCQSA>stYoXo7G1Z{Z!d=zJ~XW8@T&#^ek z47KXnP}+OS#>#KIu9&n8J*sfPbkpHu$2>_kcvjK)7O|#jhdl72{f_h+izCW$3Puf| z3nZm19!xosT%hrJ;ttR`pJovzdM`C1e`dpfwlQ ztX#S@111thZ|@10H*JCju#11; zEAS}a-U?S~vwO?Dy|0IVf-$DnB&OsLW>_;6kR#`vc8BWuICv*zq90+C?ZI$5Jd!Bj z{Llj}yeu*^&>A=HJE^1~cOy_!!7=k?(vjY4#78%GmQ$xbfC29uK2L9H^?`4` zO#Yg^0Zp5b8`HF$le3y-#YW#g{`YBp(a%m{Dsl2(1{325&%}zs_xBT@@-JSzx_?$@ z(Sb=w4oDrzarPx9wjgQos?8sxt=;%@Pd=h*QfP!Evly|;$ga3nzt}J58PY5fGQm_s zqn_RV~%o=^KqLZ8Vx7$VjL=OCSI2h`)Xof+C1h zffI``;;<=q;k#GdGYLLSSNA*GS;ld~CLDdSe0e9)wLMg?US-Vu0Jxz#52X0H``-BJ z0GL;L%EzsKF)28h+wlXAeLVI{-^Pl%iLU*44Wq<2q)PB{b2|uWC_M6o1!C0$Dd;*6 zJ#0$mF@e9u(Sd4&0XO5Qu5#x%F$M4)X?`dyGFKQK74^2UG30B_60Q(xH2C+3g4O$| zxJ(0^4{-B-w?DWl{H;&DRjSQ8W_FyDm5ijZ3A|MpMK*bI36USPKo9ks*2_uTnCflT zPo3UVkTWD~wl+32E$?NK;$(!=)zhk{qq)6GX)_h@38VHP20@G{Dk#_@Goz3qkUJ<_ zMpnFfk??~H067uNittHd@ywoT$TZ<74J|DKJAZqT??L!y$^KxwfZb;Ktq`a+EQo{y zE-8s*lf*21<3`TQk-nWL@_b|`P>g3ICnczNc{{#jFEvT7hK{-UWdTm(@Svwfzwbd%G zZVl-wIuOnvPL%8e2N=^P4IC%bnAE_p{&}pB2#wu%3>1(})yhp_j05K-4t9oe;&T+c ziteQoA%_od;y?xwCFmND2Md=tMg1vV--~ni8>jn?_*vKb9dJ|%f~{vy9+$_N z8q6_CNVry&CS=ohhp{xoLcU!EE1jOQ^+J713*VX!9v!p0}3@omCJ#Ud&pb})CVeN+1X8; z^0}hC9G06&uGBK?LP8K@UMub-(!@9O3(K&yw3OHkti)*?8u}ZbYpy6iFK@_2{}$Y! zW5NyDf;hjvsl9mq>eZ`Qs$^wlF>J2#h*;P+>&cV5_T9M5r(lRmGdvh^ubp2)O{L+*i z$iSMqwm&a==+*`Y7jEh;IVEj$e$zX3lBmfhiz|INh7xX7q+!Mt%}2@@A(b-IamsL@P_sA$*>&LE!%6&M512ws!`m!)x2q& zGVD+6`8H9z_x0jpL>!_UH--%xhE@ggmM64YCyt!x^kWH8?HS<3whs38crY{g z`3?&b3|k$l~@x*F{LJAODuaa`XP^FA!J|C7HKPyHSgT{mB+|4 z!1v}TZOix~T)5Eo{rkG+0 h<87A^bg`VHIbQgy2nK@}FTQl9gda%R?qMJ+3YH~x z*Jy&>sUS>u{_&Ob!)g!HhorP{=!g*@*n(#sBf zj4m@nkX=V+YphaLSt%8Nf6D-aTX*i9IljdhJW^aVJN3g!Q*Z}CJ{X+?y%V}pOm=1v@uq2d1JlZK+1YV2x+LZC{#979Q9OS5|& z8Swg0*e_9-{F}0Nb@^D#IXS{(EsL?}>>{=SpWMC!Pnc6y5yQ*9hlu(JDUta?p^eqn7{yVm*=9L(wbDYIwKwy+3in{mmpO;WVb-8bz9X$#uf{$;t+`+{}= zgc`40;>Pp~in?x-IPb>4y!&Un#EYt2x5maYIy{Da7V9)gj>RZW4u%lkH@RPrKfA>s zStikrty2a`z$Z#V70w5D^XJY<`z}rIOy4{1NH74@b?77m0&-D>jvafX;RQiVn=`^fN7u#Fg{F zFp4>zZ0+kWKOy~QW?gpM^B;FQkFXFm4q{~p^_=cUXs?KEU`^}K5YyR@!Oue`lUuMY zNy>zE=E1yKyyuY*4b)xRO-AKO)&f(<{^Fu~bM4+xC@+`F?A+VbmB_g3t(nCV5*La@al4jVpR9p%~=bB-}=TyXo~_RiVOyYp*YAM0OD zukAyDxwF}fLm3$o$0ZZ`?Ky!qzrLX%DzXH<0H&&1CcT)nFn_*E0$;3m$Q^DJe4pjH z`G{i?cV;ww6$?u&$g;g}l>LaD=lS!^wC?4)!{*!9V#8auW$v>duh09K#=J;UOThm=z?t!B)-y{-3dM;^5qg)~5LTgU`+3_m5Gxvshpw$B2+6)Y()f?~T7rZpgNa^~d658oGljKn=Z zJiPPg(iUc%#@tl#YJI-nFj{`M$)u-1j5#%(m)DDG71Cxp;th+p{g^;Z(es>j%wujY zPBJL0sZoq~$`SI{{z4WldUPN^f0xD!+2yD6@)T9mc_d%}D_6#=8^N)Z48)#Jb%^-P z)dceF_(|&poh2*#(n1~?dnR#7xGBQ#LXpF3e3xVVyB&SW8U~?ax?jXLGMKN2+P}Wv zd}sA2*pYJYF`^j4Ka5b$zL{ke6-9Omdnz||*43zsDyq}ISXbLqB|bVyCi}}q6XwO3 zcQ{E$hldwB+BQAy^e&^bOC%|H`9;g@-U)&+wO6>|C{<|J4HYnCP#< zQK|Ux+6Q|kSL|HJb4GE=lcIxP5bi#D^rDJ-fb^!^+fY<$$<3y6@b=cZ)mr2|o75eP zgrMiZD4R=eyitY#dOpQp<$$S~OsCJ5_t=K+Zru|Gy;w#4ws2*|hfrMugSp;kI+^KE zEvzn97$%d(8t6C3yI)-Ea~avXH^y*%))CS5#&wxmfy>O~->RNwsoy#Db%l8Tcw?0; z#xHaUifO{xW>!xXz2lMB-^~(z*oj_bB!9{po(V_Dry1xz z9ZD~B2^QmB_z#%1vswSljk&;{A&QNhuwk$;8jsV{e%6iV89Xr_uE&CjGhhsMEVW^D zK-uC!I;Rp6jB-MB8tgacoKIyt=%Q~gO9s=R=XdK-I!R=2>x4P}^dSEzKw!9cDQ@oxI zsRWf-_~h@ZoLDTr^B=peW}#uHdNj_M3)(tQ&j@!+#-vyck{uL5bbjB|WF@+RGAnRC z=`TUQ{EFCt8>+Gryd$Ov+2<#Jxde6r*s>~zZuTTCPnKUp6L>Yx*{Xv1z>&@ovS=#`BrWuONXBCC(!^ir zJR@u$c^cXtZhi1E6ejw5+Y z>6paLBTr(THP1M+hVyq}=WaUULAqO;?9SwF2Dzs8S(8@t7XpaCX8%aLtT$r;~r^`DA5xid#$#on7^dF546f)H_(WX`tHna_%cRBO%i9X_G2JFGT zF-#*FG2*b+lx@R(jPtE4og-$Fx@9{>{v7tg?F+~_WCr3ZUZZYTUq=3WY`u8E*5)>Y z>8Dk@8))c+%f47;NEv_koMguFw6M^p7IdN=(y`chMzgt!IIOw(wT`g&qj zz;xl=?_T?=R4c#1%&a~#LsId#&WI5uk`|W+MQo!SJA7DkdaZiU*oW$R1_t6mZ=UiP zUlfSSx>_|KGM|bOO2Wh;UT2@n7`k`vW*dKy%<0IHs1)6YeyT5RdCYD4=jKufxGk2N zxAoff>)l?gGZFU^#%v|ltZWn~vQuGd$^i+vVvlpthYEO-|;^YzbJbH!xWdVwuu zo(?xn%5n^W3UQsYL}tS=%a}?l7poKYvnS_VHu|D zCCUkpu((uJcc9v&;-(EJRe~kl{#8kIwYdEwyKUy7^5K0ekjFM|Non!>kscs`5u%w| z7qZkA$rn$$9@qERRR8n9)LS1%6$kS*Y7p<>Br?Y

%$KK99LqKYcxeyZE0K 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 From 38b7f516bc52df2d49baff23b15c74359fd35615 Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Tue, 31 Oct 2023 16:32:39 +0100 Subject: [PATCH 24/31] fix(ds): Change the directory where message database is stored --- apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl index 32ca85935..4140c0ed7 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl @@ -368,7 +368,7 @@ rocksdb_open(Shard, Options) -> -spec db_dir(shard_id()) -> file:filename(). db_dir({DB, ShardId}) -> - filename:join("data", lists:flatten([atom_to_list(DB), $:, atom_to_list(ShardId)])). + filename:join(["data", atom_to_list(DB), atom_to_list(ShardId)]). %%-------------------------------------------------------------------------------- %% Schema access From 46d8301bc0b1d75eb3cdaf293555fd52016ae5c4 Mon Sep 17 00:00:00 2001 From: Andrew Mayorov Date: Mon, 30 Oct 2023 19:55:14 +0700 Subject: [PATCH 25/31] feat(emqx): expose timestamp function in `emqx_message` So that the code that relies on it would not need to guess clock source and precision. --- apps/emqx/src/emqx_message.erl | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/emqx/src/emqx_message.erl b/apps/emqx/src/emqx_message.erl index 509d4c90d..4ff36504d 100644 --- a/apps/emqx/src/emqx_message.erl +++ b/apps/emqx/src/emqx_message.erl @@ -66,7 +66,8 @@ -export([ is_expired/1, - update_expiry/1 + update_expiry/1, + timestamp_now/0 ]). -export([ @@ -113,14 +114,13 @@ make(From, Topic, Payload) -> emqx_types:payload() ) -> emqx_types:message(). make(From, QoS, Topic, Payload) when ?QOS_0 =< QoS, QoS =< ?QOS_2 -> - Now = erlang:system_time(millisecond), #message{ id = emqx_guid:gen(), qos = QoS, from = From, topic = Topic, payload = Payload, - timestamp = Now + timestamp = timestamp_now() }. -spec make( @@ -137,7 +137,6 @@ make(From, QoS, Topic, Payload, Flags, Headers) when is_map(Flags), is_map(Headers) -> - Now = erlang:system_time(millisecond), #message{ id = emqx_guid:gen(), qos = QoS, @@ -146,7 +145,7 @@ make(From, QoS, Topic, Payload, Flags, Headers) when headers = Headers, topic = Topic, payload = Payload, - timestamp = Now + timestamp = timestamp_now() }. -spec make( @@ -164,7 +163,6 @@ make(MsgId, From, QoS, Topic, Payload, Flags, Headers) when is_map(Flags), is_map(Headers) -> - Now = erlang:system_time(millisecond), #message{ id = MsgId, qos = QoS, @@ -173,7 +171,7 @@ make(MsgId, From, QoS, Topic, Payload, Flags, Headers) when headers = Headers, topic = Topic, payload = Payload, - timestamp = Now + timestamp = timestamp_now() }. %% optimistic esitmation of a message size after serialization @@ -403,6 +401,11 @@ from_map(#{ extra = Extra }. +%% @doc Get current timestamp in milliseconds. +-spec timestamp_now() -> integer(). +timestamp_now() -> + erlang:system_time(millisecond). + %% MilliSeconds elapsed(Since) -> - max(0, erlang:system_time(millisecond) - Since). + max(0, timestamp_now() - Since). From 7a94db25c30029867be5fb3ab584a2313b6b2ae8 Mon Sep 17 00:00:00 2001 From: Andrew Mayorov Date: Mon, 30 Oct 2023 19:58:59 +0700 Subject: [PATCH 26/31] fix(ds): don't iterate over incomplete epoch in bitmask lts storage --- .../src/emqx_ds_storage_bitfield_lts.erl | 125 +++++++++++------- 1 file changed, 78 insertions(+), 47 deletions(-) diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl index 85f4f5aa7..4dddaff67 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl @@ -59,7 +59,8 @@ db :: rocksdb:db_handle(), data :: rocksdb:cf_handle(), trie :: emqx_ds_lts:trie(), - keymappers :: array:array(emqx_ds_bitmask_keymapper:keymapper()) + keymappers :: array:array(emqx_ds_bitmask_keymapper:keymapper()), + ts_offset :: non_neg_integer() }). -type s() :: #s{}. @@ -147,7 +148,13 @@ open(_Shard, DBHandle, GenId, CFRefs, Schema) -> || N <- lists:seq(0, MaxWildcardLevels) ] ), - #s{db = DBHandle, data = DataCF, trie = Trie, keymappers = KeymapperCache}. + #s{ + db = DBHandle, + data = DataCF, + trie = Trie, + keymappers = KeymapperCache, + ts_offset = TSOffsetBits + }. -spec store_batch( emqx_ds_replication_layer:shard_id(), s(), [emqx_types:message()], emqx_ds:message_store_opts() @@ -177,13 +184,26 @@ make_iterator(_Shard, _Data, #stream{storage_key = StorageKey}, TopicFilter, Sta storage_key = StorageKey }}. -next(_Shard, #s{db = DB, data = CF, keymappers = Keymappers}, It0, BatchSize) -> +next(_Shard, Schema = #s{ts_offset = TSOffset}, It, BatchSize) -> + %% Compute safe cutoff time. + %% It's the point in time where the last complete epoch ends, so we need to know + %% the current time to compute it. + Now = emqx_message:timestamp_now(), + SafeCutoffTime = (Now bsr TSOffset) bsl TSOffset, + next_until(Schema, It, SafeCutoffTime, BatchSize). + +next_until(_Schema, It, SafeCutoffTime, _BatchSize) when It#it.start_time >= SafeCutoffTime -> + %% We're in the middle of the current epoch, so we can't yet iterate over it. + %% It would be unsafe otherwise: messages can be stored in the current epoch + %% concurrently with iterating over it. They can end up earlier (in the iteration + %% order) due to the nature of keymapping, potentially causing us to miss them. + {ok, It, []}; +next_until(#s{db = DB, data = CF, keymappers = Keymappers}, It, SafeCutoffTime, BatchSize) -> #it{ start_time = StartTime, - storage_key = StorageKey - } = It0, + storage_key = {TopicIndex, Varying} + } = It, %% Make filter: - {TopicIndex, Varying} = StorageKey, Inequations = [ {'=', TopicIndex}, {'>=', StartTime} @@ -197,10 +217,8 @@ next(_Shard, #s{db = DB, data = CF, keymappers = Keymappers}, It0, BatchSize) -> Varying ) ], - %% Obtain a keymapper for the current number of varying - %% levels. Magic constant 2: we have two extra dimensions of topic - %% index and time; the rest of dimensions are varying levels. - NVarying = length(Inequations) - 2, + %% Obtain a keymapper for the current number of varying levels. + NVarying = length(Varying), %% Assert: NVarying =< ?WILDCARD_LIMIT orelse error({too_many_varying_topic_levels, NVarying}), @@ -215,7 +233,7 @@ next(_Shard, #s{db = DB, data = CF, keymappers = Keymappers}, It0, BatchSize) -> ]), try put(?COUNTER, 0), - next_loop(ITHandle, Keymapper, Filter, It0, [], BatchSize) + next_loop(ITHandle, Keymapper, Filter, SafeCutoffTime, It, [], BatchSize) after rocksdb:iterator_close(ITHandle), erase(?COUNTER) @@ -225,9 +243,9 @@ next(_Shard, #s{db = DB, data = CF, keymappers = Keymappers}, It0, BatchSize) -> %% Internal functions %%================================================================================ -next_loop(_ITHandle, _KeyMapper, _Filter, It, Acc, 0) -> +next_loop(_ITHandle, _KeyMapper, _Filter, _Cutoff, It, Acc, 0) -> {ok, It, lists:reverse(Acc)}; -next_loop(ITHandle, KeyMapper, Filter, It0, Acc0, N0) -> +next_loop(ITHandle, KeyMapper, Filter, Cutoff, It0, Acc0, N0) -> inc_counter(), #it{last_seen_key = Key0} = It0, case emqx_ds_bitmask_keymapper:bin_increment(Filter, Key0) of @@ -238,51 +256,64 @@ next_loop(ITHandle, KeyMapper, Filter, It0, Acc0, N0) -> true = Key1 > Key0, case rocksdb:iterator_move(ITHandle, {seek, Key1}) of {ok, Key, Val} -> - It1 = It0#it{last_seen_key = Key}, - case check_message(Filter, It1, Val) of - {true, Msg} -> - N1 = N0 - 1, - Acc1 = [Msg | Acc0]; - false -> - N1 = N0, - Acc1 = Acc0 - end, - {N, It, Acc} = traverse_interval(ITHandle, KeyMapper, Filter, It1, Acc1, N1), - next_loop(ITHandle, KeyMapper, Filter, It, Acc, N); + {N, It, Acc} = + traverse_interval(ITHandle, Filter, Cutoff, Key, Val, It0, Acc0, N0), + next_loop(ITHandle, KeyMapper, Filter, Cutoff, It, Acc, N); {error, invalid_iterator} -> {ok, It0, lists:reverse(Acc0)} end end. -traverse_interval(_ITHandle, _KeyMapper, _Filter, It, Acc, 0) -> - {0, It, Acc}; -traverse_interval(ITHandle, KeyMapper, Filter, It0, Acc, N) -> - inc_counter(), - case rocksdb:iterator_move(ITHandle, next) of - {ok, Key, Val} -> - It = It0#it{last_seen_key = Key}, - case check_message(Filter, It, Val) of - {true, Msg} -> - traverse_interval(ITHandle, KeyMapper, Filter, It, [Msg | Acc], N - 1); - false -> - traverse_interval(ITHandle, KeyMapper, Filter, It, Acc, N) - end; - {error, invalid_iterator} -> - {0, It0, Acc} - end. - --spec check_message(emqx_ds_bitmask_keymapper:filter(), iterator(), binary()) -> - {true, emqx_types:message()} | false. -check_message(Filter, #it{last_seen_key = Key}, Val) -> +traverse_interval(ITHandle, Filter, Cutoff, Key, Val, It0, Acc0, N) -> + It = It0#it{last_seen_key = Key}, case emqx_ds_bitmask_keymapper:bin_checkmask(Filter, Key) of true -> Msg = deserialize(Val), - %% TODO: check strict time and hash collisions - {true, Msg}; + case check_message(Cutoff, It, Msg) of + true -> + Acc = [Msg | Acc0], + traverse_interval(ITHandle, Filter, Cutoff, It, Acc, N - 1); + false -> + traverse_interval(ITHandle, Filter, Cutoff, It, Acc0, N); + overflow -> + {0, It0, Acc0} + end; false -> - false + {N, It, Acc0} end. +traverse_interval(_ITHandle, _Filter, _Cutoff, It, Acc, 0) -> + {0, It, Acc}; +traverse_interval(ITHandle, Filter, Cutoff, It, Acc, N) -> + inc_counter(), + case rocksdb:iterator_move(ITHandle, next) of + {ok, Key, Val} -> + traverse_interval(ITHandle, Filter, Cutoff, Key, Val, It, Acc, N); + {error, invalid_iterator} -> + {0, It, Acc} + end. + +-spec check_message(emqx_ds:time(), iterator(), emqx_types:message()) -> + true | false | overflow. +check_message( + Cutoff, + _It, + #message{timestamp = Timestamp} +) when Timestamp >= Cutoff -> + %% We hit the current epoch, we can't continue iterating over it yet. + %% It would be unsafe otherwise: messages can be stored in the current epoch + %% concurrently with iterating over it. They can end up earlier (in the iteration + %% order) due to the nature of keymapping, potentially causing us to miss them. + overflow; +check_message( + _Cutoff, + #it{start_time = StartTime, topic_filter = TopicFilter}, + #message{timestamp = Timestamp, topic = Topic} +) when Timestamp >= StartTime -> + emqx_topic:match(emqx_topic:words(Topic), TopicFilter); +check_message(_Cutoff, _It, _Msg) -> + false. + format_key(KeyMapper, Key) -> Vec = [integer_to_list(I, 16) || I <- emqx_ds_bitmask_keymapper:key_to_vector(KeyMapper, Key)], lists:flatten(io_lib:format("~.16B (~s)", [Key, string:join(Vec, ",")])). From 54951c273f2844d666dd605b85f3638d496a7be2 Mon Sep 17 00:00:00 2001 From: Andrew Mayorov Date: Mon, 30 Oct 2023 20:59:53 +0700 Subject: [PATCH 27/31] feat(ds): mix safe cutoff into keymapper filter during iteration --- .../emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl | 7 +++++-- .../src/emqx_ds_storage_bitfield_lts.erl | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl index a67dbc0eb..ee2173000 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl @@ -154,7 +154,8 @@ -opaque keymapper() :: #keymapper{}. --type scalar_range() :: any | {'=', scalar() | infinity} | {'>=', scalar()}. +-type scalar_range() :: + any | {'=', scalar() | infinity} | {'>=', scalar()} | {scalar(), '..', scalar()}. -include("emqx_ds_bitmask.hrl"). @@ -523,7 +524,9 @@ constraints_to_ranges(#keymapper{dim_sizeof = DimSizeof}, Filter) -> ({'=', Val}, _Bitsize) -> {Val, Val}; ({'>=', Val}, Bitsize) -> - {Val, ones(Bitsize)} + {Val, ones(Bitsize)}; + ({Min, '..', Max}, _Bitsize) -> + {Min, Max} end, Filter, DimSizeof diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl index 4dddaff67..129c2500e 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl @@ -206,7 +206,7 @@ next_until(#s{db = DB, data = CF, keymappers = Keymappers}, It, SafeCutoffTime, %% Make filter: Inequations = [ {'=', TopicIndex}, - {'>=', StartTime} + {StartTime, '..', SafeCutoffTime - 1} | lists:map( fun ('+') -> From 74cb43f8b19ecf79bc20136ff594c61b00669594 Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Thu, 2 Nov 2023 11:47:28 +0100 Subject: [PATCH 28/31] fix(ds): Add unique ID to the key --- apps/emqx/src/emqx_persistent_session_ds.erl | 10 ++++++++-- .../test/emqx_persistent_messages_SUITE.erl | 18 ++++++++++-------- .../test/emqx_persistent_session_SUITE.erl | 13 ++++++++----- apps/emqx_durable_storage/include/emqx_ds.hrl | 19 +++++++++++++++++++ apps/emqx_durable_storage/src/emqx_ds.erl | 1 + .../src/emqx_ds_bitmask_keymapper.erl | 5 +++-- .../src/emqx_ds_storage_bitfield_lts.erl | 15 +++++++++++---- .../src/emqx_ds_storage_layer.erl | 6 ++++-- 8 files changed, 64 insertions(+), 23 deletions(-) create mode 100644 apps/emqx_durable_storage/include/emqx_ds.hrl diff --git a/apps/emqx/src/emqx_persistent_session_ds.erl b/apps/emqx/src/emqx_persistent_session_ds.erl index abecb72a2..9a9e05a7a 100644 --- a/apps/emqx/src/emqx_persistent_session_ds.erl +++ b/apps/emqx/src/emqx_persistent_session_ds.erl @@ -115,6 +115,7 @@ session(). create(#{clientid := ClientID}, _ConnInfo, Conf) -> % TODO: expiration + ensure_timers(), ensure_session(ClientID, Conf). -spec open(clientinfo(), conninfo()) -> @@ -127,10 +128,9 @@ open(#{clientid := ClientID}, _ConnInfo) -> %% somehow isolate those idling not-yet-expired sessions into a separate process %% space, and move this call back into `emqx_cm` where it belongs. ok = emqx_cm:discard_session(ClientID), - ensure_timer(pull), - ensure_timer(get_streams), case open_session(ClientID) of Session = #{} -> + ensure_timers(), {true, Session, []}; false -> false @@ -705,6 +705,12 @@ export_record(Record, I, [Field | Rest], Acc) -> export_record(_, _, [], Acc) -> Acc. +%% TODO: find a more reliable way to perform actions that have side +%% effects. Add `CBM:init' callback to the session behavior? +ensure_timers() -> + ensure_timer(pull), + ensure_timer(get_streams). + -spec ensure_timer(pull | get_streams) -> ok. ensure_timer(Type) -> _ = emqx_utils:start_timer(100, {emqx_session, Type}), diff --git a/apps/emqx/test/emqx_persistent_messages_SUITE.erl b/apps/emqx/test/emqx_persistent_messages_SUITE.erl index db025a457..52ba090b5 100644 --- a/apps/emqx/test/emqx_persistent_messages_SUITE.erl +++ b/apps/emqx/test/emqx_persistent_messages_SUITE.erl @@ -26,9 +26,6 @@ -import(emqx_common_test_helpers, [on_exit/1]). --define(DEFAULT_KEYSPACE, default). --define(DS_SHARD_ID, <<"local">>). --define(DS_SHARD, {?DEFAULT_KEYSPACE, ?DS_SHARD_ID}). -define(PERSISTENT_MESSAGE_DB, emqx_persistent_message). all() -> @@ -49,6 +46,7 @@ init_per_testcase(t_session_subscription_iterators = TestCase, Config) -> Nodes = emqx_cth_cluster:start(Cluster, #{work_dir => emqx_cth_suite:work_dir(TestCase, Config)}), [{nodes, Nodes} | Config]; init_per_testcase(TestCase, Config) -> + ok = emqx_ds:drop_db(?PERSISTENT_MESSAGE_DB), Apps = emqx_cth_suite:start( app_specs(), #{work_dir => emqx_cth_suite:work_dir(TestCase, Config)} @@ -59,9 +57,9 @@ end_per_testcase(t_session_subscription_iterators, Config) -> Nodes = ?config(nodes, Config), emqx_common_test_helpers:call_janitor(60_000), ok = emqx_cth_cluster:stop(Nodes), - ok; + end_per_testcase(common, Config); end_per_testcase(_TestCase, Config) -> - Apps = ?config(apps, Config), + Apps = proplists:get_value(apps, Config, []), emqx_common_test_helpers:call_janitor(60_000), clear_db(), emqx_cth_suite:stop(Apps), @@ -97,6 +95,7 @@ t_messages_persisted(_Config) -> Results = [emqtt:publish(CP, Topic, Payload, 1) || {Topic, Payload} <- Messages], ct:pal("Results = ~p", [Results]), + timer:sleep(2000), Persisted = consume(['#'], 0), @@ -141,6 +140,8 @@ t_messages_persisted_2(_Config) -> {ok, #{reason_code := ?RC_NO_MATCHING_SUBSCRIBERS}} = emqtt:publish(CP, T(<<"client/2/topic">>), <<"8">>, 1), + timer:sleep(2000), + Persisted = consume(['#'], 0), ct:pal("Persisted = ~p", [Persisted]), @@ -251,13 +252,14 @@ connect(Opts0 = #{}) -> {ok, _} = emqtt:connect(Client), Client. -consume(TopicFiler, StartMS) -> +consume(TopicFilter, StartMS) -> + Streams = emqx_ds:get_streams(?PERSISTENT_MESSAGE_DB, TopicFilter, StartMS), lists:flatmap( fun({_Rank, Stream}) -> - {ok, It} = emqx_ds:make_iterator(Stream, StartMS, 0), + {ok, It} = emqx_ds:make_iterator(Stream, TopicFilter, StartMS), consume(It) end, - emqx_ds:get_streams(?PERSISTENT_MESSAGE_DB, TopicFiler, StartMS) + Streams ). consume(It) -> diff --git a/apps/emqx/test/emqx_persistent_session_SUITE.erl b/apps/emqx/test/emqx_persistent_session_SUITE.erl index 008305671..5a14e0bc9 100644 --- a/apps/emqx/test/emqx_persistent_session_SUITE.erl +++ b/apps/emqx/test/emqx_persistent_session_SUITE.erl @@ -24,6 +24,8 @@ -compile(export_all). -compile(nowarn_export_all). +-define(PERSISTENT_MESSAGE_DB, emqx_persistent_message). + %%-------------------------------------------------------------------- %% SUITE boilerplate %%-------------------------------------------------------------------- @@ -131,6 +133,7 @@ get_listener_port(Type, Name) -> end_per_group(Group, Config) when Group == tcp; Group == ws; Group == quic -> ok = emqx_cth_suite:stop(?config(group_apps, Config)); end_per_group(_, _Config) -> + ok = emqx_ds:drop_db(?PERSISTENT_MESSAGE_DB), ok. init_per_testcase(TestCase, Config) -> @@ -188,7 +191,7 @@ receive_messages(Count, Msgs) -> receive_messages(Count - 1, [Msg | Msgs]); _Other -> receive_messages(Count, Msgs) - after 5000 -> + after 15000 -> Msgs end. @@ -227,11 +230,11 @@ wait_for_cm_unregister(ClientId, N) -> end. publish(Topic, Payloads) -> - publish(Topic, Payloads, false). + publish(Topic, Payloads, false, 2). -publish(Topic, Payloads, WaitForUnregister) -> +publish(Topic, Payloads, WaitForUnregister, QoS) -> Fun = fun(Client, Payload) -> - {ok, _} = emqtt:publish(Client, Topic, Payload, 2) + {ok, _} = emqtt:publish(Client, Topic, Payload, QoS) end, do_publish(Payloads, Fun, WaitForUnregister). @@ -532,7 +535,7 @@ t_publish_while_client_is_gone_qos1(Config) -> ok = emqtt:disconnect(Client1), maybe_kill_connection_process(ClientId, Config), - ok = publish(Topic, [Payload1, Payload2]), + ok = publish(Topic, [Payload1, Payload2], false, 1), {ok, Client2} = emqtt:start_link([ {proto_ver, v5}, diff --git a/apps/emqx_durable_storage/include/emqx_ds.hrl b/apps/emqx_durable_storage/include/emqx_ds.hrl new file mode 100644 index 000000000..c9ee4b7f7 --- /dev/null +++ b/apps/emqx_durable_storage/include/emqx_ds.hrl @@ -0,0 +1,19 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- +-ifndef(EMQX_DS_HRL_HRL). +-define(EMQX_DS_HRL_HRL, true). + +-endif. diff --git a/apps/emqx_durable_storage/src/emqx_ds.erl b/apps/emqx_durable_storage/src/emqx_ds.erl index 1e7f88367..27a0745bc 100644 --- a/apps/emqx_durable_storage/src/emqx_ds.erl +++ b/apps/emqx_durable_storage/src/emqx_ds.erl @@ -111,6 +111,7 @@ open_db(DB, Opts = #{backend := builtin}) -> emqx_ds_replication_layer:open_db(DB, Opts). %% @doc TODO: currently if one or a few shards are down, they won't be + %% deleted. -spec drop_db(db()) -> ok. drop_db(DB) -> diff --git a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl index ee2173000..5666b45ae 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl @@ -214,7 +214,7 @@ vector_to_key(#keymapper{scanner = [Actions | Scanner]}, [Coord | Vector]) -> bin_vector_to_key(Keymapper = #keymapper{dim_sizeof = DimSizeof, size = Size}, Binaries) -> Vec = lists:zipwith( fun(Bin, SizeOf) -> - <> = Bin, + <> = Bin, Int end, Binaries, @@ -402,7 +402,8 @@ bin_increment( Filter = #filter{size = Size, bitmask = Bitmask, bitfilter = Bitfilter, range_max = RangeMax}, KeyBin ) -> - <> = KeyBin, + %% The key may contain random suffix, skip it: + <> = KeyBin, Key1 = Key0 + 1, if Key1 band Bitmask =:= Bitfilter, Key1 =< RangeMax -> diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl index 129c2500e..fe198c207 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl @@ -206,7 +206,10 @@ next_until(#s{db = DB, data = CF, keymappers = Keymappers}, It, SafeCutoffTime, %% Make filter: Inequations = [ {'=', TopicIndex}, - {StartTime, '..', SafeCutoffTime - 1} + {StartTime, '..', SafeCutoffTime - 1}, + %% Unique integer: + any + %% Varying topic levels: | lists:map( fun ('+') -> @@ -337,9 +340,12 @@ make_key(#s{keymappers = KeyMappers, trie = Trie}, #message{timestamp = Timestam ]) -> binary(). make_key(KeyMapper, TopicIndex, Timestamp, Varying) -> + UniqueInteger = erlang:unique_integer([monotonic, positive]), emqx_ds_bitmask_keymapper:key_to_bitstring( KeyMapper, - emqx_ds_bitmask_keymapper:vector_to_key(KeyMapper, [TopicIndex, Timestamp | Varying]) + emqx_ds_bitmask_keymapper:vector_to_key(KeyMapper, [ + TopicIndex, Timestamp, UniqueInteger | Varying + ]) ). %% TODO: don't hardcode the thresholds @@ -366,9 +372,10 @@ make_keymapper(TopicIndexBytes, BitsPerTopicLevel, TSBits, TSOffsetBits, N) -> %% Dimension Offset Bitsize [{1, 0, TopicIndexBytes * ?BYTE_SIZE}, %% Topic index {2, TSOffsetBits, TSBits - TSOffsetBits }] ++ %% Timestamp epoch - [{2 + I, 0, BitsPerTopicLevel } %% Varying topic levels + [{3 + I, 0, BitsPerTopicLevel } %% Varying topic levels || I <- lists:seq(1, N)] ++ - [{2, 0, TSOffsetBits }], %% Timestamp offset + [{2, 0, TSOffsetBits }, %% Timestamp offset + {3, 0, 64 }], %% Unique integer Keymapper = emqx_ds_bitmask_keymapper:make_keymapper(lists:reverse(Bitsources)), %% Assert: case emqx_ds_bitmask_keymapper:bitsize(Keymapper) rem 8 of diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl index 4140c0ed7..57af33d61 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl @@ -24,6 +24,8 @@ -export([start_link/2, init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). %% internal exports: +-export([db_dir/1]). + -export_type([gen_id/0, generation/0, cf_refs/0, stream/0, iterator/0]). -include_lib("snabbkaffe/include/snabbkaffe.hrl"). @@ -132,7 +134,7 @@ open_shard(Shard, Options) -> -spec drop_shard(shard_id()) -> ok. drop_shard(Shard) -> - emqx_ds_storage_layer_sup:stop_shard(Shard), + catch emqx_ds_storage_layer_sup:stop_shard(Shard), ok = rocksdb:destroy(db_dir(Shard), []). -spec store_batch(shard_id(), [emqx_types:message()], emqx_ds:message_store_opts()) -> @@ -368,7 +370,7 @@ rocksdb_open(Shard, Options) -> -spec db_dir(shard_id()) -> file:filename(). db_dir({DB, ShardId}) -> - filename:join(["data", atom_to_list(DB), atom_to_list(ShardId)]). + filename:join([emqx:data_dir(), atom_to_list(DB), atom_to_list(ShardId)]). %%-------------------------------------------------------------------------------- %% Schema access From 7cb032285687d88c66aa6f7bd9049ee31be39aad Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Mon, 6 Nov 2023 13:32:41 +0100 Subject: [PATCH 29/31] fix(emqx): Move bpapi and emqx message record to emqx_utils app --- apps/emqx/include/bpapi.hrl | 7 +-- apps/emqx/include/emqx.hrl | 24 +---------- .../src/emqx_ds_storage_bitfield_lts.erl | 2 +- .../src/emqx_ds_storage_reference.erl | 2 +- .../src/emqx_durable_storage.app.src | 2 +- .../src/proto/emqx_ds_proto_v1.erl | 2 +- apps/emqx_utils/include/bpapi.hrl | 22 ++++++++++ apps/emqx_utils/include/emqx_message.hrl | 43 +++++++++++++++++++ .../src/bpapi/emqx_bpapi_trans.erl | 0 9 files changed, 71 insertions(+), 33 deletions(-) create mode 100644 apps/emqx_utils/include/bpapi.hrl create mode 100644 apps/emqx_utils/include/emqx_message.hrl rename apps/{emqx => emqx_utils}/src/bpapi/emqx_bpapi_trans.erl (100%) diff --git a/apps/emqx/include/bpapi.hrl b/apps/emqx/include/bpapi.hrl index 1373e0381..ed7693e78 100644 --- a/apps/emqx/include/bpapi.hrl +++ b/apps/emqx/include/bpapi.hrl @@ -14,9 +14,4 @@ %% limitations under the License. %%-------------------------------------------------------------------- --ifndef(EMQX_BPAPI_HRL). --define(EMQX_BPAPI_HRL, true). - --compile({parse_transform, emqx_bpapi_trans}). - --endif. +-include_lib("emqx_utils/include/bpapi.hrl"). diff --git a/apps/emqx/include/emqx.hrl b/apps/emqx/include/emqx.hrl index 664ec5803..86a64d8bb 100644 --- a/apps/emqx/include/emqx.hrl +++ b/apps/emqx/include/emqx.hrl @@ -55,29 +55,7 @@ -record(subscription, {topic, subid, subopts}). -%% See 'Application Message' in MQTT Version 5.0 --record(message, { - %% Global unique message ID - id :: binary(), - %% Message QoS - qos = 0, - %% Message from - from :: atom() | binary(), - %% Message flags - flags = #{} :: emqx_types:flags(), - %% Message headers. May contain any metadata. e.g. the - %% protocol version number, username, peerhost or - %% the PUBLISH properties (MQTT 5.0). - headers = #{} :: emqx_types:headers(), - %% Topic that the message is published to - topic :: emqx_types:topic(), - %% Message Payload - payload :: emqx_types:payload(), - %% Timestamp (Unit: millisecond) - timestamp :: integer(), - %% not used so far, for future extension - extra = [] :: term() -}). +-include_lib("emqx_utils/include/emqx_message.hrl"). -record(delivery, { %% Sender of the delivery diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl index fe198c207..d8352df18 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl @@ -31,7 +31,7 @@ -export_type([options/0]). --include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx_utils/include/emqx_message.hrl"). -include_lib("snabbkaffe/include/trace.hrl"). %%================================================================================ diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl index ec00f1310..6676faf88 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_reference.erl @@ -34,7 +34,7 @@ -export_type([options/0]). --include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx_utils/include/emqx_message.hrl"). %%================================================================================ %% Type declarations diff --git a/apps/emqx_durable_storage/src/emqx_durable_storage.app.src b/apps/emqx_durable_storage/src/emqx_durable_storage.app.src index 6edbfda9b..f106494c8 100644 --- a/apps/emqx_durable_storage/src/emqx_durable_storage.app.src +++ b/apps/emqx_durable_storage/src/emqx_durable_storage.app.src @@ -5,7 +5,7 @@ {vsn, "0.1.6"}, {modules, []}, {registered, []}, - {applications, [kernel, stdlib, rocksdb, gproc, mria]}, + {applications, [kernel, stdlib, rocksdb, gproc, mria, emqx_utils]}, {mod, {emqx_ds_app, []}}, {env, []} ]}. diff --git a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl index c974b253f..17e873ecd 100644 --- a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl +++ b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v1.erl @@ -17,7 +17,7 @@ -behavior(emqx_bpapi). --include_lib("emqx/include/bpapi.hrl"). +-include_lib("emqx_utils/include/bpapi.hrl"). %% API: -export([open_shard/3, drop_shard/2, get_streams/4, make_iterator/5, next/4]). diff --git a/apps/emqx_utils/include/bpapi.hrl b/apps/emqx_utils/include/bpapi.hrl new file mode 100644 index 000000000..1373e0381 --- /dev/null +++ b/apps/emqx_utils/include/bpapi.hrl @@ -0,0 +1,22 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2017-2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-ifndef(EMQX_BPAPI_HRL). +-define(EMQX_BPAPI_HRL, true). + +-compile({parse_transform, emqx_bpapi_trans}). + +-endif. diff --git a/apps/emqx_utils/include/emqx_message.hrl b/apps/emqx_utils/include/emqx_message.hrl new file mode 100644 index 000000000..a0d196fa9 --- /dev/null +++ b/apps/emqx_utils/include/emqx_message.hrl @@ -0,0 +1,43 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- +-ifndef(EMQX_MESSAGE_HRL). +-define(EMQX_MESSAGE_HRL, true). + +%% See 'Application Message' in MQTT Version 5.0 +-record(message, { + %% Global unique message ID + id :: binary(), + %% Message QoS + qos = 0, + %% Message from + from :: atom() | binary(), + %% Message flags + flags = #{} :: emqx_types:flags(), + %% Message headers. May contain any metadata. e.g. the + %% protocol version number, username, peerhost or + %% the PUBLISH properties (MQTT 5.0). + headers = #{} :: emqx_types:headers(), + %% Topic that the message is published to + topic :: emqx_types:topic(), + %% Message Payload + payload :: emqx_types:payload(), + %% Timestamp (Unit: millisecond) + timestamp :: integer(), + %% not used so far, for future extension + extra = [] :: term() +}). + +-endif. diff --git a/apps/emqx/src/bpapi/emqx_bpapi_trans.erl b/apps/emqx_utils/src/bpapi/emqx_bpapi_trans.erl similarity index 100% rename from apps/emqx/src/bpapi/emqx_bpapi_trans.erl rename to apps/emqx_utils/src/bpapi/emqx_bpapi_trans.erl From c030188eb74198e10884e6e60a5322f0ddb4054e Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Mon, 6 Nov 2023 19:04:14 +0100 Subject: [PATCH 30/31] chore(ds): Add rebar.config file to app/emqx_durable_storage --- apps/emqx_durable_storage/rebar.config | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 apps/emqx_durable_storage/rebar.config diff --git a/apps/emqx_durable_storage/rebar.config b/apps/emqx_durable_storage/rebar.config new file mode 100644 index 000000000..f04819025 --- /dev/null +++ b/apps/emqx_durable_storage/rebar.config @@ -0,0 +1,3 @@ +%% -*- mode:erlang -*- +{deps, + [{emqx_utils, {path, "../emqx_utils"}}]}. From a1cdbaa76d1dc4f7a9e1287f8ba09300512e6001 Mon Sep 17 00:00:00 2001 From: ieQu1 <99872536+ieQu1@users.noreply.github.com> Date: Wed, 8 Nov 2023 04:22:36 +0100 Subject: [PATCH 31/31] fix(ds): Address code review remarks --- Makefile | 2 +- apps/emqx/src/emqx_persistent_session_ds.erl | 9 +++--- .../src/emqx_ds_bitmask_keymapper.erl | 28 ++++++++++--------- .../src/emqx_ds_storage_bitfield_lts.erl | 7 +---- 4 files changed, 22 insertions(+), 24 deletions(-) diff --git a/Makefile b/Makefile index 8e8f4b493..254a4b0f9 100644 --- a/Makefile +++ b/Makefile @@ -85,7 +85,7 @@ $(REL_PROFILES:%=%-compile): $(REBAR) merge-config .PHONY: ct ct: $(REBAR) merge-config - @ENABLE_COVER_COMPILE=1 $(REBAR) ct --name $(CT_NODE_NAME) -c -v --cover_export_name $(CT_COVER_EXPORT_PREFIX)-ct + @$(REBAR) ct --name $(CT_NODE_NAME) -c -v --cover_export_name $(CT_COVER_EXPORT_PREFIX)-ct ## only check bpapi for enterprise profile because it's a super-set. .PHONY: static_checks diff --git a/apps/emqx/src/emqx_persistent_session_ds.erl b/apps/emqx/src/emqx_persistent_session_ds.erl index 9a9e05a7a..f3027f500 100644 --- a/apps/emqx/src/emqx_persistent_session_ds.erl +++ b/apps/emqx/src/emqx_persistent_session_ds.erl @@ -605,9 +605,11 @@ session_read_subscriptions(DSSessionId) -> ), mnesia:select(?SESSION_SUBSCRIPTIONS_TAB, MS, read). --spec new_subscription_id(id(), topic_filter()) -> {subscription_id(), emqx_ds:time()}. +-spec new_subscription_id(id(), topic_filter()) -> {subscription_id(), integer()}. new_subscription_id(DSSessionId, TopicFilter) -> - NowMS = erlang:system_time(microsecond), + %% Note: here we use _milliseconds_ to match with the timestamp + %% field of `#message' record. + NowMS = erlang:system_time(millisecond), DSSubId = {DSSessionId, TopicFilter}, {DSSubId, NowMS}. @@ -662,8 +664,7 @@ renew_streams(Id, ExistingStreams, TopicFilter, StartTime) -> ok; false -> mnesia:write(?SESSION_STREAM_TAB, Rec, write), - % StartTime), - {ok, Iterator} = emqx_ds:make_iterator(Stream, TopicFilter, 0), + {ok, Iterator} = emqx_ds:make_iterator(Stream, TopicFilter, StartTime), IterRec = #ds_iter{id = {Id, Stream}, iter = Iterator}, mnesia:write(?SESSION_ITER_TAB, IterRec, write) end diff --git a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl index 5666b45ae..a3b65c7e6 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_bitmask_keymapper.erl @@ -512,22 +512,24 @@ make_bitfilter(Keymapper = #keymapper{dim_sizeof = DimSizeof}, Ranges) -> {Bitmask, Bitfilter} = lists:unzip(L), {vector_to_key(Keymapper, Bitmask), vector_to_key(Keymapper, Bitfilter)}. -%% Transform inequalities into a list of closed intervals that the +%% Transform constraints into a list of closed intervals that the %% vector elements should lie in. constraints_to_ranges(#keymapper{dim_sizeof = DimSizeof}, Filter) -> lists:zipwith( - fun - (any, Bitsize) -> - {0, ones(Bitsize)}; - ({'=', infinity}, Bitsize) -> - Val = ones(Bitsize), - {Val, Val}; - ({'=', Val}, _Bitsize) -> - {Val, Val}; - ({'>=', Val}, Bitsize) -> - {Val, ones(Bitsize)}; - ({Min, '..', Max}, _Bitsize) -> - {Min, Max} + fun(Constraint, Bitsize) -> + Max = ones(Bitsize), + case Constraint of + any -> + {0, Max}; + {'=', infinity} -> + {Max, Max}; + {'=', Val} when Val =< Max -> + {Val, Val}; + {'>=', Val} when Val =< Max -> + {Val, Max}; + {A, '..', B} when A =< Max, B =< Max -> + {A, B} + end end, Filter, DimSizeof diff --git a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl index d8352df18..d57d8013c 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_bitfield_lts.erl @@ -27,7 +27,7 @@ -export([create/4, open/5, store_batch/4, get_streams/4, make_iterator/5, next/4]). %% internal exports: --export([format_key/2, format_keyfilter/1]). +-export([format_key/2]). -export_type([options/0]). @@ -321,11 +321,6 @@ format_key(KeyMapper, Key) -> Vec = [integer_to_list(I, 16) || I <- emqx_ds_bitmask_keymapper:key_to_vector(KeyMapper, Key)], lists:flatten(io_lib:format("~.16B (~s)", [Key, string:join(Vec, ",")])). -format_keyfilter(any) -> - any; -format_keyfilter({Op, Val}) -> - {Op, integer_to_list(Val, 16)}. - -spec make_key(s(), emqx_types:message()) -> {binary(), [binary()]}. make_key(#s{keymappers = KeyMappers, trie = Trie}, #message{timestamp = Timestamp, topic = TopicBin}) -> Tokens = emqx_topic:tokens(TopicBin),

$*LYGw?t9b)0J&0U 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