feat(sessds): List persistent subscriptions in the REST API
This commit is contained in:
parent
124c5047d0
commit
180130d684
|
@ -176,7 +176,8 @@ format(WhichNode, {{Topic, _Subscriber}, SubOpts}) ->
|
||||||
#{
|
#{
|
||||||
topic => emqx_topic:maybe_format_share(Topic),
|
topic => emqx_topic:maybe_format_share(Topic),
|
||||||
clientid => maps:get(subid, SubOpts, null),
|
clientid => maps:get(subid, SubOpts, null),
|
||||||
node => WhichNode
|
node => WhichNode,
|
||||||
|
durable => false
|
||||||
},
|
},
|
||||||
maps:with([qos, nl, rap, rh], SubOpts)
|
maps:with([qos, nl, rap, rh], SubOpts)
|
||||||
).
|
).
|
||||||
|
@ -196,7 +197,22 @@ check_match_topic(#{<<"match_topic">> := MatchTopic}) ->
|
||||||
check_match_topic(_) ->
|
check_match_topic(_) ->
|
||||||
ok.
|
ok.
|
||||||
|
|
||||||
do_subscriptions_query(QString) ->
|
do_subscriptions_query(QString0) ->
|
||||||
|
{IsDurable, QString} = maps:take(
|
||||||
|
<<"durable">>, maps:merge(#{<<"durable">> => undefined}, QString0)
|
||||||
|
),
|
||||||
|
case emqx_persistent_message:is_persistence_enabled() andalso IsDurable of
|
||||||
|
false ->
|
||||||
|
do_subscriptions_query_mem(QString);
|
||||||
|
true ->
|
||||||
|
do_subscriptions_query_persistent(QString);
|
||||||
|
undefined ->
|
||||||
|
merge_queries(
|
||||||
|
QString, fun do_subscriptions_query_mem/1, fun do_subscriptions_query_persistent/1
|
||||||
|
)
|
||||||
|
end.
|
||||||
|
|
||||||
|
do_subscriptions_query_mem(QString) ->
|
||||||
Args = [?SUBOPTION, QString, ?SUBS_QSCHEMA, fun ?MODULE:qs2ms/2, fun ?MODULE:format/2],
|
Args = [?SUBOPTION, QString, ?SUBS_QSCHEMA, fun ?MODULE:qs2ms/2, fun ?MODULE:format/2],
|
||||||
case maps:get(<<"node">>, QString, undefined) of
|
case maps:get(<<"node">>, QString, undefined) of
|
||||||
undefined ->
|
undefined ->
|
||||||
|
@ -210,8 +226,196 @@ do_subscriptions_query(QString) ->
|
||||||
end
|
end
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
do_subscriptions_query_persistent(#{<<"page">> := Page, <<"limit">> := Limit} = QString) ->
|
||||||
|
Count = emqx_persistent_session_ds_router:stats(n_routes),
|
||||||
|
%% TODO: filtering by client ID can be implemented more efficiently:
|
||||||
|
FilterTopic = maps:get(<<"topic">>, QString, '_'),
|
||||||
|
Stream0 = emqx_persistent_session_ds_router:stream(FilterTopic),
|
||||||
|
SubPred = fun(Sub) ->
|
||||||
|
compare_optional(<<"topic">>, QString, topic, Sub) andalso
|
||||||
|
compare_optional(<<"clientid">>, QString, clientid, Sub) andalso
|
||||||
|
compare_optional(<<"qos">>, QString, qos, Sub) andalso
|
||||||
|
compare_match_topic_optional(<<"match_topic">>, QString, topic, Sub)
|
||||||
|
end,
|
||||||
|
NDropped = (Page - 1) * Limit,
|
||||||
|
{_, Stream} = consume_n_matching(
|
||||||
|
fun persistent_route_to_subscription/1, SubPred, NDropped, Stream0
|
||||||
|
),
|
||||||
|
{Subscriptions, Stream1} = consume_n_matching(
|
||||||
|
fun persistent_route_to_subscription/1, SubPred, Limit, Stream
|
||||||
|
),
|
||||||
|
HasNext = Stream1 =/= [],
|
||||||
|
Meta =
|
||||||
|
case maps:is_key(<<"match_topic">>, QString) orelse maps:is_key(<<"qos">>, QString) of
|
||||||
|
true ->
|
||||||
|
%% Fuzzy searches shouldn't return count:
|
||||||
|
#{
|
||||||
|
limit => Limit,
|
||||||
|
page => Page,
|
||||||
|
hasnext => HasNext
|
||||||
|
};
|
||||||
|
false ->
|
||||||
|
#{
|
||||||
|
count => Count,
|
||||||
|
limit => Limit,
|
||||||
|
page => Page,
|
||||||
|
hasnext => HasNext
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
|
||||||
|
#{
|
||||||
|
meta => Meta,
|
||||||
|
data => Subscriptions
|
||||||
|
}.
|
||||||
|
|
||||||
|
compare_optional(QField, Query, SField, Subscription) ->
|
||||||
|
case Query of
|
||||||
|
#{QField := Expected} ->
|
||||||
|
maps:get(SField, Subscription) =:= Expected;
|
||||||
|
_ ->
|
||||||
|
true
|
||||||
|
end.
|
||||||
|
|
||||||
|
compare_match_topic_optional(QField, Query, SField, Subscription) ->
|
||||||
|
case Query of
|
||||||
|
#{QField := TopicFilter} ->
|
||||||
|
Topic = maps:get(SField, Subscription),
|
||||||
|
emqx_topic:match(Topic, TopicFilter);
|
||||||
|
_ ->
|
||||||
|
true
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% @doc Drop elements from the stream until encountered N elements
|
||||||
|
%% matching the predicate function.
|
||||||
|
-spec consume_n_matching(
|
||||||
|
fun((T) -> Q),
|
||||||
|
fun((Q) -> boolean()),
|
||||||
|
non_neg_integer(),
|
||||||
|
emqx_utils_stream:stream(T)
|
||||||
|
) -> {[Q], emqx_utils_stream:stream(T) | empty}.
|
||||||
|
consume_n_matching(Map, Pred, N, S) ->
|
||||||
|
consume_n_matching(Map, Pred, N, S, []).
|
||||||
|
|
||||||
|
consume_n_matching(_Map, _Pred, _N, [], Acc) ->
|
||||||
|
{lists:reverse(Acc), []};
|
||||||
|
consume_n_matching(_Map, _Pred, 0, S, Acc) ->
|
||||||
|
{lists:reverse(Acc), S};
|
||||||
|
consume_n_matching(Map, Pred, N, S0, Acc) ->
|
||||||
|
case emqx_utils_stream:next(S0) of
|
||||||
|
[] ->
|
||||||
|
consume_n_matching(Map, Pred, N, [], Acc);
|
||||||
|
[Elem | S] ->
|
||||||
|
Mapped = Map(Elem),
|
||||||
|
case Pred(Mapped) of
|
||||||
|
true -> consume_n_matching(Map, Pred, N - 1, S, [Mapped | Acc]);
|
||||||
|
false -> consume_n_matching(Map, Pred, N, S, Acc)
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
persistent_route_to_subscription(#route{topic = Topic, dest = SessionId}) ->
|
||||||
|
case emqx_persistent_session_ds:get_client_subscription(SessionId, Topic) of
|
||||||
|
#{subopts := SubOpts} ->
|
||||||
|
#{qos := Qos, nl := Nl, rh := Rh, rap := Rap} = SubOpts,
|
||||||
|
#{
|
||||||
|
topic => Topic,
|
||||||
|
clientid => SessionId,
|
||||||
|
node => all,
|
||||||
|
|
||||||
|
qos => Qos,
|
||||||
|
nl => Nl,
|
||||||
|
rh => Rh,
|
||||||
|
rap => Rap,
|
||||||
|
durable => true
|
||||||
|
};
|
||||||
|
undefined ->
|
||||||
|
#{
|
||||||
|
topic => Topic,
|
||||||
|
clientid => SessionId,
|
||||||
|
node => all,
|
||||||
|
durable => true
|
||||||
|
}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% @private This function merges paginated results from two sources.
|
||||||
|
%%
|
||||||
|
%% Note: this implementation is far from ideal: `count' for the
|
||||||
|
%% queries may be missing, it may be larger than the actual number of
|
||||||
|
%% elements. This may lead to empty pages that can confuse the user.
|
||||||
|
%%
|
||||||
|
%% Not much can be done to mitigate that, though: since the count may
|
||||||
|
%% be incorrect, we cannot run simple math to determine when one
|
||||||
|
%% stream begins and another ends: it requires actual iteration.
|
||||||
|
%%
|
||||||
|
%% Ideally, the dashboard must be split between durable and mem
|
||||||
|
%% subscriptions, and this function should be removed for good.
|
||||||
|
merge_queries(QString0, Q1, Q2) ->
|
||||||
|
#{<<"limit">> := Limit, <<"page">> := Page} = QString0,
|
||||||
|
C1 = resp_count(QString0, Q1),
|
||||||
|
C2 = resp_count(QString0, Q2),
|
||||||
|
Meta =
|
||||||
|
case is_number(C1) andalso is_number(C2) of
|
||||||
|
true ->
|
||||||
|
#{
|
||||||
|
count => C1 + C2,
|
||||||
|
limit => Limit,
|
||||||
|
page => Page
|
||||||
|
};
|
||||||
|
false ->
|
||||||
|
#{
|
||||||
|
limit => Limit,
|
||||||
|
page => Page
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
case {C1, C2} of
|
||||||
|
{_, 0} ->
|
||||||
|
%% The second query is empty. Just return the result of Q1 as usual:
|
||||||
|
Q1(QString0);
|
||||||
|
{0, _} ->
|
||||||
|
%% The first query is empty. Just return the result of Q2 as usual:
|
||||||
|
Q2(QString0);
|
||||||
|
_ when is_number(C1) ->
|
||||||
|
%% Both queries are potentially non-empty, but we at least
|
||||||
|
%% have the page number for the first query. We try to
|
||||||
|
%% stich the pages together and thus respect the limit
|
||||||
|
%% (except for the page where the results switch from Q1
|
||||||
|
%% to Q2).
|
||||||
|
|
||||||
|
%% Page where data from the second query is estimated to
|
||||||
|
%% begin:
|
||||||
|
Q2Page = ceil(C1 / Limit),
|
||||||
|
case Page =< Q2Page of
|
||||||
|
true ->
|
||||||
|
#{data := Data, meta := #{hasnext := HN}} = Q1(QString0),
|
||||||
|
#{
|
||||||
|
data => Data,
|
||||||
|
meta => Meta#{hasnext => HN orelse C2 > 0}
|
||||||
|
};
|
||||||
|
false ->
|
||||||
|
QString = QString0#{<<"page">> => Page - Q2Page},
|
||||||
|
#{data := Data, meta := #{hasnext := HN}} = Q2(QString),
|
||||||
|
#{data => Data, meta => Meta#{hasnext => HN}}
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
%% We don't know how many items is there in the first
|
||||||
|
%% query, and the second query is not empty (this includes
|
||||||
|
%% the case where `C2' is `undefined'). Best we can do is
|
||||||
|
%% to interleave the queries. This may produce less
|
||||||
|
%% results per page than `Limit'.
|
||||||
|
QString = QString0#{<<"limit">> => ceil(Limit / 2)},
|
||||||
|
#{data := D1, meta := #{hasnext := HN1}} = Q1(QString),
|
||||||
|
#{data := D2, meta := #{hasnext := HN2}} = Q2(QString),
|
||||||
|
#{
|
||||||
|
meta => Meta#{hasnext => HN1 or HN2},
|
||||||
|
data => D1 ++ D2
|
||||||
|
}
|
||||||
|
end.
|
||||||
|
|
||||||
|
resp_count(Query, QFun) ->
|
||||||
|
#{meta := Meta} = QFun(Query#{<<"limit">> => 1, <<"page">> => 1}),
|
||||||
|
maps:get(count, Meta, undefined).
|
||||||
|
|
||||||
%%--------------------------------------------------------------------
|
%%--------------------------------------------------------------------
|
||||||
%% QueryString to MatchSpec
|
%% QueryString to MatchSpec (mem sessions)
|
||||||
%%--------------------------------------------------------------------
|
%%--------------------------------------------------------------------
|
||||||
|
|
||||||
-spec qs2ms(atom(), {list(), list()}) -> emqx_mgmt_api:match_spec_and_filter().
|
-spec qs2ms(atom(), {list(), list()}) -> emqx_mgmt_api:match_spec_and_filter().
|
||||||
|
|
|
@ -36,17 +36,72 @@
|
||||||
-define(TOPIC_SORT, #{?TOPIC1 => 1, ?TOPIC2 => 2}).
|
-define(TOPIC_SORT, #{?TOPIC1 => 1, ?TOPIC2 => 2}).
|
||||||
|
|
||||||
all() ->
|
all() ->
|
||||||
emqx_common_test_helpers:all(?MODULE).
|
[
|
||||||
|
{group, mem},
|
||||||
|
{group, persistent}
|
||||||
|
].
|
||||||
|
|
||||||
|
groups() ->
|
||||||
|
CommonTCs = emqx_common_test_helpers:all(?MODULE),
|
||||||
|
[
|
||||||
|
{mem, CommonTCs},
|
||||||
|
%% Shared subscriptions are currently not supported:
|
||||||
|
{persistent, CommonTCs -- [t_list_with_shared_sub, t_subscription_api]}
|
||||||
|
].
|
||||||
|
|
||||||
init_per_suite(Config) ->
|
init_per_suite(Config) ->
|
||||||
emqx_mgmt_api_test_util:init_suite(),
|
Apps = emqx_cth_suite:start(
|
||||||
|
[
|
||||||
|
{emqx,
|
||||||
|
"session_persistence {\n"
|
||||||
|
" enable = true\n"
|
||||||
|
" renew_streams_interval = 10ms\n"
|
||||||
|
"}"},
|
||||||
|
emqx_management,
|
||||||
|
emqx_mgmt_api_test_util:emqx_dashboard()
|
||||||
|
],
|
||||||
|
#{work_dir => emqx_cth_suite:work_dir(Config)}
|
||||||
|
),
|
||||||
|
[{apps, Apps} | Config].
|
||||||
|
|
||||||
|
end_per_suite(Config) ->
|
||||||
|
ok = emqx_cth_suite:stop(?config(apps, Config)).
|
||||||
|
|
||||||
|
init_per_group(persistent, Config) ->
|
||||||
|
ClientConfig = #{
|
||||||
|
username => ?USERNAME,
|
||||||
|
clientid => ?CLIENTID,
|
||||||
|
proto_ver => v5,
|
||||||
|
clean_start => true,
|
||||||
|
properties => #{'Session-Expiry-Interval' => 300}
|
||||||
|
},
|
||||||
|
[{client_config, ClientConfig}, {durable, true} | Config];
|
||||||
|
init_per_group(mem, Config) ->
|
||||||
|
ClientConfig = #{
|
||||||
|
username => ?USERNAME, clientid => ?CLIENTID, proto_ver => v5, clean_start => true
|
||||||
|
},
|
||||||
|
[{client_config, ClientConfig}, {durable, false} | Config].
|
||||||
|
|
||||||
|
end_per_group(_, Config) ->
|
||||||
Config.
|
Config.
|
||||||
|
|
||||||
end_per_suite(_) ->
|
init_per_testcase(_TC, Config) ->
|
||||||
emqx_mgmt_api_test_util:end_suite().
|
case ?config(client_config, Config) of
|
||||||
|
ClientConfig when is_map(ClientConfig) ->
|
||||||
|
{ok, Client} = emqtt:start_link(ClientConfig),
|
||||||
|
{ok, _} = emqtt:connect(Client),
|
||||||
|
[{client, Client} | Config];
|
||||||
|
_ ->
|
||||||
|
Config
|
||||||
|
end.
|
||||||
|
|
||||||
|
end_per_testcase(_TC, Config) ->
|
||||||
|
Client = proplists:get_value(client, Config),
|
||||||
|
emqtt:disconnect(Client).
|
||||||
|
|
||||||
t_subscription_api(Config) ->
|
t_subscription_api(Config) ->
|
||||||
Client = proplists:get_value(client, Config),
|
Client = proplists:get_value(client, Config),
|
||||||
|
Durable = atom_to_list(?config(durable, Config)),
|
||||||
{ok, _, _} = emqtt:subscribe(
|
{ok, _, _} = emqtt:subscribe(
|
||||||
Client, [
|
Client, [
|
||||||
{?TOPIC1, [{rh, ?TOPIC1RH}, {rap, ?TOPIC1RAP}, {nl, ?TOPIC1NL}, {qos, ?TOPIC1QOS}]}
|
{?TOPIC1, [{rh, ?TOPIC1RH}, {rap, ?TOPIC1RAP}, {nl, ?TOPIC1NL}, {qos, ?TOPIC1QOS}]}
|
||||||
|
@ -54,12 +109,13 @@ t_subscription_api(Config) ->
|
||||||
),
|
),
|
||||||
{ok, _, _} = emqtt:subscribe(Client, ?TOPIC2),
|
{ok, _, _} = emqtt:subscribe(Client, ?TOPIC2),
|
||||||
Path = emqx_mgmt_api_test_util:api_path(["subscriptions"]),
|
Path = emqx_mgmt_api_test_util:api_path(["subscriptions"]),
|
||||||
|
timer:sleep(100),
|
||||||
{ok, Response} = emqx_mgmt_api_test_util:request_api(get, Path),
|
{ok, Response} = emqx_mgmt_api_test_util:request_api(get, Path),
|
||||||
Data = emqx_utils_json:decode(Response, [return_maps]),
|
Data = emqx_utils_json:decode(Response, [return_maps]),
|
||||||
Meta = maps:get(<<"meta">>, Data),
|
Meta = maps:get(<<"meta">>, Data),
|
||||||
?assertEqual(1, maps:get(<<"page">>, Meta)),
|
?assertEqual(1, maps:get(<<"page">>, Meta)),
|
||||||
?assertEqual(emqx_mgmt:default_row_limit(), maps:get(<<"limit">>, Meta)),
|
?assertEqual(emqx_mgmt:default_row_limit(), maps:get(<<"limit">>, Meta)),
|
||||||
?assertEqual(2, maps:get(<<"count">>, Meta)),
|
?assertEqual(2, maps:get(<<"count">>, Meta), Data),
|
||||||
Subscriptions = maps:get(<<"data">>, Data),
|
Subscriptions = maps:get(<<"data">>, Data),
|
||||||
?assertEqual(length(Subscriptions), 2),
|
?assertEqual(length(Subscriptions), 2),
|
||||||
Sort =
|
Sort =
|
||||||
|
@ -90,7 +146,8 @@ t_subscription_api(Config) ->
|
||||||
{"node", atom_to_list(node())},
|
{"node", atom_to_list(node())},
|
||||||
{"qos", "0"},
|
{"qos", "0"},
|
||||||
{"share_group", "test_group"},
|
{"share_group", "test_group"},
|
||||||
{"match_topic", "t/#"}
|
{"match_topic", "t/#"},
|
||||||
|
{"durable", Durable}
|
||||||
],
|
],
|
||||||
Headers = emqx_mgmt_api_test_util:auth_header_(),
|
Headers = emqx_mgmt_api_test_util:auth_header_(),
|
||||||
|
|
||||||
|
@ -103,6 +160,7 @@ t_subscription_api(Config) ->
|
||||||
|
|
||||||
t_subscription_fuzzy_search(Config) ->
|
t_subscription_fuzzy_search(Config) ->
|
||||||
Client = proplists:get_value(client, Config),
|
Client = proplists:get_value(client, Config),
|
||||||
|
Durable = atom_to_list(?config(durable, Config)),
|
||||||
Topics = [
|
Topics = [
|
||||||
<<"t/foo">>,
|
<<"t/foo">>,
|
||||||
<<"t/foo/bar">>,
|
<<"t/foo/bar">>,
|
||||||
|
@ -116,7 +174,8 @@ t_subscription_fuzzy_search(Config) ->
|
||||||
MatchQs = [
|
MatchQs = [
|
||||||
{"clientid", ?CLIENTID},
|
{"clientid", ?CLIENTID},
|
||||||
{"node", atom_to_list(node())},
|
{"node", atom_to_list(node())},
|
||||||
{"match_topic", "t/#"}
|
{"match_topic", "t/#"},
|
||||||
|
{"durable", Durable}
|
||||||
],
|
],
|
||||||
|
|
||||||
MatchData1 = #{<<"meta">> := MatchMeta1} = request_json(get, MatchQs, Headers),
|
MatchData1 = #{<<"meta">> := MatchMeta1} = request_json(get, MatchQs, Headers),
|
||||||
|
@ -130,12 +189,13 @@ t_subscription_fuzzy_search(Config) ->
|
||||||
LimitMatchQuery = [
|
LimitMatchQuery = [
|
||||||
{"clientid", ?CLIENTID},
|
{"clientid", ?CLIENTID},
|
||||||
{"match_topic", "+/+/+"},
|
{"match_topic", "+/+/+"},
|
||||||
{"limit", "3"}
|
{"limit", "3"},
|
||||||
|
{"durable", Durable}
|
||||||
],
|
],
|
||||||
|
|
||||||
MatchData2 = #{<<"meta">> := MatchMeta2} = request_json(get, LimitMatchQuery, Headers),
|
MatchData2 = #{<<"meta">> := MatchMeta2} = request_json(get, LimitMatchQuery, Headers),
|
||||||
?assertEqual(#{<<"page">> => 1, <<"limit">> => 3, <<"hasnext">> => true}, MatchMeta2),
|
?assertEqual(#{<<"page">> => 1, <<"limit">> => 3, <<"hasnext">> => true}, MatchMeta2),
|
||||||
?assertEqual(3, length(maps:get(<<"data">>, MatchData2))),
|
?assertEqual(3, length(maps:get(<<"data">>, MatchData2)), MatchData2),
|
||||||
|
|
||||||
MatchData2P2 =
|
MatchData2P2 =
|
||||||
#{<<"meta">> := MatchMeta2P2} =
|
#{<<"meta">> := MatchMeta2P2} =
|
||||||
|
@ -176,8 +236,8 @@ t_list_with_shared_sub(_Config) ->
|
||||||
|
|
||||||
ok.
|
ok.
|
||||||
|
|
||||||
t_list_with_invalid_match_topic(_Config) ->
|
t_list_with_invalid_match_topic(Config) ->
|
||||||
Client = proplists:get_value(client, _Config),
|
Client = proplists:get_value(client, Config),
|
||||||
RealTopic = <<"t/+">>,
|
RealTopic = <<"t/+">>,
|
||||||
Topic = <<"$share/g1/", RealTopic/binary>>,
|
Topic = <<"$share/g1/", RealTopic/binary>>,
|
||||||
|
|
||||||
|
@ -212,12 +272,3 @@ request_json(Method, Query, Headers) when is_list(Query) ->
|
||||||
|
|
||||||
path() ->
|
path() ->
|
||||||
emqx_mgmt_api_test_util:api_path(["subscriptions"]).
|
emqx_mgmt_api_test_util:api_path(["subscriptions"]).
|
||||||
|
|
||||||
init_per_testcase(_TC, Config) ->
|
|
||||||
{ok, Client} = emqtt:start_link(#{username => ?USERNAME, clientid => ?CLIENTID, proto_ver => v5}),
|
|
||||||
{ok, _} = emqtt:connect(Client),
|
|
||||||
[{client, Client} | Config].
|
|
||||||
|
|
||||||
end_per_testcase(_TC, Config) ->
|
|
||||||
Client = proplists:get_value(client, Config),
|
|
||||||
emqtt:disconnect(Client).
|
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
- Ensure consistency of the durable message replay when the subscriptions are modified before session reconnects
|
||||||
|
|
||||||
|
- Persistent sessions save inflight packet IDs for the received QoS2 messages
|
||||||
|
|
||||||
|
- Make behavior of the persistent sessions consistent with the non-persistent sessions in regard to overlapping subscriptions
|
||||||
|
|
||||||
|
- List persistent subscriptions in the REST API
|
Loading…
Reference in New Issue