From b972b9100dd479a4365003889e35370eb4f4b5b9 Mon Sep 17 00:00:00 2001 From: Karl Nilsson Date: Mon, 1 Jun 2026 15:49:48 +0100 Subject: [PATCH 1/7] QQ: v9 - module setup. --- deps/rabbit/src/rabbit_fifo.erl | 102 +- deps/rabbit/src/rabbit_fifo_v8.erl | 4328 ++++++++++++++++++++++++++++ deps/rabbit/src/rabbit_fifo_v8.hrl | 317 ++ 3 files changed, 4666 insertions(+), 81 deletions(-) create mode 100644 deps/rabbit/src/rabbit_fifo_v8.erl create mode 100644 deps/rabbit/src/rabbit_fifo_v8.hrl diff --git a/deps/rabbit/src/rabbit_fifo.erl b/deps/rabbit/src/rabbit_fifo.erl index 5422cda80bf1..2bb953deb6f4 100644 --- a/deps/rabbit/src/rabbit_fifo.erl +++ b/deps/rabbit/src/rabbit_fifo.erl @@ -12,7 +12,7 @@ -compile(inline_list_funcs). -compile(inline). -compile({no_auto_import, [apply/3]}). --dialyzer({nowarn_function, convert_v7_to_v8/2}). +-dialyzer({nowarn_function, convert_v8_to_v9/2}). -dialyzer(no_improper_lists). -include("rabbit_queue_type.hrl"). @@ -20,7 +20,7 @@ -include_lib("kernel/include/logger.hrl"). --define(STATE, ?MODULE). +-define(STATE, rabbit_fifo). -define(DLX, rabbit_fifo_dlx). -define(CONSUMER_PID(Pid), #consumer{cfg = #consumer_cfg{pid = Pid}}). @@ -903,8 +903,8 @@ finalize_ra_seq({SeqAcc, Start, End}) -> State :: state(), OldMeta :: ra_snapshot:meta(), OldState :: state(). -snapshot_installed(_Meta, #?MODULE{cfg = #cfg{}, - consumers = Consumers} = State, +snapshot_installed(_Meta, #?STATE{cfg = #cfg{}, + consumers = Consumers} = State, _OldMeta, _OldState) -> %% here we need to redliver all pending consumer messages %% to local consumers @@ -931,8 +931,8 @@ snapshot_installed(_Meta, #?MODULE{cfg = #cfg{}, delivery_effects(SendAcc, State) ++ credit_reply_resend_effect(State). -credit_reply_resend_effect(#?MODULE{waiting_consumers = Waiting, - consumers = Consumers} = State) -> +credit_reply_resend_effect(#?STATE{waiting_consumers = Waiting, + consumers = Consumers} = State) -> Available = messages_ready(State), maps:fold( fun(ConsumerKey, @@ -966,69 +966,9 @@ credit_reply_resend_effect(#?MODULE{waiting_consumers = Waiting, Acc end, [], maps:merge(Consumers, maps:from_list(Waiting))). -v7_to_v8_consumer(Con, Timeout) -> - V7Cfg = element(#consumer.cfg, Con), - Status0 = element(#consumer.status, Con), - Ch0 = element(#consumer.checked_out, Con), - Ch = maps:map(fun (_, M) -> ?C_MSG(Timeout, M) end, Ch0), - Cfg = #consumer_cfg{meta = element(#consumer_cfg.meta, V7Cfg), - pid = element(#consumer_cfg.pid, V7Cfg), - tag = element(#consumer_cfg.tag, V7Cfg), - credit_mode = element(#consumer_cfg.credit_mode, V7Cfg), - lifetime = element(#consumer_cfg.lifetime, V7Cfg), - priority = element(#consumer_cfg.priority, V7Cfg), - timeout = ?DEFAULT_CONSUMER_TIMEOUT_MS - }, - Status = case Status0 of - suspected_down -> - {suspected_down, up}; - _ -> - Status0 - end, - #consumer{cfg = Cfg, - status = Status, - next_msg_id = element(#consumer.next_msg_id, Con), - checked_out = Ch, - credit = element(#consumer.credit, Con), - delivery_count = element(#consumer.delivery_count, Con) - }. - -convert_v7_to_v8(#{system_time := Ts} = _Meta, StateV7) -> - %% the structure is intact for now - Cons0 = element(#?STATE.consumers, StateV7), - Waiting0 = element(#?STATE.waiting_consumers, StateV7), - Timeout = Ts + ?DEFAULT_CONSUMER_TIMEOUT_MS, - Cons = maps:map( - fun (_CKey, Con) -> - v7_to_v8_consumer(Con, Timeout) - end, Cons0), - Waiting = lists:map(fun({Cid, Con}) -> - {Cid, v7_to_v8_consumer(Con, Timeout)} - end, Waiting0), - - Msgs = element(#?STATE.messages, StateV7), - Cfg = element(#?STATE.cfg, StateV7), - {Hi, No} = rabbit_fifo_q:to_queues(Msgs), - Pq0 = queue:fold(fun (I, Acc) -> - rabbit_fifo_pq:in(9, I, Acc) - end, rabbit_fifo_pq:new(), Hi), - Pq = queue:fold(fun (I, Acc) -> - rabbit_fifo_pq:in(?DEFAULT_PRIORITY, I, Acc) - end, Pq0, No), - Dlx0 = element(#?STATE.dlx, StateV7), - Dlx = Dlx0#?DLX{unused = ?NIL}, - StateV8 = StateV7, - StateV8#?STATE{cfg = Cfg#cfg{consumer_disconnected_timeout = 60_000, - delayed_retry = disabled}, - reclaimable_bytes = 0, - messages = Pq, - consumers = Cons, - waiting_consumers = Waiting, - next_consumer_timeout = Timeout, - last_command_time = Ts, - dlx = Dlx, - delayed = #delayed{} - }. +convert_v8_to_v9(#{} = _Meta, StateV8) -> + State = StateV8, + State. purge_node(Meta, Node, State, Effects) -> lists:foldl(fun(Pid, {S0, E0}) -> @@ -1252,7 +1192,7 @@ get_checked_out(CKey, From, To, #?STATE{consumers = Consumers}) -> end. -spec version() -> pos_integer(). -version() -> 8. +version() -> 9. which_module(0) -> rabbit_fifo_v0; which_module(1) -> rabbit_fifo_v1; @@ -1262,7 +1202,8 @@ which_module(4) -> rabbit_fifo_v7; which_module(5) -> rabbit_fifo_v7; which_module(6) -> rabbit_fifo_v7; which_module(7) -> rabbit_fifo_v7; -which_module(8) -> ?MODULE. +which_module(8) -> rabbit_fifo_v8; +which_module(9) -> ?MODULE. -define(AUX, aux_v4). @@ -1730,10 +1671,7 @@ query_notify_decorators_info(#?STATE{consumers = Consumers} = State) -> -spec usage(atom()) -> float(). usage(Name) when is_atom(Name) -> - case ets:lookup(rabbit_fifo_usage, Name) of - [] -> 0.0; - [{_, Use}] -> Use - end. + ets:lookup_element(rabbit_fifo_usage, Name, 2, 0.0). %%% Internal @@ -3583,7 +3521,9 @@ convert(Meta, 6, To, State) -> %% no conversion needed, this version only includes a logic change convert(Meta, 7, To, State); convert(Meta, 7, To, State) -> - convert(Meta, 8, To, convert_v7_to_v8(Meta, State)). + convert(Meta, 8, To, rabbit_fifo_v8:convert_v7_to_v8(Meta, State)); +convert(Meta, 8, To, State) -> + convert(Meta, 9, To, convert_v8_to_v9(Meta, State)). smallest_raft_index(#?STATE{messages = Messages, returns = Returns, @@ -4267,7 +4207,7 @@ consumer_info(ConsumerKey, delivery_count = DeliveryCount, next_msg_id = NextMsgId}, State) -> - #?MODULE{cfg = #cfg{consumer_strategy = Strat}} = State, + #?STATE{cfg = #cfg{consumer_strategy = Strat}} = State, #{next_msg_id => NextMsgId, credit => Credit, key => ConsumerKey, @@ -4278,8 +4218,8 @@ consumer_info(ConsumerKey, handle_waiting_timedout_consumers(Meta, Key, MsgIds, State0) -> case State0 of - #?MODULE{cfg = #cfg{consumer_strategy = single_active}, - waiting_consumers = Waiting0} -> + #?STATE{cfg = #cfg{consumer_strategy = single_active}, + waiting_consumers = Waiting0} -> %% check if consumer key relates to any of the waiting %% consumers case find_waiting_consumer(Key, Waiting0) of @@ -4293,12 +4233,12 @@ handle_waiting_timedout_consumers(Meta, Key, MsgIds, State0) -> Waiting = add_waiting({CKey, WC}, Waiting0), {State, Effects} = activate_next_consumer( - State0#?MODULE{waiting_consumers = Waiting}, []), + State0#?STATE{waiting_consumers = Waiting}, []), checkout(Meta, State0, State, Effects); Rem -> WC = WC0#consumer{timed_out_msg_ids = Rem}, Waiting = add_waiting({CKey, WC}, Waiting0), - {State0#?MODULE{waiting_consumers = Waiting}, ok} + {State0#?STATE{waiting_consumers = Waiting}, ok} end; _ -> {State0, ok} diff --git a/deps/rabbit/src/rabbit_fifo_v8.erl b/deps/rabbit/src/rabbit_fifo_v8.erl new file mode 100644 index 000000000000..61c80ba29e94 --- /dev/null +++ b/deps/rabbit/src/rabbit_fifo_v8.erl @@ -0,0 +1,4328 @@ +%% This Source Code Form is subject to the terms of the Mozilla Public +%% License, v. 2.0. If a copy of the MPL was not distributed with this +%% file, You can obtain one at https://mozilla.org/MPL/2.0/. +%% +%% Copyright (c) 2007-2026 Broadcom. All Rights Reserved. +%% The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries. +%% All rights reserved. +-module(rabbit_fifo_v8). + +-behaviour(ra_machine). + +-compile(inline_list_funcs). +-compile(inline). +-compile({no_auto_import, [apply/3]}). +-dialyzer({nowarn_function, convert_v7_to_v8/2}). +-dialyzer(no_improper_lists). + +-include("rabbit_queue_type.hrl"). +-include("rabbit_fifo_v8.hrl"). + +-include_lib("kernel/include/logger.hrl"). + +-define(STATE, rabbit_fifo). +-define(DLX, rabbit_fifo_dlx). + +-define(CONSUMER_PID(Pid), #consumer{cfg = #consumer_cfg{pid = Pid}}). +-define(CONSUMER_PRIORITY(P), #consumer{cfg = #consumer_cfg{priority = P}}). +-define(CONSUMER_TAG_PID(Tag, Pid), + #consumer{cfg = #consumer_cfg{tag = Tag, + pid = Pid}}). + +-define(ENQ_OVERHEAD_B, 256). +-ifdef(TEST). +-define(SIZE(Msg), + case mc:is(Msg) of + true -> + mc:size(Msg); + false when is_binary(Msg) -> + {0, byte_size(Msg)}; + false -> + {0, erts_debug:size(Msg)} + end). + +-else. +-define(SIZE(Msg), mc:size(Msg)). +-endif. + +-define(DEFAULT_PRIORITY, 4). +-define(MAX_PRIORITY, 31). +-define(DEFAULT_CONSUMER_TIMEOUT_MS, 1_800_000). + +-export([ + %% ra_machine callbacks + init/1, + apply/3, + live_indexes/1, + snapshot_installed/4, + state_enter/2, + tick/2, + overview/1, + + get_checked_out/4, + %% versioning + version/0, + which_module/1, + %% aux + init_aux/1, + handle_aux/5, + % queries + query_messages_ready/1, + query_messages_checked_out/1, + query_messages_total/1, + query_processes/1, + query_waiting_consumers/1, + query_consumer_count/1, + query_consumers/1, + query_stat/1, + query_stat_dlx/1, + query_single_active_consumer/1, + query_in_memory_usage/1, + query_peek/2, + query_notify_decorators_info/1, + usage/1, + + %% misc + get_msg_header/1, + get_header/2, + annotate_msg/2, + get_msg_from_cmd/1, + + %% protocol helpers + make_enqueue/3, + make_enqueue_old/3, + make_register_enqueuer/1, + make_checkout/3, + make_settle/2, + make_return/2, + is_return/1, + make_discard/2, + make_credit/4, + make_modify/5, + make_purge/0, + make_purge_nodes/1, + make_update_config/1, + make_garbage_collection/0, + make_delayed/1, + + exec_read/3, + convert_v7_to_v8/2 + + ]). + +-ifdef(TEST). +-export([update_header/4, + chunk_disk_msgs/3, + smallest_raft_index/1, + make_requeue/4]). +-endif. + +-import(serial_number, [add/2, diff/2]). +-define(ENQ_V2, e). + +%% command records representing all the protocol actions that are supported +-record(enqueue, {pid :: option(pid()), + seq :: option(msg_seqno()), + msg :: raw_msg()}). +-record(?ENQ_V2, {seq :: option(msg_seqno()), + msg :: raw_msg(), + size :: {MetadataSize :: non_neg_integer(), + PayloadSize :: non_neg_integer()}}). +-record(requeue, {consumer_key :: consumer_key(), + msg_id :: msg_id(), + index :: ra:index(), + header :: msg_header(), + msg :: raw_msg()}). +-record(register_enqueuer, {pid :: pid()}). +-record(checkout, {consumer_id :: consumer_id(), + spec :: checkout_spec(), + meta :: consumer_meta()}). +-record(settle, {consumer_key :: consumer_key(), + msg_ids :: [msg_id()]}). +-record(return, {consumer_key :: consumer_key(), + msg_ids :: [msg_id()]}). +-record(discard, {consumer_key :: consumer_key(), + msg_ids :: [msg_id()]}). +-record(credit, {consumer_key :: consumer_key(), + credit :: non_neg_integer(), + delivery_count :: rabbit_queue_type:delivery_count(), + drain :: boolean()}). +-record(modify, {consumer_key :: consumer_key(), + msg_ids :: [msg_id()], + delivery_failed :: boolean(), + undeliverable_here :: boolean(), + annotations :: mc:annotations()}). +-record(purge, {}). +-record(purge_nodes, {nodes :: [node()]}). +-record(update_config, {config :: config()}). +-record(garbage_collection, {}). +-type delayed_op() :: {retry, all | non_neg_integer()} + | {assign_deferred, consumer_key(), [binary()]}. +-record(delayed_cmd, {op :: delayed_op()}). + +-opaque protocol() :: + #enqueue{} | + #?ENQ_V2{} | + #requeue{} | + #register_enqueuer{} | + #checkout{} | + #settle{} | + #return{} | + #discard{} | + #credit{} | + #modify{} | + #purge{} | + #purge_nodes{} | + #update_config{} | + #garbage_collection{} | + #delayed_cmd{}. + +-type command() :: protocol() | + rabbit_fifo_dlx:protocol() | + ra_machine:builtin_command(). +%% all the command types supported by ra fifo + +-type client_msg() :: delivery(). +%% the messages `rabbit_fifo' can send to consumers. + +-opaque state() :: #?STATE{}. + +-export_type([protocol/0, + delivery/0, + command/0, + credit_mode/0, + consumer_meta/0, + consumer_id/0, + consumer_key/0, + client_msg/0, + msg/0, + msg_id/0, + msg_seqno/0, + delivery_msg/0, + state/0, + config/0, + delayed_op/0]). + +-spec init(config()) -> state(). +init(#{name := Name, + queue_resource := Resource} = Conf) -> + update_config(Conf, #?STATE{cfg = #cfg{name = Name, + resource = Resource}}). + +update_config(Conf, State) -> + DLH = maps:get(dead_letter_handler, Conf, undefined), + % BLH = maps:get(become_leader_handler, Conf, undefined), + Overflow = maps:get(overflow_strategy, Conf, drop_head), + MaxLength = maps:get(max_length, Conf, undefined), + MaxBytes = maps:get(max_bytes, Conf, undefined), + DeliveryLimit = case maps:get(delivery_limit, Conf, undefined) of + DL when is_number(DL) andalso + DL < 0 -> + undefined; + DL -> + DL + end, + + Expires = maps:get(expires, Conf, undefined), + MsgTTL = maps:get(msg_ttl, Conf, undefined), + ConsumerStrategy = case maps:get(single_active_consumer_on, Conf, false) of + true -> + single_active; + false -> + competing + end, + DisconnectedTimeout = maps:get(consumer_disconnected_timeout, Conf, 60_000), + DelayedRetry = maps:get(delayed_retry, Conf, disabled), + Cfg = State#?STATE.cfg, + + %% Only set last_active from 'created' during init, not during config updates. + %% If last_active is already set, preserve it to avoid resetting queue TTL. + LastActive = case State#?STATE.last_active of + undefined -> + maps:get(created, Conf, undefined); + Existing -> + Existing + end, + State#?STATE{cfg = Cfg#cfg{dead_letter_handler = DLH, + overflow_strategy = Overflow, + max_length = MaxLength, + max_bytes = MaxBytes, + consumer_strategy = ConsumerStrategy, + delivery_limit = DeliveryLimit, + expires = Expires, + msg_ttl = MsgTTL, + consumer_disconnected_timeout = DisconnectedTimeout, + delayed_retry = DelayedRetry}, + last_active = LastActive}. + +% msg_ids are scoped per consumer +-spec apply(ra_machine:command_meta_data(), command(), state()) -> + {state(), ra_machine:reply(), ra_machine:effects() | ra_machine:effect()} | + {state(), ra_machine:reply()}. +apply(Meta, {machine_version, FromVersion, ToVersion}, VXState) -> + %% machine version upgrades cant be done in apply_ + State = convert(Meta, FromVersion, ToVersion, VXState), + {State, ok, [{aux, {dlx, setup}}]}; +apply(#{system_time := Ts} = Meta, Cmd, + #?STATE{reclaimable_bytes = ReclBytes} = State) -> + %% Add estimated reclaimable bytes. + %% This is the simplest way to record the reclaimable bytes for most + %% commands but it is a bit more garbage-y as almost always creates a + %% new state copy before even processing the command. + Bytes = estimate_reclaimable_size(Cmd), + apply_(Meta, Cmd, State#?STATE{reclaimable_bytes = ReclBytes + Bytes, + last_command_time = Ts}). + +apply_(Meta, #enqueue{pid = From, seq = Seq, + msg = RawMsg}, State00) -> + apply_enqueue(Meta, From, Seq, RawMsg, message_size(RawMsg), State00); +apply_(#{reply_mode := {notify, _Corr, EnqPid}} = Meta, + #?ENQ_V2{seq = Seq, msg = RawMsg, size = Size}, State00) -> + apply_enqueue(Meta, EnqPid, Seq, RawMsg, Size, State00); +apply_(Meta, #?ENQ_V2{seq = Seq, msg = RawMsg, size = Size}, State00) -> + %% untracked + apply_enqueue(Meta, undefined, Seq, RawMsg, Size, State00); +apply_(_Meta, #register_enqueuer{pid = Pid}, + #?STATE{enqueuers = Enqueuers0, + cfg = #cfg{overflow_strategy = Overflow}} = State0) -> + State = case maps:is_key(Pid, Enqueuers0) of + true -> + %% if the enqueuer exits just echo the overflow state + State0; + false -> + State0#?STATE{enqueuers = Enqueuers0#{Pid => #enqueuer{}}} + end, + Res = case is_over_limit(State) of + true when Overflow == reject_publish -> + reject_publish; + _ -> + ok + end, + {State, Res, [{monitor, process, Pid}]}; +apply_(Meta, #settle{msg_ids = MsgIds, + consumer_key = Key}, + #?STATE{consumers = Consumers} = State0) -> + case find_consumer(Key, Consumers) of + {ConsumerKey, Con0} -> + %% find_consumer/2 returns the actual consumer key even if + %% if id was passed instead for example + complete_and_checkout(Meta, MsgIds, ConsumerKey, + Con0, [], State0); + _ -> + handle_waiting_timedout_consumers(Meta, Key, MsgIds, State0) + end; +apply_(Meta, #discard{consumer_key = ConsumerKey, + msg_ids = MsgIds}, + #?STATE{consumers = Consumers } = State0) -> + case find_consumer(ConsumerKey, Consumers) of + {ActualConsumerKey, #consumer{} = Con} -> + discard(Meta, MsgIds, ActualConsumerKey, Con, true, #{}, State0); + _ -> + handle_waiting_timedout_consumers(Meta, ConsumerKey, + MsgIds, State0) + end; +apply_(Meta, #return{consumer_key = ConsumerKey, + msg_ids = MsgIds}, + #?STATE{consumers = Cons} = State) -> + case find_consumer(ConsumerKey, Cons) of + {ActualConsumerKey, Consumer} -> + return(Meta, ActualConsumerKey, Consumer, MsgIds, false, + #{}, [], State); + _ -> + handle_waiting_timedout_consumers(Meta, ConsumerKey, MsgIds, State) + end; +apply_(Meta, #modify{consumer_key = ConsumerKey, + delivery_failed = DeliveryFailed, + undeliverable_here = UndelHere, + annotations = Anns, + msg_ids = MsgIds}, + #?STATE{consumers = Cons} = State) -> + case find_consumer(ConsumerKey, Cons) of + {ActualConsumerKey, Consumer} + when UndelHere == false -> + return(Meta, ActualConsumerKey, Consumer, MsgIds, DeliveryFailed, + Anns, [], State); + {ActualConsumerKey, Consumer} + when UndelHere == true -> + discard(Meta, MsgIds, ActualConsumerKey, + Consumer, DeliveryFailed, Anns, State); + _ -> + handle_waiting_timedout_consumers(Meta, ConsumerKey, MsgIds, State) + end; +apply_(#{index := Idx} = Meta, + #requeue{consumer_key = ConsumerKey, + msg_id = MsgId, + header = Header0}, + #?STATE{consumers = Cons, + messages = Messages} = State00) -> + %% the actual consumer key was looked up in the aux handler so we + %% dont need to use find_consumer/2 here + case Cons of + #{ConsumerKey := #consumer{checked_out = Checked0} = Con0} + when is_map_key(MsgId, Checked0) -> + %% construct a message with the current raft index + %% and update acquired count before adding it to the message queue + Header = update_header(acquired_count, fun incr/1, 1, Header0), + State0 = add_bytes_return(Header, State00), + Con = Con0#consumer{checked_out = maps:remove(MsgId, Checked0), + credit = increase_credit(Con0, 1)}, + State1 = State0#?STATE{messages = rabbit_fifo_pq:in(?DEFAULT_PRIORITY, + ?MSG(Idx, Header), + Messages)}, + State2 = update_or_remove_con(Meta, ConsumerKey, Con, State1), + {State3, Effects} = activate_next_consumer({State2, []}), + checkout(Meta, State0, State3, Effects); + _ -> + {State00, ok, []} + end; +apply_(Meta, #credit{consumer_key = ConsumerKey} = Credit, + #?STATE{consumers = Cons} = State) -> + case Cons of + #{ConsumerKey := Con} -> + credit_active_consumer(Meta, Credit, Con, State); + _ -> + case lists:keytake(ConsumerKey, 1, State#?STATE.waiting_consumers) of + {value, {_, Con}, Waiting} -> + credit_inactive_consumer(Credit, Con, Waiting, State); + false -> + %% credit for unknown consumer - just ignore + {State, ok} + end + end; +apply_(_, #checkout{spec = {dequeue, _}}, + #?STATE{cfg = #cfg{consumer_strategy = single_active}} = State0) -> + {State0, {error, {unsupported, single_active_consumer}}}; +apply_(#{index := Index, + system_time := Ts, + from := From} = Meta, + #checkout{spec = {dequeue, Settlement}, + meta = ConsumerMeta, + consumer_id = ConsumerId}, + #?STATE{consumers = Consumers} = State00) -> + %% dequeue always updates last_active + State0 = State00#?STATE{last_active = Ts}, + %% all dequeue operations result in keeping the queue from expiring + Exists = find_consumer(ConsumerId, Consumers) /= undefined, + case messages_ready(State0) of + 0 -> + {State0, {dequeue, empty}, []}; + _ when Exists -> + %% a dequeue using the same consumer_id isn't possible at this point + {State0, {dequeue, empty}}; + _ -> + Timeout = get_consumer_timeout(ConsumerMeta, State00), + {_, State1} = update_consumer(Meta, ConsumerId, ConsumerId, + ConsumerMeta, + {once, {simple_prefetch, 1}}, 0, + Timeout, State0), + case checkout_one(Meta, false, State1, []) of + {success, _, MsgId, Msg, _ExpiredMsg, State2, Effects0} -> + RaftIdx = get_msg_idx(Msg), + Header = get_msg_header(Msg), + {State4, Effects1} = + case Settlement of + unsettled -> + {_, Pid} = ConsumerId, + {State2, [{monitor, process, Pid} | Effects0]}; + settled -> + %% immediately settle the checkout + {State3, _, SettleEffects} = + apply(Meta, make_settle(ConsumerId, [MsgId]), + State2), + {State3, SettleEffects ++ Effects0} + end, + Effects2 = [reply_log_effect(RaftIdx, MsgId, Header, + messages_ready(State4), From) + | Effects1], + {State, Effects} = evaluate_limit(Index, State0, + State4, Effects2), + {State, '$ra_no_reply', Effects}; + {nochange, _ExpiredMsg = true, State2, Effects0} -> + %% All ready messages expired. + State3 = State2#?STATE{consumers = + maps:remove(ConsumerId, + State2#?STATE.consumers)}, + {State, Effects} = evaluate_limit(Index, State0, + State3, Effects0), + {State, {dequeue, empty}, Effects} + end + end; +apply_(Meta, #checkout{spec = Spec, + consumer_id = ConsumerId}, State0) + when Spec == cancel orelse + Spec == remove -> + case consumer_key_from_id(ConsumerId, State0) of + {ok, ConsumerKey} -> + {State1, Effects1} = activate_next_consumer( + cancel_consumer(Meta, ConsumerKey, State0, [], + Spec)), + Reply = {ok, consumer_cancel_info(ConsumerKey, State1)}, + {State, _, Effects} = checkout(Meta, State0, State1, Effects1), + + {State, Reply, Effects}; + error -> + {State0, {error, consumer_not_found}, []} + end; +apply_(#{index := Idx} = Meta, + #checkout{spec = Spec0, + meta = ConsumerMeta, + consumer_id = {_, Pid} = ConsumerId}, State0) -> + %% might be better to check machine_version + IsV4 = tuple_size(Spec0) == 2, + %% normalise spec format + Spec = case Spec0 of + {_, _} -> + Spec0; + {Life, Prefetch, simple_prefetch} -> + {Life, {simple_prefetch, Prefetch}}; + {Life, _Credit, credited} -> + {Life, credited} + end, + Priority = get_consumer_priority(ConsumerMeta), + Timeout = get_consumer_timeout(ConsumerMeta, State0), + ConsumerKey = case consumer_key_from_id(ConsumerId, State0) of + {ok, K} -> + K; + error when IsV4 -> + %% if the consumer does not already exist use the + %% raft index as it's unique identifier in future + %% settle, credit, return and discard operations + Idx; + error -> + ConsumerId + end, + {Consumer, State1} = update_consumer(Meta, ConsumerKey, ConsumerId, + ConsumerMeta, Spec, Priority, + Timeout, State0), + {State2, Effs} = activate_next_consumer(State1, []), + + %% reply with a consumer infos + Reply = {ok, consumer_info(ConsumerKey, Consumer, State2)}, + checkout(Meta, State0, State2, [{monitor, process, Pid} | Effs], Reply); +apply_(#{index := Index}, #purge{}, + #?STATE{messages_total = Total, + delayed = #delayed{tree = Tree}, + dlx = DlxState} = State0) -> + NumReady = messages_ready(State0), + {NumDlx, _} = dlx_stat(DlxState), + DelayedLen = gb_trees:size(Tree), + NumPurged = NumReady + DelayedLen + NumDlx, + State1 = State0#?STATE{messages = rabbit_fifo_pq:new(), + messages_total = Total - NumReady - DelayedLen, + returns = lqueue:new(), + delayed = #delayed{}, + dlx = dlx_purge(DlxState), + msg_bytes_enqueue = 0 + }, + Effects0 = [{aux, force_checkpoint}, garbage_collection], + Reply = {purge, NumPurged}, + {State, Effects} = evaluate_limit(Index, State0, State1, Effects0), + {State, Reply, Effects}; +apply_(#{index := _Idx}, #garbage_collection{}, State) -> + {State, ok, [{aux, garbage_collection}]}; +apply_(#{system_time := Ts} = Meta, #delayed_cmd{op = {retry, Mode}}, + #?STATE{delayed = Delayed0, returns = Returns0} = State0) -> + {Msgs, Delayed} = take_delayed_for_retry(Mode, Ts, Delayed0), + NumRetried = length(Msgs), + Returns = lists:foldl(fun (Msg, Acc) -> lqueue:in(Msg, Acc) end, + Returns0, Msgs), + State1 = State0#?STATE{delayed = Delayed, returns = Returns}, + checkout(Meta, State0, State1, [], {ok, NumRetried}); +apply_(#{system_time := Ts} = Meta, + #delayed_cmd{op = {assign_deferred, ConsumerKey, Tokens}}, + #?STATE{delayed = Delayed0, consumers = Consumers0} = State0) -> + case maps:get(ConsumerKey, Consumers0, undefined) of + undefined -> + {State0, {error, consumer_not_found}, []}; + #consumer{status = Status} when Status =/= up -> + {State0, {error, consumer_not_active}, []}; + #consumer{credit = Credit} -> + {Msgs, NotFound, Delayed} = take_deferred(Tokens, Delayed0), + NumMsgs = length(Msgs), + case NumMsgs > Credit of + true -> + Err = {insufficient_credit, Credit, NumMsgs}, + {State0, {error, Err}, []}; + false -> + State1 = State0#?STATE{delayed = Delayed}, + {State2, Effects} = + assign_to_consumer(Meta, Ts, ConsumerKey, + Msgs, State1, []), + Reply = case NotFound of + [] -> {ok, NumMsgs}; + _ -> {partial, NumMsgs, NotFound} + end, + {State2, Reply, Effects} + end + end; +apply_(Meta, {timeout, expire_msgs}, State) -> + apply_(Meta, {timeout, {expire_msgs, shallow}}, State); +apply_(#{system_time := Ts} = Meta, + {timeout, {expire_msgs, shallow}}, State0) -> + {State, Effects} = expire_shallow(Ts, State0), + checkout(Meta, State0, State, Effects); +apply_(#{system_time := Ts} = Meta, + {down, Pid, noconnection}, + #?STATE{consumers = Cons0, + enqueuers = Enqs0} = State0) -> + + %% A node has been disconnected. This doesn't necessarily mean that + %% any processes on this node are down, they _may_ come back so here + %% we just mark them as suspected (effectively deactivated) + %% and return all checked out messages to the main queue for delivery to any + %% live consumers + + Node = node(Pid), + + DisconnectedTimeout = State0#?STATE.cfg#cfg.consumer_disconnected_timeout, + {Cons, Effects1} = + maps:fold( + fun(CKey, #consumer{cfg = #consumer_cfg{pid = P}} = C0, + {Cns0, Eff}) when P =:= Pid -> + C = update_consumer_status(suspected_down, C0), + Eff0 = [{timer, {consumer_disconnected_timeout, CKey}, + DisconnectedTimeout} | Eff], + Eff1 = consumer_update_active_effects(State0, C, false, + suspected_down, Eff0), + {Cns0#{CKey => C}, Eff1}; + (_, _, St) -> + St + end, {Cons0, []}, maps:iterator(Cons0, ordered)), + Enqs = case Enqs0 of + #{Pid := E} -> + Enqs0#{Pid := E#enqueuer{status = suspected_down}}; + _ -> + Enqs0 + end, + + WaitingConsumers = update_waiting_consumer_status(Pid, State0, + suspected_down), + % Monitor the node so that we can "unsuspect" these processes when the node + % comes back, then re-issue all monitors and discover the final fate of + % these processes + Effects = [{monitor, node, Node} | Effects1], + checkout(Meta, State0, State0#?STATE{enqueuers = Enqs, + waiting_consumers = WaitingConsumers, + consumers = Cons, + last_active = Ts}, Effects); +apply_(Meta, {timeout, {consumer_disconnected_timeout, CKey}}, + #?STATE{cfg = #cfg{consumer_strategy = competing}, + consumers = Consumers} = State0) -> + + case find_consumer(CKey, Consumers) of + {_CKey, #consumer{status = {suspected_down, _}} = Consumer} -> + %% the consumer is still suspected and has timed out + %% return all messages + {State1, Effects0} = return_all(Meta, State0, [], CKey, + Consumer, false), + + checkout(Meta, State0, State1, Effects0); + _ -> + {State0, []} + end; +apply_(#{system_time := Ts} = Meta, {timeout, {consumer_disconnected_timeout, CKey}}, + #?STATE{cfg = #cfg{consumer_strategy = single_active}, + waiting_consumers = Waiting0, + consumers = Consumers} = State0) -> + + case find_consumer(CKey, Consumers) of + {_CKey, #consumer{status = {suspected_down, Status}} = Consumer} -> + %% the consumer is still suspected and has timed out + %% return all messages + {State1, Effects0} = return_all(Meta, State0, [], CKey, + Consumer, false), + Waiting = case State1#?STATE.consumers of + #{CKey := C} when Status =/= cancelled -> + Waiting0 ++ + [{CKey, C#consumer{status = {suspected_down, up}}}]; + _ -> + Waiting0 + end, + State2 = State1#?STATE{consumers = maps:remove(CKey, State1#?STATE.consumers), + waiting_consumers = Waiting, + last_active = Ts}, + {State, Effects1} = activate_next_consumer(State2, Effects0), + checkout(Meta, State0, State, Effects1); + _ -> + {State0, []} + end; +apply_(Meta, {down, Pid, _Info}, State0) -> + {State1, Effects1} = activate_next_consumer(handle_down(Meta, Pid, State0)), + checkout(Meta, State0, State1, Effects1); +apply_(Meta, {nodeup, Node}, #?STATE{consumers = Cons0, + enqueuers = Enqs0} = State0) -> + %% A node we are monitoring has come back. + %% If we have suspected any processes of being + %% down we should now re-issue the monitors for them to detect if they're + %% actually down or not + %% send leader change events to all disconnected enqueuers to prompt them + %% to resend any messages stuck during disconnection, + %% ofc it may not be a leader change per se but it has the same effect + Effects0 = lists:flatten([[{monitor, process, P}, + {send_msg, P, leader_change, ra_event}] + || P <- suspected_pids_for(Node, State0)]), + + Enqs1 = maps:map(fun(P, E) when node(P) =:= Node -> + E#enqueuer{status = up}; + (_, E) -> E + end, Enqs0), + + ConsumerUpdateActiveFun = consumer_active_flag_update_function(State0), + %% mark all consumers as up + {State1, Effects1} = + maps:fold( + fun(ConsumerKey, + ?CONSUMER_PID(P) = + #consumer{status = {suspected_down, _}} = C0, + {SAcc, EAcc0}) + when node(P) =:= Node -> + #consumer{status = NextStatus} = C = + update_consumer_status(up, C0), + EAcc1 = ConsumerUpdateActiveFun(SAcc, ConsumerKey, + C0, true, NextStatus, EAcc0), + %% cancel timers + EAcc = [{timer, + {consumer_disconnected_timeout, ConsumerKey}, + infinity} | EAcc1], + + {update_or_remove_con(Meta, ConsumerKey, C, SAcc), EAcc}; + (_, _, Acc) -> + Acc + end, {State0, Effects0}, maps:iterator(Cons0, ordered)), + Waiting = update_waiting_consumer_status(Node, State1, up), + State2 = State1#?STATE{enqueuers = Enqs1, + waiting_consumers = Waiting}, + {State, Effects} = activate_next_consumer(State2, Effects1), + checkout(Meta, State0, State, Effects); +apply_(_, {nodedown, _Node}, State) -> + {State, ok}; +apply_(Meta, #purge_nodes{nodes = Nodes}, State0) -> + {State, Effects} = lists:foldl(fun(Node, {S, E}) -> + purge_node(Meta, Node, S, E) + end, {State0, []}, Nodes), + {State, ok, Effects}; +apply_(Meta, + #update_config{config = #{} = Conf}, + #?STATE{cfg = #cfg{dead_letter_handler = OldDLH, + resource = QRes}, + dlx = DlxState0} = State0) -> + NewDLH = maps:get(dead_letter_handler, Conf, OldDLH), + {DlxState, Effects0} = update_config(OldDLH, NewDLH, QRes, + DlxState0), + State1 = update_config(Conf, State0#?STATE{dlx = DlxState}), + checkout(Meta, State0, State1, Effects0); +apply_(Meta, {dlx, _} = Cmd, + #?STATE{cfg = #cfg{dead_letter_handler = DLH}, + reclaimable_bytes = ReclaimableBytes0, + dlx = DlxState0} = State0) -> + {DlxState, ReclaimableBytes, Effects0} = dlx_apply(Meta, Cmd, DLH, DlxState0), + State1 = State0#?STATE{dlx = DlxState, + reclaimable_bytes = ReclaimableBytes0 + ReclaimableBytes}, + checkout(Meta, State0, State1, Effects0); +apply_(#{system_time := Ts} = Meta, + {timeout, evaluate_consumer_timeout}, + #?STATE{cfg = #cfg{resource = QName, + consumer_strategy = ConsumerStrat}, + consumers = Cons0} = State0) -> + %% if any consumer message times out the consumer enters timeout state and + %% all messages are returned + {State1, Effects0} = + maps:fold( + fun (CKey, #consumer{checked_out = Checked, + timed_out_msg_ids = TimedOutMsgIds0} = Con0, + {S0, E0} = Acc) -> + %% TODO: consumers with suspected_down status should not expire + %% messages as messages will be returned shortly anyway + %% or we could return expored messages but not change the status + case maps:filter(fun (_, ?C_MSG(T, _)) -> + Ts >= T + end, Checked) of + M when map_size(M) == 0 -> + Acc; + TimedOut -> + MsgIds = maps:keys(TimedOut), + MsgIdsSorted = lists:sort(MsgIds), + %% TODO if SAC move to quiescing?? + TimedOutMsgIds = lists:sort(TimedOutMsgIds0 ++ MsgIds), + Con = update_consumer_status( + timeout, Con0#consumer{timed_out_msg_ids = TimedOutMsgIds}), + ?CONSUMER_TAG_PID(Tag, Pid) = Con, + E = [{send_msg, Pid, + {released, QName, Tag, MsgIdsSorted, timeout}, ra_event} | E0], + return_multiple(Meta, CKey, Con, MsgIdsSorted, false, + #{}, E, S0) + end + end, {State0, []}, maps:iterator(Cons0, ordered)), + + %% Check if single_active consumer is in timeout or suspected_down state + %% with no checked out messages + %% and move it back to waiting_consumers if so + {State2, Effects1} = + case ConsumerStrat of + single_active -> + maps:fold( + fun (CKey, #consumer{status = {_, _}, + checked_out = Checked} = Con, {S, E}) + when map_size(Checked) == 0 -> + %% Remove from active consumers and add to waiting + %% if the consumer has timed out _and_ it has no + %% remaining messages checked out + Consumers = maps:remove(CKey, S#?STATE.consumers), + Waiting0 = S#?STATE.waiting_consumers, + Waiting = add_waiting({CKey, Con}, Waiting0), + S1 = S#?STATE{consumers = Consumers, + waiting_consumers = Waiting}, + E1 = consumer_update_active_effects( + S1, Con, false, waiting, E), + {S1, E1}; + (_, _, Acc) -> + Acc + end, {State1, Effects0}, + maps:iterator(State1#?STATE.consumers, ordered)); + _ -> + {State1, Effects0} + end, + + %% activate SAC + NextConsumerTimeout = next_consumer_timeout(State2), + {State, Effects} = + activate_next_consumer({State2#?STATE{next_consumer_timeout = + NextConsumerTimeout}, + Effects1}), + checkout(Meta, State0, State, Effects); +apply_(_Meta, Cmd, State) -> + %% handle unhandled commands gracefully + ?LOG_DEBUG("rabbit_fifo: unhandled command ~W", [Cmd, 10]), + {State, ok, []}. + +next_consumer_timeout(#?STATE{consumers = Cons}) -> + maps:fold(fun (_, #consumer{checked_out = Ch}, Acc) -> + Min = maps:fold(fun (_, ?C_MSG(T, _), A) -> + min(T, A) + end, infinity, Ch), + min(Min, Acc) + end, infinity, Cons). + + +-spec live_indexes(state()) -> {ra_seq, ra_seq:state()}. +live_indexes(#?STATE{cfg = #cfg{}, + returns = Returns, + messages = Messages, + consumers = Consumers, + delayed = Delayed, + dlx = DLX}) -> + ExtraBuckets = prepare_extra_buckets(Returns, Consumers, Delayed, DLX), + Seq = rabbit_fifo_pq:fold_by_index(fun build_ra_seq/2, + {[], undefined, undefined}, + Messages, + ExtraBuckets), + {ra_seq, finalize_ra_seq(Seq)}. + +%% Prepare extra buckets for fold_by_index (keys > 31 to avoid priority +%% collision) +prepare_extra_buckets(Returns, Consumers, #delayed{tree = Tree}, + #?DLX{discards = Discards, consumer = DlxConsumer}) -> + %% Bucket 32: returns (sorted by index) + B0 = case lqueue:len(Returns) of + 0 -> #{}; + _ -> #{32 => lqueue_to_sorted_queue(Returns)} + end, + %% Bucket 33: consumers checked_out (sorted by index) + B1 = case maps:size(Consumers) of + 0 -> B0; + _ -> B0#{33 => consumers_to_sorted_queue(Consumers)} + end, + %% Bucket 34: dlx discards (sorted by index) + B2 = case lqueue:len(Discards) of + 0 -> B1; + _ -> B1#{34 => dlx_discards_to_sorted_queue(Discards)} + end, + %% Bucket 35: dlx consumer checked_out (sorted by index) + B3 = case DlxConsumer of + undefined -> B2; + #dlx_consumer{checked_out = DlxCheckedOut} + when map_size(DlxCheckedOut) == 0 -> + B2; + #dlx_consumer{checked_out = DlxCheckedOut} -> + B2#{35 => dlx_consumer_to_sorted_queue(DlxCheckedOut)} + end, + %% Bucket 36: delayed messages (sorted by index) + case gb_trees:is_empty(Tree) of + true -> B3; + false -> + B3#{36 => delayed_to_sorted_queue(Tree)} + end. + +lqueue_to_sorted_queue(LQ) -> + Msgs = lqueue:to_list(LQ), + Sorted = lists:sort(fun(A, B) -> get_msg_idx(A) =< get_msg_idx(B) end, Msgs), + {length(Sorted), [], Sorted}. + +consumers_to_sorted_queue(Consumers) -> + Msgs = maps:fold( + fun (_Cid, #consumer{checked_out = Ch}, Acc) -> + maps:fold(fun (_MsgId, ?C_MSG(Msg), A) -> [Msg | A] end, Acc, Ch) + end, [], Consumers), + Sorted = lists:sort(fun(A, B) -> get_msg_idx(A) =< get_msg_idx(B) end, Msgs), + {length(Sorted), [], Sorted}. + +dlx_discards_to_sorted_queue(Discards) -> + Msgs = lqueue:fold(fun (?TUPLE(_, Msg), Acc) -> [Msg | Acc] end, [], Discards), + Sorted = lists:sort(fun(A, B) -> get_msg_idx(A) =< get_msg_idx(B) end, Msgs), + {length(Sorted), [], Sorted}. + +dlx_consumer_to_sorted_queue(DlxCheckedOut) -> + Msgs = maps:fold(fun (_MsgId, ?TUPLE(_, Msg), Acc) -> [Msg | Acc] end, [], DlxCheckedOut), + Sorted = lists:sort(fun(A, B) -> get_msg_idx(A) =< get_msg_idx(B) end, Msgs), + {length(Sorted), [], Sorted}. + +delayed_to_sorted_queue(Tree) -> + Msgs = gb_trees:values(Tree), + Sorted = lists:sort(fun(A, B) -> get_msg_idx(A) =< get_msg_idx(B) end, Msgs), + {length(Sorted), [], Sorted}. + +%% Build ra_seq with Start/End as separate tuple elements to avoid allocations +build_ra_seq(Idx, {SeqAcc, undefined, undefined}) -> + {SeqAcc, Idx, Idx}; +build_ra_seq(Idx, {SeqAcc, Start, End}) when Idx == End + 1 -> + {SeqAcc, Start, Idx}; +build_ra_seq(Idx, {SeqAcc, Start, End}) -> + {emit_range(Start, End, SeqAcc), Idx, Idx}. + +emit_range(S, S, Seq) -> [S | Seq]; +emit_range(S, E, Seq) when E == S + 1 -> [E, S | Seq]; +emit_range(S, E, Seq) -> [{S, E} | Seq]. + +finalize_ra_seq({SeqAcc, undefined, undefined}) -> + SeqAcc; +finalize_ra_seq({SeqAcc, Start, End}) -> + emit_range(Start, End, SeqAcc). + +-spec snapshot_installed(Meta, State, OldMeta, OldState) -> + ra_machine:effects() when + Meta :: ra_snapshot:meta(), + State :: state(), + OldMeta :: ra_snapshot:meta(), + OldState :: state(). +snapshot_installed(_Meta, #?STATE{cfg = #cfg{}, + consumers = Consumers} = State, + _OldMeta, _OldState) -> + %% here we need to redliver all pending consumer messages + %% to local consumers + %% TODO: with some additional state (raft indexes assigned to consumer) + %% we could reduce the number of resends but it is questionable if this + %% complexity is worth the effort. rabbit_fifo_client will de-duplicate + %% deliveries anyway + SendAcc = maps:fold( + fun (_ConsumerKey, #consumer{cfg = #consumer_cfg{tag = Tag, + pid = Pid}, + checked_out = Checked}, + Acc) -> + case node(Pid) == node() of + true -> + Iter = maps:iterator(Checked, ordered), + Msgs = maps:fold(fun (K, ?C_MSG(M), Ac0) -> + [{K, M} | Ac0] + end, [], Iter), + Acc#{{Tag, Pid} => Msgs}; + false -> + Acc + end + end, #{}, Consumers), + delivery_effects(SendAcc, State) ++ + credit_reply_resend_effect(State). + +credit_reply_resend_effect(#?STATE{waiting_consumers = Waiting, + consumers = Consumers} = State) -> + Available = messages_ready(State), + maps:fold( + fun(ConsumerKey, + #consumer{cfg = #consumer_cfg{meta = CMeta, + tag = CTag, + credit_mode = {credited, _}, + pid = CPid}, + drain = Drain, + credit = Credit, + delivery_count = DeliveryCount}, + Acc) -> + {Avail, Props} = case is_map_key(ConsumerKey, Consumers) of + true -> + {Available, #{active => true}}; + false -> + {0, #{active => false}} + end, + Reply = case maps:get(link_state_properties, CMeta, false) of + true -> + #credit_reply{ctag = CTag, + delivery_count = DeliveryCount, + credit = Credit, + available = Avail, + drain = Drain, + properties = Props}; + false -> + {credit_reply, CTag, DeliveryCount, Credit, Avail, Drain} + end, + [{send_msg, CPid, Reply, ?DELIVERY_SEND_MSG_OPTS} | Acc]; + (_, _, Acc) -> + Acc + end, [], maps:merge(Consumers, maps:from_list(Waiting))). + +v7_to_v8_consumer(Con, Timeout) -> + V7Cfg = element(#consumer.cfg, Con), + Status0 = element(#consumer.status, Con), + Ch0 = element(#consumer.checked_out, Con), + Ch = maps:map(fun (_, M) -> ?C_MSG(Timeout, M) end, Ch0), + Cfg = #consumer_cfg{meta = element(#consumer_cfg.meta, V7Cfg), + pid = element(#consumer_cfg.pid, V7Cfg), + tag = element(#consumer_cfg.tag, V7Cfg), + credit_mode = element(#consumer_cfg.credit_mode, V7Cfg), + lifetime = element(#consumer_cfg.lifetime, V7Cfg), + priority = element(#consumer_cfg.priority, V7Cfg), + timeout = ?DEFAULT_CONSUMER_TIMEOUT_MS + }, + Status = case Status0 of + suspected_down -> + {suspected_down, up}; + _ -> + Status0 + end, + #consumer{cfg = Cfg, + status = Status, + next_msg_id = element(#consumer.next_msg_id, Con), + checked_out = Ch, + credit = element(#consumer.credit, Con), + delivery_count = element(#consumer.delivery_count, Con) + }. + +convert_v7_to_v8(#{system_time := Ts} = _Meta, StateV7) -> + %% the structure is intact for now + Cons0 = element(#?STATE.consumers, StateV7), + Waiting0 = element(#?STATE.waiting_consumers, StateV7), + Timeout = Ts + ?DEFAULT_CONSUMER_TIMEOUT_MS, + Cons = maps:map( + fun (_CKey, Con) -> + v7_to_v8_consumer(Con, Timeout) + end, Cons0), + Waiting = lists:map(fun({Cid, Con}) -> + {Cid, v7_to_v8_consumer(Con, Timeout)} + end, Waiting0), + + Msgs = element(#?STATE.messages, StateV7), + Cfg = element(#?STATE.cfg, StateV7), + {Hi, No} = rabbit_fifo_q:to_queues(Msgs), + Pq0 = queue:fold(fun (I, Acc) -> + rabbit_fifo_pq:in(9, I, Acc) + end, rabbit_fifo_pq:new(), Hi), + Pq = queue:fold(fun (I, Acc) -> + rabbit_fifo_pq:in(?DEFAULT_PRIORITY, I, Acc) + end, Pq0, No), + Dlx0 = element(#?STATE.dlx, StateV7), + Dlx = Dlx0#?DLX{unused = ?NIL}, + StateV8 = StateV7, + StateV8#?STATE{cfg = Cfg#cfg{consumer_disconnected_timeout = 60_000, + delayed_retry = disabled}, + reclaimable_bytes = 0, + messages = Pq, + consumers = Cons, + waiting_consumers = Waiting, + next_consumer_timeout = Timeout, + last_command_time = Ts, + dlx = Dlx, + delayed = #delayed{} + }. + +purge_node(Meta, Node, State, Effects) -> + lists:foldl(fun(Pid, {S0, E0}) -> + {S, E} = handle_down(Meta, Pid, S0), + {S, E0 ++ E} + end, {State, Effects}, + all_pids_for(Node, State)). + +%% any downs that are not noconnection +handle_down(Meta, Pid, #?STATE{consumers = Cons0, + enqueuers = Enqs0} = State0) -> + % Remove any enqueuer for the down pid + State1 = State0#?STATE{enqueuers = maps:remove(Pid, Enqs0)}, + {Effects1, State2} = handle_waiting_consumer_down(Pid, State1), + % return checked out messages to main queue + % Find the consumers for the down pid + DownConsumers = maps:filter(fun(_CKey, ?CONSUMER_PID(P)) -> + P =:= Pid + end, Cons0), + DownConsumerKeys = maps_ordered_keys(DownConsumers), + lists:foldl(fun(ConsumerKey, {S, E}) -> + cancel_consumer(Meta, ConsumerKey, S, E, down) + end, {State2, Effects1}, DownConsumerKeys). + +consumer_active_flag_update_function( + #?STATE{cfg = #cfg{consumer_strategy = competing}}) -> + fun(State, _ConsumerKey, Consumer, Active, ActivityStatus, Effects) -> + consumer_update_active_effects(State, Consumer, Active, + ActivityStatus, Effects) + end; +consumer_active_flag_update_function( + #?STATE{cfg = #cfg{consumer_strategy = single_active}}) -> + fun(_, _, _, _, _, Effects) -> + Effects + end. + +handle_waiting_consumer_down(_Pid, + #?STATE{cfg = #cfg{consumer_strategy = competing}} + = State) -> + {[], State}; +handle_waiting_consumer_down(_Pid, + #?STATE{cfg = #cfg{consumer_strategy = single_active}, + waiting_consumers = []} = State) -> + {[], State}; +handle_waiting_consumer_down(Pid, + #?STATE{cfg = #cfg{consumer_strategy = single_active}, + waiting_consumers = WaitingConsumers0} + = State0) -> + % get cancel effects for down waiting consumers + Down = lists:filter(fun({_, ?CONSUMER_PID(P)}) -> P =:= Pid end, + WaitingConsumers0), + Effects = lists:foldl(fun ({_ConsumerKey, Consumer}, Effects) -> + ConsumerId = consumer_id(Consumer), + cancel_consumer_effects(ConsumerId, State0, + Effects) + end, [], Down), + % update state to have only up waiting consumers + StillUp = lists:filter(fun({_CKey, ?CONSUMER_PID(P)}) -> + P =/= Pid + end, + WaitingConsumers0), + State = State0#?STATE{waiting_consumers = StillUp}, + {Effects, State}. + +update_waiting_consumer_status(DownPidOrNode, + #?STATE{waiting_consumers = WaitingConsumers}, + Status) -> + sort_waiting( + [if is_pid(DownPidOrNode) andalso DownPidOrNode == Pid -> + {ConsumerKey, update_consumer_status(Status, Consumer)}; + is_atom(DownPidOrNode) andalso DownPidOrNode == node(Pid) -> + {ConsumerKey, update_consumer_status(Status, Consumer)}; + true -> + {ConsumerKey, Consumer} + end || {ConsumerKey, ?CONSUMER_PID(Pid) = Consumer} + <- WaitingConsumers, Consumer#consumer.status =/= cancelled]). + +-spec state_enter(ra_server:ra_state() | eol, state()) -> + ra_machine:effects(). +state_enter(leader, + #?STATE{consumers = Cons, + enqueuers = Enqs, + waiting_consumers = WaitingConsumers, + cfg = #cfg{resource = QRes, + dead_letter_handler = DLH}, + dlx = DlxState}) -> + % return effects to monitor all current consumers and enqueuers + Pids = lists:usort(maps:keys(Enqs) + ++ [P || ?CONSUMER_PID(P) <- maps:values(Cons)] + ++ [P || {_, ?CONSUMER_PID(P)} <- WaitingConsumers]), + Mons = [{monitor, process, P} || P <- Pids], + Nots = [{send_msg, P, leader_change, ra_event} || P <- Pids], + NodeMons = lists:usort([{monitor, node, node(P)} || P <- Pids]), + NotifyDecs = notify_decorators_startup(QRes), + Effects = Mons ++ Nots ++ NodeMons ++ [NotifyDecs], + + case DLH of + at_least_once -> + ensure_worker_started(QRes, DlxState); + _ -> + ok + end, + Effects; +state_enter(eol, #?STATE{enqueuers = Enqs, + consumers = Cons0, + waiting_consumers = WaitingConsumers0}) -> + Custs = maps:fold(fun(_K, ?CONSUMER_PID(P) = V, S) -> + S#{P => V} + end, #{}, Cons0), + WaitingConsumers1 = lists:foldl(fun({_, ?CONSUMER_PID(P) = V}, Acc) -> + Acc#{P => V} + end, #{}, WaitingConsumers0), + AllConsumers = maps:merge(Custs, WaitingConsumers1), + [{send_msg, P, eol, ra_event} + || P <- maps:keys(maps:merge(Enqs, AllConsumers))] ++ + [{aux, eol}]; +state_enter(_, #?STATE{cfg = #cfg{dead_letter_handler = DLH, + resource = _QRes}, + dlx = DlxState}) -> + case DLH of + at_least_once -> + ensure_worker_terminated(DlxState); + _ -> + ok + end, + %% catch all as not handling all states + []. + +-spec tick(non_neg_integer(), state()) -> ra_machine:effects(). +tick(Ts, #?STATE{cfg = #cfg{resource = QName}} = State) -> + case is_expired(Ts, State) of + true -> + [{mod_call, rabbit_quorum_queue, spawn_deleter, [QName]}]; + false -> + [{aux, {handle_tick, [QName, overview(State), all_nodes(State)]}}] + end. + +-spec overview(state()) -> map(). +overview(#?STATE{consumers = Cons, + enqueuers = Enqs, + msg_bytes_enqueue = EnqueueBytes, + msg_bytes_checkout = CheckoutBytes, + cfg = Cfg, + dlx = DlxState, + delayed = Delayed, + reclaimable_bytes = ReclaimableBytes, + messages = Messages, + returns = Returns, + waiting_consumers = WaitingConsumers} = State) -> + Conf = #{name => Cfg#cfg.name, + resource => Cfg#cfg.resource, + dead_lettering_enabled => undefined =/= Cfg#cfg.dead_letter_handler, + dead_letter_handler => Cfg#cfg.dead_letter_handler, + overflow_strategy => Cfg#cfg.overflow_strategy, + max_length => Cfg#cfg.max_length, + max_bytes => Cfg#cfg.max_bytes, + consumer_strategy => Cfg#cfg.consumer_strategy, + expires => Cfg#cfg.expires, + msg_ttl => Cfg#cfg.msg_ttl, + delivery_limit => Cfg#cfg.delivery_limit, + consumer_disconnected_timeout => Cfg#cfg.consumer_disconnected_timeout, + delayed_retry => Cfg#cfg.delayed_retry}, + SacOverview = case active_consumer(Cons) of + {SacConsumerKey, SacCon} -> + SacConsumerId = consumer_id(SacCon), + NumWaiting = length(WaitingConsumers), + #{single_active_consumer_id => SacConsumerId, + single_active_consumer_key => SacConsumerKey, + single_active_num_waiting_consumers => NumWaiting}; + _ -> + #{} + end, + MsgsRet = lqueue:len(Returns), + + #{num_active_priorities := NumActivePriorities, + detail := Detail} = rabbit_fifo_pq:overview(Messages), + + {DelayedLen, NextDelayedAt, LastDelayedAt} = + case Delayed of + #delayed{next = undefined} -> + {0, undefined, undefined}; + #delayed{next = {NextTs, _, _}, tree = Tree} -> + {?TUPLE(LastTs, _), _} = gb_trees:largest(Tree), + {gb_trees:size(Tree), NextTs, LastTs} + end, + Overview = #{type => ?STATE, + config => Conf, + num_consumers => map_size(Cons), + num_active_consumers => query_consumer_count(State), + num_checked_out => num_checked_out(State), + num_enqueuers => maps:size(Enqs), + num_ready_messages => messages_ready(State), + num_ready_messages_return => MsgsRet, + num_delayed_messages => DelayedLen, + next_delayed_at => NextDelayedAt, + last_delayed_at => LastDelayedAt, + num_messages => messages_total(State), + enqueue_message_bytes => EnqueueBytes, + checkout_message_bytes => CheckoutBytes, + reclaimable_bytes_count => ReclaimableBytes, + smallest_raft_index => smallest_raft_index(State), + num_active_priorities => NumActivePriorities, + messages_by_priority => Detail + }, + DlxOverview = dlx_overview(DlxState), + maps:merge(maps:merge(Overview, DlxOverview), SacOverview). + +-spec get_checked_out(consumer_key(), msg_id(), msg_id(), state()) -> + [{msg_id(), {ra:index(), msg_header()}}]. +get_checked_out(CKey, From, To, #?STATE{consumers = Consumers}) -> + case find_consumer(CKey, Consumers) of + {_CKey, #consumer{checked_out = Checked}} -> + [begin + ?C_MSG(Msg) = maps:get(K, Checked), + I = get_msg_idx(Msg), + H = get_msg_header(Msg), + {K, {I, H}} + end || K <- lists:seq(From, To), maps:is_key(K, Checked)]; + _ -> + [] + end. + +-spec version() -> pos_integer(). +version() -> 8. + +which_module(0) -> rabbit_fifo_v0; +which_module(1) -> rabbit_fifo_v1; +which_module(2) -> rabbit_fifo_v3; +which_module(3) -> rabbit_fifo_v3; +which_module(4) -> rabbit_fifo_v7; +which_module(5) -> rabbit_fifo_v7; +which_module(6) -> rabbit_fifo_v7; +which_module(7) -> rabbit_fifo_v7; +which_module(8) -> ?MODULE. + +-define(AUX, aux_v4). + +-record(snapshot, {index :: ra:index(), + timestamp :: milliseconds(), + messages_total = 0 :: non_neg_integer(), + reclaimable_bytes = 0 :: non_neg_integer(), + min_reclaimable = ?SNAP_MIN_RECLAIMABLE_B :: non_neg_integer()}). +-record(aux_gc, {last_raft_idx = 0 :: ra:index()}). +-record(?AUX, {name :: atom(), + last_decorators_state :: term(), + last_consumer_timeout = + infinity :: infinity | non_neg_integer(), + gc = #aux_gc{} :: #aux_gc{}, + tick_pid :: undefined | pid(), + cache = #{} :: map(), + last_checkpoint :: tuple() | #snapshot{} + }). + +init_aux(Name) when is_atom(Name) -> + %% TODO: catch specific exception throw if table already exists + ok = ra_machine_ets:create_table(rabbit_fifo_usage, + [named_table, set, public, + {write_concurrency, true}]), + {_, SnapMinReclaimable} = + persistent_term:get(quorum_queue_snapshot_config, + {?CHECK_MIN_INTERVAL_MS, + ?SNAP_MIN_RECLAIMABLE_B}), + Range = max(1, SnapMinReclaimable - ?SNAP_MIN_RECLAIMABLE_LOW_B), + MinReclaimable = ?SNAP_MIN_RECLAIMABLE_LOW_B + rand:uniform(Range), + #?AUX{name = Name, + last_checkpoint = #snapshot{index = 0, + timestamp = erlang:system_time(millisecond), + messages_total = 0, + min_reclaimable = MinReclaimable}}. + +handle_aux(RaftState, Tag, Cmd, AuxV2, RaAux) + when element(1, AuxV2) == aux_v2 -> + Name = element(2, AuxV2), + AuxV3 = init_aux(Name), + handle_aux(RaftState, Tag, Cmd, AuxV3, RaAux); +handle_aux(RaftState, Tag, Cmd, AuxV3, RaAux) + when element(1, AuxV3) == aux_v3 -> + AuxV4 = #?AUX{name = element(2, AuxV3), + last_decorators_state = element(3, AuxV3), + last_consumer_timeout = infinity, + gc = element(5, AuxV3), + tick_pid = element(6, AuxV3), + cache = element(7, AuxV3), + last_checkpoint = element(8, AuxV3) + }, + handle_aux(RaftState, Tag, Cmd, AuxV4, RaAux); +handle_aux(leader, cast, eval, + #?AUX{last_decorators_state = LastDec, + last_consumer_timeout = LastConTimeout0, + last_checkpoint = Check0} = Aux0, + RaAux) -> + + #?STATE{cfg = #cfg{resource = QName}, + next_consumer_timeout = NextConTimeout, + reclaimable_bytes = ReclaimableBytes} = MacState = + ra_aux:machine_state(RaAux), + + Ts = erlang:system_time(millisecond), + EffMacVer = try ra_aux:effective_machine_version(RaAux) of + V -> V + catch _:_ -> + %% this function is not available in older aux states. + %% this is a guess + undefined + end, + {Check, Effects0} = do_snapshot(EffMacVer, Ts, Check0, RaAux, + ReclaimableBytes, false), + + %% this is called after each batch of commands have been applied + %% set timer for message expire + Effects1 = timer_effect(MacState, Effects0), + %% if the timer has already elapsed (stale time from a prior leadership term) + %% then set infinity to ensure a timer is started + LastConTimeout = if LastConTimeout0 < Ts -> + infinity; + true -> + LastConTimeout0 + end, + + Effects2 = maybe_add_consumer_timeout_effect(NextConTimeout, + LastConTimeout, + Effects1), + case query_notify_decorators_info(MacState) of + LastDec -> + {no_reply, Aux0#?AUX{last_checkpoint = Check, + last_consumer_timeout = NextConTimeout}, RaAux, Effects2}; + {MaxActivePriority, IsEmpty} = NewLast -> + Effects = [notify_decorators_effect(QName, MaxActivePriority, IsEmpty) + | Effects2], + {no_reply, Aux0#?AUX{last_checkpoint = Check, + last_consumer_timeout = NextConTimeout, + last_decorators_state = NewLast}, RaAux, + Effects} + end; +handle_aux(_RaftState, cast, eval, + #?AUX{last_checkpoint = Check0} = Aux0, RaAux) -> + + Ts = erlang:system_time(millisecond), + + EffMacVer = ra_aux:effective_machine_version(RaAux), + #?STATE{reclaimable_bytes = ReclaimableBytes} = ra_aux:machine_state(RaAux), + {Check, Effects} = do_snapshot(EffMacVer, Ts, Check0, RaAux, + ReclaimableBytes, false), + {no_reply, Aux0#?AUX{last_checkpoint = Check}, RaAux, Effects}; +handle_aux(_RaftState, cast, {#return{msg_ids = MsgIds, + consumer_key = Key} = Ret, Corr, Pid}, + Aux0, RaAux0) -> + case ra_aux:machine_state(RaAux0) of + #?STATE{cfg = #cfg{delivery_limit = undefined}, + consumers = Consumers} -> + case find_consumer(Key, Consumers) of + {ConsumerKey, #consumer{checked_out = Checked}} -> + {RaAux, ToReturn} = + maps:fold( + fun (MsgId, ?C_MSG(Msg), {RA0, Acc}) -> + Idx = get_msg_idx(Msg), + Header = get_msg_header(Msg), + %% it is possible this is not found if the consumer + %% crashed and the message got removed + case ra_aux:log_fetch(Idx, RA0) of + {{_Term, _Meta, Cmd}, RA} -> + RawMsg = get_msg_from_cmd(Cmd), + {RA, [{MsgId, Idx, Header, RawMsg} | Acc]}; + {undefined, RA} -> + {RA, Acc} + end + end, {RaAux0, []}, maps:with(MsgIds, Checked)), + + Appends = make_requeue(ConsumerKey, {notify, Corr, Pid}, + lists:sort(ToReturn), []), + {no_reply, Aux0, RaAux, Appends}; + _ -> + {no_reply, Aux0, RaAux0} + end; + _ -> + %% for returns with a delivery limit set we can just return as before + {no_reply, Aux0, RaAux0, [{append, Ret, {notify, Corr, Pid}}]} + end; +handle_aux(leader, _, {handle_tick, [QName, Overview0, Nodes]}, + #?AUX{tick_pid = Pid} = Aux, RaAux) -> + Overview = Overview0#{members_info => ra_aux:members_info(RaAux)}, + NewPid = + case process_is_alive(Pid) of + false -> + %% No active TICK pid + %% this function spawns and returns the tick process pid + rabbit_quorum_queue:handle_tick(QName, Overview, Nodes); + true -> + %% Active TICK pid, do nothing + Pid + end, + + %% TODO: check consumer timeouts + {no_reply, Aux#?AUX{tick_pid = NewPid}, RaAux, []}; +handle_aux(_, _, {get_checked_out, ConsumerKey, MsgIds}, Aux0, RaAux0) -> + #?STATE{cfg = #cfg{}, + consumers = Consumers} = ra_aux:machine_state(RaAux0), + case Consumers of + #{ConsumerKey := #consumer{checked_out = Checked}} -> + {RaState, IdMsgs} = + maps:fold( + fun (MsgId, ?C_MSG(Msg), {S0, Acc}) -> + Idx = get_msg_idx(Msg), + Header = get_msg_header(Msg), + %% it is possible this is not found if the consumer + %% crashed and the message got removed + case ra_aux:log_fetch(Idx, S0) of + {{_Term, _Meta, Cmd}, S} -> + {S, [{MsgId, {Header, get_msg_from_cmd(Cmd)}} | Acc]}; + {undefined, S} -> + {S, Acc} + end + end, {RaAux0, []}, maps:with(MsgIds, Checked)), + {reply, {ok, lists:keysort(1, IdMsgs)}, Aux0, RaState}; + _ -> + {reply, {error, consumer_not_found}, Aux0, RaAux0} + end; +handle_aux(_RaState, cast, tick, #?AUX{name = _Name} = State0, + RaAux) -> + Aux = eval_gc(RaAux, ra_aux:machine_state(RaAux), State0), + {no_reply, Aux, RaAux, []}; +handle_aux(_RaState, cast, eol, #?AUX{name = Name} = Aux, RaAux) -> + ets:delete(rabbit_fifo_usage, Name), + {no_reply, Aux, RaAux}; +handle_aux(_RaState, {call, _From}, oldest_entry_timestamp, + #?AUX{cache = Cache} = Aux0, RaAux0) -> + {CachedIdx, CachedTs} = maps:get(oldest_entry, Cache, + {undefined, undefined}), + case smallest_raft_index(ra_aux:machine_state(RaAux0)) of + %% if there are no entries, we return current timestamp + %% so that any previously obtained entries are considered + %% older than this + undefined -> + Aux1 = Aux0#?AUX{cache = maps:remove(oldest_entry, Cache)}, + {reply, {ok, erlang:system_time(millisecond)}, Aux1, RaAux0}; + CachedIdx -> + %% cache hit + {reply, {ok, CachedTs}, Aux0, RaAux0}; + Idx when is_integer(Idx) -> + case ra_aux:log_fetch(Idx, RaAux0) of + {{_Term, #{ts := Timestamp}, _Cmd}, RaAux} -> + Aux1 = Aux0#?AUX{cache = Cache#{oldest_entry => + {Idx, Timestamp}}}, + {reply, {ok, Timestamp}, Aux1, RaAux}; + {undefined, RaAux} -> + %% fetch failed + {reply, {error, failed_to_get_timestamp}, Aux0, RaAux} + end + end; +handle_aux(_RaState, {call, _From}, {peek, Pos}, Aux0, + RaAux0) -> + MacState = ra_aux:machine_state(RaAux0), + case query_peek(Pos, MacState) of + {ok, Msg} -> + Idx = get_msg_idx(Msg), + Header = get_msg_header(Msg), + %% need to re-hydrate from the log + {{_, _, Cmd}, RaAux} = ra_aux:log_fetch(Idx, RaAux0), + ActualMsg = get_msg_from_cmd(Cmd), + {reply, {ok, {Header, ActualMsg}}, Aux0, RaAux}; + Err -> + {reply, Err, Aux0, RaAux0} + end; +handle_aux(_, _, garbage_collection, Aux, RaAux) -> + {no_reply, force_eval_gc(RaAux, Aux), RaAux}; +handle_aux(_RaState, _, force_checkpoint, + #?AUX{last_checkpoint = Check0} = Aux, RaAux) -> + Ts = erlang:system_time(millisecond), + #?STATE{cfg = #cfg{resource = QR}, + reclaimable_bytes = ReclaimableBytes} = ra_aux:machine_state(RaAux), + ?LOG_DEBUG("~ts: rabbit_fifo: forcing snapshot at ~b", + [rabbit_misc:rs(QR), ra_aux:last_applied(RaAux)]), + EffMacVer = ra_aux:effective_machine_version(RaAux), + {Check, Effects} = do_snapshot(EffMacVer, Ts, Check0, RaAux, + ReclaimableBytes, true), + {no_reply, Aux#?AUX{last_checkpoint = Check}, RaAux, Effects}; +handle_aux(leader, _, {dlx, setup}, Aux, RaAux) -> + #?STATE{dlx = DlxState, + cfg = #cfg{dead_letter_handler = DLH, + resource = QRes}} = ra_aux:machine_state(RaAux), + case DLH of + at_least_once -> + ensure_worker_started(QRes, DlxState); + _ -> + ok + end, + {no_reply, Aux, RaAux}; +handle_aux(_, _, {dlx, teardown, Pid}, Aux, RaAux) -> + terminate_dlx_worker(Pid), + {no_reply, Aux, RaAux}; +handle_aux(_, _, Unhandled, Aux, RaAux) -> + #?STATE{cfg = #cfg{resource = QR}} = ra_aux:machine_state(RaAux), + ?LOG_DEBUG("~ts: rabbit_fifo: unhandled aux command ~P", + [rabbit_misc:rs(QR), Unhandled, 10]), + {no_reply, Aux, RaAux}. + + +eval_gc(RaAux, MacState, + #?AUX{gc = #aux_gc{last_raft_idx = LastGcIdx} = Gc} = AuxState) -> + {Idx, _} = ra_aux:log_last_index_term(RaAux), + #?STATE{cfg = #cfg{resource = QR}} = ra_aux:machine_state(RaAux), + {memory, Mem} = erlang:process_info(self(), memory), + case messages_total(MacState) of + 0 when Idx > LastGcIdx andalso + Mem > ?GC_MEM_LIMIT_B -> + garbage_collect(), + {memory, MemAfter} = erlang:process_info(self(), memory), + ?LOG_DEBUG("~ts: full GC sweep complete. " + "Process memory changed from ~.2fMB to ~.2fMB.", + [rabbit_misc:rs(QR), Mem/?MB, MemAfter/?MB]), + AuxState#?AUX{gc = Gc#aux_gc{last_raft_idx = Idx}}; + _ -> + AuxState + end. + +force_eval_gc(RaAux, + #?AUX{gc = #aux_gc{last_raft_idx = LastGcIdx} = Gc} = AuxState) -> + {Idx, _} = ra_aux:log_last_index_term(RaAux), + #?STATE{cfg = #cfg{resource = QR}} = ra_aux:machine_state(RaAux), + {memory, Mem} = erlang:process_info(self(), memory), + case Idx > LastGcIdx of + true -> + garbage_collect(), + {memory, MemAfter} = erlang:process_info(self(), memory), + ?LOG_DEBUG("~ts: full GC sweep complete. " + "Process memory changed from ~.2fMB to ~.2fMB.", + [rabbit_misc:rs(QR), Mem/?MB, MemAfter/?MB]), + AuxState#?AUX{gc = Gc#aux_gc{last_raft_idx = Idx}}; + false -> + AuxState + end. + +process_is_alive(Pid) when is_pid(Pid) -> + is_process_alive(Pid); +process_is_alive(_) -> + false. +%%% Queries + +query_messages_ready(State) -> + messages_ready(State). + +query_messages_checked_out(#?STATE{consumers = Consumers}) -> + maps:fold(fun (_, #consumer{checked_out = C}, S) -> + maps:size(C) + S + end, 0, Consumers). + +query_messages_total(State) -> + messages_total(State). + +query_processes(#?STATE{enqueuers = Enqs, consumers = Cons0}) -> + Cons = maps:fold(fun(_, ?CONSUMER_PID(P) = V, S) -> + S#{P => V} + end, #{}, Cons0), + maps:keys(maps:merge(Enqs, Cons)). + + +query_waiting_consumers(#?STATE{waiting_consumers = WaitingConsumers}) -> + WaitingConsumers. + +query_consumer_count(#?STATE{consumers = Consumers, + waiting_consumers = WaitingConsumers}) -> + Up = maps:filter(fun(_ConsumerKey, #consumer{status = Status}) -> + %% TODO: should this really not include suspected + %% consumers? + is_atom(Status) + end, Consumers), + maps:size(Up) + length(WaitingConsumers). + +query_consumers(#?STATE{consumers = Consumers, + waiting_consumers = WaitingConsumers, + cfg = #cfg{consumer_strategy = ConsumerStrategy}} + = State) -> + ActiveActivityStatusFun = + case ConsumerStrategy of + competing -> + fun(_ConsumerKey, #consumer{status = Status}) -> + case Status of + {suspected_down, _} -> + {false, suspected_down}; + _ -> + {true, Status} + end + end; + single_active -> + SingleActiveConsumer = query_single_active_consumer(State), + fun(_, ?CONSUMER_TAG_PID(Tag, Pid)) -> + case SingleActiveConsumer of + {value, {Tag, Pid}} -> + {true, single_active}; + _ -> + {false, waiting} + end + end + end, + FromConsumers = + maps:fold(fun (_, #consumer{status = cancelled}, Acc) -> + Acc; + (Key, + #consumer{cfg = #consumer_cfg{tag = Tag, + pid = Pid, + meta = Meta}} = Consumer, + Acc) -> + {Active, ActivityStatus} = + ActiveActivityStatusFun(Key, Consumer), + maps:put(Key, + {Pid, Tag, + maps:get(ack, Meta, undefined), + maps:get(prefetch, Meta, undefined), + Active, + ActivityStatus, + maps:get(args, Meta, []), + maps:get(username, Meta, undefined)}, + Acc) + end, #{}, Consumers), + FromWaitingConsumers = + lists:foldl( + fun ({_, #consumer{status = cancelled}}, + Acc) -> + Acc; + ({Key, + #consumer{cfg = #consumer_cfg{tag = Tag, + pid = Pid, + meta = Meta}} = Consumer}, + Acc) -> + {Active, ActivityStatus} = + ActiveActivityStatusFun(Key, Consumer), + maps:put(Key, + {Pid, Tag, + maps:get(ack, Meta, undefined), + maps:get(prefetch, Meta, undefined), + Active, + ActivityStatus, + maps:get(args, Meta, []), + maps:get(username, Meta, undefined)}, + Acc) + end, #{}, WaitingConsumers), + maps:merge(FromConsumers, FromWaitingConsumers). + + +query_single_active_consumer(#?STATE{cfg = #cfg{consumer_strategy = single_active}, + consumers = Consumers} = State) -> + try active_consumer(Consumers) of + undefined -> + {error, no_value}; + {_CKey, ?CONSUMER_TAG_PID(Tag, Pid)} -> + {value, {Tag, Pid}} + catch error:function_clause:_ -> + %% the state may be v7, try that + rabbit_fifo_v7:query_single_active_consumer(State) + end; +query_single_active_consumer(MaybeV7) + when is_tuple(MaybeV7) -> + rabbit_fifo_v7:query_single_active_consumer(MaybeV7); +query_single_active_consumer(_) -> + disabled. + +query_stat(#?STATE{consumers = Consumers} = State) -> + {messages_ready(State), maps:size(Consumers)}. + +query_in_memory_usage(#?STATE{ }) -> + {0, 0}. + +query_stat_dlx(#?STATE{dlx = DlxState}) -> + dlx_stat(DlxState). + +query_peek(Pos, State0) when Pos > 0 -> + case take_next_msg(State0) of + empty -> + {error, no_message_at_pos}; + {Msg, _State} + when Pos == 1 -> + {ok, unpack(Msg)}; + {_Msg, State} -> + query_peek(Pos-1, State) + end. + +unpack(Packed) when ?IS_PACKED(Packed) -> + ?MSG(?PACKED_IDX(Packed), ?PACKED_SZ(Packed)); +unpack(Msg) -> + Msg. + +query_notify_decorators_info(#?STATE{consumers = Consumers} = State) -> + MaxActivePriority = maps:fold( + fun(_, #consumer{credit = C, + status = up, + cfg = #consumer_cfg{priority = P}}, + MaxP) when C > 0 -> + case MaxP of + empty -> P; + MaxP when MaxP > P -> MaxP; + _ -> P + end; + (_, _, MaxP) -> + MaxP + end, empty, Consumers), + IsEmpty = (messages_ready(State) == 0), + {MaxActivePriority, IsEmpty}. + +-spec usage(atom()) -> float(). +usage(Name) when is_atom(Name) -> + case ets:lookup(rabbit_fifo_usage, Name) of + [] -> 0.0; + [{_, Use}] -> Use + end. + +%%% Internal + +messages_ready(#?STATE{messages = M, + returns = R}) -> + rabbit_fifo_pq:len(M) + lqueue:len(R). + +%% Messages ready plus delayed - used for limit calculations. +%% Delayed messages count towards the limit even though they're not ready. +messages_ready_plus_delayed(#?STATE{delayed = #delayed{tree = Tree}} = State) -> + messages_ready(State) + gb_trees:size(Tree). + +messages_total(#?STATE{messages_total = Total, + dlx = DlxState}) -> + {DlxTotal, _} = dlx_stat(DlxState), + Total + DlxTotal. + +num_checked_out(#?STATE{consumers = Cons}) -> + maps:fold(fun (_, #consumer{checked_out = C}, Acc) -> + maps:size(C) + Acc + end, 0, Cons). + +cancel_consumer(Meta, ConsumerKey, + #?STATE{cfg = #cfg{consumer_strategy = competing}} = State, + Effects, Reason) -> + cancel_consumer0(Meta, ConsumerKey, State, Effects, Reason); +cancel_consumer(Meta, ConsumerKey, + #?STATE{cfg = #cfg{consumer_strategy = single_active}, + waiting_consumers = []} = State, + Effects, Reason) -> + %% single active consumer on, no consumers are waiting + cancel_consumer0(Meta, ConsumerKey, State, Effects, Reason); +cancel_consumer(Meta, ConsumerKey, + #?STATE{consumers = Cons0, + cfg = #cfg{consumer_strategy = single_active}, + waiting_consumers = Waiting0} = State0, + Effects0, Reason) -> + %% single active consumer on, consumers are waiting + case Cons0 of + #{ConsumerKey := #consumer{status = _}} -> + % The active consumer is to be removed + cancel_consumer0(Meta, ConsumerKey, State0, + Effects0, Reason); + _ -> + % The cancelled consumer is not active or cancelled + % Just remove it from idle_consumers + case lists:keyfind(ConsumerKey, 1, Waiting0) of + {_, ?CONSUMER_TAG_PID(T, P)} -> + Waiting = lists:keydelete(ConsumerKey, 1, Waiting0), + Effects = cancel_consumer_effects({T, P}, State0, Effects0), + % A waiting consumer isn't supposed to have any checked out messages, + % so nothing special to do here + {State0#?STATE{waiting_consumers = Waiting}, Effects}; + _ -> + {State0, Effects0} + end + end. + +consumer_update_active_effects(#?STATE{cfg = #cfg{resource = QName}} = State, + #consumer{cfg = #consumer_cfg{meta = Meta, + pid = CPid, + tag = CTag, + credit_mode = Mode}, + delivery_count = DeliveryCount, + credit = Credit, + drain = Drain}, + Active, ActivityStatus, Effects0) -> + Ack = maps:get(ack, Meta, undefined), + Prefetch = maps:get(prefetch, Meta, undefined), + Args = maps:get(args, Meta, []), + Effects = [{mod_call, rabbit_quorum_queue, update_consumer_handler, + [QName, {CTag, CPid}, false, Ack, Prefetch, + Active, ActivityStatus, Args]} | Effects0], + case Mode of + {credited, _} when map_get(link_state_properties, Meta) =:= true -> + Avail = case Active of + true -> messages_ready(State); + false -> 0 + end, + Reply = #credit_reply{ctag = CTag, + delivery_count = DeliveryCount, + credit = Credit, + available = Avail, + drain = Drain, + properties = #{active => Active}}, + [{send_msg, CPid, Reply, ?DELIVERY_SEND_MSG_OPTS} | Effects]; + _ -> + Effects + end. + +cancel_consumer0(Meta, ConsumerKey, + #?STATE{consumers = C0} = S0, Effects0, Reason) -> + case C0 of + #{ConsumerKey := Consumer} -> + {S, Effects2} = maybe_return_all(Meta, ConsumerKey, Consumer, + S0, Effects0, Reason), + + + %% The effects are emitted before the consumer is actually removed + %% if the consumer has unacked messages. This is a bit weird but + %% in line with what classic queues do (from an external point of + %% view) + Effects = cancel_consumer_effects(consumer_id(Consumer), S, Effects2), + {S, Effects}; + _ -> + %% already removed: do nothing + {S0, Effects0} + end. + +activate_next_consumer({State, Effects}) -> + activate_next_consumer(State, Effects). + +activate_next_consumer(#?STATE{cfg = #cfg{consumer_strategy = competing}} = State, + Effects) -> + {State, Effects}; +activate_next_consumer(#?STATE{consumers = Cons0, + waiting_consumers = Waiting0} = State0, + Effects0) -> + %% invariant, the waiting list always need to be sorted by consumers that are + %% up - then by priority + NextConsumer = + case Waiting0 of + [{_, #consumer{status = up}} = Next | _] -> + Next; + _ -> + undefined + end, + + case {active_consumer(Cons0), NextConsumer} of + {undefined, {NextCKey, #consumer{cfg = NextCCfg} = NextC}} -> + Remaining = tl(Waiting0), + %% TODO: can this happen? + Consumer = case maps:get(NextCKey, Cons0, undefined) of + undefined -> + NextC; + Existing -> + %% there was an exisiting non-active consumer + %% just update the existing cancelled consumer + %% with the new config + Existing#consumer{cfg = NextCCfg} + end, + #?STATE{service_queue = ServiceQueue} = State0, + ServiceQueue1 = maybe_queue_consumer(NextCKey, + Consumer, + ServiceQueue), + State = State0#?STATE{consumers = Cons0#{NextCKey => Consumer}, + service_queue = ServiceQueue1, + waiting_consumers = Remaining}, + Effects = consumer_update_active_effects(State, Consumer, + true, single_active, + Effects0), + {State, Effects}; + {{ActiveCKey, ?CONSUMER_PRIORITY(ActivePriority) = + #consumer{checked_out = ActiveChecked} = Active}, + {NextCKey, ?CONSUMER_PRIORITY(WaitingPriority) = Consumer}} + when WaitingPriority > ActivePriority andalso + map_size(ActiveChecked) == 0 -> + Remaining = tl(Waiting0), + %% the next consumer is a higher priority and should take over + %% and this consumer does not have any pending messages + #?STATE{service_queue = ServiceQueue} = State0, + ServiceQueue1 = maybe_queue_consumer(NextCKey, + Consumer, + ServiceQueue), + Cons1 = Cons0#{NextCKey => Consumer}, + Cons = maps:remove(ActiveCKey, Cons1), + Waiting = add_waiting({ActiveCKey, Active}, Remaining), + State = State0#?STATE{consumers = Cons, + service_queue = ServiceQueue1, + waiting_consumers = Waiting}, + Effects1 = consumer_update_active_effects(State, Active, + false, waiting, + Effects0), + Effects = consumer_update_active_effects(State, Consumer, + true, single_active, + Effects1), + {State, Effects}; + {{ActiveCKey, ?CONSUMER_PRIORITY(ActivePriority) = Active}, + {_NextCKey, ?CONSUMER_PRIORITY(WaitingPriority)}} + when WaitingPriority > ActivePriority -> + %% A higher priority consumer has attached but the current one has + %% pending messages + Cons = maps:update(ActiveCKey, + Active#consumer{status = quiescing}, + Cons0), + {State0#?STATE{consumers = Cons}, Effects0}; + _ -> + %% no activation + {State0, Effects0} + end. + +active_consumer({CKey, #consumer{status = Status} = Consumer, _I}) + when Status == up orelse + Status == quiescing orelse + Status == {suspected_down, up} orelse + Status == {suspected_down, quiescing} -> + {CKey, Consumer}; +active_consumer({_CKey, #consumer{status = _}, I}) -> + active_consumer(maps:next(I)); +active_consumer(none) -> + undefined; +active_consumer(M) when is_map(M) -> + I = maps:iterator(M), + active_consumer(maps:next(I)). + +is_active(_ConsumerKey, #?STATE{cfg = #cfg{consumer_strategy = competing}}) -> + %% all competing consumers are potentially active + true; +is_active(ConsumerKey, #?STATE{cfg = #cfg{consumer_strategy = single_active}, + consumers = Consumers}) -> + case active_consumer(Consumers) of + {ConsumerKey, _} -> + true; + _ -> + false + end. + +maybe_return_all(#{system_time := Ts} = Meta, ConsumerKey, + #consumer{cfg = CCfg} = Consumer, S0, + Effects0, Reason) -> + case Reason of + cancel -> + {update_or_remove_con( + Meta, ConsumerKey, + Consumer#consumer{cfg = CCfg#consumer_cfg{lifetime = once}, + credit = 0, + status = cancelled}, + S0), Effects0}; + _ -> + {S1, Effects} = return_all(Meta, S0, Effects0, ConsumerKey, + Consumer, Reason == down), + {S1#?STATE{consumers = maps:remove(ConsumerKey, S1#?STATE.consumers), + last_active = Ts}, + Effects} + end. + +apply_enqueue(#{index := RaftIdx, + system_time := Ts} = Meta, From, + Seq, RawMsg, Size, State0) -> + case maybe_enqueue(RaftIdx, Ts, From, Seq, RawMsg, Size, [], State0) of + {ok, State1, Effects1} -> + checkout(Meta, State0, State1, Effects1); + {out_of_sequence, State, Effects} -> + {State, not_enqueued, Effects}; + {duplicate, State, Effects} -> + {State, ok, Effects} + end. + +decr_total(#?STATE{messages_total = Tot} = State) -> + State#?STATE{messages_total = Tot - 1}. + +drop_head(#?STATE{reclaimable_bytes = ReclaimableBytes0} = State0, Effects) -> + case take_next_msg(State0) of + {Msg, State1} -> + Header = get_msg_header(Msg), + State = decr_total(add_bytes_drop(Header, State1)), + #?STATE{cfg = #cfg{dead_letter_handler = DLH}, + dlx = DlxState} = State, + {_, _RetainedBytes, DlxEffects} = + discard_or_dead_letter([Msg], maxlen, DLH, DlxState), + Size = get_header(size, Header), + {State#?STATE{reclaimable_bytes = ReclaimableBytes0 + Size + ?ENQ_OVERHEAD_B}, + add_drop_head_effects(DlxEffects, Effects)}; + empty -> + {State0, Effects} + end. + +add_drop_head_effects([{mod_call, + rabbit_global_counters, + messages_dead_lettered, + [Reason, rabbit_quorum_queue, Type, NewLen]}], + [{mod_call, + rabbit_global_counters, + messages_dead_lettered, + [Reason, rabbit_quorum_queue, Type, PrevLen]} | Rem]) -> + %% combine global counter update effects to avoid bulding a huge list of + %% effects if many messages are dropped at the same time as could happen + %% when the `max_length' is changed via a configuration update. + [{mod_call, + rabbit_global_counters, + messages_dead_lettered, + [Reason, rabbit_quorum_queue, Type, PrevLen + NewLen]} | Rem]; +add_drop_head_effects(New, Old) -> + New ++ Old. + +maybe_set_msg_ttl(Msg, RaCmdTs, Header, + #?STATE{cfg = #cfg{msg_ttl = MsgTTL}}) -> + case mc:is(Msg) of + true -> + TTL = min(MsgTTL, mc:ttl(Msg)), + update_expiry_header(RaCmdTs, TTL, Header); + false -> + update_expiry_header(RaCmdTs, MsgTTL, Header) + end. + +maybe_set_msg_delivery_count(Msg, Header) -> + case mc:is(Msg) of + true -> + case mc:get_annotation(delivery_count, Msg) of + undefined -> + Header; + DelCnt -> + update_header(delivery_count, fun (_) -> DelCnt end, + DelCnt, Header) + end; + false -> + Header + end. + +update_expiry_header(_, undefined, Header) -> + Header; +update_expiry_header(RaCmdTs, 0, Header) -> + %% We do not comply exactly with the "TTL=0 models AMQP immediate flag" semantics + %% as done for classic queues where the message is discarded if it cannot be + %% consumed immediately. + %% Instead, we discard the message if it cannot be consumed within the same millisecond + %% when it got enqueued. This behaviour should be good enough. + update_expiry_header(RaCmdTs + 1, Header); +update_expiry_header(RaCmdTs, TTL, Header) -> + update_expiry_header(RaCmdTs + TTL, Header). + +update_expiry_header(ExpiryTs, Header) -> + update_header(expiry, fun(Ts) -> Ts end, ExpiryTs, Header). + +make_msg(Idx, Sz) + when Idx =< ?PACKED_IDX_MAX andalso + (is_integer(Sz) andalso Sz =< ?PACKED_SZ_MAX) -> + ?PACK(Idx, Sz); +make_msg(Idx, Hdr) -> + ?MSG(Idx, Hdr). + +maybe_enqueue(RaftIdx, Ts, undefined, undefined, RawMsg, + {MetaSize, BodySize}, + Effects, #?STATE{msg_bytes_enqueue = Enqueue, + messages = Messages, + messages_total = Total} = State0) -> + % direct enqueue without tracking + Size = MetaSize + BodySize, + Header0 = maybe_set_msg_ttl(RawMsg, Ts, Size, State0), + Header = maybe_set_msg_delivery_count(RawMsg, Header0), + Msg = make_msg(RaftIdx, Header), + Priority = msg_priority(RawMsg), + State = State0#?STATE{msg_bytes_enqueue = Enqueue + Size, + messages_total = Total + 1, + messages = rabbit_fifo_pq:in(Priority, Msg, Messages) + }, + {ok, State, Effects}; +maybe_enqueue(RaftIdx, Ts, From, MsgSeqNo, RawMsg, + {MetaSize, BodySize} = MsgSize, + Effects0, #?STATE{msg_bytes_enqueue = BytesEnqueued, + enqueuers = Enqueuers0, + messages = Messages, + reclaimable_bytes = ReclaimableBytes0, + messages_total = Total} = State0) -> + Size = MetaSize + BodySize, + case maps:get(From, Enqueuers0, undefined) of + undefined -> + State1 = State0#?STATE{enqueuers = Enqueuers0#{From => #enqueuer{}}}, + {Res, State, Effects} = maybe_enqueue(RaftIdx, Ts, From, MsgSeqNo, + RawMsg, MsgSize, Effects0, + State1), + {Res, State, [{monitor, process, From} | Effects]}; + #enqueuer{next_seqno = MsgSeqNo} = Enq0 -> + % it is the next expected seqno + % TODO: it is not good to query the `mc' container inside the + % statemachine as it may be modified to behave differently without + % concern for the state machine + Header0 = maybe_set_msg_ttl(RawMsg, Ts, Size, State0), + Header = maybe_set_msg_delivery_count(RawMsg, Header0), + Msg = make_msg(RaftIdx, Header), + Enq = Enq0#enqueuer{next_seqno = MsgSeqNo + 1}, + MsgCache = case can_immediately_deliver(State0) of + true -> + {RaftIdx, RawMsg}; + false -> + undefined + end, + Priority = msg_priority(RawMsg), + State = State0#?STATE{msg_bytes_enqueue = BytesEnqueued + Size, + messages_total = Total + 1, + messages = rabbit_fifo_pq:in(Priority, Msg, Messages), + enqueuers = Enqueuers0#{From => Enq}, + msg_cache = MsgCache + }, + {ok, State, Effects0}; + #enqueuer{next_seqno = Next} + when MsgSeqNo > Next -> + %% TODO: when can this happen? + State = State0#?STATE{reclaimable_bytes = + ReclaimableBytes0 + Size + ?ENQ_OVERHEAD_B}, + {out_of_sequence, State, Effects0}; + #enqueuer{next_seqno = Next} when MsgSeqNo =< Next -> + % duplicate delivery + State = State0#?STATE{reclaimable_bytes = + ReclaimableBytes0 + Size + ?ENQ_OVERHEAD_B}, + {duplicate, State, Effects0} + end. + +return(Meta, ConsumerKey, Consumer0, + MsgIds, IncrDelCount, Anns, Effects0, State0) + when is_map(Anns) -> + Consumer = maybe_untimeout(Consumer0, MsgIds), + {State2, Effects1} = return_multiple(Meta, ConsumerKey, Consumer, + MsgIds, IncrDelCount, Anns, + Effects0, State0), + {State3, Effects2} = activate_next_consumer({State2, Effects1}), + checkout(Meta, State0, State3, Effects2). + +return_multiple(Meta, ConsumerKey, #consumer{checked_out = Checked} = Consumer, + MsgIds, IncrDelCount, Anns, Effects0, + #?STATE{consumers = Consumers} = State0) -> + State1 = State0#?STATE{consumers = Consumers#{ConsumerKey => Consumer}}, + {State, Effects} = + lists:foldl( + fun(MsgId, Acc = {S0, E0}) -> + case Checked of + #{MsgId := CMsg} -> + return_one(Meta, MsgId, CMsg, IncrDelCount, Anns, + S0, E0, ConsumerKey); + #{} -> + Acc + end + end, {State1, Effects0}, MsgIds), + {State, Effects}. + +% used to process messages that are finished +% complete(Meta, ConsumerKey, [MsgId], +% #consumer{checked_out = Checked0} = Con0, +% #?STATE{msg_bytes_checkout = BytesCheckout, +% reclaimable_bytes = DiscBytes, +% messages_total = Tot} = State0, +% Effects) -> +% case maps:take(MsgId, Checked0) of +% {?C_MSG(Msg), Checked} -> +% Hdr = get_msg_header(Msg), +% SettledSize = get_header(size, Hdr), +% Con = Con0#consumer{checked_out = Checked, +% credit = increase_credit(Con0, 1)}, +% State1 = update_or_remove_con(Meta, ConsumerKey, Con, State0), +% {State1#?STATE{msg_bytes_checkout = BytesCheckout - SettledSize, +% reclaimable_bytes = DiscBytes + SettledSize + ?ENQ_OVERHEAD, +% messages_total = Tot - 1}, +% Effects}; +% error -> +% {State0, Effects} +% end; +complete(Meta, ConsumerKey, MsgIds, + #consumer{checked_out = Checked0} = Con0, + #?STATE{msg_bytes_checkout = BytesCheckout, + reclaimable_bytes = ReclBytes, + messages_total = Tot} = State0, Effects) -> + {SettledSize, Checked} + = lists:foldl( + fun (MsgId, {S0, Ch0}) -> + case maps:take(MsgId, Ch0) of + {?C_MSG(Msg), Ch} -> + Hdr = get_msg_header(Msg), + S = get_header(size, Hdr) + S0, + {S, Ch}; + error -> + {S0, Ch0} + end + end, {0, Checked0}, MsgIds), + Len = map_size(Checked0) - map_size(Checked), + Con = Con0#consumer{checked_out = Checked, + credit = increase_credit(Con0, Len)}, + State1 = update_or_remove_con(Meta, ConsumerKey, Con, State0), + {State1#?STATE{msg_bytes_checkout = BytesCheckout - SettledSize, + reclaimable_bytes = ReclBytes + SettledSize + (Len * ?ENQ_OVERHEAD_B), + messages_total = Tot - Len}, + Effects}. + +increase_credit(#consumer{cfg = #consumer_cfg{lifetime = once}, + credit = Credit}, _) -> + %% once consumers cannot increment credit + Credit; +increase_credit(#consumer{cfg = #consumer_cfg{lifetime = auto, + credit_mode = credited}, + credit = Credit}, _) -> + %% credit_mode: `credited' also doesn't automatically increment credit + Credit; +increase_credit(#consumer{cfg = #consumer_cfg{lifetime = auto, + credit_mode = {credited, _}}, + credit = Credit}, _) -> + %% credit_mode: `credited' also doesn't automatically increment credit + Credit; +increase_credit(#consumer{cfg = #consumer_cfg{credit_mode = + {simple_prefetch, MaxCredit}}, + credit = Current}, Credit) + when MaxCredit > 0 -> + min(MaxCredit, Current + Credit); +increase_credit(#consumer{credit = Current}, Credit) -> + Current + Credit. + +maybe_untimeout(#consumer{status = {timeout, Status}, + timed_out_msg_ids = TimedOut0} = C, + MsgIds) -> + case TimedOut0 -- MsgIds of + [] -> + C#consumer{status = Status, + timed_out_msg_ids = []}; + TimedOut -> + C#consumer{timed_out_msg_ids = TimedOut} + end; +maybe_untimeout(#consumer{} = Consumer, _MsgIds) -> + Consumer. + +complete_and_checkout(#{} = Meta, MsgIds, ConsumerKey, + #consumer{} = Con0, + Effects0, State0) -> + Con1 = maybe_untimeout(Con0, MsgIds), + {State1, Effects1} = complete(Meta, ConsumerKey, MsgIds, + Con1, State0, Effects0), + %% a completion could have removed the active/quiescing consumer + Effects2 = add_active_effect(Con1, State1, Effects1), + {State2, Effects} = activate_next_consumer(State1, Effects2), + checkout(Meta, State0, State2, Effects). + +add_active_effect(#consumer{status = quiescing} = Consumer, + #?STATE{cfg = #cfg{consumer_strategy = single_active}, + consumers = Consumers} = State, + Effects) -> + case active_consumer(Consumers) of + undefined -> + consumer_update_active_effects(State, Consumer, false, waiting, Effects); + _ -> + Effects + end; +add_active_effect(_, _, Effects) -> + Effects. + +cancel_consumer_effects(ConsumerId, + #?STATE{cfg = #cfg{resource = QName}}, + Effects) when is_tuple(ConsumerId) -> + [{mod_call, rabbit_quorum_queue, + cancel_consumer_handler, [QName, ConsumerId]} | Effects]. + +update_msg_header(Key, Fun, Def, Msg) -> + ?MSG(get_msg_idx(Msg), + update_header(Key, Fun, Def, get_msg_header(Msg))). + +update_header(expiry, _, Expiry, Size) + when is_integer(Size) -> + ?TUPLE(Size, Expiry); +update_header(Key, UpdateFun, Default, Size) + when is_integer(Size) -> + update_header(Key, UpdateFun, Default, #{size => Size}); +update_header(Key, UpdateFun, Default, ?TUPLE(Size, Expiry)) + when is_integer(Size) andalso + is_integer(Expiry) -> + update_header(Key, UpdateFun, Default, #{size => Size, + expiry => Expiry}); +update_header(Key, UpdateFun, Default, Header) + when is_map_key(size, Header) -> + maps:update_with(Key, UpdateFun, Default, Header). + +get_msg_idx(?MSG(Idx, _Header)) -> + Idx; +get_msg_idx(Packed) when ?IS_PACKED(Packed) -> + ?PACKED_IDX(Packed). + +get_msg_header(?MSG(_Idx, Header)) -> + Header; +get_msg_header(Packed) when ?IS_PACKED(Packed) -> + ?PACKED_SZ(Packed). + +get_header(size, Size) + when is_integer(Size) -> + Size; +get_header(_Key, Size) + when is_integer(Size) -> + undefined; +get_header(size, ?TUPLE(Size, Expiry)) + when is_integer(Size) andalso + is_integer(Expiry) -> + Size; +get_header(expiry, ?TUPLE(Size, Expiry)) + when is_integer(Size) andalso + is_integer(Expiry) -> + Expiry; +get_header(_Key, ?TUPLE(Size, Expiry)) + when is_integer(Size) andalso + is_integer(Expiry) -> + undefined; +get_header(Key, Header) + when is_map(Header) andalso + is_map_key(size, Header) -> + maps:get(Key, Header, undefined). + +annotate_msg(Header, Msg0) -> + case mc:is(Msg0) of + true when is_map(Header) -> + Msg1 = maps:fold(fun (K, V, Acc) -> + mc:set_annotation(K, V, Acc) + end, Msg0, maps:get(anns, Header, #{})), + Msg = case Header of + #{acquired_count := AcqCount} -> + mc:set_annotation(acquired_count, AcqCount, Msg1); + _ -> + Msg1 + end, + case Header of + #{delivery_count := DelCount} -> + mc:set_annotation(delivery_count, DelCount, Msg); + _ -> + Msg + end; + _ -> + Msg0 + end. + +return_one(#{system_time := Ts} = Meta, MsgId, + ?C_MSG(Msg0), DeliveryFailed, Anns, + #?STATE{returns = Returns, + delayed = Delayed, + consumers = Consumers, + dlx = DlxState0, + reclaimable_bytes = ReclaimableBytes0, + last_command_time = LastTs, + cfg = #cfg{delivery_limit = DeliveryLimit, + delayed_retry = DelayedRetry, + dead_letter_handler = DLH}} = State0, + Effects0, ConsumerKey) -> + %% Use monotonic timestamp to prevent message reordering due to + %% non-monotonic Ra timestamps. + MonotonicTs = max(LastTs, Ts), + #consumer{checked_out = Checked0} = Con0 = maps:get(ConsumerKey, Consumers), + Msg = incr_msg_headers(Msg0, DeliveryFailed, Anns), + Header = get_msg_header(Msg), + + case get_header(delivery_count, Header) of + DeliveryCount + when is_integer(DeliveryCount) andalso + DeliveryCount > DeliveryLimit -> + {DlxState, RetainedBytes, DlxEffects} = + discard_or_dead_letter([Msg], delivery_limit, DLH, DlxState0), + %% subtract retained bytes as complete/6 will add them on irrespective + %% of dead letter strategy, alt, consider adding a new argument to + %% indicate if message ids were retained + State1 = State0#?STATE{dlx = DlxState, + reclaimable_bytes = + ReclaimableBytes0 - RetainedBytes}, + {State, Effects} = complete(Meta, ConsumerKey, [MsgId], + Con0, State1, Effects0), + {State, Effects ++ DlxEffects}; + _ -> + Checked = maps:remove(MsgId, Checked0), + Con = Con0#consumer{checked_out = Checked, + credit = increase_credit(Con0, 1)}, + State1 = case should_delay(DeliveryFailed, DelayedRetry, + MonotonicTs, Header, Anns) of + {true, ReadyAt, DeferralToken} -> + Idx = get_msg_idx(Msg), + State0#?STATE{delayed = delayed_in(ReadyAt, Idx, + Msg, DeferralToken, + Delayed)}; + false -> + State0#?STATE{returns = lqueue:in(Msg, Returns)} + end, + State = update_or_remove_con(Meta, ConsumerKey, Con, State1), + {add_bytes_return(Header, State), Effects0} + end. + +should_delay(DeliveryFailed, DelayedRetry, Ts, Header, Anns) -> + %% First check for explicit x-opt-delivery-time annotation. + %% This takes precedence over delayed_retry configuration. + DeferralToken = case Anns of + #{<<"x-opt-deferral-token">> := Token} + when is_binary(Token) -> + Token; + _ -> + undefined + end, + case Anns of + #{<<"x-opt-delivery-time">> := DeliveryTime} + when is_integer(DeliveryTime), + DeliveryTime > Ts -> + {true, DeliveryTime, DeferralToken}; + _ -> + case should_delay0(DeliveryFailed, DelayedRetry, Ts, Header) of + {true, ReadyAt} -> + {true, ReadyAt, DeferralToken}; + false -> + false + end + end. + +should_delay0(_DeliveryFailed, disabled, _Ts, _Header) -> + false; +should_delay0(_DeliveryFailed, {all, Min, Max}, Ts, Header) -> + {true, calculate_ready_at(Ts, Min, Max, Header)}; +should_delay0(true, {failed, Min, Max}, Ts, Header) -> + {true, calculate_ready_at(Ts, Min, Max, Header)}; +should_delay0(false, {failed, _, _}, _Ts, _Header) -> + false; +should_delay0(false, {returned, Min, Max}, Ts, Header) -> + {true, calculate_ready_at(Ts, Min, Max, Header)}; +should_delay0(true, {returned, _, _}, _Ts, _Header) -> + false. + +calculate_ready_at(Ts, Min, Max, Header) -> + DeliveryCount = case get_header(delivery_count, Header) of + undefined -> 1; + DC -> max(1, DC) + end, + Delay = min(Min * DeliveryCount, Max), + Ts + Delay. + +return_all(Meta, #?STATE{consumers = Cons} = State0, Effects0, ConsumerKey, + #consumer{checked_out = Checked} = Con, DeliveryFailed) -> + State = State0#?STATE{consumers = Cons#{ConsumerKey => Con}}, + maps:fold(fun (MsgId, CMsg, {S, E}) -> + return_one(Meta, MsgId, CMsg, DeliveryFailed, #{}, + S, E, ConsumerKey) + end, {State, Effects0}, maps:iterator(Checked, ordered)). + +checkout(Meta, OldState, State, Effects) -> + checkout(Meta, OldState, State, Effects, ok). + +checkout(#{index := Index} = Meta, + #?STATE{} = OldState, + State0, Effects0, Reply) -> + {#?STATE{cfg = #cfg{dead_letter_handler = DLH}, + dlx = DlxState0} = State1, _ExpiredMsg, Effects1} = + checkout0(Meta, checkout_one(Meta, false, State0, Effects0), #{}), + {DlxState, DlxDeliveryEffects} = dlx_checkout(DLH, DlxState0), + %% TODO: only update dlx state if it has changed? + %% by this time the cache should be used + State2 = State1#?STATE{msg_cache = undefined, + dlx = DlxState}, + Effects2 = DlxDeliveryEffects ++ Effects1, + {State, Effects} = evaluate_limit(Index, OldState, State2, Effects2), + {State, Reply, Effects}. + +checkout0(Meta, {success, ConsumerKey, MsgId, + Msg, ExpiredMsg, State, Effects}, + SendAcc0) -> + DelMsg = {MsgId, Msg}, + SendAcc = case SendAcc0 of + #{ConsumerKey := LogMsgs} -> + SendAcc0#{ConsumerKey := [DelMsg | LogMsgs]}; + #{} -> + SendAcc0#{ConsumerKey => [DelMsg]} + end, + checkout0(Meta, checkout_one(Meta, ExpiredMsg, State, Effects), SendAcc); +checkout0(_Meta, {_Activity, ExpiredMsg, State, Effects}, SendAcc) -> + {State, ExpiredMsg, Effects ++ delivery_effects(SendAcc, State)}. + +evaluate_limit(Idx, State1, State2, OuterEffects) -> + case evaluate_limit0(Idx, State1, State2, []) of + {State, []} -> + {State, OuterEffects}; + {State, Effects} -> + {State, OuterEffects ++ lists:reverse(Effects)} + end. + +evaluate_limit0(_Index, + #?STATE{cfg = #cfg{max_length = undefined, + max_bytes = undefined}}, + #?STATE{cfg = #cfg{max_length = undefined, + max_bytes = undefined}} = State, + Effects) -> + {State, Effects}; +evaluate_limit0(_Index, _BeforeState, + #?STATE{cfg = #cfg{max_length = undefined, + max_bytes = undefined}, + enqueuers = Enqs0} = State0, + Effects0) -> + %% max_length and/or max_bytes policies have just been deleted + {Enqs, Effects} = unblock_enqueuers(Enqs0, Effects0), + {State0#?STATE{enqueuers = Enqs}, Effects}; +evaluate_limit0(Index, BeforeState, + #?STATE{cfg = #cfg{overflow_strategy = Strategy}, + enqueuers = Enqs0} = State0, + Effects0) -> + case is_over_limit(State0) of + true when Strategy == drop_head -> + {State, Effects} = drop_head(State0, Effects0), + evaluate_limit0(Index, BeforeState, State, Effects); + true when Strategy == reject_publish -> + %% generate send_msg effect for each enqueuer to let them know + %% they need to block + {Enqs, Effects} = + maps:fold( + fun (P, #enqueuer{blocked = undefined} = E0, {Enqs, Acc}) -> + E = E0#enqueuer{blocked = Index}, + {Enqs#{P => E}, + [{send_msg, P, {queue_status, reject_publish}, + [ra_event]} | Acc]}; + (_P, _E, Acc) -> + Acc + end, {Enqs0, Effects0}, Enqs0), + {State0#?STATE{enqueuers = Enqs}, Effects}; + false when Strategy == reject_publish -> + %% TODO: optimise as this case gets called for every command + %% pretty much + Before = is_below_soft_limit(BeforeState), + case {Before, is_below_soft_limit(State0)} of + {false, true} -> + %% we have moved below the lower limit + {Enqs, Effects} = unblock_enqueuers(Enqs0, Effects0), + {State0#?STATE{enqueuers = Enqs}, Effects}; + _ -> + {State0, Effects0} + end; + false -> + {State0, Effects0} + end. + +unblock_enqueuers(Enqs0, Effects0) -> + maps:fold( + fun (P, #enqueuer{} = E0, {Enqs, Acc}) -> + E = E0#enqueuer{blocked = undefined}, + {Enqs#{P => E}, + [{send_msg, P, {queue_status, go}, [ra_event]} + | Acc]}; + (_P, _E, Acc) -> + Acc + end, {Enqs0, Effects0}, Enqs0). + +%% [6,5,4,3,2,1] -> [[1,2],[3,4],[5,6]] +chunk_disk_msgs([], _Bytes, [[] | Chunks]) -> + Chunks; +chunk_disk_msgs([], _Bytes, Chunks) -> + Chunks; +chunk_disk_msgs([{_MsgId, Msg} = ConsumerMsg | Rem], + Bytes, Chunks) + when Bytes >= ?DELIVERY_CHUNK_LIMIT_B -> + Size = get_header(size, get_msg_header(Msg)), + chunk_disk_msgs(Rem, Size, [[ConsumerMsg] | Chunks]); +chunk_disk_msgs([{_MsgId, Msg} = ConsumerMsg | Rem], Bytes, + [CurChunk | Chunks]) -> + Size = get_header(size, get_msg_header(Msg)), + chunk_disk_msgs(Rem, Bytes + Size, [[ConsumerMsg | CurChunk] | Chunks]). + +delivery_effects(AccMap, _State) + when map_size(AccMap) =:= 0 -> + %% does this ever happen? + []; +delivery_effects(AccMap, State) -> + Effs = maps:fold(fun(C, DiskMsgs, Efs) + when is_list(DiskMsgs) -> + lists:foldl( + fun (Msgs, E) -> + [delivery_effect(C, Msgs, State) | E] + end, Efs, chunk_disk_msgs(DiskMsgs, 0, [[]])) + end, [], AccMap), + lists:reverse(Effs). + +take_next_msg(#?STATE{returns = Returns0, + messages = Messages0} = State) -> + case lqueue:out(Returns0) of + {{value, NextMsg}, Returns} -> + {NextMsg, State#?STATE{returns = Returns}}; + {empty, _} -> + case rabbit_fifo_pq:out(Messages0) of + empty -> + empty; + {Msg, Messages} -> + {Msg, State#?STATE{messages = Messages}} + end + end. + +take_next_delayed(_Ts, #delayed{next = undefined}) -> + empty; +take_next_delayed(Ts, #delayed{next = {ReadyAt, Idx, Msg}, + tree = Tree0, + deferred = Deferred0}) when Ts >= ReadyAt -> + Key = ?TUPLE(ReadyAt, Idx), + Tree = gb_trees:delete(Key, Tree0), + Next = case gb_trees:is_empty(Tree) of + true -> + undefined; + false -> + {?TUPLE(NextReadyAt, NextIdx), V} = gb_trees:smallest(Tree), + {NextReadyAt, NextIdx, V} + end, + %% Remove any deferral token that maps to this key + Deferred = maps:filter(fun(_Token, K) -> K =/= Key end, Deferred0), + Delayed = #delayed{tree = Tree, next = Next, deferred = Deferred}, + {Msg, Delayed}; +take_next_delayed(_Ts, #delayed{}) -> + empty. + +%% Take all ready delayed messages (for promoting to returns queue) +take_ready_delayed(Ts, Delayed) -> + take_ready_delayed(Ts, Delayed, []). + +take_ready_delayed(Ts, Delayed0, Acc) -> + case take_next_delayed(Ts, Delayed0) of + {Msg, Delayed} -> + take_ready_delayed(Ts, Delayed, [Msg | Acc]); + empty -> + {lists:reverse(Acc), Delayed0} + end. + +take_delayed_for_retry(all, _Ts, #delayed{tree = Tree}) -> + Msgs = gb_trees:values(Tree), + {Msgs, #delayed{tree = gb_trees:empty(), next = undefined}}; +take_delayed_for_retry(0, _Ts, Delayed) -> + {[], Delayed}; +take_delayed_for_retry(N, Ts, Delayed) when is_integer(N), N > 0 -> + take_delayed_for_retry(N, Ts, Delayed, []). + +take_delayed_for_retry(0, _Ts, Delayed, Acc) -> + {lists:reverse(Acc), Delayed}; +take_delayed_for_retry(N, Ts, #delayed{tree = Tree0, + deferred = Deferred0} = Delayed0, Acc) -> + case gb_trees:is_empty(Tree0) of + true -> + {lists:reverse(Acc), Delayed0}; + false -> + {Key, Msg, Tree} = gb_trees:take_smallest(Tree0), + Next = case gb_trees:is_empty(Tree) of + true -> + undefined; + false -> + {NextKey, NextMsg} = gb_trees:smallest(Tree), + ?TUPLE(ReadyAt, Idx) = NextKey, + {ReadyAt, Idx, NextMsg} + end, + %% Remove any deferral token that maps to this key + Deferred = maps:filter(fun(_Token, K) -> K =/= Key end, Deferred0), + Delayed = #delayed{tree = Tree, next = Next, deferred = Deferred}, + take_delayed_for_retry(N - 1, Ts, Delayed, [Msg | Acc]) + end. + +take_deferred(Tokens, Delayed) -> + take_deferred(Tokens, Delayed, [], []). + +take_deferred([], Delayed, MsgsAcc, NotFoundAcc) -> + {lists:reverse(MsgsAcc), lists:reverse(NotFoundAcc), Delayed}; +take_deferred([Token | Rest], #delayed{tree = Tree0, + deferred = Deferred0} = Delayed0, + MsgsAcc, NotFoundAcc) -> + case maps:take(Token, Deferred0) of + {Key, Deferred1} -> + case gb_trees:lookup(Key, Tree0) of + {value, Msg} -> + Tree = gb_trees:delete(Key, Tree0), + Next = update_delayed_next(Tree), + Delayed = Delayed0#delayed{tree = Tree, + next = Next, + deferred = Deferred1}, + take_deferred(Rest, Delayed, [Msg | MsgsAcc], NotFoundAcc); + none -> + %% Key in deferred map but not in tree - inconsistent, + %% treat as not found and clean up + Delayed = Delayed0#delayed{deferred = Deferred1}, + take_deferred(Rest, Delayed, MsgsAcc, [Token | NotFoundAcc]) + end; + error -> + take_deferred(Rest, Delayed0, MsgsAcc, [Token | NotFoundAcc]) + end. + +update_delayed_next(Tree) -> + case gb_trees:is_empty(Tree) of + true -> + undefined; + false -> + {Key, Msg} = gb_trees:smallest(Tree), + ?TUPLE(ReadyAt, Idx) = Key, + {ReadyAt, Idx, Msg} + end. + +assign_to_consumer(_Meta, _Ts, _ConsumerKey, [], State, Effects) -> + {State, Effects}; +assign_to_consumer(#{system_time := Ts} = Meta, _Ts, ConsumerKey, Msgs, + #?STATE{consumers = Consumers0, + msg_bytes_checkout = BytesCheckout0, + msg_bytes_enqueue = BytesEnqueue0, + next_consumer_timeout = NextConTimeout0} = State0, + Effects0) -> + #consumer{checked_out = Checked0, + next_msg_id = NextMsgId0, + credit = Credit0, + delivery_count = DelCnt0, + cfg = Cfg} = Con0 = maps:get(ConsumerKey, Consumers0), + {Checked, NextMsgId, Credit, DelCnt, BytesCheckout, BytesEnqueue, + NextConTimeout, DeliveryMsgs} = + lists:foldl( + fun(Msg, {CheckedAcc, MsgIdAcc, CreditAcc, DelCntAcc, + BytesCheckoutAcc, BytesEnqueueAcc, TimeoutAcc, + DelMsgsAcc}) -> + Timeout = Ts + Cfg#consumer_cfg.timeout, + CMsg = ?C_MSG(Timeout, Msg), + CheckedAcc1 = maps:put(MsgIdAcc, CMsg, CheckedAcc), + Size = get_header(size, get_msg_header(Msg)), + {CheckedAcc1, MsgIdAcc + 1, CreditAcc - 1, + add(DelCntAcc, 1), BytesCheckoutAcc + Size, + BytesEnqueueAcc - Size, min(Timeout, TimeoutAcc), + [{MsgIdAcc, Msg} | DelMsgsAcc]} + end, + {Checked0, NextMsgId0, Credit0, DelCnt0, + BytesCheckout0, BytesEnqueue0, NextConTimeout0, []}, + Msgs), + Con = Con0#consumer{checked_out = Checked, + next_msg_id = NextMsgId, + credit = Credit, + delivery_count = DelCnt}, + State1 = State0#?STATE{msg_bytes_checkout = BytesCheckout, + msg_bytes_enqueue = BytesEnqueue, + next_consumer_timeout = NextConTimeout}, + State = update_or_remove_con(Meta, ConsumerKey, Con, State1), + DelMsgs = lists:reverse(DeliveryMsgs), + DeliveryEffect = delivery_effect(ConsumerKey, DelMsgs, State), + Effects = [DeliveryEffect | Effects0], + {State, Effects}. + +delayed_in(ReadyAt, Idx, Msg, DeferralToken, #delayed{tree = Tree0, + next = Next0, + deferred = Deferred0}) -> + Key = ?TUPLE(ReadyAt, Idx), + Tree = gb_trees:insert(Key, Msg, Tree0), + Next = case Next0 of + undefined -> + {ReadyAt, Idx, Msg}; + {NextTs, _, _} when ReadyAt < NextTs -> + {ReadyAt, Idx, Msg}; + _ -> + Next0 + end, + Deferred = case DeferralToken of + undefined -> + Deferred0; + _ -> + Deferred0#{DeferralToken => Key} + end, + #delayed{tree = Tree, next = Next, deferred = Deferred}. + +peek_next_msg(#?STATE{returns = Returns0, + messages = Messages0}) -> + case lqueue:get(Returns0, empty) of + empty -> + rabbit_fifo_pq:get(Messages0); + Msg -> + Msg + end. + +delivery_effect(ConsumerKey, [{MsgId, ?MSG(Idx, Header)}], + #?STATE{msg_cache = {Idx, RawMsg}} = State) -> + {CTag, CPid} = consumer_id(ConsumerKey, State), + {send_msg, CPid, {delivery, CTag, [{MsgId, {Header, RawMsg}}]}, + ?DELIVERY_SEND_MSG_OPTS}; +delivery_effect(ConsumerKey, [{MsgId, Msg}], + #?STATE{msg_cache = {Idx, RawMsg}} = State) + when is_integer(Msg) andalso ?PACKED_IDX(Msg) == Idx -> + Header = get_msg_header(Msg), + {CTag, CPid} = consumer_id(ConsumerKey, State), + {send_msg, CPid, {delivery, CTag, [{MsgId, {Header, RawMsg}}]}, + ?DELIVERY_SEND_MSG_OPTS}; +delivery_effect(ConsumerKey, Msgs, #?STATE{} = State) -> + {CTag, CPid} = consumer_id(ConsumerKey, State), + {RaftIdxs, _Num} = lists:foldr(fun ({_, Msg}, {Acc, N}) -> + + {[get_msg_idx(Msg) | Acc], N+1} + end, {[], 0}, Msgs), + {log_ext, RaftIdxs, + fun (ReadPlan) -> + case node(CPid) == node() of + true -> + [{send_msg, CPid, {delivery, CTag, ReadPlan, Msgs}, + ?DELIVERY_SEND_MSG_OPTS}]; + false -> + %% if we got there we need to read the data on this node + %% and send it to the consumer pid as it isn't availble + %% locally + {DelMsgs, Flru} = exec_read(undefined, ReadPlan, Msgs), + %% we need to evict all cached items here + _ = ra_flru:evict_all(Flru), + [{send_msg, CPid, {delivery, CTag, DelMsgs}, + ?DELIVERY_SEND_MSG_OPTS}] + end + end, + {local, node(CPid)}}. + +reply_log_effect(RaftIdx, MsgId, Header, Ready, From) -> + {log, [RaftIdx], + fun ([]) -> + []; + ([Cmd]) -> + [{reply, From, + {wrap_reply, + {dequeue, {MsgId, {Header, get_msg_from_cmd(Cmd)}}, Ready}}}] + end}. + +checkout_one(#{system_time := Ts} = Meta, ExpiredMsg0, InitState0, Effects0) -> + %% Before checking out any message to any consumer, + %% first remove all expired messages from the head of the queue. + {ExpiredMsg, #?STATE{service_queue = SQ0, + messages = Messages0, + msg_bytes_checkout = BytesCheckout, + msg_bytes_enqueue = BytesEnqueue, + next_consumer_timeout = NextConTimeout, + consumers = Cons0} = InitState, Effects1} = + expire_msgs(Ts, ExpiredMsg0, InitState0, Effects0), + + case priority_queue:out(SQ0) of + {{value, ConsumerKey}, SQ1} + when is_map_key(ConsumerKey, Cons0) -> + case take_next_msg(InitState) of + {Msg, State0} -> + %% there are consumers waiting to be serviced + %% process consumer checkout + case maps:get(ConsumerKey, Cons0) of + #consumer{credit = Credit, + status = Status} + when Credit =:= 0 orelse + Status =/= up -> + %% not an active consumer but still in the consumers + %% map - this can happen when draining + %% or when higher priority single active consumers + %% take over, recurse without consumer in service + %% queue + checkout_one(Meta, ExpiredMsg, + InitState#?STATE{service_queue = SQ1}, + Effects1); + #consumer{checked_out = Checked0, + next_msg_id = Next, + credit = Credit, + delivery_count = DelCnt0, + cfg = Cfg} = Con0 -> + Timeout = Ts + Cfg#consumer_cfg.timeout, + Checked = maps:put(Next, ?C_MSG(Timeout, Msg), + Checked0), + DelCnt = add(DelCnt0, 1), + Con = Con0#consumer{checked_out = Checked, + next_msg_id = Next + 1, + credit = Credit - 1, + delivery_count = DelCnt}, + Size = get_header(size, get_msg_header(Msg)), + State1 = + State0#?STATE{service_queue = SQ1, + msg_bytes_checkout = + BytesCheckout + Size, + msg_bytes_enqueue = + BytesEnqueue - Size, + next_consumer_timeout = + min(Timeout, NextConTimeout)}, + State = update_or_remove_con(Meta, ConsumerKey, + Con, State1), + {success, ConsumerKey, Next, Msg, ExpiredMsg, + State, Effects1} + end; + empty -> + {nochange, ExpiredMsg, InitState, Effects1} + end; + {{value, _ConsumerId}, SQ1} -> + %% consumer was not active but was queued, recurse + checkout_one(Meta, ExpiredMsg, + InitState#?STATE{service_queue = SQ1}, Effects1); + {empty, _} -> + case rabbit_fifo_pq:len(Messages0) of + 0 -> + {nochange, ExpiredMsg, InitState, Effects1}; + _ -> + {inactive, ExpiredMsg, InitState, Effects1} + end + end. + +%% TODO: consider adding a bit of "margin" to potentialy catch multiple +%% timedout messages in one event +maybe_add_consumer_timeout_effect(Timeout, infinity, Effects) + when is_list(Effects) -> + [{timer, evaluate_consumer_timeout, Timeout, {abs, true}} | Effects]; +maybe_add_consumer_timeout_effect(Timeout, PrevTimeout, Effects) -> + case Timeout < PrevTimeout of + true -> + [{timer, evaluate_consumer_timeout, Timeout, {abs, true}} + | Effects]; + false -> + Effects + end. + +msg_is_expired(Ts, ?MSG(_, _) = Msg) -> + Header = get_msg_header(Msg), + case get_header(expiry, Header) of + undefined -> + false; + Expiry -> + Ts >= Expiry + end; +msg_is_expired(_Ts, _) -> + false. + +%% dequeue all expired messages +expire_msgs(RaCmdTs, Result, State, Effects) -> + %% In the normal case, there are no expired messages. + %% Therefore, first lqueue:get/2 to check whether we need to lqueue:out/1 + %% because the latter can be much slower than the former. + case msg_is_expired(RaCmdTs, peek_next_msg(State)) of + true -> + expire_batch(RaCmdTs, State, Effects); + false -> + {Result, State, Effects} + end. + +expire_shallow(Ts, #?STATE{returns = Returns0, + delayed = Delayed0} = State0) -> + %% Promote ready delayed messages to returns queue + {ReadyMsgs, Delayed} = take_ready_delayed(Ts, Delayed0), + Returns = lists:foldl(fun (Msg, Acc) -> lqueue:in(Msg, Acc) end, + Returns0, ReadyMsgs), + State = State0#?STATE{returns = Returns, delayed = Delayed}, + {_, State1, DlxEffects} = expire_batch(Ts, State, []), + {State1, DlxEffects}. + +%% Batch-collect all expired messages from returns and messages queues, +%% then process them in a single discard_or_dead_letter call. +expire_batch(Ts, #?STATE{cfg = #cfg{dead_letter_handler = DLH}, + returns = Returns0, + messages = Messages0, + dlx = DlxState0, + reclaimable_bytes = ReclaimableBytes, + messages_total = Tot, + msg_bytes_enqueue = MsgBytesEnqueue} = State0, + Effects) -> + {ExpiredReturns, Returns} = take_expired_returns(Ts, Returns0), + + {ExpiredMsgs, Messages} = rabbit_fifo_pq:take_while( + fun (Msg) -> msg_is_expired(Ts, Msg) end, + Messages0), + + ExpMsgs = ExpiredReturns ++ ExpiredMsgs, + + {DlxState, RetainedBytes, DlxEffects} = + discard_or_dead_letter(ExpMsgs, expired, DLH, DlxState0), + + {NumExpired, Size} = count_and_size(ExpMsgs), + + DiscardedSize = Size + (NumExpired * ?ENQ_OVERHEAD_B) - RetainedBytes, + State = State0#?STATE{dlx = DlxState, + returns = Returns, + messages = Messages, + messages_total = Tot - NumExpired, + reclaimable_bytes = ReclaimableBytes + DiscardedSize, + msg_bytes_enqueue = MsgBytesEnqueue - Size}, + {true, State, Effects ++ DlxEffects}. + +take_expired_returns(Ts, Returns) -> + take_expired_returns(Ts, Returns, []). + +take_expired_returns(Ts, Returns0, Acc) -> + case lqueue:peek(Returns0) of + {value, Msg} -> + case msg_is_expired(Ts, Msg) of + true -> + take_expired_returns(Ts, lqueue:drop(Returns0), + [Msg | Acc]); + false -> + {lists:reverse(Acc), Returns0} + end; + empty -> + {lists:reverse(Acc), Returns0} + end. + +count_and_size(Msgs) -> + lists:foldl(fun (Msg, {N, Acc}) -> + Header = get_msg_header(Msg), + {N + 1, Acc + get_header(size, Header)} + end, {0, 0}, Msgs). + +timer_effect(#?STATE{messages_total = 0, + delayed = #delayed{next = undefined}}, Effects) -> + Effects; +timer_effect(#?STATE{messages_total = 0, + delayed = #delayed{next = {NextDelayedTs, _, _}}}, + Effects) -> + [{timer, expire_msgs, NextDelayedTs, {abs, true}} | Effects]; +timer_effect(#?STATE{returns = Returns, + messages = Messages, + delayed = Delayed}, Effects) -> + %% TODO: most queues don't use message ttls, to avoid doing this frequently + %% when not required we could keep a flag in the machine state to indicate + %% if a ttl has ever been seen in the queue and avoid this code path based + %% on the value of that flag. + ReturnedExpiry = case lqueue:peek(Returns) of + empty -> + undefined; + {value, Returned} -> + get_header(expiry, get_msg_header(Returned)) + end, + + %% this checks the next messages of all priorities and returns the smallest + %% expiry time or undefined + NextExpiry = rabbit_fifo_pq:fold_priorities_next( + fun (Msg, Acc) -> + Header = get_msg_header(Msg), + case get_header(expiry, Header) of + undefined -> + Acc; + Expiry when Acc == undefined -> + max(0, Expiry); + Expiry -> + CalcExpiry = max(0, Expiry), + case CalcExpiry < Acc of + true -> + CalcExpiry; + false -> + Acc + end + end + end, ReturnedExpiry, Messages), + + %% Also consider the next delayed message timestamp + NextDelayedTs = case Delayed of + #delayed{next = undefined} -> + undefined; + #delayed{next = {Ts, _, _}} -> + Ts + end, + + NextTimeout = case {NextExpiry, NextDelayedTs} of + {undefined, undefined} -> + undefined; + {undefined, D} -> + D; + {E, undefined} -> + E; + {E, D} -> + min(E, D) + end, + + case NextTimeout of + undefined -> + Effects; + Timeout -> + [{timer, expire_msgs, Timeout, {abs, true}} | Effects] + end. + +update_or_remove_con(Meta, ConsumerKey, + #consumer{cfg = #consumer_cfg{lifetime = once}, + checked_out = Checked, + credit = 0} = Con, + #?STATE{consumers = Cons} = State) -> + case map_size(Checked) of + 0 -> + #{system_time := Ts} = Meta, + % we're done with this consumer + State#?STATE{consumers = maps:remove(ConsumerKey, Cons), + last_active = Ts}; + _ -> + % there are unsettled items so need to keep around + State#?STATE{consumers = maps:put(ConsumerKey, Con, Cons)} + end; +update_or_remove_con(_Meta, ConsumerKey, + #consumer{status = quiescing, + checked_out = Checked} = Con0, + #?STATE{consumers = Cons, + waiting_consumers = Waiting} = State) + when map_size(Checked) == 0 -> + Con = Con0#consumer{status = up}, + State#?STATE{consumers = maps:remove(ConsumerKey, Cons), + waiting_consumers = add_waiting({ConsumerKey, Con}, Waiting)}; +update_or_remove_con(_Meta, ConsumerKey, + #consumer{} = Con, + #?STATE{consumers = Cons, + service_queue = ServiceQueue} = State) -> + State#?STATE{consumers = maps:put(ConsumerKey, Con, Cons), + service_queue = maybe_queue_consumer(ConsumerKey, Con, + ServiceQueue)}. + +maybe_queue_consumer(Key, #consumer{credit = Credit, + status = up, + cfg = #consumer_cfg{priority = P}}, + ServiceQueue) + when Credit > 0 -> + % TODO: queue:member could surely be quite expensive, however the practical + % number of unique consumers may not be large enough for it to matter + case priority_queue:member(Key, ServiceQueue) of + true -> + ServiceQueue; + false -> + priority_queue:in(Key, P, ServiceQueue) + end; +maybe_queue_consumer(_Key, _Consumer, ServiceQueue) -> + ServiceQueue. + +update_consumer(Meta, ConsumerKey, {Tag, Pid}, ConsumerMeta, + {Life, Mode} = Spec, Priority, Timeout, + #?STATE{cfg = #cfg{consumer_strategy = competing}, + consumers = Cons0} = State0) -> + Consumer = case Cons0 of + #{ConsumerKey := #consumer{} = Consumer0} -> + merge_consumer(Meta, Consumer0, ConsumerMeta, + Spec, Priority); + _ -> + Credit = included_credit(Mode), + DeliveryCount = initial_delivery_count(Mode), + #consumer{cfg = #consumer_cfg{tag = Tag, + pid = Pid, + lifetime = Life, + meta = ConsumerMeta, + priority = Priority, + credit_mode = Mode, + timeout = Timeout}, + credit = Credit, + delivery_count = DeliveryCount} + end, + {Consumer, update_or_remove_con(Meta, ConsumerKey, Consumer, State0)}; +update_consumer(Meta, ConsumerKey, {Tag, Pid}, ConsumerMeta, + {Life, Mode} = Spec, Priority, Timeout, + #?STATE{cfg = #cfg{consumer_strategy = single_active}, + consumers = Cons0, + waiting_consumers = Waiting0, + service_queue = _ServiceQueue0} = State) -> + %% if it is the current active consumer, just update + %% if it is a cancelled active consumer, add to waiting unless it is the only + %% one, then merge + case active_consumer(Cons0) of + {ConsumerKey, #consumer{status = up} = Consumer0} -> + Consumer = merge_consumer(Meta, Consumer0, ConsumerMeta, + Spec, Priority), + {Consumer, update_or_remove_con(Meta, ConsumerKey, Consumer, State)}; + undefined when is_map_key(ConsumerKey, Cons0) -> + %% there is no active consumer and the current consumer is in the + %% consumers map and thus must be cancelled, in this case we can just + %% merge and effectively make this the current active one + Consumer0 = maps:get(ConsumerKey, Cons0), + Consumer = merge_consumer(Meta, Consumer0, ConsumerMeta, + Spec, Priority), + {Consumer, update_or_remove_con(Meta, ConsumerKey, Consumer, State)}; + _ -> + %% add as a new waiting consumer + Credit = included_credit(Mode), + DeliveryCount = initial_delivery_count(Mode), + Consumer = #consumer{cfg = #consumer_cfg{tag = Tag, + pid = Pid, + lifetime = Life, + meta = ConsumerMeta, + priority = Priority, + credit_mode = Mode, + timeout = Timeout}, + credit = Credit, + delivery_count = DeliveryCount}, + Waiting = add_waiting({ConsumerKey, Consumer}, Waiting0), + {Consumer, State#?STATE{waiting_consumers = Waiting}} + end. + +add_waiting({Key, _} = New, Waiting) -> + sort_waiting(lists:keystore(Key, 1, Waiting, New)). + +sort_waiting(Waiting) -> + lists:sort(fun + ({_, ?CONSUMER_PRIORITY(P1) = #consumer{status = up}}, + {_, ?CONSUMER_PRIORITY(P2) = #consumer{status = up}}) + when P1 =/= P2 -> + P2 =< P1; + ({C1, #consumer{status = up, + credit = Cr1}}, + {C2, #consumer{status = up, + credit = Cr2}}) -> + %% both are up, priority the same + if Cr1 == Cr2 -> + %% same credit + %% sort by key, first attached priority + C1 =< C2; + true -> + %% else sort by credit + Cr2 =< Cr1 + end; + (_, {_, #consumer{status = Status}}) -> + %% not up + Status /= up + end, Waiting). + +merge_consumer(_Meta, #consumer{cfg = CCfg, checked_out = Checked} = Consumer, + ConsumerMeta, {Life, Mode}, Priority) -> + Credit = included_credit(Mode), + NumChecked = map_size(Checked), + NewCredit = max(0, Credit - NumChecked), + Consumer#consumer{cfg = CCfg#consumer_cfg{priority = Priority, + meta = ConsumerMeta, + credit_mode = Mode, + lifetime = Life}, + status = up, + credit = NewCredit}. + +included_credit({simple_prefetch, Credit}) -> + Credit; +included_credit({credited, _}) -> + 0; +included_credit(credited) -> + 0. + +credit_active_consumer(Meta, + #credit{credit = LinkCreditRcv, + delivery_count = DeliveryCountRcv, + drain = Drain, + consumer_key = ConsumerKey}, + #consumer{delivery_count = DeliveryCountSnd} = Con0, + #?STATE{consumers = Cons0, + service_queue = ServiceQueue0} = State0) -> + LinkCreditSnd = link_credit_snd(DeliveryCountRcv, LinkCreditRcv, + DeliveryCountSnd), + %% grant the credit + Con1 = Con0#consumer{drain = Drain, + credit = LinkCreditSnd}, + ServiceQueue = maybe_queue_consumer(ConsumerKey, Con1, ServiceQueue0), + State1 = State0#?STATE{service_queue = ServiceQueue, + consumers = maps:update(ConsumerKey, Con1, Cons0)}, + {State2, ok, Effects} = checkout(Meta, State0, State1, []), + + #?STATE{consumers = Cons1 = #{ConsumerKey := Con2}} = State2, + #consumer{cfg = #consumer_cfg{meta = CMeta, + pid = CPid, + tag = CTag}, + credit = PostCred, + delivery_count = PostDeliveryCount} = Con2, + Avail = messages_ready(State2), + {Credit, DeliveryCount, State} = + case Drain andalso PostCred > 0 of + true -> + AdvancedDeliveryCount = add(PostDeliveryCount, PostCred), + ZeroCredit = 0, + Con = Con2#consumer{delivery_count = AdvancedDeliveryCount, + credit = ZeroCredit}, + Cons = maps:update(ConsumerKey, Con, Cons1), + State3 = State2#?STATE{consumers = Cons}, + {ZeroCredit, AdvancedDeliveryCount, State3}; + false -> + {PostCred, PostDeliveryCount, State2} + end, + Reply = case CMeta of + #{link_state_properties := true} -> + #credit_reply{ctag = CTag, + delivery_count = DeliveryCount, + credit = Credit, + available = Avail, + drain = Drain, + properties = #{active => true}}; + _ -> + {credit_reply, CTag, DeliveryCount, Credit, Avail, Drain} + end, + %% We must send the delivery effects to the queue client + %% before credit_reply such that session process can send to + %% AMQP 1.0 client TRANSFERs before FLOW. + {State, ok, Effects ++ [{send_msg, CPid, Reply, ?DELIVERY_SEND_MSG_OPTS}]}. + +credit_inactive_consumer(#credit{credit = LinkCreditRcv, + delivery_count = DeliveryCountRcv, + drain = Drain, + consumer_key = ConsumerKey}, + #consumer{cfg = #consumer_cfg{meta = CMeta, + pid = CPid, + tag = CTag}, + delivery_count = DeliveryCountSnd} = Con0, + Waiting0, State0) -> + LinkCreditSnd = link_credit_snd(DeliveryCountRcv, + LinkCreditRcv, + DeliveryCountSnd), + {Credit, DeliveryCount} = + case Drain of + true -> + %% By issuing drain=true, the client says "either send a + %% transfer or a flow frame". Since there are no messages to + %% send to an inactive consumer, we advance the delivery-count + %% consuming all link-credit and send a credit_reply with + %% drain=true to the session which causes the session to + %% send a flow frame to the client. + AdvancedDeliveryCount = add(DeliveryCountSnd, LinkCreditSnd), + {0, AdvancedDeliveryCount}; + false -> + {LinkCreditSnd, DeliveryCountSnd} + end, + %% Grant the credit. + Con = Con0#consumer{credit = Credit, + drain = Drain, + delivery_count = DeliveryCount}, + Waiting = add_waiting({ConsumerKey, Con}, Waiting0), + State = State0#?STATE{waiting_consumers = Waiting}, + %% No messages are available for inactive consumers. + Avail = 0, + Reply = case CMeta of + #{link_state_properties := true} -> + #credit_reply{ctag = CTag, + delivery_count = DeliveryCount, + credit = Credit, + available = Avail, + drain = Drain, + properties = #{active => false}}; + _ -> + {credit_reply, CTag, DeliveryCount, Credit, Avail, Drain} + end, + {State, ok, {send_msg, CPid, Reply, ?DELIVERY_SEND_MSG_OPTS}}. + +is_over_limit(#?STATE{cfg = #cfg{max_length = undefined, + max_bytes = undefined}}) -> + false; +is_over_limit(#?STATE{cfg = #cfg{max_length = MaxLength, + max_bytes = MaxBytes}, + msg_bytes_enqueue = BytesEnq, + dlx = DlxState} = State) -> + {NumDlx, BytesDlx} = dlx_stat(DlxState), + (messages_ready_plus_delayed(State) + NumDlx > MaxLength) orelse + (BytesEnq + BytesDlx > MaxBytes). + +is_below_soft_limit(#?STATE{cfg = #cfg{max_length = undefined, + max_bytes = undefined}}) -> + false; +is_below_soft_limit(#?STATE{cfg = #cfg{max_length = MaxLength, + max_bytes = MaxBytes}, + msg_bytes_enqueue = BytesEnq, + dlx = DlxState} = State) -> + {NumDlx, BytesDlx} = dlx_stat(DlxState), + is_below(MaxLength, messages_ready_plus_delayed(State) + NumDlx) andalso + is_below(MaxBytes, BytesEnq + BytesDlx). + +is_below(undefined, _Num) -> + true; +is_below(Val, Num) when is_integer(Val) andalso is_integer(Num) -> + Num =< trunc(Val * ?LOW_LIMIT). + +-spec make_enqueue(option(pid()), option(msg_seqno()), raw_msg()) -> + protocol(). +make_enqueue(undefined, undefined, Msg) -> + %% need to keep this old version for untracked enqueues + #enqueue{msg = Msg}; +make_enqueue(Pid, Seq, Msg) + when is_pid(Pid) andalso + is_integer(Seq) -> + #?ENQ_V2{seq = Seq, + msg = Msg, + size = ?SIZE(Msg)}. + +make_enqueue_old(Pid, Seq, Msg) -> + %% we just use this version of the prop test, + %% TODO: refactor prop test + #enqueue{msg = Msg, + seq = Seq, + pid = Pid}. + +-spec make_register_enqueuer(pid()) -> protocol(). +make_register_enqueuer(Pid) -> + #register_enqueuer{pid = Pid}. + +-spec make_checkout(consumer_id(), checkout_spec(), consumer_meta()) -> + protocol(). +make_checkout({_, _} = ConsumerId, Spec, Meta) -> + #checkout{consumer_id = ConsumerId, + spec = Spec, meta = Meta}. + +-spec make_settle(consumer_key(), [msg_id()]) -> protocol(). +make_settle(ConsumerKey, MsgIds) when is_list(MsgIds) -> + #settle{consumer_key = ConsumerKey, msg_ids = MsgIds}. + +-spec make_return(consumer_key(), [msg_id()]) -> protocol(). +make_return(ConsumerKey, MsgIds) -> + #return{consumer_key = ConsumerKey, msg_ids = MsgIds}. + +-spec is_return(protocol()) -> boolean(). +is_return(Command) -> + is_record(Command, return). + +-spec make_discard(consumer_key(), [msg_id()]) -> protocol(). +make_discard(ConsumerKey, MsgIds) -> + #discard{consumer_key = ConsumerKey, msg_ids = MsgIds}. + +-spec make_credit(consumer_key(), rabbit_queue_type:credit(), + non_neg_integer(), boolean()) -> protocol(). +make_credit(Key, Credit, DeliveryCount, Drain) -> + #credit{consumer_key = Key, + credit = Credit, + delivery_count = DeliveryCount, + drain = Drain}. + +-spec make_modify(consumer_key(), [msg_id()], + boolean(), boolean(), mc:annotations()) -> protocol(). +make_modify(ConsumerKey, MsgIds, DeliveryFailed, UndeliverableHere, Anns) + when is_list(MsgIds) andalso + is_boolean(DeliveryFailed) andalso + is_boolean(UndeliverableHere) andalso + is_map(Anns) -> + #modify{consumer_key = ConsumerKey, + msg_ids = MsgIds, + delivery_failed = DeliveryFailed, + undeliverable_here = UndeliverableHere, + annotations = Anns}. + + +-spec make_purge() -> protocol(). +make_purge() -> #purge{}. + +-spec make_garbage_collection() -> protocol(). +make_garbage_collection() -> #garbage_collection{}. + +-spec make_purge_nodes([node()]) -> protocol(). +make_purge_nodes(Nodes) -> + #purge_nodes{nodes = Nodes}. + +-spec make_update_config(config()) -> protocol(). +make_update_config(Config) -> + #update_config{config = Config}. + +-spec make_delayed(delayed_op()) -> protocol(). +make_delayed({retry, Mode} = Op) when Mode =:= all orelse + (is_integer(Mode) andalso Mode >= 0) -> + #delayed_cmd{op = Op}; +make_delayed({assign_deferred, _ConsumerKey, Tokens} = Op) when is_list(Tokens) -> + #delayed_cmd{op = Op}. + +add_bytes_drop(Header, + #?STATE{msg_bytes_enqueue = Enqueue} = State) -> + Size = get_header(size, Header), + State#?STATE{msg_bytes_enqueue = Enqueue - Size}. + + +add_bytes_return(Header, + #?STATE{msg_bytes_checkout = Checkout, + msg_bytes_enqueue = Enqueue} = State) -> + Size = get_header(size, Header), + State#?STATE{msg_bytes_checkout = Checkout - Size, + msg_bytes_enqueue = Enqueue + Size}. + +message_size(B) when is_binary(B) -> + byte_size(B); +message_size(Msg) -> + case mc:is(Msg) of + true -> + mc:size(Msg); + false -> + %% probably only hit this for testing so ok to use erts_debug + {0, erts_debug:size(Msg)} + end. + +all_nodes(#?STATE{consumers = Cons0, + enqueuers = Enqs0, + waiting_consumers = WaitingConsumers0}) -> + Nodes0 = maps:fold(fun(_, ?CONSUMER_PID(P), Acc) -> + Acc#{node(P) => ok} + end, #{}, Cons0), + Nodes1 = maps:fold(fun(P, _, Acc) -> + Acc#{node(P) => ok} + end, Nodes0, Enqs0), + maps:keys( + lists:foldl(fun({_, ?CONSUMER_PID(P)}, Acc) -> + Acc#{node(P) => ok} + end, Nodes1, WaitingConsumers0)). + +all_pids_for(Node, #?STATE{consumers = Cons0, + enqueuers = Enqs0, + waiting_consumers = WaitingConsumers0}) -> + Cons = maps:fold(fun(_, ?CONSUMER_PID(P), Acc) + when node(P) =:= Node -> + [P | Acc]; + (_, _, Acc) -> + Acc + end, [], maps:iterator(Cons0, ordered)), + Enqs = maps:fold(fun(P, _, Acc) + when node(P) =:= Node -> + [P | Acc]; + (_, _, Acc) -> + Acc + end, Cons, maps:iterator(Enqs0, ordered)), + lists:foldl(fun({_, ?CONSUMER_PID(P)}, Acc) + when node(P) =:= Node -> + [P | Acc]; + (_, Acc) -> Acc + end, Enqs, WaitingConsumers0). + +suspected_pids_for(Node, #?STATE{consumers = Cons0, + enqueuers = Enqs0, + waiting_consumers = WaitingConsumers0}) -> + Cons = maps:fold(fun(_Key, + #consumer{cfg = #consumer_cfg{pid = P}, + status = {suspected_down, _}}, + Acc) + when node(P) =:= Node -> + [P | Acc]; + (_, _, Acc) -> + Acc + end, [], maps:iterator(Cons0, ordered)), + Enqs = maps:fold(fun(P, #enqueuer{status = suspected_down}, Acc) + when node(P) =:= Node -> + [P | Acc]; + (_, _, Acc) -> + Acc + end, Cons, maps:iterator(Enqs0, ordered)), + lists:foldl(fun({_Key, + #consumer{cfg = #consumer_cfg{pid = P}, + status = {suspected_down, _}}}, Acc) + when node(P) =:= Node -> + [P | Acc]; + (_, Acc) -> Acc + end, Enqs, WaitingConsumers0). + +is_expired(Ts, #?STATE{cfg = #cfg{expires = Expires}, + last_active = LastActive, + consumers = Consumers}) + when is_number(LastActive) andalso is_number(Expires) -> + %% TODO: should it be active consumers? + Active = maps:filter(fun (_, #consumer{status = {suspected_down, _}}) -> + false; + (_, _) -> + true + end, Consumers), + + Ts > (LastActive + Expires) andalso maps:size(Active) == 0; +is_expired(_Ts, _State) -> + false. + +get_consumer_timeout(CMeta, + #?STATE{cfg = + #cfg{}}) -> + %% the spec _should_ always contain the timeout key but we need to provide + %% a default to handle consumers from nodes running a prior version + maps:get(timeout, CMeta, ?DEFAULT_CONSUMER_TIMEOUT_MS). + +get_consumer_priority(#{priority := Priority}) -> + Priority; +get_consumer_priority(#{args := Args}) -> + %% fallback, v3 option + case rabbit_misc:table_lookup(Args, <<"x-priority">>) of + {_Type, Value} -> + Value; + _ -> + 0 + end; +get_consumer_priority(_) -> + 0. + +notify_decorators_effect(QName, MaxActivePriority, IsEmpty) -> + {mod_call, rabbit_quorum_queue, spawn_notify_decorators, + [QName, consumer_state_changed, [MaxActivePriority, IsEmpty]]}. + +notify_decorators_startup(QName) -> + {mod_call, rabbit_quorum_queue, spawn_notify_decorators, + [QName, startup, []]}. + +convert(_Meta, To, To, State) -> + State; +convert(Meta, 0, To, State) -> + convert(Meta, 1, To, rabbit_fifo_v1:convert_v0_to_v1(State)); +convert(Meta, 1, To, State) -> + convert(Meta, 2, To, rabbit_fifo_v3:convert_v1_to_v2(State)); +convert(Meta, 2, To, State) -> + convert(Meta, 3, To, rabbit_fifo_v3:convert_v2_to_v3(State)); +convert(Meta, 3, To, State) -> + convert(Meta, 4, To, rabbit_fifo_v7:convert_v3_to_v4(Meta, State)); +convert(Meta, 4, To, State) -> + %% no conversion needed, this version only includes a logic change + convert(Meta, 5, To, State); +convert(Meta, 5, To, State) -> + %% no conversion needed, this version only includes a logic change + convert(Meta, 6, To, State); +convert(Meta, 6, To, State) -> + %% no conversion needed, this version only includes a logic change + convert(Meta, 7, To, State); +convert(Meta, 7, To, State) -> + convert(Meta, 8, To, convert_v7_to_v8(Meta, State)). + +smallest_raft_index(#?STATE{messages = Messages, + returns = Returns, + consumers = Consumers, + delayed = #delayed{tree = Tree}, + dlx = #?DLX{consumer = DlxConsumer, + discards = Discards}}) -> + Min0 = rabbit_fifo_pq:get_lowest_index(Messages), + Min1 = lqueue:fold(fun (Msg, Acc) -> + min(get_msg_idx(Msg), Acc) + end, Min0, Returns), + Min2 = maps:fold(fun (_Cid, #consumer{checked_out = Ch}, Acc0) -> + maps:fold(fun (_MsgId, ?C_MSG(Msg), Acc) -> + min(get_msg_idx(Msg), Acc) + end, Acc0, Ch) + end, Min1, Consumers), + Min3 = lqueue:fold(fun (?TUPLE(_Reason, Msg), Acc) -> + min(get_msg_idx(Msg), Acc) + end, Min2, Discards), + Min4 = case gb_trees:is_empty(Tree) of + true -> + Min3; + false -> + Iter = gb_trees:iterator(Tree), + smallest_delayed_index(Iter, Min3) + end, + case DlxConsumer of + undefined -> + Min4; + #dlx_consumer{checked_out = Checked} -> + maps:fold(fun(_MsgId, ?TUPLE(_Reason, Msg), Acc) -> + min(get_msg_idx(Msg), Acc) + end, Min4, Checked) + end. + +smallest_delayed_index(Iter, Min) -> + case gb_trees:next(Iter) of + none -> + Min; + {?TUPLE(_, Idx), _, Iter1} -> + smallest_delayed_index(Iter1, min(Idx, Min)) + end. + +make_requeue(ConsumerKey, Notify, [{MsgId, Idx, Header, Msg}], Acc) -> + lists:reverse([{append, + #requeue{consumer_key = ConsumerKey, + index = Idx, + header = Header, + msg_id = MsgId, + msg = Msg}, + Notify} + | Acc]); +make_requeue(ConsumerKey, Notify, [{MsgId, Idx, Header, Msg} | Rem], Acc) -> + make_requeue(ConsumerKey, Notify, Rem, + [{append, + #requeue{consumer_key = ConsumerKey, + index = Idx, + header = Header, + msg_id = MsgId, + msg = Msg}, + noreply} + | Acc]); +make_requeue(_ConsumerId, _Notify, [], []) -> + []. + +can_immediately_deliver(#?STATE{service_queue = SQ, + consumers = Consumers} = State) -> + case messages_ready(State) of + 0 when map_size(Consumers) > 0 -> + %% TODO: is is probably good enough but to be 100% we'd need to + %% scan all consumers and ensure at least one has credit + priority_queue:is_empty(SQ) == false; + _ -> + false + end. + +incr(I) -> + I + 1. + +get_msg_from_cmd(#?ENQ_V2{msg = M}) -> + M; +get_msg_from_cmd(#enqueue{msg = M}) -> + M; +get_msg_from_cmd(#requeue{msg = M}) -> + M. + +initial_delivery_count({credited, Count}) -> + Count; +initial_delivery_count({simple_prefetch, _}) -> + 0. + +link_credit_snd(DeliveryCountRcv, LinkCreditRcv, DeliveryCountSnd) -> + amqp10_util:link_credit_snd(DeliveryCountRcv, LinkCreditRcv, + DeliveryCountSnd). + +consumer_id(#consumer{cfg = Cfg}) -> + {Cfg#consumer_cfg.tag, Cfg#consumer_cfg.pid}. + +consumer_id(Key, #?STATE{consumers = Consumers}) + when is_integer(Key) -> + consumer_id(maps:get(Key, Consumers)); +consumer_id({_, _} = ConsumerId, _State) -> + ConsumerId. + + +consumer_key_from_id(ConsumerId, #?STATE{consumers = Consumers}) + when is_map_key(ConsumerId, Consumers) -> + {ok, ConsumerId}; +consumer_key_from_id(ConsumerId, #?STATE{consumers = Consumers, + waiting_consumers = Waiting}) -> + case consumer_key_from_id(ConsumerId, maps:next(maps:iterator(Consumers))) of + {ok, _} = Res -> + Res; + error -> + %% scan the waiting consumers + case lists:search(fun ({_K, ?CONSUMER_TAG_PID(T, P)}) -> + {T, P} == ConsumerId + end, Waiting) of + {value, {K, _}} -> + {ok, K}; + false -> + error + end + end; +consumer_key_from_id({CTag, CPid}, {Key, ?CONSUMER_TAG_PID(T, P), _I}) + when T == CTag andalso P == CPid -> + {ok, Key}; +consumer_key_from_id(ConsumerId, {_, _, I}) -> + consumer_key_from_id(ConsumerId, maps:next(I)); +consumer_key_from_id(_ConsumerId, none) -> + error. + +consumer_cancel_info(ConsumerKey, #?STATE{consumers = Consumers}) -> + case Consumers of + #{ConsumerKey := #consumer{checked_out = Checked}} -> + #{key => ConsumerKey, + num_checked_out => map_size(Checked)}; + _ -> + #{} + end. + +find_consumer(Key, Consumers) -> + case Consumers of + #{Key := Con} -> + {Key, Con}; + _ when is_tuple(Key) -> + %% sometimes rabbit_fifo_client may send a settle, return etc + %% by it's ConsumerId even if it was created with an integer key + %% as it may have lost it's state after a consumer cancel + maps_search(fun (_K, ?CONSUMER_TAG_PID(Tag, Pid)) -> + Key == {Tag, Pid} + end, Consumers); + _ -> + undefined + end. + +find_waiting_consumer(Key, Consumers) -> + case lists:keyfind(Key, 1, Consumers) of + {Key, Con} -> + {Key, Con}; + _ when is_tuple(Key) -> + %% sometimes rabbit_fifo_client may send a settle, return etc + %% by it's ConsumerId even if it was created with an integer key + %% as it may have lost it's state after a consumer cancel + case lists:search(fun ({_K, ?CONSUMER_TAG_PID(Tag, Pid)}) -> + Key == {Tag, Pid} + end, Consumers) of + {value, Value} -> + Value; + false -> + undefined + end; + _ -> + undefined + end. + +maps_search(_Pred, none) -> + undefined; +maps_search(Pred, {K, V, I}) -> + case Pred(K, V) of + true -> + {K, V}; + false -> + maps_search(Pred, maps:next(I)) + end; +maps_search(Pred, Map) when is_map(Map) -> + maps_search(Pred, maps:next(maps:iterator(Map))). + +msg_priority(Msg) -> + case mc:is(Msg) of + true -> + case mc:priority(Msg) of + P when is_integer(P) -> + max(0, min(P, ?MAX_PRIORITY)); + _ -> + ?DEFAULT_PRIORITY + end; + false -> + ?DEFAULT_PRIORITY + end. + +do_snapshot(MacVer, Ts, Ch, RaAux, ReclaimableBytes, Force) + when element(1, Ch) == checkpoint andalso + is_integer(MacVer) andalso + MacVer >= 8 -> + Idx = element(2, Ch), + LastTs = element(3, Ch), + do_snapshot(MacVer, Ts, #snapshot{index = Idx, timestamp = LastTs}, + RaAux, ReclaimableBytes, Force); +do_snapshot(MacVer, Ts, + #snapshot{timestamp = SnapTime, + reclaimable_bytes = LastReclaimableBytes, + min_reclaimable = MinReclaimable0} = Snap0, + RaAux, ReclaimableBytes, Force) + when is_integer(MacVer) andalso MacVer >= 8 -> + {CheckMinInterval, SnapMinReclaimable} = + persistent_term:get(quorum_queue_snapshot_config, + {?CHECK_MIN_INTERVAL_MS, + ?SNAP_MIN_RECLAIMABLE_B}), + TimeSince = Ts - SnapTime, + case TimeSince > CheckMinInterval orelse Force of + true -> + DataRemoved = ReclaimableBytes - LastReclaimableBytes, + WalFillRatio = ra_aux:wal_fill_ratio(RaAux), + %% Scale the minimum reclaimable threshold inversely with the + %% WAL fill ratio. When the WAL is nearly empty the threshold + %% is high so we defer; when the WAL is nearly full the + %% threshold drops so we snapshot before rollover. + %% Also ensure the removed data exceeds the approximate + %% snapshot size so that the snapshot is smaller than the + %% data it saves. + ScaledLimit = round(MinReclaimable0 * + (1.0 - WalFillRatio)), + MacState = ra_aux:machine_state(RaAux), + Limit = max(ScaledLimit, approx_snap_size(MacState)), + case DataRemoved > Limit orelse Force of + true -> + Range = max(1, SnapMinReclaimable - ?SNAP_MIN_RECLAIMABLE_LOW_B), + MinReclaimable = ?SNAP_MIN_RECLAIMABLE_LOW_B + rand:uniform(Range), + Idx = ra_aux:last_applied(RaAux), + MsgsTot = messages_total(MacState), + Snap = #snapshot{index = Idx, + timestamp = Ts, + messages_total = MsgsTot, + reclaimable_bytes = ReclaimableBytes, + min_reclaimable = MinReclaimable}, + Cond = #{condition => [{written, Idx}, + no_snapshot_sends]}, + {Snap, [{release_cursor, Idx, MacState, Cond}]}; + false -> + {Snap0, []} + end; + false -> + {Snap0, []} + end. + +approx_snap_size(#?STATE{consumers = Consumers, + enqueuers = Enqueuers, + waiting_consumers = Waiting} = State) -> + MsgsTot = messages_total(State), + NumEnqs = map_size(Enqueuers), + NumCons = map_size(Consumers), + NumWaiting = length(Waiting), + CheckedOut = maps:fold( + fun (_, #consumer{checked_out = C}, Acc) -> + Acc + map_size(C) + end, 0, Consumers), + 4096 + + MsgsTot * 32 + + CheckedOut * 72 + + NumEnqs * 112 + + NumCons * 296 + + NumWaiting * 312. + +discard(Meta, MsgIds, ConsumerKey, + #consumer{checked_out = Checked} = Con, + DelFailed, Anns, + #?STATE{cfg = #cfg{dead_letter_handler = DLH}, + reclaimable_bytes = DiscardedBytes0, + dlx = DlxState0} = State0) -> + %% We publish to dead-letter exchange in the same order + %% as messages got rejected by the client. + DiscardMsgs = lists:filtermap( + fun(Id) -> + case maps:get(Id, Checked, undefined) of + undefined -> + false; + ?C_MSG(Msg0) -> + {true, incr_msg_headers(Msg0, DelFailed, Anns)} + end + end, MsgIds), + {DlxState, RetainedBytes, Effects} = + discard_or_dead_letter(DiscardMsgs, rejected, DLH, DlxState0), + State = State0#?STATE{dlx = DlxState, + reclaimable_bytes = DiscardedBytes0 - RetainedBytes}, + complete_and_checkout(Meta, MsgIds, ConsumerKey, Con, Effects, State). + +incr_msg_headers(Msg0, DeliveryFailed, Anns) -> + Msg1 = update_msg_header(acquired_count, fun incr/1, 1, Msg0), + Msg2 = case map_size(Anns) > 0 of + true -> + update_msg_header(anns, fun(A) -> + maps:merge(A, Anns) + end, Anns, + Msg1); + false -> + Msg1 + end, + case DeliveryFailed of + true -> + update_msg_header(delivery_count, fun incr/1, 1, Msg2); + false -> + Msg2 + end. + +exec_read(Flru0, ReadPlan, Msgs) -> + try ra_log_read_plan:execute(ReadPlan, Flru0) of + {Entries, Flru} -> + %% return a list in original order + {lists:map(fun ({MsgId, Msg}) -> + Idx = get_msg_idx(Msg), + Header = get_msg_header(Msg), + Cmd = maps:get(Idx, Entries), + {MsgId, {Header, get_msg_from_cmd(Cmd)}} + end, Msgs), Flru} + catch exit:{missing_key, _} + when Flru0 =/= undefined -> + %% this segment has most likely been appended to but the + %% cached index doesn't know about new items and need to be + %% re-generated + _ = ra_flru:evict_all(Flru0), + %% retry without segment cache + exec_read(undefined, ReadPlan, Msgs) + end. + +maps_ordered_keys(Map) -> + lists:sort(maps:keys(Map)). + +%% enqueue overhead: 256b + message size + +estimate_reclaimable_size(#?ENQ_V2{}) -> + 0; +estimate_reclaimable_size(Cmd) + when is_record(Cmd, settle) orelse + is_record(Cmd, return) orelse + is_record(Cmd, discard) orelse + is_record(Cmd, credit) -> + 128; +estimate_reclaimable_size(#checkout{}) -> + 300; +estimate_reclaimable_size(#register_enqueuer{}) -> + 200; +estimate_reclaimable_size(#modify{}) -> + 256; +estimate_reclaimable_size(#update_config{}) -> + 512; +estimate_reclaimable_size(#purge{}) -> + 64; +estimate_reclaimable_size(#purge_nodes{}) -> + 64; +estimate_reclaimable_size(#requeue{}) -> + 0; +estimate_reclaimable_size(#enqueue{}) -> + 0; +estimate_reclaimable_size({nodeup, _}) -> + 96; +estimate_reclaimable_size({down, _, _}) -> + 96; +estimate_reclaimable_size({dlx, _Cmd}) -> + 64; +estimate_reclaimable_size(_Cmd) -> + %% something is better than nothing + 64. + + +dlx_apply(_Meta, {dlx, {settle, MsgIds}}, at_least_once, + #?DLX{consumer = #dlx_consumer{checked_out = Checked0}} = State0) -> + Acked = maps:with(MsgIds, Checked0), + {DBytes, State} = + maps:fold( + fun(MsgId, ?TUPLE(_Rsn, Msg), + {Sz, #?DLX{consumer = #dlx_consumer{checked_out = Checked} = C, + msg_bytes_checkout = BytesCheckout} = S}) -> + Hdr = get_msg_header(Msg), + Size = get_header(size, Hdr), + {Sz + Size + ?ENQ_OVERHEAD_B, + S#?DLX{consumer = C#dlx_consumer{checked_out = + maps:remove(MsgId, Checked)}, + msg_bytes_checkout = BytesCheckout - Size}} + end, {0, State0}, Acked), + {State, DBytes, + [{mod_call, rabbit_global_counters, messages_dead_lettered_confirmed, + [rabbit_quorum_queue, at_least_once, maps:size(Acked)]}]}; +dlx_apply(_, {dlx, {checkout, Pid, Prefetch}}, + at_least_once, + #?DLX{consumer = undefined} = State0) -> + State = State0#?DLX{consumer = #dlx_consumer{pid = Pid, + prefetch = Prefetch}}, + {State, 0, []}; +dlx_apply(_, {dlx, {checkout, ConsumerPid, Prefetch}}, + at_least_once, + #?DLX{consumer = #dlx_consumer{pid = OldConsumerPid, + checked_out = CheckedOldConsumer}, + discards = Discards0, + msg_bytes = Bytes, + msg_bytes_checkout = BytesCheckout} = State0) -> + %% Since we allow only a single consumer, the new consumer replaces the old consumer. + case ConsumerPid of + OldConsumerPid -> + ok; + _ -> + ?LOG_DEBUG("Terminating ~p since ~p becomes active rabbit_fifo_dlx_worker", + [OldConsumerPid, ConsumerPid]), + %% turn into aux command + ensure_worker_terminated(State0) + end, + %% All checked out messages to the old consumer need to be returned to the discards queue + %% such that these messages will be re-delivered to the new consumer. + %% When inserting back into the discards queue, we respect the original order in which messages + %% were discarded. + {Discards, BytesMoved} = maps:fold( + fun(_Id, ?TUPLE(_, Msg) = RsnMsg, {D, B}) -> + Size = get_header(size, get_msg_header(Msg)), + {lqueue:in_r(RsnMsg, D), B + Size} + end, + {Discards0, 0}, + maps:iterator(CheckedOldConsumer, reversed)), + State = State0#?DLX{consumer = #dlx_consumer{pid = ConsumerPid, + prefetch = Prefetch}, + discards = Discards, + msg_bytes = Bytes + BytesMoved, + msg_bytes_checkout = BytesCheckout - BytesMoved}, + {State, 0, []}; +dlx_apply(_, Cmd, DLH, State) -> + ?LOG_DEBUG("Ignoring command ~tp for dead_letter_handler ~tp", [Cmd, DLH]), + {State, 0, []}. + +%% nodeup: 74 bytes +%% down: 90 bytes +%% enqueue overhead 210 + +ensure_worker_started(QRef, #?DLX{consumer = undefined}) -> + start_worker(QRef); +ensure_worker_started(QRef, #?DLX{consumer = #dlx_consumer{pid = Pid}}) -> + case is_local_and_alive(Pid) of + true -> + ?LOG_DEBUG("rabbit_fifo_dlx_worker ~tp already started for ~ts", + [Pid, rabbit_misc:rs(QRef)]); + false -> + start_worker(QRef) + end. + +%% Ensure that starting the rabbit_fifo_dlx_worker succeeds. +%% Therefore, do not use an effect. +%% Also therefore, if starting the rabbit_fifo_dlx_worker fails, let the +%% Ra server process crash in which case another Ra node will become leader. +start_worker(QRef) -> + {ok, Pid} = supervisor:start_child(rabbit_fifo_dlx_sup, [QRef]), + ?LOG_DEBUG("started rabbit_fifo_dlx_worker ~tp for ~ts", + [Pid, rabbit_misc:rs(QRef)]). + +ensure_worker_terminated(#?DLX{consumer = undefined}) -> + ok; +ensure_worker_terminated(#?DLX{consumer = #dlx_consumer{pid = Pid}}) -> + terminate_dlx_worker(Pid). + +terminate_dlx_worker(Pid) -> + case is_local_and_alive(Pid) of + true -> + %% Note that we can't return a mod_call effect here + %% because mod_call is executed on the leader only. + ok = supervisor:terminate_child(rabbit_fifo_dlx_sup, Pid), + ?LOG_DEBUG("terminated rabbit_fifo_dlx_worker ~tp", [Pid]); + false -> + ok + end. + +local_alive_consumer_pid(#?DLX{consumer = undefined}) -> + undefined; +local_alive_consumer_pid(#?DLX{consumer = #dlx_consumer{pid = Pid}}) -> + case is_local_and_alive(Pid) of + true -> + Pid; + false -> + undefined + end. + +is_local_and_alive(Pid) + when node(Pid) =:= node() -> + is_process_alive(Pid); +is_local_and_alive(_) -> + false. + +update_config(at_least_once, at_least_once, _, State) -> + case local_alive_consumer_pid(State) of + undefined -> + {State, []}; + Pid -> + %% Notify rabbit_fifo_dlx_worker about potentially updated policies. + {State, [{send_msg, Pid, {dlx_event, self(), lookup_topology}, cast}]} + end; +update_config(SameDLH, SameDLH, _, State) -> + {State, []}; +update_config(OldDLH, NewDLH, QRes, State0) -> + LogOnLeader = {mod_call, logger, debug, + ["Switching dead_letter_handler from ~tp to ~tp for ~ts", + [OldDLH, NewDLH, rabbit_misc:rs(QRes)]]}, + {State1, Effects0} = switch_from(OldDLH, QRes, State0), + {State, Effects} = switch_to(NewDLH, State1, Effects0), + {State, [LogOnLeader | Effects]}. + +switch_from(at_least_once, QRes, DlxState) -> + %% Switch from at-least-once to some other strategy. + %% TODO: do worker teardown in aux handler + {Num, Bytes} = dlx_stat(DlxState), + Pid = case DlxState of + #?DLX{consumer = #dlx_consumer{pid = P}} -> + P; + _ -> undefined + end, + %% Log only on leader. + {#?DLX{}, [{aux, {dlx, teardown, Pid}}, + {mod_call, logger, info, + ["Deleted ~b dead-lettered messages (with total messages size of ~b bytes) in ~ts", + [Num, Bytes, rabbit_misc:rs(QRes)]]}]}; +switch_from(_, _, State) -> + {State, []}. + +switch_to(at_least_once, _, Effects) -> + %% Switch from some other strategy to at-least-once. + %% Dlx worker needs to be started on the leader. + %% The cleanest way to determine the Ra state of this node is + %% delegation to handle_aux. + {#?DLX{}, Effects ++ [{aux, {dlx, setup}}]}; +switch_to(_, State, Effects) -> + {State, Effects}. + + +dlx_purge(#?DLX{consumer = Consumer0} = State) -> + Consumer = case Consumer0 of + undefined -> + undefined; + #dlx_consumer{} -> + Consumer0#dlx_consumer{checked_out = #{}} + end, + State#?DLX{discards = lqueue:new(), + msg_bytes = 0, + msg_bytes_checkout = 0, + consumer = Consumer}. + +dlx_stat(#?DLX{consumer = Con, + discards = Discards, + msg_bytes = MsgBytes, + msg_bytes_checkout = MsgBytesCheckout}) -> + Num0 = lqueue:len(Discards), + Num = case Con of + undefined -> + Num0; + #dlx_consumer{checked_out = Checked} -> + %% O(1) because Erlang maps maintain their own size + Num0 + maps:size(Checked) + end, + Bytes = MsgBytes + MsgBytesCheckout, + {Num, Bytes}. + + +dlx_overview(#?DLX{consumer = undefined, + msg_bytes = MsgBytes, + msg_bytes_checkout = 0, + discards = Discards}) -> + dlx_overview0(Discards, #{}, MsgBytes, 0); +dlx_overview(#?DLX{consumer = #dlx_consumer{checked_out = Checked}, + msg_bytes = MsgBytes, + msg_bytes_checkout = MsgBytesCheckout, + discards = Discards}) -> + dlx_overview0(Discards, Checked, MsgBytes, MsgBytesCheckout). + +dlx_overview0(Discards, Checked, MsgBytes, MsgBytesCheckout) -> + #{num_discarded => lqueue:len(Discards), + num_discard_checked_out => maps:size(Checked), + discard_message_bytes => MsgBytes, + discard_checkout_message_bytes => MsgBytesCheckout}. + + +discard_or_dead_letter(Msgs, Reason, undefined, State) -> + {State, 0, + [{mod_call, rabbit_global_counters, messages_dead_lettered, + [Reason, rabbit_quorum_queue, disabled, length(Msgs)]}]}; +discard_or_dead_letter(Msgs0, Reason, {at_most_once, {Mod, Fun, Args}}, State) -> + Idxs = lists:map(fun get_msg_idx/1, Msgs0), + %% TODO: this could be turned into a log_ext effect instead to avoid + %% reading from disk inside the qq process + Effect = {log, Idxs, + fun (Log) -> + Lookup = maps:from_list(lists:zip(Idxs, Log)), + Msgs = [begin + Idx = get_msg_idx(Msg), + Hdr = get_msg_header(Msg), + Cmd = maps:get(Idx, Lookup), + %% ensure header delivery count + %% is copied to the message container + annotate_msg(Hdr, rabbit_fifo:get_msg_from_cmd(Cmd)) + end || Msg <- Msgs0], + [{mod_call, Mod, Fun, Args ++ [Reason, Msgs]}] + end}, + {State, 0, [Effect]}; +discard_or_dead_letter(Msgs, Reason, at_least_once, State0) + when Reason =/= maxlen -> + {State, RetainedBytes} = lists:foldl( + fun(Msg, {#?DLX{discards = D, + msg_bytes = B} = S, R}) -> + MsgSize = size_in_bytes(Msg), + {S#?DLX{discards = lqueue:in(?TUPLE(Reason, Msg), D), + msg_bytes = B + MsgSize}, + R + MsgSize + ?ENQ_OVERHEAD_B} + end, {State0, 0}, Msgs), + {State, RetainedBytes, + [{mod_call, rabbit_global_counters, messages_dead_lettered, + [Reason, rabbit_quorum_queue, at_least_once, length(Msgs)]}]}. + + +size_in_bytes(Msg) -> + Header = get_msg_header(Msg), + get_header(size, Header). + +dlx_checkout(at_least_once, #?DLX{consumer = #dlx_consumer{}} = State) -> + dlx_checkout0(dlx_checkout_one(State), []); +dlx_checkout(_, State) -> + {State, []}. + +dlx_checkout0({success, MsgId, ?TUPLE(Reason, Msg), State}, SendAcc) -> + Idx = get_msg_idx(Msg), + Hdr = get_msg_header(Msg), + DelMsg = {Idx, {Reason, Hdr, MsgId}}, + dlx_checkout0(dlx_checkout_one(State), [DelMsg | SendAcc]); +dlx_checkout0(#?DLX{consumer = #dlx_consumer{pid = Pid}} = State, SendAcc) -> + Effects = dlx_delivery_effects(Pid, SendAcc), + {State, Effects}. + +dlx_checkout_one(#?DLX{consumer = #dlx_consumer{checked_out = Checked, + prefetch = Prefetch}} = State) + when map_size(Checked) >= Prefetch -> + State; +dlx_checkout_one(#?DLX{discards = Discards0, + msg_bytes = Bytes, + msg_bytes_checkout = BytesCheckout, + consumer = #dlx_consumer{checked_out = Checked0, + next_msg_id = Next} = Con0} = State0) -> + case lqueue:out(Discards0) of + {{value, ?TUPLE(_, Msg) = ReasonMsg}, Discards} -> + Checked = maps:put(Next, ReasonMsg, Checked0), + Size = size_in_bytes(Msg), + State = State0#?DLX{discards = Discards, + msg_bytes = Bytes - Size, + msg_bytes_checkout = BytesCheckout + Size, + consumer = Con0#dlx_consumer{checked_out = Checked, + next_msg_id = Next + 1}}, + {success, Next, ReasonMsg, State}; + {empty, _} -> + State0 + end. + +dlx_delivery_effects(_CPid, []) -> + []; +dlx_delivery_effects(CPid, Msgs0) -> + Msgs1 = lists:reverse(Msgs0), + {RaftIdxs, RsnIds} = lists:unzip(Msgs1), + [{log, RaftIdxs, + fun(Log) -> + Msgs = lists:zipwith( + fun (Cmd, {Reason, H, MsgId}) -> + {MsgId, {Reason, + annotate_msg(H, rabbit_fifo:get_msg_from_cmd(Cmd))}} + end, Log, RsnIds), + [{send_msg, CPid, {dlx_event, self(), {dlx_delivery, Msgs}}, [cast]}] + end}]. + +consumer_info(ConsumerKey, + #consumer{checked_out = Checked, + credit = Credit, + delivery_count = DeliveryCount, + next_msg_id = NextMsgId}, + State) -> + #?STATE{cfg = #cfg{consumer_strategy = Strat}} = State, + #{next_msg_id => NextMsgId, + credit => Credit, + key => ConsumerKey, + delivery_count => DeliveryCount, + is_active => is_active(ConsumerKey, State), + consumer_strategy => Strat, + num_checked_out => map_size(Checked)}. + +handle_waiting_timedout_consumers(Meta, Key, MsgIds, State0) -> + case State0 of + #?STATE{cfg = #cfg{consumer_strategy = single_active}, + waiting_consumers = Waiting0} -> + %% check if consumer key relates to any of the waiting + %% consumers + case find_waiting_consumer(Key, Waiting0) of + {CKey, #consumer{status = {timeout, Inner}, + timed_out_msg_ids = TimedMsgIds} = WC0} -> + case TimedMsgIds -- MsgIds of + [] -> + %% all timed out msgids have been settled + WC = WC0#consumer{status = Inner, + timed_out_msg_ids = []}, + Waiting = add_waiting({CKey, WC}, Waiting0), + {State, Effects} = + activate_next_consumer( + State0#?STATE{waiting_consumers = Waiting}, []), + checkout(Meta, State0, State, Effects); + Rem -> + WC = WC0#consumer{timed_out_msg_ids = Rem}, + Waiting = add_waiting({CKey, WC}, Waiting0), + {State0#?STATE{waiting_consumers = Waiting}, ok} + end; + _ -> + {State0, ok} + end; + _ -> + {State0, ok} + end. + +update_consumer_status(suspected_down, #consumer{status = Status} = C) + when is_atom(Status) -> + C#consumer{status = {suspected_down, Status}}; +update_consumer_status(timeout, #consumer{status = Status} = C) + when is_atom(Status) -> + C#consumer{status = {timeout, Status}}; +update_consumer_status(suspected_down, #consumer{status = {timeout, S}} = C) -> + C#consumer{status = {suspected_down, S}}; +update_consumer_status(timeout, #consumer{status = {_, _}} = C) -> + C; +update_consumer_status(up, #consumer{status = {_, Status}, + timed_out_msg_ids = [_|_]} = C) -> + C#consumer{status = {timeout, Status}}; +update_consumer_status(up, #consumer{status = {_, Status}, + timed_out_msg_ids = []} = C) -> + C#consumer{status = Status}; +update_consumer_status(Target, #consumer{} = C) -> + C#consumer{status = Target}. + diff --git a/deps/rabbit/src/rabbit_fifo_v8.hrl b/deps/rabbit/src/rabbit_fifo_v8.hrl new file mode 100644 index 000000000000..d466ad717ab7 --- /dev/null +++ b/deps/rabbit/src/rabbit_fifo_v8.hrl @@ -0,0 +1,317 @@ +%% This Source Code Form is subject to the terms of the Mozilla Public +%% License, v. 2.0. If a copy of the MPL was not distributed with this +%% file, You can obtain one at https://mozilla.org/MPL/2.0/. +%% +%% Copyright (c) 2007-2026 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries. All rights reserved. + +%% macros for memory optimised tuple structures +%% [A|B] saves 1 byte compared to {A,B} +-define(TUPLE(A, B), [A | B]). + +%% We only hold Raft index and message header in memory. +%% Raw message data is always stored on disk. +-define(MSG(Index, Header), ?TUPLE(Index, Header)). + +-define(C_MSG(Timeout, Msg), {Timeout, Msg}). +-define(C_MSG(Msg), {_, Msg}). +-define(NIL, []). + +-define(IS_HEADER(H), + (is_integer(H) andalso H >= 0) orelse + is_list(H) orelse + (is_map(H) andalso is_map_key(size, H))). + +-define(DELIVERY_SEND_MSG_OPTS, [local, ra_event]). + +%% constants for packed msg references where both the raft index and the size +%% is packed into a single immidate term +%% +%% 59 bytes as immedate ints are signed +-define(PACKED_MAX, 16#7FFF_FFFF_FFFF_FFF). +%% index bits - enough for 2000 days at 100k indexes p/sec +-define(PACKED_IDX_BITS, 44). +-define(PACKED_IDX_MAX, 16#FFFF_FFFF_FFF). +-define(PACKED_SZ_BITS, 15). %% size +-define(PACKED_SZ_MAX, 16#7FFF). %% 15 bits + +-define(PACK(Idx, Sz), + (Idx bxor (Sz bsl ?PACKED_IDX_BITS))). +-define(PACKED_IDX(PackedInt), + (PackedInt band ?PACKED_IDX_MAX)). +-define(PACKED_SZ(PackedInt), + ((PackedInt bsr 44) band 16#7FFF)). + +-define(IS_PACKED(Int), (Int >= 0 andalso Int =< ?PACKED_MAX)). + +-type optimised_tuple(A, B) :: nonempty_improper_list(A, B). + +-type option(T) :: undefined | T. + +-type raw_msg() :: term(). +%% The raw message. It is opaque to rabbit_fifo. + +-type msg_id() :: non_neg_integer(). +%% A consumer-scoped monotonically incrementing integer included with a +%% {@link delivery/0.}. Used to settle deliveries using +%% {@link rabbit_fifo_client:settle/3.} + +-type msg_seqno() :: non_neg_integer(). +%% A sender process scoped monotonically incrementing integer included +%% in enqueue messages. Used to ensure ordering of messages send from the +%% same process + +-type msg_header() :: msg_size() | + optimised_tuple(msg_size(), Expiry :: milliseconds()) | + #{size := msg_size(), + acquired_count => non_neg_integer(), + delivery_count => non_neg_integer(), + expiry => milliseconds()}. +%% The message header: +%% size: The size of the message payload in bytes. +%% delivery_count: The number of unsuccessful delivery attempts. +%% A non-zero value indicates a previous attempt. +%% return_count: The number of explicit returns. +%% expiry: Epoch time in ms when a message expires. Set during enqueue. +%% Value is determined by per-queue or per-message message TTL. +%% If it contains only the size it can be condensed to an integer. +%% If it contains only the size and expiry it can be condensed to an improper list. + +-type msg_size() :: non_neg_integer(). +%% the size in bytes of the msg payload + +%% 60 byte integer, immediate +-type packed_msg() :: 0..?PACKED_MAX. + +-type msg() :: packed_msg() | optimised_tuple(ra:index(), msg_header()). + +%% a consumer message +-type c_msg() :: {LockExpiration :: milliseconds(), msg()}. + +-type delivery_msg() :: {msg_id(), {msg_header(), raw_msg()}}. +%% A tuple consisting of the message id, and the headered message. + +-type delivery() :: {delivery, rabbit_types:ctag(), [delivery_msg()]}. +%% Represents the delivery of one or more rabbit_fifo messages. + +-type consumer_id() :: {rabbit_types:ctag(), pid()}. +%% The entity that receives messages. Uniquely identifies a consumer. + +-type consumer_idx() :: ra:index(). +%% v4 can reference consumers by the raft index they were added at. +%% The entity that receives messages. Uniquely identifies a consumer. +-type consumer_key() :: consumer_id() | consumer_idx(). + +-type credit_mode() :: + {credited, InitialDeliveryCount :: rabbit_queue_type:delivery_count()} | + %% machine_version 2 + {simple_prefetch, MaxCredit :: non_neg_integer()}. +%% determines how credit is replenished + +-type checkout_spec() :: {once | auto, + Num :: non_neg_integer(), + credited | simple_prefetch} | + + {dequeue, settled | unsettled} | + cancel | remove | + %% new v4 format + {once | auto, credit_mode()}. + +%% static meta data associated with a consumer +-type consumer_meta() :: #{ack => boolean(), + username => binary(), + prefetch => non_neg_integer(), + args => list(), + priority => 0..255, + timeout => milliseconds(), + link_state_properties => true}. + +-type applied_mfa() :: {module(), atom(), list()}. +% represents a partially applied module call + +-define(CHECK_MIN_INTERVAL_MS, 1000). +-define(CHECK_MIN_INDEXES, 4096 * 2). +-define(CHECK_MAX_INDEXES, 666_667). +%% once these many bytes have been written since the last checkpoint +%% we request a checkpoint irrespectively +-define(CHECK_MAX_BYTES, 128_000_000). +-define(SNAP_OUT_BYTES, 64_000_000). +-define(SNAP_MIN_RECLAIMABLE_B, 10_000_000). +-define(SNAP_MIN_RECLAIMABLE_LOW_B, 2_000_000). + +-define(USE_AVG_HALF_LIFE, 10000.0). +%% an average QQ without any message uses about 100KB so setting this limit +%% to ~10 times that should be relatively safe. +-define(GC_MEM_LIMIT_B, 2_000_000). + +-define(MB, 1_048_576). +-define(LOW_LIMIT, 0.8). +-define(DELIVERY_CHUNK_LIMIT_B, 128_000). + +-type seconds() :: non_neg_integer(). +-type milliseconds() :: non_neg_integer(). + +-record(consumer_cfg, + {meta = #{} :: consumer_meta(), + pid :: pid(), + tag :: rabbit_types:ctag(), + %% the mode of how credit is incremented + %% simple_prefetch: credit is re-filled as deliveries are settled + %% or returned. + %% credited: credit can only be changed by receiving a consumer_credit + %% command: `{credit, ReceiverDeliveryCount, Credit}' + credit_mode :: credited | credit_mode(), + lifetime = once :: once | auto, + priority = 0 :: integer(), + timeout = 1_800_000 :: milliseconds()}). + +-type consumer_status() :: up | cancelled | quiescing. + +-record(consumer, + {cfg = #consumer_cfg{}, + status = up :: consumer_status() | + {suspected_down, consumer_status()} | + %% a message has been pending for longer than the + %% consumer timeout + {timeout, consumer_status()}, + next_msg_id = 0 :: msg_id(), + checked_out = #{} :: #{msg_id() => c_msg()}, + %% max number of messages that can be sent + %% decremented for each delivery + credit = 0 :: non_neg_integer(), + %% AMQP 1.0 §2.6.7 + delivery_count :: rabbit_queue_type:delivery_count(), + drain = false :: boolean(), + timed_out_msg_ids = [] :: [msg_id()] + }). + +-type consumer() :: #consumer{}. + +-type consumer_strategy() :: competing | single_active. + +-type dead_letter_handler() :: option({at_most_once, applied_mfa()} | at_least_once). + +-type delayed_retry() :: disabled | + {all | failed | returned, + Min :: milliseconds(), + Max :: milliseconds()}. + +-type deferral_token() :: binary(). +-type delayed_key() :: optimised_tuple(milliseconds(), ra:index()). + +-record(delayed, + {tree = gb_trees:empty() :: gb_trees:tree(delayed_key(), msg()), + %% Cached smallest entry for O(1) readiness check in take_next_msg + next = undefined :: option({milliseconds(), ra:index(), msg()}), + %% Map from deferral token to tree key for direct message lookup + deferred = #{} :: #{deferral_token() => delayed_key()}}). + +-record(enqueuer, + {next_seqno = 1 :: msg_seqno(), + unused = ?NIL, + status = up :: up | suspected_down, + %% it is useful to have a record of when this was blocked + %% so that we can retry sending the block effect if + %% the publisher did not receive the initial one + blocked :: option(ra:index()), + unused_1 = ?NIL, + unused_2 = ?NIL + }). + +-record(cfg, + {name :: atom(), + resource :: rabbit_types:r('queue'), + unused_1 = ?NIL, + dead_letter_handler :: dead_letter_handler(), + become_leader_handler :: option(applied_mfa()), + overflow_strategy = drop_head :: drop_head | reject_publish, + max_length :: option(non_neg_integer()), + max_bytes :: option(non_neg_integer()), + %% whether single active consumer is on or not for this queue + consumer_strategy = competing :: consumer_strategy(), + %% the maximum number of unsuccessful delivery attempts permitted + delivery_limit :: option(non_neg_integer()), + expires :: option(milliseconds()), + msg_ttl :: option(milliseconds()), + %% time to wait before returning messages when consumer's node + %% becomes unreachable + consumer_disconnected_timeout = 60_000 :: milliseconds(), + %% delayed retry configuration for returned messages + delayed_retry = disabled :: delayed_retry() + }). + +-record(messages, + { + messages = rabbit_fifo_pq:new() :: rabbit_fifo_pq:state(), + messages_total = 0 :: non_neg_integer(), + % queue of returned msg_in_ids - when checking out it picks from + returns = lqueue:new() :: lqueue:lqueue(term()) + }). + +-record(dlx_consumer, + {pid :: pid(), + prefetch :: non_neg_integer(), + checked_out = #{} :: #{msg_id() => + optimised_tuple(rabbit_dead_letter:reason(), msg())}, + next_msg_id = 0 :: msg_id()}). + +-record(rabbit_fifo_dlx, + {consumer :: option(#dlx_consumer{}), + %% Queue of dead-lettered messages. + discards = lqueue:new() :: lqueue:lqueue(optimised_tuple(rabbit_dead_letter:reason(), msg())), + unused = ?NIL, + msg_bytes = 0 :: non_neg_integer(), + msg_bytes_checkout = 0 :: non_neg_integer()}). + +-record(rabbit_fifo, + {cfg :: #cfg{}, + % unassigned messages + messages = rabbit_fifo_pq:new() :: rabbit_fifo_pq:state(), + messages_total = 0 :: non_neg_integer(), + % queue of returned msg_in_ids - when checking out it picks from + returns = lqueue:new() :: lqueue:lqueue(term()), + % Reclaimable bytes - a counter that is incremented every time a + % command is processed that does not need to be kept (live indexes). + % Approximate, used for triggering snapshots. + % Reset to 0 when release_cursor gets stored. + reclaimable_bytes = 0, + % a map containing all the live processes that have ever enqueued + % a message to this queue + enqueuers = #{} :: #{pid() => #enqueuer{}}, + last_command_time = 0, + next_consumer_timeout = infinity :: infinity | milliseconds(), + % consumers need to reflect consumer state at time of snapshot + consumers = #{} :: #{consumer_key() => consumer()}, + % consumers that require further service are queued here + service_queue = priority_queue:new() :: priority_queue:q(), + %% state for at-least-once dead-lettering + dlx = #rabbit_fifo_dlx{} :: #rabbit_fifo_dlx{}, + msg_bytes_enqueue = 0 :: non_neg_integer(), + msg_bytes_checkout = 0 :: non_neg_integer(), + %% one is picked if active consumer is cancelled or dies + %% used only when single active consumer is on + waiting_consumers = [] :: [{consumer_key(), consumer()}], + %% records the timestamp whenever the queue was last considered + %% active in terms of consumer activity + last_active :: option(non_neg_integer()), + msg_cache :: option({ra:index(), raw_msg()}), + %% delayed retry messages awaiting redelivery + delayed = #delayed{} :: #delayed{} + }). + +-type config() :: #{name := atom(), + queue_resource := rabbit_types:r('queue'), + dead_letter_handler => dead_letter_handler(), + become_leader_handler => applied_mfa(), + % checkpoint_min_indexes => non_neg_integer(), + % checkpoint_max_indexes => non_neg_integer(), + max_length => non_neg_integer(), + max_bytes => non_neg_integer(), + overflow_strategy => drop_head | reject_publish, + single_active_consumer_on => boolean(), + delivery_limit => non_neg_integer() | -1, + expires => non_neg_integer(), + msg_ttl => non_neg_integer(), + created => non_neg_integer(), + consumer_disconnected_timeout => milliseconds(), + delayed_retry => delayed_retry() + }. From d676e10b0290a8898df9f1e555cf12fe48ebc874 Mon Sep 17 00:00:00 2001 From: Karl Nilsson Date: Tue, 2 Jun 2026 08:55:59 +0100 Subject: [PATCH 2/7] Add per-node ingress byte tracking to rabbit_fifo v9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the OSS infrastructure for ingress-dependent quorum queue leader rebalancing. This adds: - Per-node enqueue byte counters in the replicated state machine, updated deterministically on every enqueue via apply_enqueue/6 - Exposure of cumulative totals via overview/1, propagated to all replicas on every tick - Aux state leaky integrators (via ra_li) to compute smoothed per-node ingress rates, updated on handle_tick - A get_ingress_rates aux query that returns bytes/second per node, callable locally without cross-node coordination The new field ingress_bytes_by_node defaults to #{} and is never read by OSS, so pre-v9 members in mixed clusters are unaffected. The aux state version is bumped to aux_v5 with an upgrade clause for rolling restarts. Decay time is configurable via persistent_term. Tests added to verify accumulation, snapshot persistence, v8→v9 conversion, and rate decay behavior under controlled tick timing. --- deps/rabbit/src/rabbit_fifo.erl | 118 ++++++++++++++---- deps/rabbit/src/rabbit_fifo.hrl | 7 +- deps/rabbit/test/quorum_queue_SUITE.erl | 3 +- deps/rabbit/test/rabbit_fifo_SUITE.erl | 40 +++++- .../rabbit_fifo_dlx_integration_SUITE.erl | 16 ++- deps/rabbit/test/rabbit_fifo_prop_SUITE.erl | 34 ++++- 6 files changed, 177 insertions(+), 41 deletions(-) diff --git a/deps/rabbit/src/rabbit_fifo.erl b/deps/rabbit/src/rabbit_fifo.erl index 2bb953deb6f4..49573ca2bddc 100644 --- a/deps/rabbit/src/rabbit_fifo.erl +++ b/deps/rabbit/src/rabbit_fifo.erl @@ -203,8 +203,7 @@ delayed_op/0]). -spec init(config()) -> state(). -init(#{name := Name, - queue_resource := Resource} = Conf) -> +init(#{name := Name, queue_resource := Resource} = Conf) -> update_config(Conf, #?STATE{cfg = #cfg{name = Name, resource = Resource}}). @@ -696,9 +695,12 @@ apply_(Meta, {nodeup, Node}, #?STATE{consumers = Cons0, apply_(_, {nodedown, _Node}, State) -> {State, ok}; apply_(Meta, #purge_nodes{nodes = Nodes}, State0) -> - {State, Effects} = lists:foldl(fun(Node, {S, E}) -> + {State1, Effects} = lists:foldl(fun(Node, {S, E}) -> purge_node(Meta, Node, S, E) end, {State0, []}, Nodes), + State = State1#?STATE{ + ingress_bytes_by_node = + maps:without(Nodes, State1#?STATE.ingress_bytes_by_node)}, {State, ok, Effects}; apply_(Meta, #update_config{config = #{} = Conf}, @@ -967,8 +969,7 @@ credit_reply_resend_effect(#?STATE{waiting_consumers = Waiting, end, [], maps:merge(Consumers, maps:from_list(Waiting))). convert_v8_to_v9(#{} = _Meta, StateV8) -> - State = StateV8, - State. + erlang:append_element(StateV8, #{}). purge_node(Meta, Node, State, Effects) -> lists:foldl(fun(Pid, {S0, E0}) -> @@ -1171,7 +1172,8 @@ overview(#?STATE{consumers = Cons, reclaimable_bytes_count => ReclaimableBytes, smallest_raft_index => smallest_raft_index(State), num_active_priorities => NumActivePriorities, - messages_by_priority => Detail + messages_by_priority => Detail, + ingress_bytes_by_node => State#?STATE.ingress_bytes_by_node }, DlxOverview = dlx_overview(DlxState), maps:merge(maps:merge(Overview, DlxOverview), SacOverview). @@ -1205,7 +1207,13 @@ which_module(7) -> rabbit_fifo_v7; which_module(8) -> rabbit_fifo_v8; which_module(9) -> ?MODULE. --define(AUX, aux_v4). +-define(AUX, aux_v5). +-define(DEFAULT_INGRESS_DECAY_MS, 60_000). + +-record(ingress_aux, + {last_totals = #{} :: #{node() | undefined => non_neg_integer()}, + estimators = #{} :: #{node() | undefined => ra_li:state()}, + decay_ms = ?DEFAULT_INGRESS_DECAY_MS :: pos_integer()}). -record(snapshot, {index :: ra:index(), timestamp :: milliseconds(), @@ -1220,7 +1228,8 @@ which_module(9) -> ?MODULE. gc = #aux_gc{} :: #aux_gc{}, tick_pid :: undefined | pid(), cache = #{} :: map(), - last_checkpoint :: tuple() | #snapshot{} + last_checkpoint :: tuple() | #snapshot{}, + ingress = #ingress_aux{} :: #ingress_aux{} }). init_aux(Name) when is_atom(Name) -> @@ -1234,11 +1243,38 @@ init_aux(Name) when is_atom(Name) -> ?SNAP_MIN_RECLAIMABLE_B}), Range = max(1, SnapMinReclaimable - ?SNAP_MIN_RECLAIMABLE_LOW_B), MinReclaimable = ?SNAP_MIN_RECLAIMABLE_LOW_B + rand:uniform(Range), + DecayMs = persistent_term:get(rabbit_fifo_ingress_decay_ms, + ?DEFAULT_INGRESS_DECAY_MS), #?AUX{name = Name, last_checkpoint = #snapshot{index = 0, timestamp = erlang:system_time(millisecond), messages_total = 0, - min_reclaimable = MinReclaimable}}. + min_reclaimable = MinReclaimable}, + ingress = #ingress_aux{decay_ms = DecayMs}}. + +update_ingress(Overview, Nodes, #ingress_aux{last_totals = LastTotals, + estimators = Estimators0, + decay_ms = DecayMs} = Ingress) -> + NewTotals = maps:get(ingress_bytes_by_node, Overview, #{}), + Ts = erlang:monotonic_time(millisecond), + Estimators1 = + maps:fold(fun(Node, NewTotal, Est) -> + Delta = NewTotal - maps:get(Node, LastTotals, 0), + Li0 = maps:get(Node, Est, ra_li:new(DecayMs)), + Li1 = ra_li:update(Delta, Ts, Li0), + Est#{Node => Li1} + end, Estimators0, NewTotals), + ActiveNodes = sets:from_list(Nodes, [{version, 2}]), + Estimators = maps:filter(fun(Node, _) -> + Node =:= undefined orelse + sets:is_element(Node, ActiveNodes) + end, Estimators1), + Ingress#ingress_aux{last_totals = NewTotals, + estimators = Estimators}. + +compute_ingress_rates(#ingress_aux{estimators = Estimators}) -> + Ts = erlang:monotonic_time(millisecond), + maps:map(fun(_Node, Li) -> ra_li:rate(Ts, Li) end, Estimators). handle_aux(RaftState, Tag, Cmd, AuxPre, RaAux) when element(1, AuxPre) == aux_v2 orelse @@ -1257,6 +1293,19 @@ handle_aux(RaftState, Tag, Cmd, AuxV3, RaAux) last_checkpoint = element(8, AuxV3) }, handle_aux(RaftState, Tag, Cmd, AuxV4, RaAux); +handle_aux(RaftState, Tag, Cmd, AuxV4, RaAux) + when element(1, AuxV4) == aux_v4 -> + DecayMs = persistent_term:get(rabbit_fifo_ingress_decay_ms, + ?DEFAULT_INGRESS_DECAY_MS), + AuxV5 = #?AUX{name = element(2, AuxV4), + last_decorators_state = element(3, AuxV4), + last_consumer_timeout = element(4, AuxV4), + gc = element(5, AuxV4), + tick_pid = element(6, AuxV4), + cache = element(7, AuxV4), + last_checkpoint = element(8, AuxV4), + ingress = #ingress_aux{decay_ms = DecayMs}}, + handle_aux(RaftState, Tag, Cmd, AuxV5, RaAux); handle_aux(leader, cast, eval, #?AUX{last_decorators_state = LastDec, last_consumer_timeout = LastConTimeout0, @@ -1349,22 +1398,25 @@ handle_aux(_RaftState, cast, {#return{msg_ids = MsgIds, %% for returns with a delivery limit set we can just return as before {no_reply, Aux0, RaAux0, [{append, Ret, {notify, Corr, Pid}}]} end; -handle_aux(leader, _, {handle_tick, [QName, Overview0, Nodes]}, - #?AUX{tick_pid = Pid} = Aux, RaAux) -> - Overview = Overview0#{members_info => ra_aux:members_info(RaAux)}, - NewPid = - case process_is_alive(Pid) of - false -> - %% No active TICK pid - %% this function spawns and returns the tick process pid - rabbit_quorum_queue:handle_tick(QName, Overview, Nodes); - true -> - %% Active TICK pid, do nothing - Pid - end, - %% TODO: check consumer timeouts - {no_reply, Aux#?AUX{tick_pid = NewPid}, RaAux, []}; +handle_aux(RaftState, _, {handle_tick, [QName, Overview0, Nodes]}, + #?AUX{tick_pid = Pid, ingress = Ingress0} = Aux, RaAux) -> + Overview = Overview0#{members_info => ra_aux:members_info(RaAux)}, + Ingress = update_ingress(Overview0, Nodes, Ingress0), + Aux1 = Aux#?AUX{ingress = Ingress}, + case RaftState of + leader -> + NewPid = + case process_is_alive(Pid) of + false -> + rabbit_quorum_queue:handle_tick(QName, Overview, Nodes); + true -> + Pid + end, + {no_reply, Aux1#?AUX{tick_pid = NewPid}, RaAux, []}; + _ -> + {no_reply, Aux1, RaAux, []} + end; handle_aux(_, _, {get_checked_out, ConsumerKey, MsgIds}, Aux0, RaAux0) -> #?STATE{cfg = #cfg{}, consumers = Consumers} = ra_aux:machine_state(RaAux0), @@ -1461,6 +1513,10 @@ handle_aux(leader, _, {dlx, setup}, Aux, RaAux) -> handle_aux(_, _, {dlx, teardown, Pid}, Aux, RaAux) -> terminate_dlx_worker(Pid), {no_reply, Aux, RaAux}; +handle_aux(_, {call, _From}, get_ingress_rates, + #?AUX{ingress = Ingress} = Aux, RaAux) -> + Rates = compute_ingress_rates(Ingress), + {reply, {ok, Rates}, Aux, RaAux}; handle_aux(_, _, Unhandled, Aux, RaAux) -> #?STATE{cfg = #cfg{resource = QR}} = ra_aux:machine_state(RaAux), ?LOG_DEBUG("~ts: rabbit_fifo: unhandled aux command ~P", @@ -1908,12 +1964,24 @@ maybe_return_all(#{system_time := Ts} = Meta, ConsumerKey, Effects} end. +node_of(undefined) -> undefined; +node_of(Pid) when is_pid(Pid) -> node(Pid). + +bump_ingress(Node, Size, Map) -> + maps:update_with(Node, fun(V) -> V + Size end, Size, Map). + apply_enqueue(#{index := RaftIdx, system_time := Ts} = Meta, From, Seq, RawMsg, Size, State0) -> case maybe_enqueue(RaftIdx, Ts, From, Seq, RawMsg, Size, [], State0) of {ok, State1, Effects1} -> - checkout(Meta, State0, State1, Effects1); + {MetaSize, BodySize} = Size, + TotalSize = MetaSize + BodySize, + IngressByNode = State1#?STATE.ingress_bytes_by_node, + State2 = State1#?STATE{ + ingress_bytes_by_node = + bump_ingress(node_of(From), TotalSize, IngressByNode)}, + checkout(Meta, State0, State2, Effects1); {out_of_sequence, State, Effects} -> {State, not_enqueued, Effects}; {duplicate, State, Effects} -> diff --git a/deps/rabbit/src/rabbit_fifo.hrl b/deps/rabbit/src/rabbit_fifo.hrl index d466ad717ab7..c757ee678183 100644 --- a/deps/rabbit/src/rabbit_fifo.hrl +++ b/deps/rabbit/src/rabbit_fifo.hrl @@ -295,7 +295,12 @@ last_active :: option(non_neg_integer()), msg_cache :: option({ra:index(), raw_msg()}), %% delayed retry messages awaiting redelivery - delayed = #delayed{} :: #delayed{} + delayed = #delayed{} :: #delayed{}, + %% bytes enqueued, accumulated per originating node since the + %% creation of this state machine. Monotonically non-decreasing + %% on every node (under leader replication). Used by leader + %% migration tooling to estimate per-node ingress. + ingress_bytes_by_node = #{} :: #{node() | undefined => non_neg_integer()} }). -type config() :: #{name := atom(), diff --git a/deps/rabbit/test/quorum_queue_SUITE.erl b/deps/rabbit/test/quorum_queue_SUITE.erl index 8d60fc4528c6..2b226b349ca7 100644 --- a/deps/rabbit/test/quorum_queue_SUITE.erl +++ b/deps/rabbit/test/quorum_queue_SUITE.erl @@ -4741,7 +4741,8 @@ purge(Config) -> {'queue.purge_ok', 2} = amqp_channel:call(Ch, #'queue.purge'{queue = QQ}), - ?assertEqual([0], dirty_query([Server], RaName, fun rabbit_fifo:query_messages_total/1)). + ?assertMatch(#{num_messages := 0}, machine_overview({RaName, Server})), + ok. peek(Config) -> [Server | _] = rabbit_ct_broker_helpers:get_node_configs(Config, nodename), diff --git a/deps/rabbit/test/rabbit_fifo_SUITE.erl b/deps/rabbit/test/rabbit_fifo_SUITE.erl index 949e61da76e4..38d4f47c4e48 100644 --- a/deps/rabbit/test/rabbit_fifo_SUITE.erl +++ b/deps/rabbit/test/rabbit_fifo_SUITE.erl @@ -4097,7 +4097,7 @@ machine_version_test(Config) -> consumers = #{3 := #consumer{cfg = #consumer_cfg{priority = 0}}}, service_queue = S, messages = Msgs}, ok, - [_|_]} = apply(meta(Config, Idx), {machine_version, 7, 8}, S1), + [_|_]} = apply(meta(Config, Idx), {machine_version, 7, 9}, S1), ?assertEqual(1, rabbit_fifo_pq:len(Msgs)), ?assert(priority_queue:is_queue(S)), @@ -4125,16 +4125,16 @@ machine_version_waiting_consumer_test(Config) -> #consumer_cfg{priority = 0}}}, service_queue = S, messages = Msgs}, ok, _} = apply(meta(Config, Idx), - {machine_version, 7, 8}, S1), + {machine_version, 7, 9}, S1), %% validate message conversion to lqueue ?assertEqual(0, rabbit_fifo_pq:len(Msgs)), ?assert(priority_queue:is_queue(S)), ?assertEqual(1, priority_queue:len(S)), ok. -convert_v7_to_v8_test(Config) -> +convert_v7_to_v9_test(Config) -> ConfigV7 = [{machine_version, 7} | Config], - ConfigV8 = [{machine_version, 8} | Config], + ConfigV9 = [{machine_version, 9} | Config], EPid = test_util:fake_pid(node()), Pid1 = test_util:fake_pid(node()), @@ -4159,7 +4159,7 @@ convert_v7_to_v8_test(Config) -> {StateV7, _} = run_log(rabbit_fifo_v7, ConfigV7, Init, Entries, fun (_) -> true end), {#rabbit_fifo{consumers = Consumers}, ok, _} = - apply(meta(ConfigV8, ?LINE), {machine_version, 7, 8}, StateV7), + apply(meta(ConfigV9, ?LINE), {machine_version, 7, 9}, StateV7), ?assertMatch(#consumer{status = {suspected_down, up}}, maps:get(Cid1, Consumers)), @@ -4994,6 +4994,36 @@ query_single_active_consumer_consumer_info_test(Config) -> ok. +%% Ingress tracking tests + +ingress_bytes_by_node_accumulates_on_enqueue_test(Config) -> + S0 = test_init(ingress_accumulates), + Msg = mk_mc(<<"hello">>), + Pid = self(), + Enq = make_enqueue(Pid, 1, Msg), + {S1, _, _} = apply(meta(Config, 1, 0, {notify, 1, Pid}), Enq, S0), + Ingress = S1#rabbit_fifo.ingress_bytes_by_node, + ?assert(maps:get(node(Pid), Ingress, 0) > 0), + ok. + +ingress_bytes_by_node_survives_snapshot_test(Config) -> + S0 = test_init(ingress_snapshot), + Msg = mk_mc(<<"test">>), + Pid = self(), + Enq = make_enqueue(Pid, 1, Msg), + {S1, _, _} = apply(meta(Config, 1, 0, {notify, 1, Pid}), Enq, S0), + Ingress = S1#rabbit_fifo.ingress_bytes_by_node, + ?assert(maps:size(Ingress) > 0), + %% Simulate snapshot/restore via serialization round-trip + S2 = binary_to_term(term_to_binary(S1)), + Ingress2 = S2#rabbit_fifo.ingress_bytes_by_node, + ?assertEqual(Ingress, Ingress2), + ok. + + +%% Ingress tracking tests end + + %% Utility init(Conf) -> rabbit_fifo:init(Conf). diff --git a/deps/rabbit/test/rabbit_fifo_dlx_integration_SUITE.erl b/deps/rabbit/test/rabbit_fifo_dlx_integration_SUITE.erl index a4320fb5d738..4c50b87224f2 100644 --- a/deps/rabbit/test/rabbit_fifo_dlx_integration_SUITE.erl +++ b/deps/rabbit/test/rabbit_fifo_dlx_integration_SUITE.erl @@ -655,8 +655,8 @@ reject_publish_max_length_target_quorum_queue(Config) -> amqp_channel:call(Ch, #'basic.get'{queue = TargetQ}), 30000) end || N <- lists:seq(1,4)], - eventually(?_assertMatch([{0, _}], - dirty_query([Server], RaName, fun rabbit_fifo:query_stat_dlx/1)), 500, 10), + eventually(?_assertMatch(#{num_discarded := 0}, + machine_overview({RaName, Server}))), ?assertEqual(4, counted(messages_dead_lettered_expired_total, Config)), eventually(?_assertEqual(4, counted(messages_dead_lettered_confirmed_total, Config))). @@ -706,8 +706,8 @@ reject_publish_down_target_quorum_queue(Config) -> sets:add_element(Msg, S) end, sets:new([{version, 2}]), lists:seq(1, 50)), ?assertEqual(50, sets:size(Received)), - eventually(?_assertMatch([{0, _}], - dirty_query([Server], RaName, fun rabbit_fifo:query_stat_dlx/1)), 500, 10), + eventually(?_assertMatch(#{num_discarded := 0}, + machine_overview({RaName, Server}))), ?assertEqual(50, counted(messages_dead_lettered_expired_total, Config)), eventually(?_assertEqual(50, counted(messages_dead_lettered_confirmed_total, Config))). @@ -999,3 +999,11 @@ counted(Metric, Config) -> metric(Metric, Counters) -> Metrics = maps:get(#{queue_type => rabbit_quorum_queue, dead_letter_strategy => at_least_once}, Counters), maps:get(Metric, Metrics). + +machine_overview(ServerId) when is_tuple(ServerId) -> + case ra:member_overview(ServerId) of + {ok, #{machine := Mac}, _} -> + Mac; + Err -> + Err + end. diff --git a/deps/rabbit/test/rabbit_fifo_prop_SUITE.erl b/deps/rabbit/test/rabbit_fifo_prop_SUITE.erl index a11ae45244a0..d7e9ed0e24f7 100644 --- a/deps/rabbit/test/rabbit_fifo_prop_SUITE.erl +++ b/deps/rabbit/test/rabbit_fifo_prop_SUITE.erl @@ -10,7 +10,7 @@ -include_lib("rabbit_common/include/rabbit_framing.hrl"). -include_lib("rabbit_common/include/rabbit.hrl"). --define(MACHINE_VERSION, 8). +-define(MACHINE_VERSION, 9). %%%=================================================================== %%% Common Test callbacks @@ -84,7 +84,8 @@ all_tests() -> dlx_09, single_active_ordering_02, two_nodes_same_otp_version, - two_nodes_different_otp_version + two_nodes_different_otp_version, + ingress_bytes_by_node_accumulation ]. groups() -> @@ -1126,6 +1127,29 @@ is_same_otp_version(ConfigOrNode) -> ct:pal("Our CT node runs OTP ~s, other node runs OTP ~s", [OurOTP, OtherOTP]), OurOTP =:= OtherOTP. +ingress_bytes_by_node_accumulation(_Config) -> + Size = 500, + run_proper( + fun () -> + InitConf = config(?FUNCTION_NAME, undefined, undefined, false, undefined), + ?FORALL(O, ?LET(Ops, log_gen_different_nodes(Size), expand(Ops, InitConf)), + begin + Indexes = lists:seq(1, length(O)), + Entries = lists:zip(Indexes, O), + InitState = test_init(InitConf), + {State1, _Effs1} = run_log(InitState, Entries), + IngressByNode = State1#rabbit_fifo.ingress_bytes_by_node, + %% Verify the map is properly populated + IsMap = is_map(IngressByNode), + %% Verify all values are non-negative + ValidValues = maps:fold( + fun(_, V, Acc) -> + Acc andalso is_integer(V) andalso V >= 0 + end, true, IngressByNode), + IsMap andalso ValidValues + end) + end, [], Size). + two_nodes(Node) -> Size = 100, run_proper( @@ -1511,7 +1535,7 @@ different_nodes_prop(Node, Conf, Commands) -> Entries = lists:zip(Indexes, Commands), InitState = test_init(Conf), Fun = fun(_) -> true end, - MachineVersion = 8, + MachineVersion = rabbit_fifo:version(), {State1, _Effs1} = run_log(InitState, Entries, Fun, MachineVersion), {State2, _Effs2} = erpc:call(Node, ?MODULE, run_log, @@ -1598,8 +1622,8 @@ valid_simple_prefetch(_, _, _, _, _) -> true. upgrade_prop(Conf, Commands) -> - FromVersion = 7, - ToVersion = 8, + FromVersion = 8, + ToVersion = 9, FromMod = rabbit_fifo:which_module(FromVersion), ToMod = rabbit_fifo:which_module(ToVersion), Indexes = lists:seq(1, length(Commands)), From eada6ff580cd81952691bc885716fba5e8c903ec Mon Sep 17 00:00:00 2001 From: Karl Nilsson Date: Wed, 1 Jul 2026 10:32:30 +0100 Subject: [PATCH 3/7] Wire message deferral tokens through AMQP 1.0 FLOW to quorum queues Consumers can now request previously deferred (parked) messages by listing their tokens under the rabbitmq:deferred-tokens FLOW property. rabbit_queue_type gains an optional assign_deferred/4 callback, implemented by quorum queues via rabbit_fifo_client. In rabbit_fifo, a deferral token is only honoured when the client also sets an explicit x-opt-delivery-time; the delayed-retry path never creates a deferred entry, keeping tokens purely client-driven. Advertise support via the rabbitmq:deferred-tokens link capability and document the protocol in deps/rabbit/DEFERRED.md. --- deps/rabbit/DEFERRED.md | 112 +++++++++++ deps/rabbit/src/rabbit_amqp_session.erl | 46 ++++- deps/rabbit/src/rabbit_fifo.erl | 75 ++++--- deps/rabbit/src/rabbit_fifo.hrl | 7 +- deps/rabbit/src/rabbit_fifo_client.erl | 18 +- deps/rabbit/src/rabbit_queue_type.erl | 26 +++ deps/rabbit/src/rabbit_quorum_queue.erl | 17 +- deps/rabbit/test/amqp_client_SUITE.erl | 256 ++++++++++++++++++++++++ deps/rabbit/test/rabbit_fifo_SUITE.erl | 71 +++++-- 9 files changed, 548 insertions(+), 80 deletions(-) create mode 100644 deps/rabbit/DEFERRED.md diff --git a/deps/rabbit/DEFERRED.md b/deps/rabbit/DEFERRED.md new file mode 100644 index 000000000000..5ed85a8017a3 --- /dev/null +++ b/deps/rabbit/DEFERRED.md @@ -0,0 +1,112 @@ +# Message Deferral for Quorum Queues + +## Overview + +Message deferral lets an AMQP 1.0 consumer return a message to a quorum queue in a +*parked* state under a client-chosen token, then later pull that specific message back +on demand — without waiting for a delayed-retry timer or competing with other consumers. + +This is useful for workload scheduling patterns where a consumer wants to decide at +receive time that a message should not be processed immediately, assign it a token +for later retrieval, and then fetch it explicitly when ready. + +## Constraints + +- **Quorum queues only.** Classic queues and streams do not support deferral tokens. + Clients can detect support by checking for `rabbitmq:deferral-tokens` in the + `offered-capabilities` field of the ATTACH response. +- **`x-opt-delivery-time` is required.** A deferral token alone does not park a + message. Both `x-opt-deferral-token` and `x-opt-delivery-time` must be present in + the MODIFIED outcome's `message-annotations`. A message returned via the + `delayed-retry` queue configuration never creates a deferred entry, even if a token + is present. +- **Tokens must be of AMQP type `utf8`.** This applies both to `x-opt-deferral-token` + in the MODIFIED outcome's `message-annotations` and to each element of the + `rabbitmq:deferral-tokens` array in a FLOW frame's `properties`. Any other AMQP + type (e.g. `binary`, `symbol`) is rejected with `amqp:invalid-field`. +- **Credit must cover the matched messages.** When requesting deferred messages via a + FLOW frame, the link credit granted in that same FLOW must be at least as large as + the number of messages the submitted tokens resolve to (a single token may resolve + to more than one message; see below). If credit is exhausted by normal message + delivery before the deferred assignment runs, no deferred messages are delivered + for that FLOW. + +## Protocol Usage + +### 1. Attach a consuming link and verify capability + +Attach a link to a quorum queue source. Inspect the `offered-capabilities` array in +the ATTACH response for the symbol `rabbitmq:deferral-tokens`. If absent, the queue +does not support deferral. + +### 2. Receive a message + +The broker delivers a message to the link via a TRANSFER frame. Note the +`delivery-tag` for the settlement step. + +### 3. Park the message with a deferral token + +Send a DISPOSITION frame settling the delivery with a MODIFIED outcome. Include both +annotations in the `message-annotations` map of the MODIFIED outcome: + +- `x-opt-deferral-token` (symbol key, utf8 value) — a client-chosen opaque + identifier for this parked message. The same token may be assigned to more than + one message, e.g. by settling a range of deliveries (`first =/= last`) with a + single MODIFIED outcome. Retrieving such a token returns all messages parked + under it, oldest first. +- `x-opt-delivery-time` (symbol key, timestamp value in milliseconds since the Unix + epoch) — the earliest time at which the message becomes eligible for normal + timer-based redelivery. The message is held until this time unless explicitly + retrieved earlier via `assign_deferred`. + +Example (pseudocode): + +``` +DISPOSITION { + role = receiver, + first = , + settled = true, + state = MODIFIED { + delivery-failed = false, + undeliverable-here = false, + message-annotations = { + x-opt-deferral-token: "job-42-retry-1", + x-opt-delivery-time: 1780000000000 + } + } +} +``` + +### 4. Retrieve the message by token + +When ready to process the parked message, send a FLOW frame on the same consuming +link. Grant enough link credit to receive the deferred messages and include the tokens +in the `properties` map of the FLOW frame under the key `rabbitmq:deferral-tokens` as +an array of utf8 values. + +Example (pseudocode): + +``` +FLOW { + handle = , + delivery-count = , + link-credit = 1, + properties = { + rabbitmq:deferral-tokens: ["job-42-retry-1"] + } +} +``` + +The broker looks up each token in the parked message set and delivers any matches +directly to this link as normal TRANSFER frames. Tokens that are not found (because +the message was already expired by its delivery time and requeued normally, or the +token was never issued) produce no delivery — the client is responsible for tracking +which tokens it expects. + +## Relationship to `delayed-retry` + +Deferral tokens and the queue-level `x-delayed-retry-*` configuration are +complementary but independent. A message returned with `x-opt-delivery-time` follows +the deferral path. A message returned without `x-opt-delivery-time` follows the +delayed-retry path if the queue is configured for it. The two paths do not interact: +delayed-retry messages cannot be retrieved by token. diff --git a/deps/rabbit/src/rabbit_amqp_session.erl b/deps/rabbit/src/rabbit_amqp_session.erl index be9a2dbd4fc9..65ce34d0f3eb 100644 --- a/deps/rabbit/src/rabbit_amqp_session.erl +++ b/deps/rabbit/src/rabbit_amqp_session.erl @@ -2148,7 +2148,14 @@ settle_op_from_outcome(#'v1_0.modified'{delivery_failed = DelFailed, {map, KVList} -> Anns1 = lists:map( %% "all symbolic keys except those beginning with "x-" are reserved." [3.2.10] - fun({{symbol, <<"x-", _/binary>> = K}, V}) -> + fun({{symbol, <<"x-opt-deferral-token">> = K}, {utf8, _} = V}) -> + {K, unwrap_simple_type(V)}; + ({{symbol, <<"x-opt-deferral-token">>}, Other}) -> + protocol_error( + ?V_1_0_AMQP_ERROR_INVALID_FIELD, + "x-opt-deferral-token must be of AMQP type utf8, got: ~tp", + [Other]); + ({{symbol, <<"x-", _/binary>> = K}, V}) -> {K, unwrap_simple_type(V)} end, KVList), maps:from_list(Anns1) @@ -3162,7 +3169,8 @@ handle_outgoing_link_flow_control( delivery_count = MaybeDeliveryCountRcv, link_credit = ?UINT(LinkCreditRcv), drain = Drain0, - echo = Echo0}, + echo = Echo0, + properties = FlowProps}, #state{outgoing_links = OutgoingLinks, queue_states = QStates0 } = State0) -> @@ -3186,14 +3194,21 @@ handle_outgoing_link_flow_control( credit = CappedCredit, drain = Drain}, at_least_one_credit_req_in_flight = true}, - {ok, QStates, Actions} = rabbit_queue_type:credit( - QName, Ctag, - QFC#queue_flow_ctl.delivery_count, - CappedCredit, Drain, QStates0), + {ok, QStates1, Actions0} = rabbit_queue_type:credit( + QName, Ctag, + QFC#queue_flow_ctl.delivery_count, + CappedCredit, Drain, QStates0), + {ok, QStates, Actions1} = case parse_deferred_tokens(FlowProps) of + [] -> + {ok, QStates1, []}; + Tokens -> + rabbit_queue_type:assign_deferred( + QName, Ctag, Tokens, QStates1) + end, State = State0#state{ queue_states = QStates, outgoing_links = OutgoingLinks#{HandleInt := Link}}, - handle_queue_actions(Actions, State); + handle_queue_actions(Actions0 ++ Actions1, State); true -> %% A credit request is currently in-flight. Let's first process its reply %% before sending the next request. This ensures our outgoing_pending @@ -3203,6 +3218,8 @@ handle_outgoing_link_flow_control( %% to reason about. Therefore, we stash the new request. If there is already a %% stashed request, we replace it because the latest flow control state from the %% client applies. + %% Deferral tokens are not stashed; they target the current state of the delayed + %% set and must not be replayed when the stashed credit request is processed. Link = Link0#outgoing_link{ stashed_credit_req = #credit_req{ delivery_count = DeliveryCountRcv, @@ -3212,6 +3229,21 @@ handle_outgoing_link_flow_control( State0#state{outgoing_links = OutgoingLinks#{HandleInt := Link}} end. +parse_deferred_tokens(undefined) -> + []; +parse_deferred_tokens({map, KVList}) -> + case lists:keyfind({symbol, <<"rabbitmq:deferral-tokens">>}, 1, KVList) of + {{symbol, <<"rabbitmq:deferral-tokens">>}, {array, utf8, Elems}} -> + [T || {utf8, T} <- Elems]; + false -> + []; + {{symbol, <<"rabbitmq:deferral-tokens">>}, Other} -> + protocol_error( + ?V_1_0_AMQP_ERROR_INVALID_FIELD, + "rabbitmq:deferral-tokens must be an array of AMQP type utf8, got: ~tp", + [Other]) + end. + delivery_count_rcv(?UINT(DeliveryCount)) -> DeliveryCount; delivery_count_rcv(undefined) -> diff --git a/deps/rabbit/src/rabbit_fifo.erl b/deps/rabbit/src/rabbit_fifo.erl index 49573ca2bddc..5b6cefb2dcd2 100644 --- a/deps/rabbit/src/rabbit_fifo.erl +++ b/deps/rabbit/src/rabbit_fifo.erl @@ -2404,24 +2404,20 @@ return_one(#{system_time := Ts} = Meta, MsgId, end. should_delay(DeliveryFailed, DelayedRetry, Ts, Header, Anns) -> - %% First check for explicit x-opt-delivery-time annotation. - %% This takes precedence over delayed_retry configuration. - DeferralToken = case Anns of - #{<<"x-opt-deferral-token">> := Token} - when is_binary(Token) -> - Token; - _ -> - undefined - end, case Anns of #{<<"x-opt-delivery-time">> := DeliveryTime} when is_integer(DeliveryTime), DeliveryTime > Ts -> + %% Deferral tokens are only honoured when the client explicitly + %% sets a delivery time; the delayed-retry path never creates a + %% deferred entry so that tokens remain a purely client-driven + %% mechanism. + DeferralToken = maps:get(<<"x-opt-deferral-token">>, Anns, undefined), {true, DeliveryTime, DeferralToken}; _ -> case should_delay0(DeliveryFailed, DelayedRetry, Ts, Header) of {true, ReadyAt} -> - {true, ReadyAt, DeferralToken}; + {true, ReadyAt, undefined}; false -> false end @@ -2617,8 +2613,7 @@ take_next_delayed(Ts, #delayed{next = {ReadyAt, Idx, Msg}, {?TUPLE(NextReadyAt, NextIdx), V} = gb_trees:smallest(Tree), {NextReadyAt, NextIdx, V} end, - %% Remove any deferral token that maps to this key - Deferred = maps:filter(fun(_Token, K) -> K =/= Key end, Deferred0), + Deferred = remove_deferred_key(Key, Deferred0), Delayed = #delayed{tree = Tree, next = Next, deferred = Deferred}, {Msg, Delayed}; take_next_delayed(_Ts, #delayed{}) -> @@ -2661,12 +2656,22 @@ take_delayed_for_retry(N, Ts, #delayed{tree = Tree0, ?TUPLE(ReadyAt, Idx) = NextKey, {ReadyAt, Idx, NextMsg} end, - %% Remove any deferral token that maps to this key - Deferred = maps:filter(fun(_Token, K) -> K =/= Key end, Deferred0), + Deferred = remove_deferred_key(Key, Deferred0), Delayed = #delayed{tree = Tree, next = Next, deferred = Deferred}, take_delayed_for_retry(N - 1, Ts, Delayed, [Msg | Acc]) end. +%% Drop a single tree key from every token's key list, dropping the token +%% entirely once its last key is removed. +remove_deferred_key(Key, Deferred0) -> + maps:filtermap( + fun(_Token, Keys) -> + case lists:delete(Key, Keys) of + [] -> false; + Remaining -> {true, Remaining} + end + end, Deferred0). + take_deferred(Tokens, Delayed) -> take_deferred(Tokens, Delayed, [], []). @@ -2676,20 +2681,30 @@ take_deferred([Token | Rest], #delayed{tree = Tree0, deferred = Deferred0} = Delayed0, MsgsAcc, NotFoundAcc) -> case maps:take(Token, Deferred0) of - {Key, Deferred1} -> - case gb_trees:lookup(Key, Tree0) of - {value, Msg} -> - Tree = gb_trees:delete(Key, Tree0), - Next = update_delayed_next(Tree), - Delayed = Delayed0#delayed{tree = Tree, - next = Next, - deferred = Deferred1}, - take_deferred(Rest, Delayed, [Msg | MsgsAcc], NotFoundAcc); - none -> - %% Key in deferred map but not in tree - inconsistent, - %% treat as not found and clean up - Delayed = Delayed0#delayed{deferred = Deferred1}, - take_deferred(Rest, Delayed, MsgsAcc, [Token | NotFoundAcc]) + {Keys, Deferred1} -> + %% Keys were prepended as they were parked, so reverse to + %% resolve them oldest first. + {Tree, MsgsAcc1, Found} = + lists:foldl( + fun(Key, {TreeAcc, Acc, FoundAcc}) -> + case gb_trees:lookup(Key, TreeAcc) of + {value, Msg} -> + {gb_trees:delete(Key, TreeAcc), [Msg | Acc], true}; + none -> + %% Key in deferred map but not in tree - + %% inconsistent, skip and clean up + {TreeAcc, Acc, FoundAcc} + end + end, {Tree0, MsgsAcc, false}, lists:reverse(Keys)), + Next = update_delayed_next(Tree), + Delayed = Delayed0#delayed{tree = Tree, + next = Next, + deferred = Deferred1}, + case Found of + true -> + take_deferred(Rest, Delayed, MsgsAcc1, NotFoundAcc); + false -> + take_deferred(Rest, Delayed, MsgsAcc1, [Token | NotFoundAcc]) end; error -> take_deferred(Rest, Delayed0, MsgsAcc, [Token | NotFoundAcc]) @@ -2766,7 +2781,9 @@ delayed_in(ReadyAt, Idx, Msg, DeferralToken, #delayed{tree = Tree0, undefined -> Deferred0; _ -> - Deferred0#{DeferralToken => Key} + maps:update_with(DeferralToken, + fun(Keys) -> [Key | Keys] end, + [Key], Deferred0) end, #delayed{tree = Tree, next = Next, deferred = Deferred}. diff --git a/deps/rabbit/src/rabbit_fifo.hrl b/deps/rabbit/src/rabbit_fifo.hrl index c757ee678183..0d25baeacf58 100644 --- a/deps/rabbit/src/rabbit_fifo.hrl +++ b/deps/rabbit/src/rabbit_fifo.hrl @@ -202,8 +202,11 @@ {tree = gb_trees:empty() :: gb_trees:tree(delayed_key(), msg()), %% Cached smallest entry for O(1) readiness check in take_next_msg next = undefined :: option({milliseconds(), ra:index(), msg()}), - %% Map from deferral token to tree key for direct message lookup - deferred = #{} :: #{deferral_token() => delayed_key()}}). + %% Map from deferral token to the tree keys of all messages parked + %% under it. A single token may address more than one message, + %% e.g. when a ranged DISPOSITION settles several deliveries with + %% the same annotations. + deferred = #{} :: #{deferral_token() => [delayed_key()]}}). -record(enqueuer, {next_seqno = 1 :: msg_seqno(), diff --git a/deps/rabbit/src/rabbit_fifo_client.erl b/deps/rabbit/src/rabbit_fifo_client.erl index 2eafad411b51..42b4a314e02d 100644 --- a/deps/rabbit/src/rabbit_fifo_client.erl +++ b/deps/rabbit/src/rabbit_fifo_client.erl @@ -527,26 +527,16 @@ retry_delayed(Server, Mode) when Mode =:= all orelse %% @param ConsumerTag the tag uniquely identifying the consumer. %% @param Tokens list of deferral tokens (from x-opt-deferral-token). %% @param State the {@module} state -%% @returns `{ok, NumAssigned}' on success, -%% `{partial, NumAssigned, NotFoundTokens}' if some tokens not found, -%% `{error, Reason}' on failure. +%% @returns `{state(), actions()}' - matched messages arrive as normal deliveries. -spec assign_deferred(rabbit_types:ctag(), [binary()], state()) -> - {{ok, non_neg_integer()} | - {partial, non_neg_integer(), [binary()]} | - {error, term()}, - state()}. + {state(), rabbit_queue_type:actions()}. assign_deferred(ConsumerTag, [_|_] = Tokens, State0) -> ConsumerKey = consumer_key(ConsumerTag, State0), ServerId = pick_server(State0), Cmd = rabbit_fifo:make_delayed({assign_deferred, ConsumerKey, Tokens}), - case ra:process_command(ServerId, Cmd, ?COMMAND_TIMEOUT) of - {ok, Reply, _} -> - {Reply, State0}; - Err -> - {Err, State0} - end; + {send_command(ServerId, undefined, Cmd, normal, State0), []}; assign_deferred(_ConsumerTag, [], State) -> - {{ok, 0}, State}. + {State, []}. -spec pending_size(state()) -> non_neg_integer(). pending_size(#state{pending = Pend}) -> diff --git a/deps/rabbit/src/rabbit_queue_type.erl b/deps/rabbit/src/rabbit_queue_type.erl index 422ee0fa5eef..8ebd3f988657 100644 --- a/deps/rabbit/src/rabbit_queue_type.erl +++ b/deps/rabbit/src/rabbit_queue_type.erl @@ -52,6 +52,7 @@ deliver/4, settle/5, credit/6, + assign_deferred/4, dequeue/5, fold_state/3, is_policy_applicable/2, @@ -248,6 +249,15 @@ Drain :: boolean(), queue_state()) -> {queue_state(), actions()}. +-optional_callbacks([assign_deferred/4]). + +%% Assign messages that were deferred (via x-opt-deferral-token) back to a +%% consumer. Only supported by quorum queues. Matched messages arrive as +%% normal deliveries; the call is fire-and-forget. +-callback assign_deferred(queue_name(), rabbit_types:ctag(), Tokens :: [binary()], + queue_state()) -> + {queue_state(), actions()}. + -callback dequeue(amqqueue:amqqueue(), NoAck :: boolean(), LimiterPid :: pid(), rabbit_types:ctag(), Timeout :: non_neg_integer() | infinity | undefined, @@ -764,6 +774,22 @@ credit(QName, CTag, DeliveryCount, Credit, Drain, Ctxs) -> Drain, State0), {ok, set_ctx(QName, Ctx#ctx{state = State}, Ctxs), Actions}. +-spec assign_deferred(queue_name(), rabbit_types:ctag(), [binary()], state()) -> + {ok, state(), actions()}. +assign_deferred(QName, CTag, Tokens, Ctxs) -> + case get_ctx(QName, Ctxs, undefined) of + undefined -> + {ok, Ctxs, []}; + #ctx{state = State0, module = Mod} = Ctx -> + case erlang:function_exported(Mod, assign_deferred, 4) of + true -> + {State, Actions} = Mod:assign_deferred(QName, CTag, Tokens, State0), + {ok, set_ctx(QName, Ctx#ctx{state = State}, Ctxs), Actions}; + false -> + {ok, Ctxs, []} + end + end. + -spec dequeue(amqqueue:amqqueue(), boolean(), pid(), rabbit_types:ctag(), state()) -> {ok, non_neg_integer(), term(), state()} | diff --git a/deps/rabbit/src/rabbit_quorum_queue.erl b/deps/rabbit/src/rabbit_quorum_queue.erl index df4d287bfebf..635854842aee 100644 --- a/deps/rabbit/src/rabbit_quorum_queue.erl +++ b/deps/rabbit/src/rabbit_quorum_queue.erl @@ -632,7 +632,8 @@ capabilities() -> rebalance_module => ?MODULE, can_redeliver => true, is_replicable => true, - distribution_modes => [move] + distribution_modes => [move], + amqp_link_capabilities => [<<"rabbitmq:deferral-tokens">>] }. rpc_delete_metrics(QName) -> @@ -1410,15 +1411,11 @@ retry_delayed(Q, Mode) when ?is_amqqueue(Q) -> Server = amqqueue:get_pid(Q), rabbit_fifo_client:retry_delayed(Server, Mode). --spec assign_deferred(rabbit_types:ctag(), [binary()], - rabbit_fifo_client:state(), - amqqueue:amqqueue()) -> - {{ok, non_neg_integer()} | - {partial, non_neg_integer(), [binary()]} | - {error, term()}, - rabbit_fifo_client:state()}. -assign_deferred(CTag, Tokens, QState, _Q) -> - rabbit_fifo_client:assign_deferred(CTag, Tokens, QState). +-spec assign_deferred(rabbit_amqqueue:name(), rabbit_types:ctag(), [binary()], + rabbit_fifo_client:state()) -> + {rabbit_fifo_client:state(), rabbit_queue_type:actions()}. +assign_deferred(_QName, CTag, Tokens, QState) -> + rabbit_fifo_client:assign_deferred(quorum_ctag(CTag), Tokens, QState). cleanup_data_dir() -> Names = [begin diff --git a/deps/rabbit/test/amqp_client_SUITE.erl b/deps/rabbit/test/amqp_client_SUITE.erl index 8166645d100c..56bf8f0c1080 100644 --- a/deps/rabbit/test/amqp_client_SUITE.erl +++ b/deps/rabbit/test/amqp_client_SUITE.erl @@ -146,6 +146,10 @@ groups() -> modified_classic_queue, modified_quorum_queue, modified_quorum_queue_delivery_time, + modified_quorum_queue_deferral_token, + modified_quorum_queue_deferral_token_ranged_disposition, + modified_quorum_queue_deferral_token_invalid_annotation_type, + modified_quorum_queue_deferral_token_invalid_flow_property_type, modified_dead_letter_headers_exchange, modified_dead_letter_history, dead_letter_headers_exchange, @@ -845,6 +849,258 @@ modified_quorum_queue_delivery_time(Config) -> rabbitmq_amqp_client:delete_queue(LinkPair, QName)), ok = close(Init). +%% Test that a message returned with both x-opt-deferral-token and +%% x-opt-delivery-time in the modified outcome is parked in a quorum +%% queue, and can be pulled back explicitly by token via a FLOW frame's +%% rabbitmq:deferral-tokens property, ahead of its delivery time. +%% See deps/rabbit/DEFERRED.md. +modified_quorum_queue_deferral_token(Config) -> + QName = atom_to_binary(?FUNCTION_NAME), + {_, Session, LinkPair} = Init = init(Config), + {ok, #{type := <<"quorum">>}} = rabbitmq_amqp_client:declare_queue( + LinkPair, QName, + #{arguments => #{<<"x-queue-type">> => {utf8, <<"quorum">>}}}), + Address = rabbitmq_amqp_address:queue(QName), + {ok, Sender} = amqp10_client:attach_sender_link(Session, <<"sender">>, Address), + ok = wait_for_credit(Sender), + ok = amqp10_client:send_msg(Sender, amqp10_msg:new(<<"t1">>, <<"m1">>, true)), + ok = amqp10_client:send_msg(Sender, amqp10_msg:new(<<"t2">>, <<"m2">>, true)), + + {ok, Receiver} = amqp10_client:attach_receiver_link( + Session, <<"receiver">>, Address, unsettled), + OutputHandle = element(4, Receiver), + FarFuture = erlang:system_time(millisecond) + 3_600_000, + + %% Park m1 under Token1 with a delivery time far in the future. + {ok, M1} = amqp10_client:get_msg(Receiver), + ?assertEqual([<<"m1">>], amqp10_msg:body(M1)), + Token1 = <<"token-1">>, + ok = amqp10_client:settle_msg( + Receiver, M1, + {modified, false, false, + #{<<"x-opt-deferral-token">> => Token1, + <<"x-opt-delivery-time">> => FarFuture}}), + + %% m2 is delivered next since m1 is parked, not merely delayed. + {ok, M2} = amqp10_client:get_msg(Receiver), + ?assertEqual([<<"m2">>], amqp10_msg:body(M2)), + ok = amqp10_client:settle_msg(Receiver, M2, accepted), + + %% m1 does not come back on its own before its delivery time elapses. + ok = amqp10_client:flow_link_credit(Receiver, 1, never, false), + receive {amqp10_msg, Receiver, _} -> ct:fail(message_should_be_parked) + after 500 -> ok + end, + + %% Retrieve m1 explicitly by its deferral token. + ok = amqp10_client_session:flow_link( + Session, OutputHandle, + #'v1_0.flow'{ + link_credit = {uint, 1}, + properties = {map, [{{symbol, <<"rabbitmq:deferral-tokens">>}, + {array, utf8, [{utf8, Token1}]}}]}}, + never), + M1b = receive {amqp10_msg, Receiver, Msg1} -> Msg1 + after 5000 -> ct:fail({missing_msg, ?LINE}) + end, + ?assertEqual([<<"m1">>], amqp10_msg:body(M1b)), + ok = amqp10_client:settle_msg(Receiver, M1b, accepted), + + %% An unknown token yields no delivery. + ok = amqp10_client_session:flow_link( + Session, OutputHandle, + #'v1_0.flow'{ + link_credit = {uint, 1}, + properties = {map, [{{symbol, <<"rabbitmq:deferral-tokens">>}, + {array, utf8, [{utf8, <<"unknown-token">>}]}}]}}, + never), + receive {amqp10_msg, Receiver, _} -> ct:fail(unexpected_message) + after 500 -> ok + end, + + %% Park m3 and m4 under two more tokens. + ok = amqp10_client:send_msg(Sender, amqp10_msg:new(<<"t3">>, <<"m3">>, true)), + ok = amqp10_client:send_msg(Sender, amqp10_msg:new(<<"t4">>, <<"m4">>, true)), + ok = amqp10_client:detach_link(Sender), + {ok, M3} = amqp10_client:get_msg(Receiver), + ?assertEqual([<<"m3">>], amqp10_msg:body(M3)), + Token3 = <<"token-3">>, + ok = amqp10_client:settle_msg( + Receiver, M3, + {modified, false, false, + #{<<"x-opt-deferral-token">> => Token3, + <<"x-opt-delivery-time">> => FarFuture}}), + {ok, M4} = amqp10_client:get_msg(Receiver), + ?assertEqual([<<"m4">>], amqp10_msg:body(M4)), + Token4 = <<"token-4">>, + ok = amqp10_client:settle_msg( + Receiver, M4, + {modified, false, false, + #{<<"x-opt-deferral-token">> => Token4, + <<"x-opt-delivery-time">> => FarFuture}}), + + %% Requesting both tokens with credit for only one is all-or-nothing: + %% neither message is delivered. + ok = amqp10_client_session:flow_link( + Session, OutputHandle, + #'v1_0.flow'{ + link_credit = {uint, 1}, + properties = {map, [{{symbol, <<"rabbitmq:deferral-tokens">>}, + {array, utf8, [{utf8, Token3}, {utf8, Token4}]}}]}}, + never), + receive {amqp10_msg, Receiver, _} -> ct:fail(unexpected_message) + after 500 -> ok + end, + + %% Granting enough credit for both tokens delivers both messages. + ok = amqp10_client_session:flow_link( + Session, OutputHandle, + #'v1_0.flow'{ + link_credit = {uint, 2}, + properties = {map, [{{symbol, <<"rabbitmq:deferral-tokens">>}, + {array, utf8, [{utf8, Token3}, {utf8, Token4}]}}]}}, + never), + ReceivedBodies = [begin + M = receive {amqp10_msg, Receiver, Msg} -> Msg + after 5000 -> ct:fail({missing_msg, ?LINE}) + end, + ok = amqp10_client:settle_msg(Receiver, M, accepted), + amqp10_msg:body(M) + end || _ <- [1, 2]], + ?assertEqual([[<<"m3">>], [<<"m4">>]], lists:sort(ReceivedBodies)), + + ok = amqp10_client:detach_link(Receiver), + ?assertMatch({ok, #{message_count := 0}}, + rabbitmq_amqp_client:delete_queue(LinkPair, QName)), + ok = close(Init). + +%% Test that a single deferral token can address more than one message: +%% settling a range of deliveries (first =/= last) with one MODIFIED outcome +%% assigns the same token to every message in the range, and requesting +%% that token later returns all of them, oldest first. +modified_quorum_queue_deferral_token_ranged_disposition(Config) -> + QName = atom_to_binary(?FUNCTION_NAME), + {_, Session, LinkPair} = Init = init(Config), + {ok, #{type := <<"quorum">>}} = rabbitmq_amqp_client:declare_queue( + LinkPair, QName, + #{arguments => #{<<"x-queue-type">> => {utf8, <<"quorum">>}}}), + Address = rabbitmq_amqp_address:queue(QName), + {ok, Sender} = amqp10_client:attach_sender_link(Session, <<"sender">>, Address), + ok = wait_for_credit(Sender), + ok = amqp10_client:send_msg(Sender, amqp10_msg:new(<<"t1">>, <<"m1">>, true)), + ok = amqp10_client:send_msg(Sender, amqp10_msg:new(<<"t2">>, <<"m2">>, true)), + ok = amqp10_client:detach_link(Sender), + + {ok, Receiver} = amqp10_client:attach_receiver_link( + Session, <<"receiver">>, Address, unsettled), + OutputHandle = element(4, Receiver), + FarFuture = erlang:system_time(millisecond) + 3_600_000, + + ok = amqp10_client:flow_link_credit(Receiver, 2, never, false), + [M1, M2] = receive_messages(Receiver, 2), + ?assertEqual([<<"m1">>], amqp10_msg:body(M1)), + ?assertEqual([<<"m2">>], amqp10_msg:body(M2)), + + %% Settle both deliveries in a single ranged DISPOSITION with one + %% shared deferral token. + Token = <<"shared-token">>, + ok = amqp10_client_session:disposition( + Receiver, + amqp10_msg:delivery_id(M1), + amqp10_msg:delivery_id(M2), + true, + {modified, false, false, + #{<<"x-opt-deferral-token">> => Token, + <<"x-opt-delivery-time">> => FarFuture}}), + + %% Neither message comes back on its own before the delivery time elapses. + ok = amqp10_client:flow_link_credit(Receiver, 1, never, false), + receive {amqp10_msg, Receiver, _} -> ct:fail(message_should_be_parked) + after 500 -> ok + end, + + %% Requesting the shared token returns both messages, oldest first. + ok = amqp10_client_session:flow_link( + Session, OutputHandle, + #'v1_0.flow'{ + link_credit = {uint, 2}, + properties = {map, [{{symbol, <<"rabbitmq:deferral-tokens">>}, + {array, utf8, [{utf8, Token}]}}]}}, + never), + [M1b, M2b] = receive_messages(Receiver, 2), + ?assertEqual([<<"m1">>], amqp10_msg:body(M1b)), + ?assertEqual([<<"m2">>], amqp10_msg:body(M2b)), + ok = amqp10_client:settle_msg(Receiver, M1b, accepted), + ok = amqp10_client:settle_msg(Receiver, M2b, accepted), + + ok = amqp10_client:detach_link(Receiver), + ?assertMatch({ok, #{message_count := 0}}, + rabbitmq_amqp_client:delete_queue(LinkPair, QName)), + ok = close(Init). + +%% Test that a x-opt-deferral-token of any AMQP type other than utf8 is +%% rejected instead of silently accepted. +modified_quorum_queue_deferral_token_invalid_annotation_type(Config) -> + QName = atom_to_binary(?FUNCTION_NAME), + {Connection, Session, LinkPair} = init(Config), + {ok, #{type := <<"quorum">>}} = rabbitmq_amqp_client:declare_queue( + LinkPair, QName, + #{arguments => #{<<"x-queue-type">> => {utf8, <<"quorum">>}}}), + Address = rabbitmq_amqp_address:queue(QName), + {ok, Sender} = amqp10_client:attach_sender_link(Session, <<"sender">>, Address), + ok = wait_for_credit(Sender), + ok = amqp10_client:send_msg(Sender, amqp10_msg:new(<<"t1">>, <<"m1">>, true)), + ok = amqp10_client:detach_link(Sender), + + {ok, Receiver} = amqp10_client:attach_receiver_link( + Session, <<"receiver">>, Address, unsettled), + {ok, M1} = amqp10_client:get_msg(Receiver), + FarFuture = erlang:system_time(millisecond) + 3_600_000, + %% Explicitly force the wire type to binary instead of utf8. + ok = amqp10_client:settle_msg( + Receiver, M1, + {modified, false, false, + #{<<"x-opt-deferral-token">> => {binary, <<"token-1">>}, + <<"x-opt-delivery-time">> => FarFuture}}), + receive + {amqp10_event, + {session, Session, + {ended, #'v1_0.error'{condition = ?V_1_0_AMQP_ERROR_INVALID_FIELD}}}} -> ok + after 30000 -> flush(missing_ended), + ct:fail("did not receive expected error") + end, + ok = close_connection_sync(Connection). + +%% Test that a rabbitmq:deferral-tokens FLOW property whose array elements +%% are of any AMQP type other than utf8 is rejected instead of silently +%% ignored. +modified_quorum_queue_deferral_token_invalid_flow_property_type(Config) -> + QName = atom_to_binary(?FUNCTION_NAME), + {Connection, Session, LinkPair} = init(Config), + {ok, #{type := <<"quorum">>}} = rabbitmq_amqp_client:declare_queue( + LinkPair, QName, + #{arguments => #{<<"x-queue-type">> => {utf8, <<"quorum">>}}}), + Address = rabbitmq_amqp_address:queue(QName), + {ok, Receiver} = amqp10_client:attach_receiver_link( + Session, <<"receiver">>, Address, unsettled), + OutputHandle = element(4, Receiver), + %% Explicitly force the array element wire type to binary instead of utf8. + ok = amqp10_client_session:flow_link( + Session, OutputHandle, + #'v1_0.flow'{ + link_credit = {uint, 1}, + properties = {map, [{{symbol, <<"rabbitmq:deferral-tokens">>}, + {array, binary, [{binary, <<"token-1">>}]}}]}}, + never), + receive + {amqp10_event, + {session, Session, + {ended, #'v1_0.error'{condition = ?V_1_0_AMQP_ERROR_INVALID_FIELD}}}} -> ok + after 30000 -> flush(missing_ended), + ct:fail("did not receive expected error") + end, + ok = close_connection_sync(Connection). + %% Test that a message can be routed based on the message-annotations %% provided in the modified outcome as described in %% https://rabbitmq.com/blog/2024/10/11/modified-outcome diff --git a/deps/rabbit/test/rabbit_fifo_SUITE.erl b/deps/rabbit/test/rabbit_fifo_SUITE.erl index 38d4f47c4e48..5067047b5087 100644 --- a/deps/rabbit/test/rabbit_fifo_SUITE.erl +++ b/deps/rabbit/test/rabbit_fifo_SUITE.erl @@ -3692,10 +3692,10 @@ delayed_retry_empty_command_test(Config) -> ok. delayed_assign_deferred_test(Config) -> - %% Test assigning deferred messages by token to a specific consumer + %% Test assigning deferred messages by token to a specific consumer. + %% A deferral token only parks a message when x-opt-delivery-time is also set. Conf = #{name => ?FUNCTION_NAME, - queue_resource => rabbit_misc:r("/", queue, ?FUNCTION_NAME_B), - delayed_retry => {all, 10000, 10000}}, + queue_resource => rabbit_misc:r("/", queue, ?FUNCTION_NAME_B)}, State0 = init(Conf), Cid = {?FUNCTION_NAME_B, self()}, %% Enqueue a message @@ -3703,9 +3703,10 @@ delayed_assign_deferred_test(Config) -> %% Checkout the message {State2, #{key := CKey, next_msg_id := MsgId}, _} = checkout_ts(Config, 2, 100, Cid, {auto, {simple_prefetch, 2}}, State1), - %% Return with deferral token via modify + %% Return with deferral token and explicit delivery time via modify Token1 = <<"token-1">>, - Anns = #{<<"x-opt-deferral-token">> => Token1}, + Anns = #{<<"x-opt-deferral-token">> => Token1, + <<"x-opt-delivery-time">> => 10000}, Modify = rabbit_fifo:make_modify(CKey, [MsgId], false, false, Anns), {State3, _, _} = apply(meta(Config, 3, 100), Modify, State2), ?assertMatch(#{num_delayed_messages := 1, @@ -3717,11 +3718,44 @@ delayed_assign_deferred_test(Config) -> num_checked_out := 1}, rabbit_fifo:overview(State4)), ok. +delayed_assign_deferred_multiple_messages_test(Config) -> + %% A single deferral token may be assigned to more than one message, + %% e.g. as a result of a ranged DISPOSITION (first =/= last) settling + %% several deliveries with the same MODIFIED annotations. + Conf = #{name => ?FUNCTION_NAME, + queue_resource => rabbit_misc:r("/", queue, ?FUNCTION_NAME_B)}, + State0 = init(Conf), + Cid = {?FUNCTION_NAME_B, self()}, + %% Enqueue 2 messages. + {State1, _} = enq(Config, 1, 1, msg1, State0), + {State2, _} = enq(Config, 2, 2, msg2, State1), + %% Checkout both messages with prefetch 2. + {State3, #{key := CKey, next_msg_id := MsgId}, _} = + checkout_ts(Config, 3, 100, Cid, {auto, {simple_prefetch, 2}}, State2), + %% Return both in a single modify command under the same token, as a + %% ranged DISPOSITION would. + Token = <<"token-shared">>, + Anns = #{<<"x-opt-deferral-token">> => Token, + <<"x-opt-delivery-time">> => 10000}, + Modify = rabbit_fifo:make_modify(CKey, [MsgId, MsgId + 1], false, false, Anns), + {State4, _, _} = apply(meta(Config, 4, 100), Modify, State3), + ?assertMatch(#{num_delayed_messages := 2, + num_checked_out := 0}, rabbit_fifo:overview(State4)), + %% Assigning the shared token back to the consumer returns both messages. + Cmd = rabbit_fifo:make_delayed({assign_deferred, CKey, [Token]}), + {State5, {ok, 2}, _} = apply(meta(Config, 5, 100), Cmd, State4), + ?assertMatch(#{num_delayed_messages := 0, + num_checked_out := 2}, rabbit_fifo:overview(State5)), + %% The token is now fully consumed: requesting it again finds nothing. + Cmd2 = rabbit_fifo:make_delayed({assign_deferred, CKey, [Token]}), + {_State6, {partial, 0, [Token]}, _} = apply(meta(Config, 6, 100), Cmd2, State5), + ok. + delayed_assign_deferred_insufficient_credit_test(Config) -> - %% Test that assign_deferred fails when consumer has insufficient credit + %% Test that assign_deferred fails when consumer has insufficient credit. + %% Both tokens use explicit x-opt-delivery-time so they are parked. Conf = #{name => ?FUNCTION_NAME, - queue_resource => rabbit_misc:r("/", queue, ?FUNCTION_NAME_B), - delayed_retry => {all, 10000, 10000}}, + queue_resource => rabbit_misc:r("/", queue, ?FUNCTION_NAME_B)}, State0 = init(Conf), Cid = {?FUNCTION_NAME_B, self()}, %% Enqueue 2 messages @@ -3730,11 +3764,13 @@ delayed_assign_deferred_insufficient_credit_test(Config) -> %% Checkout both messages with prefetch 2 {State3, #{key := CKey, next_msg_id := MsgId}, _} = checkout_ts(Config, 3, 100, Cid, {auto, {simple_prefetch, 2}}, State2), - %% Return both with deferral tokens + %% Return both with deferral tokens and explicit delivery times Token1 = <<"token-1">>, Token2 = <<"token-2">>, - Anns1 = #{<<"x-opt-deferral-token">> => Token1}, - Anns2 = #{<<"x-opt-deferral-token">> => Token2}, + Anns1 = #{<<"x-opt-deferral-token">> => Token1, + <<"x-opt-delivery-time">> => 10000}, + Anns2 = #{<<"x-opt-deferral-token">> => Token2, + <<"x-opt-delivery-time">> => 10000}, Mod1 = rabbit_fifo:make_modify(CKey, [MsgId], false, false, Anns1), {State4, _, _} = apply(meta(Config, 4, 100), Mod1, State3), Mod2 = rabbit_fifo:make_modify(CKey, [MsgId+1], false, false, Anns2), @@ -3750,10 +3786,9 @@ delayed_assign_deferred_insufficient_credit_test(Config) -> ok. delayed_assign_deferred_not_found_test(Config) -> - %% Test that assign_deferred returns partial when some tokens not found + %% Test that assign_deferred returns partial when some tokens not found. Conf = #{name => ?FUNCTION_NAME, - queue_resource => rabbit_misc:r("/", queue, ?FUNCTION_NAME_B), - delayed_retry => {all, 10000, 10000}}, + queue_resource => rabbit_misc:r("/", queue, ?FUNCTION_NAME_B)}, State0 = init(Conf), Cid = {?FUNCTION_NAME_B, self()}, %% Enqueue a message @@ -3761,9 +3796,10 @@ delayed_assign_deferred_not_found_test(Config) -> %% Checkout the message {State2, #{key := CKey, next_msg_id := MsgId}, _} = checkout_ts(Config, 2, 100, Cid, {auto, {simple_prefetch, 2}}, State1), - %% Return with deferral token + %% Return with deferral token and explicit delivery time Token1 = <<"token-1">>, - Anns = #{<<"x-opt-deferral-token">> => Token1}, + Anns = #{<<"x-opt-deferral-token">> => Token1, + <<"x-opt-delivery-time">> => 10000}, Mod = rabbit_fifo:make_modify(CKey, [MsgId], false, false, Anns), {State3, _, _} = apply(meta(Config, 3, 100), Mod, State2), %% Try to assign with one valid and one invalid token @@ -3778,8 +3814,7 @@ delayed_assign_deferred_not_found_test(Config) -> delayed_assign_deferred_consumer_not_found_test(Config) -> %% Test that assign_deferred fails when consumer doesn't exist Conf = #{name => ?FUNCTION_NAME, - queue_resource => rabbit_misc:r("/", queue, ?FUNCTION_NAME_B), - delayed_retry => {all, 10000, 10000}}, + queue_resource => rabbit_misc:r("/", queue, ?FUNCTION_NAME_B)}, State0 = init(Conf), %% Try to assign to non-existent consumer {_State, {error, consumer_not_found}, []} = From a9d50c39a0373541ad9395316d0e25de8c154b7d Mon Sep 17 00:00:00 2001 From: Karl Nilsson Date: Mon, 6 Jul 2026 13:52:24 +0100 Subject: [PATCH 4/7] QQ: annotate messages with the raft index they were written at Add an "x-opt-index" message annotation to every message read from a quorum queue - via basic.get, normal delivery, or dead-lettering - containing the raft log index the message currently occupies. This lets a client identify exactly which point in the log a message came from, e.g. to correlate it with the queue's own operator-facing raft index. --- deps/rabbit/src/rabbit_fifo.erl | 49 ++++++++----- deps/rabbit/test/amqp_client_SUITE.erl | 26 +++++++ deps/rabbit/test/quorum_queue_SUITE.erl | 94 +++++++++++++++++++++++-- 3 files changed, 148 insertions(+), 21 deletions(-) diff --git a/deps/rabbit/src/rabbit_fifo.erl b/deps/rabbit/src/rabbit_fifo.erl index 5b6cefb2dcd2..0999487c836b 100644 --- a/deps/rabbit/src/rabbit_fifo.erl +++ b/deps/rabbit/src/rabbit_fifo.erl @@ -1431,7 +1431,8 @@ handle_aux(_, _, {get_checked_out, ConsumerKey, MsgIds}, Aux0, RaAux0) -> %% crashed and the message got removed case ra_aux:log_fetch(Idx, S0) of {{_Term, _Meta, Cmd}, S} -> - {S, [{MsgId, {Header, get_msg_from_cmd(Cmd)}} | Acc]}; + RawMsg = annotate_index(Idx, get_msg_from_cmd(Cmd)), + {S, [{MsgId, {Header, RawMsg}} | Acc]}; {undefined, S} -> {S, Acc} end @@ -2332,10 +2333,9 @@ get_header(Key, Header) annotate_msg(Header, Msg0) -> case mc:is(Msg0) of true when is_map(Header) -> - Msg1 = maps:fold(fun (K, V, Acc) -> - mc:set_annotation(K, V, Acc) - end, Msg0, maps:get(anns, Header, #{})), - Msg = case Header of + Msg1 = maps:fold(fun mc:set_annotation/3, Msg0, + maps:get(anns, Header, #{})), + Msg2 = case Header of #{acquired_count := AcqCount} -> mc:set_annotation(acquired_count, AcqCount, Msg1); _ -> @@ -2343,14 +2343,26 @@ annotate_msg(Header, Msg0) -> end, case Header of #{delivery_count := DelCount} -> - mc:set_annotation(delivery_count, DelCount, Msg); + mc:set_annotation(delivery_count, DelCount, Msg2); _ -> - Msg + Msg2 end; _ -> Msg0 end. +%% Stamps the raft index a message currently occupies onto the message +%% itself. Applied server-side (never over the wire) so that clients - +%% including ones running an older release that predates this annotation - +%% don't need to understand any new wire format to benefit from it. +annotate_index(Idx, Msg0) -> + case mc:is(Msg0) of + true -> + mc:set_annotation(<<"x-opt-index">>, Idx, Msg0); + false -> + Msg0 + end. + return_one(#{system_time := Ts} = Meta, MsgId, ?C_MSG(Msg0), DeliveryFailed, Anns, #?STATE{returns = Returns, @@ -2799,19 +2811,18 @@ peek_next_msg(#?STATE{returns = Returns0, delivery_effect(ConsumerKey, [{MsgId, ?MSG(Idx, Header)}], #?STATE{msg_cache = {Idx, RawMsg}} = State) -> {CTag, CPid} = consumer_id(ConsumerKey, State), - {send_msg, CPid, {delivery, CTag, [{MsgId, {Header, RawMsg}}]}, + {send_msg, CPid, {delivery, CTag, [{MsgId, {Header, annotate_index(Idx, RawMsg)}}]}, ?DELIVERY_SEND_MSG_OPTS}; delivery_effect(ConsumerKey, [{MsgId, Msg}], #?STATE{msg_cache = {Idx, RawMsg}} = State) when is_integer(Msg) andalso ?PACKED_IDX(Msg) == Idx -> Header = get_msg_header(Msg), {CTag, CPid} = consumer_id(ConsumerKey, State), - {send_msg, CPid, {delivery, CTag, [{MsgId, {Header, RawMsg}}]}, + {send_msg, CPid, {delivery, CTag, [{MsgId, {Header, annotate_index(Idx, RawMsg)}}]}, ?DELIVERY_SEND_MSG_OPTS}; delivery_effect(ConsumerKey, Msgs, #?STATE{} = State) -> {CTag, CPid} = consumer_id(ConsumerKey, State), {RaftIdxs, _Num} = lists:foldr(fun ({_, Msg}, {Acc, N}) -> - {[get_msg_idx(Msg) | Acc], N+1} end, {[], 0}, Msgs), {log_ext, RaftIdxs, @@ -2838,9 +2849,10 @@ reply_log_effect(RaftIdx, MsgId, Header, Ready, From) -> fun ([]) -> []; ([Cmd]) -> + RawMsg = annotate_index(RaftIdx, get_msg_from_cmd(Cmd)), [{reply, From, {wrap_reply, - {dequeue, {MsgId, {Header, get_msg_from_cmd(Cmd)}}, Ready}}}] + {dequeue, {MsgId, {Header, RawMsg}}, Ready}}}] end}. checkout_one(#{system_time := Ts} = Meta, ExpiredMsg0, InitState0, Effects0) -> @@ -3932,7 +3944,8 @@ exec_read(Flru0, ReadPlan, Msgs) -> Idx = get_msg_idx(Msg), Header = get_msg_header(Msg), Cmd = maps:get(Idx, Entries), - {MsgId, {Header, get_msg_from_cmd(Cmd)}} + RawMsg = annotate_index(Idx, get_msg_from_cmd(Cmd)), + {MsgId, {Header, RawMsg}} end, Msgs), Flru} catch exit:{missing_key, _} when Flru0 =/= undefined -> @@ -4210,7 +4223,8 @@ discard_or_dead_letter(Msgs0, Reason, {at_most_once, {Mod, Fun, Args}}, State) - Cmd = maps:get(Idx, Lookup), %% ensure header delivery count %% is copied to the message container - annotate_msg(Hdr, rabbit_fifo:get_msg_from_cmd(Cmd)) + annotate_index(Idx, + annotate_msg(Hdr, rabbit_fifo:get_msg_from_cmd(Cmd))) end || Msg <- Msgs0], [{mod_call, Mod, Fun, Args ++ [Reason, Msgs]}] end}, @@ -4275,14 +4289,15 @@ dlx_delivery_effects(_CPid, []) -> []; dlx_delivery_effects(CPid, Msgs0) -> Msgs1 = lists:reverse(Msgs0), - {RaftIdxs, RsnIds} = lists:unzip(Msgs1), + {RaftIdxs, _RsnIds} = lists:unzip(Msgs1), [{log, RaftIdxs, fun(Log) -> Msgs = lists:zipwith( - fun (Cmd, {Reason, H, MsgId}) -> + fun (Cmd, {Idx, {Reason, H, MsgId}}) -> {MsgId, {Reason, - annotate_msg(H, rabbit_fifo:get_msg_from_cmd(Cmd))}} - end, Log, RsnIds), + annotate_index(Idx, + annotate_msg(H, rabbit_fifo:get_msg_from_cmd(Cmd)))}} + end, Log, Msgs1), [{send_msg, CPid, {dlx_event, self(), {dlx_delivery, Msgs}}, [cast]}] end}]. diff --git a/deps/rabbit/test/amqp_client_SUITE.erl b/deps/rabbit/test/amqp_client_SUITE.erl index 56bf8f0c1080..c50d994ac3ee 100644 --- a/deps/rabbit/test/amqp_client_SUITE.erl +++ b/deps/rabbit/test/amqp_client_SUITE.erl @@ -94,6 +94,7 @@ groups() -> sync_get_settled_classic_queue, sync_get_settled_quorum_queue, sync_get_settled_stream, + x_opt_index_quorum_queue, timed_get_classic_queue, timed_get_quorum_queue, timed_get_stream, @@ -2775,6 +2776,31 @@ sync_get_settled(QType, Config) -> timed_get_classic_queue(Config) -> timed_get(<<"classic">>, Config). +%% Every message carries the "x-opt-index" message annotation, the raft +%% index the message was written at. +x_opt_index_quorum_queue(Config) -> + QName = atom_to_binary(?FUNCTION_NAME), + {_Conn0, Ch} = rabbit_ct_client_helpers:open_connection_and_channel(Config), + #'queue.declare_ok'{} = amqp_channel:call( + Ch, #'queue.declare'{ + queue = QName, + durable = true, + arguments = [{<<"x-queue-type">>, longstr, <<"quorum">>}]}), + OpnConf = connection_config(Config), + {ok, Connection} = amqp10_client:open_connection(OpnConf), + {ok, Session} = amqp10_client:begin_session_sync(Connection), + Address = rabbitmq_amqp_address:queue(QName), + {ok, Sender} = amqp10_client:attach_sender_link(Session, <<"test-sender">>, Address), + ok = wait_for_credit(Sender), + ok = amqp10_client:send_msg(Sender, amqp10_msg:new(<<"tag1">>, <<"m1">>, true)), + {ok, Receiver} = amqp10_client:attach_receiver_link( + Session, <<"test-receiver">>, Address, unsettled), + {ok, Msg} = amqp10_client:get_msg(Receiver), + ?assertMatch(#{<<"x-opt-index">> := V} when is_integer(V), + amqp10_msg:message_annotations(Msg)), + ok = amqp10_client:settle_msg(Receiver, Msg, accepted), + ok = amqp10_client:close_connection(Connection). + timed_get_quorum_queue(Config) -> timed_get(<<"quorum">>, Config). diff --git a/deps/rabbit/test/quorum_queue_SUITE.erl b/deps/rabbit/test/quorum_queue_SUITE.erl index 2b226b349ca7..155870e97fdd 100644 --- a/deps/rabbit/test/quorum_queue_SUITE.erl +++ b/deps/rabbit/test/quorum_queue_SUITE.erl @@ -93,6 +93,7 @@ groups() -> consume_in_minority, get_in_minority, reject_after_leader_transfer, + x_opt_index_annotation, delete_members, rebalance, node_removal_is_not_quorum_critical, @@ -2496,6 +2497,74 @@ reject_after_leader_transfer(Config) -> requeue = true}), ok. +%% Every message carries the "x-opt-index" annotation, the raft index the +%% message was written at. Check that it is present for basic.get, for +%% deliveries to a consumer on a node with a member, and for deliveries to a +%% consumer on a node without a member (i.e. read remotely via log_ext). +x_opt_index_annotation(Config) -> + check_quorum_queues_v9_compat(Config), + + [Server0, Server1, Server2] = + rabbit_ct_broker_helpers:get_node_configs(Config, nodename), + QQ = ?config(queue_name, Config), + RaName = ra_name(QQ), + IndexHeader = <<"x-opt-index">>, + + Ch0 = rabbit_ct_client_helpers:open_channel(Config, Server0), + ?assertEqual({'queue.declare_ok', QQ, 0, 0}, + declare(Ch0, QQ, [{<<"x-queue-type">>, longstr, <<"quorum">>}])), + ?awaitMatch(3, count_online_nodes(Server0, <<"/">>, QQ), ?DEFAULT_AWAIT), + + %% basic.get + publish(Ch0, QQ), + wait_for_messages_ready([Server0, Server1, Server2], RaName, 1), + {#'basic.get_ok'{delivery_tag = GetTag}, + #amqp_msg{props = #'P_basic'{headers = GetHeaders}}} = + basic_get(Ch0, QQ, false, 10), + ?assertMatch({IndexHeader, long, _}, + rabbit_basic:header(IndexHeader, GetHeaders)), + ok = amqp_channel:call(Ch0, #'basic.ack'{delivery_tag = GetTag}), + + %% delivery to a consumer on a node that has a member + publish(Ch0, QQ), + wait_for_messages_ready([Server0, Server1, Server2], RaName, 1), + Ch1 = rabbit_ct_client_helpers:open_channel(Config, Server1), + subscribe(Ch1, QQ, false), + receive + {#'basic.deliver'{delivery_tag = LocalTag}, + #amqp_msg{props = #'P_basic'{headers = LocalHeaders}}} -> + ?assertMatch({IndexHeader, long, _}, + rabbit_basic:header(IndexHeader, LocalHeaders)), + amqp_channel:cast(Ch1, #'basic.ack'{delivery_tag = LocalTag}) + after ?TIMEOUT -> + flush(10), + exit(basic_deliver_timeout) + end, + amqp_channel:close(Ch1), + + %% remove Server2's member so it no longer holds a copy of the raft log + ?assertEqual(ok, + rpc:call(Server0, rabbit_queue_type_ra, delete_member, + [<<"/">>, QQ, Server2])), + ?awaitMatch(2, count_online_nodes(Server0, <<"/">>, QQ), ?DEFAULT_AWAIT), + + %% delivery to a consumer on a node that does not have a member + publish(Ch0, QQ), + wait_for_messages_ready([Server0, Server1], RaName, 1), + Ch2 = rabbit_ct_client_helpers:open_channel(Config, Server2), + subscribe(Ch2, QQ, false), + receive + {#'basic.deliver'{delivery_tag = RemoteTag}, + #amqp_msg{props = #'P_basic'{headers = RemoteHeaders}}} -> + ?assertMatch({IndexHeader, long, _}, + rabbit_basic:header(IndexHeader, RemoteHeaders)), + amqp_channel:cast(Ch2, #'basic.ack'{delivery_tag = RemoteTag}) + after ?TIMEOUT -> + flush(10), + exit(basic_deliver_timeout) + end, + ok. + delete_members(Config) -> [Server0, Server1, Server2] = rabbit_ct_broker_helpers:get_node_configs(Config, nodename), @@ -5507,8 +5576,15 @@ amqpl_headers(Config) -> #amqp_msg{props = #'P_basic'{headers = Headers2Received}} } = amqp_channel:call(Ch, #'basic.get'{queue = QQ}), - ?assertEqual(Headers1Sent, Headers1Received), - ?assertEqual(Headers2Sent, Headers2Received), + %% every message carries the raft index it was written at as the + %% "x-opt-index" header, even when the client didn't set any headers + case (min_mac_version(Config) >= 9) of + true -> + ?assertMatch([{<<"x-opt-index">>, long, _}], Headers1Received), + ?assertMatch([{<<"x-opt-index">>, long, _}], Headers2Received); + false -> + ok + end, ok = amqp_channel:cast(Ch, #'basic.ack'{delivery_tag = DeliveryTag, multiple = true}). @@ -6702,9 +6778,19 @@ basic_get(Ch, Q, NoAck, Attempt) -> end. check_quorum_queues_v8_compat(Config) -> + check_quorum_queues_vn_compat(8, Config). + +check_quorum_queues_v9_compat(Config) -> + check_quorum_queues_vn_compat(9, Config). + +min_mac_version(Config) -> Nodes = rabbit_ct_broker_helpers:get_node_configs(Config, nodename), - MacVer = lists:min([V || {ok, V} <- erpc:multicall(Nodes, rabbit_fifo, version, [])]), - case MacVer >= 8 of + MacVer = lists:min([V || {ok, V} <- + erpc:multicall(Nodes, rabbit_fifo, version, [])]), + MacVer. + +check_quorum_queues_vn_compat(Version, Config) -> + case min_mac_version(Config) >= Version of true -> ok; false -> From 9fe859e8c813de414d61cf83479792916a80be2c Mon Sep 17 00:00:00 2001 From: Karl Nilsson Date: Tue, 14 Jul 2026 14:14:20 +0100 Subject: [PATCH 5/7] QQ: record consumer metrics on the Ra leader, not the consumer's node consumer_created/updated/deleted rows were recorded on the consuming channel's node, unlike other queue metrics which live on the Ra leader. Cross-node updates went through an erpc:cast that silently dropped on a disconnected node, leaving stale rows with no retry. Move ownership to the leader: the checkout apply clause and state_enter(leader/follower, ...) now write/clear these rows locally, since mod_call effects only ever run on the current leader and must never crash it. Also fan out a one-off cleanup on the v9 machine version bump, to remove stale rows any node may have written under the old scheme. --- deps/rabbit/src/rabbit_fifo.erl | 106 ++++++++++++++++++--- deps/rabbit/src/rabbit_quorum_queue.erl | 119 ++++++++++++++---------- deps/rabbit/test/rabbit_fifo_SUITE.erl | 18 ++-- 3 files changed, 176 insertions(+), 67 deletions(-) diff --git a/deps/rabbit/src/rabbit_fifo.erl b/deps/rabbit/src/rabbit_fifo.erl index 0999487c836b..b025feddfe8d 100644 --- a/deps/rabbit/src/rabbit_fifo.erl +++ b/deps/rabbit/src/rabbit_fifo.erl @@ -260,7 +260,21 @@ update_config(Conf, State) -> apply(Meta, {machine_version, FromVersion, ToVersion}, VXState) -> %% machine version upgrades cant be done in apply_ State = convert(Meta, FromVersion, ToVersion, VXState), - {State, ok, [{aux, {dlx, setup}}]}; + Effects = case ToVersion of + 9 -> + %% v9 moved consumer metrics ownership from the + %% consuming channel's node to the current Ra leader; + %% clear out any stale rows earlier versions may have + %% left on other nodes and repopulate authoritatively + #?STATE{cfg = #cfg{resource = QRes}} = State, + [{mod_call, rabbit_quorum_queue, + consumer_metrics_v9_upgrade, + [QRes, consumer_metrics_rows(State)]}, + {aux, {dlx, setup}}]; + _ -> + [{aux, {dlx, setup}}] + end, + {State, ok, Effects}; apply(#{system_time := Ts} = Meta, Cmd, #?STATE{reclaimable_bytes = ReclBytes} = State) -> %% Add estimated reclaimable bytes. @@ -492,7 +506,23 @@ apply_(#{index := Idx} = Meta, {Consumer, State1} = update_consumer(Meta, ConsumerKey, ConsumerId, ConsumerMeta, Spec, Priority, Timeout, State0), - {State2, Effs} = activate_next_consumer(State1, []), + WasActive = is_active(ConsumerKey, State1), + {State2, Effs0} = activate_next_consumer(State1, []), + {Active, ActivityStatus} = consumer_activity_status(ConsumerKey, State2), + Effs = case {WasActive, Active} of + {false, true} -> + %% activate_next_consumer/2 has already emitted the + %% metrics effect as part of promoting this consumer + Effs0; + _ -> + %% record (or refresh) the consumer metrics on the + %% leader; needed here as activate_next_consumer/2 does + %% not emit anything for the competing consumer strategy + %% nor for a consumer that stays/becomes non-active + [consumer_metrics_effect(State2, Consumer, Active, + ActivityStatus) + | Effs0] + end, %% reply with a consumer infos Reply = {ok, consumer_info(ConsumerKey, Consumer, State2)}, @@ -1055,7 +1085,7 @@ state_enter(leader, waiting_consumers = WaitingConsumers, cfg = #cfg{resource = QRes, dead_letter_handler = DLH}, - dlx = DlxState}) -> + dlx = DlxState} = State) -> % return effects to monitor all current consumers and enqueuers Pids = lists:usort(maps:keys(Enqs) ++ [P || ?CONSUMER_PID(P) <- maps:values(Cons)] @@ -1064,7 +1094,12 @@ state_enter(leader, Nots = [{send_msg, P, leader_change, ra_event} || P <- Pids], NodeMons = lists:usort([{monitor, node, node(P)} || P <- Pids]), NotifyDecs = notify_decorators_startup(QRes), - Effects = Mons ++ Nots ++ NodeMons ++ [NotifyDecs], + %% (re-)populate the local consumer metrics table with the current + %% consumers of this queue: this node is now authoritative for them + ConsumerMetrics = {mod_call, rabbit_quorum_queue, + bulk_update_consumer_metrics, + [QRes, consumer_metrics_rows(State)]}, + Effects = Mons ++ Nots ++ NodeMons ++ [NotifyDecs, ConsumerMetrics], case DLH of at_least_once -> @@ -1073,6 +1108,18 @@ state_enter(leader, ok end, Effects; +state_enter(follower, #?STATE{cfg = #cfg{resource = QRes, + dead_letter_handler = DLH}, + dlx = DlxState}) -> + %% this node is no longer authoritative for this queue's consumer + %% metrics, drop the locally cached rows + case DLH of + at_least_once -> + ensure_worker_terminated(DlxState); + _ -> + ok + end, + [{mod_call, rabbit_quorum_queue, delete_local_consumer_metrics, [QRes]}]; state_enter(eol, #?STATE{enqueuers = Enqs, consumers = Cons0, waiting_consumers = WaitingConsumers0}) -> @@ -1787,21 +1834,56 @@ cancel_consumer(Meta, ConsumerKey, end end. -consumer_update_active_effects(#?STATE{cfg = #cfg{resource = QName}} = State, +%% is this consumer currently entitled to receive deliveries and what its +%% externally-visible activity status is, given the queue's consumer strategy +consumer_activity_status(_ConsumerKey, + #?STATE{cfg = #cfg{consumer_strategy = competing}}) -> + {true, up}; +consumer_activity_status(ConsumerKey, + #?STATE{cfg = #cfg{consumer_strategy = single_active}} = State) -> + case is_active(ConsumerKey, State) of + true -> {true, single_active}; + false -> {false, waiting} + end. + +consumer_metrics_effect(#?STATE{cfg = #cfg{resource = QName}}, + #consumer{cfg = #consumer_cfg{meta = Meta, + pid = CPid, + tag = CTag}}, + Active, ActivityStatus) -> + Ack = maps:get(ack, Meta, undefined), + Prefetch = maps:get(prefetch, Meta, undefined), + Args = maps:get(args, Meta, []), + {mod_call, rabbit_quorum_queue, update_consumer_handler, + [QName, {CTag, CPid}, false, Ack, Prefetch, Active, ActivityStatus, Args]}. + +%% metrics rows for every consumer of this queue, active or waiting, used to +%% (re-)populate the local consumer metrics table when this node becomes leader +consumer_metrics_rows(#?STATE{consumers = Cons, + waiting_consumers = WaitingConsumers} = State) -> + [consumer_metrics_row(ConsumerKey, Consumer, State) + || {ConsumerKey, Consumer} <- maps:to_list(Cons) ++ WaitingConsumers]. + +consumer_metrics_row(ConsumerKey, + #consumer{cfg = #consumer_cfg{pid = Pid, + tag = Tag, + meta = Meta}}, + State) -> + {Active, ActivityStatus} = consumer_activity_status(ConsumerKey, State), + {Pid, Tag, maps:get(ack, Meta, undefined), maps:get(prefetch, Meta, undefined), + Active, ActivityStatus, maps:get(args, Meta, [])}. + +consumer_update_active_effects(State, #consumer{cfg = #consumer_cfg{meta = Meta, pid = CPid, tag = CTag, credit_mode = Mode}, delivery_count = DeliveryCount, credit = Credit, - drain = Drain}, + drain = Drain} = Consumer, Active, ActivityStatus, Effects0) -> - Ack = maps:get(ack, Meta, undefined), - Prefetch = maps:get(prefetch, Meta, undefined), - Args = maps:get(args, Meta, []), - Effects = [{mod_call, rabbit_quorum_queue, update_consumer_handler, - [QName, {CTag, CPid}, false, Ack, Prefetch, - Active, ActivityStatus, Args]} | Effects0], + Effects = [consumer_metrics_effect(State, Consumer, Active, ActivityStatus) + | Effects0], case Mode of {credited, _} when map_get(link_state_properties, Meta) =:= true -> Avail = case Active of diff --git a/deps/rabbit/src/rabbit_quorum_queue.erl b/deps/rabbit/src/rabbit_quorum_queue.erl index 635854842aee..1a175ffc8c33 100644 --- a/deps/rabbit/src/rabbit_quorum_queue.erl +++ b/deps/rabbit/src/rabbit_quorum_queue.erl @@ -34,8 +34,10 @@ deliver/3]). -export([dead_letter_publish/5]). -export([cluster_state/1, status/1, status/2]). --export([update_consumer_handler/8, update_consumer/9]). --export([cancel_consumer_handler/2, cancel_consumer/3]). +-export([update_consumer_handler/8]). +-export([cancel_consumer_handler/2]). +-export([bulk_update_consumer_metrics/2, delete_local_consumer_metrics/1]). +-export([consumer_metrics_v9_upgrade/2]). -export([become_leader/2, handle_tick/3, spawn_deleter/1]). -export([rpc_delete_metrics/1, key_metrics_rpc/1]). @@ -465,29 +467,70 @@ single_active_consumer_on(Q) -> QArguments = amqqueue:get_arguments(Q), table_lookup(QArguments, <<"x-single-active-consumer">>, false). +%% these mod_call effects are always applied on the current Ra leader +%% (see ra_server_proc:handle_effect/5), which is where quorum queue +%% consumer metrics are recorded, so no cross-node dispatch is needed update_consumer_handler(QName, {ConsumerTag, ChPid}, Exclusive, AckRequired, Prefetch, Active, ActivityStatus, Args) -> - catch rabbit_queue_type_util:local_or_remote_handler(ChPid, ?MODULE, update_consumer, - [QName, ChPid, ConsumerTag, - Exclusive, AckRequired, - Prefetch, Active, - ActivityStatus, Args]). - -update_consumer(QName, ChPid, ConsumerTag, Exclusive, AckRequired, Prefetch, - Active, ActivityStatus, Args) -> catch rabbit_core_metrics:consumer_updated(ChPid, ConsumerTag, Exclusive, - AckRequired, - QName, Prefetch, Active, - ActivityStatus, Args). + AckRequired, QName, Prefetch, + Active, ActivityStatus, Args). cancel_consumer_handler(QName, {ConsumerTag, ChPid}) -> - catch rabbit_queue_type_util:local_or_remote_handler(ChPid, ?MODULE, cancel_consumer, - [QName, ChPid, ConsumerTag]). - -cancel_consumer(QName, ChPid, ConsumerTag) -> catch rabbit_core_metrics:consumer_deleted(ChPid, ConsumerTag, QName), rabbit_queue_type_util:notify_consumer_deleted(ChPid, ConsumerTag, QName, ?INTERNAL_USER). +%% called when this node becomes the Ra leader for QName: the previous +%% leader's local rows (if any) were already dropped when it stepped down +%% (see delete_local_consumer_metrics/1), but bulk-replace defensively so +%% this table is never a mix of two leader terms' data +%% +%% this mod_call effect (like all mod_call effects) is not protected by +%% ra_server_proc, so a crash here would take down the whole Ra server; +%% the consumer_created table may also legitimately not exist, e.g. in +%% rabbit_fifo_int_SUITE which runs bare ra clusters without core_metrics +bulk_update_consumer_metrics(QName, ConsumerMetrics) -> + catch bulk_update_consumer_metrics0(QName, ConsumerMetrics). + +bulk_update_consumer_metrics0(QName, ConsumerMetrics) -> + delete_local_consumer_metrics(QName), + %% quorum queues don't support exclusive consumers + [rabbit_core_metrics:consumer_created(ChPid, ConsumerTag, false, + AckRequired, QName, Prefetch, + Active, ActivityStatus, Args) + || {ChPid, ConsumerTag, AckRequired, Prefetch, Active, + ActivityStatus, Args} <- ConsumerMetrics], + ok. + +delete_local_consumer_metrics(QName) -> + catch ets:match_delete(consumer_created, + {{QName, '_', '_'}, '_', '_', '_', '_', '_', '_'}), + ok. + +%% one-off cleanup run when a queue's machine version crosses into v9: prior +%% versions wrote consumer_created from the consuming channel's node instead +%% of the Ra leader's, so any node in the cluster could be holding a stale +%% or duplicate row for this queue; wipe them all and let the current +%% leader (the only node this mod_call effect runs on) repopulate +%% authoritatively +%% +%% see bulk_update_consumer_metrics/2 for why this must never crash +consumer_metrics_v9_upgrade(QName, ConsumerMetrics) -> + catch consumer_metrics_v9_upgrade0(QName, ConsumerMetrics). + +consumer_metrics_v9_upgrade0(QName, ConsumerMetrics) -> + case rabbit_amqqueue:lookup(QName) of + {ok, Q} when ?is_amqqueue(Q) -> + Nodes = get_nodes(Q), + _ = [_ = erpc_call(Node, ?MODULE, delete_local_consumer_metrics, + [QName], ?RPC_TIMEOUT) + || Node <- Nodes, Node =/= node()], + ok; + _ -> + ok + end, + bulk_update_consumer_metrics(QName, ConsumerMetrics). + become_leader(_QName, _Name) -> %% noop now as we instead rely on the promt tick_timeout + repair to update %% the meta data store after a leader change @@ -1271,38 +1314,16 @@ consume(Q, Spec, QState0) when ?amqqueue_is_quorum(Q) -> %% in credit_reply link_state_properties => true}, case rabbit_fifo_client:checkout(ConsumerTag, Mode, ConsumerMeta, QState0) of - {ok, Infos, QState} -> - %% this info key was added in QQ v8 - IsSac = maps:get(consumer_strategy, Infos, competing) == single_active, - case IsSac orelse single_active_consumer_on(Q) of - true -> - ActivityStatus = case Infos of - #{is_active := true} -> - single_active; - _ -> - waiting - end, - rabbit_core_metrics:consumer_created(ChPid, ConsumerTag, - ExclusiveConsume, - AckRequired, QName, - Prefetch, - ActivityStatus == single_active, - ActivityStatus, Args), - rabbit_queue_type_util:notify_consumer_created( - ChPid, ConsumerTag, ExclusiveConsume, AckRequired, - QName, Prefetch, Args, none, ActingUser), - {ok, QState}; - false -> - rabbit_core_metrics:consumer_created(ChPid, ConsumerTag, - ExclusiveConsume, - AckRequired, QName, - Prefetch, true, - up, Args), - rabbit_queue_type_util:notify_consumer_created( - ChPid, ConsumerTag, ExclusiveConsume, AckRequired, - QName, Prefetch, Args, none, ActingUser), - {ok, QState} - end; + {ok, _Infos, QState} -> + %% the consumer_created/updated rabbit_core_metrics rows for this + %% consumer are recorded on the Ra leader as part of applying the + %% checkout command (see rabbit_fifo:consumer_metrics_effect/4), + %% not here, as this code may run on a different node than the + %% leader + rabbit_queue_type_util:notify_consumer_created( + ChPid, ConsumerTag, ExclusiveConsume, AckRequired, + QName, Prefetch, Args, none, ActingUser), + {ok, QState}; Err -> consume_error(Err, QName) end. diff --git a/deps/rabbit/test/rabbit_fifo_SUITE.erl b/deps/rabbit/test/rabbit_fifo_SUITE.erl index 5067047b5087..604bdd5299f7 100644 --- a/deps/rabbit/test/rabbit_fifo_SUITE.erl +++ b/deps/rabbit/test/rabbit_fifo_SUITE.erl @@ -1231,6 +1231,7 @@ return_auto_checked_out_test(Config) -> {State1, #{key := CKey, next_msg_id := MsgId}, [_Monitor, + {mod_call, rabbit_quorum_queue, update_consumer_handler, _}, {log_ext, [1], _Fun1, _} ]} = checkout(Config, ?LINE, Cid, 1, State0), % return should include another delivery {State2, _, Effects} = apply(meta(Config, 3), @@ -1246,7 +1247,7 @@ return_auto_checked_out_test(Config) -> {State4, #{key := CKey2, next_msg_id := MsgId3}, - [_, {log_ext, [1], _Fun3, _} ]} = checkout(Config, ?LINE, Cid, 1, State3), + [_, _, {log_ext, [1], _Fun3, _} ]} = checkout(Config, ?LINE, Cid, 1, State3), [{_, {_, #{delivery_count := 1, acquired_count := 2}}}] @@ -1262,6 +1263,7 @@ requeue_test(Config) -> {State1, #{key := CKey, next_msg_id := MsgId}, [_Monitor, + {mod_call, rabbit_quorum_queue, update_consumer_handler, _}, {log_ext, [1], _Fun, _}]} = checkout(Config, ?LINE, Cid, 1, State0), [{MsgId, {H1, _}}] = rabbit_fifo:get_checked_out(CKey, MsgId, MsgId, State1), @@ -2096,8 +2098,9 @@ single_active_consumer_state_enter_leader_include_waiting_consumers_test(Config) ct:pal("Efx ~p", [Effects]), %% 2 effects for each consumer process (channel process), %% 1 effect for the node, - %% 1 for decorators - ?assertEqual(2 * 3 + 1 + 1, length(Effects)). + %% 1 for decorators, + %% 1 for the bulk consumer metrics resync + ?assertEqual(2 * 3 + 1 + 1 + 1, length(Effects)). single_active_consumer_state_enter_eol_include_waiting_consumers_test(Config) -> Resource = rabbit_misc:r("/", queue, ?FUNCTION_NAME_B), @@ -2480,10 +2483,13 @@ single_active_consumer_priority_test(Config) -> assert_update_consumer_handler_state_transition(C2, Resource, true, single_active, lists:nth(2, ModCalls)), %% C1 should transition to waiting assert_update_consumer_handler_state_transition(C1, Resource, false, waiting, lists:nth(3, ModCalls)), - %% C3 is added as single_active - assert_update_consumer_handler_state_transition(C3, Resource, true, single_active, lists:nth(4, ModCalls)), + %% C3 is added as waiting (the active consumer, C2, still has a message + %% checked out so cannot be pre-empted immediately) + assert_update_consumer_handler_state_transition(C3, Resource, false, waiting, lists:nth(4, ModCalls)), + %% C3 is promoted to single_active once C2's message is settled + assert_update_consumer_handler_state_transition(C3, Resource, true, single_active, lists:nth(5, ModCalls)), %% C2 should transition as waiting - assert_update_consumer_handler_state_transition(C2, Resource, false, waiting, lists:nth(5, ModCalls)), + assert_update_consumer_handler_state_transition(C2, Resource, false, waiting, lists:nth(6, ModCalls)), ok. From 13f51bf88c07fe619f016e703ac84a02ef5fb747 Mon Sep 17 00:00:00 2001 From: Karl Nilsson Date: Wed, 15 Jul 2026 14:51:24 +0100 Subject: [PATCH 6/7] QQ: delay consumer metrics deletion until unsettled messages are settled Cancelling a consumer with unacked messages used to delete its consumer_created row immediately, even though the consumer itself lingers in state until those messages are settled/discarded/returned. Move the delete effect into update_or_remove_con/5, the single place a cancelled consumer is actually dropped from state, so the metrics row disappears exactly when the consumer does rather than up front. down/remove still delete immediately, since those forcibly return all messages and remove the consumer right away. --- deps/rabbit/src/rabbit_fifo.erl | 131 ++++++++++++++----------- deps/rabbit/test/rabbit_fifo_SUITE.erl | 27 +++++ 2 files changed, 102 insertions(+), 56 deletions(-) diff --git a/deps/rabbit/src/rabbit_fifo.erl b/deps/rabbit/src/rabbit_fifo.erl index b025feddfe8d..3590731c6b74 100644 --- a/deps/rabbit/src/rabbit_fifo.erl +++ b/deps/rabbit/src/rabbit_fifo.erl @@ -381,8 +381,9 @@ apply_(#{index := Idx} = Meta, State1 = State0#?STATE{messages = rabbit_fifo_pq:in(?DEFAULT_PRIORITY, ?MSG(Idx, Header), Messages)}, - State2 = update_or_remove_con(Meta, ConsumerKey, Con, State1), - {State3, Effects} = activate_next_consumer({State2, []}), + {State2, Effects0} = update_or_remove_con(Meta, ConsumerKey, Con, + State1, []), + {State3, Effects} = activate_next_consumer({State2, Effects0}), checkout(Meta, State0, State3, Effects); _ -> {State00, ok, []} @@ -423,11 +424,11 @@ apply_(#{index := Index, {State0, {dequeue, empty}}; _ -> Timeout = get_consumer_timeout(ConsumerMeta, State00), - {_, State1} = update_consumer(Meta, ConsumerId, ConsumerId, - ConsumerMeta, - {once, {simple_prefetch, 1}}, 0, - Timeout, State0), - case checkout_one(Meta, false, State1, []) of + {_, State1, Effects00} = update_consumer(Meta, ConsumerId, ConsumerId, + ConsumerMeta, + {once, {simple_prefetch, 1}}, + 0, Timeout, State0, []), + case checkout_one(Meta, false, State1, Effects00) of {success, _, MsgId, Msg, _ExpiredMsg, State2, Effects0} -> RaftIdx = get_msg_idx(Msg), Header = get_msg_header(Msg), @@ -503,11 +504,11 @@ apply_(#{index := Idx} = Meta, error -> ConsumerId end, - {Consumer, State1} = update_consumer(Meta, ConsumerKey, ConsumerId, - ConsumerMeta, Spec, Priority, - Timeout, State0), + {Consumer, State1, Effects00} = update_consumer(Meta, ConsumerKey, ConsumerId, + ConsumerMeta, Spec, Priority, + Timeout, State0, []), WasActive = is_active(ConsumerKey, State1), - {State2, Effs0} = activate_next_consumer(State1, []), + {State2, Effs0} = activate_next_consumer(State1, Effects00), {Active, ActivityStatus} = consumer_activity_status(ConsumerKey, State2), Effs = case {WasActive, Active} of {false, true} -> @@ -713,7 +714,7 @@ apply_(Meta, {nodeup, Node}, #?STATE{consumers = Cons0, {consumer_disconnected_timeout, ConsumerKey}, infinity} | EAcc1], - {update_or_remove_con(Meta, ConsumerKey, C, SAcc), EAcc}; + update_or_remove_con(Meta, ConsumerKey, C, SAcc, EAcc); (_, _, Acc) -> Acc end, {State0, Effects0}, maps:iterator(Cons0, ordered)), @@ -1905,16 +1906,11 @@ cancel_consumer0(Meta, ConsumerKey, #?STATE{consumers = C0} = S0, Effects0, Reason) -> case C0 of #{ConsumerKey := Consumer} -> - {S, Effects2} = maybe_return_all(Meta, ConsumerKey, Consumer, - S0, Effects0, Reason), - - - %% The effects are emitted before the consumer is actually removed - %% if the consumer has unacked messages. This is a bit weird but - %% in line with what classic queues do (from an external point of - %% view) - Effects = cancel_consumer_effects(consumer_id(Consumer), S, Effects2), - {S, Effects}; + %% the consumer_created/deleted metrics effect is emitted by + %% maybe_return_all/update_or_remove_con, only once the consumer + %% is actually removed (immediately, or once any unsettled + %% messages have all been settled/discarded/returned) + maybe_return_all(Meta, ConsumerKey, Consumer, S0, Effects0, Reason); _ -> %% already removed: do nothing {S0, Effects0} @@ -2033,18 +2029,23 @@ maybe_return_all(#{system_time := Ts} = Meta, ConsumerKey, Effects0, Reason) -> case Reason of cancel -> - {update_or_remove_con( - Meta, ConsumerKey, - Consumer#consumer{cfg = CCfg#consumer_cfg{lifetime = once}, - credit = 0, - status = cancelled}, - S0), Effects0}; + %% keep the consumer around, and its metrics untouched, until + %% any unsettled messages are settled/discarded/returned; see + %% update_or_remove_con/5 + update_or_remove_con( + Meta, ConsumerKey, + Consumer#consumer{cfg = CCfg#consumer_cfg{lifetime = once}, + credit = 0, + status = cancelled}, + S0, Effects0); _ -> + %% down/remove: the consumer is gone immediately regardless of + %% any unsettled messages, which are returned to the queue below {S1, Effects} = return_all(Meta, S0, Effects0, ConsumerKey, Consumer, Reason == down), {S1#?STATE{consumers = maps:remove(ConsumerKey, S1#?STATE.consumers), last_active = Ts}, - Effects} + cancel_consumer_effects(consumer_id(Consumer), S1, Effects)} end. node_of(undefined) -> undefined; @@ -2289,11 +2290,12 @@ complete(Meta, ConsumerKey, MsgIds, Len = map_size(Checked0) - map_size(Checked), Con = Con0#consumer{checked_out = Checked, credit = increase_credit(Con0, Len)}, - State1 = update_or_remove_con(Meta, ConsumerKey, Con, State0), + {State1, Effects1} = update_or_remove_con(Meta, ConsumerKey, Con, State0, + Effects), {State1#?STATE{msg_bytes_checkout = BytesCheckout - SettledSize, reclaimable_bytes = ReclBytes + SettledSize + (Len * ?ENQ_OVERHEAD_B), messages_total = Tot - Len}, - Effects}. + Effects1}. increase_credit(#consumer{cfg = #consumer_cfg{lifetime = once}, credit = Credit}, _) -> @@ -2493,8 +2495,9 @@ return_one(#{system_time := Ts} = Meta, MsgId, false -> State0#?STATE{returns = lqueue:in(Msg, Returns)} end, - State = update_or_remove_con(Meta, ConsumerKey, Con, State1), - {add_bytes_return(Header, State), Effects0} + {State, Effects1} = update_or_remove_con(Meta, ConsumerKey, Con, + State1, Effects0), + {add_bytes_return(Header, State), Effects1} end. should_delay(DeliveryFailed, DelayedRetry, Ts, Header, Anns) -> @@ -2852,10 +2855,11 @@ assign_to_consumer(#{system_time := Ts} = Meta, _Ts, ConsumerKey, Msgs, State1 = State0#?STATE{msg_bytes_checkout = BytesCheckout, msg_bytes_enqueue = BytesEnqueue, next_consumer_timeout = NextConTimeout}, - State = update_or_remove_con(Meta, ConsumerKey, Con, State1), + {State, Effects1} = update_or_remove_con(Meta, ConsumerKey, Con, State1, + Effects0), DelMsgs = lists:reverse(DeliveryMsgs), DeliveryEffect = delivery_effect(ConsumerKey, DelMsgs, State), - Effects = [DeliveryEffect | Effects0], + Effects = [DeliveryEffect | Effects1], {State, Effects}. delayed_in(ReadyAt, Idx, Msg, DeferralToken, #delayed{tree = Tree0, @@ -2990,10 +2994,11 @@ checkout_one(#{system_time := Ts} = Meta, ExpiredMsg0, InitState0, Effects0) -> BytesEnqueue - Size, next_consumer_timeout = min(Timeout, NextConTimeout)}, - State = update_or_remove_con(Meta, ConsumerKey, - Con, State1), + {State, Effects2} = update_or_remove_con( + Meta, ConsumerKey, Con, + State1, Effects1), {success, ConsumerKey, Next, Msg, ExpiredMsg, - State, Effects1} + State, Effects2} end; empty -> {nochange, ExpiredMsg, InitState, Effects1} @@ -3185,33 +3190,41 @@ update_or_remove_con(Meta, ConsumerKey, #consumer{cfg = #consumer_cfg{lifetime = once}, checked_out = Checked, credit = 0} = Con, - #?STATE{consumers = Cons} = State) -> + #?STATE{consumers = Cons} = State, Effects) -> case map_size(Checked) of 0 -> #{system_time := Ts} = Meta, - % we're done with this consumer - State#?STATE{consumers = maps:remove(ConsumerKey, Cons), - last_active = Ts}; + % we're done with this consumer: this is the only point a + % lifetime=once consumer is actually removed, whether that's + % immediately on cancellation or, if it still had unsettled + % messages, once the last of them is settled/discarded/returned; + % the consumer metrics are cleared to match, not before + {State#?STATE{consumers = maps:remove(ConsumerKey, Cons), + last_active = Ts}, + cancel_consumer_effects(consumer_id(Con), State, Effects)}; _ -> - % there are unsettled items so need to keep around - State#?STATE{consumers = maps:put(ConsumerKey, Con, Cons)} + % there are unsettled items so need to keep around; the + % consumer metrics are left as-is until it is actually removed + {State#?STATE{consumers = maps:put(ConsumerKey, Con, Cons)}, Effects} end; update_or_remove_con(_Meta, ConsumerKey, #consumer{status = quiescing, checked_out = Checked} = Con0, #?STATE{consumers = Cons, - waiting_consumers = Waiting} = State) + waiting_consumers = Waiting} = State, Effects) when map_size(Checked) == 0 -> Con = Con0#consumer{status = up}, - State#?STATE{consumers = maps:remove(ConsumerKey, Cons), - waiting_consumers = add_waiting({ConsumerKey, Con}, Waiting)}; + {State#?STATE{consumers = maps:remove(ConsumerKey, Cons), + waiting_consumers = add_waiting({ConsumerKey, Con}, Waiting)}, + Effects}; update_or_remove_con(_Meta, ConsumerKey, #consumer{} = Con, #?STATE{consumers = Cons, - service_queue = ServiceQueue} = State) -> - State#?STATE{consumers = maps:put(ConsumerKey, Con, Cons), + service_queue = ServiceQueue} = State, Effects) -> + {State#?STATE{consumers = maps:put(ConsumerKey, Con, Cons), service_queue = maybe_queue_consumer(ConsumerKey, Con, - ServiceQueue)}. + ServiceQueue)}, + Effects}. maybe_queue_consumer(Key, #consumer{credit = Credit, status = up, @@ -3232,7 +3245,7 @@ maybe_queue_consumer(_Key, _Consumer, ServiceQueue) -> update_consumer(Meta, ConsumerKey, {Tag, Pid}, ConsumerMeta, {Life, Mode} = Spec, Priority, Timeout, #?STATE{cfg = #cfg{consumer_strategy = competing}, - consumers = Cons0} = State0) -> + consumers = Cons0} = State0, Effects0) -> Consumer = case Cons0 of #{ConsumerKey := #consumer{} = Consumer0} -> merge_consumer(Meta, Consumer0, ConsumerMeta, @@ -3250,13 +3263,15 @@ update_consumer(Meta, ConsumerKey, {Tag, Pid}, ConsumerMeta, credit = Credit, delivery_count = DeliveryCount} end, - {Consumer, update_or_remove_con(Meta, ConsumerKey, Consumer, State0)}; + {State, Effects} = update_or_remove_con(Meta, ConsumerKey, Consumer, + State0, Effects0), + {Consumer, State, Effects}; update_consumer(Meta, ConsumerKey, {Tag, Pid}, ConsumerMeta, {Life, Mode} = Spec, Priority, Timeout, #?STATE{cfg = #cfg{consumer_strategy = single_active}, consumers = Cons0, waiting_consumers = Waiting0, - service_queue = _ServiceQueue0} = State) -> + service_queue = _ServiceQueue0} = State, Effects0) -> %% if it is the current active consumer, just update %% if it is a cancelled active consumer, add to waiting unless it is the only %% one, then merge @@ -3264,7 +3279,9 @@ update_consumer(Meta, ConsumerKey, {Tag, Pid}, ConsumerMeta, {ConsumerKey, #consumer{status = up} = Consumer0} -> Consumer = merge_consumer(Meta, Consumer0, ConsumerMeta, Spec, Priority), - {Consumer, update_or_remove_con(Meta, ConsumerKey, Consumer, State)}; + {State1, Effects} = update_or_remove_con(Meta, ConsumerKey, + Consumer, State, Effects0), + {Consumer, State1, Effects}; undefined when is_map_key(ConsumerKey, Cons0) -> %% there is no active consumer and the current consumer is in the %% consumers map and thus must be cancelled, in this case we can just @@ -3272,7 +3289,9 @@ update_consumer(Meta, ConsumerKey, {Tag, Pid}, ConsumerMeta, Consumer0 = maps:get(ConsumerKey, Cons0), Consumer = merge_consumer(Meta, Consumer0, ConsumerMeta, Spec, Priority), - {Consumer, update_or_remove_con(Meta, ConsumerKey, Consumer, State)}; + {State1, Effects} = update_or_remove_con(Meta, ConsumerKey, + Consumer, State, Effects0), + {Consumer, State1, Effects}; _ -> %% add as a new waiting consumer Credit = included_credit(Mode), @@ -3287,7 +3306,7 @@ update_consumer(Meta, ConsumerKey, {Tag, Pid}, ConsumerMeta, credit = Credit, delivery_count = DeliveryCount}, Waiting = add_waiting({ConsumerKey, Consumer}, Waiting0), - {Consumer, State#?STATE{waiting_consumers = Waiting}} + {Consumer, State#?STATE{waiting_consumers = Waiting}, Effects0} end. add_waiting({Key, _} = New, Waiting) -> diff --git a/deps/rabbit/test/rabbit_fifo_SUITE.erl b/deps/rabbit/test/rabbit_fifo_SUITE.erl index 604bdd5299f7..b4482cd0b8c9 100644 --- a/deps/rabbit/test/rabbit_fifo_SUITE.erl +++ b/deps/rabbit/test/rabbit_fifo_SUITE.erl @@ -1316,6 +1316,33 @@ cancelled_checkout_out_test(Config) -> apply(meta(Config, 7), make_checkout(Cid, {dequeue, settled}, #{}), State4), ok. +cancel_consumer_metrics_deletion_delayed_until_settled_test(Config) -> + Cid = {<<"cid">>, self()}, + {State0, _} = enq(Config, 1, 1, first, test_init(test)), + {State1, #{key := CKey, next_msg_id := NextMsgId}, _} = + checkout(Config, ?LINE, Cid, 1, State0), %% prefetch of 1, 1 msg out + %% cancelling while a message is still checked out must not delete the + %% consumer metrics yet + {State2, _, Effects1} = apply(meta(Config, 3), + rabbit_fifo:make_checkout(Cid, cancel, #{}), + State1), + ?assertNot(has_cancel_consumer_handler_effect(Effects1)), + ?assertEqual(1, map_size(State2#rabbit_fifo.consumers)), + %% settling the last unacked message finally removes the consumer, and + %% only then should its metrics be deleted + {State3, ok, Effects2} = + apply(meta(Config, 4), rabbit_fifo:make_settle(CKey, [NextMsgId]), + State2), + ?assertEqual(0, map_size(State3#rabbit_fifo.consumers)), + ?assert(has_cancel_consumer_handler_effect(Effects2)), + ok. + +has_cancel_consumer_handler_effect(Effects) -> + lists:any(fun ({mod_call, rabbit_quorum_queue, + cancel_consumer_handler, _}) -> true; + (_) -> false + end, Effects). + down_with_noproc_consumer_returns_unsettled_test(Config) -> Cid = {?FUNCTION_NAME_B, self()}, {State0, _} = enq(Config, 1, 1, second, test_init(test)), From b445f7beed72f2fed2cccb7b2b77ffad843a6f4f Mon Sep 17 00:00:00 2001 From: Karl Nilsson Date: Fri, 24 Jul 2026 10:35:11 +0100 Subject: [PATCH 7/7] rabbit_fifo_SUITE: test updates --- deps/rabbit/test/rabbit_fifo_SUITE.erl | 29 +++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/deps/rabbit/test/rabbit_fifo_SUITE.erl b/deps/rabbit/test/rabbit_fifo_SUITE.erl index b4482cd0b8c9..b7c81253c3a9 100644 --- a/deps/rabbit/test/rabbit_fifo_SUITE.erl +++ b/deps/rabbit/test/rabbit_fifo_SUITE.erl @@ -4092,7 +4092,7 @@ aux_upgrade_from_v1_test(_) -> ok = meck:new(ra_log, []), meck:expect(ra_log, last_index_term, fun (_) -> {0, 0} end), {no_reply, Aux, _, []} = handle_aux(leader, cast, tick, AuxV1, State0), - ?assertEqual(aux_v4, element(1, Aux)), + ?assertEqual(aux_v5, element(1, Aux)), ?assertEqual(Name, element(2, Aux)), meck:unload(), ok. @@ -4114,7 +4114,7 @@ aux_upgrade_from_v2_test(_) -> ok = meck:new(ra_log, []), meck:expect(ra_log, last_index_term, fun (_) -> {0, 0} end), {no_reply, Aux, _, []} = handle_aux(leader, cast, tick, AuxV2, State0), - ?assertEqual(aux_v4, element(1, Aux)), + ?assertEqual(aux_v5, element(1, Aux)), ?assertEqual(Name, element(2, Aux)), meck:unload(), ok. @@ -4137,7 +4137,30 @@ aux_upgrade_from_v3_test(_) -> ok = meck:new(ra_log, []), meck:expect(ra_log, last_index_term, fun (_) -> {0, 0} end), {no_reply, Aux, _, []} = handle_aux(leader, cast, tick, AuxV3, State0), - ?assertEqual(aux_v4, element(1, Aux)), + ?assertEqual(aux_v5, element(1, Aux)), + ?assertEqual(Name, element(2, Aux)), + meck:unload(), + ok. + +aux_upgrade_from_v4_test(_) -> + _ = ra_machine_ets:start_link(), + Name = ?FUNCTION_NAME, + %% shape of the aux state as used by an earlier version of rabbit_fifo, + %% before per-node ingress tracking was added + AuxV4 = {aux_v4, Name, unused_last_decorators_state, unused_consumer_timeout, + {aux_gc, 0}, unused_tick_pid, unused_cache, unused_last_checkpoint}, + LastApplied = 0, + State0 = #{machine_state => + init(#{name => Name, + queue_resource => rabbit_misc:r("/", queue, ?FUNCTION_NAME_B), + single_active_consumer_on => false}), + log => mock_log, + cfg => #cfg{}, + last_applied => LastApplied}, + ok = meck:new(ra_log, []), + meck:expect(ra_log, last_index_term, fun (_) -> {0, 0} end), + {no_reply, Aux, _, []} = handle_aux(leader, cast, tick, AuxV4, State0), + ?assertEqual(aux_v5, element(1, Aux)), ?assertEqual(Name, element(2, Aux)), meck:unload(), ok.