Skip to content

Commit 4379cca

Browse files
authored
Merge pull request #119 from zmstone/260723-linger-for-memory-mode
fix: apply max_linger_ms to memory-mode queues (linger before pop)
2 parents 4bab2e0 + c51eabc commit 4379cca

3 files changed

Lines changed: 275 additions & 8 deletions

File tree

changelog.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
* 4.1.11
2+
- `max_linger_ms` now also applies to memory-mode (and not-yet-offloaded) queues.
3+
Since 4.1.1, the linger delay was only applied before enqueue, and only when
4+
replayq is writing to disk (to batch disk writes), meaning memory-mode producers
5+
sent each arrival immediately, resulting in many small produce requests under
6+
steady low-to-moderate load.
7+
Now, when it is time to form a new produce request and the queue holds less than
8+
`min(max_linger_bytes, max_batch_bytes)` bytes, the producer waits up to
9+
`max_linger_ms` for more calls to accumulate before popping the queue.
10+
The wait ends immediately once a full batch's worth of bytes is queued, so under
11+
sustained load full batches are sent with no added latency; only under-sized
12+
(tail) batches are delayed, bounded by `max_linger_ms`.
13+
The default `max_linger_ms = 0` keeps the previous behavior (send immediately).
14+
115
* 4.1.10
216
- Reserve a minimum producer buffer under high memory pressure.
317
When `drop_if_highmem` is enabled and the system reports high memory usage,

src/wolff_producer.erl

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@
8989
-define(reconnect, reconnect).
9090
-define(linger_expire, linger_expire).
9191
-define(linger_expire_timer, linger_expire_timer).
92+
-define(pop_linger_expire, pop_linger_expire).
93+
-define(pop_linger_timer, pop_linger_timer).
9294
-define(DEFAULT_REPLAYQ_SEG_BYTES, 10 * 1024 * 1024).
9395
-define(DEFAULT_REPLAYQ_LIMIT, 2000000000).
9496
-define(Q_ITEM(CallId, Ts, Batch), {CallId, Ts, Batch}).
@@ -130,6 +132,7 @@
130132
, topic := topic()
131133
, calls := ?EMPTY | calls()
132134
, sender => pid()
135+
, ?pop_linger_timer := false | reference()
133136
}.
134137

