Skip to content

Commit d71ba5d

Browse files
committed
Merge remote-tracking branch 'origin/main-4.1' into 260723-sync-main-4.1
# Conflicts: # changelog.md # src/wolff_producer.erl
2 parents 3f362cd + 4379cca commit d71ba5d

3 files changed

Lines changed: 277 additions & 8 deletions

File tree

changelog.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@
1919
buffered messages across long broker outages instead of sending arbitrarily
2020
stale data.
2121

22+
* 4.1.11
23+
- `max_linger_ms` now also applies to memory-mode (and not-yet-offloaded) queues.
24+
Since 4.1.1, the linger delay was only applied before enqueue, and only when
25+
replayq is writing to disk (to batch disk writes), meaning memory-mode producers
26+
sent each arrival immediately, resulting in many small produce requests under
27+
steady low-to-moderate load.
28+
Now, when it is time to form a new produce request and the queue holds less than
29+
`min(max_linger_bytes, max_batch_bytes)` bytes, the producer waits up to
30+
`max_linger_ms` for more calls to accumulate before popping the queue.
31+
The wait ends immediately once a full batch's worth of bytes is queued, so under
32+
sustained load full batches are sent with no added latency; only under-sized
33+
(tail) batches are delayed, bounded by `max_linger_ms`.
34+
The default `max_linger_ms = 0` keeps the previous behavior (send immediately).
35+
2236
* 4.1.10
2337
- Reserve a minimum producer buffer under high memory pressure.
2438
When `drop_if_highmem` is enabled and the system reports high memory usage,

src/wolff_producer.erl

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@
9595
-define(reconnect, reconnect).
9696
-define(linger_expire, linger_expire).
9797
-define(linger_expire_timer, linger_expire_timer).
98+
-define(pop_linger_expire, pop_linger_expire).
99+
-define(pop_linger_timer, pop_linger_timer).
98100
-define(DEFAULT_REPLAYQ_SEG_BYTES, 10 * 1024 * 1024).
99101
-define(DEFAULT_REPLAYQ_LIMIT, 2000000000).
100102
-define(Q_ITEM(CallId, Ts, Batch), {CallId, Ts, Batch}).
@@ -137,6 +139,7 @@
137139
, topic := topic()
138140
, calls := ?EMPTY | calls()
139141
, sender => pid()
142+
, ?pop_linger_timer := false | reference()
140143
}.
141144

