Skip to content

Commit 3ed624d

Browse files
committed
Merge remote-tracking branch 'origin/main-1.5' into 250907-sync-main-1.5
2 parents 7d7dc3b + 83b2dbc commit 3ed624d

6 files changed

Lines changed: 187 additions & 22 deletions

File tree

changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
* 4.0.12 (merge 1.5.18)
2+
- Partition metadata handling.
3+
- Fixed an issue introduced in 4.0.7 (1.5.15) where temporarily missing partitions in the metadata response could leave a `wolff_producer` process permanently disconnected.
4+
`wolff_producer` now always attempts to reconnect, even if a partition disappears and reappears.
5+
- Ensured that partition numbers in the metadata response are always sequential, even when Kafka returns malformed metadata.
6+
17
* 4.0.11
28
- Upgrade to `kafka_protocol-4.2.8`. Fixed build speed and link issue for crc32c.
39

rebar.config

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,10 @@
3030
{tag, "v2.2.0-emqx-1"}}}]}
3131
]}]}.
3232
{shell, [{apps, [wolff]}]}.
33+
{profiles, [
34+
{test, [
35+
{deps, [
36+
{meck, "1.0.0"}
37+
]}
38+
]}
39+
]}.

src/wolff_client.erl

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,8 @@ safe_call(Pid, Call) ->
149149
end.
150150

151151
%% request client to send Pid the leader connection.
152-
recv_leader_connection(Client, Group, Topic, Partition, Pid, MaxPartitions) ->
153-
gen_server:cast(Client, {recv_leader_connection, Group, Topic, Partition, Pid, MaxPartitions}).
152+
recv_leader_connection(Client, Group, Topic, Partition, Caller, MaxPartitions) ->
153+
gen_server:cast(Client, {recv_leader_connection, Group, Topic, Partition, Caller, MaxPartitions}).
154154