135138
%% @doc Start a per-partition producer worker.
@@ -143,8 +146,12 @@
143146
%% * `max_batch_bytes': Most number of bytes to collect into a produce request.
144147
%% NOTE: This is only a rough estimation of the batch size towards kafka, NOT the
145148
%% exact max allowed message size configured in kafka.
146-
%% * `max_linger_ms': Age in milliseconds a baatch can stay in queue when the connection
147-
%% is idle (as in no pending acks). Default: 0 (as in send immediately)
149+
%% * `max_linger_ms': Number of milliseconds to wait for more calls to accumulate
150+
%% a larger batch. When the queue is writing to disk, the wait happens before
151+
%% enqueue (to also batch disk writes); otherwise the producer delays popping
152+
%% the queue for a new produce request until at least
153+
%% `min(max_linger_bytes, max_batch_bytes)' bytes are queued, or the linger
154+
%% time expires. Default: 0 (as in send immediately)
148155
%% * `max_linger_bytes': Number of bytes to collect before sending it to Kafka.
149156
%% If set to 0, `max_batch_bytes' is taken for mem-only mode, otherwise it's 10 times
150157
%% `max_batch_bytes' (but never exceeds 10MB) to optimize disk write.
@@ -165,7 +172,8 @@ start_link(ClientId, Topic, Partition, MaybeConnPid, Config) ->
165172
partition => Partition,
166173
conn => MaybeConnPid,
167174
config => use_defaults(Config),
168-
?linger_expire_timer => false
175+
?linger_expire_timer => false,
176+
?pop_linger_timer => false
169177
},
170178
%% the garbage collection can be expensive if using the default 'on_heap' option.
171179
SpawnOpts = [{spawn_opt, [{message_queue_data, off_heap}]}],
@@ -309,6 +317,16 @@ handle_info(?linger_expire, St0) ->
309317
St1 = enqueue_calls(St0#{?linger_expire_timer => false}, no_linger),
310318
St = maybe_send_to_kafka(St1),
311319
{noreply, St};
320+
handle_info({timeout, Ref, ?pop_linger_expire}, St0) ->
321+
case maps:get(?pop_linger_timer, St0, false) of
322+
Ref ->
323+
%% Lingered long enough, flush the queue even if the batch is under-sized
324+
St = maybe_send_to_kafka(St0#{?pop_linger_timer => false}, no_linger),
325+
{noreply, St};
326+
_ ->
327+
%% stale timer message: the timer fired right before it was cancelled
328+
{noreply, St0}
329+
end;
312330
handle_info(?SEND_REQ(_, Batch, _) = Call, #{calls := Calls0, config := #{max_linger_bytes := Max}} = St0) ->
313331
Bytes = batch_bytes(Batch),
314332
Calls = collect_send_calls(Call, Bytes, Calls0, Max),
@@ -375,7 +393,9 @@ handle_leader_connection(#{topic := Topic, partition := Partition, conn := Conn}
375393
conn := Conn},
376394
St2 = get_produce_version(St1),
377395
St3 = resend_sent_reqs(St2, ?reconnect),
378-
maybe_send_to_kafka(St3);
396+
%% no_linger: the queue had the whole disconnected period to accumulate,
397+
%% flush it immediately (also in case the pop-linger expired while disconnected)
398+
maybe_send_to_kafka(St3, no_linger);
379399
handle_leader_connection(#{conn := Reason} = St) ->
380400
mark_connection_down(St#{reconnect_timer => ?no_timer}, Reason).
381401

@@ -488,14 +508,19 @@ resend_sent_reqs(#{sent_reqs := SentReqs,
488508
NewSentReqs = lists:foldl(F, [], queue:to_list(SentReqs)),
489509
St#{sent_reqs := queue:from_list(lists:reverse(NewSentReqs))}.
490510

491-
maybe_send_to_kafka(#{conn := Conn, replayq := Q} = St) ->
511+
maybe_send_to_kafka(St) ->
512+
maybe_send_to_kafka(St, maybe_linger).
513+
514+
%% Linger =:= no_linger when the pop-linger has expired or after a reconnect:
515+
%% flush the queue without waiting for more calls to accumulate.
516+
maybe_send_to_kafka(#{conn := Conn, replayq := Q} = St, Linger) ->
492517
case replayq:count(Q) =:= 0 of
493518
true ->
494519
%% nothing to send
495520
St;
496521
false when is_pid(Conn) ->
497522
%% has connection, try send
498-
maybe_send_to_kafka_has_pending(St);
523+
maybe_send_to_kafka_has_pending(St, Linger);
499524
false ->
500525
%% no connection
501526
%% maybe the connection was closed after ideling
@@ -504,9 +529,13 @@ maybe_send_to_kafka(#{conn := Conn, replayq := Q} = St) ->
504529
ensure_delayed_reconnect(St, no_delay_for_first_attempt)
505530
end.
506531

507-
maybe_send_to_kafka_has_pending(St) ->
532+
maybe_send_to_kafka_has_pending(St, Linger) ->
508533
case is_send_ahead_allowed(St) of
509-
true -> send_to_kafka(St);
534+
true ->
535+
case should_linger_before_pop(St, Linger) of
536+
true -> ensure_pop_linger_timer(St);
537+
false -> send_to_kafka(ensure_pop_linger_cancel(St))
538+
end;
510539
false -> St %% reached max inflight limit
511540
end.
512541

@@ -949,6 +978,44 @@ is_linger_continue(#{calls := Calls, config := Config, replayq := Q}) ->
949978
false
950979
end.
951980

981+
%% Check if the producer should delay popping the queue so an under-sized
982+
%% batch gets a chance to grow from future send calls.
983+
%% Only when not writing to disk: when writing to disk, the linger is applied
984+
%% before enqueue (see is_linger_continue/1) to also batch disk writes.
985+
%% NOTE: the ?pop_linger_timer key may be absent when the new beam is
986+
%% hot-loaded into an already running producer, hence maps:get/3 with
987+
%% default in ensure_pop_linger_* below.
988+
should_linger_before_pop(_St, no_linger) ->
989+
false;
990+
should_linger_before_pop(#{config := #{max_linger_ms := 0}}, _) ->
991+
false;
992+
should_linger_before_pop(#{replayq := Q, config := Config}, _) ->
993+
#{max_linger_bytes := MaxLingerBytes, max_batch_bytes := MaxBatchBytes} = Config,
994+
(not replayq:is_writing_to_disk(Q)) andalso
995+
replayq:bytes(Q) < min(MaxLingerBytes, MaxBatchBytes).
996+
997+
ensure_pop_linger_timer(St) ->
998+
case maps:get(?pop_linger_timer, St, false) of
999+
false ->
1000+
#{config := #{max_linger_ms := Ms}} = St,
1001+
Ref = erlang:start_timer(Ms, self(), ?pop_linger_expire),
1002+
St#{?pop_linger_timer => Ref};
1003+
_ ->
1004+
%% timer is already started
1005+
St
1006+
end.
1007+
1008+
ensure_pop_linger_cancel(St) ->
1009+
case maps:get(?pop_linger_timer, St, false) of
1010+
false ->
1011+
St;
1012+
Ref ->
1013+
%% no need to flush a stale ?pop_linger_expire message:
1014+
%% it's ignored by the timer reference check in handle_info
1015+
_ = erlang:cancel_timer(Ref),
1016+
St#{?pop_linger_timer => false}
1017+
end.
1018+
9521019
enqueue_calls(#{calls := ?EMPTY} = St, _) ->
9531020
%% no call to enqueue
9541021
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:
@@ -638,6 +641,189 @@ replayq_highmem_overflow_test() ->
638641
ets:delete(CntrEventsTable),
639642
deinstall_event_logging(?FUNCTION_NAME).
640643

644+
%% In memory mode, the producer should linger before popping the queue:
645+
%% messages arriving one-by-one (each handled in its own mailbox message)
646+
%% are collected into a single produce request which is
647+
%% sent when max_linger_ms expires since the first message.
648+
pop_linger_batch_test_() ->
649+
{timeout, 30, fun pop_linger_batch/0}.
650+
651+
pop_linger_batch() ->
652+
ClientCfg = client_config(),
653+
{ok, Client} = start_client(<<"client-1">>, ?HOSTS, ClientCfg),
654+
LingerMs = 500,
655+
ProducerCfg = #{partitioner => fun(_, _) -> 0 end,
656+
required_acks => all_isr,
657+
max_linger_ms => LingerMs
658+
},
659+
{ok, Producers} = wolff:start_producers(Client, <<"test-topic">>, ProducerCfg),
660+
TesterPid = self(),
661+
ok = meck:new(kpro, [no_history, no_link, passthrough]),
662+
meck:expect(kpro, send,
663+
fun(Conn, Req) ->
664+
Payload = iolist_to_binary(lists:last(tuple_to_list(Req))),
665+
TesterPid ! {sent_to_kafka, Payload},
666+
meck:passthrough([Conn, Req])
667+
end),
668+
Values = [<<"pop-linger-a">>, <<"pop-linger-b">>, <<"pop-linger-c">>],
669+
AckFun = fun(_Partition, _BaseOffset) -> TesterPid ! ack, ok end,
670+
T0 = erlang:monotonic_time(millisecond),
671+
lists:foreach(
672+
fun(V) ->
673+
_ = wolff:send(Producers, [#{key => <<>>, value => V}], AckFun),
674+
timer:sleep(50) %% ensure calls are not collected from mailbox at once
675+
end, Values),
676+
try
677+
%% expect one single produce request holding all 3 values,
678+
%% sent only after the linger time expired
679+
receive
680+
{sent_to_kafka, Payload} ->
681+
Elapsed = erlang:monotonic_time(millisecond) - T0,
682+
?assert(Elapsed >= LingerMs - ?LINGER_SLACK_MS, #{elapsed => Elapsed}),
683+
lists:foreach(
684+
fun(V) -> ?assertNotEqual(nomatch, binary:match(Payload, V)) end,
685+
Values)
686+
after
687+
3000 -> error(timeout)
688+
end,
689+
%% all 3 calls are acked from the single produce request
690+
ok = wait_for_acks(length(Values)),
691+
%% and there is no other produce request
692+
receive
693+
{sent_to_kafka, _} = Extra -> error({unexpected, Extra})
694+
after
695+
200 -> ok
696+
end,
697+
%% linger timer is cleared after the pop
698+
Pid = wolff_producers:lookup_producer(Producers, 0),
699+
?assertMatch(#{pop_linger_timer := false}, sys:get_state(Pid))
700+
after
701+
meck:unload(kpro),
702+
ok = wolff:stop_producers(Producers),
703+
ok = stop_client(Client)
704+
end.
705+
706+
%% The pop-linger must not delay sending when the queue already holds
707+
%% min(max_linger_bytes, max_batch_bytes) worth of bytes.
708+
pop_linger_full_batch_no_wait_test_() ->
709+
{timeout, 30, fun pop_linger_full_batch_no_wait/0}.
710+
711+
pop_linger_full_batch_no_wait() ->
712+
ClientCfg = client_config(),
713+
{ok, Client} = start_client(<<"client-1">>, ?HOSTS, ClientCfg),
714+
LingerMs = 5000,
715+
Msg = #{key => <<>>, value => <<"pop-linger-full-batch">>},
716+
MsgBytes = wolff_producer:batch_bytes([Msg]),
717+
ProducerCfg = #{partitioner => fun(_, _) -> 0 end,
718+
required_acks => all_isr,
719+
max_linger_ms => LingerMs,
720+
%% the 2nd message accumulates enough bytes to flush
721+
max_linger_bytes => MsgBytes + 1
722+
},
723+
{ok, Producers} = wolff:start_producers(Client, <<"test-topic">>, ProducerCfg),
724+
TesterPid = self(),
725+
ok = meck:new(kpro, [no_history, no_link, passthrough]),
726+
meck:expect(kpro, send,
727+
fun(Conn, Req) ->
728+
Payload = iolist_to_binary(lists:last(tuple_to_list(Req))),
729+
TesterPid ! {sent_to_kafka, Payload},
730+
meck:passthrough([Conn, Req])
731+
end),
732+
AckFun = fun(_Partition, _BaseOffset) -> TesterPid ! ack, ok end,
733+
T0 = erlang:monotonic_time(millisecond),
734+
_ = wolff:send(Producers, [Msg], AckFun),
735+
_ = wolff:send(Producers, [Msg], AckFun),
736+
try
737+
%% both messages are flushed in one produce request,
738+
%% well before the linger time expires
739+
receive
740+
{sent_to_kafka, Payload} ->
741+
Elapsed = erlang:monotonic_time(millisecond) - T0,
742+
?assert(Elapsed < LingerMs div 2, #{elapsed => Elapsed}),
743+
?assertEqual(2, length(binary:matches(Payload, <<"pop-linger-full-batch">>)))
744+
after
745+
3000 -> error(timeout)
746+
end,
747+
ok = wait_for_acks(2)
748+
after
749+
meck:unload(kpro),
750+
ok = wolff:stop_producers(Producers),
751+
ok = stop_client(Client)
752+
end.
753+
754+
%% A lone under-sized message is delivered within max_linger_ms, never stalls.
755+
pop_linger_lone_message_test_() ->
756+
{timeout, 30, fun pop_linger_lone_message/0}.
757+
758+
pop_linger_lone_message() ->
759+
ClientCfg = client_config(),
760+
{ok, Client} = start_client(<<"client-1">>, ?HOSTS, ClientCfg),
761+
LingerMs = 300,
762+
ProducerCfg = #{partitioner => fun(_, _) -> 0 end,
763+
required_acks => all_isr,
764+
max_linger_ms => LingerMs
765+
},
766+
{ok, Producers} = wolff:start_producers(Client, <<"test-topic">>, ProducerCfg),
767+
TesterPid = self(),
768+
AckFun = fun(_Partition, _BaseOffset) -> TesterPid ! ack, ok end,
769+
T0 = erlang:monotonic_time(millisecond),
770+
_ = wolff:send(Producers, [#{key => <<>>, value => <<"pop-linger-lone">>}], AckFun),
771+
try
772+
ok = wait_for_acks(1),
773+
Elapsed = erlang:monotonic_time(millisecond) - T0,
774+
?assert(Elapsed >= LingerMs - ?LINGER_SLACK_MS, #{elapsed => Elapsed})
775+
after
776+
ok = wolff:stop_producers(Producers),
777+
ok = stop_client(Client)
778+
end.
779+
780+
%% When the pop-linger expires while the connection is down, the message
781+
%% stays queued, and is flushed upon reconnect without lingering another time.
782+
pop_linger_expire_while_disconnected_test_() ->
783+
{timeout, 30, fun pop_linger_expire_while_disconnected/0}.
784+
785+
pop_linger_expire_while_disconnected() ->
786+
ClientCfg = client_config(),
787+
{ok, Client} = start_client(<<"client-1">>, ?HOSTS, ClientCfg),
788+
LingerMs = 300,
789+
ProducerCfg = #{partitioner => fun(_, _) -> 0 end,
790+
required_acks => all_isr,
791+
max_linger_ms => LingerMs,
792+
reconnect_delay_ms => 1000
793+
},
794+
{ok, Producers} = wolff:start_producers(Client, <<"test-topic">>, ProducerCfg),
795+
Pid = wolff_producers:lookup_producer(Producers, 0),
796+
#{conn := Conn} = sys:get_state(Pid),
797+
?assert(is_pid(Conn)),
798+
TesterPid = self(),
799+
AckFun = fun(_Partition, _BaseOffset) -> TesterPid ! ack, ok end,
800+
%% an under-sized message starts the pop-linger timer
801+
_ = wolff:send(Producers, [#{key => <<>>, value => <<"pop-linger-disc">>}], AckFun),
802+
exit(Conn, kill),
803+
%% wait for the linger to expire while disconnected
804+
timer:sleep(LingerMs * 2),
805+
try
806+
%% linger expired, but still disconnected: the message is still queued
807+
#{conn := Conn2, replayq := Q} = sys:get_state(Pid),
808+
?assertNot(is_pid(Conn2)),
809+
?assertEqual(1, replayq:count(Q)),
810+
%% flushed upon reconnect
811+
ok = wait_for_acks(1),
812+
?assertMatch(#{pop_linger_timer := false}, sys:get_state(Pid))
813+
after
814+
ok = wolff:stop_producers(Producers),
815+
ok = stop_client(Client)
816+
end.
817+
818+
wait_for_acks(0) ->
819+
ok;
820+
wait_for_acks(N) ->
821+
receive
822+
ack -> wait_for_acks(N - 1)
823+
after
824+
5000 -> error(timeout)
825+
end.
826+
641827
mem_only_replayq_test() ->
642828
CntrEventsTable = ets:new(cntr_events, [public]),
643829
install_event_logging(?FUNCTION_NAME, CntrEventsTable, false),

0 commit comments

Comments
 (0)