142145
%% @doc Start a per-partition producer worker.
@@ -150,8 +153,12 @@
150153
%% * `max_batch_bytes': Most number of bytes to collect into a produce request.
151154
%% NOTE: This is only a rough estimation of the batch size towards kafka, NOT the
152155
%% exact max allowed message size configured in kafka.
153-
%% * `max_linger_ms': Age in milliseconds a baatch can stay in queue when the connection
154-
%% is idle (as in no pending acks). Default: 0 (as in send immediately)
156+
%% * `max_linger_ms': Number of milliseconds to wait for more calls to accumulate
157+
%% a larger batch. When the queue is writing to disk, the wait happens before
158+
%% enqueue (to also batch disk writes); otherwise the producer delays popping
159+
%% the queue for a new produce request until at least
160+
%% `min(max_linger_bytes, max_batch_bytes)' bytes are queued, or the linger
161+
%% time expires. Default: 0 (as in send immediately)
155162
%% * `max_linger_bytes': Number of bytes to collect before sending it to Kafka.
156163
%% If set to 0, `max_batch_bytes' is taken for mem-only mode, otherwise it's 10 times
157164
%% `max_batch_bytes' (but never exceeds 10MB) to optimize disk write.
@@ -190,7 +197,8 @@ start_link(ClientId, Topic, Partition, MaybeConnPid, Config) ->
190197
partition => Partition,
191198
conn => MaybeConnPid,
192199
config => use_defaults(Config),
193-
?linger_expire_timer => false
200+
?linger_expire_timer => false,
201+
?pop_linger_timer => false
194202
},
195203
%% the garbage collection can be expensive if using the default 'on_heap' option.
196204
SpawnOpts = [{spawn_opt, [{message_queue_data, off_heap}]}],
@@ -334,6 +342,16 @@ handle_info(?linger_expire, St0) ->
334342
St1 = enqueue_calls(St0#{?linger_expire_timer => false}, no_linger),
335343
St = maybe_send_to_kafka(St1),
336344
{noreply, St};
345+
handle_info({timeout, Ref, ?pop_linger_expire}, St0) ->
346+
case maps:get(?pop_linger_timer, St0, false) of
347+
Ref ->
348+
%% Lingered long enough, flush the queue even if the batch is under-sized
349+
St = maybe_send_to_kafka(St0#{?pop_linger_timer => false}, no_linger),
350+
{noreply, St};
351+
_ ->
352+
%% stale timer message: the timer fired right before it was cancelled
353+
{noreply, St0}
354+
end;
337355
handle_info(?SEND_REQ(_, Batch, _) = Call, #{calls := Calls0, config := #{max_linger_bytes := Max}} = St0) ->
338356
Bytes = batch_bytes(Batch),
339357
Calls = collect_send_calls(Call, Bytes, Calls0, Max),
@@ -400,7 +418,9 @@ handle_leader_connection(#{topic := Topic, partition := Partition, conn := Conn}
400418
conn := Conn},
401419
St2 = get_produce_version(St1),
402420
St3 = resend_sent_reqs(St2, ?reconnect),
403-
maybe_send_to_kafka(St3);
421+
%% no_linger: the queue had the whole disconnected period to accumulate,
422+
%% flush it immediately (also in case the pop-linger expired while disconnected)
423+
maybe_send_to_kafka(St3, no_linger);
404424
handle_leader_connection(#{conn := Reason} = St) ->
405425
mark_connection_down(St#{reconnect_timer => ?no_timer}, Reason).
406426

@@ -619,15 +639,22 @@ discard_expired_front(#{replayq := Q,
619639
ok = maybe_log_discard(St, NrOfCalls, ?DISCARD_EXPIRED),
620640
St#{replayq := NewQ, pending_acks := NewPendingAcks}.
621641

622-
maybe_send_to_kafka(St0) ->
642+
maybe_send_to_kafka(St) ->
643+
maybe_send_to_kafka(St, maybe_linger).
644+
645+
%% Linger =:= no_linger when the pop-linger has expired or after a reconnect:
646+
%% flush the queue without waiting for more calls to accumulate.
647+
maybe_send_to_kafka(St0, Linger) ->
648+
%% shed expired batches first, so the pop-linger gate below
649+
%% sees the queue size without them
623650
#{conn := Conn, replayq := Q} = St = drop_expired_head(St0),
624651
case replayq:count(Q) =:= 0 of
625652
true ->
626653
%% nothing to send
627654
St;
628655
false when is_pid(Conn) ->
629656
%% has connection, try send
630-
maybe_send_to_kafka_has_pending(St);
657+
maybe_send_to_kafka_has_pending(St, Linger);
631658
false ->
632659
%% no connection
633660
%% maybe the connection was closed after ideling
@@ -636,9 +663,13 @@ maybe_send_to_kafka(St0) ->
636663
ensure_delayed_reconnect(St, no_delay_for_first_attempt)
637664
end.
638665

639-
maybe_send_to_kafka_has_pending(St) ->
666+
maybe_send_to_kafka_has_pending(St, Linger) ->
640667
case is_send_ahead_allowed(St) of
641-
true -> send_to_kafka(St);
668+
true ->
669+
case should_linger_before_pop(St, Linger) of
670+
true -> ensure_pop_linger_timer(St);
671+
false -> send_to_kafka(ensure_pop_linger_cancel(St))
672+
end;
642673
false -> St %% reached max inflight limit
643674
end.
644675

@@ -1106,6 +1137,44 @@ is_linger_continue(#{calls := Calls, config := Config, replayq := Q}) ->
11061137
false
11071138
end.
11081139

1140+
%% Check if the producer should delay popping the queue so an under-sized
1141+
%% batch gets a chance to grow from future send calls.
1142+
%% Only when not writing to disk: when writing to disk, the linger is applied
1143+
%% before enqueue (see is_linger_continue/1) to also batch disk writes.
1144+
%% NOTE: the ?pop_linger_timer key may be absent when the new beam is
1145+
%% hot-loaded into an already running producer, hence maps:get/3 with
1146+
%% default in ensure_pop_linger_* below.
1147+
should_linger_before_pop(_St, no_linger) ->
1148+
false;
1149+
should_linger_before_pop(#{config := #{max_linger_ms := 0}}, _) ->
1150+
false;
1151+
should_linger_before_pop(#{replayq := Q, config := Config}, _) ->
1152+
#{max_linger_bytes := MaxLingerBytes, max_batch_bytes := MaxBatchBytes} = Config,
1153+
(not replayq:is_writing_to_disk(Q)) andalso
1154+
replayq:bytes(Q) < min(MaxLingerBytes, MaxBatchBytes).
1155+
1156+
ensure_pop_linger_timer(St) ->
1157+
case maps:get(?pop_linger_timer, St, false) of
1158+
false ->
1159+
#{config := #{max_linger_ms := Ms}} = St,
1160+
Ref = erlang:start_timer(Ms, self(), ?pop_linger_expire),
1161+
St#{?pop_linger_timer => Ref};
1162+
_ ->
1163+
%% timer is already started
1164+
St
1165+
end.
1166+
1167+
ensure_pop_linger_cancel(St) ->
1168+
case maps:get(?pop_linger_timer, St, false) of
1169+
false ->
1170+
St;
1171+
Ref ->
1172+
%% no need to flush a stale ?pop_linger_expire message:
1173+
%% it's ignored by the timer reference check in handle_info
1174+
_ = erlang:cancel_timer(Ref),
1175+
St#{?pop_linger_timer => false}
1176+
end.
1177+
11091178
enqueue_calls(#{calls := ?EMPTY} = St, _) ->
11101179
%% no call to enqueue
11111180
St;

test/wolff_tests.erl

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919

2020
-define(KEY, key(?FUNCTION_NAME)).
2121
-define(HOSTS, [{"localhost", 9092}]).
22+
%% Allowed measurement slack when asserting that at least
23+
%% max_linger_ms has elapsed (timer precision, scheduling delays).
24+
-define(LINGER_SLACK_MS, 100).
2225
-define(assert_eq_optional_tail(EXPR,EXPECTED), assert_eq_optional_tail(fun() -> EXPR end, EXPECTED)).
2326

2427
%% Kafka v2 batch consists of below fields:
@@ -791,6 +794,189 @@ replayq_highmem_overflow_test() ->
791794
ets:delete(CntrEventsTable),
792795
deinstall_event_logging(?FUNCTION_NAME).
793796

797+
%% In memory mode, the producer should linger before popping the queue:
798+
%% messages arriving one-by-one (each handled in its own mailbox message)
799+
%% are collected into a single produce request which is
800+
%% sent when max_linger_ms expires since the first message.
801+
pop_linger_batch_test_() ->
802+
{timeout, 30, fun pop_linger_batch/0}.
803+
804+
pop_linger_batch() ->
805+
ClientCfg = client_config(),
806+
{ok, Client} = start_client(<<"client-1">>, ?HOSTS, ClientCfg),
807+
LingerMs = 500,
808+
ProducerCfg = #{partitioner => fun(_, _) -> 0 end,
809+
required_acks => all_isr,
810+
max_linger_ms => LingerMs
811+
},
812+
{ok, Producers} = wolff:start_producers(Client, <<"test-topic">>, ProducerCfg),
813+
TesterPid = self(),
814+
ok = meck:new(kpro, [no_history, no_link, passthrough]),
815+
meck:expect(kpro, send,
816+
fun(Conn, Req) ->
817+
Payload = iolist_to_binary(lists:last(tuple_to_list(Req))),
818+
TesterPid ! {sent_to_kafka, Payload},
819+
meck:passthrough([Conn, Req])
820+
end),
821+
Values = [<<"pop-linger-a">>, <<"pop-linger-b">>, <<"pop-linger-c">>],
822+
AckFun = fun(_Partition, _BaseOffset) -> TesterPid ! ack, ok end,
823+
T0 = erlang:monotonic_time(millisecond),
824+
lists:foreach(
825+
fun(V) ->
826+
_ = wolff:send(Producers, [#{key => <<>>, value => V}], AckFun),
827+
timer:sleep(50) %% ensure calls are not collected from mailbox at once
828+
end, Values),
829+
try
830+
%% expect one single produce request holding all 3 values,
831+
%% sent only after the linger time expired
832+
receive
833+
{sent_to_kafka, Payload} ->
834+
Elapsed = erlang:monotonic_time(millisecond) - T0,
835+
?assert(Elapsed >= LingerMs - ?LINGER_SLACK_MS, #{elapsed => Elapsed}),
836+
lists:foreach(
837+
fun(V) -> ?assertNotEqual(nomatch, binary:match(Payload, V)) end,
838+
Values)
839+
after
840+
3000 -> error(timeout)
841+
end,
842+
%% all 3 calls are acked from the single produce request
843+
ok = wait_for_acks(length(Values)),
844+
%% and there is no other produce request
845+
receive
846+
{sent_to_kafka, _} = Extra -> error({unexpected, Extra})
847+
after
848+
200 -> ok
849+
end,
850+
%% linger timer is cleared after the pop
851+
Pid = wolff_producers:lookup_producer(Producers, 0),
852+
?assertMatch(#{pop_linger_timer := false}, sys:get_state(Pid))
853+
after
854+
meck:unload(kpro),
855+
ok = wolff:stop_producers(Producers),
856+
ok = stop_client(Client)
857+
end.
858+
859+
%% The pop-linger must not delay sending when the queue already holds
860+
%% min(max_linger_bytes, max_batch_bytes) worth of bytes.
861+
pop_linger_full_batch_no_wait_test_() ->
862+
{timeout, 30, fun pop_linger_full_batch_no_wait/0}.
863+
864+
pop_linger_full_batch_no_wait() ->
865+
ClientCfg = client_config(),
866+
{ok, Client} = start_client(<<"client-1">>, ?HOSTS, ClientCfg),
867+
LingerMs = 5000,
868+
Msg = #{key => <<>>, value => <<"pop-linger-full-batch">>},
869+
MsgBytes = wolff_producer:batch_bytes([Msg]),
870+
ProducerCfg = #{partitioner => fun(_, _) -> 0 end,
871+
required_acks => all_isr,
872+
max_linger_ms => LingerMs,
873+
%% the 2nd message accumulates enough bytes to flush
874+
max_linger_bytes => MsgBytes + 1
875+
},
876+
{ok, Producers} = wolff:start_producers(Client, <<"test-topic">>, ProducerCfg),
877+
TesterPid = self(),
878+
ok = meck:new(kpro, [no_history, no_link, passthrough]),
879+
meck:expect(kpro, send,
880+
fun(Conn, Req) ->
881+
Payload = iolist_to_binary(lists:last(tuple_to_list(Req))),
882+
TesterPid ! {sent_to_kafka, Payload},
883+
meck:passthrough([Conn, Req])
884+
end),
885+
AckFun = fun(_Partition, _BaseOffset) -> TesterPid ! ack, ok end,
886+
T0 = erlang:monotonic_time(millisecond),
887+
_ = wolff:send(Producers, [Msg], AckFun),
888+
_ = wolff:send(Producers, [Msg], AckFun),
889+
try
890+
%% both messages are flushed in one produce request,
891+
%% well before the linger time expires
892+
receive
893+
{sent_to_kafka, Payload} ->
894+
Elapsed = erlang:monotonic_time(millisecond) - T0,
895+
?assert(Elapsed < LingerMs div 2, #{elapsed => Elapsed}),
896+
?assertEqual(2, length(binary:matches(Payload, <<"pop-linger-full-batch">>)))
897+
after
898+
3000 -> error(timeout)
899+
end,
900+
ok = wait_for_acks(2)
901+
after
902+
meck:unload(kpro),
903+
ok = wolff:stop_producers(Producers),
904+
ok = stop_client(Client)
905+
end.
906+
907+
%% A lone under-sized message is delivered within max_linger_ms, never stalls.
908+
pop_linger_lone_message_test_() ->
909+
{timeout, 30, fun pop_linger_lone_message/0}.
910+
911+
pop_linger_lone_message() ->
912+
ClientCfg = client_config(),
913+
{ok, Client} = start_client(<<"client-1">>, ?HOSTS, ClientCfg),
914+
LingerMs = 300,
915+
ProducerCfg = #{partitioner => fun(_, _) -> 0 end,
916+
required_acks => all_isr,
917+
max_linger_ms => LingerMs
918+
},
919+
{ok, Producers} = wolff:start_producers(Client, <<"test-topic">>, ProducerCfg),
920+
TesterPid = self(),
921+
AckFun = fun(_Partition, _BaseOffset) -> TesterPid ! ack, ok end,
922+
T0 = erlang:monotonic_time(millisecond),
923+
_ = wolff:send(Producers, [#{key => <<>>, value => <<"pop-linger-lone">>}], AckFun),
924+
try
925+
ok = wait_for_acks(1),
926+
Elapsed = erlang:monotonic_time(millisecond) - T0,
927+
?assert(Elapsed >= LingerMs - ?LINGER_SLACK_MS, #{elapsed => Elapsed})
928+
after
929+
ok = wolff:stop_producers(Producers),
930+
ok = stop_client(Client)
931+
end.
932+
933+
%% When the pop-linger expires while the connection is down, the message
934+
%% stays queued, and is flushed upon reconnect without lingering another time.
935+
pop_linger_expire_while_disconnected_test_() ->
936+
{timeout, 30, fun pop_linger_expire_while_disconnected/0}.
937+
938+
pop_linger_expire_while_disconnected() ->
939+
ClientCfg = client_config(),
940+
{ok, Client} = start_client(<<"client-1">>, ?HOSTS, ClientCfg),
941+
LingerMs = 300,
942+
ProducerCfg = #{partitioner => fun(_, _) -> 0 end,
943+
required_acks => all_isr,
944+
max_linger_ms => LingerMs,
945+
reconnect_delay_ms => 1000
946+
},
947+
{ok, Producers} = wolff:start_producers(Client, <<"test-topic">>, ProducerCfg),
948+
Pid = wolff_producers:lookup_producer(Producers, 0),
949+
#{conn := Conn} = sys:get_state(Pid),
950+
?assert(is_pid(Conn)),
951+
TesterPid = self(),
952+
AckFun = fun(_Partition, _BaseOffset) -> TesterPid ! ack, ok end,
953+
%% an under-sized message starts the pop-linger timer
954+
_ = wolff:send(Producers, [#{key => <<>>, value => <<"pop-linger-disc">>}], AckFun),
955+
exit(Conn, kill),
956+
%% wait for the linger to expire while disconnected
957+
timer:sleep(LingerMs * 2),
958+
try
959+
%% linger expired, but still disconnected: the message is still queued
960+
#{conn := Conn2, replayq := Q} = sys:get_state(Pid),
961+
?assertNot(is_pid(Conn2)),
962+
?assertEqual(1, replayq:count(Q)),
963+
%% flushed upon reconnect
964+
ok = wait_for_acks(1),
965+
?assertMatch(#{pop_linger_timer := false}, sys:get_state(Pid))
966+
after
967+
ok = wolff:stop_producers(Producers),
968+
ok = stop_client(Client)
969+
end.
970+
971+
wait_for_acks(0) ->
972+
ok;
973+
wait_for_acks(N) ->
974+
receive
975+
ack -> wait_for_acks(N - 1)
976+
after
977+
5000 -> error(timeout)
978+
end.
979+
794980
mem_only_replayq_test() ->
795981
CntrEventsTable = ets:new(cntr_events, [public]),
796982
install_event_logging(?FUNCTION_NAME, CntrEventsTable, false),

0 commit comments

Comments
 (0)