diff --git a/.github/workflows/build_and_push_docker_images.yaml b/.github/workflows/build_and_push_docker_images.yaml index 54f83a1a4..0945327e8 100644 --- a/.github/workflows/build_and_push_docker_images.yaml +++ b/.github/workflows/build_and_push_docker_images.yaml @@ -69,7 +69,7 @@ permissions: jobs: build: runs-on: ${{ github.repository_owner == 'emqx' && fromJSON(format('["self-hosted","ephemeral","linux","{0}"]', matrix.arch)) || 'ubuntu-22.04' }} - container: "ghcr.io/emqx/emqx-builder/${{ inputs.builder_vsn }}:${{ inputs.elixir_vsn }}-${{ inputs.otp_vsn }}-debian11" + container: "ghcr.io/emqx/emqx-builder/${{ inputs.builder_vsn }}:${{ inputs.elixir_vsn }}-${{ inputs.otp_vsn }}-debian12" outputs: PKG_VSN: ${{ steps.build.outputs.PKG_VSN }} @@ -166,7 +166,7 @@ jobs: DOCKER_BUILD_NOCACHE: true DOCKER_PLATFORMS: linux/amd64,linux/arm64 DOCKER_LOAD: true - EMQX_RUNNER: 'public.ecr.aws/debian/debian:11-slim@sha256:22cfb3c06a7dd5e18d86123a73405664475b9d9fa209cbedcf4c50a25649cc74' + EMQX_RUNNER: 'public.ecr.aws/debian/debian:12-slim' EMQX_DOCKERFILE: 'deploy/docker/Dockerfile' PKG_VSN: ${{ needs.build.outputs.PKG_VSN }} EMQX_BUILDER_VERSION: ${{ inputs.builder_vsn }} @@ -203,10 +203,23 @@ jobs: docker exec -t -u root -w /root $CID bash -c 'apt-get -y update && apt-get -y install net-tools' docker exec -t -u root $CID node_dump docker rm -f $CID - - name: push images + - name: Push docker image if: inputs.publish || github.repository_owner != 'emqx' + env: + PROFILE: ${{ matrix.profile[0] }} + DOCKER_REGISTRY: ${{ matrix.profile[1] }} + DOCKER_ORG: ${{ github.repository_owner }} + DOCKER_LATEST: ${{ inputs.latest }} + DOCKER_PUSH: true + DOCKER_BUILD_NOCACHE: false + DOCKER_PLATFORMS: linux/amd64,linux/arm64 + DOCKER_LOAD: false + EMQX_RUNNER: 'public.ecr.aws/debian/debian:12-slim' + EMQX_DOCKERFILE: 'deploy/docker/Dockerfile' + PKG_VSN: ${{ needs.build.outputs.PKG_VSN }} + EMQX_BUILDER_VERSION: ${{ inputs.builder_vsn }} + EMQX_BUILDER_OTP: ${{ inputs.otp_vsn }} + EMQX_BUILDER_ELIXIR: ${{ inputs.elixir_vsn }} + EMQX_SOURCE_TYPE: tgz run: | - for tag in $(cat .emqx_docker_image_tags); do - echo "Pushing tag $tag" - docker push $tag - done + ./build ${PROFILE} docker diff --git a/.gitignore b/.gitignore index 7068c1c7d..5e91d4bc5 100644 --- a/.gitignore +++ b/.gitignore @@ -72,5 +72,7 @@ ct_run*/ apps/emqx_conf/etc/emqx.conf.all.rendered* rebar-git-cache.tar # build docker image locally +.dockerignore .docker_image_tag +.emqx_docker_image_tags .git/ diff --git a/Makefile b/Makefile index 17c8bcfd2..2dddd4fbe 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,8 @@ REBAR = $(CURDIR)/rebar3 BUILD = $(CURDIR)/build SCRIPTS = $(CURDIR)/scripts export EMQX_RELUP ?= true -export EMQX_DEFAULT_BUILDER = ghcr.io/emqx/emqx-builder/5.3-2:1.15.7-26.2.1-2-debian11 -export EMQX_DEFAULT_RUNNER = public.ecr.aws/debian/debian:11-slim +export EMQX_DEFAULT_BUILDER = ghcr.io/emqx/emqx-builder/5.3-2:1.15.7-26.2.1-2-debian12 +export EMQX_DEFAULT_RUNNER = public.ecr.aws/debian/debian:12-slim export EMQX_REL_FORM ?= tgz export QUICER_DOWNLOAD_FROM_RELEASE = 1 ifeq ($(OS),Windows_NT) @@ -21,7 +21,7 @@ endif # Dashboard version # from https://github.com/emqx/emqx-dashboard5 export EMQX_DASHBOARD_VERSION ?= v1.7.0 -export EMQX_EE_DASHBOARD_VERSION ?= e1.6.0-beta.2 +export EMQX_EE_DASHBOARD_VERSION ?= e1.6.0-beta.5 PROFILE ?= emqx REL_PROFILES := emqx emqx-enterprise diff --git a/apps/emqx/include/emqx_release.hrl b/apps/emqx/include/emqx_release.hrl index 056e505cb..dd09309c2 100644 --- a/apps/emqx/include/emqx_release.hrl +++ b/apps/emqx/include/emqx_release.hrl @@ -32,7 +32,7 @@ %% `apps/emqx/src/bpapi/README.md' %% Opensource edition --define(EMQX_RELEASE_CE, "5.6.0-alpha.2"). +-define(EMQX_RELEASE_CE, "5.6.0-rc.1"). %% Enterprise edition --define(EMQX_RELEASE_EE, "5.6.0-alpha.2"). +-define(EMQX_RELEASE_EE, "5.6.0-rc.1"). 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 99bb05010..730cc9e38 100644 --- a/apps/emqx/integration_test/emqx_persistent_session_ds_SUITE.erl +++ b/apps/emqx/integration_test/emqx_persistent_session_ds_SUITE.erl @@ -118,7 +118,6 @@ app_specs() -> app_specs(Opts) -> ExtraEMQXConf = maps:get(extra_emqx_conf, Opts, ""), [ - emqx_durable_storage, {emqx, "session_persistence = {enable = true}" ++ ExtraEMQXConf} ]. @@ -154,6 +153,14 @@ start_client(Opts0 = #{}) -> on_exit(fun() -> catch emqtt:stop(Client) end), Client. +start_connect_client(Opts = #{}) -> + Client = start_client(Opts), + ?assertMatch({ok, _}, emqtt:connect(Client)), + Client. + +mk_clientid(Prefix, ID) -> + iolist_to_binary(io_lib:format("~p/~p", [Prefix, ID])). + restart_node(Node, NodeSpec) -> ?tp(will_restart_node, #{}), emqx_cth_cluster:restart(Node, NodeSpec), @@ -601,3 +608,66 @@ t_session_gc(Config) -> [] ), ok. + +t_session_replay_retry(_Config) -> + %% Verify that the session recovers smoothly from transient errors during + %% replay. + + ok = emqx_ds_test_helpers:mock_rpc(), + + NClients = 10, + ClientSubOpts = #{ + clientid => mk_clientid(?FUNCTION_NAME, sub), + auto_ack => never + }, + ClientSub = start_connect_client(ClientSubOpts), + ?assertMatch( + {ok, _, [?RC_GRANTED_QOS_1]}, + emqtt:subscribe(ClientSub, <<"t/#">>, ?QOS_1) + ), + + ClientsPub = [ + start_connect_client(#{ + clientid => mk_clientid(?FUNCTION_NAME, I), + properties => #{'Session-Expiry-Interval' => 0} + }) + || I <- lists:seq(1, NClients) + ], + lists:foreach( + fun(Client) -> + Index = integer_to_binary(rand:uniform(NClients)), + Topic = <<"t/", Index/binary>>, + ?assertMatch({ok, #{}}, emqtt:publish(Client, Topic, Index, 1)) + end, + ClientsPub + ), + + Pubs0 = emqx_common_test_helpers:wait_publishes(NClients, 5_000), + NPubs = length(Pubs0), + ?assertEqual(NClients, NPubs, ?drainMailbox()), + + ok = emqtt:stop(ClientSub), + + %% Make `emqx_ds` believe that roughly half of the shards are unavailable. + ok = emqx_ds_test_helpers:mock_rpc_result( + fun(_Node, emqx_ds_replication_layer, _Function, [_DB, Shard | _]) -> + case erlang:phash2(Shard) rem 2 of + 0 -> unavailable; + 1 -> passthrough + end + end + ), + + _ClientSub = start_connect_client(ClientSubOpts#{clean_start => false}), + + Pubs1 = emqx_common_test_helpers:wait_publishes(NPubs, 5_000), + ?assert(length(Pubs1) < length(Pubs0), Pubs1), + + %% "Recover" the shards. + emqx_ds_test_helpers:unmock_rpc(), + + Pubs2 = emqx_common_test_helpers:wait_publishes(NPubs - length(Pubs1), 5_000), + ?assertEqual( + [maps:with([topic, payload, qos], P) || P <- Pubs0], + [maps:with([topic, payload, qos], P) || P <- Pubs1 ++ Pubs2] + ). diff --git a/apps/emqx/priv/bpapi.versions b/apps/emqx/priv/bpapi.versions index 36c5f247e..11ac8f582 100644 --- a/apps/emqx/priv/bpapi.versions +++ b/apps/emqx/priv/bpapi.versions @@ -39,6 +39,7 @@ {emqx_management,2}. {emqx_management,3}. {emqx_management,4}. +{emqx_management,5}. {emqx_metrics,1}. {emqx_mgmt_api_plugins,1}. {emqx_mgmt_api_plugins,2}. diff --git a/apps/emqx/rebar.config b/apps/emqx/rebar.config index cbaae0b39..3822078b7 100644 --- a/apps/emqx/rebar.config +++ b/apps/emqx/rebar.config @@ -30,7 +30,7 @@ {esockd, {git, "https://github.com/emqx/esockd", {tag, "5.11.1"}}}, {ekka, {git, "https://github.com/emqx/ekka", {tag, "0.19.0"}}}, {gen_rpc, {git, "https://github.com/emqx/gen_rpc", {tag, "3.3.1"}}}, - {hocon, {git, "https://github.com/emqx/hocon.git", {tag, "0.42.0"}}}, + {hocon, {git, "https://github.com/emqx/hocon.git", {tag, "0.42.1"}}}, {emqx_http_lib, {git, "https://github.com/emqx/emqx_http_lib.git", {tag, "0.5.3"}}}, {pbkdf2, {git, "https://github.com/emqx/erlang-pbkdf2.git", {tag, "2.0.4"}}}, {recon, {git, "https://github.com/ferd/recon", {tag, "2.5.1"}}}, diff --git a/apps/emqx/src/emqx_channel.erl b/apps/emqx/src/emqx_channel.erl index 51b66f4f9..94497ef46 100644 --- a/apps/emqx/src/emqx_channel.erl +++ b/apps/emqx/src/emqx_channel.erl @@ -1210,6 +1210,10 @@ handle_call( ChanInfo1 = info(NChannel), emqx_cm:set_chan_info(ClientId, ChanInfo1#{sockinfo => SockInfo}), reply(ok, reset_timer(keepalive, NChannel)); +handle_call({Type, _Meta} = MsgsReq, Channel = #channel{session = Session}) when + Type =:= mqueue_msgs; Type =:= inflight_msgs +-> + {reply, emqx_session:info(MsgsReq, Session), Channel}; handle_call(Req, Channel) -> ?SLOG(error, #{msg => "unexpected_call", call => Req}), reply(ignored, Channel). diff --git a/apps/emqx/src/emqx_cm_registry_keeper.erl b/apps/emqx/src/emqx_cm_registry_keeper.erl index e96fcdd7d..c78731dea 100644 --- a/apps/emqx/src/emqx_cm_registry_keeper.erl +++ b/apps/emqx/src/emqx_cm_registry_keeper.erl @@ -20,7 +20,8 @@ -export([ start_link/0, - count/1 + count/1, + purge/0 ]). %% gen_server callbacks @@ -48,7 +49,10 @@ start_link() -> init(_) -> case mria_config:whoami() =:= replicant of true -> - ignore; + %% Do not run delete loops on replicant nodes + %% because the core nodes will do it anyway + %% The process is started to serve the 'count' calls + {ok, #{no_deletes => true}}; false -> ok = send_delay_start(), {ok, #{next_clientid => undefined}} @@ -71,6 +75,19 @@ count(Since) -> gen_server:call(?MODULE, {count, Since}, infinity) end. +%% @doc Delete all retained history. Only for tests. +-spec purge() -> ok. +purge() -> + purge_loop(undefined). + +purge_loop(StartId) -> + case cleanup_one_chunk(StartId, _IsPurge = true) of + '$end_of_table' -> + ok; + NextId -> + purge_loop(NextId) + end. + handle_call({count, Since}, _From, State) -> {LastCountTime, LastCount} = case State of @@ -128,10 +145,13 @@ code_change(_OldVsn, State, _Extra) -> {ok, State}. cleanup_one_chunk(NextClientId) -> + cleanup_one_chunk(NextClientId, false). + +cleanup_one_chunk(NextClientId, IsPurge) -> Retain = retain_duration(), Now = now_ts(), IsExpired = fun(#channel{pid = Ts}) -> - is_integer(Ts) andalso (Ts < Now - Retain) + IsPurge orelse (is_integer(Ts) andalso (Ts < Now - Retain)) end, cleanup_loop(NextClientId, ?CLEANUP_CHUNK_SIZE, IsExpired). diff --git a/apps/emqx/src/emqx_config.erl b/apps/emqx/src/emqx_config.erl index 5147f2b6d..9da453260 100644 --- a/apps/emqx/src/emqx_config.erl +++ b/apps/emqx/src/emqx_config.erl @@ -715,6 +715,7 @@ add_handlers() -> ok = emqx_config_logger:add_handler(), ok = emqx_config_zones:add_handler(), emqx_sys_mon:add_handler(), + emqx_persistent_message:add_handler(), ok. remove_handlers() -> diff --git a/apps/emqx/src/emqx_cpu_sup_worker.erl b/apps/emqx/src/emqx_cpu_sup_worker.erl new file mode 100644 index 000000000..969a40736 --- /dev/null +++ b/apps/emqx/src/emqx_cpu_sup_worker.erl @@ -0,0 +1,92 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2024 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_cpu_sup_worker). + +-behaviour(gen_server). + +-include("logger.hrl"). + +%% gen_server APIs +-export([start_link/0]). + +-export([ + cpu_util/0, + cpu_util/1 +]). + +%% gen_server callbacks +-export([ + init/1, + handle_continue/2, + handle_call/3, + handle_cast/2, + terminate/2, + code_change/3 +]). + +-define(CPU_USAGE_WORKER, ?MODULE). + +%%-------------------------------------------------------------------- +%% API +%%-------------------------------------------------------------------- + +cpu_util() -> + gen_server:call(?CPU_USAGE_WORKER, ?FUNCTION_NAME, infinity). + +cpu_util(Args) -> + gen_server:call(?CPU_USAGE_WORKER, {?FUNCTION_NAME, Args}, infinity). + +%%-------------------------------------------------------------------- +%% gen_server callbacks +%% simply handle cpu_sup:util/0,1 called in one process +%%-------------------------------------------------------------------- + +start_link() -> + gen_server:start_link({local, ?CPU_USAGE_WORKER}, ?MODULE, [], []). + +init([]) -> + {ok, undefined, {continue, setup}}. + +handle_continue(setup, undefined) -> + %% start os_mon temporarily + {ok, _} = application:ensure_all_started(os_mon), + %% The returned value of the first call to cpu_sup:util/0 or cpu_sup:util/1 by a + %% process will on most systems be the CPU utilization since system boot, + %% but this is not guaranteed and the value should therefore be regarded as garbage. + %% This also applies to the first call after a restart of cpu_sup. + _Val = cpu_sup:util(), + {noreply, #{}}. + +handle_call(cpu_util, _From, State) -> + Val = cpu_sup:util(), + {reply, Val, State}; +handle_call({cpu_util, Args}, _From, State) -> + Val = erlang:apply(cpu_sup, util, Args), + {reply, Val, State}; +handle_call(Req, _From, State) -> + ?SLOG(error, #{msg => "unexpected_call", call => Req}), + {reply, ignored, State}. + +handle_cast(Msg, State) -> + ?SLOG(error, #{msg => "unexpected_cast", cast => Msg}), + {noreply, State}. + +terminate(_Reason, _State) -> + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. diff --git a/apps/emqx/src/emqx_inflight.erl b/apps/emqx/src/emqx_inflight.erl index c342a846f..1f4433e57 100644 --- a/apps/emqx/src/emqx_inflight.erl +++ b/apps/emqx/src/emqx_inflight.erl @@ -36,7 +36,8 @@ max_size/1, is_full/1, is_empty/1, - window/1 + window/1, + query/2 ]). -export_type([inflight/0]). @@ -138,3 +139,47 @@ size(?INFLIGHT(Tree)) -> -spec max_size(inflight()) -> non_neg_integer(). max_size(?INFLIGHT(MaxSize, _Tree)) -> MaxSize. + +-spec query(inflight(), #{continuation => Cont, limit := L}) -> + {[{key(), term()}], #{continuation := Cont, count := C}} +when + Cont :: none | end_of_data | key(), + L :: non_neg_integer(), + C :: non_neg_integer(). +query(?INFLIGHT(Tree), #{limit := Limit} = Pager) -> + Count = gb_trees:size(Tree), + ContKey = maps:get(continuation, Pager, none), + {List, NextCont} = sublist(iterator_from(ContKey, Tree), Limit), + {List, #{continuation => NextCont, count => Count}}. + +iterator_from(none, Tree) -> + gb_trees:iterator(Tree); +iterator_from(ContKey, Tree) -> + It = gb_trees:iterator_from(ContKey, Tree), + case gb_trees:next(It) of + {ContKey, _Val, ItNext} -> ItNext; + _ -> It + end. + +sublist(_It, 0) -> + {[], none}; +sublist(It, Len) -> + {ListAcc, HasNext} = sublist(It, Len, []), + {lists:reverse(ListAcc), next_cont(ListAcc, HasNext)}. + +sublist(It, 0, Acc) -> + {Acc, gb_trees:next(It) =/= none}; +sublist(It, Len, Acc) -> + case gb_trees:next(It) of + none -> + {Acc, false}; + {Key, Val, ItNext} -> + sublist(ItNext, Len - 1, [{Key, Val} | Acc]) + end. + +next_cont(_Acc, false) -> + end_of_data; +next_cont([{LastKey, _LastVal} | _Acc], _HasNext) -> + LastKey; +next_cont([], _HasNext) -> + end_of_data. diff --git a/apps/emqx/src/emqx_mqueue.erl b/apps/emqx/src/emqx_mqueue.erl index d085a196b..e3e54cdc9 100644 --- a/apps/emqx/src/emqx_mqueue.erl +++ b/apps/emqx/src/emqx_mqueue.erl @@ -68,7 +68,8 @@ stats/1, dropped/1, to_list/1, - filter/2 + filter/2, + query/2 ]). -define(NO_PRIORITY_TABLE, disabled). @@ -171,6 +172,55 @@ filter(Pred, #mqueue{q = Q, len = Len, dropped = Droppend} = MQ) -> MQ#mqueue{q = Q2, len = Len2, dropped = Droppend + Diff} end. +-spec query(mqueue(), #{continuation => ContMsgId, limit := L}) -> + {[message()], #{continuation := ContMsgId, count := C}} +when + ContMsgId :: none | end_of_data | binary(), + C :: non_neg_integer(), + L :: non_neg_integer(). +query(MQ, #{limit := Limit} = Pager) -> + ContMsgId = maps:get(continuation, Pager, none), + {List, NextCont} = sublist(skip_until(MQ, ContMsgId), Limit), + {List, #{continuation => NextCont, count => len(MQ)}}. + +skip_until(MQ, none = _MsgId) -> + MQ; +skip_until(MQ, MsgId) -> + do_skip_until(MQ, MsgId). + +do_skip_until(MQ, MsgId) -> + case out(MQ) of + {empty, MQ} -> + MQ; + {{value, #message{id = MsgId}}, Q1} -> + Q1; + {{value, _Msg}, Q1} -> + do_skip_until(Q1, MsgId) + end. + +sublist(_MQ, 0) -> + {[], none}; +sublist(MQ, Len) -> + {ListAcc, HasNext} = sublist(MQ, Len, []), + {lists:reverse(ListAcc), next_cont(ListAcc, HasNext)}. + +sublist(MQ, 0, Acc) -> + {Acc, element(1, out(MQ)) =/= empty}; +sublist(MQ, Len, Acc) -> + case out(MQ) of + {empty, _MQ} -> + {Acc, false}; + {{value, Msg}, Q1} -> + sublist(Q1, Len - 1, [Msg | Acc]) + end. + +next_cont(_Acc, false) -> + end_of_data; +next_cont([#message{id = Id} | _Acc], _HasNext) -> + Id; +next_cont([], _HasNext) -> + end_of_data. + to_list(MQ, Acc) -> case out(MQ) of {empty, _MQ} -> diff --git a/apps/emqx/src/emqx_os_mon.erl b/apps/emqx/src/emqx_os_mon.erl index 1cc1e8469..6c5d9843b 100644 --- a/apps/emqx/src/emqx_os_mon.erl +++ b/apps/emqx/src/emqx_os_mon.erl @@ -18,6 +18,7 @@ -behaviour(gen_server). +-include("emqx.hrl"). -include("logger.hrl"). -export([start_link/0]). @@ -47,8 +48,6 @@ ]). -export([is_os_check_supported/0]). --include("emqx.hrl"). - -define(OS_MON, ?MODULE). start_link() -> @@ -92,6 +91,8 @@ handle_continue(setup, undefined) -> SysHW = init_os_monitor(), MemRef = start_mem_check_timer(), CpuRef = start_cpu_check_timer(), + %% the value of the first call should be regarded as garbage. + _Val = cpu_sup:util(), {noreply, #{sysmem_high_watermark => SysHW, mem_time_ref => MemRef, cpu_time_ref => CpuRef}}. init_os_monitor() -> @@ -131,7 +132,7 @@ handle_info({timeout, _Timer, mem_check}, #{sysmem_high_watermark := HWM} = Stat handle_info({timeout, _Timer, cpu_check}, State) -> CPUHighWatermark = emqx:get_config([sysmon, os, cpu_high_watermark]) * 100, CPULowWatermark = emqx:get_config([sysmon, os, cpu_low_watermark]) * 100, - CPUVal = emqx_vm:cpu_util(), + CPUVal = cpu_sup:util(), case CPUVal of %% 0 or 0.0 Busy when Busy == 0 -> diff --git a/apps/emqx/src/emqx_persistent_message.erl b/apps/emqx/src/emqx_persistent_message.erl index 9787dfd9a..36ad8a3df 100644 --- a/apps/emqx/src/emqx_persistent_message.erl +++ b/apps/emqx/src/emqx_persistent_message.erl @@ -16,11 +16,16 @@ -module(emqx_persistent_message). +-behaviour(emqx_config_handler). + -include("emqx.hrl"). -export([init/0]). -export([is_persistence_enabled/0, force_ds/0]). +%% Config handler +-export([add_handler/0, pre_config_update/3]). + %% Message persistence -export([ persist/1 @@ -66,6 +71,19 @@ storage_backend(Path) -> %%-------------------------------------------------------------------- +-spec add_handler() -> ok. +add_handler() -> + emqx_config_handler:add_handler([session_persistence], ?MODULE). + +pre_config_update([session_persistence], #{<<"enable">> := New}, #{<<"enable">> := Old}) when + New =/= Old +-> + {error, "Hot update of session_persistence.enable parameter is currently not supported"}; +pre_config_update(_Root, _NewConf, _OldConf) -> + ok. + +%%-------------------------------------------------------------------- + -spec persist(emqx_types:message()) -> ok | {skipped, _Reason} | {error, _TODO}. persist(Msg) -> diff --git a/apps/emqx/src/emqx_persistent_session_ds.erl b/apps/emqx/src/emqx_persistent_session_ds.erl index bb3c78b48..8cf3cb284 100644 --- a/apps/emqx/src/emqx_persistent_session_ds.erl +++ b/apps/emqx/src/emqx_persistent_session_ds.erl @@ -123,7 +123,12 @@ -define(TIMER_PULL, timer_pull). -define(TIMER_GET_STREAMS, timer_get_streams). -define(TIMER_BUMP_LAST_ALIVE_AT, timer_bump_last_alive_at). --type timer() :: ?TIMER_PULL | ?TIMER_GET_STREAMS | ?TIMER_BUMP_LAST_ALIVE_AT. +-define(TIMER_RETRY_REPLAY, timer_retry_replay). + +-type timer() :: ?TIMER_PULL | ?TIMER_GET_STREAMS | ?TIMER_BUMP_LAST_ALIVE_AT | ?TIMER_RETRY_REPLAY. + +%% TODO: Needs configuration? +-define(TIMEOUT_RETRY_REPLAY, 1000). -type session() :: #{ %% Client ID @@ -134,10 +139,15 @@ s := emqx_persistent_session_ds_state:t(), %% Buffer: inflight := emqx_persistent_session_ds_inflight:t(), + %% In-progress replay: + %% List of stream replay states to be added to the inflight buffer. + replay => [{_StreamKey, stream_state()}, ...], %% Timers: timer() => reference() }. +-define(IS_REPLAY_ONGOING(SESS), is_map_key(replay, SESS)). + -record(req_sync, { from :: pid(), ref :: reference() @@ -450,12 +460,14 @@ deliver(ClientInfo, Delivers, Session0) -> -spec handle_timeout(clientinfo(), _Timeout, session()) -> {ok, replies(), session()} | {ok, replies(), timeout(), session()}. -handle_timeout( - ClientInfo, - ?TIMER_PULL, - Session0 -) -> - {Publishes, Session1} = drain_buffer(fetch_new_messages(Session0, ClientInfo)), +handle_timeout(ClientInfo, ?TIMER_PULL, Session0) -> + {Publishes, Session1} = + case ?IS_REPLAY_ONGOING(Session0) of + false -> + drain_buffer(fetch_new_messages(Session0, ClientInfo)); + true -> + {[], Session0} + end, Timeout = case Publishes of [] -> @@ -465,6 +477,9 @@ handle_timeout( end, Session = emqx_session:ensure_timer(?TIMER_PULL, Timeout, Session1), {ok, Publishes, Session}; +handle_timeout(ClientInfo, ?TIMER_RETRY_REPLAY, Session0) -> + Session = replay_streams(Session0, ClientInfo), + {ok, [], Session}; handle_timeout(_ClientInfo, ?TIMER_GET_STREAMS, Session0 = #{s := S0}) -> S1 = emqx_persistent_session_ds_subs:gc(S0), S = emqx_persistent_session_ds_stream_scheduler:renew_streams(S1), @@ -503,30 +518,47 @@ bump_last_alive(S0) -> {ok, replies(), session()}. replay(ClientInfo, [], Session0 = #{s := S0}) -> Streams = emqx_persistent_session_ds_stream_scheduler:find_replay_streams(S0), - Session = lists:foldl( - fun({_StreamKey, Stream}, SessionAcc) -> - replay_batch(Stream, SessionAcc, ClientInfo) - end, - Session0, - Streams - ), + Session = replay_streams(Session0#{replay => Streams}, ClientInfo), + {ok, [], Session}. + +replay_streams(Session0 = #{replay := [{_StreamKey, Srs0} | Rest]}, ClientInfo) -> + case replay_batch(Srs0, Session0, ClientInfo) of + Session = #{} -> + replay_streams(Session#{replay := Rest}, ClientInfo); + {error, recoverable, Reason} -> + RetryTimeout = ?TIMEOUT_RETRY_REPLAY, + ?SLOG(warning, #{ + msg => "failed_to_fetch_replay_batch", + stream => Srs0, + reason => Reason, + class => recoverable, + retry_in_ms => RetryTimeout + }), + emqx_session:ensure_timer(?TIMER_RETRY_REPLAY, RetryTimeout, Session0) + %% TODO: Handle unrecoverable errors. + end; +replay_streams(Session0 = #{replay := []}, _ClientInfo) -> + Session = maps:remove(replay, Session0), %% Note: we filled the buffer with the historical messages, and %% from now on we'll rely on the normal inflight/flow control %% mechanisms to replay them: - {ok, [], pull_now(Session)}. + pull_now(Session). --spec replay_batch(stream_state(), session(), clientinfo()) -> session(). -replay_batch(Srs0, Session, ClientInfo) -> +-spec replay_batch(stream_state(), session(), clientinfo()) -> session() | emqx_ds:error(_). +replay_batch(Srs0, Session0, ClientInfo) -> #srs{batch_size = BatchSize} = Srs0, - %% TODO: retry on errors: - {Srs, Inflight} = enqueue_batch(true, BatchSize, Srs0, Session, ClientInfo), - %% Assert: - Srs =:= Srs0 orelse - ?tp(warning, emqx_persistent_session_ds_replay_inconsistency, #{ - expected => Srs0, - got => Srs - }), - Session#{inflight => Inflight}. + case enqueue_batch(true, BatchSize, Srs0, Session0, ClientInfo) of + {ok, Srs, Session} -> + %% Assert: + Srs =:= Srs0 orelse + ?tp(warning, emqx_persistent_session_ds_replay_inconsistency, #{ + expected => Srs0, + got => Srs + }), + Session; + {error, _, _} = Error -> + Error + end. %%-------------------------------------------------------------------- @@ -746,7 +778,7 @@ fetch_new_messages([I | Streams], Session0 = #{inflight := Inflight}, ClientInfo fetch_new_messages(Streams, Session, ClientInfo) end. -new_batch({StreamKey, Srs0}, BatchSize, Session = #{s := S0}, ClientInfo) -> +new_batch({StreamKey, Srs0}, BatchSize, Session0 = #{s := S0}, ClientInfo) -> SN1 = emqx_persistent_session_ds_state:get_seqno(?next(?QOS_1), S0), SN2 = emqx_persistent_session_ds_state:get_seqno(?next(?QOS_2), S0), Srs1 = Srs0#srs{ @@ -756,11 +788,30 @@ new_batch({StreamKey, Srs0}, BatchSize, Session = #{s := S0}, ClientInfo) -> last_seqno_qos1 = SN1, last_seqno_qos2 = SN2 }, - {Srs, Inflight} = enqueue_batch(false, BatchSize, Srs1, Session, ClientInfo), - S1 = emqx_persistent_session_ds_state:put_seqno(?next(?QOS_1), Srs#srs.last_seqno_qos1, S0), - S2 = emqx_persistent_session_ds_state:put_seqno(?next(?QOS_2), Srs#srs.last_seqno_qos2, S1), - S = emqx_persistent_session_ds_state:put_stream(StreamKey, Srs, S2), - Session#{s => S, inflight => Inflight}. + case enqueue_batch(false, BatchSize, Srs1, Session0, ClientInfo) of + {ok, Srs, Session} -> + S1 = emqx_persistent_session_ds_state:put_seqno( + ?next(?QOS_1), + Srs#srs.last_seqno_qos1, + S0 + ), + S2 = emqx_persistent_session_ds_state:put_seqno( + ?next(?QOS_2), + Srs#srs.last_seqno_qos2, + S1 + ), + S = emqx_persistent_session_ds_state:put_stream(StreamKey, Srs, S2), + Session#{s => S}; + {error, Class, Reason} -> + %% TODO: Handle unrecoverable error. + ?SLOG(info, #{ + msg => "failed_to_fetch_batch", + stream => Srs1, + reason => Reason, + class => Class + }), + Session0 + end. enqueue_batch(IsReplay, BatchSize, Srs0, Session = #{inflight := Inflight0}, ClientInfo) -> #srs{ @@ -789,13 +840,13 @@ enqueue_batch(IsReplay, BatchSize, Srs0, Session = #{inflight := Inflight0}, Cli last_seqno_qos1 = LastSeqnoQos1, last_seqno_qos2 = LastSeqnoQos2 }, - {Srs, Inflight}; + {ok, Srs, Session#{inflight := Inflight}}; {ok, end_of_stream} -> %% No new messages; just update the end iterator: - {Srs0#srs{it_begin = ItBegin, it_end = end_of_stream, batch_size = 0}, Inflight0}; - {error, _} when not IsReplay -> - ?SLOG(info, #{msg => "failed_to_fetch_batch", iterator => ItBegin}), - {Srs0, Inflight0} + Srs = Srs0#srs{it_begin = ItBegin, it_end = end_of_stream, batch_size = 0}, + {ok, Srs, Session#{inflight := Inflight0}}; + {error, _, _} = Error -> + Error end. %% key_of_iter(#{3 := #{3 := #{5 := K}}}) -> diff --git a/apps/emqx/src/emqx_rpc.erl b/apps/emqx/src/emqx_rpc.erl index e6ce5002a..61aa2a8ca 100644 --- a/apps/emqx/src/emqx_rpc.erl +++ b/apps/emqx/src/emqx_rpc.erl @@ -35,6 +35,7 @@ -export_type([ badrpc/0, + call_result/1, call_result/0, cast_result/0, multicall_result/1, diff --git a/apps/emqx/src/emqx_session.erl b/apps/emqx/src/emqx_session.erl index a84ed4d83..de9af5388 100644 --- a/apps/emqx/src/emqx_session.erl +++ b/apps/emqx/src/emqx_session.erl @@ -527,7 +527,7 @@ info(Session) -> -spec info ([atom()], t()) -> [{atom(), _Value}]; - (atom(), t()) -> _Value. + (atom() | {atom(), _Meta}, t()) -> _Value. info(Keys, Session) when is_list(Keys) -> [{Key, info(Key, Session)} || Key <- Keys]; info(impl, Session) -> diff --git a/apps/emqx/src/emqx_session_mem.erl b/apps/emqx/src/emqx_session_mem.erl index e5e60583f..dbb440f41 100644 --- a/apps/emqx/src/emqx_session_mem.erl +++ b/apps/emqx/src/emqx_session_mem.erl @@ -268,6 +268,9 @@ info(inflight_cnt, #session{inflight = Inflight}) -> emqx_inflight:size(Inflight); info(inflight_max, #session{inflight = Inflight}) -> emqx_inflight:max_size(Inflight); +info({inflight_msgs, PagerParams}, #session{inflight = Inflight}) -> + {InflightList, Meta} = emqx_inflight:query(Inflight, PagerParams), + {[I#inflight_data.message || {_, I} <- InflightList], Meta}; info(retry_interval, #session{retry_interval = Interval}) -> Interval; info(mqueue, #session{mqueue = MQueue}) -> @@ -278,6 +281,8 @@ info(mqueue_max, #session{mqueue = MQueue}) -> emqx_mqueue:max_len(MQueue); info(mqueue_dropped, #session{mqueue = MQueue}) -> emqx_mqueue:dropped(MQueue); +info({mqueue_msgs, PagerParams}, #session{mqueue = MQueue}) -> + emqx_mqueue:query(MQueue, PagerParams); info(next_pkt_id, #session{next_pkt_id = PacketId}) -> PacketId; info(awaiting_rel, #session{awaiting_rel = AwaitingRel}) -> diff --git a/apps/emqx/src/emqx_shared_sub.erl b/apps/emqx/src/emqx_shared_sub.erl index ebdfff0e7..ce694ba33 100644 --- a/apps/emqx/src/emqx_shared_sub.erl +++ b/apps/emqx/src/emqx_shared_sub.erl @@ -435,7 +435,7 @@ handle_call({unsubscribe, Group, Topic, SubPid}, _From, State) -> true = ets:delete_object(?SHARED_SUBS, {{Group, Topic}, SubPid}), delete_route_if_needed({Group, Topic}), maybe_delete_round_robin_count({Group, Topic}), - {reply, ok, State}; + {reply, ok, update_stats(State)}; handle_call(Req, _From, State) -> ?SLOG(error, #{msg => "unexpected_call", req => Req}), {reply, ignored, State}. diff --git a/apps/emqx/src/emqx_sys_mon.erl b/apps/emqx/src/emqx_sys_mon.erl index 9d0bf37bf..f41febeb6 100644 --- a/apps/emqx/src/emqx_sys_mon.erl +++ b/apps/emqx/src/emqx_sys_mon.erl @@ -58,8 +58,8 @@ remove_handler() -> post_config_update(_, _Req, NewConf, OldConf, _AppEnvs) -> #{os := OS1, vm := VM1} = OldConf, #{os := OS2, vm := VM2} = NewConf, - VM1 =/= VM2 andalso ?MODULE:update(VM2), - OS1 =/= OS2 andalso emqx_os_mon:update(OS2), + (VM1 =/= VM2) andalso ?MODULE:update(VM2), + (OS1 =/= OS2) andalso emqx_os_mon:update(OS2), ok. update(VM) -> diff --git a/apps/emqx/src/emqx_sys_sup.erl b/apps/emqx/src/emqx_sys_sup.erl index 8055d6d56..9bcc655b8 100644 --- a/apps/emqx/src/emqx_sys_sup.erl +++ b/apps/emqx/src/emqx_sys_sup.erl @@ -28,7 +28,7 @@ start_link() -> init([]) -> OsMon = case emqx_os_mon:is_os_check_supported() of - true -> [child_spec(emqx_os_mon)]; + true -> [child_spec(emqx_os_mon), child_spec(emqx_cpu_sup_worker)]; false -> [] end, Children = diff --git a/apps/emqx/src/emqx_vm.erl b/apps/emqx/src/emqx_vm.erl index 2ca58b6d2..5877ab184 100644 --- a/apps/emqx/src/emqx_vm.erl +++ b/apps/emqx/src/emqx_vm.erl @@ -16,6 +16,8 @@ -module(emqx_vm). +-include("logger.hrl"). + -export([ schedulers/0, scheduler_usage/1, @@ -376,28 +378,29 @@ avg15() -> compat_windows(fun cpu_sup:avg15/0). cpu_util() -> - compat_windows(fun cpu_sup:util/0). + compat_windows(fun() -> emqx_cpu_sup_worker:cpu_util() end). cpu_util(Args) -> - compat_windows(fun cpu_sup:util/1, Args). + compat_windows(fun() -> emqx_cpu_sup_worker:cpu_util(Args) end). +-spec compat_windows(function()) -> any(). +compat_windows(Fun) when is_function(Fun, 0) -> + case emqx_os_mon:is_os_check_supported() of + true -> + try Fun() of + Val when is_float(Val) -> floor(Val * 100) / 100; + Val when is_number(Val) -> Val; + Val when is_tuple(Val) -> Val; + _ -> 0.0 + catch + _:_ -> 0.0 + end; + false -> + 0.0 + end; compat_windows(Fun) -> - case compat_windows(Fun, []) of - Val when is_float(Val) -> floor(Val * 100) / 100; - Val when is_number(Val) -> Val; - _ -> 0.0 - end. - -compat_windows(Fun, Args) -> - try - case emqx_os_mon:is_os_check_supported() of - false -> 0.0; - true when Args =:= [] -> Fun(); - true -> Fun(Args) - end - catch - _:_ -> 0.0 - end. + ?SLOG(warning, "Invalid function: ~p", [Fun]), + error({badarg, Fun}). load(Avg) -> floor((Avg / 256) * 100) / 100. diff --git a/apps/emqx/test/emqx_common_test_helpers.erl b/apps/emqx/test/emqx_common_test_helpers.erl index a383e0b2c..7a25e925d 100644 --- a/apps/emqx/test/emqx_common_test_helpers.erl +++ b/apps/emqx/test/emqx_common_test_helpers.erl @@ -61,6 +61,7 @@ read_schema_configs/2, render_config_file/2, wait_for/4, + wait_publishes/2, wait_mqtt_payload/1, select_free_port/1 ]). @@ -426,6 +427,16 @@ wait_for(Fn, Ln, F, Timeout) -> {Pid, Mref} = erlang:spawn_monitor(fun() -> wait_loop(F, catch_call(F)) end), wait_for_down(Fn, Ln, Timeout, Pid, Mref, false). +wait_publishes(0, _Timeout) -> + []; +wait_publishes(Count, Timeout) -> + receive + {publish, Msg} -> + [Msg | wait_publishes(Count - 1, Timeout)] + after Timeout -> + [] + end. + flush() -> flush([]). diff --git a/apps/emqx/test/emqx_inflight_SUITE.erl b/apps/emqx/test/emqx_inflight_SUITE.erl index c3b7ca6fc..a220129af 100644 --- a/apps/emqx/test/emqx_inflight_SUITE.erl +++ b/apps/emqx/test/emqx_inflight_SUITE.erl @@ -116,5 +116,83 @@ t_window(_) -> ), ?assertEqual([a, b], emqx_inflight:window(Inflight)). -% t_to_list(_) -> -% error('TODO'). +t_to_list(_) -> + Inflight = lists:foldl( + fun(Seq, InflightAcc) -> + emqx_inflight:insert(Seq, integer_to_binary(Seq), InflightAcc) + end, + emqx_inflight:new(100), + [1, 6, 2, 3, 10, 7, 9, 8, 4, 5] + ), + ExpList = [{Seq, integer_to_binary(Seq)} || Seq <- lists:seq(1, 10)], + ?assertEqual(ExpList, emqx_inflight:to_list(Inflight)). + +t_query(_) -> + EmptyInflight = emqx_inflight:new(500), + ?assertMatch( + {[], #{continuation := end_of_data}}, emqx_inflight:query(EmptyInflight, #{limit => 50}) + ), + ?assertMatch( + {[], #{continuation := end_of_data}}, + emqx_inflight:query(EmptyInflight, #{continuation => <<"empty">>, limit => 50}) + ), + ?assertMatch( + {[], #{continuation := end_of_data}}, + emqx_inflight:query(EmptyInflight, #{continuation => none, limit => 50}) + ), + + Inflight = lists:foldl( + fun(Seq, QAcc) -> + emqx_inflight:insert(Seq, integer_to_binary(Seq), QAcc) + end, + EmptyInflight, + lists:reverse(lists:seq(1, 114)) + ), + + LastCont = lists:foldl( + fun(PageSeq, Cont) -> + Limit = 10, + PagerParams = #{continuation => Cont, limit => Limit}, + {Page, #{continuation := NextCont} = Meta} = emqx_inflight:query(Inflight, PagerParams), + ?assertEqual(10, length(Page)), + ExpFirst = PageSeq * Limit - Limit + 1, + ExpLast = PageSeq * Limit, + ?assertEqual({ExpFirst, integer_to_binary(ExpFirst)}, lists:nth(1, Page)), + ?assertEqual({ExpLast, integer_to_binary(ExpLast)}, lists:nth(10, Page)), + ?assertMatch( + #{count := 114, continuation := IntCont} when is_integer(IntCont), + Meta + ), + NextCont + end, + none, + lists:seq(1, 11) + ), + {LastPartialPage, LastMeta} = emqx_inflight:query(Inflight, #{ + continuation => LastCont, limit => 10 + }), + ?assertEqual(4, length(LastPartialPage)), + ?assertEqual({111, <<"111">>}, lists:nth(1, LastPartialPage)), + ?assertEqual({114, <<"114">>}, lists:nth(4, LastPartialPage)), + ?assertMatch(#{continuation := end_of_data, count := 114}, LastMeta), + + ?assertMatch( + {[], #{continuation := end_of_data}}, + emqx_inflight:query(Inflight, #{continuation => <<"not-existing-cont-id">>, limit => 10}) + ), + + {LargePage, LargeMeta} = emqx_inflight:query(Inflight, #{limit => 1000}), + ?assertEqual(114, length(LargePage)), + ?assertEqual({1, <<"1">>}, hd(LargePage)), + ?assertEqual({114, <<"114">>}, lists:last(LargePage)), + ?assertMatch(#{continuation := end_of_data}, LargeMeta), + + {FullPage, FullMeta} = emqx_inflight:query(Inflight, #{limit => 114}), + ?assertEqual(114, length(FullPage)), + ?assertEqual({1, <<"1">>}, hd(FullPage)), + ?assertEqual({114, <<"114">>}, lists:last(FullPage)), + ?assertMatch(#{continuation := end_of_data}, FullMeta), + + {EmptyPage, EmptyMeta} = emqx_inflight:query(Inflight, #{limit => 0}), + ?assertEqual([], EmptyPage), + ?assertMatch(#{continuation := none, count := 114}, EmptyMeta). diff --git a/apps/emqx/test/emqx_mqueue_SUITE.erl b/apps/emqx/test/emqx_mqueue_SUITE.erl index 51db4b98a..f3e1629a7 100644 --- a/apps/emqx/test/emqx_mqueue_SUITE.erl +++ b/apps/emqx/test/emqx_mqueue_SUITE.erl @@ -282,6 +282,74 @@ t_dropped(_) -> {Msg, Q2} = ?Q:in(Msg, Q1), ?assertEqual(1, ?Q:dropped(Q2)). +t_query(_) -> + EmptyQ = ?Q:init(#{max_len => 500, store_qos0 => true}), + ?assertMatch({[], #{continuation := end_of_data}}, ?Q:query(EmptyQ, #{limit => 50})), + ?assertMatch( + {[], #{continuation := end_of_data}}, + ?Q:query(EmptyQ, #{continuation => <<"empty">>, limit => 50}) + ), + ?assertMatch( + {[], #{continuation := end_of_data}}, ?Q:query(EmptyQ, #{continuation => none, limit => 50}) + ), + + Q = lists:foldl( + fun(Seq, QAcc) -> + Msg = emqx_message:make(<<"t">>, integer_to_binary(Seq)), + {_, QAcc1} = ?Q:in(Msg, QAcc), + QAcc1 + end, + EmptyQ, + lists:seq(1, 114) + ), + + LastCont = lists:foldl( + fun(PageSeq, Cont) -> + Limit = 10, + PagerParams = #{continuation => Cont, limit => Limit}, + {Page, #{continuation := NextCont} = Meta} = ?Q:query(Q, PagerParams), + ?assertEqual(10, length(Page)), + ExpFirstPayload = integer_to_binary(PageSeq * Limit - Limit + 1), + ExpLastPayload = integer_to_binary(PageSeq * Limit), + ?assertEqual( + ExpFirstPayload, + emqx_message:payload(lists:nth(1, Page)), + #{page_seq => PageSeq, page => Page, meta => Meta} + ), + ?assertEqual(ExpLastPayload, emqx_message:payload(lists:nth(10, Page))), + ?assertMatch(#{count := 114, continuation := <<_/binary>>}, Meta), + NextCont + end, + none, + lists:seq(1, 11) + ), + {LastPartialPage, LastMeta} = ?Q:query(Q, #{continuation => LastCont, limit => 10}), + ?assertEqual(4, length(LastPartialPage)), + ?assertEqual(<<"111">>, emqx_message:payload(lists:nth(1, LastPartialPage))), + ?assertEqual(<<"114">>, emqx_message:payload(lists:nth(4, LastPartialPage))), + ?assertMatch(#{continuation := end_of_data, count := 114}, LastMeta), + + ?assertMatch( + {[], #{continuation := end_of_data}}, + ?Q:query(Q, #{continuation => <<"not-existing-cont-id">>, limit => 10}) + ), + + {LargePage, LargeMeta} = ?Q:query(Q, #{limit => 1000}), + ?assertEqual(114, length(LargePage)), + ?assertEqual(<<"1">>, emqx_message:payload(hd(LargePage))), + ?assertEqual(<<"114">>, emqx_message:payload(lists:last(LargePage))), + ?assertMatch(#{continuation := end_of_data}, LargeMeta), + + {FullPage, FullMeta} = ?Q:query(Q, #{limit => 114}), + ?assertEqual(114, length(FullPage)), + ?assertEqual(<<"1">>, emqx_message:payload(hd(FullPage))), + ?assertEqual(<<"114">>, emqx_message:payload(lists:last(FullPage))), + ?assertMatch(#{continuation := end_of_data}, FullMeta), + + {EmptyPage, EmptyMeta} = ?Q:query(Q, #{limit => 0}), + ?assertEqual([], EmptyPage), + ?assertMatch(#{continuation := none, count := 114}, EmptyMeta). + conservation_prop() -> ?FORALL( {Priorities, Messages}, diff --git a/apps/emqx/test/emqx_os_mon_SUITE.erl b/apps/emqx/test/emqx_os_mon_SUITE.erl index 01220ee50..a458cea38 100644 --- a/apps/emqx/test/emqx_os_mon_SUITE.erl +++ b/apps/emqx/test/emqx_os_mon_SUITE.erl @@ -132,7 +132,8 @@ do_sys_mem_check_alarm(_Config) -> get_memory_usage, fun() -> Mem end, fun() -> - timer:sleep(500), + %% wait for `os_mon` started + timer:sleep(10_000), Alarms = emqx_alarm:get_alarms(activated), ?assert( emqx_vm_mon_SUITE:is_existing( diff --git a/apps/emqx/test/emqx_vm_SUITE.erl b/apps/emqx/test/emqx_vm_SUITE.erl index 5312c7983..e74f14316 100644 --- a/apps/emqx/test/emqx_vm_SUITE.erl +++ b/apps/emqx/test/emqx_vm_SUITE.erl @@ -21,7 +21,8 @@ -include_lib("eunit/include/eunit.hrl"). -all() -> emqx_common_test_helpers:all(?MODULE). +all() -> + emqx_common_test_helpers:all(?MODULE). t_load(_Config) -> lists:foreach( @@ -97,7 +98,7 @@ t_get_process_limit(_Config) -> emqx_vm:get_process_limit(). t_cpu_util(_Config) -> - _Cpu = emqx_vm:cpu_util(). + ?assertMatch(Val when is_number(Val), emqx_vm:cpu_util()). easy_server() -> {ok, LSock} = gen_tcp:listen(5678, [binary, {packet, 0}, {active, false}]), diff --git a/apps/emqx_bridge/src/emqx_bridge_api.erl b/apps/emqx_bridge/src/emqx_bridge_api.erl index 69b17a843..5a862c492 100644 --- a/apps/emqx_bridge/src/emqx_bridge_api.erl +++ b/apps/emqx_bridge/src/emqx_bridge_api.erl @@ -764,7 +764,7 @@ is_bridge_enabled_v1(BridgeType, BridgeName) -> %% we read from the translated config because the defaults are populated here. try emqx:get_config([bridges, BridgeType, binary_to_existing_atom(BridgeName)]) of ConfMap -> - maps:get(enable, ConfMap, false) + maps:get(enable, ConfMap, true) catch error:{config_not_found, _} -> throw(not_found); diff --git a/apps/emqx_bridge/src/emqx_bridge_v2_api.erl b/apps/emqx_bridge/src/emqx_bridge_v2_api.erl index 92c0b43a0..a7bef1952 100644 --- a/apps/emqx_bridge/src/emqx_bridge_v2_api.erl +++ b/apps/emqx_bridge/src/emqx_bridge_v2_api.erl @@ -126,8 +126,8 @@ paths() -> %% %% try to match the latter first, trying to interpret `metrics' as an operation... "/sources/:id/metrics", "/sources/:id/metrics/reset", - "/sources_probe" - %% "/source_types" + "/sources_probe", + "/source_types" ]. error_schema(Code, Message) -> @@ -639,16 +639,16 @@ schema("/source_types") -> 'operationId' => '/source_types', get => #{ tags => [<<"sources">>], - desc => ?DESC("desc_api10"), + desc => ?DESC("desc_api11"), summary => <<"List available source types">>, responses => #{ 200 => emqx_dashboard_swagger:schema_with_examples( - array(emqx_bridge_v2_schema:action_types_sc()), + array(emqx_bridge_v2_schema:source_types_sc()), #{ <<"types">> => #{ summary => <<"Source types">>, - value => emqx_bridge_v2_schema:action_types() + value => emqx_bridge_v2_schema:source_types() } } ) @@ -990,7 +990,7 @@ call_operation_if_enabled(NodeOrAll, OperFunc, [Nodes, ConfRootKey, BridgeType, is_enabled_bridge(ConfRootKey, BridgeType, BridgeName) -> try emqx_bridge_v2:lookup(ConfRootKey, BridgeType, binary_to_existing_atom(BridgeName)) of {ok, #{raw_config := ConfMap}} -> - maps:get(<<"enable">>, ConfMap, false); + maps:get(<<"enable">>, ConfMap, true); {error, not_found} -> throw(not_found) catch diff --git a/apps/emqx_bridge/test/emqx_bridge_v2_testlib.erl b/apps/emqx_bridge/test/emqx_bridge_v2_testlib.erl index aa15b6eb7..8a781d6e7 100644 --- a/apps/emqx_bridge/test/emqx_bridge_v2_testlib.erl +++ b/apps/emqx_bridge/test/emqx_bridge_v2_testlib.erl @@ -458,6 +458,24 @@ probe_bridge_api(Kind, BridgeType, BridgeName, BridgeConfig) -> ct:pal("bridge probe (~s, http) result:\n ~p", [Kind, Res]), Res. +probe_connector_api(Config) -> + probe_connector_api(Config, _Overrides = #{}). + +probe_connector_api(Config, Overrides) -> + #{ + connector_type := Type, + connector_name := Name + } = get_common_values(Config), + ConnectorConfig0 = get_value(connector_config, Config), + ConnectorConfig1 = emqx_utils_maps:deep_merge(ConnectorConfig0, Overrides), + Params = ConnectorConfig1#{<<"type">> => Type, <<"name">> => Name}, + Path = emqx_mgmt_api_test_util:api_path(["connectors_probe"]), + ct:pal("probing connector (~s, http):\n ~p", [Type, Params]), + Method = post, + Res = request(Method, Path, Params), + ct:pal("probing connector (~s, http) result:\n ~p", [Type, Res]), + Res. + list_bridges_http_api_v1() -> Path = emqx_mgmt_api_test_util:api_path(["bridges"]), ct:pal("list bridges (http v1)"), diff --git a/apps/emqx_bridge_dynamo/src/emqx_bridge_dynamo_connector.erl b/apps/emqx_bridge_dynamo/src/emqx_bridge_dynamo_connector.erl index 4aad2bd97..36f54a63f 100644 --- a/apps/emqx_bridge_dynamo/src/emqx_bridge_dynamo_connector.erl +++ b/apps/emqx_bridge_dynamo/src/emqx_bridge_dynamo_connector.erl @@ -178,14 +178,34 @@ on_batch_query(InstanceId, [{_ChannelId, _} | _] = Query, State) -> on_batch_query(_InstanceId, Query, _State) -> {error, {unrecoverable_error, {invalid_request, Query}}}. -on_get_status(_InstanceId, #{pool_name := Pool}) -> - Health = emqx_resource_pool:health_check_workers( - Pool, {emqx_bridge_dynamo_connector_client, is_connected, []} - ), - status_result(Health). +health_check_timeout() -> + 2500. -status_result(_Status = true) -> ?status_connected; -status_result(_Status = false) -> ?status_connecting. +on_get_status(_InstanceId, #{pool_name := Pool} = State) -> + Health = emqx_resource_pool:health_check_workers( + Pool, + {emqx_bridge_dynamo_connector_client, is_connected, [ + health_check_timeout() + ]}, + health_check_timeout(), + #{return_values => true} + ), + case Health of + {error, timeout} -> + {?status_connecting, State, <<"timeout_while_checking_connection">>}; + {ok, Results} -> + status_result(Results, State) + end. + +status_result(Results, State) -> + case lists:filter(fun(Res) -> Res =/= true end, Results) of + [] when Results =:= [] -> + ?status_connecting; + [] -> + ?status_connected; + [{false, Error} | _] -> + {?status_connecting, State, Error} + end. %%======================================================================================== %% Helper fns diff --git a/apps/emqx_bridge_dynamo/src/emqx_bridge_dynamo_connector_client.erl b/apps/emqx_bridge_dynamo/src/emqx_bridge_dynamo_connector_client.erl index 622eaa382..4f924ef67 100644 --- a/apps/emqx_bridge_dynamo/src/emqx_bridge_dynamo_connector_client.erl +++ b/apps/emqx_bridge_dynamo/src/emqx_bridge_dynamo_connector_client.erl @@ -9,7 +9,7 @@ %% API -export([ start_link/1, - is_connected/1, + is_connected/2, query/4 ]). @@ -27,20 +27,17 @@ -export([execute/2]). -endif. -%% The default timeout for DynamoDB REST API calls is 10 seconds, -%% but this value for `gen_server:call` is 5s, -%% so we should pass the timeout to `gen_server:call` --define(HEALTH_CHECK_TIMEOUT, 10000). - %%%=================================================================== %%% API %%%=================================================================== -is_connected(Pid) -> +is_connected(Pid, Timeout) -> try - gen_server:call(Pid, is_connected, ?HEALTH_CHECK_TIMEOUT) + gen_server:call(Pid, is_connected, Timeout) catch - _:_ -> - false + _:{timeout, _} -> + {false, <<"timeout_while_checking_connection_dynamo_client">>}; + _:Error -> + {false, Error} end. query(Pid, Table, Query, Templates) -> @@ -76,8 +73,8 @@ handle_call(is_connected, _From, State) -> case erlcloud_ddb2:list_tables([{limit, 1}]) of {ok, _} -> true; - _ -> - false + Error -> + {false, Error} end, {reply, IsConnected, State}; handle_call({query, Table, Query, Templates}, _From, State) -> diff --git a/apps/emqx_bridge_dynamo/test/emqx_bridge_dynamo_SUITE.erl b/apps/emqx_bridge_dynamo/test/emqx_bridge_dynamo_SUITE.erl index 8fcdf97ce..dab7b21f0 100644 --- a/apps/emqx_bridge_dynamo/test/emqx_bridge_dynamo_SUITE.erl +++ b/apps/emqx_bridge_dynamo/test/emqx_bridge_dynamo_SUITE.erl @@ -88,7 +88,9 @@ init_per_suite(Config) -> end_per_suite(_Config) -> emqx_mgmt_api_test_util:end_suite(), - ok = emqx_common_test_helpers:stop_apps([emqx_bridge, emqx_resource, emqx_conf, erlcloud]), + ok = emqx_common_test_helpers:stop_apps([ + emqx_rule_engine, emqx_bridge, emqx_resource, emqx_conf, erlcloud + ]), ok. init_per_testcase(TestCase, Config) -> @@ -134,7 +136,7 @@ common_init(ConfigT) -> emqx_common_test_helpers:reset_proxy(ProxyHost, ProxyPort), % Ensure enterprise bridge module is loaded ok = emqx_common_test_helpers:start_apps([ - emqx_conf, emqx_resource, emqx_bridge + emqx_conf, emqx_resource, emqx_bridge, emqx_rule_engine ]), _ = application:ensure_all_started(erlcloud), _ = emqx_bridge_enterprise:module_info(), @@ -273,6 +275,24 @@ create_bridge_http(Params) -> Error -> Error end. +update_bridge_http(#{<<"type">> := Type, <<"name">> := Name} = Config) -> + BridgeID = emqx_bridge_resource:bridge_id(Type, Name), + Path = emqx_mgmt_api_test_util:api_path(["bridges", BridgeID]), + AuthHeader = emqx_mgmt_api_test_util:auth_header_(), + case emqx_mgmt_api_test_util:request_api(put, Path, "", AuthHeader, Config) of + {ok, Res} -> {ok, emqx_utils_json:decode(Res, [return_maps])}; + Error -> Error + end. + +get_bridge_http(#{<<"type">> := Type, <<"name">> := Name}) -> + BridgeID = emqx_bridge_resource:bridge_id(Type, Name), + Path = emqx_mgmt_api_test_util:api_path(["bridges", BridgeID]), + AuthHeader = emqx_mgmt_api_test_util:auth_header_(), + case emqx_mgmt_api_test_util:request_api(get, Path, "", AuthHeader) of + {ok, Res} -> {ok, emqx_utils_json:decode(Res, [return_maps])}; + Error -> Error + end. + send_message(Config, Payload) -> Name = ?config(dynamo_name, Config), BridgeType = ?config(dynamo_bridge_type, Config), @@ -359,6 +379,33 @@ t_setup_via_config_and_publish(Config) -> ), ok. +%% https://emqx.atlassian.net/browse/EMQX-11984 +t_setup_via_http_api_and_update_wrong_config(Config) -> + BridgeType = ?config(dynamo_bridge_type, Config), + Name = ?config(dynamo_name, Config), + PgsqlConfig0 = ?config(dynamo_config, Config), + PgsqlConfig = PgsqlConfig0#{ + <<"name">> => Name, + <<"type">> => BridgeType, + %% NOTE: using literal secret with HTTP API requests. + <<"aws_secret_access_key">> => <> + }, + BrokenConfig = PgsqlConfig#{<<"url">> => <<"http://non_existing_host:9999">>}, + ?assertMatch( + {ok, _}, + create_bridge_http(BrokenConfig) + ), + WrongURL2 = <<"http://non_existing_host:9998">>, + BrokenConfig2 = PgsqlConfig#{<<"url">> => WrongURL2}, + ?assertMatch( + {ok, _}, + update_bridge_http(BrokenConfig2) + ), + %% Check that the update worked + {ok, Result} = get_bridge_http(PgsqlConfig), + ?assertMatch(#{<<"url">> := WrongURL2}, Result), + emqx_bridge:remove(BridgeType, Name). + t_setup_via_http_api_and_publish(Config) -> BridgeType = ?config(dynamo_bridge_type, Config), Name = ?config(dynamo_name, Config), diff --git a/apps/emqx_bridge_gcp_pubsub/src/emqx_bridge_gcp_pubsub_client.erl b/apps/emqx_bridge_gcp_pubsub/src/emqx_bridge_gcp_pubsub_client.erl index f27aab422..67218fcf0 100644 --- a/apps/emqx_bridge_gcp_pubsub/src/emqx_bridge_gcp_pubsub_client.erl +++ b/apps/emqx_bridge_gcp_pubsub/src/emqx_bridge_gcp_pubsub_client.erl @@ -198,13 +198,13 @@ get_status(#{connect_timeout := Timeout, pool_name := PoolName} = State) -> %%------------------------------------------------------------------------------------------------- -spec get_topic(topic(), state(), request_opts()) -> {ok, map()} | {error, term()}. -get_topic(Topic, ConnectorState, ReqOpts) -> - #{project_id := ProjectId} = ConnectorState, +get_topic(Topic, ClientState, ReqOpts) -> + #{project_id := ProjectId} = ClientState, Method = get, Path = <<"/v1/projects/", ProjectId/binary, "/topics/", Topic/binary>>, Body = <<>>, PreparedRequest = {prepared_request, {Method, Path, Body}, ReqOpts}, - ?MODULE:query_sync(PreparedRequest, ConnectorState). + ?MODULE:query_sync(PreparedRequest, ClientState). %%------------------------------------------------------------------------------------------------- %% Helper fns diff --git a/apps/emqx_bridge_gcp_pubsub/src/emqx_bridge_gcp_pubsub_impl_producer.erl b/apps/emqx_bridge_gcp_pubsub/src/emqx_bridge_gcp_pubsub_impl_producer.erl index 299b90226..13040dccf 100644 --- a/apps/emqx_bridge_gcp_pubsub/src/emqx_bridge_gcp_pubsub_impl_producer.erl +++ b/apps/emqx_bridge_gcp_pubsub/src/emqx_bridge_gcp_pubsub_impl_producer.erl @@ -186,10 +186,14 @@ on_batch_query_async(ResourceId, Requests, ReplyFunAndArgs, ConnectorState) -> {ok, connector_state()}. on_add_channel(_ConnectorResId, ConnectorState0, ActionId, ActionConfig) -> #{installed_actions := InstalledActions0} = ConnectorState0, - ChannelState = install_channel(ActionConfig), - InstalledActions = InstalledActions0#{ActionId => ChannelState}, - ConnectorState = ConnectorState0#{installed_actions := InstalledActions}, - {ok, ConnectorState}. + case install_channel(ActionConfig, ConnectorState0) of + {ok, ChannelState} -> + InstalledActions = InstalledActions0#{ActionId => ChannelState}, + ConnectorState = ConnectorState0#{installed_actions := InstalledActions}, + {ok, ConnectorState}; + Error = {error, _} -> + Error + end. -spec on_remove_channel( connector_resource_id(), @@ -218,8 +222,7 @@ on_get_channel_status(_ConnectorResId, _ChannelId, _ConnectorState) -> %% Helper fns %%------------------------------------------------------------------------------------------------- -%% TODO: check if topic exists ("unhealthy target") -install_channel(ActionConfig) -> +install_channel(ActionConfig, ConnectorState) -> #{ parameters := #{ attributes_template := AttributesTemplate, @@ -231,13 +234,27 @@ install_channel(ActionConfig) -> request_ttl := RequestTTL } } = ActionConfig, - #{ - attributes_template => preproc_attributes(AttributesTemplate), - ordering_key_template => emqx_placeholder:preproc_tmpl(OrderingKeyTemplate), - payload_template => emqx_placeholder:preproc_tmpl(PayloadTemplate), - pubsub_topic => PubSubTopic, - request_ttl => RequestTTL - }. + #{client := Client} = ConnectorState, + case + emqx_bridge_gcp_pubsub_client:get_topic(PubSubTopic, Client, #{request_ttl => RequestTTL}) + of + {error, #{status_code := 404}} -> + {error, {unhealthy_target, <<"Topic does not exist">>}}; + {error, #{status_code := 403}} -> + {error, {unhealthy_target, <<"Permission denied for topic">>}}; + {error, #{status_code := 401}} -> + {error, {unhealthy_target, <<"Bad credentials">>}}; + {error, Reason} -> + {error, Reason}; + {ok, _} -> + {ok, #{ + attributes_template => preproc_attributes(AttributesTemplate), + ordering_key_template => emqx_placeholder:preproc_tmpl(OrderingKeyTemplate), + payload_template => emqx_placeholder:preproc_tmpl(PayloadTemplate), + pubsub_topic => PubSubTopic, + request_ttl => RequestTTL + }} + end. -spec do_send_requests_sync( connector_state(), diff --git a/apps/emqx_bridge_gcp_pubsub/test/emqx_bridge_gcp_pubsub_producer_SUITE.erl b/apps/emqx_bridge_gcp_pubsub/test/emqx_bridge_gcp_pubsub_producer_SUITE.erl index 4acc5ff3c..6666a3fd0 100644 --- a/apps/emqx_bridge_gcp_pubsub/test/emqx_bridge_gcp_pubsub_producer_SUITE.erl +++ b/apps/emqx_bridge_gcp_pubsub/test/emqx_bridge_gcp_pubsub_producer_SUITE.erl @@ -76,6 +76,7 @@ only_sync_tests() -> [t_query_sync]. init_per_suite(Config) -> + emqx_common_test_helpers:clear_screen(), Apps = emqx_cth_suite:start( [ emqx, @@ -257,20 +258,31 @@ create_rule_and_action_http(Config) -> success_http_handler() -> TestPid = self(), fun(Req0, State) -> - {ok, Body, Req} = cowboy_req:read_body(Req0), - TestPid ! {http, cowboy_req:headers(Req), Body}, - Rep = cowboy_req:reply( - 200, - #{<<"content-type">> => <<"application/json">>}, - emqx_utils_json:encode(#{messageIds => [<<"6058891368195201">>]}), - Req - ), - {ok, Rep, State} + case {cowboy_req:method(Req0), cowboy_req:path(Req0)} of + {<<"GET">>, <<"/v1/projects/myproject/topics/", _/binary>>} -> + Rep = cowboy_req:reply( + 200, + #{<<"content-type">> => <<"application/json">>}, + <<"{}">>, + Req0 + ), + {ok, Rep, State}; + _ -> + {ok, Body, Req} = cowboy_req:read_body(Req0), + TestPid ! {http, cowboy_req:headers(Req), Body}, + Rep = cowboy_req:reply( + 200, + #{<<"content-type">> => <<"application/json">>}, + emqx_utils_json:encode(#{messageIds => [<<"6058891368195201">>]}), + Req + ), + {ok, Rep, State} + end end. start_echo_http_server() -> HTTPHost = "localhost", - HTTPPath = <<"/v1/projects/myproject/topics/mytopic:publish">>, + HTTPPath = '_', ServerSSLOpts = [ {verify, verify_none}, @@ -656,6 +668,20 @@ wait_n_events(TelemetryTable, ResourceId, NEvents, Timeout, EventName) -> error({timeout_waiting_for_telemetry, EventName}) end. +kill_gun_process(EhttpcPid) -> + State = ehttpc:get_state(EhttpcPid, minimal), + GunPid = maps:get(client, State), + true = is_pid(GunPid), + _ = exit(GunPid, kill), + ok. + +kill_gun_processes(ConnectorResourceId) -> + Pool = ehttpc:workers(ConnectorResourceId), + Workers = lists:map(fun({_, Pid}) -> Pid end, Pool), + %% assert there is at least one pool member + ?assertMatch([_ | _], Workers), + lists:foreach(fun(Pid) -> kill_gun_process(Pid) end, Workers). + %%------------------------------------------------------------------------------ %% Testcases %%------------------------------------------------------------------------------ @@ -1343,15 +1369,26 @@ t_failure_with_body(Config) -> TestPid = self(), FailureWithBodyHandler = fun(Req0, State) -> - {ok, Body, Req} = cowboy_req:read_body(Req0), - TestPid ! {http, cowboy_req:headers(Req), Body}, - Rep = cowboy_req:reply( - 400, - #{<<"content-type">> => <<"application/json">>}, - emqx_utils_json:encode(#{}), - Req - ), - {ok, Rep, State} + case {cowboy_req:method(Req0), cowboy_req:path(Req0)} of + {<<"GET">>, <<"/v1/projects/myproject/topics/", _/binary>>} -> + Rep = cowboy_req:reply( + 200, + #{<<"content-type">> => <<"application/json">>}, + <<"{}">>, + Req0 + ), + {ok, Rep, State}; + _ -> + {ok, Body, Req} = cowboy_req:read_body(Req0), + TestPid ! {http, cowboy_req:headers(Req), Body}, + Rep = cowboy_req:reply( + 400, + #{<<"content-type">> => <<"application/json">>}, + emqx_utils_json:encode(#{}), + Req + ), + {ok, Rep, State} + end end, ok = emqx_bridge_http_connector_test_server:set_handler(FailureWithBodyHandler), Topic = <<"t/topic">>, @@ -1381,15 +1418,26 @@ t_failure_no_body(Config) -> TestPid = self(), FailureNoBodyHandler = fun(Req0, State) -> - {ok, Body, Req} = cowboy_req:read_body(Req0), - TestPid ! {http, cowboy_req:headers(Req), Body}, - Rep = cowboy_req:reply( - 400, - #{<<"content-type">> => <<"application/json">>}, - <<>>, - Req - ), - {ok, Rep, State} + case {cowboy_req:method(Req0), cowboy_req:path(Req0)} of + {<<"GET">>, <<"/v1/projects/myproject/topics/", _/binary>>} -> + Rep = cowboy_req:reply( + 200, + #{<<"content-type">> => <<"application/json">>}, + <<"{}">>, + Req0 + ), + {ok, Rep, State}; + _ -> + {ok, Body, Req} = cowboy_req:read_body(Req0), + TestPid ! {http, cowboy_req:headers(Req), Body}, + Rep = cowboy_req:reply( + 400, + #{<<"content-type">> => <<"application/json">>}, + <<>>, + Req + ), + {ok, Rep, State} + end end, ok = emqx_bridge_http_connector_test_server:set_handler(FailureNoBodyHandler), Topic = <<"t/topic">>, @@ -1415,20 +1463,6 @@ t_failure_no_body(Config) -> ), ok. -kill_gun_process(EhttpcPid) -> - State = ehttpc:get_state(EhttpcPid, minimal), - GunPid = maps:get(client, State), - true = is_pid(GunPid), - _ = exit(GunPid, kill), - ok. - -kill_gun_processes(ConnectorResourceId) -> - Pool = ehttpc:workers(ConnectorResourceId), - Workers = lists:map(fun({_, Pid}) -> Pid end, Pool), - %% assert there is at least one pool member - ?assertMatch([_ | _], Workers), - lists:foreach(fun(Pid) -> kill_gun_process(Pid) end, Workers). - t_unrecoverable_error(Config) -> ActionResourceId = ?config(action_resource_id, Config), ConnectorResourceId = ?config(connector_resource_id, Config), @@ -1436,19 +1470,30 @@ t_unrecoverable_error(Config) -> TestPid = self(), FailureNoBodyHandler = fun(Req0, State) -> - {ok, Body, Req} = cowboy_req:read_body(Req0), - TestPid ! {http, cowboy_req:headers(Req), Body}, - %% kill the gun process while it's waiting for the - %% response so we provoke an `{error, _}' response from - %% ehttpc. - ok = kill_gun_processes(ConnectorResourceId), - Rep = cowboy_req:reply( - 200, - #{<<"content-type">> => <<"application/json">>}, - <<>>, - Req - ), - {ok, Rep, State} + case {cowboy_req:method(Req0), cowboy_req:path(Req0)} of + {<<"GET">>, <<"/v1/projects/myproject/topics/", _/binary>>} -> + Rep = cowboy_req:reply( + 200, + #{<<"content-type">> => <<"application/json">>}, + <<"{}">>, + Req0 + ), + {ok, Rep, State}; + _ -> + {ok, Body, Req} = cowboy_req:read_body(Req0), + TestPid ! {http, cowboy_req:headers(Req), Body}, + %% kill the gun process while it's waiting for the + %% response so we provoke an `{error, _}' response from + %% ehttpc. + ok = kill_gun_processes(ConnectorResourceId), + Rep = cowboy_req:reply( + 200, + #{<<"content-type">> => <<"application/json">>}, + <<>>, + Req + ), + {ok, Rep, State} + end end, ok = emqx_bridge_http_connector_test_server:set_handler(FailureNoBodyHandler), Topic = <<"t/topic">>, diff --git a/apps/emqx_bridge_gcp_pubsub/test/emqx_bridge_v2_gcp_pubsub_producer_SUITE.erl b/apps/emqx_bridge_gcp_pubsub/test/emqx_bridge_v2_gcp_pubsub_producer_SUITE.erl new file mode 100644 index 000000000..f2255c343 --- /dev/null +++ b/apps/emqx_bridge_gcp_pubsub/test/emqx_bridge_v2_gcp_pubsub_producer_SUITE.erl @@ -0,0 +1,215 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2022-2024 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- + +-module(emqx_bridge_v2_gcp_pubsub_producer_SUITE). + +-compile(nowarn_export_all). +-compile(export_all). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). +-include_lib("snabbkaffe/include/snabbkaffe.hrl"). + +-define(CONNECTOR_TYPE_BIN, <<"gcp_pubsub_producer">>). +-define(ACTION_TYPE_BIN, <<"gcp_pubsub_producer">>). + +%%------------------------------------------------------------------------------ +%% CT boilerplate +%%------------------------------------------------------------------------------ + +all() -> + emqx_common_test_helpers:all(?MODULE). + +init_per_suite(Config) -> + emqx_common_test_helpers:clear_screen(), + emqx_bridge_gcp_pubsub_consumer_SUITE:init_per_suite(Config). + +end_per_suite(Config) -> + emqx_bridge_gcp_pubsub_consumer_SUITE:end_per_suite(Config). + +init_per_testcase(TestCase, Config) -> + common_init_per_testcase(TestCase, Config). + +common_init_per_testcase(TestCase, Config0) -> + ct:timetrap(timer:seconds(60)), + ServiceAccountJSON = + #{<<"project_id">> := ProjectId} = + emqx_bridge_gcp_pubsub_utils:generate_service_account_json(), + UniqueNum = integer_to_binary(erlang:unique_integer()), + Name = <<(atom_to_binary(TestCase))/binary, UniqueNum/binary>>, + ConnectorConfig = connector_config(Name, ServiceAccountJSON), + PubsubTopic = Name, + ActionConfig = action_config(#{ + connector => Name, + parameters => #{pubsub_topic => PubsubTopic} + }), + Config = [ + {bridge_kind, action}, + {action_type, ?ACTION_TYPE_BIN}, + {action_name, Name}, + {action_config, ActionConfig}, + {connector_name, Name}, + {connector_type, ?CONNECTOR_TYPE_BIN}, + {connector_config, ConnectorConfig}, + {service_account_json, ServiceAccountJSON}, + {project_id, ProjectId}, + {pubsub_topic, PubsubTopic} + | Config0 + ], + ok = emqx_bridge_gcp_pubsub_consumer_SUITE:ensure_topic(Config, PubsubTopic), + Config. + +end_per_testcase(_Testcase, Config) -> + ProxyHost = ?config(proxy_host, Config), + ProxyPort = ?config(proxy_port, Config), + emqx_common_test_helpers:reset_proxy(ProxyHost, ProxyPort), + emqx_bridge_v2_testlib:delete_all_bridges_and_connectors(), + emqx_common_test_helpers:call_janitor(60_000), + ok = snabbkaffe:stop(), + ok. + +%%------------------------------------------------------------------------------ +%% Helper fns +%%------------------------------------------------------------------------------ + +connector_config(Name, ServiceAccountJSON) -> + InnerConfigMap0 = + #{ + <<"enable">> => true, + <<"tags">> => [<<"bridge">>], + <<"description">> => <<"my cool bridge">>, + <<"connect_timeout">> => <<"5s">>, + <<"pool_size">> => 8, + <<"pipelining">> => <<"100">>, + <<"max_retries">> => <<"2">>, + <<"service_account_json">> => ServiceAccountJSON, + <<"resource_opts">> => + #{ + <<"health_check_interval">> => <<"1s">>, + <<"start_after_created">> => true, + <<"start_timeout">> => <<"5s">> + } + }, + emqx_bridge_v2_testlib:parse_and_check_connector(?ACTION_TYPE_BIN, Name, InnerConfigMap0). + +action_config(Overrides0) -> + Overrides = emqx_utils_maps:binary_key_map(Overrides0), + CommonConfig = + #{ + <<"enable">> => true, + <<"connector">> => <<"please override">>, + <<"parameters">> => + #{ + <<"pubsub_topic">> => <<"please override">> + }, + <<"resource_opts">> => #{ + <<"batch_size">> => 1, + <<"batch_time">> => <<"0ms">>, + <<"buffer_mode">> => <<"memory_only">>, + <<"buffer_seg_bytes">> => <<"10MB">>, + <<"health_check_interval">> => <<"15s">>, + <<"inflight_window">> => 100, + <<"max_buffer_bytes">> => <<"256MB">>, + <<"metrics_flush_interval">> => <<"1s">>, + <<"query_mode">> => <<"sync">>, + <<"request_ttl">> => <<"45s">>, + <<"resume_interval">> => <<"15s">>, + <<"worker_pool_size">> => <<"1">> + } + }, + maps:merge(CommonConfig, Overrides). + +assert_persisted_service_account_json_is_binary(ConnectorName) -> + %% ensure cluster.hocon has a binary encoded json string as the value + {ok, Hocon} = hocon:files([application:get_env(emqx, cluster_hocon_file, undefined)]), + ?assertMatch( + Bin when is_binary(Bin), + emqx_utils_maps:deep_get( + [ + <<"connectors">>, + <<"gcp_pubsub_producer">>, + ConnectorName, + <<"service_account_json">> + ], + Hocon + ) + ), + ok. + +%%------------------------------------------------------------------------------ +%% Testcases +%%------------------------------------------------------------------------------ + +t_start_stop(Config) -> + ok = emqx_bridge_v2_testlib:t_start_stop(Config, gcp_pubsub_stop), + ok. + +t_create_via_http(Config) -> + ok = emqx_bridge_v2_testlib:t_create_via_http(Config), + ok. + +t_create_via_http_json_object_service_account(Config0) -> + %% After the config goes through the roundtrip with `hocon_tconf:check_plain', service + %% account json comes back as a binary even if the input is a json object. + ConnectorName = ?config(connector_name, Config0), + ConnConfig0 = ?config(connector_config, Config0), + Config1 = proplists:delete(connector_config, Config0), + ConnConfig1 = maps:update_with( + <<"service_account_json">>, + fun(X) -> + ?assert(is_binary(X), #{json => X}), + JSON = emqx_utils_json:decode(X, [return_maps]), + ?assert(is_map(JSON)), + JSON + end, + ConnConfig0 + ), + Config = [{connector_config, ConnConfig1} | Config1], + ok = emqx_bridge_v2_testlib:t_create_via_http(Config), + assert_persisted_service_account_json_is_binary(ConnectorName), + ok. + +%% Check that creating an action (V2) with a non-existent topic leads returns an error. +t_bad_topic(Config) -> + ?check_trace( + begin + %% Should it really be 201 here? + ?assertMatch( + {ok, {{_, 201, _}, _, #{}}}, + emqx_bridge_v2_testlib:create_bridge_api( + Config, + #{<<"parameters">> => #{<<"pubsub_topic">> => <<"i-dont-exist">>}} + ) + ), + #{ + kind := Kind, + type := Type, + name := Name + } = emqx_bridge_v2_testlib:get_common_values(Config), + ActionConfig0 = emqx_bridge_v2_testlib:get_value(action_config, Config), + ProbeRes = emqx_bridge_v2_testlib:probe_bridge_api( + Kind, + Type, + Name, + emqx_utils_maps:deep_merge( + ActionConfig0, + #{<<"parameters">> => #{<<"pubsub_topic">> => <<"i-dont-exist">>}} + ) + ), + ?assertMatch( + {error, {{_, 400, _}, _, _}}, + ProbeRes + ), + {error, {{_, 400, _}, _, #{<<"message">> := Msg}}} = ProbeRes, + ?assertMatch(match, re:run(Msg, <<"unhealthy_target">>, [{capture, none}]), #{ + msg => Msg + }), + ?assertMatch(match, re:run(Msg, <<"Topic does not exist">>, [{capture, none}]), #{ + msg => Msg + }), + ok + end, + [] + ), + ok. diff --git a/apps/emqx_bridge_hstreamdb/src/emqx_bridge_hstreamdb.erl b/apps/emqx_bridge_hstreamdb/src/emqx_bridge_hstreamdb.erl index d66077171..e4b5c9ed7 100644 --- a/apps/emqx_bridge_hstreamdb/src/emqx_bridge_hstreamdb.erl +++ b/apps/emqx_bridge_hstreamdb/src/emqx_bridge_hstreamdb.erl @@ -173,14 +173,16 @@ fields(action_parameters) -> {record_template, mk(binary(), #{default => <<"${payload}">>, desc => ?DESC("record_template")})}, {aggregation_pool_size, - mk(integer(), #{ + mk(pos_integer(), #{ default => ?DEFAULT_AGG_POOL_SIZE, desc => ?DESC("aggregation_pool_size") })}, {max_batches, - mk(integer(), #{default => ?DEFAULT_MAX_BATCHES, desc => ?DESC("max_batches")})}, + mk(pos_integer(), #{default => ?DEFAULT_MAX_BATCHES, desc => ?DESC("max_batches")})}, {writer_pool_size, - mk(integer(), #{default => ?DEFAULT_WRITER_POOL_SIZE, desc => ?DESC("writer_pool_size")})}, - {batch_size, mk(integer(), #{default => 100, desc => ?DESC("batch_size")})}, + mk(pos_integer(), #{ + default => ?DEFAULT_WRITER_POOL_SIZE, desc => ?DESC("writer_pool_size") + })}, + {batch_size, mk(pos_integer(), #{default => 100, desc => ?DESC("batch_size")})}, {batch_interval, mk(emqx_schema:timeout_duration_ms(), #{ default => ?DEFAULT_BATCH_INTERVAL_RAW, desc => ?DESC("batch_interval") diff --git a/apps/emqx_bridge_iotdb/src/emqx_bridge_iotdb_connector.erl b/apps/emqx_bridge_iotdb/src/emqx_bridge_iotdb_connector.erl index 58fd66fe1..612b5e10b 100644 --- a/apps/emqx_bridge_iotdb/src/emqx_bridge_iotdb_connector.erl +++ b/apps/emqx_bridge_iotdb/src/emqx_bridge_iotdb_connector.erl @@ -544,6 +544,8 @@ convert_int(Str) when is_binary(Str) -> _:_ -> convert_int(binary_to_float(Str)) end; +convert_int(null) -> + null; convert_int(undefined) -> null. @@ -556,6 +558,8 @@ convert_float(Str) when is_binary(Str) -> _:_ -> convert_float(binary_to_integer(Str)) end; +convert_float(null) -> + null; convert_float(undefined) -> null. diff --git a/apps/emqx_bridge_kafka/src/emqx_bridge_kafka.erl b/apps/emqx_bridge_kafka/src/emqx_bridge_kafka.erl index 84bbe01c1..f021eaa84 100644 --- a/apps/emqx_bridge_kafka/src/emqx_bridge_kafka.erl +++ b/apps/emqx_bridge_kafka/src/emqx_bridge_kafka.erl @@ -32,6 +32,9 @@ producer_opts/1 ]). +%% Internal export to be used in v2 schema +-export([consumer_topic_mapping_validator/1]). + -export([ kafka_connector_config_fields/0, kafka_producer_converter/2, diff --git a/apps/emqx_bridge_kafka/src/emqx_bridge_kafka_consumer_schema.erl b/apps/emqx_bridge_kafka/src/emqx_bridge_kafka_consumer_schema.erl index 266d3f7d9..b5b0224de 100644 --- a/apps/emqx_bridge_kafka/src/emqx_bridge_kafka_consumer_schema.erl +++ b/apps/emqx_bridge_kafka/src/emqx_bridge_kafka_consumer_schema.erl @@ -65,7 +65,7 @@ fields(source_parameters) -> type => hocon_schema:field_schema(Sc, type), required => false, default => [], - validator => fun(_) -> ok end, + validator => fun legacy_consumer_topic_mapping_validator/1, importance => ?IMPORTANCE_HIDDEN }, {Name, hocon_schema:override(Sc, Override)}; @@ -231,3 +231,9 @@ connector_example(put) -> start_timeout => <<"5s">> } }. + +legacy_consumer_topic_mapping_validator(_TopicMapping = []) -> + %% Can be (and should be, unless it has migrated from v1) empty in v2. + ok; +legacy_consumer_topic_mapping_validator(TopicMapping = [_ | _]) -> + emqx_bridge_kafka:consumer_topic_mapping_validator(TopicMapping). diff --git a/apps/emqx_bridge_kafka/src/emqx_bridge_kafka_impl_consumer.erl b/apps/emqx_bridge_kafka/src/emqx_bridge_kafka_impl_consumer.erl index 6cfcf7d5d..c4f66dfff 100644 --- a/apps/emqx_bridge_kafka/src/emqx_bridge_kafka_impl_consumer.erl +++ b/apps/emqx_bridge_kafka/src/emqx_bridge_kafka_impl_consumer.erl @@ -220,10 +220,17 @@ on_stop(ConnectorResId, State) -> -spec on_get_status(connector_resource_id(), connector_state()) -> ?status_connected | ?status_disconnected. -on_get_status(_ConnectorResId, _State = #{kafka_client_id := ClientID}) -> - case brod_sup:find_client(ClientID) of - [_Pid] -> ?status_connected; - _ -> ?status_disconnected +on_get_status(_ConnectorResId, State = #{kafka_client_id := ClientID}) -> + case whereis(ClientID) of + Pid when is_pid(Pid) -> + case check_client_connectivity(Pid) of + {Status, Reason} -> + {Status, State, Reason}; + Status -> + Status + end; + _ -> + ?status_disconnected end; on_get_status(_ConnectorResId, _State) -> ?status_disconnected. @@ -631,6 +638,39 @@ is_dry_run(ConnectorResId) -> string:equal(TestIdStart, ConnectorResId) end. +-spec check_client_connectivity(pid()) -> + ?status_connected + | ?status_disconnected + | {?status_disconnected, term()}. +check_client_connectivity(ClientPid) -> + %% We use a fake group id just to probe the connection, as `get_group_coordinator' + %% will ensure a connection to the broker. + FakeGroupId = <<"____emqx_consumer_probe">>, + case brod_client:get_group_coordinator(ClientPid, FakeGroupId) of + {error, client_down} -> + ?status_disconnected; + {error, {client_down, Reason}} -> + %% `brod' should have already logged the client being down. + {?status_disconnected, maybe_clean_error(Reason)}; + {error, Reason} -> + %% `brod' should have already logged the client being down. + {?status_disconnected, maybe_clean_error(Reason)}; + {ok, _Metadata} -> + ?status_connected + end. + +%% Attempt to make the returned error a bit more friendly. +maybe_clean_error(Reason) -> + case Reason of + [{{Host, Port}, {nxdomain, _Stacktrace}} | _] when is_integer(Port) -> + HostPort = iolist_to_binary([Host, ":", integer_to_binary(Port)]), + {HostPort, nxdomain}; + [{error_code, Code}, {error_msg, Msg} | _] -> + {Code, Msg}; + _ -> + Reason + end. + -spec make_client_id(connector_resource_id(), binary(), atom() | binary()) -> atom(). make_client_id(ConnectorResId, BridgeType, BridgeName) -> case is_dry_run(ConnectorResId) of diff --git a/apps/emqx_bridge_kafka/test/emqx_bridge_kafka_impl_consumer_SUITE.erl b/apps/emqx_bridge_kafka/test/emqx_bridge_kafka_impl_consumer_SUITE.erl index 23a8b4828..56aabb1c3 100644 --- a/apps/emqx_bridge_kafka/test/emqx_bridge_kafka_impl_consumer_SUITE.erl +++ b/apps/emqx_bridge_kafka/test/emqx_bridge_kafka_impl_consumer_SUITE.erl @@ -74,6 +74,7 @@ testcases(once) -> t_node_joins_existing_cluster, t_cluster_node_down, t_multiple_topic_mappings, + t_duplicated_kafka_topics, t_dynamic_mqtt_topic, t_resource_manager_crash_after_subscriber_started, t_resource_manager_crash_before_subscriber_started @@ -292,7 +293,10 @@ end_per_group(_Group, _Config) -> init_per_testcase(t_cluster_group = TestCase, Config0) -> Config = emqx_utils:merge_opts(Config0, [{num_partitions, 6}]), common_init_per_testcase(TestCase, Config); -init_per_testcase(t_multiple_topic_mappings = TestCase, Config0) -> +init_per_testcase(TestCase, Config0) when + TestCase =:= t_multiple_topic_mappings; + TestCase =:= t_duplicated_kafka_topics +-> KafkaTopicBase = << (atom_to_binary(TestCase))/binary, @@ -671,7 +675,12 @@ authentication(_) -> parse_and_check(ConfigString, Name) -> {ok, RawConf} = hocon:binary(ConfigString, #{format => map}), TypeBin = ?BRIDGE_TYPE_BIN, - hocon_tconf:check_plain(emqx_bridge_schema, RawConf, #{required => false, atom_key => false}), + #{<<"bridges">> := #{TypeBin := #{Name := _}}} = + hocon_tconf:check_plain( + emqx_bridge_schema, + RawConf, + #{required => false, atom_key => false} + ), #{<<"bridges">> := #{TypeBin := #{Name := Config}}} = RawConf, Config. @@ -1359,6 +1368,28 @@ t_multiple_topic_mappings(Config) -> ), ok. +%% Although we have a test for the v1 schema, the v1 compatibility layer does some +%% shenanigans that do not go through V1 schema validations... +t_duplicated_kafka_topics(Config) -> + #{<<"topic_mapping">> := [#{<<"kafka_topic">> := KT} | _] = TM0} = + ?config(kafka_config, Config), + TM = [M#{<<"kafka_topic">> := KT} || M <- TM0], + ?check_trace( + begin + ?assertMatch( + {error, {{_, 400, _}, _, _}}, + create_bridge_api( + Config, + #{<<"topic_mapping">> => TM} + ) + ), + + ok + end, + [] + ), + ok. + t_on_get_status(Config) -> ProxyPort = ?config(proxy_port, Config), ProxyHost = ?config(proxy_host, Config), @@ -2071,6 +2102,7 @@ t_begin_offset_earliest(Config) -> {ok, _} = create_bridge(Config, #{ <<"kafka">> => #{<<"offset_reset_policy">> => <<"earliest">>} }), + ?retry(500, 20, ?assertEqual({ok, connected}, health_check(Config))), #{num_published => NumMessages} end, diff --git a/apps/emqx_bridge_kafka/test/emqx_bridge_v2_kafka_consumer_SUITE.erl b/apps/emqx_bridge_kafka/test/emqx_bridge_v2_kafka_consumer_SUITE.erl index 02a7a6279..8568e2f62 100644 --- a/apps/emqx_bridge_kafka/test/emqx_bridge_v2_kafka_consumer_SUITE.erl +++ b/apps/emqx_bridge_kafka/test/emqx_bridge_v2_kafka_consumer_SUITE.erl @@ -339,3 +339,15 @@ t_update_topic(Config) -> emqx_bridge_v2_testlib:get_source_api(?SOURCE_TYPE_BIN, Name) ), ok. + +t_bad_bootstrap_host(Config) -> + ?assertMatch( + {error, {{_, 400, _}, _, _}}, + emqx_bridge_v2_testlib:probe_connector_api( + Config, + #{ + <<"bootstrap_hosts">> => <<"bad_host:9999">> + } + ) + ), + ok. diff --git a/apps/emqx_bridge_kinesis/src/emqx_bridge_kinesis.erl b/apps/emqx_bridge_kinesis/src/emqx_bridge_kinesis.erl index 06cf76cf5..d82cdcf6c 100644 --- a/apps/emqx_bridge_kinesis/src/emqx_bridge_kinesis.erl +++ b/apps/emqx_bridge_kinesis/src/emqx_bridge_kinesis.erl @@ -62,7 +62,19 @@ fields(kinesis_action) -> required => true, desc => ?DESC("action_parameters") } - ) + ), + #{ + resource_opts_ref => hoconsc:ref(?MODULE, action_resource_opts) + } + ); +fields(action_resource_opts) -> + emqx_bridge_v2_schema:action_resource_opts_fields( + _Overrides = [ + {batch_size, #{ + type => range(1, 500), + validator => emqx_resource_validator:max(int, 500) + }} + ] ); fields("config_producer") -> emqx_bridge_schema:common_bridge_fields() ++ @@ -84,6 +96,7 @@ fields("resource_opts") -> fields("creation_opts") -> emqx_resource_schema:create_opts([ {batch_size, #{ + type => range(1, 500), validator => emqx_resource_validator:max(int, 500) }} ]); @@ -199,6 +212,8 @@ desc(action_parameters) -> ?DESC("action_parameters"); desc(connector_resource_opts) -> ?DESC(emqx_resource_schema, "resource_opts"); +desc(action_resource_opts) -> + ?DESC(emqx_resource_schema, "resource_opts"); desc(_) -> undefined. diff --git a/apps/emqx_bridge_opents/src/emqx_bridge_opents_connector.erl b/apps/emqx_bridge_opents/src/emqx_bridge_opents_connector.erl index af4cba951..e8bd30471 100644 --- a/apps/emqx_bridge_opents/src/emqx_bridge_opents_connector.erl +++ b/apps/emqx_bridge_opents/src/emqx_bridge_opents_connector.erl @@ -292,50 +292,20 @@ try_render_messages([{ChannelId, _} | _] = BatchReq, Channels) -> render_channel_message(Msg, #{data := DataList}, Acc) -> RawOpts = #{return => rawlist, var_trans => fun(X) -> X end}, lists:foldl( - fun(#{metric := MetricTk, tags := TagsTk, value := ValueTk} = Data, InAcc) -> + fun( + #{ + metric := MetricTk, + tags := TagsProcer, + value := ValueProcer, + timestamp := TimeProcer + }, + InAcc + ) -> MetricVal = emqx_placeholder:proc_tmpl(MetricTk, Msg), - - TagsVal = - case TagsTk of - [tags | TagTkList] -> - maps:from_list([ - { - emqx_placeholder:proc_tmpl(TagName, Msg), - emqx_placeholder:proc_tmpl(TagValue, Msg) - } - || {TagName, TagValue} <- TagTkList - ]); - TagsTks -> - case emqx_placeholder:proc_tmpl(TagsTks, Msg, RawOpts) of - [undefined] -> - #{}; - [Any] -> - Any - end - end, - - ValueVal = - case ValueTk of - [_] -> - %% just one element, maybe is a variable or a plain text - %% we should keep it as it is - erlang:hd(emqx_placeholder:proc_tmpl(ValueTk, Msg, RawOpts)); - Tks when is_list(Tks) -> - emqx_placeholder:proc_tmpl(Tks, Msg); - Raw -> - %% not a token list, just a raw value - Raw - end, - Base = #{metric => MetricVal, tags => TagsVal, value => ValueVal}, - [ - case maps:get(timestamp, Data, undefined) of - undefined -> - Base; - TimestampTk -> - Base#{timestamp => emqx_placeholder:proc_tmpl(TimestampTk, Msg)} - end - | InAcc - ] + TagsVal = TagsProcer(Msg, RawOpts), + ValueVal = ValueProcer(Msg, RawOpts), + Result = TimeProcer(Msg, #{metric => MetricVal, tags => TagsVal, value => ValueVal}), + [Result | InAcc] end, Acc, DataList @@ -345,41 +315,72 @@ preproc_data_template([]) -> preproc_data_template(emqx_bridge_opents:default_data_template()); preproc_data_template(DataList) -> lists:map( - fun(#{tags := Tags, value := Value} = Data) -> - Data2 = maps:without([tags, value], Data), - Template = maps:map( - fun(_Key, Val) -> - emqx_placeholder:preproc_tmpl(Val) - end, - Data2 - ), - - TagsTk = - case Tags of - Tmpl when is_binary(Tmpl) -> - emqx_placeholder:preproc_tmpl(Tmpl); - Map when is_map(Map) -> - [ - tags - | [ - { - emqx_placeholder:preproc_tmpl(emqx_utils_conv:bin(TagName)), - emqx_placeholder:preproc_tmpl(TagValue) - } - || {TagName, TagValue} <- maps:to_list(Map) - ] - ] - end, - - ValueTk = - case Value of - Text when is_binary(Text) -> - emqx_placeholder:preproc_tmpl(Text); - Raw -> - Raw - end, - - Template#{tags => TagsTk, value => ValueTk} + fun(#{metric := Metric, tags := Tags, value := Value} = Data) -> + TagsProcer = mk_tags_procer(Tags), + ValueProcer = mk_value_procer(Value), + #{ + metric => emqx_placeholder:preproc_tmpl(Metric), + tags => TagsProcer, + value => ValueProcer, + timestamp => mk_timestamp_procer(Data) + } end, DataList ). + +mk_tags_procer(Tmpl) when is_binary(Tmpl) -> + TagsTks = emqx_placeholder:preproc_tmpl(Tmpl), + fun(Msg, RawOpts) -> + case emqx_placeholder:proc_tmpl(TagsTks, Msg, RawOpts) of + [undefined] -> + #{}; + [Any] -> + Any + end + end; +mk_tags_procer(Map) when is_map(Map) -> + TagTkList = [ + { + emqx_placeholder:preproc_tmpl(emqx_utils_conv:bin(TagName)), + emqx_placeholder:preproc_tmpl(TagValue) + } + || {TagName, TagValue} <- maps:to_list(Map) + ], + fun(Msg, _RawOpts) -> + maps:from_list([ + { + emqx_placeholder:proc_tmpl(TagName, Msg), + emqx_placeholder:proc_tmpl(TagValue, Msg) + } + || {TagName, TagValue} <- TagTkList + ]) + end. + +mk_value_procer(Text) when is_binary(Text) -> + ValueTk = emqx_placeholder:preproc_tmpl(Text), + case ValueTk of + [_] -> + %% just one element, maybe is a variable or a plain text + %% we should keep it as it is + fun(Msg, RawOpts) -> + erlang:hd(emqx_placeholder:proc_tmpl(ValueTk, Msg, RawOpts)) + end; + Tks when is_list(Tks) -> + fun(Msg, _RawOpts) -> + emqx_placeholder:proc_tmpl(Tks, Msg) + end + end; +mk_value_procer(Raw) -> + fun(_, _) -> + Raw + end. + +mk_timestamp_procer(#{timestamp := Timestamp}) -> + TimestampTk = emqx_placeholder:preproc_tmpl(Timestamp), + fun(Msg, Base) -> + Base#{timestamp => emqx_placeholder:proc_tmpl(TimestampTk, Msg)} + end; +mk_timestamp_procer(_) -> + fun(_Msg, Base) -> + Base + end. diff --git a/apps/emqx_bridge_pulsar/test/emqx_bridge_pulsar_v2_SUITE.erl b/apps/emqx_bridge_pulsar/test/emqx_bridge_pulsar_v2_SUITE.erl index 69b384ab8..0636806de 100644 --- a/apps/emqx_bridge_pulsar/test/emqx_bridge_pulsar_v2_SUITE.erl +++ b/apps/emqx_bridge_pulsar/test/emqx_bridge_pulsar_v2_SUITE.erl @@ -212,20 +212,25 @@ t_action(Config) -> ?assertEqual(ReqPayload, emqx_utils_json:decode(RespPayload)), ok = emqtt:disconnect(C1), InstanceId = instance_id(actions, Name), - #{counters := Counters} = emqx_resource:get_metrics(InstanceId), + ?retry( + 100, + 20, + ?assertMatch( + #{ + counters := #{ + dropped := 0, + success := 1, + matched := 1, + failed := 0, + received := 0 + } + }, + emqx_resource:get_metrics(InstanceId) + ) + ), ok = delete_action(Name), ActionsAfterDelete = emqx_bridge_v2:list(actions), ?assertNot(lists:any(Any, ActionsAfterDelete), ActionsAfterDelete), - ?assertMatch( - #{ - dropped := 0, - success := 1, - matched := 1, - failed := 0, - received := 0 - }, - Counters - ), ok. %%------------------------------------------------------------------------------ @@ -292,7 +297,8 @@ pulsar_action(Config) -> <<"pulsar_topic">> => ?config(pulsar_topic, Config) }, <<"resource_opts">> => #{ - <<"health_check_interval">> => <<"1s">> + <<"health_check_interval">> => <<"1s">>, + <<"metrics_flush_interval">> => <<"300ms">> } } } diff --git a/apps/emqx_bridge_rabbitmq/test/emqx_bridge_rabbitmq_v2_SUITE.erl b/apps/emqx_bridge_rabbitmq/test/emqx_bridge_rabbitmq_v2_SUITE.erl index cf68c20e6..952d53b5e 100644 --- a/apps/emqx_bridge_rabbitmq/test/emqx_bridge_rabbitmq_v2_SUITE.erl +++ b/apps/emqx_bridge_rabbitmq/test/emqx_bridge_rabbitmq_v2_SUITE.erl @@ -12,6 +12,7 @@ -include_lib("common_test/include/ct.hrl"). -include_lib("stdlib/include/assert.hrl"). -include_lib("amqp_client/include/amqp_client.hrl"). +-import(emqx_config_SUITE, [prepare_conf_file/3]). -import(emqx_bridge_rabbitmq_test_utils, [ rabbit_mq_exchange/0, @@ -317,6 +318,60 @@ t_action_not_exist_exchange(_Config) -> ?assertNot(lists:any(Any, ActionsAfterDelete), ActionsAfterDelete), ok. +t_replace_action_source(Config) -> + Action = #{<<"rabbitmq">> => #{<<"my_action">> => rabbitmq_action()}}, + Source = #{<<"rabbitmq">> => #{<<"my_source">> => rabbitmq_source()}}, + ConnectorName = atom_to_binary(?MODULE), + Connector = #{<<"rabbitmq">> => #{ConnectorName => rabbitmq_connector(get_rabbitmq(Config))}}, + Rabbitmq = #{ + <<"actions">> => Action, + <<"sources">> => Source, + <<"connectors">> => Connector + }, + ConfBin0 = hocon_pp:do(Rabbitmq, #{}), + ConfFile0 = prepare_conf_file(?FUNCTION_NAME, ConfBin0, Config), + ?assertMatch(ok, emqx_conf_cli:conf(["load", "--replace", ConfFile0])), + ?assertMatch( + #{<<"rabbitmq">> := #{<<"my_action">> := _}}, + emqx_config:get_raw([<<"actions">>]), + Action + ), + ?assertMatch( + #{<<"rabbitmq">> := #{<<"my_source">> := _}}, + emqx_config:get_raw([<<"sources">>]), + Source + ), + ?assertMatch( + #{<<"rabbitmq">> := #{ConnectorName := _}}, + emqx_config:get_raw([<<"connectors">>]), + Connector + ), + + Empty = #{ + <<"actions">> => #{}, + <<"sources">> => #{}, + <<"connectors">> => #{} + }, + ConfBin1 = hocon_pp:do(Empty, #{}), + ConfFile1 = prepare_conf_file(?FUNCTION_NAME, ConfBin1, Config), + ?assertMatch(ok, emqx_conf_cli:conf(["load", "--replace", ConfFile1])), + + ?assertEqual(#{}, emqx_config:get_raw([<<"actions">>])), + ?assertEqual(#{}, emqx_config:get_raw([<<"sources">>])), + ?assertMatch(#{}, emqx_config:get_raw([<<"connectors">>])), + + %% restore connectors + Rabbitmq2 = #{<<"connectors">> => Connector}, + ConfBin2 = hocon_pp:do(Rabbitmq2, #{}), + ConfFile2 = prepare_conf_file(?FUNCTION_NAME, ConfBin2, Config), + ?assertMatch(ok, emqx_conf_cli:conf(["load", "--replace", ConfFile2])), + ?assertMatch( + #{<<"rabbitmq">> := #{ConnectorName := _}}, + emqx_config:get_raw([<<"connectors">>]), + Connector + ), + ok. + waiting_for_disconnected_alarms(InstanceId) -> waiting_for_disconnected_alarms(InstanceId, 0). diff --git a/apps/emqx_bridge_rocketmq/src/emqx_bridge_rocketmq_connector.erl b/apps/emqx_bridge_rocketmq/src/emqx_bridge_rocketmq_connector.erl index 2e7ad63c9..1af520a93 100644 --- a/apps/emqx_bridge_rocketmq/src/emqx_bridge_rocketmq_connector.erl +++ b/apps/emqx_bridge_rocketmq/src/emqx_bridge_rocketmq_connector.erl @@ -244,7 +244,7 @@ do_query( ?TRACE( "QUERY", "rocketmq_connector_received", - #{connector => InstanceId, query => Query, state => State} + #{connector => InstanceId, query => Query, state => redact(State)} ), ChannelId = get_channel_id(Query), #{ diff --git a/apps/emqx_bridge_tdengine/src/emqx_bridge_tdengine_connector.erl b/apps/emqx_bridge_tdengine/src/emqx_bridge_tdengine_connector.erl index 7d2815f54..f0c3a6e35 100644 --- a/apps/emqx_bridge_tdengine/src/emqx_bridge_tdengine_connector.erl +++ b/apps/emqx_bridge_tdengine/src/emqx_bridge_tdengine_connector.erl @@ -6,10 +6,11 @@ -behaviour(emqx_resource). --include_lib("typerefl/include/types.hrl"). --include_lib("emqx/include/logger.hrl"). --include_lib("snabbkaffe/include/snabbkaffe.hrl"). -include_lib("hocon/include/hoconsc.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("typerefl/include/types.hrl"). +-include_lib("snabbkaffe/include/snabbkaffe.hrl"). +-include_lib("emqx_resource/include/emqx_resource.hrl"). -export([namespace/0, roots/0, fields/1, desc/1]). @@ -209,18 +210,50 @@ on_batch_query(InstanceId, BatchReq, State) -> ?SLOG(error, LogMeta#{msg => "invalid_request"}), {error, {unrecoverable_error, invalid_request}}. -on_get_status(_InstanceId, #{pool_name := PoolName}) -> - Health = emqx_resource_pool:health_check_workers(PoolName, fun ?MODULE:do_get_status/1), - status_result(Health). - -do_get_status(Conn) -> - case tdengine:insert(Conn, "select server_version()", []) of - {ok, _} -> true; - _ -> false +on_get_status(_InstanceId, #{pool_name := PoolName} = State) -> + case + emqx_resource_pool:health_check_workers( + PoolName, + fun ?MODULE:do_get_status/1, + emqx_resource_pool:health_check_timeout(), + #{return_values => true} + ) + of + {ok, []} -> + {?status_connecting, State, undefined}; + {ok, Values} -> + case lists:keyfind(error, 1, Values) of + false -> + ?status_connected; + {error, Reason} -> + {?status_connecting, State, enhance_reason(Reason)} + end; + {error, Reason} -> + {?status_connecting, State, enhance_reason(Reason)} end. -status_result(_Status = true) -> connected; -status_result(_Status = false) -> connecting. +do_get_status(Conn) -> + try + tdengine:insert( + Conn, + "select server_version()", + [], + emqx_resource_pool:health_check_timeout() + ) + of + {ok, _} -> + true; + {error, _} = Error -> + Error + catch + _Type:Reason -> + {error, Reason} + end. + +enhance_reason(timeout) -> + connection_timeout; +enhance_reason(Reason) -> + Reason. on_add_channel( _InstanceId, @@ -253,7 +286,12 @@ on_get_channels(InstanceId) -> on_get_channel_status(InstanceId, ChannelId, #{channels := Channels} = State) -> case maps:is_key(ChannelId, Channels) of true -> - on_get_status(InstanceId, State); + case on_get_status(InstanceId, State) of + {Status, _State, Reason} -> + {Status, Reason}; + Status -> + Status + end; _ -> {error, not_exists} end. diff --git a/apps/emqx_conf/src/emqx_conf_cli.erl b/apps/emqx_conf/src/emqx_conf_cli.erl index c50495b3e..d6462a0b6 100644 --- a/apps/emqx_conf/src/emqx_conf_cli.erl +++ b/apps/emqx_conf/src/emqx_conf_cli.erl @@ -245,10 +245,11 @@ load_config_from_raw(RawConf0, Opts) -> case check_config(RawConf1) of {ok, RawConf} -> %% It has been ensured that the connector is always the first configuration to be updated. - %% However, when deleting the connector, we need to clean up the dependent actions first; + %% However, when deleting the connector, we need to clean up the dependent actions/sources first; %% otherwise, the deletion will fail. - %% notice: we can't create a action before connector. - uninstall_actions(RawConf, Opts), + %% notice: we can't create a action/sources before connector. + uninstall(<<"actions">>, RawConf, Opts), + uninstall(<<"sources">>, RawConf, Opts), Error = lists:filtermap( fun({K, V}) -> @@ -288,27 +289,33 @@ load_config_from_raw(RawConf0, Opts) -> {error, Errors} end. -uninstall_actions(#{<<"actions">> := New}, #{mode := replace}) -> - Old = emqx_conf:get_raw([<<"actions">>], #{}), - #{removed := Removed} = emqx_bridge_v2:diff_confs(New, Old), - maps:foreach( - fun({Type, Name}, _) -> - case emqx_bridge_v2:remove(Type, Name) of - ok -> - ok; - {error, Reason} -> - ?SLOG(error, #{ - msg => "failed_to_remove_action", - type => Type, - name => Name, - error => Reason - }) - end - end, - Removed - ); -%% we don't delete things when in merge mode or without actions key. -uninstall_actions(_RawConf, _) -> +uninstall(ActionOrSource, Conf, #{mode := replace}) -> + case maps:find(ActionOrSource, Conf) of + {ok, New} -> + Old = emqx_conf:get_raw([ActionOrSource], #{}), + ActionOrSourceAtom = binary_to_existing_atom(ActionOrSource), + #{removed := Removed} = emqx_bridge_v2:diff_confs(New, Old), + maps:foreach( + fun({Type, Name}, _) -> + case emqx_bridge_v2:remove(ActionOrSourceAtom, Type, Name) of + ok -> + ok; + {error, Reason} -> + ?SLOG(error, #{ + msg => "failed_to_remove", + type => Type, + name => Name, + error => Reason + }) + end + end, + Removed + ); + error -> + ok + end; +%% we don't delete things when in merge mode or without actions/sources key. +uninstall(_, _RawConf, _) -> ok. update_config_cluster( @@ -481,7 +488,8 @@ filter_readonly_config(Raw) -> end. reload_config(AllConf, Opts) -> - uninstall_actions(AllConf, Opts), + uninstall(<<"actions">>, AllConf, Opts), + uninstall(<<"sources">>, AllConf, Opts), Fold = fun({Key, Conf}, Acc) -> case update_config_local(Key, Conf, Opts) of ok -> diff --git a/apps/emqx_connector/src/emqx_connector.erl b/apps/emqx_connector/src/emqx_connector.erl index bf9a960d5..159e05f9b 100644 --- a/apps/emqx_connector/src/emqx_connector.erl +++ b/apps/emqx_connector/src/emqx_connector.erl @@ -473,6 +473,8 @@ ensure_no_channels(Configs) -> fun({Type, ConnectorName}) -> fun(_) -> case emqx_connector_resource:get_channels(Type, ConnectorName) of + {error, not_found} -> + ok; {ok, []} -> ok; {ok, Channels} -> diff --git a/apps/emqx_connector/src/emqx_connector_api.erl b/apps/emqx_connector/src/emqx_connector_api.erl index e3aa6abf5..97f68b7ef 100644 --- a/apps/emqx_connector/src/emqx_connector_api.erl +++ b/apps/emqx_connector/src/emqx_connector_api.erl @@ -532,7 +532,7 @@ call_operation_if_enabled(NodeOrAll, OperFunc, [Nodes, BridgeType, BridgeName]) is_enabled_connector(ConnectorType, ConnectorName) -> try emqx:get_config([connectors, ConnectorType, binary_to_existing_atom(ConnectorName)]) of ConfMap -> - maps:get(enable, ConfMap, false) + maps:get(enable, ConfMap, true) catch error:{config_not_found, _} -> throw(not_found); diff --git a/apps/emqx_dashboard/include/emqx_dashboard.hrl b/apps/emqx_dashboard/include/emqx_dashboard.hrl index 683df6f3d..547ecca6b 100644 --- a/apps/emqx_dashboard/include/emqx_dashboard.hrl +++ b/apps/emqx_dashboard/include/emqx_dashboard.hrl @@ -85,3 +85,17 @@ sent => sent_msg_rate, dropped => dropped_msg_rate }). + +-define(CURRENT_SAMPLE_NON_RATE, + [ + node_uptime, + retained_msg_count, + shared_subscriptions + ] ++ ?LICENSE_QUOTA +). + +-if(?EMQX_RELEASE_EDITION == ee). +-define(LICENSE_QUOTA, [license_quota]). +-else. +-define(LICENSE_QUOTA, []). +-endif. diff --git a/apps/emqx_dashboard/src/emqx_dashboard_monitor.erl b/apps/emqx_dashboard/src/emqx_dashboard_monitor.erl index f5798708e..b136742d0 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard_monitor.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard_monitor.erl @@ -264,6 +264,8 @@ merge_cluster_rate(Node, Cluster) -> NCluster#{topics => V}; (retained_msg_count, V, NCluster) -> NCluster#{retained_msg_count => V}; + (shared_subscriptions, V, NCluster) -> + NCluster#{shared_subscriptions => V}; (license_quota, V, NCluster) -> NCluster#{license_quota => V}; %% for cluster sample, ignore node_uptime @@ -357,8 +359,8 @@ next_interval() -> sample(Time) -> Fun = - fun(Key, Res) -> - maps:put(Key, getstats(Key), Res) + fun(Key, Acc) -> + Acc#{Key => getstats(Key)} end, Data = lists:foldl(Fun, #{}, ?SAMPLER_LIST), #emqx_monit{time = Time, data = Data}. @@ -416,6 +418,8 @@ stats(live_connections) -> emqx_stats:getstat('live_connections.count'); stats(cluster_sessions) -> emqx_stats:getstat('cluster_sessions.count'); stats(topics) -> emqx_stats:getstat('topics.count'); stats(subscriptions) -> emqx_stats:getstat('subscriptions.count'); +stats(shared_subscriptions) -> emqx_stats:getstat('subscriptions.shared.count'); +stats(retained_msg_count) -> emqx_stats:getstat('retained.count'); stats(received) -> emqx_metrics:val('messages.received'); stats(received_bytes) -> emqx_metrics:val('bytes.received'); stats(sent) -> emqx_metrics:val('messages.sent'); @@ -428,7 +432,8 @@ stats(dropped) -> emqx_metrics:val('messages.dropped'). %% the non rate values should be same on all nodes non_rate_value() -> (license_quota())#{ - retained_msg_count => emqx_retainer:retained_count(), + retained_msg_count => stats(retained_msg_count), + shared_subscriptions => stats(shared_subscriptions), node_uptime => emqx_sys:uptime() }. diff --git a/apps/emqx_dashboard/src/emqx_dashboard_monitor_api.erl b/apps/emqx_dashboard/src/emqx_dashboard_monitor_api.erl index 97397056d..7dc1e919e 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard_monitor_api.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard_monitor_api.erl @@ -94,7 +94,7 @@ schema("/monitor_current/nodes/:node") -> description => ?DESC(current_stats_node), parameters => [parameter_node()], responses => #{ - 200 => hoconsc:mk(hoconsc:ref(sampler_current), #{}), + 200 => hoconsc:mk(hoconsc:ref(sampler_current_node), #{}), 404 => emqx_dashboard_swagger:error_codes(['NOT_FOUND'], <<"Node not found">>) } } @@ -125,8 +125,17 @@ fields(sampler) -> || SamplerName <- ?SAMPLER_LIST ], [{time_stamp, hoconsc:mk(non_neg_integer(), #{desc => <<"Timestamp">>})} | Samplers]; +fields(sampler_current_node) -> + fields_current(sample_names(sampler_current_node)); fields(sampler_current) -> - Names = maps:values(?DELTA_SAMPLER_RATE_MAP) ++ ?GAUGE_SAMPLER_LIST, + fields_current(sample_names(sampler_current)). + +sample_names(sampler_current_node) -> + maps:values(?DELTA_SAMPLER_RATE_MAP) ++ ?GAUGE_SAMPLER_LIST ++ ?CURRENT_SAMPLE_NON_RATE; +sample_names(sampler_current) -> + sample_names(sampler_current_node) -- [node_uptime]. + +fields_current(Names) -> [ {SamplerName, hoconsc:mk(integer(), #{desc => swagger_desc(SamplerName)})} || SamplerName <- Names @@ -167,6 +176,8 @@ current_rate(Node) -> %% ------------------------------------------------------------------------------------------------- %% Internal +-define(APPROXIMATE_DESC, " Can only represent an approximate state."). + swagger_desc(received) -> swagger_desc_format("Received messages "); swagger_desc(received_bytes) -> @@ -178,30 +189,18 @@ swagger_desc(sent_bytes) -> swagger_desc(dropped) -> swagger_desc_format("Dropped messages "); swagger_desc(subscriptions) -> - << - "Subscriptions at the time of sampling." - " Can only represent the approximate state" - >>; + <<"Subscriptions at the time of sampling.", ?APPROXIMATE_DESC>>; swagger_desc(topics) -> - << - "Count topics at the time of sampling." - " Can only represent the approximate state" - >>; + <<"Count topics at the time of sampling.", ?APPROXIMATE_DESC>>; swagger_desc(connections) -> - << - "Sessions at the time of sampling." - " Can only represent the approximate state" - >>; + <<"Sessions at the time of sampling.", ?APPROXIMATE_DESC>>; swagger_desc(live_connections) -> - << - "Connections at the time of sampling." - " Can only represent the approximate state" - >>; + <<"Connections at the time of sampling.", ?APPROXIMATE_DESC>>; swagger_desc(cluster_sessions) -> << "Total number of sessions in the cluster at the time of sampling. " - "It includes expired sessions when `broker.session_history_retain` is set to a duration greater than `0s`. " - "Can only represent the approximate state" + "It includes expired sessions when `broker.session_history_retain` is set to a duration greater than `0s`." + ?APPROXIMATE_DESC >>; swagger_desc(received_msg_rate) -> swagger_desc_format("Dropped messages ", per); @@ -210,7 +209,15 @@ swagger_desc(sent_msg_rate) -> swagger_desc_format("Sent messages ", per); %swagger_desc(sent_bytes_rate) -> swagger_desc_format("Sent bytes ", per); swagger_desc(dropped_msg_rate) -> - swagger_desc_format("Dropped messages ", per). + swagger_desc_format("Dropped messages ", per); +swagger_desc(retained_msg_count) -> + <<"Retained messages count at the time of sampling.", ?APPROXIMATE_DESC>>; +swagger_desc(shared_subscriptions) -> + <<"Shared subscriptions count at the time of sampling.", ?APPROXIMATE_DESC>>; +swagger_desc(node_uptime) -> + <<"Node up time in seconds. Only presented in endpoint: `/monitor_current/nodes/:node`.">>; +swagger_desc(license_quota) -> + <<"License quota. AKA: limited max_connections for cluster">>. swagger_desc_format(Format) -> swagger_desc_format(Format, last). diff --git a/apps/emqx_dashboard/src/emqx_dashboard_swagger.erl b/apps/emqx_dashboard/src/emqx_dashboard_swagger.erl index 27b1ef2fc..38d5df662 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard_swagger.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard_swagger.erl @@ -178,8 +178,36 @@ fields(hasnext) -> >>, Meta = #{desc => Desc, required => true}, [{hasnext, hoconsc:mk(boolean(), Meta)}]; +fields('after') -> + Desc = << + "The value of \"last\" field returned in the previous response. It can then be used" + " in subsequent requests to get the next chunk of results.
" + "It is used instead of \"page\" parameter to traverse volatile data.
" + "Can be omitted or set to \"none\" to get the first chunk of data.
" + "\last\" = end_of_data\" is returned, if there is no more data.
" + "Sending \"after=end_of_table\" back to the server will result in \"400 Bad Request\"" + " error response." + >>, + Meta = #{ + in => query, desc => Desc, required => false, example => <<"AAYS53qRa0n07AAABFIACg">> + }, + [{'after', hoconsc:mk(hoconsc:union([none, end_of_data, binary()]), Meta)}]; +fields(last) -> + Desc = << + "An opaque token that can then be in subsequent requests to get " + " the next chunk of results: \"?after={last}\"
" + "if there is no more data, \"last\" = end_of_data\" is returned.
" + "Sending \"after=end_of_table\" back to the server will result in \"400 Bad Request\"" + " error response." + >>, + Meta = #{ + desc => Desc, required => true, example => <<"AAYS53qRa0n07AAABFIACg">> + }, + [{last, hoconsc:mk(hoconsc:union([none, end_of_data, binary()]), Meta)}]; fields(meta) -> - fields(page) ++ fields(limit) ++ fields(count) ++ fields(hasnext). + fields(page) ++ fields(limit) ++ fields(count) ++ fields(hasnext); +fields(continuation_meta) -> + fields(last) ++ fields(count). -spec schema_with_example(hocon_schema:type(), term()) -> hocon_schema:field_schema(). schema_with_example(Type, Example) -> @@ -416,20 +444,79 @@ check_parameter( check_parameter([], _Bindings, _QueryStr, _Module, NewBindings, NewQueryStr) -> {NewBindings, NewQueryStr}; check_parameter([{Name, Type} | Spec], Bindings, QueryStr, Module, BindingsAcc, QueryStrAcc) -> - Schema = ?INIT_SCHEMA#{roots => [{Name, Type}]}, case hocon_schema:field_schema(Type, in) of path -> + Schema = ?INIT_SCHEMA#{roots => [{Name, Type}]}, Option = #{atom_key => true}, NewBindings = hocon_tconf:check_plain(Schema, Bindings, Option), NewBindingsAcc = maps:merge(BindingsAcc, NewBindings), check_parameter(Spec, Bindings, QueryStr, Module, NewBindingsAcc, QueryStrAcc); query -> + Type1 = maybe_wrap_array_qs_param(Type), + Schema = ?INIT_SCHEMA#{roots => [{Name, Type1}]}, Option = #{}, NewQueryStr = hocon_tconf:check_plain(Schema, QueryStr, Option), NewQueryStrAcc = maps:merge(QueryStrAcc, NewQueryStr), check_parameter(Spec, Bindings, QueryStr, Module, BindingsAcc, NewQueryStrAcc) end. +%% Compatibility layer for minirest 1.4.0 that parses repetitive QS params into lists. +%% Previous minirest releases dropped all but the last repetitive params. + +maybe_wrap_array_qs_param(FieldSchema) -> + Conv = hocon_schema:field_schema(FieldSchema, converter), + Type = hocon_schema:field_schema(FieldSchema, type), + case array_or_single_qs_param(Type, Conv) of + any -> + FieldSchema; + array -> + override_conv(FieldSchema, fun wrap_array_conv/2, Conv); + single -> + override_conv(FieldSchema, fun unwrap_array_conv/2, Conv) + end. + +array_or_single_qs_param(?ARRAY(_Type), undefined) -> + array; +%% Qs field schema is an array and defines a converter: +%% don't change (wrap/unwrap) the original value, and let the converter handle it. +%% For example, it can be a CSV list. +array_or_single_qs_param(?ARRAY(_Type), _Conv) -> + any; +array_or_single_qs_param(?UNION(Types), _Conv) -> + HasArray = lists:any( + fun + (?ARRAY(_)) -> true; + (_) -> false + end, + Types + ), + case HasArray of + true -> any; + false -> single + end; +array_or_single_qs_param(_, _Conv) -> + single. + +override_conv(FieldSchema, NewConv, OldConv) -> + Conv = compose_converters(NewConv, OldConv), + hocon_schema:override(FieldSchema, FieldSchema#{converter => Conv}). + +compose_converters(NewFun, undefined = _OldFun) -> + NewFun; +compose_converters(NewFun, OldFun) -> + case erlang:fun_info(OldFun, arity) of + {_, 2} -> + fun(V, Opts) -> OldFun(NewFun(V, Opts), Opts) end; + {_, 1} -> + fun(V, Opts) -> OldFun(NewFun(V, Opts)) end + end. + +wrap_array_conv(Val, _Opts) when is_list(Val); Val =:= undefined -> Val; +wrap_array_conv(SingleVal, _Opts) -> [SingleVal]. + +unwrap_array_conv([HVal | _], _Opts) -> HVal; +unwrap_array_conv(SingleVal, _Opts) -> SingleVal. + check_request_body(#{body := Body}, Schema, Module, CheckFun, true) -> Type0 = hocon_schema:field_schema(Schema, type), Type = diff --git a/apps/emqx_dashboard/test/emqx_dashboard_monitor_SUITE.erl b/apps/emqx_dashboard/test/emqx_dashboard_monitor_SUITE.erl index 93305727e..e13d63f45 100644 --- a/apps/emqx_dashboard/test/emqx_dashboard_monitor_SUITE.erl +++ b/apps/emqx_dashboard/test/emqx_dashboard_monitor_SUITE.erl @@ -21,24 +21,53 @@ -import(emqx_dashboard_SUITE, [auth_header_/0]). --include_lib("eunit/include/eunit.hrl"). -include("emqx_dashboard.hrl"). +-include_lib("eunit/include/eunit.hrl"). -define(SERVER, "http://127.0.0.1:18083"). -define(BASE_PATH, "/api/v5"). +-define(BASE_RETAINER_CONF, << + "retainer {\n" + " enable = true\n" + " msg_clear_interval = 0s\n" + " msg_expiry_interval = 0s\n" + " max_payload_size = 1MB\n" + " flow_control {\n" + " batch_read_number = 0\n" + " batch_deliver_number = 0\n" + " }\n" + " backend {\n" + " type = built_in_database\n" + " storage_type = ram\n" + " max_retained_messages = 0\n" + " }\n" + "}" +>>). + +%%-------------------------------------------------------------------- +%% CT boilerplate +%%-------------------------------------------------------------------- + all() -> emqx_common_test_helpers:all(?MODULE). init_per_suite(Config) -> - meck:new(emqx_retainer, [non_strict, passthrough, no_history, no_link]), - meck:expect(emqx_retainer, retained_count, fun() -> 0 end), - emqx_mgmt_api_test_util:init_suite([]), + ok = emqx_mgmt_api_test_util:init_suite([emqx, emqx_conf, emqx_retainer]), Config. end_per_suite(_Config) -> - meck:unload([emqx_retainer]), - emqx_mgmt_api_test_util:end_suite([]). + emqx_mgmt_api_test_util:end_suite([emqx_retainer]). + +set_special_configs(emqx_retainer) -> + emqx_retainer:update_config(?BASE_RETAINER_CONF), + ok; +set_special_configs(_App) -> + ok. + +%%-------------------------------------------------------------------- +%% Test Cases +%%-------------------------------------------------------------------- t_monitor_samplers_all(_Config) -> timer:sleep(?DEFAULT_SAMPLE_INTERVAL * 2 * 1000 + 20), @@ -112,6 +141,65 @@ t_monitor_current_api_live_connections(_) -> {ok, _} = emqtt:connect(C2), ok = emqtt:disconnect(C2). +t_monitor_current_retained_count(_) -> + process_flag(trap_exit, true), + ClientId = <<"live_conn_tests">>, + {ok, C} = emqtt:start_link([{clean_start, false}, {clientid, ClientId}]), + {ok, _} = emqtt:connect(C), + _ = emqtt:publish(C, <<"t1">>, <<"qos1-retain">>, [{qos, 1}, {retain, true}]), + + ok = waiting_emqx_stats_and_monitor_update('retained.count'), + {ok, Res} = request(["monitor_current"]), + {ok, ResNode} = request(["monitor_current", "nodes", node()]), + + ?assertEqual(1, maps:get(<<"retained_msg_count">>, Res)), + ?assertEqual(1, maps:get(<<"retained_msg_count">>, ResNode)), + ok = emqtt:disconnect(C), + ok. + +t_monitor_current_shared_subscription(_) -> + process_flag(trap_exit, true), + ShareT = <<"$share/group1/t/1">>, + AssertFun = fun(Num) -> + {ok, Res} = request(["monitor_current"]), + {ok, ResNode} = request(["monitor_current", "nodes", node()]), + ?assertEqual(Num, maps:get(<<"shared_subscriptions">>, Res)), + ?assertEqual(Num, maps:get(<<"shared_subscriptions">>, ResNode)), + ok + end, + + ok = AssertFun(0), + + ClientId1 = <<"live_conn_tests1">>, + ClientId2 = <<"live_conn_tests2">>, + {ok, C1} = emqtt:start_link([{clean_start, false}, {clientid, ClientId1}]), + {ok, _} = emqtt:connect(C1), + _ = emqtt:subscribe(C1, {ShareT, 1}), + + ok = AssertFun(1), + + {ok, C2} = emqtt:start_link([{clean_start, true}, {clientid, ClientId2}]), + {ok, _} = emqtt:connect(C2), + _ = emqtt:subscribe(C2, {ShareT, 1}), + ok = AssertFun(2), + + _ = emqtt:unsubscribe(C2, ShareT), + ok = AssertFun(1), + _ = emqtt:subscribe(C2, {ShareT, 1}), + ok = AssertFun(2), + + ok = emqtt:disconnect(C1), + %% C1: clean_start = false, proto_ver = 3.1.1 + %% means disconnected but the session pid with a share-subscription is still alive + ok = AssertFun(2), + + _ = emqx_cm:kick_session(ClientId1), + ok = AssertFun(1), + + ok = emqtt:disconnect(C2), + ok = AssertFun(0), + ok. + t_monitor_reset(_) -> restart_monitor(), {ok, Rate} = request(["monitor_current"]), diff --git a/apps/emqx_durable_storage/src/emqx_ds.erl b/apps/emqx_durable_storage/src/emqx_ds.erl index c7fa3552b..4143c9ffd 100644 --- a/apps/emqx_durable_storage/src/emqx_ds.erl +++ b/apps/emqx_durable_storage/src/emqx_ds.erl @@ -68,6 +68,8 @@ make_iterator_result/1, make_iterator_result/0, make_delete_iterator_result/1, make_delete_iterator_result/0, + error/1, + ds_specific_stream/0, ds_specific_iterator/0, ds_specific_generation_rank/0, @@ -118,14 +120,14 @@ -type message_key() :: binary(). --type store_batch_result() :: ok | {error, _}. +-type store_batch_result() :: ok | error(_). --type make_iterator_result(Iterator) :: {ok, Iterator} | {error, _}. +-type make_iterator_result(Iterator) :: {ok, Iterator} | error(_). -type make_iterator_result() :: make_iterator_result(iterator()). -type next_result(Iterator) :: - {ok, Iterator, [{message_key(), emqx_types:message()}]} | {ok, end_of_stream} | {error, _}. + {ok, Iterator, [{message_key(), emqx_types:message()}]} | {ok, end_of_stream} | error(_). -type next_result() :: next_result(iterator()). @@ -142,6 +144,8 @@ -type delete_next_result() :: delete_next_result(delete_iterator()). +-type error(Reason) :: {error, recoverable | unrecoverable, Reason}. + %% Timestamp %% Earliest possible timestamp is 0. %% TODO granularity? Currently, we should always use milliseconds, as that's the unit we 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 4229112d3..9135819f9 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_replication_layer.erl @@ -195,7 +195,12 @@ drop_db(DB) -> -spec store_batch(emqx_ds:db(), [emqx_types:message(), ...], emqx_ds:message_store_opts()) -> emqx_ds:store_batch_result(). store_batch(DB, Messages, Opts) -> - emqx_ds_replication_layer_egress:store_batch(DB, Messages, Opts). + try + emqx_ds_replication_layer_egress:store_batch(DB, Messages, Opts) + catch + error:{Reason, _Call} when Reason == timeout; Reason == noproc -> + {error, recoverable, Reason} + end. -spec get_streams(emqx_ds:db(), emqx_ds:topic_filter(), emqx_ds:time()) -> [{emqx_ds:stream_rank(), stream()}]. @@ -204,7 +209,14 @@ get_streams(DB, TopicFilter, StartTime) -> lists:flatmap( fun(Shard) -> Node = node_of_shard(DB, Shard), - Streams = emqx_ds_proto_v4:get_streams(Node, DB, Shard, TopicFilter, StartTime), + Streams = + try + emqx_ds_proto_v4:get_streams(Node, DB, Shard, TopicFilter, StartTime) + catch + error:{erpc, _} -> + %% TODO: log? + [] + end, lists:map( fun({RankY, StorageLayerStream}) -> RankX = Shard, @@ -240,11 +252,14 @@ get_delete_streams(DB, TopicFilter, StartTime) -> make_iterator(DB, Stream, TopicFilter, StartTime) -> ?stream_v2(Shard, StorageStream) = Stream, Node = node_of_shard(DB, Shard), - case emqx_ds_proto_v4:make_iterator(Node, DB, Shard, StorageStream, TopicFilter, StartTime) of + try emqx_ds_proto_v4:make_iterator(Node, DB, Shard, StorageStream, TopicFilter, StartTime) of {ok, Iter} -> {ok, #{?tag => ?IT, ?shard => Shard, ?enc => Iter}}; - Err = {error, _} -> - Err + Error = {error, _, _} -> + Error + catch + error:RPCError = {erpc, _} -> + {error, recoverable, RPCError} end. -spec make_delete_iterator(emqx_ds:db(), delete_stream(), emqx_ds:topic_filter(), emqx_ds:time()) -> @@ -263,28 +278,19 @@ make_delete_iterator(DB, Stream, TopicFilter, StartTime) -> Err end. --spec update_iterator( - emqx_ds:db(), - iterator(), - emqx_ds:message_key() -) -> +-spec update_iterator(emqx_ds:db(), iterator(), emqx_ds:message_key()) -> emqx_ds:make_iterator_result(iterator()). update_iterator(DB, OldIter, DSKey) -> #{?tag := ?IT, ?shard := Shard, ?enc := StorageIter} = OldIter, Node = node_of_shard(DB, Shard), - case - emqx_ds_proto_v4:update_iterator( - Node, - DB, - Shard, - StorageIter, - DSKey - ) - of + try emqx_ds_proto_v4:update_iterator(Node, DB, Shard, StorageIter, DSKey) of {ok, Iter} -> {ok, #{?tag => ?IT, ?shard => Shard, ?enc => Iter}}; - Err = {error, _} -> - Err + Error = {error, _, _} -> + Error + catch + error:RPCError = {erpc, _} -> + {error, recoverable, RPCError} end. -spec next(emqx_ds:db(), iterator(), pos_integer()) -> emqx_ds:next_result(iterator()). @@ -303,8 +309,12 @@ next(DB, Iter0, BatchSize) -> {ok, StorageIter, Batch} -> Iter = Iter0#{?enc := StorageIter}, {ok, Iter, Batch}; - Other -> - Other + Ok = {ok, _} -> + Ok; + Error = {error, _, _} -> + Error; + RPCError = {badrpc, _} -> + {error, recoverable, RPCError} end. -spec delete_next(emqx_ds:db(), delete_iterator(), emqx_ds:delete_selector(), pos_integer()) -> @@ -408,7 +418,7 @@ do_get_streams_v2(DB, Shard, TopicFilter, StartTime) -> emqx_ds:topic_filter(), emqx_ds:time() ) -> - {ok, emqx_ds_storage_layer:iterator()} | {error, _}. + emqx_ds:make_iterator_result(emqx_ds_storage_layer:iterator()). do_make_iterator_v1(_DB, _Shard, _Stream, _TopicFilter, _StartTime) -> error(obsolete_api). @@ -419,7 +429,7 @@ do_make_iterator_v1(_DB, _Shard, _Stream, _TopicFilter, _StartTime) -> emqx_ds:topic_filter(), emqx_ds:time() ) -> - {ok, emqx_ds_storage_layer:iterator()} | {error, _}. + emqx_ds:make_iterator_result(emqx_ds_storage_layer:iterator()). do_make_iterator_v2(DB, Shard, Stream, TopicFilter, StartTime) -> emqx_ds_storage_layer:make_iterator({DB, Shard}, Stream, TopicFilter, StartTime). 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 1f4b3f6ca..64984e1d8 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 @@ -245,7 +245,7 @@ drop(_Shard, DBHandle, GenId, CFRefs, #s{}) -> emqx_ds_storage_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 = #{atomic := true}) -> +store_batch(_ShardId, S = #s{db = DB, data = Data}, Messages, _Options) -> {ok, Batch} = rocksdb:batch(), lists:foreach( fun(Msg) -> @@ -255,18 +255,17 @@ store_batch(_ShardId, S = #s{db = DB, data = Data}, Messages, _Options = #{atomi end, Messages ), - Res = rocksdb:write_batch(DB, Batch, _WriteOptions = []), + Result = rocksdb:write_batch(DB, Batch, []), rocksdb:release_batch(Batch), - Res; -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 - ). + %% NOTE + %% Strictly speaking, `{error, incomplete}` is a valid result but should be impossible to + %% observe until there's `{no_slowdown, true}` in write options. + case Result of + ok -> + ok; + {error, {error, Reason}} -> + {error, unrecoverable, {rocksdb, Reason}} + end. -spec get_streams( emqx_ds_storage_layer:shard_id(), 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 0f38629d4..1ab0df580 100644 --- a/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl +++ b/apps/emqx_durable_storage/src/emqx_ds_storage_layer.erl @@ -302,7 +302,7 @@ make_iterator( Err end; {error, not_found} -> - {error, end_of_stream} + {error, unrecoverable, generation_not_found} end. -spec make_delete_iterator(shard_id(), delete_stream(), emqx_ds:topic_filter(), emqx_ds:time()) -> @@ -326,9 +326,7 @@ make_delete_iterator( {error, end_of_stream} end. --spec update_iterator( - shard_id(), iterator(), emqx_ds:message_key() -) -> +-spec update_iterator(shard_id(), iterator(), emqx_ds:message_key()) -> emqx_ds:make_iterator_result(iterator()). update_iterator( Shard, @@ -348,7 +346,7 @@ update_iterator( Err end; {error, not_found} -> - {error, end_of_stream} + {error, unrecoverable, generation_not_found} end. -spec next(shard_id(), iterator(), pos_integer()) -> @@ -365,12 +363,12 @@ next(Shard, Iter = #{?tag := ?IT, ?generation := GenId, ?enc := GenIter0}, Batch {ok, end_of_stream}; {ok, GenIter, Batch} -> {ok, Iter#{?enc := GenIter}, Batch}; - Error = {error, _} -> + Error = {error, _, _} -> Error end; {error, not_found} -> %% generation was possibly dropped by GC - {ok, end_of_stream} + {error, unrecoverable, generation_not_found} end. -spec delete_next(shard_id(), delete_iterator(), emqx_ds:delete_selector(), pos_integer()) -> diff --git a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v4.erl b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v4.erl index 12612dace..62ea33c3e 100644 --- a/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v4.erl +++ b/apps/emqx_durable_storage/src/proto/emqx_ds_proto_v4.erl @@ -67,7 +67,7 @@ get_streams(Node, DB, Shard, TopicFilter, Time) -> emqx_ds:topic_filter(), emqx_ds:time() ) -> - {ok, emqx_ds_storage_layer:iterator()} | {error, _}. + emqx_ds:make_iterator_result(). make_iterator(Node, DB, Shard, Stream, TopicFilter, StartTime) -> erpc:call(Node, emqx_ds_replication_layer, do_make_iterator_v2, [ DB, Shard, Stream, TopicFilter, StartTime @@ -80,9 +80,7 @@ make_iterator(Node, DB, Shard, Stream, TopicFilter, StartTime) -> emqx_ds_storage_layer:iterator(), pos_integer() ) -> - {ok, emqx_ds_storage_layer:iterator(), [{emqx_ds:message_key(), [emqx_types:message()]}]} - | {ok, end_of_stream} - | {error, _}. + emqx_rpc:call_result(emqx_ds:next_result()). next(Node, DB, Shard, Iter, BatchSize) -> emqx_rpc:call(Shard, Node, emqx_ds_replication_layer, do_next_v1, [DB, Shard, Iter, BatchSize]). @@ -106,7 +104,7 @@ store_batch(Node, DB, Shard, Batch, Options) -> emqx_ds_storage_layer:iterator(), emqx_ds:message_key() ) -> - {ok, emqx_ds_storage_layer:iterator()} | {error, _}. + emqx_ds:make_iterator_result(). update_iterator(Node, DB, Shard, OldIter, DSKey) -> erpc:call(Node, emqx_ds_replication_layer, do_update_iterator_v2, [ DB, Shard, OldIter, DSKey diff --git a/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl b/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl index 607545347..082ed2dff 100644 --- a/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl +++ b/apps/emqx_durable_storage/test/emqx_ds_SUITE.erl @@ -21,6 +21,7 @@ -include_lib("emqx/include/emqx.hrl"). -include_lib("common_test/include/ct.hrl"). -include_lib("stdlib/include/assert.hrl"). +-include_lib("emqx/include/asserts.hrl"). -include_lib("snabbkaffe/include/snabbkaffe.hrl"). -define(N_SHARDS, 1). @@ -446,7 +447,10 @@ t_drop_generation_with_never_used_iterator(_Config) -> ], ?assertMatch(ok, emqx_ds:store_batch(DB, Msgs1)), - ?assertMatch({ok, end_of_stream, []}, iterate(DB, Iter0, 1)), + ?assertMatch( + {error, unrecoverable, generation_not_found, []}, + iterate(DB, Iter0, 1) + ), %% New iterator for the new stream will only see the later messages. [{_, Stream1}] = emqx_ds:get_streams(DB, TopicFilter, StartTime), @@ -495,9 +499,10 @@ t_drop_generation_with_used_once_iterator(_Config) -> ], ?assertMatch(ok, emqx_ds:store_batch(DB, Msgs1)), - ?assertMatch({ok, end_of_stream, []}, iterate(DB, Iter1, 1)), - - ok. + ?assertMatch( + {error, unrecoverable, generation_not_found, []}, + iterate(DB, Iter1, 1) + ). t_drop_generation_update_iterator(_Config) -> %% This checks the behavior of `emqx_ds:update_iterator' after the generation @@ -523,9 +528,10 @@ t_drop_generation_update_iterator(_Config) -> ok = emqx_ds:add_generation(DB), ok = emqx_ds:drop_generation(DB, GenId0), - ?assertEqual({error, end_of_stream}, emqx_ds:update_iterator(DB, Iter1, Key2)), - - ok. + ?assertEqual( + {error, unrecoverable, generation_not_found}, + emqx_ds:update_iterator(DB, Iter1, Key2) + ). t_make_iterator_stale_stream(_Config) -> %% This checks the behavior of `emqx_ds:make_iterator' after the generation underlying @@ -549,7 +555,7 @@ t_make_iterator_stale_stream(_Config) -> ok = emqx_ds:drop_generation(DB, GenId0), ?assertEqual( - {error, end_of_stream}, + {error, unrecoverable, generation_not_found}, emqx_ds:make_iterator(DB, Stream0, TopicFilter, StartTime) ), @@ -590,9 +596,99 @@ t_get_streams_concurrently_with_drop_generation(_Config) -> ok end, [] + ). + +t_error_mapping_replication_layer(_Config) -> + %% This checks that the replication layer maps recoverable errors correctly. + + ok = emqx_ds_test_helpers:mock_rpc(), + ok = snabbkaffe:start_trace(), + + DB = ?FUNCTION_NAME, + ?assertMatch(ok, emqx_ds:open_db(DB, (opts())#{n_shards => 2})), + [Shard1, Shard2] = emqx_ds_replication_layer_meta:shards(DB), + + TopicFilter = emqx_topic:words(<<"foo/#">>), + Msgs = [ + message(<<"C1">>, <<"foo/bar">>, <<"1">>, 0), + message(<<"C1">>, <<"foo/baz">>, <<"2">>, 1), + message(<<"C2">>, <<"foo/foo">>, <<"3">>, 2), + message(<<"C3">>, <<"foo/xyz">>, <<"4">>, 3), + message(<<"C4">>, <<"foo/bar">>, <<"5">>, 4), + message(<<"C5">>, <<"foo/oof">>, <<"6">>, 5) + ], + + ?assertMatch(ok, emqx_ds:store_batch(DB, Msgs)), + + ?block_until(#{?snk_kind := emqx_ds_replication_layer_egress_flush, shard := Shard1}), + ?block_until(#{?snk_kind := emqx_ds_replication_layer_egress_flush, shard := Shard2}), + + Streams0 = emqx_ds:get_streams(DB, TopicFilter, 0), + Iterators0 = lists:map( + fun({_Rank, S}) -> + {ok, Iter} = emqx_ds:make_iterator(DB, S, TopicFilter, 0), + Iter + end, + Streams0 ), - ok. + %% Disrupt the link to the second shard. + ok = emqx_ds_test_helpers:mock_rpc_result( + fun(_Node, emqx_ds_replication_layer, _Function, Args) -> + case Args of + [DB, Shard1 | _] -> passthrough; + [DB, Shard2 | _] -> unavailable + end + end + ), + + %% Result of `emqx_ds:get_streams/3` will just contain partial results, not an error. + Streams1 = emqx_ds:get_streams(DB, TopicFilter, 0), + ?assert( + length(Streams1) > 0 andalso length(Streams1) =< length(Streams0), + Streams1 + ), + + %% At least one of `emqx_ds:make_iterator/4` will end in an error. + Results1 = lists:map( + fun({_Rank, S}) -> + case emqx_ds:make_iterator(DB, S, TopicFilter, 0) of + Ok = {ok, _Iter} -> + Ok; + Error = {error, recoverable, {erpc, _}} -> + Error; + Other -> + ct:fail({unexpected_result, Other}) + end + end, + Streams0 + ), + ?assert( + length([error || {error, _, _} <- Results1]) > 0, + Results1 + ), + + %% At least one of `emqx_ds:next/3` over initial set of iterators will end in an error. + Results2 = lists:map( + fun(Iter) -> + case emqx_ds:next(DB, Iter, _BatchSize = 42) of + Ok = {ok, _Iter, [_ | _]} -> + Ok; + Error = {error, recoverable, {badrpc, _}} -> + Error; + Other -> + ct:fail({unexpected_result, Other}) + end + end, + Iterators0 + ), + ?assert( + length([error || {error, _, _} <- Results2]) > 0, + Results2 + ), + + snabbkaffe:stop(), + meck:unload(). update_data_set() -> [ @@ -628,6 +724,10 @@ fetch_all(DB, TopicFilter, StartTime) -> Streams ). +message(ClientId, Topic, Payload, PublishedAt) -> + Msg = message(Topic, Payload, PublishedAt), + Msg#message{from = ClientId}. + message(Topic, Payload, PublishedAt) -> #message{ topic = Topic, @@ -647,8 +747,8 @@ iterate(DB, It0, BatchSize, Acc) -> iterate(DB, It, BatchSize, Acc ++ Msgs); {ok, end_of_stream} -> {ok, end_of_stream, Acc}; - Ret -> - Ret + {error, Class, Reason} -> + {error, Class, Reason, Acc} end. delete(DB, It, Selector, BatchSize) -> diff --git a/apps/emqx_durable_storage/test/emqx_ds_test_helpers.erl b/apps/emqx_durable_storage/test/emqx_ds_test_helpers.erl new file mode 100644 index 000000000..d26c6dd30 --- /dev/null +++ b/apps/emqx_durable_storage/test/emqx_ds_test_helpers.erl @@ -0,0 +1,58 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2024 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_test_helpers). + +-compile(export_all). +-compile(nowarn_export_all). + +%% RPC mocking + +mock_rpc() -> + ok = meck:new(erpc, [passthrough, no_history, unstick]), + ok = meck:new(gen_rpc, [passthrough, no_history]). + +unmock_rpc() -> + catch meck:unload(erpc), + catch meck:unload(gen_rpc). + +mock_rpc_result(ExpectFun) -> + mock_rpc_result(erpc, ExpectFun), + mock_rpc_result(gen_rpc, ExpectFun). + +mock_rpc_result(erpc, ExpectFun) -> + ok = meck:expect(erpc, call, fun(Node, Mod, Function, Args) -> + case ExpectFun(Node, Mod, Function, Args) of + passthrough -> + meck:passthrough([Node, Mod, Function, Args]); + unavailable -> + meck:exception(error, {erpc, noconnection}); + {timeout, Timeout} -> + ok = timer:sleep(Timeout), + meck:exception(error, {erpc, timeout}) + end + end); +mock_rpc_result(gen_rpc, ExpectFun) -> + ok = meck:expect(gen_rpc, call, fun(Dest = {Node, _}, Mod, Function, Args) -> + case ExpectFun(Node, Mod, Function, Args) of + passthrough -> + meck:passthrough([Dest, Mod, Function, Args]); + unavailable -> + {badtcp, econnrefused}; + {timeout, Timeout} -> + ok = timer:sleep(Timeout), + {badrpc, timeout} + end + end). diff --git a/apps/emqx_ldap/src/emqx_ldap.app.src b/apps/emqx_ldap/src/emqx_ldap.app.src index 3598d317c..b0d8ec59c 100644 --- a/apps/emqx_ldap/src/emqx_ldap.app.src +++ b/apps/emqx_ldap/src/emqx_ldap.app.src @@ -1,6 +1,6 @@ {application, emqx_ldap, [ {description, "EMQX LDAP Connector"}, - {vsn, "0.1.7"}, + {vsn, "0.1.8"}, {registered, []}, {applications, [ kernel, diff --git a/apps/emqx_ldap/src/emqx_ldap.erl b/apps/emqx_ldap/src/emqx_ldap.erl index 79423995e..d04be5d68 100644 --- a/apps/emqx_ldap/src/emqx_ldap.erl +++ b/apps/emqx_ldap/src/emqx_ldap.erl @@ -327,7 +327,7 @@ do_ldap_query( mk_log_func(LogTag) -> fun(_Level, Format, Args) -> ?SLOG( - info, + debug, #{ msg => LogTag, log => io_lib:format(Format, [redact_ldap_log(Arg) || Arg <- Args]) diff --git a/apps/emqx_management/include/emqx_mgmt.hrl b/apps/emqx_management/include/emqx_mgmt.hrl index 8ad1cd871..a802bad21 100644 --- a/apps/emqx_management/include/emqx_mgmt.hrl +++ b/apps/emqx_management/include/emqx_mgmt.hrl @@ -15,3 +15,6 @@ %%-------------------------------------------------------------------- -define(DEFAULT_ROW_LIMIT, 100). + +-define(URL_PARAM_INTEGER, url_param_integer). +-define(URL_PARAM_BINARY, url_param_binary). diff --git a/apps/emqx_management/src/emqx_mgmt.erl b/apps/emqx_management/src/emqx_mgmt.erl index a1ab0bc3f..9428ef8e2 100644 --- a/apps/emqx_management/src/emqx_mgmt.erl +++ b/apps/emqx_management/src/emqx_mgmt.erl @@ -18,6 +18,7 @@ -include("emqx_mgmt.hrl"). -include_lib("emqx/include/emqx_cm.hrl"). +-include_lib("emqx/include/logger.hrl"). -elvis([{elvis_style, invalid_dynamic_call, disable}]). -elvis([{elvis_style, god_modules, disable}]). @@ -52,6 +53,7 @@ kickout_clients/1, list_authz_cache/1, list_client_subscriptions/1, + list_client_msgs/3, client_subscriptions/2, clean_authz_cache/1, clean_authz_cache/2, @@ -116,6 +118,13 @@ -elvis([{elvis_style, god_modules, disable}]). +-define(maybe_log_node_errors(LogData, Errors), + case Errors of + [] -> ok; + _ -> ?SLOG(error, (LogData)#{node_errors => Errors}) + end +). + %%-------------------------------------------------------------------- %% Node Info %%-------------------------------------------------------------------- @@ -184,7 +193,7 @@ get_sys_memory() -> end. node_info(Nodes) -> - emqx_rpc:unwrap_erpc(emqx_management_proto_v4:node_info(Nodes)). + emqx_rpc:unwrap_erpc(emqx_management_proto_v5:node_info(Nodes)). stopped_node_info(Node) -> {Node, #{node => Node, node_status => 'stopped', role => core}}. @@ -204,23 +213,17 @@ cpu_stats() -> false -> []; true -> - Idle = vm_stats('cpu.idle'), - [ - {cpu_idle, Idle}, - {cpu_use, 100 - Idle} - ] + vm_stats('cpu') end. -vm_stats('cpu.idle') -> - case emqx_vm:cpu_util([detailed]) of - {_Num, _Use, List, _} when is_list(List) -> proplists:get_value(idle, List, 0); - %% return {all, 0, 0, []} when cpu_sup is not started - _ -> 0 - end; -vm_stats('cpu.use') -> - case vm_stats('cpu.idle') of - 0 -> 0; - Idle -> 100 - Idle +vm_stats('cpu') -> + CpuUtilArg = [], + case emqx_vm:cpu_util([CpuUtilArg]) of + %% return 0.0 when `emqx_cpu_sup_worker` is not started + {all, Use, Idle, _} -> + [{cpu_use, Use}, {cpu_idle, Idle}]; + _ -> + [{cpu_use, 0}, {cpu_idle, 0}] end; vm_stats('total.memory') -> {_, MemTotal} = get_sys_memory(), @@ -253,7 +256,7 @@ convert_broker_info({K, V}, M) -> M#{K => iolist_to_binary(V)}. broker_info(Nodes) -> - emqx_rpc:unwrap_erpc(emqx_management_proto_v4:broker_info(Nodes)). + emqx_rpc:unwrap_erpc(emqx_management_proto_v5:broker_info(Nodes)). %%-------------------------------------------------------------------- %% Metrics and Stats @@ -366,7 +369,7 @@ kickout_client(Node, ClientId) -> kickout_clients(ClientIds) when is_list(ClientIds) -> F = fun(Node) -> - emqx_management_proto_v4:kickout_clients(Node, ClientIds) + emqx_management_proto_v5:kickout_clients(Node, ClientIds) end, Results = lists:map(F, emqx:running_nodes()), case lists:filter(fun(Res) -> Res =/= ok end, Results) of @@ -417,6 +420,12 @@ list_client_subscriptions_mem(ClientId) -> end end. +list_client_msgs(MsgsType, ClientId, PagerParams) when + MsgsType =:= inflight_msgs; + MsgsType =:= mqueue_msgs +-> + call_client(ClientId, {MsgsType, PagerParams}). + client_subscriptions(Node, ClientId) -> {Node, unwrap_rpc(emqx_broker_proto_v1:list_client_subscriptions(Node, ClientId))}. @@ -460,17 +469,34 @@ set_keepalive(_ClientId, _Interval) -> %% @private call_client(ClientId, Req) -> - Results = [call_client(Node, ClientId, Req) || Node <- emqx:running_nodes()], - Expected = lists:filter( + case emqx_cm_registry:is_enabled() of + true -> + do_call_client(ClientId, Req); + false -> + call_client_on_all_nodes(ClientId, Req) + end. + +call_client_on_all_nodes(ClientId, Req) -> + Nodes = emqx:running_nodes(), + Results = call_client(Nodes, ClientId, Req), + {Expected, Errs} = lists:foldr( fun - ({error, _}) -> false; - (_) -> true + ({_N, {error, not_found}}, Acc) -> Acc; + ({_N, {error, _}} = Err, {OkAcc, ErrAcc}) -> {OkAcc, [Err | ErrAcc]}; + ({_N, OkRes}, {OkAcc, ErrAcc}) -> {[OkRes | OkAcc], ErrAcc} end, - Results + {[], []}, + lists:zip(Nodes, Results) ), + ?maybe_log_node_errors(#{msg => "call_client_failed", request => Req}, Errs), case Expected of - [] -> {error, not_found}; - [Result | _] -> Result + [] -> + case Errs of + [] -> {error, not_found}; + [{_Node, FirstErr} | _] -> FirstErr + end; + [Result | _] -> + Result end. %% @private @@ -490,8 +516,8 @@ do_call_client(ClientId, Req) -> end. %% @private -call_client(Node, ClientId, Req) -> - unwrap_rpc(emqx_management_proto_v4:call_client(Node, ClientId, Req)). +call_client(Nodes, ClientId, Req) -> + emqx_rpc:unwrap_erpc(emqx_management_proto_v5:call_client(Nodes, ClientId, Req)). %%-------------------------------------------------------------------- %% Subscriptions @@ -504,7 +530,7 @@ do_list_subscriptions() -> throw(not_implemented). list_subscriptions(Node) -> - unwrap_rpc(emqx_management_proto_v4:list_subscriptions(Node)). + unwrap_rpc(emqx_management_proto_v5:list_subscriptions(Node)). list_subscriptions_via_topic(Topic, FormatFun) -> lists:append([ @@ -526,7 +552,7 @@ subscribe(ClientId, TopicTables) -> subscribe(emqx:running_nodes(), ClientId, TopicTables). subscribe([Node | Nodes], ClientId, TopicTables) -> - case unwrap_rpc(emqx_management_proto_v4:subscribe(Node, ClientId, TopicTables)) of + case unwrap_rpc(emqx_management_proto_v5:subscribe(Node, ClientId, TopicTables)) of {error, _} -> subscribe(Nodes, ClientId, TopicTables); {subscribe, Res} -> {subscribe, Res, Node} end; @@ -553,7 +579,7 @@ unsubscribe(ClientId, Topic) -> -spec unsubscribe([node()], emqx_types:clientid(), emqx_types:topic()) -> {unsubscribe, _} | {error, channel_not_found}. unsubscribe([Node | Nodes], ClientId, Topic) -> - case unwrap_rpc(emqx_management_proto_v4:unsubscribe(Node, ClientId, Topic)) of + case unwrap_rpc(emqx_management_proto_v5:unsubscribe(Node, ClientId, Topic)) of {error, _} -> unsubscribe(Nodes, ClientId, Topic); Re -> Re end; @@ -576,7 +602,7 @@ unsubscribe_batch(ClientId, Topics) -> -spec unsubscribe_batch([node()], emqx_types:clientid(), [emqx_types:topic()]) -> {unsubscribe_batch, _} | {error, channel_not_found}. unsubscribe_batch([Node | Nodes], ClientId, Topics) -> - case unwrap_rpc(emqx_management_proto_v4:unsubscribe_batch(Node, ClientId, Topics)) of + case unwrap_rpc(emqx_management_proto_v5:unsubscribe_batch(Node, ClientId, Topics)) of {error, _} -> unsubscribe_batch(Nodes, ClientId, Topics); Re -> Re end; @@ -655,6 +681,7 @@ lookup_running_client(ClientId, FormatFun) -> %%-------------------------------------------------------------------- %% Internal Functions. %%-------------------------------------------------------------------- + unwrap_rpc({badrpc, Reason}) -> {error, Reason}; unwrap_rpc(Res) -> diff --git a/apps/emqx_management/src/emqx_mgmt_api.erl b/apps/emqx_management/src/emqx_mgmt_api.erl index be8f24bc3..1b4e9a255 100644 --- a/apps/emqx_management/src/emqx_mgmt_api.erl +++ b/apps/emqx_management/src/emqx_mgmt_api.erl @@ -17,6 +17,7 @@ -module(emqx_mgmt_api). -include_lib("stdlib/include/qlc.hrl"). +-include("emqx_mgmt.hrl"). -elvis([{elvis_style, dont_repeat_yourself, #{min_complexity => 100}}]). @@ -37,6 +38,8 @@ -export([ parse_pager_params/1, + parse_cont_pager_params/2, + encode_cont_pager_params/2, parse_qstring/2, init_query_result/0, init_query_state/5, @@ -45,6 +48,7 @@ finalize_query/2, mark_complete/2, format_query_result/3, + format_query_result/4, maybe_collect_total_from_tail_nodes/2 ]). @@ -134,6 +138,33 @@ page(Params) -> limit(Params) when is_map(Params) -> maps:get(<<"limit">>, Params, emqx_mgmt:default_row_limit()). +continuation(Params, Encoding) -> + try + decode_continuation(maps:get(<<"after">>, Params, none), Encoding) + catch + _:_ -> + error + end. + +decode_continuation(none, _Encoding) -> + none; +decode_continuation(end_of_data, _Encoding) -> + %% Clients should not send "after=end_of_data" back to the server + error; +decode_continuation(Cont, ?URL_PARAM_INTEGER) -> + binary_to_integer(Cont); +decode_continuation(Cont, ?URL_PARAM_BINARY) -> + emqx_utils:hexstr_to_bin(Cont). + +encode_continuation(none, _Encoding) -> + none; +encode_continuation(end_of_data, _Encoding) -> + end_of_data; +encode_continuation(Cont, ?URL_PARAM_INTEGER) -> + integer_to_binary(Cont); +encode_continuation(Cont, ?URL_PARAM_BINARY) -> + emqx_utils:bin_to_hexstr(Cont, lower). + %%-------------------------------------------------------------------- %% Node Query %%-------------------------------------------------------------------- @@ -589,10 +620,13 @@ is_fuzzy_key(<<"match_", _/binary>>) -> is_fuzzy_key(_) -> false. -format_query_result(_FmtFun, _MetaIn, Error = {error, _Node, _Reason}) -> +format_query_result(FmtFun, MetaIn, ResultAcc) -> + format_query_result(FmtFun, MetaIn, ResultAcc, #{}). + +format_query_result(_FmtFun, _MetaIn, Error = {error, _Node, _Reason}, _Opts) -> Error; format_query_result( - FmtFun, MetaIn, ResultAcc = #{hasnext := HasNext, rows := RowsAcc} + FmtFun, MetaIn, ResultAcc = #{hasnext := HasNext, rows := RowsAcc}, Opts ) -> Meta = case ResultAcc of @@ -608,7 +642,10 @@ format_query_result( data => lists:flatten( lists:foldl( fun({Node, Rows}, Acc) -> - [lists:map(fun(Row) -> exec_format_fun(FmtFun, Node, Row) end, Rows) | Acc] + [ + lists:map(fun(Row) -> exec_format_fun(FmtFun, Node, Row, Opts) end, Rows) + | Acc + ] end, [], RowsAcc @@ -616,10 +653,11 @@ format_query_result( ) }. -exec_format_fun(FmtFun, Node, Row) -> +exec_format_fun(FmtFun, Node, Row, Opts) -> case erlang:fun_info(FmtFun, arity) of {arity, 1} -> FmtFun(Row); - {arity, 2} -> FmtFun(Node, Row) + {arity, 2} -> FmtFun(Node, Row); + {arity, 3} -> FmtFun(Node, Row, Opts) end. parse_pager_params(Params) -> @@ -632,6 +670,25 @@ parse_pager_params(Params) -> false end. +-spec parse_cont_pager_params(map(), ?URL_PARAM_INTEGER | ?URL_PARAM_BINARY) -> + #{limit := pos_integer(), continuation := none | end_of_table | binary()} | false. +parse_cont_pager_params(Params, Encoding) -> + Cont = continuation(Params, Encoding), + Limit = b2i(limit(Params)), + case Limit > 0 andalso Cont =/= error of + true -> + #{continuation => Cont, limit => Limit}; + false -> + false + end. + +-spec encode_cont_pager_params(map(), ?URL_PARAM_INTEGER | ?URL_PARAM_BINARY) -> map(). +encode_cont_pager_params(#{continuation := Cont} = Meta, ContEncoding) -> + Meta1 = maps:remove(continuation, Meta), + Meta1#{last => encode_continuation(Cont, ContEncoding)}; +encode_cont_pager_params(Meta, _ContEncoding) -> + Meta. + %%-------------------------------------------------------------------- %% Types %%-------------------------------------------------------------------- diff --git a/apps/emqx_management/src/emqx_mgmt_api_banned.erl b/apps/emqx_management/src/emqx_mgmt_api_banned.erl index 5d02f242d..0a24dab15 100644 --- a/apps/emqx_management/src/emqx_mgmt_api_banned.erl +++ b/apps/emqx_management/src/emqx_mgmt_api_banned.erl @@ -169,7 +169,8 @@ banned(get, #{query_string := Params}) -> banned(post, #{body := Body}) -> case emqx_banned:parse(Body) of {error, Reason} -> - {400, 'BAD_REQUEST', list_to_binary(Reason)}; + ErrorReason = io_lib:format("~p", [Reason]), + {400, 'BAD_REQUEST', list_to_binary(ErrorReason)}; Ban -> case emqx_banned:create(Ban) of {ok, Banned} -> diff --git a/apps/emqx_management/src/emqx_mgmt_api_clients.erl b/apps/emqx_management/src/emqx_mgmt_api_clients.erl index e661a8360..5a4c7baed 100644 --- a/apps/emqx_management/src/emqx_mgmt_api_clients.erl +++ b/apps/emqx_management/src/emqx_mgmt_api_clients.erl @@ -22,8 +22,8 @@ -include_lib("emqx/include/emqx.hrl"). -include_lib("emqx/include/emqx_cm.hrl"). -include_lib("hocon/include/hoconsc.hrl"). - -include_lib("emqx/include/logger.hrl"). +-include_lib("emqx_utils/include/emqx_utils_api.hrl"). -include("emqx_mgmt.hrl"). @@ -47,14 +47,17 @@ unsubscribe/2, unsubscribe_batch/2, set_keepalive/2, - sessions_count/2 + sessions_count/2, + inflight_msgs/2, + mqueue_msgs/2 ]). -export([ qs2ms/2, run_fuzzy_filter/2, format_channel_info/1, - format_channel_info/2 + format_channel_info/2, + format_channel_info/3 ]). %% for batch operation @@ -64,7 +67,10 @@ -define(CLIENT_QSCHEMA, [ {<<"node">>, atom}, + %% list {<<"username">>, binary}, + %% list + {<<"clientid">>, binary}, {<<"ip_address">>, ip}, {<<"conn_state">>, atom}, {<<"clean_start">>, atom}, @@ -101,6 +107,8 @@ paths() -> "/clients/:clientid/unsubscribe", "/clients/:clientid/unsubscribe/bulk", "/clients/:clientid/keepalive", + "/clients/:clientid/mqueue_messages", + "/clients/:clientid/inflight_messages", "/sessions_count" ]. @@ -121,10 +129,13 @@ schema("/clients") -> example => <<"emqx@127.0.0.1">> })}, {username, - hoconsc:mk(binary(), #{ + hoconsc:mk(hoconsc:array(binary()), #{ in => query, required => false, - desc => <<"User name">> + desc => << + "User name, multiple values can be specified by" + " repeating the parameter: username=u1&username=u2" + >> })}, {ip_address, hoconsc:mk(binary(), #{ @@ -198,7 +209,17 @@ schema("/clients") -> "Search client connection creation time by less" " than or equal method, rfc3339 or timestamp(millisecond)" >> - })} + })}, + {clientid, + hoconsc:mk(hoconsc:array(binary()), #{ + in => query, + required => false, + desc => << + "Client ID, multiple values can be specified by" + " repeating the parameter: clientid=c1&clientid=c2" + >> + })}, + ?R_REF(requested_client_fields) ], responses => #{ 200 => @@ -391,6 +412,14 @@ schema("/clients/:clientid/keepalive") -> } } }; +schema("/clients/:clientid/mqueue_messages") -> + ContExample = <<"AAYS53qRa0n07AAABFIACg">>, + RespSchema = ?R_REF(mqueue_messages), + client_msgs_schema(mqueue_msgs, ?DESC(get_client_mqueue_msgs), ContExample, RespSchema); +schema("/clients/:clientid/inflight_messages") -> + ContExample = <<"10">>, + RespSchema = ?R_REF(inflight_messages), + client_msgs_schema(inflight_msgs, ?DESC(get_client_inflight_msgs), ContExample, RespSchema); schema("/sessions_count") -> #{ 'operationId' => sessions_count, @@ -411,7 +440,10 @@ schema("/sessions_count") -> responses => #{ 200 => hoconsc:mk(binary(), #{ desc => <<"Number of sessions">> - }) + }), + 400 => emqx_dashboard_swagger:error_codes( + ['BAD_REQUEST'], <<"Node {name} cannot handle this request.">> + ) } } }. @@ -621,6 +653,50 @@ fields(subscribe) -> fields(unsubscribe) -> [ {topic, hoconsc:mk(binary(), #{desc => <<"Topic">>, example => <<"testtopic/#">>})} + ]; +fields(mqueue_messages) -> + [ + {data, hoconsc:mk(hoconsc:array(?REF(message)), #{desc => ?DESC(mqueue_msgs_list)})}, + {meta, hoconsc:mk(hoconsc:ref(emqx_dashboard_swagger, continuation_meta), #{})} + ]; +fields(inflight_messages) -> + [ + {data, hoconsc:mk(hoconsc:array(?REF(message)), #{desc => ?DESC(inflight_msgs_list)})}, + {meta, hoconsc:mk(hoconsc:ref(emqx_dashboard_swagger, continuation_meta), #{})} + ]; +fields(message) -> + [ + {msgid, hoconsc:mk(binary(), #{desc => ?DESC(msg_id)})}, + {topic, hoconsc:mk(binary(), #{desc => ?DESC(msg_topic)})}, + {qos, hoconsc:mk(emqx_schema:qos(), #{desc => ?DESC(msg_qos)})}, + {publish_at, hoconsc:mk(integer(), #{desc => ?DESC(msg_publish_at)})}, + {from_clientid, hoconsc:mk(binary(), #{desc => ?DESC(msg_from_clientid)})}, + {from_username, hoconsc:mk(binary(), #{desc => ?DESC(msg_from_username)})}, + {payload, hoconsc:mk(binary(), #{desc => ?DESC(msg_payload)})} + ]; +fields(requested_client_fields) -> + %% NOTE: some Client fields actually returned in response are missing in schema: + %% enable_authn, is_persistent, listener, peerport + ClientFields = [element(1, F) || F <- fields(client)], + [ + {fields, + hoconsc:mk( + hoconsc:union([all, hoconsc:array(hoconsc:enum(ClientFields))]), + #{ + in => query, + required => false, + default => all, + desc => <<"Comma separated list of client fields to return in the response">>, + converter => fun + (all, _Opts) -> + all; + (<<"all">>, _Opts) -> + all; + (CsvFields, _Opts) when is_binary(CsvFields) -> + binary:split(CsvFields, <<",">>, [global, trim_all]) + end + } + )} ]. %%%============================================================================================== @@ -693,6 +769,15 @@ set_keepalive(put, #{bindings := #{clientid := ClientID}, body := Body}) -> end end. +mqueue_msgs(get, #{bindings := #{clientid := ClientID}, query_string := QString}) -> + list_client_msgs(mqueue_msgs, ClientID, QString). + +inflight_msgs(get, #{ + bindings := #{clientid := ClientID}, + query_string := QString +}) -> + list_client_msgs(inflight_msgs, ClientID, QString). + %%%============================================================================================== %% api apply @@ -825,6 +910,63 @@ unsubscribe_batch(#{clientid := ClientID, topics := Topics}) -> %%-------------------------------------------------------------------- %% internal function +client_msgs_schema(OpId, Desc, ContExample, RespSchema) -> + #{ + 'operationId' => OpId, + get => #{ + description => Desc, + tags => ?TAGS, + parameters => client_msgs_params(), + responses => #{ + 200 => + emqx_dashboard_swagger:schema_with_example(RespSchema, #{ + <<"data">> => [message_example()], + <<"meta">> => #{ + <<"count">> => 100, + <<"last">> => ContExample + } + }), + 400 => + emqx_dashboard_swagger:error_codes( + ['INVALID_PARAMETER'], <<"Invalid parameters">> + ), + 404 => emqx_dashboard_swagger:error_codes( + ['CLIENTID_NOT_FOUND'], <<"Client ID not found">> + ) + } + } + }. + +client_msgs_params() -> + [ + {clientid, hoconsc:mk(binary(), #{in => path})}, + {payload, + hoconsc:mk(hoconsc:enum([none, base64, plain]), #{ + in => query, + default => base64, + desc => << + "Client's inflight/mqueue messages payload encoding." + " If set to `none`, no payload is returned in the response." + >> + })}, + {max_payload_bytes, + hoconsc:mk(emqx_schema:bytesize(), #{ + in => query, + default => <<"1MB">>, + desc => << + "Client's inflight/mqueue messages payload limit." + " The total payload size of all messages in the response will not exceed this value." + " Messages beyond the limit will be silently omitted in the response." + " The only exception to this rule is when the first message payload" + " is already larger than the limit." + " In this case, the first message will be returned in the response." + >>, + validator => fun max_bytes_validator/1 + })}, + hoconsc:ref(emqx_dashboard_swagger, 'after'), + hoconsc:ref(emqx_dashboard_swagger, limit) + ]. + do_subscribe(ClientID, Topic0, Options) -> try emqx_topic:parse(Topic0, Options) of {Topic, Opts} -> @@ -870,7 +1012,10 @@ list_clients_cluster_query(QString, Options) -> ?CHAN_INFO_TAB, NQString, fun ?MODULE:qs2ms/2, Meta, Options ), Res = do_list_clients_cluster_query(Nodes, QueryState, ResultAcc), - emqx_mgmt_api:format_query_result(fun ?MODULE:format_channel_info/2, Meta, Res) + Opts = #{fields => maps:get(<<"fields">>, QString, all)}, + emqx_mgmt_api:format_query_result( + fun ?MODULE:format_channel_info/3, Meta, Res, Opts + ) catch throw:{bad_value_type, {Key, ExpectedType, AcutalValue}} -> {error, invalid_query_string_param, {Key, ExpectedType, AcutalValue}} @@ -922,7 +1067,8 @@ list_clients_node_query(Node, QString, Options) -> ?CHAN_INFO_TAB, NQString, fun ?MODULE:qs2ms/2, Meta, Options ), Res = do_list_clients_node_query(Node, QueryState, ResultAcc), - emqx_mgmt_api:format_query_result(fun ?MODULE:format_channel_info/2, Meta, Res) + Opts = #{fields => maps:get(<<"fields">>, QString, all)}, + emqx_mgmt_api:format_query_result(fun ?MODULE:format_channel_info/3, Meta, Res, Opts) end. add_persistent_session_count(QueryState0 = #{total := Totals0}) -> @@ -993,10 +1139,18 @@ do_persistent_session_count(Cursor, N) -> case emqx_persistent_session_ds_state:session_iterator_next(Cursor, 1) of {[], _} -> N; - {_, NextCursor} -> - do_persistent_session_count(NextCursor, N + 1) + {[{_Id, Meta}], NextCursor} -> + case is_expired(Meta) of + true -> + do_persistent_session_count(NextCursor, N); + false -> + do_persistent_session_count(NextCursor, N + 1) + end end. +is_expired(#{last_alive_at := LastAliveAt, expiry_interval := ExpiryInterval}) -> + LastAliveAt + ExpiryInterval < erlang:system_time(millisecond). + do_persistent_session_query(ResultAcc, QueryState) -> case emqx_persistent_message:is_persistence_enabled() of true -> @@ -1014,7 +1168,7 @@ do_persistent_session_query1(ResultAcc, QueryState, Iter0) -> %% through all the nodes. #{limit := Limit} = QueryState, {Rows0, Iter} = emqx_persistent_session_ds_state:session_iterator_next(Iter0, Limit), - Rows = remove_live_sessions(Rows0), + Rows = drop_live_and_expired(Rows0), case emqx_mgmt_api:accumulate_query_rows(undefined, Rows, QueryState, ResultAcc) of {enough, NResultAcc} -> emqx_mgmt_api:finalize_query(NResultAcc, emqx_mgmt_api:mark_complete(QueryState, true)); @@ -1024,19 +1178,50 @@ do_persistent_session_query1(ResultAcc, QueryState, Iter0) -> do_persistent_session_query1(NResultAcc, QueryState, Iter) end. -remove_live_sessions(Rows) -> +drop_live_and_expired(Rows) -> lists:filtermap( - fun({ClientId, _Session}) -> - case emqx_mgmt:lookup_running_client(ClientId, _FormatFn = undefined) of - [] -> - {true, {ClientId, emqx_persistent_session_ds_state:print_session(ClientId)}}; - [_ | _] -> - false + fun({ClientId, Session}) -> + case is_expired(Session) orelse is_live_session(ClientId) of + true -> + false; + false -> + {true, {ClientId, emqx_persistent_session_ds_state:print_session(ClientId)}} end end, Rows ). +%% Return 'true' if there is a live channel found in the global channel registry. +%% NOTE: We cannot afford to query all running nodes to find out if a session is live. +%% i.e. assuming the global session registry is always enabled. +%% Otherwise this function may return `false` for `true` causing the session to appear +%% twice in the query result. +is_live_session(ClientId) -> + [] =/= emqx_cm_registry:lookup_channels(ClientId). + +list_client_msgs(MsgType, ClientID, QString) -> + case emqx_mgmt_api:parse_cont_pager_params(QString, cont_encoding(MsgType)) of + false -> + {400, #{code => <<"INVALID_PARAMETER">>, message => <<"after_limit_invalid">>}}; + PagerParams = #{} -> + case emqx_mgmt:list_client_msgs(MsgType, ClientID, PagerParams) of + {error, not_found} -> + {404, ?CLIENTID_NOT_FOUND}; + {Msgs, Meta = #{}} when is_list(Msgs) -> + format_msgs_resp(MsgType, Msgs, Meta, QString) + end + end. + +%% integer packet id +cont_encoding(inflight_msgs) -> ?URL_PARAM_INTEGER; +%% binary message id +cont_encoding(mqueue_msgs) -> ?URL_PARAM_BINARY. + +max_bytes_validator(MaxBytes) when is_integer(MaxBytes), MaxBytes > 0 -> + ok; +max_bytes_validator(_MaxBytes) -> + {error, "must be higher than 0"}. + %%-------------------------------------------------------------------- %% QueryString to Match Spec @@ -1050,19 +1235,36 @@ qs2ms(_Tab, {QString, FuzzyQString}) -> -spec qs2ms(list()) -> ets:match_spec(). qs2ms(Qs) -> {MtchHead, Conds} = qs2ms(Qs, 2, {#{}, []}), - [{{'$1', MtchHead, '_'}, Conds, ['$_']}]. + [{{{'$1', '_'}, MtchHead, '_'}, Conds, ['$_']}]. qs2ms([], _, {MtchHead, Conds}) -> {MtchHead, lists:reverse(Conds)}; +qs2ms([{Key, '=:=', Value} | Rest], N, {MtchHead, Conds}) when is_list(Value) -> + {Holder, NxtN} = holder_and_nxt(Key, N), + NMtchHead = emqx_mgmt_util:merge_maps(MtchHead, ms(Key, Holder)), + qs2ms(Rest, NxtN, {NMtchHead, [orelse_cond(Holder, Value) | Conds]}); qs2ms([{Key, '=:=', Value} | Rest], N, {MtchHead, Conds}) -> NMtchHead = emqx_mgmt_util:merge_maps(MtchHead, ms(Key, Value)), qs2ms(Rest, N, {NMtchHead, Conds}); qs2ms([Qs | Rest], N, {MtchHead, Conds}) -> - Holder = binary_to_atom(iolist_to_binary(["$", integer_to_list(N)]), utf8), + Holder = holder(N), NMtchHead = emqx_mgmt_util:merge_maps(MtchHead, ms(element(1, Qs), Holder)), NConds = put_conds(Qs, Holder, Conds), qs2ms(Rest, N + 1, {NMtchHead, NConds}). +%% This is a special case: clientid is a part of the key (ClientId, Pid}, as the table is ordered_set, +%% using partially bound key optimizes traversal. +holder_and_nxt(clientid, N) -> + {'$1', N}; +holder_and_nxt(_, N) -> + {holder(N), N + 1}. + +holder(N) -> list_to_atom([$$ | integer_to_list(N)]). + +orelse_cond(Holder, ValuesList) -> + Conds = [{'=:=', Holder, V} || V <- ValuesList], + erlang:list_to_tuple(['orelse' | Conds]). + put_conds({_, Op, V}, Holder, Conds) -> [{Op, Holder, V} | Conds]; put_conds({_, Op1, V1, Op2, V2}, Holder, Conds) -> @@ -1072,8 +1274,8 @@ put_conds({_, Op1, V1, Op2, V2}, Holder, Conds) -> | Conds ]. -ms(clientid, X) -> - #{clientinfo => #{clientid => X}}; +ms(clientid, _X) -> + #{}; ms(username, X) -> #{clientinfo => #{username => X}}; ms(conn_state, X) -> @@ -1117,7 +1319,11 @@ format_channel_info({ClientId, PSInfo}) -> %% offline persistent session format_persistent_session_info(ClientId, PSInfo). -format_channel_info(WhichNode, {_, ClientInfo0, ClientStats}) -> +format_channel_info(WhichNode, ChanInfo) -> + DefaultOpts = #{fields => all}, + format_channel_info(WhichNode, ChanInfo, DefaultOpts). + +format_channel_info(WhichNode, {_, ClientInfo0, ClientStats}, Opts) -> Node = maps:get(node, ClientInfo0, WhichNode), ClientInfo1 = emqx_utils_maps:deep_remove([conninfo, clientid], ClientInfo0), ClientInfo2 = emqx_utils_maps:deep_remove([conninfo, username], ClientInfo1), @@ -1136,45 +1342,17 @@ format_channel_info(WhichNode, {_, ClientInfo0, ClientStats}) -> ClientInfoMap5 = convert_expiry_interval_unit(ClientInfoMap4), ClientInfoMap = maps:put(connected, Connected, ClientInfoMap5), - RemoveList = - [ - auth_result, - peername, - sockname, - peerhost, - conn_state, - send_pend, - conn_props, - peercert, - sockstate, - subscriptions, - receive_maximum, - protocol, - is_superuser, - sockport, - anonymous, - socktype, - active_n, - await_rel_timeout, - conn_mod, - sockname, - retry_interval, - upgrade_qos, - zone, - %% session_id, defined in emqx_session.erl - id, - acl - ], + #{fields := RequestedFields} = Opts, TimesKeys = [created_at, connected_at, disconnected_at], %% format timestamp to rfc3339 result_format_undefined_to_null( lists:foldl( fun result_format_time_fun/2, - maps:without(RemoveList, ClientInfoMap), + with_client_info_fields(ClientInfoMap, RequestedFields), TimesKeys ) ); -format_channel_info(undefined, {ClientId, PSInfo0 = #{}}) -> +format_channel_info(undefined, {ClientId, PSInfo0 = #{}}, _Opts) -> format_persistent_session_info(ClientId, PSInfo0). format_persistent_session_info(ClientId, PSInfo0) -> @@ -1204,6 +1382,114 @@ format_persistent_session_info(ClientId, PSInfo0) -> ), result_format_undefined_to_null(PSInfo). +with_client_info_fields(ClientInfoMap, all) -> + RemoveList = + [ + auth_result, + peername, + sockname, + peerhost, + peerport, + conn_state, + send_pend, + conn_props, + peercert, + sockstate, + subscriptions, + receive_maximum, + protocol, + is_superuser, + sockport, + anonymous, + socktype, + active_n, + await_rel_timeout, + conn_mod, + sockname, + retry_interval, + upgrade_qos, + zone, + %% session_id, defined in emqx_session.erl + id, + acl + ], + maps:without(RemoveList, ClientInfoMap); +with_client_info_fields(ClientInfoMap, RequestedFields) when is_list(RequestedFields) -> + maps:with(RequestedFields, ClientInfoMap). + +format_msgs_resp(MsgType, Msgs, Meta, QString) -> + #{ + <<"payload">> := PayloadFmt, + <<"max_payload_bytes">> := MaxBytes + } = QString, + Meta1 = emqx_mgmt_api:encode_cont_pager_params(Meta, cont_encoding(MsgType)), + Resp = #{meta => Meta1, data => format_msgs(Msgs, PayloadFmt, MaxBytes)}, + %% Make sure minirest won't set another content-type for self-encoded JSON response body + Headers = #{<<"content-type">> => <<"application/json">>}, + case emqx_utils_json:safe_encode(Resp) of + {ok, RespBin} -> + {200, Headers, RespBin}; + _Error when PayloadFmt =:= plain -> + ?BAD_REQUEST( + <<"INVALID_PARAMETER">>, + <<"Some message payloads are not JSON serializable">> + ); + %% Unexpected internal error + Error -> + ?INTERNAL_ERROR(Error) + end. + +format_msgs([FirstMsg | Msgs], PayloadFmt, MaxBytes) -> + %% Always include at least one message payload, even if it exceeds the limit + {FirstMsg1, PayloadSize0} = format_msg(FirstMsg, PayloadFmt), + {Msgs1, _} = + catch lists:foldl( + fun(Msg, {MsgsAcc, SizeAcc} = Acc) -> + {Msg1, PayloadSize} = format_msg(Msg, PayloadFmt), + case SizeAcc + PayloadSize of + SizeAcc1 when SizeAcc1 =< MaxBytes -> + {[Msg1 | MsgsAcc], SizeAcc1}; + _ -> + throw(Acc) + end + end, + {[FirstMsg1], PayloadSize0}, + Msgs + ), + lists:reverse(Msgs1); +format_msgs([], _PayloadFmt, _MaxBytes) -> + []. + +format_msg( + #message{ + id = ID, + qos = Qos, + topic = Topic, + from = From, + timestamp = Timestamp, + headers = Headers, + payload = Payload + }, + PayloadFmt +) -> + Msg = #{ + msgid => emqx_guid:to_hexstr(ID), + qos => Qos, + topic => Topic, + publish_at => Timestamp, + from_clientid => emqx_utils_conv:bin(From), + from_username => maps:get(username, Headers, <<>>) + }, + format_payload(PayloadFmt, Msg, Payload). + +format_payload(none, Msg, _Payload) -> + {Msg, 0}; +format_payload(base64, Msg, Payload) -> + Payload1 = base64:encode(Payload), + {Msg#{payload => Payload1}, erlang:byte_size(Payload1)}; +format_payload(plain, Msg, Payload) -> + {Msg#{payload => Payload}, erlang:iolist_size(Payload)}. + %% format func helpers take_maps_from_inner(_Key, Value, Current) when is_map(Value) -> maps:merge(Current, Value); @@ -1305,7 +1591,24 @@ client_example() -> <<"recv_msg.qos0">> => 0 }. +message_example() -> + #{ + <<"msgid">> => <<"000611F460D57FA9F44500000D360002">>, + <<"topic">> => <<"t/test">>, + <<"qos">> => 0, + <<"publish_at">> => 1709055346487, + <<"from_clientid">> => <<"mqttx_59ac0a87">>, + <<"from_username">> => <<"test-user">>, + <<"payload">> => <<"eyJmb28iOiAiYmFyIn0=">> + }. + sessions_count(get, #{query_string := QString}) -> - Since = maps:get(<<"since">>, QString, 0), - Count = emqx_cm_registry_keeper:count(Since), - {200, integer_to_binary(Count)}. + try + Since = maps:get(<<"since">>, QString, 0), + Count = emqx_cm_registry_keeper:count(Since), + {200, integer_to_binary(Count)} + catch + exit:{noproc, _} -> + Msg = io_lib:format("Node (~s) cannot handle this request.", [node()]), + {400, 'BAD_REQUEST', iolist_to_binary(Msg)} + end. diff --git a/apps/emqx_management/src/emqx_mgmt_api_configs.erl b/apps/emqx_management/src/emqx_mgmt_api_configs.erl index a886d716f..f013dfcd1 100644 --- a/apps/emqx_management/src/emqx_mgmt_api_configs.erl +++ b/apps/emqx_management/src/emqx_mgmt_api_configs.erl @@ -407,7 +407,7 @@ get_configs_v1(QueryStr) -> Node = maps:get(<<"node">>, QueryStr, node()), case lists:member(Node, emqx:running_nodes()) andalso - emqx_management_proto_v4:get_full_config(Node) + emqx_management_proto_v5:get_full_config(Node) of false -> Message = list_to_binary(io_lib:format("Bad node ~p, reason not found", [Node])), diff --git a/apps/emqx_management/src/emqx_mgmt_api_listeners.erl b/apps/emqx_management/src/emqx_mgmt_api_listeners.erl index 2d2982b51..1c581514a 100644 --- a/apps/emqx_management/src/emqx_mgmt_api_listeners.erl +++ b/apps/emqx_management/src/emqx_mgmt_api_listeners.erl @@ -516,7 +516,7 @@ list_listeners() -> lists:map(fun list_listeners/1, [Self | lists:delete(Self, emqx:running_nodes())]). list_listeners(Node) -> - wrap_rpc(emqx_management_proto_v4:list_listeners(Node)). + wrap_rpc(emqx_management_proto_v5:list_listeners(Node)). listener_status_by_id(NodeL) -> Listeners = maps:to_list(listener_status_by_id(NodeL, #{})), diff --git a/apps/emqx_management/src/proto/emqx_management_proto_v5.erl b/apps/emqx_management/src/proto/emqx_management_proto_v5.erl new file mode 100644 index 000000000..eeaa6be02 --- /dev/null +++ b/apps/emqx_management/src/proto/emqx_management_proto_v5.erl @@ -0,0 +1,86 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2022-2024 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_management_proto_v5). + +-behaviour(emqx_bpapi). + +-export([ + introduced_in/0, + + node_info/1, + broker_info/1, + list_subscriptions/1, + + list_listeners/1, + subscribe/3, + unsubscribe/3, + unsubscribe_batch/3, + + call_client/3, + + get_full_config/1, + + kickout_clients/2 +]). + +-include_lib("emqx/include/bpapi.hrl"). + +introduced_in() -> + "5.6.0". + +-spec unsubscribe_batch(node(), emqx_types:clientid(), [emqx_types:topic()]) -> + {unsubscribe, _} | {error, _} | {badrpc, _}. +unsubscribe_batch(Node, ClientId, Topics) -> + rpc:call(Node, emqx_mgmt, do_unsubscribe_batch, [ClientId, Topics]). + +-spec node_info([node()]) -> emqx_rpc:erpc_multicall(map()). +node_info(Nodes) -> + erpc:multicall(Nodes, emqx_mgmt, node_info, [], 30000). + +-spec broker_info([node()]) -> emqx_rpc:erpc_multicall(map()). +broker_info(Nodes) -> + erpc:multicall(Nodes, emqx_mgmt, broker_info, [], 30000). + +-spec list_subscriptions(node()) -> [map()] | {badrpc, _}. +list_subscriptions(Node) -> + rpc:call(Node, emqx_mgmt, do_list_subscriptions, []). + +-spec list_listeners(node()) -> map() | {badrpc, _}. +list_listeners(Node) -> + rpc:call(Node, emqx_mgmt_api_listeners, do_list_listeners, []). + +-spec subscribe(node(), emqx_types:clientid(), emqx_types:topic_filters()) -> + {subscribe, _} | {error, atom()} | {badrpc, _}. +subscribe(Node, ClientId, TopicTables) -> + rpc:call(Node, emqx_mgmt, do_subscribe, [ClientId, TopicTables]). + +-spec unsubscribe(node(), emqx_types:clientid(), emqx_types:topic()) -> + {unsubscribe, _} | {error, _} | {badrpc, _}. +unsubscribe(Node, ClientId, Topic) -> + rpc:call(Node, emqx_mgmt, do_unsubscribe, [ClientId, Topic]). + +-spec call_client([node()], emqx_types:clientid(), term()) -> emqx_rpc:erpc_multicall(term()). +call_client(Nodes, ClientId, Req) -> + erpc:multicall(Nodes, emqx_mgmt, do_call_client, [ClientId, Req], 30000). + +-spec get_full_config(node()) -> map() | list() | {badrpc, _}. +get_full_config(Node) -> + rpc:call(Node, emqx_mgmt_api_configs, get_full_config, []). + +-spec kickout_clients(node(), [emqx_types:clientid()]) -> ok | {badrpc, _}. +kickout_clients(Node, ClientIds) -> + rpc:call(Node, emqx_mgmt, do_kickout_clients, [ClientIds]). diff --git a/apps/emqx_management/test/emqx_mgmt_SUITE.erl b/apps/emqx_management/test/emqx_mgmt_SUITE.erl index 9ce737353..86237c17b 100644 --- a/apps/emqx_management/test/emqx_mgmt_SUITE.erl +++ b/apps/emqx_management/test/emqx_mgmt_SUITE.erl @@ -28,14 +28,19 @@ all() -> [ {group, persistence_disabled}, - {group, persistence_enabled} + {group, persistence_enabled}, + {group, cm_registry_enabled}, + {group, cm_registry_disabled} ]. groups() -> - TCs = emqx_common_test_helpers:all(?MODULE), + CMRegistryTCs = [t_call_client_cluster], + TCs = emqx_common_test_helpers:all(?MODULE) -- CMRegistryTCs, [ {persistence_disabled, [], TCs}, - {persistence_enabled, [], [t_persist_list_subs]} + {persistence_enabled, [], [t_persist_list_subs]}, + {cm_registry_enabled, CMRegistryTCs}, + {cm_registry_disabled, CMRegistryTCs} ]. init_per_group(persistence_disabled, Config) -> @@ -66,10 +71,17 @@ init_per_group(persistence_enabled, Config) -> [ {apps, Apps} | Config - ]. + ]; +init_per_group(cm_registry_enabled, Config) -> + [{emqx_config, "broker.enable_session_registry = true"} | Config]; +init_per_group(cm_registry_disabled, Config) -> + [{emqx_config, "broker.enable_session_registry = false"} | Config]. end_per_group(_Grp, Config) -> - emqx_cth_suite:stop(?config(apps, Config)). + case ?config(apps, Config) of + undefined -> ok; + Apps -> emqx_cth_suite:stop(Apps) + end. init_per_suite(Config) -> Config. @@ -447,6 +459,83 @@ t_persist_list_subs(_) -> %% clients: VerifySubs(). +t_call_client_cluster(Config) -> + [Node1, Node2] = ?config(cluster, Config), + [Node1ClientId, Node2ClientId] = ?config(client_ids, Config), + ?assertMatch( + {[], #{}}, rpc:call(Node1, emqx_mgmt, list_client_msgs, client_msgs_args(Node1ClientId)) + ), + ?assertMatch( + {[], #{}}, rpc:call(Node2, emqx_mgmt, list_client_msgs, client_msgs_args(Node2ClientId)) + ), + ?assertMatch( + {[], #{}}, rpc:call(Node1, emqx_mgmt, list_client_msgs, client_msgs_args(Node2ClientId)) + ), + ?assertMatch( + {[], #{}}, rpc:call(Node2, emqx_mgmt, list_client_msgs, client_msgs_args(Node1ClientId)) + ), + + case proplists:get_value(name, ?config(tc_group_properties, Config)) of + cm_registry_disabled -> + %% Simulating crashes that must be handled by erpc multicall + ?assertMatch( + {error, _}, + rpc:call(Node1, emqx_mgmt, list_client_msgs, client_msgs_bad_args(Node2ClientId)) + ), + ?assertMatch( + {error, _}, + rpc:call(Node2, emqx_mgmt, list_client_msgs, client_msgs_bad_args(Node1ClientId)) + ); + cm_registry_enabled -> + %% Direct call to remote pid is expected to crash + ?assertMatch( + {badrpc, {'EXIT', _}}, + rpc:call(Node1, emqx_mgmt, list_client_msgs, client_msgs_bad_args(Node1ClientId)) + ), + ?assertMatch( + {badrpc, {'EXIT', _}}, + rpc:call(Node2, emqx_mgmt, list_client_msgs, client_msgs_bad_args(Node2ClientId)) + ); + _ -> + ok + end, + + NotFoundClientId = <<"no_such_client_id">>, + ?assertEqual( + {error, not_found}, + rpc:call(Node2, emqx_mgmt, list_client_msgs, client_msgs_args(NotFoundClientId)) + ), + ?assertEqual( + {error, not_found}, + rpc:call(Node2, emqx_mgmt, list_client_msgs, client_msgs_args(NotFoundClientId)) + ). + +t_call_client_cluster(init, Config) -> + Apps = [{emqx, ?config(emqx_config, Config)}, emqx_management], + [Node1, Node2] = + Cluster = emqx_cth_cluster:start( + [ + {list_to_atom(atom_to_list(?MODULE) ++ "1"), #{role => core, apps => Apps}}, + {list_to_atom(atom_to_list(?MODULE) ++ "2"), #{role => core, apps => Apps}} + ], + #{work_dir => emqx_cth_suite:work_dir(?FUNCTION_NAME, Config)} + ), + {ok, Node1Client, Node1ClientId} = connect_client(Node1), + {ok, Node2Client, Node2ClientId} = connect_client(Node2), + %% They may exit during the test due to simulated crashes + unlink(Node1Client), + unlink(Node2Client), + [ + {cluster, Cluster}, + {client_ids, [Node1ClientId, Node2ClientId]}, + {client_pids, [Node1Client, Node2Client]} + | Config + ]; +t_call_client_cluster('end', Config) -> + emqx_cth_cluster:stop(?config(cluster, Config)), + [exit(ClientPid, kill) || ClientPid <- ?config(client_pids, Config)], + ok. + %%% helpers ident(Arg) -> Arg. @@ -462,3 +551,24 @@ setup_clients(Config) -> disconnect_clients(Config) -> Clients = ?config(clients, Config), lists:foreach(fun emqtt:disconnect/1, Clients). + +get_mqtt_port(Node) -> + {_IP, Port} = erpc:call(Node, emqx_config, get, [[listeners, tcp, default, bind]]), + Port. + +connect_client(Node) -> + Port = get_mqtt_port(Node), + ClientId = <<(atom_to_binary(Node))/binary, "_client">>, + {ok, Client} = emqtt:start_link([ + {port, Port}, + {proto_ver, v5}, + {clientid, ClientId} + ]), + {ok, _} = emqtt:connect(Client), + {ok, Client, ClientId}. + +client_msgs_args(ClientId) -> + [mqueue_msgs, ClientId, #{limit => 10, continuation => none}]. + +client_msgs_bad_args(ClientId) -> + [mqueue_msgs, ClientId, "bad_page_params"]. diff --git a/apps/emqx_management/test/emqx_mgmt_api_clients_SUITE.erl b/apps/emqx_management/test/emqx_mgmt_api_clients_SUITE.erl index e4ad37e04..574f790fc 100644 --- a/apps/emqx_management/test/emqx_mgmt_api_clients_SUITE.erl +++ b/apps/emqx_management/test/emqx_mgmt_api_clients_SUITE.erl @@ -23,16 +23,23 @@ -include_lib("common_test/include/ct.hrl"). -include_lib("snabbkaffe/include/snabbkaffe.hrl"). -include_lib("emqx/include/asserts.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). all() -> AllTCs = emqx_common_test_helpers:all(?MODULE), [ - {group, persistent_sessions} - | AllTCs -- persistent_session_testcases() + {group, persistent_sessions}, + {group, msgs_base64_encoding}, + {group, msgs_plain_encoding} + | AllTCs -- (persistent_session_testcases() ++ client_msgs_testcases()) ]. groups() -> - [{persistent_sessions, persistent_session_testcases()}]. + [ + {persistent_sessions, persistent_session_testcases()}, + {msgs_base64_encoding, client_msgs_testcases()}, + {msgs_plain_encoding, client_msgs_testcases()} + ]. persistent_session_testcases() -> [ @@ -42,12 +49,19 @@ persistent_session_testcases() -> t_persistent_sessions4, t_persistent_sessions5 ]. +client_msgs_testcases() -> + [ + t_inflight_messages, + t_mqueue_messages + ]. init_per_suite(Config) -> + ok = snabbkaffe:start_trace(), emqx_mgmt_api_test_util:init_suite(), Config. end_per_suite(_) -> + ok = snabbkaffe:stop(), emqx_mgmt_api_test_util:end_suite(). init_per_group(persistent_sessions, Config) -> @@ -67,6 +81,10 @@ init_per_group(persistent_sessions, Config) -> #{work_dir => emqx_cth_suite:work_dir(Config)} ), [{nodes, Nodes} | Config]; +init_per_group(msgs_base64_encoding, Config) -> + [{payload_encoding, base64} | Config]; +init_per_group(msgs_plain_encoding, Config) -> + [{payload_encoding, plain} | Config]; init_per_group(_Group, Config) -> Config. @@ -77,6 +95,21 @@ end_per_group(persistent_sessions, Config) -> end_per_group(_Group, _Config) -> ok. +end_per_testcase(TC, _Config) when + TC =:= t_inflight_messages; + TC =:= t_mqueue_messages +-> + ClientId = atom_to_binary(TC), + lists:foreach(fun(P) -> exit(P, kill) end, emqx_cm:lookup_channels(local, ClientId)), + ok = emqx_common_test_helpers:wait_for( + ?FUNCTION_NAME, + ?LINE, + fun() -> [] =:= emqx_cm:lookup_channels(local, ClientId) end, + 5000 + ); +end_per_testcase(_TC, _Config) -> + ok. + t_clients(_) -> process_flag(trap_exit, true), @@ -682,6 +715,238 @@ t_query_clients_with_time(_) -> {ok, _} = emqx_mgmt_api_test_util:request_api(delete, Client1Path), {ok, _} = emqx_mgmt_api_test_util:request_api(delete, Client2Path). +t_query_multiple_clients(_) -> + process_flag(trap_exit, true), + ClientIdsUsers = [ + {<<"multi_client1">>, <<"multi_user1">>}, + {<<"multi_client1-1">>, <<"multi_user1">>}, + {<<"multi_client2">>, <<"multi_user2">>}, + {<<"multi_client2-1">>, <<"multi_user2">>}, + {<<"multi_client3">>, <<"multi_user3">>}, + {<<"multi_client3-1">>, <<"multi_user3">>}, + {<<"multi_client4">>, <<"multi_user4">>}, + {<<"multi_client4-1">>, <<"multi_user4">>} + ], + _Clients = lists:map( + fun({ClientId, Username}) -> + {ok, C} = emqtt:start_link(#{clientid => ClientId, username => Username}), + {ok, _} = emqtt:connect(C), + C + end, + ClientIdsUsers + ), + timer:sleep(100), + + Auth = emqx_mgmt_api_test_util:auth_header_(), + + %% Not found clients/users + ?assertEqual([], get_clients(Auth, "clientid=no_such_client")), + ?assertEqual([], get_clients(Auth, "clientid=no_such_client&clientid=no_such_client1")), + %% Duplicates must cause no issues + ?assertEqual([], get_clients(Auth, "clientid=no_such_client&clientid=no_such_client")), + ?assertEqual([], get_clients(Auth, "username=no_such_user&clientid=no_such_user1")), + ?assertEqual([], get_clients(Auth, "username=no_such_user&clientid=no_such_user")), + ?assertEqual( + [], + get_clients( + Auth, + "clientid=no_such_client&clientid=no_such_client" + "username=no_such_user&clientid=no_such_user1" + ) + ), + + %% Requested ClientId / username values relate to different clients + ?assertEqual([], get_clients(Auth, "clientid=multi_client1&username=multi_user2")), + ?assertEqual( + [], + get_clients( + Auth, + "clientid=multi_client1&clientid=multi_client1-1" + "&username=multi_user2&username=multi_user3" + ) + ), + ?assertEqual([<<"multi_client1">>], get_clients(Auth, "clientid=multi_client1")), + %% Duplicates must cause no issues + ?assertEqual( + [<<"multi_client1">>], get_clients(Auth, "clientid=multi_client1&clientid=multi_client1") + ), + ?assertEqual( + [<<"multi_client1">>], get_clients(Auth, "clientid=multi_client1&username=multi_user1") + ), + ?assertEqual( + lists:sort([<<"multi_client1">>, <<"multi_client1-1">>]), + lists:sort(get_clients(Auth, "username=multi_user1")) + ), + ?assertEqual( + lists:sort([<<"multi_client1">>, <<"multi_client1-1">>]), + lists:sort(get_clients(Auth, "clientid=multi_client1&clientid=multi_client1-1")) + ), + ?assertEqual( + lists:sort([<<"multi_client1">>, <<"multi_client1-1">>]), + lists:sort( + get_clients( + Auth, + "clientid=multi_client1&clientid=multi_client1-1" + "&username=multi_user1" + ) + ) + ), + ?assertEqual( + lists:sort([<<"multi_client1">>, <<"multi_client1-1">>]), + lists:sort( + get_clients( + Auth, + "clientid=no-such-client&clientid=multi_client1&clientid=multi_client1-1" + "&username=multi_user1" + ) + ) + ), + ?assertEqual( + lists:sort([<<"multi_client1">>, <<"multi_client1-1">>]), + lists:sort( + get_clients( + Auth, + "clientid=no-such-client&clientid=multi_client1&clientid=multi_client1-1" + "&username=multi_user1&username=no-such-user" + ) + ) + ), + + AllQsFun = fun(QsKey, Pos) -> + QsParts = [ + QsKey ++ "=" ++ binary_to_list(element(Pos, ClientUser)) + || ClientUser <- ClientIdsUsers + ], + lists:flatten(lists:join("&", QsParts)) + end, + AllClientsQs = AllQsFun("clientid", 1), + AllUsersQs = AllQsFun("username", 2), + AllClientIds = lists:sort([C || {C, _U} <- ClientIdsUsers]), + + ?assertEqual(AllClientIds, lists:sort(get_clients(Auth, AllClientsQs))), + ?assertEqual(AllClientIds, lists:sort(get_clients(Auth, AllUsersQs))), + ?assertEqual(AllClientIds, lists:sort(get_clients(Auth, AllClientsQs ++ "&" ++ AllUsersQs))), + + %% Test with other filter params + NodeQs = "&node=" ++ atom_to_list(node()), + NoNodeQs = "&node=nonode@nohost", + ?assertEqual( + AllClientIds, lists:sort(get_clients(Auth, AllClientsQs ++ "&" ++ AllUsersQs ++ NodeQs)) + ), + ?assertMatch( + {error, _}, get_clients_expect_error(Auth, AllClientsQs ++ "&" ++ AllUsersQs ++ NoNodeQs) + ), + + %% fuzzy search (like_{key}) must be ignored if accurate filter ({key}) is present + ?assertEqual( + AllClientIds, + lists:sort(get_clients(Auth, AllClientsQs ++ "&" ++ AllUsersQs ++ "&like_clientid=multi")) + ), + ?assertEqual( + AllClientIds, + lists:sort(get_clients(Auth, AllClientsQs ++ "&" ++ AllUsersQs ++ "&like_username=multi")) + ), + ?assertEqual( + AllClientIds, + lists:sort( + get_clients(Auth, AllClientsQs ++ "&" ++ AllUsersQs ++ "&like_clientid=does-not-matter") + ) + ), + ?assertEqual( + AllClientIds, + lists:sort( + get_clients(Auth, AllClientsQs ++ "&" ++ AllUsersQs ++ "&like_username=does-not-matter") + ) + ), + + %% Combining multiple clientids with like_username and vice versa must narrow down search results + ?assertEqual( + lists:sort([<<"multi_client1">>, <<"multi_client1-1">>]), + lists:sort(get_clients(Auth, AllClientsQs ++ "&like_username=user1")) + ), + ?assertEqual( + lists:sort([<<"multi_client1">>, <<"multi_client1-1">>]), + lists:sort(get_clients(Auth, AllUsersQs ++ "&like_clientid=client1")) + ), + ?assertEqual([], get_clients(Auth, AllClientsQs ++ "&like_username=nouser")), + ?assertEqual([], get_clients(Auth, AllUsersQs ++ "&like_clientid=nouser")). + +t_query_multiple_clients_urlencode(_) -> + process_flag(trap_exit, true), + ClientIdsUsers = [ + {<<"multi_client=a?">>, <<"multi_user=a?">>}, + {<<"mutli_client=b?">>, <<"multi_user=b?">>} + ], + _Clients = lists:map( + fun({ClientId, Username}) -> + {ok, C} = emqtt:start_link(#{clientid => ClientId, username => Username}), + {ok, _} = emqtt:connect(C), + C + end, + ClientIdsUsers + ), + timer:sleep(100), + + Auth = emqx_mgmt_api_test_util:auth_header_(), + ClientsQs = uri_string:compose_query([{<<"clientid">>, C} || {C, _} <- ClientIdsUsers]), + UsersQs = uri_string:compose_query([{<<"username">>, U} || {_, U} <- ClientIdsUsers]), + ExpectedClients = lists:sort([C || {C, _} <- ClientIdsUsers]), + ?assertEqual(ExpectedClients, lists:sort(get_clients(Auth, ClientsQs))), + ?assertEqual(ExpectedClients, lists:sort(get_clients(Auth, UsersQs))). + +t_query_clients_with_fields(_) -> + process_flag(trap_exit, true), + TCBin = atom_to_binary(?FUNCTION_NAME), + ClientId = <>, + Username = <>, + {ok, C} = emqtt:start_link(#{clientid => ClientId, username => Username}), + {ok, _} = emqtt:connect(C), + timer:sleep(100), + + Auth = emqx_mgmt_api_test_util:auth_header_(), + ?assertEqual([#{<<"clientid">> => ClientId}], get_clients_all_fields(Auth, "fields=clientid")), + ?assertEqual( + [#{<<"clientid">> => ClientId, <<"username">> => Username}], + get_clients_all_fields(Auth, "fields=clientid,username") + ), + + AllFields = get_clients_all_fields(Auth, "fields=all"), + DefaultFields = get_clients_all_fields(Auth, ""), + + ?assertEqual(AllFields, DefaultFields), + ?assertMatch( + [#{<<"clientid">> := ClientId, <<"username">> := Username}], + AllFields + ), + ?assert(map_size(hd(AllFields)) > 2), + ?assertMatch({error, _}, get_clients_expect_error(Auth, "fields=bad_field_name")), + ?assertMatch({error, _}, get_clients_expect_error(Auth, "fields=all,bad_field_name")), + ?assertMatch({error, _}, get_clients_expect_error(Auth, "fields=all,username,clientid")). + +get_clients_all_fields(Auth, Qs) -> + get_clients(Auth, Qs, false, false). + +get_clients_expect_error(Auth, Qs) -> + get_clients(Auth, Qs, true, true). + +get_clients(Auth, Qs) -> + get_clients(Auth, Qs, false, true). + +get_clients(Auth, Qs, ExpectError, ClientIdOnly) -> + ClientsPath = emqx_mgmt_api_test_util:api_path(["clients"]), + Resp = emqx_mgmt_api_test_util:request_api(get, ClientsPath, Qs, Auth), + case ExpectError of + false -> + {ok, Body} = Resp, + #{<<"data">> := Clients} = emqx_utils_json:decode(Body), + case ClientIdOnly of + true -> [ClientId || #{<<"clientid">> := ClientId} <- Clients]; + false -> Clients + end; + true -> + Resp + end. + t_keepalive(_Config) -> Username = "user_keepalive", ClientId = "client_keepalive", @@ -759,8 +1024,262 @@ t_client_id_not_found(_Config) -> ?assertMatch({error, {Http, _, Body}}, PostFun(post, PathFun(["unsubscribe"]), UnsubBody)), ?assertMatch( {error, {Http, _, Body}}, PostFun(post, PathFun(["unsubscribe", "bulk"]), [UnsubBody]) + ), + %% Mqueue messages + ?assertMatch({error, {Http, _, Body}}, ReqFun(get, PathFun(["mqueue_messages"]))), + %% Inflight messages + ?assertMatch({error, {Http, _, Body}}, ReqFun(get, PathFun(["inflight_messages"]))). + +t_sessions_count(_Config) -> + ClientId = atom_to_binary(?FUNCTION_NAME), + Topic = <<"t/test_sessions_count">>, + Conf0 = emqx_config:get([broker]), + Conf1 = hocon_maps:deep_merge(Conf0, #{session_history_retain => 5}), + %% from 1 seconds ago, which is for sure less than histry retain duration + %% hence force a call to the gen_server emqx_cm_registry_keeper + Since = erlang:system_time(seconds) - 1, + ok = emqx_config:put(#{broker => Conf1}), + {ok, Client} = emqtt:start_link([ + {proto_ver, v5}, + {clientid, ClientId}, + {clean_start, true} + ]), + {ok, _} = emqtt:connect(Client), + {ok, _, _} = emqtt:subscribe(Client, Topic, 1), + Path = emqx_mgmt_api_test_util:api_path(["sessions_count"]), + AuthHeader = emqx_mgmt_api_test_util:auth_header_(), + ?assertMatch( + {ok, "1"}, + emqx_mgmt_api_test_util:request_api( + get, Path, "since=" ++ integer_to_list(Since), AuthHeader + ) + ), + ok = emqtt:disconnect(Client), + %% simulate the situation in which the process is not running + ok = supervisor:terminate_child(emqx_cm_sup, emqx_cm_registry_keeper), + ?assertMatch( + {error, {_, 400, _}}, + emqx_mgmt_api_test_util:request_api( + get, Path, "since=" ++ integer_to_list(Since), AuthHeader + ) + ), + %% restore default value + ok = emqx_config:put(#{broker => Conf0}), + ok = emqx_cm_registry_keeper:purge(), + ok. + +t_mqueue_messages(Config) -> + ClientId = atom_to_binary(?FUNCTION_NAME), + Topic = <<"t/test_mqueue_msgs">>, + Count = emqx_mgmt:default_row_limit(), + {ok, _Client} = client_with_mqueue(ClientId, Topic, Count), + Path = emqx_mgmt_api_test_util:api_path(["clients", ClientId, "mqueue_messages"]), + ?assert(Count =< emqx:get_config([mqtt, max_mqueue_len])), + AuthHeader = emqx_mgmt_api_test_util:auth_header_(), + test_messages(Path, Topic, Count, AuthHeader, ?config(payload_encoding, Config)), + + ?assertMatch( + {error, {_, 400, _}}, + emqx_mgmt_api_test_util:request_api( + get, Path, "limit=10&after=not-base64%23%21", AuthHeader + ) + ), + ?assertMatch( + {error, {_, 400, _}}, + emqx_mgmt_api_test_util:request_api( + get, Path, "limit=-5&after=not-base64%23%21", AuthHeader + ) ). +t_inflight_messages(Config) -> + ClientId = atom_to_binary(?FUNCTION_NAME), + Topic = <<"t/test_inflight_msgs">>, + PubCount = emqx_mgmt:default_row_limit(), + {ok, Client} = client_with_inflight(ClientId, Topic, PubCount), + Path = emqx_mgmt_api_test_util:api_path(["clients", ClientId, "inflight_messages"]), + InflightLimit = emqx:get_config([mqtt, max_inflight]), + AuthHeader = emqx_mgmt_api_test_util:auth_header_(), + test_messages(Path, Topic, InflightLimit, AuthHeader, ?config(payload_encoding, Config)), + + ?assertMatch( + {error, {_, 400, _}}, + emqx_mgmt_api_test_util:request_api( + get, Path, "limit=10&after=not-int", AuthHeader + ) + ), + ?assertMatch( + {error, {_, 400, _}}, + emqx_mgmt_api_test_util:request_api( + get, Path, "limit=-5&after=invalid-int", AuthHeader + ) + ), + emqtt:stop(Client). + +client_with_mqueue(ClientId, Topic, Count) -> + {ok, Client} = emqtt:start_link([ + {proto_ver, v5}, + {clientid, ClientId}, + {clean_start, false}, + {properties, #{'Session-Expiry-Interval' => 120}} + ]), + {ok, _} = emqtt:connect(Client), + {ok, _, _} = emqtt:subscribe(Client, Topic, 1), + ok = emqtt:disconnect(Client), + publish_msgs(Topic, Count), + {ok, Client}. + +client_with_inflight(ClientId, Topic, Count) -> + {ok, Client} = emqtt:start_link([ + {proto_ver, v5}, + {clientid, ClientId}, + {clean_start, true}, + {auto_ack, never} + ]), + {ok, _} = emqtt:connect(Client), + {ok, _, _} = emqtt:subscribe(Client, Topic, 1), + publish_msgs(Topic, Count), + {ok, Client}. + +publish_msgs(Topic, Count) -> + lists:foreach( + fun(Seq) -> + emqx_broker:publish(emqx_message:make(undefined, ?QOS_1, Topic, integer_to_binary(Seq))) + end, + lists:seq(1, Count) + ). + +test_messages(Path, Topic, Count, AuthHeader, PayloadEncoding) -> + Qs0 = io_lib:format("payload=~s", [PayloadEncoding]), + {ok, MsgsResp} = emqx_mgmt_api_test_util:request_api(get, Path, Qs0, AuthHeader), + #{<<"meta">> := Meta, <<"data">> := Msgs} = emqx_utils_json:decode(MsgsResp), + + ?assertMatch( + #{ + <<"last">> := <<"end_of_data">>, + <<"count">> := Count + }, + Meta + ), + ?assertEqual(length(Msgs), Count), + lists:foreach( + fun({Seq, #{<<"payload">> := P} = M}) -> + ?assertEqual(Seq, binary_to_integer(decode_payload(P, PayloadEncoding))), + ?assertMatch( + #{ + <<"msgid">> := _, + <<"topic">> := Topic, + <<"qos">> := _, + <<"publish_at">> := _, + <<"from_clientid">> := _, + <<"from_username">> := _ + }, + M + ) + end, + lists:zip(lists:seq(1, Count), Msgs) + ), + + %% The first message payload is <<"1">>, + %% and when it is urlsafe base64 encoded (with no padding), it's <<"MQ">>, + %% so we cover both cases: + %% - when total payload size exceeds the limit, + %% - when the first message payload already exceeds the limit but is still returned in the response. + QsPayloadLimit = io_lib:format("payload=~s&max_payload_bytes=1", [PayloadEncoding]), + {ok, LimitedMsgsResp} = emqx_mgmt_api_test_util:request_api( + get, Path, QsPayloadLimit, AuthHeader + ), + #{<<"meta">> := _, <<"data">> := FirstMsgOnly} = emqx_utils_json:decode(LimitedMsgsResp), + ct:pal("~p", [FirstMsgOnly]), + ?assertEqual(1, length(FirstMsgOnly)), + ?assertEqual( + <<"1">>, decode_payload(maps:get(<<"payload">>, hd(FirstMsgOnly)), PayloadEncoding) + ), + + Limit = 19, + LastCont = lists:foldl( + fun(PageSeq, Cont) -> + Qs = io_lib:format("payload=~s&after=~s&limit=~p", [PayloadEncoding, Cont, Limit]), + {ok, MsgsRespP} = emqx_mgmt_api_test_util:request_api(get, Path, Qs, AuthHeader), + #{ + <<"meta">> := #{<<"last">> := NextCont} = MetaP, + <<"data">> := MsgsP + } = emqx_utils_json:decode(MsgsRespP), + ?assertMatch(#{<<"count">> := Count}, MetaP), + ?assertNotEqual(<<"end_of_data">>, NextCont), + ?assertEqual(length(MsgsP), Limit), + ExpFirstPayload = integer_to_binary(PageSeq * Limit - Limit + 1), + ExpLastPayload = integer_to_binary(PageSeq * Limit), + ?assertEqual( + ExpFirstPayload, decode_payload(maps:get(<<"payload">>, hd(MsgsP)), PayloadEncoding) + ), + ?assertEqual( + ExpLastPayload, + decode_payload(maps:get(<<"payload">>, lists:last(MsgsP)), PayloadEncoding) + ), + NextCont + end, + none, + lists:seq(1, Count div 19) + ), + LastPartialPage = Count div 19 + 1, + LastQs = io_lib:format("payload=~s&after=~s&limit=~p", [PayloadEncoding, LastCont, Limit]), + {ok, MsgsRespLastP} = emqx_mgmt_api_test_util:request_api(get, Path, LastQs, AuthHeader), + #{<<"meta">> := #{<<"last">> := EmptyCont} = MetaLastP, <<"data">> := MsgsLastP} = emqx_utils_json:decode( + MsgsRespLastP + ), + ?assertEqual(<<"end_of_data">>, EmptyCont), + ?assertMatch(#{<<"count">> := Count}, MetaLastP), + + ?assertEqual( + integer_to_binary(LastPartialPage * Limit - Limit + 1), + decode_payload(maps:get(<<"payload">>, hd(MsgsLastP)), PayloadEncoding) + ), + ?assertEqual( + integer_to_binary(Count), + decode_payload(maps:get(<<"payload">>, lists:last(MsgsLastP)), PayloadEncoding) + ), + + ExceedQs = io_lib:format("payload=~s&after=~s&limit=~p", [ + PayloadEncoding, EmptyCont, Limit + ]), + ?assertMatch( + {error, {_, 400, _}}, + emqx_mgmt_api_test_util:request_api(get, Path, ExceedQs, AuthHeader) + ), + + %% Invalid common page params + ?assertMatch( + {error, {_, 400, _}}, + emqx_mgmt_api_test_util:request_api(get, Path, "limit=0", AuthHeader) + ), + ?assertMatch( + {error, {_, 400, _}}, + emqx_mgmt_api_test_util:request_api(get, Path, "limit=limit", AuthHeader) + ), + + %% Invalid max_paylod_bytes param + ?assertMatch( + {error, {_, 400, _}}, + emqx_mgmt_api_test_util:request_api(get, Path, "max_payload_bytes=0", AuthHeader) + ), + ?assertMatch( + {error, {_, 400, _}}, + emqx_mgmt_api_test_util:request_api(get, Path, "max_payload_bytes=-1", AuthHeader) + ), + ?assertMatch( + {error, {_, 400, _}}, + emqx_mgmt_api_test_util:request_api(get, Path, "max_payload_bytes=-1MB", AuthHeader) + ), + ?assertMatch( + {error, {_, 400, _}}, + emqx_mgmt_api_test_util:request_api(get, Path, "max_payload_bytes=0MB", AuthHeader) + ). + +decode_payload(Payload, base64) -> + base64:decode(Payload); +decode_payload(Payload, _) -> + Payload. + t_subscribe_shared_topic(_Config) -> ClientId = <<"client_subscribe_shared">>, diff --git a/apps/emqx_management/test/emqx_mgmt_api_configs_SUITE.erl b/apps/emqx_management/test/emqx_mgmt_api_configs_SUITE.erl index e2302be93..2c90c9dac 100644 --- a/apps/emqx_management/test/emqx_mgmt_api_configs_SUITE.erl +++ b/apps/emqx_management/test/emqx_mgmt_api_configs_SUITE.erl @@ -287,12 +287,12 @@ t_configs_node({'init', Config}) -> (other_node, _) -> <<"log=2">>; (bad_node, _) -> {badrpc, bad} end, - meck:expect(emqx_management_proto_v4, get_full_config, F), + meck:expect(emqx_management_proto_v5, get_full_config, F), meck:expect(emqx_conf_proto_v3, get_hocon_config, F2), meck:expect(hocon_pp, do, fun(Conf, _) -> Conf end), Config; t_configs_node({'end', _}) -> - meck:unload([emqx, emqx_management_proto_v4, emqx_conf_proto_v3, hocon_pp]); + meck:unload([emqx, emqx_management_proto_v5, emqx_conf_proto_v3, hocon_pp]); t_configs_node(_) -> Node = atom_to_list(node()), diff --git a/apps/emqx_opentelemetry/src/emqx_opentelemetry.app.src b/apps/emqx_opentelemetry/src/emqx_opentelemetry.app.src index 7988388f4..81631b03a 100644 --- a/apps/emqx_opentelemetry/src/emqx_opentelemetry.app.src +++ b/apps/emqx_opentelemetry/src/emqx_opentelemetry.app.src @@ -1,6 +1,6 @@ {application, emqx_opentelemetry, [ {description, "OpenTelemetry for EMQX Broker"}, - {vsn, "0.2.3"}, + {vsn, "0.2.4"}, {registered, []}, {mod, {emqx_otel_app, []}}, {applications, [ diff --git a/apps/emqx_opentelemetry/src/emqx_otel_metrics.erl b/apps/emqx_opentelemetry/src/emqx_otel_metrics.erl index 757ea6a9b..185a40228 100644 --- a/apps/emqx_opentelemetry/src/emqx_otel_metrics.erl +++ b/apps/emqx_opentelemetry/src/emqx_otel_metrics.erl @@ -104,7 +104,7 @@ safe_stop_default_metrics() -> _ = opentelemetry_experimental:stop_default_metrics(), ok catch - %% noramal scenario, metrics supervisor is not started + %% normal scenario, metrics supervisor is not started exit:{noproc, _} -> ok end. @@ -254,6 +254,18 @@ create_counter(Meter, Counters, CallBack) -> Counters ). +%% Note: list_to_existing_atom("cpu.use") will crash +%% so we make sure the atom is already existing here +normalize_name(cpu_use) -> + 'cpu.use'; +normalize_name(cpu_idle) -> + 'cpu.idle'; +normalize_name(run_queue) -> + 'run.queue'; +normalize_name(total_memory) -> + 'total.memory'; +normalize_name(used_memory) -> + 'used.memory'; normalize_name(Name) -> list_to_existing_atom(lists:flatten(string:replace(atom_to_list(Name), "_", ".", all))). diff --git a/apps/emqx_prometheus/src/emqx_prometheus.erl b/apps/emqx_prometheus/src/emqx_prometheus.erl index b2aca37b6..8556e82d3 100644 --- a/apps/emqx_prometheus/src/emqx_prometheus.erl +++ b/apps/emqx_prometheus/src/emqx_prometheus.erl @@ -195,7 +195,7 @@ collect_mf(?PROMETHEUS_DEFAULT_REGISTRY, Callback) -> ok = add_collect_family(Callback, stats_metric_meta(), ?MG(stats_data, RawData)), ok = add_collect_family( Callback, - stats_metric_cluster_consistened_meta(), + stats_metric_cluster_consistented_meta(), ?MG(stats_data_cluster_consistented, RawData) ), ok = add_collect_family(Callback, vm_metric_meta(), ?MG(vm_data, RawData)), @@ -502,8 +502,6 @@ stats_metric_meta() -> {emqx_sessions_max, gauge, 'sessions.max'}, {emqx_channels_count, gauge, 'channels.count'}, {emqx_channels_max, gauge, 'channels.max'}, - {emqx_cluster_sessions_count, gauge, 'cluster_sessions.count'}, - {emqx_cluster_sessions_max, gauge, 'cluster_sessions.max'}, %% pub/sub stats {emqx_suboptions_count, gauge, 'suboptions.count'}, {emqx_suboptions_max, gauge, 'suboptions.max'}, @@ -511,21 +509,25 @@ stats_metric_meta() -> {emqx_subscribers_max, gauge, 'subscribers.max'}, {emqx_subscriptions_count, gauge, 'subscriptions.count'}, {emqx_subscriptions_max, gauge, 'subscriptions.max'}, - {emqx_subscriptions_shared_count, gauge, 'subscriptions.shared.count'}, - {emqx_subscriptions_shared_max, gauge, 'subscriptions.shared.max'}, %% delayed {emqx_delayed_count, gauge, 'delayed.count'}, {emqx_delayed_max, gauge, 'delayed.max'} ]. -stats_metric_cluster_consistened_meta() -> +stats_metric_cluster_consistented_meta() -> [ + %% sessions + {emqx_cluster_sessions_count, gauge, 'cluster_sessions.count'}, + {emqx_cluster_sessions_max, gauge, 'cluster_sessions.max'}, %% topics {emqx_topics_max, gauge, 'topics.max'}, {emqx_topics_count, gauge, 'topics.count'}, %% retained {emqx_retained_count, gauge, 'retained.count'}, - {emqx_retained_max, gauge, 'retained.max'} + {emqx_retained_max, gauge, 'retained.max'}, + %% shared subscriptions + {emqx_subscriptions_shared_count, gauge, 'subscriptions.shared.count'}, + {emqx_subscriptions_shared_max, gauge, 'subscriptions.shared.max'} ]. stats_data(Mode) -> @@ -545,7 +547,7 @@ stats_data_cluster_consistented() -> AccIn#{Name => [{[], ?C(MetricKAtom, Stats)}]} end, #{}, - stats_metric_cluster_consistened_meta() + stats_metric_cluster_consistented_meta() ). %%======================================== @@ -589,12 +591,19 @@ cluster_metric_meta() -> {emqx_cluster_nodes_stopped, gauge, undefined} ]. -cluster_data(Mode) -> +cluster_data(node) -> + Labels = [], + do_cluster_data(Labels); +cluster_data(_) -> + Labels = [{node, node(self())}], + do_cluster_data(Labels). + +do_cluster_data(Labels) -> Running = emqx:cluster_nodes(running), Stopped = emqx:cluster_nodes(stopped), #{ - emqx_cluster_nodes_running => [{with_node_label(Mode, []), length(Running)}], - emqx_cluster_nodes_stopped => [{with_node_label(Mode, []), length(Stopped)}] + emqx_cluster_nodes_running => [{Labels, length(Running)}], + emqx_cluster_nodes_stopped => [{Labels, length(Stopped)}] }. %%======================================== diff --git a/apps/emqx_prometheus/src/emqx_prometheus_cluster.erl b/apps/emqx_prometheus/src/emqx_prometheus_cluster.erl index 00a464811..8bba311dc 100644 --- a/apps/emqx_prometheus/src/emqx_prometheus_cluster.erl +++ b/apps/emqx_prometheus/src/emqx_prometheus_cluster.erl @@ -23,8 +23,6 @@ collect_json_data/2, - aggre_cluster/3, - point_to_map_fun/1, boolean_to_number/1, @@ -83,9 +81,6 @@ aggre_cluster(Module, Mode) -> Module:aggre_or_zip_init_acc() ). -aggre_cluster(LogicSumKs, ResL, Init) -> - do_aggre_cluster(LogicSumKs, ResL, Init). - do_aggre_cluster(_LogicSumKs, [], AccIn) -> AccIn; do_aggre_cluster(LogicSumKs, [{ok, {_NodeName, NodeMetric}} | Rest], AccIn) -> diff --git a/apps/emqx_prometheus/test/emqx_prometheus_data_SUITE.erl b/apps/emqx_prometheus/test/emqx_prometheus_data_SUITE.erl index a0009b8b2..72cbf8f96 100644 --- a/apps/emqx_prometheus/test/emqx_prometheus_data_SUITE.erl +++ b/apps/emqx_prometheus/test/emqx_prometheus_data_SUITE.erl @@ -287,12 +287,18 @@ assert_stats_metric_labels([MetricName | R] = _Metric, Mode) -> undefined -> ok; N when is_integer(N) -> - %% ct:print( - %% "====================~n" - %% "%% Metric: ~p~n" - %% "%% Expect labels count: ~p in Mode: ~p~n", - %% [_Metric, N, Mode] - %% ), + case N =:= length(lists:droplast(R)) of + true -> + ok; + false -> + ct:print( + "====================~n" + "%% Metric: ~p~n" + "%% Expect labels count: ~p in Mode: ~p~n" + "%% But got labels: ~p~n", + [_Metric, N, Mode, length(lists:droplast(R))] + ) + end, ?assertEqual(N, length(lists:droplast(R))) end. @@ -304,10 +310,14 @@ assert_stats_metric_labels([MetricName | R] = _Metric, Mode) -> %% `/prometheus/stats` %% BEGIN always no label +metric_meta(<<"emqx_cluster_sessions_count">>) -> ?meta(0, 0, 0); +metric_meta(<<"emqx_cluster_sessions_max">>) -> ?meta(0, 0, 0); metric_meta(<<"emqx_topics_max">>) -> ?meta(0, 0, 0); metric_meta(<<"emqx_topics_count">>) -> ?meta(0, 0, 0); metric_meta(<<"emqx_retained_count">>) -> ?meta(0, 0, 0); metric_meta(<<"emqx_retained_max">>) -> ?meta(0, 0, 0); +metric_meta(<<"emqx_subscriptions_shared_count">>) -> ?meta(0, 0, 0); +metric_meta(<<"emqx_subscriptions_shared_max">>) -> ?meta(0, 0, 0); %% END %% BEGIN no label in mode `node` metric_meta(<<"emqx_vm_cpu_use">>) -> ?meta(0, 1, 1); @@ -316,6 +326,8 @@ metric_meta(<<"emqx_vm_run_queue">>) -> ?meta(0, 1, 1); metric_meta(<<"emqx_vm_process_messages_in_queues">>) -> ?meta(0, 1, 1); metric_meta(<<"emqx_vm_total_memory">>) -> ?meta(0, 1, 1); metric_meta(<<"emqx_vm_used_memory">>) -> ?meta(0, 1, 1); +metric_meta(<<"emqx_cluster_nodes_running">>) -> ?meta(0, 1, 1); +metric_meta(<<"emqx_cluster_nodes_stopped">>) -> ?meta(0, 1, 1); %% END metric_meta(<<"emqx_cert_expiry_at">>) -> ?meta(2, 2, 2); metric_meta(<<"emqx_license_expiry_at">>) -> ?meta(0, 0, 0); diff --git a/apps/emqx_rule_engine/src/emqx_rule_engine_api.erl b/apps/emqx_rule_engine/src/emqx_rule_engine_api.erl index 50e9b4943..3497e40a6 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_engine_api.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_engine_api.erl @@ -705,7 +705,7 @@ generate_match_spec(Qs) -> generate_match_spec([], _, {MtchHead, Conds}) -> {MtchHead, lists:reverse(Conds)}; generate_match_spec([Qs | Rest], N, {MtchHead, Conds}) -> - Holder = binary_to_atom(iolist_to_binary(["$", integer_to_list(N)]), utf8), + Holder = list_to_atom([$$ | integer_to_list(N)]), NMtchHead = emqx_mgmt_util:merge_maps(MtchHead, ms(element(1, Qs), Holder)), NConds = put_conds(Qs, Holder, Conds), generate_match_spec(Rest, N + 1, {NMtchHead, NConds}). diff --git a/apps/emqx_rule_engine/src/emqx_rule_funcs.erl b/apps/emqx_rule_engine/src/emqx_rule_funcs.erl index c5a083ef4..ac7f66597 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_funcs.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_funcs.erl @@ -1327,7 +1327,7 @@ format_date(TimeUnit, Offset, FormatString, TimeEpoch) -> date_to_unix_ts(TimeUnit, FormatString, InputString) -> Unit = time_unit(TimeUnit), - emqx_utils_calendar:parse(InputString, Unit, FormatString). + emqx_utils_calendar:formatted_datetime_to_system_time(InputString, Unit, FormatString). date_to_unix_ts(TimeUnit, Offset, FormatString, InputString) -> Unit = time_unit(TimeUnit), diff --git a/apps/emqx_rule_engine/test/emqx_rule_funcs_SUITE.erl b/apps/emqx_rule_engine/test/emqx_rule_funcs_SUITE.erl index b31889ab4..36c3b6864 100644 --- a/apps/emqx_rule_engine/test/emqx_rule_funcs_SUITE.erl +++ b/apps/emqx_rule_engine/test/emqx_rule_funcs_SUITE.erl @@ -1215,6 +1215,50 @@ timezone_to_offset_seconds_helper(FunctionName) -> apply_func(FunctionName, [local]), ok. +t_date_to_unix_ts(_) -> + TestTab = [ + {{"2024-03-01T10:30:38+08:00", second}, [ + <<"second">>, <<"+08:00">>, <<"%Y-%m-%d %H-%M-%S">>, <<"2024-03-01 10:30:38">> + ]}, + {{"2024-03-01T10:30:38.333+08:00", second}, [ + <<"second">>, <<"+08:00">>, <<"%Y-%m-%d %H-%M-%S.%3N">>, <<"2024-03-01 10:30:38.333">> + ]}, + {{"2024-03-01T10:30:38.333+08:00", millisecond}, [ + <<"millisecond">>, + <<"+08:00">>, + <<"%Y-%m-%d %H-%M-%S.%3N">>, + <<"2024-03-01 10:30:38.333">> + ]}, + {{"2024-03-01T10:30:38.333+08:00", microsecond}, [ + <<"microsecond">>, + <<"+08:00">>, + <<"%Y-%m-%d %H-%M-%S.%3N">>, + <<"2024-03-01 10:30:38.333">> + ]}, + {{"2024-03-01T10:30:38.333+08:00", nanosecond}, [ + <<"nanosecond">>, + <<"+08:00">>, + <<"%Y-%m-%d %H-%M-%S.%3N">>, + <<"2024-03-01 10:30:38.333">> + ]}, + {{"2024-03-01T10:30:38.333444+08:00", microsecond}, [ + <<"microsecond">>, + <<"+08:00">>, + <<"%Y-%m-%d %H-%M-%S.%6N">>, + <<"2024-03-01 10:30:38.333444">> + ]} + ], + lists:foreach( + fun({{DateTime3339, Unit}, DateToTsArgs}) -> + ?assertEqual( + calendar:rfc3339_to_system_time(DateTime3339, [{unit, Unit}]), + apply_func(date_to_unix_ts, DateToTsArgs), + "Failed on test: " ++ DateTime3339 ++ "/" ++ atom_to_list(Unit) + ) + end, + TestTab + ). + t_parse_date_errors(_) -> ?assertError( bad_formatter_or_date, @@ -1226,6 +1270,37 @@ t_parse_date_errors(_) -> bad_formatter_or_date, emqx_rule_funcs:date_to_unix_ts(second, <<"%y-%m-%d %H:%M:%S">>, <<"2022-05-26 10:40:12">>) ), + %% invalid formats + ?assertThrow( + {missing_date_part, month}, + emqx_rule_funcs:date_to_unix_ts( + second, <<"%Y-%d %H:%M:%S">>, <<"2022-32 10:40:12">> + ) + ), + ?assertThrow( + {missing_date_part, year}, + emqx_rule_funcs:date_to_unix_ts( + second, <<"%H:%M:%S">>, <<"10:40:12">> + ) + ), + ?assertError( + _, + emqx_rule_funcs:date_to_unix_ts( + second, <<"%Y-%m-%d %H:%M:%S">>, <<"2022-05-32 10:40:12">> + ) + ), + ?assertError( + _, + emqx_rule_funcs:date_to_unix_ts( + second, <<"%Y-%m-%d %H:%M:%S">>, <<"2023-02-29 10:40:12">> + ) + ), + ?assertError( + _, + emqx_rule_funcs:date_to_unix_ts( + second, <<"%Y-%m-%d %H:%M:%S">>, <<"2024-02-30 10:40:12">> + ) + ), %% Compatibility test %% UTC+0 @@ -1245,25 +1320,42 @@ t_parse_date_errors(_) -> emqx_rule_funcs:date_to_unix_ts(second, <<"%Y-%m-%d %H:%M:%S">>, <<"2022-05-26 10-40-12">>) ), - %% UTC+0 - UnixTsLeap0 = 1582986700, + %% leap year checks ?assertEqual( - UnixTsLeap0, - emqx_rule_funcs:date_to_unix_ts(second, <<"%Y-%m-%d %H:%M:%S">>, <<"2020-02-29 14:31:40">>) + %% UTC+0 + 1709217100, + emqx_rule_funcs:date_to_unix_ts(second, <<"%Y-%m-%d %H:%M:%S">>, <<"2024-02-29 14:31:40">>) ), - - %% UTC+0 - UnixTsLeap1 = 1709297071, ?assertEqual( - UnixTsLeap1, + %% UTC+0 + 1709297071, emqx_rule_funcs:date_to_unix_ts(second, <<"%Y-%m-%d %H:%M:%S">>, <<"2024-03-01 12:44:31">>) ), - - %% UTC+0 - UnixTsLeap2 = 1709535387, ?assertEqual( - UnixTsLeap2, - emqx_rule_funcs:date_to_unix_ts(second, <<"%Y-%m-%d %H:%M:%S">>, <<"2024-03-04 06:56:27">>) + %% UTC+0 + 4107588271, + emqx_rule_funcs:date_to_unix_ts(second, <<"%Y-%m-%d %H:%M:%S">>, <<"2100-03-01 12:44:31">>) + ), + ?assertEqual( + %% UTC+8 + 1709188300, + emqx_rule_funcs:date_to_unix_ts( + second, <<"+08:00">>, <<"%Y-%m-%d %H:%M:%S">>, <<"2024-02-29 14:31:40">> + ) + ), + ?assertEqual( + %% UTC+8 + 1709268271, + emqx_rule_funcs:date_to_unix_ts( + second, <<"+08:00">>, <<"%Y-%m-%d %H:%M:%S">>, <<"2024-03-01 12:44:31">> + ) + ), + ?assertEqual( + %% UTC+8 + 4107559471, + emqx_rule_funcs:date_to_unix_ts( + second, <<"+08:00">>, <<"%Y-%m-%d %H:%M:%S">>, <<"2100-03-01 12:44:31">> + ) ), %% None zero zone shift with millisecond level precision diff --git a/apps/emqx_utils/src/emqx_utils_calendar.erl b/apps/emqx_utils/src/emqx_utils_calendar.erl index a3c1450cd..b9da2bfd5 100644 --- a/apps/emqx_utils/src/emqx_utils_calendar.erl +++ b/apps/emqx_utils/src/emqx_utils_calendar.erl @@ -22,7 +22,7 @@ formatter/1, format/3, format/4, - parse/3, + formatted_datetime_to_system_time/3, offset_second/1 ]). @@ -48,8 +48,9 @@ -define(DAYS_PER_YEAR, 365). -define(DAYS_PER_LEAP_YEAR, 366). -define(DAYS_FROM_0_TO_1970, 719528). --define(SECONDS_FROM_0_TO_1970, (?DAYS_FROM_0_TO_1970 * ?SECONDS_PER_DAY)). - +-define(DAYS_FROM_0_TO_10000, 2932897). +-define(SECONDS_FROM_0_TO_1970, ?DAYS_FROM_0_TO_1970 * ?SECONDS_PER_DAY). +-define(SECONDS_FROM_0_TO_10000, (?DAYS_FROM_0_TO_10000 * ?SECONDS_PER_DAY)). %% the maximum value is the SECONDS_FROM_0_TO_10000 in the calendar.erl, %% here minus SECONDS_PER_DAY to tolerate timezone time offset, %% so the maximum date can reach 9999-12-31 which is ample. @@ -171,10 +172,10 @@ format(Time, Unit, Offset, FormatterBin) when is_binary(FormatterBin) -> format(Time, Unit, Offset, Formatter) -> do_format(Time, time_unit(Unit), offset_second(Offset), Formatter). -parse(DateStr, Unit, FormatterBin) when is_binary(FormatterBin) -> - parse(DateStr, Unit, formatter(FormatterBin)); -parse(DateStr, Unit, Formatter) -> - do_parse(DateStr, Unit, Formatter). +formatted_datetime_to_system_time(DateStr, Unit, FormatterBin) when is_binary(FormatterBin) -> + formatted_datetime_to_system_time(DateStr, Unit, formatter(FormatterBin)); +formatted_datetime_to_system_time(DateStr, Unit, Formatter) -> + do_formatted_datetime_to_system_time(DateStr, Unit, Formatter). %%-------------------------------------------------------------------- %% Time unit @@ -467,56 +468,51 @@ padding(Data, _Len) -> Data. %%-------------------------------------------------------------------- -%% internal: parse part +%% internal: formatted_datetime_to_system_time part %%-------------------------------------------------------------------- -do_parse(DateStr, Unit, Formatter) -> +do_formatted_datetime_to_system_time(DateStr, Unit, Formatter) -> DateInfo = do_parse_date_str(DateStr, Formatter, #{}), - {Precise, PrecisionUnit} = precision(DateInfo), - Counter = - fun - (year, V, Res) -> - Res + dy(V) * ?SECONDS_PER_DAY * Precise - (?SECONDS_FROM_0_TO_1970 * Precise); - (month, V, Res) -> - Dm = dym(maps:get(year, DateInfo, 0), V), - Res + Dm * ?SECONDS_PER_DAY * Precise; - (day, V, Res) -> - Res + (V * ?SECONDS_PER_DAY * Precise); - (hour, V, Res) -> - Res + (V * ?SECONDS_PER_HOUR * Precise); - (minute, V, Res) -> - Res + (V * ?SECONDS_PER_MINUTE * Precise); - (second, V, Res) -> - Res + V * Precise; - (millisecond, V, Res) -> - case PrecisionUnit of - millisecond -> - Res + V; - microsecond -> - Res + (V * 1000); - nanosecond -> - Res + (V * 1000000) - end; - (microsecond, V, Res) -> - case PrecisionUnit of - microsecond -> - Res + V; - nanosecond -> - Res + (V * 1000) - end; - (nanosecond, V, Res) -> - Res + V; - (parsed_offset, V, Res) -> - Res - V * Precise - end, - Count = maps:fold(Counter, 0, DateInfo) - (?SECONDS_PER_DAY * Precise), - erlang:convert_time_unit(Count, PrecisionUnit, Unit). + PrecisionUnit = precision(DateInfo), + ToPrecisionUnit = fun(Time, FromUnit) -> + erlang:convert_time_unit(Time, FromUnit, PrecisionUnit) + end, + GetRequiredPart = fun(Key) -> + case maps:get(Key, DateInfo, undefined) of + undefined -> throw({missing_date_part, Key}); + Value -> Value + end + end, + GetOptionalPart = fun(Key) -> maps:get(Key, DateInfo, 0) end, + Year = GetRequiredPart(year), + Month = GetRequiredPart(month), + Day = GetRequiredPart(day), + Hour = GetRequiredPart(hour), + Min = GetRequiredPart(minute), + Sec = GetRequiredPart(second), + DateTime = {{Year, Month, Day}, {Hour, Min, Sec}}, + TotalSecs = datetime_to_system_time(DateTime) - GetOptionalPart(parsed_offset), + check(TotalSecs, DateStr, Unit), + TotalTime = + ToPrecisionUnit(TotalSecs, second) + + ToPrecisionUnit(GetOptionalPart(millisecond), millisecond) + + ToPrecisionUnit(GetOptionalPart(microsecond), microsecond) + + ToPrecisionUnit(GetOptionalPart(nanosecond), nanosecond), + erlang:convert_time_unit(TotalTime, PrecisionUnit, Unit). -precision(#{nanosecond := _}) -> {1000_000_000, nanosecond}; -precision(#{microsecond := _}) -> {1000_000, microsecond}; -precision(#{millisecond := _}) -> {1000, millisecond}; -precision(#{second := _}) -> {1, second}; -precision(_) -> {1, second}. +check(Secs, _, _) when Secs >= -?SECONDS_FROM_0_TO_1970, Secs < ?SECONDS_FROM_0_TO_10000 -> + ok; +check(_Secs, DateStr, Unit) -> + throw({bad_format, #{date_string => DateStr, to_unit => Unit}}). + +datetime_to_system_time(DateTime) -> + calendar:datetime_to_gregorian_seconds(DateTime) - ?SECONDS_FROM_0_TO_1970. + +precision(#{nanosecond := _}) -> nanosecond; +precision(#{microsecond := _}) -> microsecond; +precision(#{millisecond := _}) -> millisecond; +precision(#{second := _}) -> second; +precision(_) -> second. do_parse_date_str(<<>>, _, Result) -> Result; @@ -564,27 +560,6 @@ date_size(timezone) -> 5; date_size(timezone1) -> 6; date_size(timezone2) -> 9. -dym(Y, M) -> - case is_leap_year(Y) of - true when M > 2 -> - dm(M) + 1; - _ -> - dm(M) - end. - -dm(1) -> 0; -dm(2) -> 31; -dm(3) -> 59; -dm(4) -> 90; -dm(5) -> 120; -dm(6) -> 151; -dm(7) -> 181; -dm(8) -> 212; -dm(9) -> 243; -dm(10) -> 273; -dm(11) -> 304; -dm(12) -> 334. - str_to_int_or_error(Str, Error) -> case string:to_integer(Str) of {Int, []} -> diff --git a/bin/emqx b/bin/emqx index c7ec11c3b..1db57d7e8 100755 --- a/bin/emqx +++ b/bin/emqx @@ -529,7 +529,6 @@ else tmp_proto_dist=$(echo -e "$PS_LINE" | $GREP -oE '\s-ekka_proto_dist.*' | awk '{print $2}' || echo 'inet_tcp') SSL_DIST_OPTFILE="$(echo -e "$PS_LINE" | $GREP -oE '\-ssl_dist_optfile\s.+\s' | awk '{print $2}' || true)" tmp_ticktime="$(echo -e "$PS_LINE" | $GREP -oE '\s-kernel\snet_ticktime\s.+\s' | awk '{print $3}' || true)" - # data_dir is actually not needed, but kept anyway tmp_datadir="$(echo -e "$PS_LINE" | $GREP -oE "\-emqx_data_dir.*" | sed -E 's#.+emqx_data_dir[[:blank:]]##g' | sed -E 's#[[:blank:]]--$##g' || true)" ## Make the format like what call_hocon multi_get prints out, but only need 4 args EMQX_BOOT_CONFIGS="node.name=${tmp_nodename}\nnode.cookie=${tmp_cookie}\ncluster.proto_dist=${tmp_proto_dist}\nnode.dist_net_ticktime=$tmp_ticktime\nnode.data_dir=${tmp_datadir}" @@ -747,7 +746,11 @@ relx_start_command() { # Function to check configs without generating them check_config() { ## this command checks the configs without generating any files - call_hocon -v -s "$SCHEMA_MOD" -c "$EMQX_ETC_DIR"/emqx.conf check_schema + call_hocon -v \ + -s "$SCHEMA_MOD" \ + -c "$DATA_DIR"/configs/cluster.hocon \ + -c "$EMQX_ETC_DIR"/emqx.conf \ + check_schema } # Function to generate app.config and vm.args @@ -763,11 +766,19 @@ generate_config() { local NOW_TIME NOW_TIME="$(date +'%Y.%m.%d.%H.%M.%S')" - ## this command populates two files: app.