155155
release_leader_conns(Client, Group, Topic) ->
156156
gen_server:cast(Client, {release_leader_conns, Group, Topic}).
@@ -214,15 +214,17 @@ handle_cast({recv_leader_connection, Group, Topic, Partition, Caller, MaxConnect
214214
%% then get the real EXIT reason in the retry attempts
215215
St = flush_exit_signals(St1),
216216
Partitions = do_get_leader_connections(St, Group, Topic),
217-
MaybePid = case lists:keyfind(Partition, 1, Partitions) of
218-
{_, Pid} ->
219-
Pid;
217+
_ = case lists:keyfind(Partition, 1, Partitions) of
218+
{_, MaybePid} ->
219+
erlang:send(Caller, ?leader_connection(MaybePid));
220220
false ->
221-
%% this happens as a race between metadata refresh and partition producer shutdown
221+
log_warn("partition_missing_in_metadata_response", #{topic => Topic, partition => Partition}),
222+
%% This happens as a race between metadata refresh and partition producer shutdown
222223
%% partition producer will be shutdown by wolff_producers after metadata refresh is complete
223-
partition_missing_in_metadata_response
224+
%% Or a malformed metadata response with the last partition missing.
225+
Reason = partition_missing_in_metadata_response,
226+
erlang:send(Caller, ?leader_connection(?conn_down(Reason)))
224227
end,
225-
_ = erlang:send(Caller, ?leader_connection(MaybePid)),
226228
{noreply, St};
227229
{error, Reason} ->
228230
_ = erlang:send(Caller, ?leader_connection(?conn_down(Reason))),
@@ -617,9 +619,13 @@ do_get_metadata2(Vsn, Connection, Topic, Timeout) ->
617619
Brokers = [parse_broker_meta(M) || M <- BrokersMeta],
618620
[TopicMeta] = kpro:find(topics, Meta),
619621
ErrorCode = kpro:find(error_code, TopicMeta),
620-
Partitions = kpro:find(partitions, TopicMeta),
622+
Partitions0 = kpro:find(partitions, TopicMeta),
621623
case ErrorCode =:= ?no_error of
624+
true when Partitions0 =:= [] ->
625+
%% unsure if this is possible
626+
{error, no_partitions_metadata};
622627
true ->
628+
Partitions = fix_partition_metadata(Topic, Partitions0),
623629
{ok, {Brokers, Partitions}};
624630
false ->
625631
{error, ErrorCode} %% no such topic ?
@@ -628,6 +634,36 @@ do_get_metadata2(Vsn, Connection, Topic, Timeout) ->
628634
{error, Reason}
629635
end.
630636

637+
%% The partitions count cache only caches the total count but not
638+
%% a list of partition numbers, so we need to fix the "holes" in
639+
%% the partition sequence [0, N), if any.
640+
%% There is no partition count returned in metadata response,
641+
%% so we must rely on the max partition number to guess the total
642+
%% number of partitions.
643+
fix_partition_metadata(Topic, PartitionMetaList) ->
644+
Partitions = lists:sort(lists:map(fun(M) -> kpro:find(partition_index, M) end, PartitionMetaList)),
645+
Missing = fix_partition_metadata_loop(Partitions, 0, []),
646+
case Missing =:= [] of
647+
true ->
648+
PartitionMetaList;
649+
false ->
650+
log_warn("partitions_missing_in_metadata_response", #{topic => Topic, partitions => Missing}),
651+
PartitionMetaList ++
652+
lists:map(fun(P) ->
653+
#{partition_index => P,
654+
error_code => partition_missing_in_metadata_response
655+
}
656+
end, Missing)
657+
end.
658+
659+
fix_partition_metadata_loop([], _, Missing) ->
660+
lists:reverse(Missing);
661+
fix_partition_metadata_loop([P | Partitions], P, Missing) ->
662+
fix_partition_metadata_loop(Partitions, P + 1, Missing);
663+
fix_partition_metadata_loop([P0 | _] = Partitions, P, Missing) ->
664+
true = (P0 > P),
665+
fix_partition_metadata_loop(Partitions, P + 1, [P | Missing]).
666+
631667
-spec parse_broker_meta(kpro:struct()) -> {integer(), host()}.
632668
parse_broker_meta(BrokerMeta) ->
633669
BrokerId = kpro:find(node_id, BrokerMeta),

src/wolff_producer.erl

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -720,12 +720,29 @@ log_connection_down(Topic, Partition, Conn, Reason) ->
720720
"connection_to_partition_leader_error",
721721
#{conn => Conn, reason => Reason}).
722722

723-
ensure_delayed_reconnect(#{config := #{reconnect_delay_ms := Delay0} = Config,
724-
client_id := ClientId,
725-
topic := Topic,
726-
partition := Partition,
727-
reconnect_timer := ?no_timer
728-
} = St, DelayStrategy) ->
723+
is_timer_on(?no_timer) ->
724+
false;
725+
is_timer_on({_, Ref}) when is_reference(Ref) ->
726+
%% started by timer:apply_after
727+
is_timer_on(Ref);
728+
is_timer_on(Ref) when is_reference(Ref) ->
729+
erlang:read_timer(Ref) =/= false.
730+
731+
ensure_delayed_reconnect(St, DelayStrategy) ->
732+
Tref = maps:get(reconnect_timer, St, ?no_timer),
733+
case is_timer_on(Tref) of
734+
true ->
735+
St;
736+
false ->
737+
do_ensure_delayed_reconnect(St, DelayStrategy)
738+
end.
739+
740+
do_ensure_delayed_reconnect(
741+
#{config := #{reconnect_delay_ms := Delay0} = Config,
742+
client_id := ClientId,
743+
topic := Topic,
744+
partition := Partition
745+
} = St, DelayStrategy) ->
729746
Attempts = maps:get(reconnect_attempts, St, 0),
730747
MaxPartitions = maps:get(max_partitions, Config, all_partitions),
731748
Attempts > 0 andalso Attempts rem 10 =:= 0 andalso
@@ -755,10 +772,7 @@ ensure_delayed_reconnect(#{config := #{reconnect_delay_ms := Delay0} = Config,
755772
%% call timer:apply_after for both cases, do not use send_after here
756773
{ok, Tref} = timer:apply_after(Delay, erlang, send, [self(), ?reconnect]),
757774
St#{reconnect_timer => Tref, reconnect_attempts => Attempts + 1}
758-
end;
759-
ensure_delayed_reconnect(St, _Delay) ->
760-
%% timer already started
761-
St.
775+
end.
762776

763777
evaluate_pending_ack_funs(PendingAcks, [], _BaseOffset) -> PendingAcks;
764778
evaluate_pending_ack_funs(PendingAcks, [{CallId, BatchSize} | Rest], BaseOffset) ->
@@ -950,7 +964,7 @@ maybe_reply_queued(?SEND_REQ({Caller, Ref}, _, _)) ->
950964
erlang:send(Caller, {Ref, ?queued}).
951965

952966
eval_ack_cb(?ACK_CB(AckFun, Partition), BaseOffset) when is_function(AckFun, 2) ->
953-
ok = AckFun(Partition, BaseOffset); %% backward compatible
967+
ok = AckFun(Partition, BaseOffset);
954968
eval_ack_cb(?ACK_CB({F, A}, Partition), BaseOffset) when is_function(F) ->
955969
true = is_function(F, length(A) + 2),
956970
ok = erlang:apply(F, [Partition, BaseOffset | A]);

src/wolff_producers.erl

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,6 @@ start_producer_and_insert_pid(ClientId, Group, Topic, Partition, Config) ->
685685
?conn_down(to_be_discovered), Config),
686686
ok = insert_producers(ClientId, Group, Topic, #{Partition => Pid}).
687687

688-
%% Config is not used so far.
689688
start_partition_refresh_timer(Config) ->
690689
IntervalSeconds = maps:get(partition_count_refresh_interval_seconds, Config,
691690
?partition_count_refresh_interval_seconds),

test/wolff_supervised_tests.erl

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,110 @@ test_partition_count_decrease() ->
412412
ok = application:stop(wolff),
413413
ok.
414414

415-
test_topic_recreate_test_() ->
415+
%% The last partition is temporarily missing from metadata response.
416+
%% The producer should retry to recover
417+
last_partition_missing_in_metadata_response_test_() ->
418+
{timeout, 30, %% it takes time to alter topic via cli in docker container
419+
fun() -> test_partition_missing_in_metadata_response(2) end}.
420+
421+
%% The partition in the middle is temporarily missing from metadata response.
422+
%% The producer should retry to recover
423+
mid_partition_missing_in_metadata_response_test_() ->
424+
{timeout, 30, %% it takes time to alter topic via cli in docker container
425+
fun() -> test_partition_missing_in_metadata_response(1) end}.
426+
427+
test_partition_missing_in_metadata_response(ThePartition) ->
428+
io:format(user, "test_partition_missing_in_metadata_response\n", []),
429+
ClientId = <<"test-temporarily-malformed-metadata-response">>,
430+
%% ensure the topic does not exist
431+
Topic = <<"test-topic-5">>,
432+
delete_topic(Topic),
433+
Partitions = 3,
434+
create_topic(Topic, Partitions),
435+
io:format(user, "created topic ~s with ~p partitions\n", [Topic, Partitions]),
436+
_ = application:stop(wolff), %% ensure stopped
437+
{ok, _} = application:ensure_all_started(wolff),
438+
%% always refresh metadata
439+
ClientCfg = #{min_metadata_refresh_interval => 0},
440+
{ok, ClientPid} = wolff:ensure_supervised_client(ClientId, ?HOSTS, ClientCfg),
441+
{ok, Connections} = get_leader_connections(ClientPid, Topic),
442+
?assertEqual(Partitions, length(Connections)),
443+
%% always send it to the partition which is going to be missing
444+
Partitioner = fun(_, _) -> ThePartition end,
445+
ProducerCfg = #{required_acks => all_isr,
446+
partitioner => Partitioner,
447+
reconnect_delay_ms => 0,
448+
%% 0 means no aut-refresh
449+
partition_count_refresh_interval_seconds => 0
450+
},
451+
{ok, Producers} = wolff:ensure_supervised_producers(ClientId, Topic, ProducerCfg),
452+
?assertEqual(Partitions, wolff_producers:get_partition_cnt(ClientId, ?NO_GROUP, Topic)),
453+
Msg = #{key => ?KEY, value => <<"value">>},
454+
?assertMatch({ThePartition, _}, wolff:send_sync(Producers, [Msg], 3000)),
455+
%% mock a bad
456+
%% Kill partition leader connection
457+
Pid = get_partition_leader_connection(ClientPid, Topic, ThePartition),
458+
exit(Pid, kill),
459+
%% mock kafka_protcol to return metadata with ThePartition missing
460+
meck:new(kpro, [passthrough, no_history]),
461+
meck:expect(kpro, request_sync,
462+
fun(Connection, Req, Timeout) ->
463+
{ok, Rsp} = meck:passthrough([Connection, Req, Timeout]),
464+
case Rsp of
465+
#kpro_rsp{msg = #{topic_metadata := [#{partition_metadata := PM0} = TopicMeta]} = Meta} ->
466+
PM = lists:filter(fun(#{partition := P}) -> P =/= ThePartition end, PM0),
467+
NewRsp = Rsp#kpro_rsp{msg =Meta#{topic_metadata := [TopicMeta#{partition_metadata := PM}]}},
468+
{ok, NewRsp};
469+
_ ->
470+
{ok, Rsp}
471+
end
472+
end),
473+
?assertNot(is_pid(get_partition_leader_connection(ClientPid, Topic, ThePartition))),
474+
%% the request will be buffered, but the call times out
475+
Msg2 = #{key => ?KEY, value => <<"v">>},
476+
?assertError(timeout, wolff:send_sync(Producers, [Msg2], 100)),
477+
%% Wait for auto recover
478+
Tester = self(),
479+
meck:expect(kpro, send,
480+
fun(Conn, Req) ->
481+
Tester ! {sent, Conn},
482+
meck:passthrough([Conn, Req])
483+
end),
484+
%% Now remove the injected error (malformed metadata response)
485+
%% wolff_producer should now be able to recover
486+
meck:expect(kpro, request_sync,
487+
fun(Connection, Req, Timeout) ->
488+
meck:passthrough([Connection, Req, Timeout])
489+
end),
490+
receive
491+
{sent, NewConn} ->
492+
?assertEqual(NewConn, get_partition_leader_connection(ClientPid, Topic, ThePartition))
493+
after
494+
2000 ->
495+
%% wolff_producer retry delay is 0, but it randomize with extra 0-1000ms
496+
error(timeout)
497+
end,
498+
meck:unload(kpro),
499+
ok = fetch_and_match(ClientPid, Topic, ThePartition, 0, [Msg, Msg2]),
500+
%% cleanup
501+
ok = wolff:stop_and_delete_supervised_producers(Producers),
502+
?assertEqual([], supervisor:which_children(wolff_producers_sup)),
503+
ok = wolff:stop_and_delete_supervised_client(ClientId),
504+
?assertEqual([], supervisor:which_children(wolff_client_sup)),
505+
ok = application:stop(wolff),
506+
ok.
507+
508+
get_partition_leader_connection(Client, Topic, Partition) ->
509+
ok = wolff_client:recv_leader_connection(Client, Topic, Partition, self(), all_partitions),
510+
receive
511+
{leader_connection, Pid} ->
512+
Pid
513+
after
514+
20000 ->
515+
error(timeout)
516+
end.
517+
518+
topic_recreate_test_() ->
416519
{timeout, 30, %% it takes time to alter topic via cli in docker container
417520
fun test_topic_recreate/0}.
418521

0 commit comments

Comments
 (0)