Skip to content

Commit 83b2dbc

Browse files
authored
Merge pull request #98 from zmstone/280829-handle-partial-metata-response
250829 handle partial metata response
2 parents 439bfa0 + 9fcf7fc commit 83b2dbc

9 files changed

Lines changed: 194 additions & 34 deletions

File tree

.github/workflows/ci.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ jobs:
1212
matrix:
1313
otp:
1414
- '25.3'
15-
- '24.3'
1615
kafka:
1716
- '2.4'
1817
- '1.1'

changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
* 1.5.18
2+
- Partition metadata handling.
3+
- Fixed an issue introduced in 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
* 1.5.17
28
- Avoid `badfun` error when release hot upgrade.
39
* 1.5.16

rebar.config

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,10 @@
2020
deprecated_functions]}.
2121

2222
{shell, [{apps, [wolff]}]}.
23+
{profiles, [
24+
{test, [
25+
{deps, [
26+
{meck, "1.0.0"}
27+
]}
28+
]}
29+
]}.

src/wolff.app.src

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{application, wolff,
22
[{description, "Kafka's publisher"},
3-
{vsn, "1.5.17"},
3+
{vsn, "1.5.18"},
44
{registered, []},
55
{applications,
66
[kernel,

src/wolff.appup.src

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
%% -*- mode: erlang -*-
2-
{"1.5.17",
2+
{"1.5.18",
33
[
4-
{"1.5.16",
5-
[ {load_module, wolff_producer, brutal_purge, soft_purge, []}
6-
]},
7-
{<<"1\\.5\\.1[1-5]">>,
4+
{<<"1\\.5\\.1[1-7]">>,
85
[ {load_module, wolff_client, brutal_purge, soft_purge, []}
96
, {load_module, wolff_producers, brutal_purge, soft_purge, []}
107
, {load_module, wolff_producer, brutal_purge, soft_purge, []}
@@ -42,10 +39,7 @@
4239
}
4340
],
4441
[
45-
{"1.5.16",
46-
[ {load_module, wolff_producer, brutal_purge, soft_purge, []}
47-
]},
48-
{<<"1\\.5\\.1[1-3]">>,
42+
{<<"1\\.5\\.1[1-7]">>,
4943
[ {load_module, wolff_client, brutal_purge, soft_purge, []}
5044
, {load_module, wolff_producers, brutal_purge, soft_purge, []}
5145
, {load_module, wolff_producer, brutal_purge, soft_purge, []}

src/wolff_client.erl

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

115115
%% request client to send Pid the leader connection.
116-
recv_leader_connection(Client, Topic, Partition, Pid) ->
117-
gen_server:cast(Client, {recv_leader_connection, Topic, Partition, Pid}).
116+
recv_leader_connection(Client, Topic, Partition, Caller) ->
117+
gen_server:cast(Client, {recv_leader_connection, Topic, Partition, Caller}).
118118

119119
delete_producers_metadata(Client, Topic) ->
120120
gen_server:cast(Client, {delete_producers_metadata, Topic}).
@@ -169,15 +169,17 @@ handle_cast({recv_leader_connection, Topic, Partition, Caller}, St0) ->
169169
{ok, St1} ->
170170
St = flush_exit_signals(St1),
171171
Partitions = do_get_leader_connections(St, Topic),
172-
MaybePid = case lists:keyfind(Partition, 1, Partitions) of
173-
{_, Pid} ->
174-
Pid;
172+
_ = case lists:keyfind(Partition, 1, Partitions) of
173+
{_, MaybePid} ->
174+
erlang:send(Caller, ?leader_connection(MaybePid));
175175
false ->
176-
%% this happens as a race between metadata refresh and partition producer shutdown
176+
log_warn("partition_missing_in_metadata_response ~s-~p", [Topic, Partition]),
177+
%% This happens as a race between metadata refresh and partition producer shutdown
177178
%% partition producer will be shutdown by wolff_producers after metadata refresh is complete
178-
partition_missing_in_metadata_response
179+
%% Or a malformed metadata response with the last partition missing.
180+
Reason = partition_missing_in_metadata_response,
181+
erlang:send(Caller, ?leader_connection(?conn_down(Reason)))
179182
end,
180-
_ = erlang:send(Caller, ?leader_connection(MaybePid)),
181183
{noreply, St};
182184
{error, Reason} ->
183185
_ = erlang:send(Caller, ?leader_connection(?conn_down(Reason))),
@@ -464,9 +466,13 @@ do_get_metadata2(Vsn, Connection, Topic) ->
464466
Brokers = [parse_broker_meta(M) || M <- BrokersMeta],
465467
[TopicMeta] = kpro:find(topic_metadata, Meta),
466468
ErrorCode = kpro:find(error_code, TopicMeta),
467-
Partitions = kpro:find(partition_metadata, TopicMeta),
469+
Partitions0 = kpro:find(partition_metadata, TopicMeta),
468470
case ErrorCode =:= ?no_error of
471+
true when Partitions0 =:= [] ->
472+
%% unsure if this is possible
473+
{error, no_partitions_metadata};
469474
true ->
475+
Partitions = fix_partition_metadata(Topic, Partitions0),
470476
{ok, {Brokers, Partitions}};
471477
false ->
472478
{error, ErrorCode} %% no such topic ?
@@ -475,6 +481,36 @@ do_get_metadata2(Vsn, Connection, Topic) ->
475481
{error, Reason}
476482
end.
477483

484+
%% The partitions count cache only caches the total count but not
485+
%% a list of partition numbers, so we need to fix the "holes" in
486+
%% the partition sequence [0, N), if any.
487+
%% There is no partition count returned in metadata response,
488+
%% so we must rely on the max partition number to guess the total
489+
%% number of partitions.
490+
fix_partition_metadata(Topic, PartitionMetaList) ->
491+
Partitions = lists:sort(lists:map(fun(M) -> kpro:find(partition, M) end, PartitionMetaList)),
492+
Missing = fix_partition_metadata_loop(Partitions, 0, []),
493+
case Missing =:= [] of
494+
true ->
495+
PartitionMetaList;
496+
false ->
497+
log_warn("partitions_missing_in_metadata_response ~s: ~w", [Topic, Missing]),
498+
PartitionMetaList ++
499+
lists:map(fun(P) ->
500+
#{partition => P,
501+
error_code => partition_missing_in_metadata_response
502+
}
503+
end, Missing)
504+
end.
505+
506+
fix_partition_metadata_loop([], _, Missing) ->
507+
lists:reverse(Missing);
508+
fix_partition_metadata_loop([P | Partitions], P, Missing) ->
509+
fix_partition_metadata_loop(Partitions, P + 1, Missing);
510+
fix_partition_metadata_loop([P0 | _] = Partitions, P, Missing) ->
511+
true = (P0 > P),
512+
fix_partition_metadata_loop(Partitions, P + 1, [P | Missing]).
513+
478514
-spec parse_broker_meta(kpro:struct()) -> {integer(), host()}.
479515
parse_broker_meta(BrokerMeta) ->
480516
BrokerId = kpro:find(node_id, BrokerMeta),

src/wolff_producer.erl

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -591,13 +591,30 @@ log_connection_down(Topic, Partition, _, noproc) ->
591591
log_connection_down(Topic, Partition, _, Reason) ->
592592
log_info(Topic, Partition, "connection to partition leader is down~nreason=~p", [Reason]).
593593

594-
ensure_delayed_reconnect(#{config := #{reconnect_delay_ms := Delay0},
595-
client_id := ClientId,
596-
topic := Topic,
597-
partition := Partition,
598-
reconnect_timer := ?no_timer,
599-
conn := Conn
600-
} = St, DelayStrategy) ->
594+
is_timer_on(?no_timer) ->
595+
false;
596+
is_timer_on({_, Ref}) when is_reference(Ref) ->
597+
%% started by timer:apply_after
598+
is_timer_on(Ref);
599+
is_timer_on(Ref) when is_reference(Ref) ->
600+
erlang:read_timer(Ref) =/= false.
601+
602+
ensure_delayed_reconnect(St, DelayStrategy) ->
603+
Tref = maps:get(reconnect_timer, St, ?no_timer),
604+
case is_timer_on(Tref) of
605+
true ->
606+
St;
607+
false ->
608+
do_ensure_delayed_reconnect(St, DelayStrategy)
609+
end.
610+
611+
do_ensure_delayed_reconnect(
612+
#{config := #{reconnect_delay_ms := Delay0},
613+
client_id := ClientId,
614+
topic := Topic,
615+
partition := Partition,
616+
conn := Conn
617+
} = St, DelayStrategy) ->
601618
Attempts = maps:get(reconnect_attempts, St, 0),
602619
Attempts > 0 andalso Attempts rem 10 =:= 0 andalso
603620
log_error(Topic, Partition, "still disconnected after ~p reconnect attempts, conn=~p~n", [Attempts, Conn]),
@@ -621,10 +638,7 @@ ensure_delayed_reconnect(#{config := #{reconnect_delay_ms := Delay0},
621638
%% call timer:apply_after for both cases, do not use send_after here
622639
{ok, Tref} = timer:apply_after(Delay, erlang, send, [self(), ?reconnect]),
623640
St#{reconnect_timer => Tref, reconnect_attempts => Attempts + 1}
624-
end;
625-
ensure_delayed_reconnect(St, _Delay) ->
626-
%% timer already started
627-
St.
641+
end.
628642

629643
evaluate_pending_ack_funs(PendingAcks, [], _BaseOffset) -> PendingAcks;
630644
evaluate_pending_ack_funs(PendingAcks, [{?no_caller_ack, BatchSize} | Rest], BaseOffset) ->

src/wolff_producers.erl

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,6 @@ start_producer_and_insert_pid(Ets, ClientId, Topic, Partition, Config) ->
351351
ets:insert(Ets, {Partition, Pid}),
352352
ok.
353353

354-
%% Config is not used so far.
355354
start_partition_refresh_timer(Config) ->
356355
IntervalSeconds = maps:get(partition_count_refresh_interval_seconds, Config,
357356
?partition_count_refresh_interval_seconds),

test/wolff_supervised_tests.erl

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ partition_count_decrease_test_() ->
210210

211211
test_partition_count_decrease() ->
212212
io:format(user, "test_partition_count_decrease\n", []),
213-
ClientId = <<"test-add-more-partitions">>,
213+
ClientId = <<"test-recreate-topic-with-fewer-partitions">>,
214214
%% ensure the topic does not exist
215215
Topic = <<"test-topic-4">>,
216216
delete_topic(Topic),
@@ -273,7 +273,112 @@ test_partition_count_decrease() ->
273273
ok = application:stop(wolff),
274274
ok.
275275

276-
test_topic_recreate_test_() ->
276+
%% The last partition is temporarily missing from metadata response.
277+
%% The producer should retry to recover
278+
last_partition_missing_in_metadata_response_test_() ->
279+
{timeout, 30, %% it takes time to alter topic via cli in docker container
280+
fun() -> test_partition_missing_in_metadata_response(2) end}.
281+
282+
%% The partition in the middle is temporarily missing from metadata response.
283+
%% The producer should retry to recover
284+
mid_partition_missing_in_metadata_response_test_() ->
285+
{timeout, 30, %% it takes time to alter topic via cli in docker container
286+
fun() -> test_partition_missing_in_metadata_response(1) end}.
287+
288+
test_partition_missing_in_metadata_response(ThePartition) ->
289+
io:format(user, "test_partition_missing_in_metadata_response\n", []),
290+
ClientId = <<"test-temporarily-malformed-metadata-response">>,
291+
%% ensure the topic does not exist
292+
Topic = <<"test-topic-5">>,
293+
delete_topic(Topic),
294+
Partitions = 3,
295+
create_topic(Topic, Partitions),
296+
io:format(user, "created topic ~s with ~p partitions\n", [Topic, Partitions]),
297+
_ = application:stop(wolff), %% ensure stopped
298+
{ok, _} = application:ensure_all_started(wolff),
299+
%% always refresh metadata
300+
ClientCfg = #{min_metadata_refresh_interval => 0},
301+
{ok, ClientPid} = wolff:ensure_supervised_client(ClientId, ?HOSTS, ClientCfg),
302+
{ok, Connections} = wolff_client:get_leader_connections(ClientPid, Topic),
303+
?assertEqual(Partitions, length(Connections)),
304+
%% always send it to the partition which is going to be missing
305+
Partitioner = fun(_, _) -> ThePartition end,
306+
Name = ?FUNCTION_NAME,
307+
ProducerCfg = #{required_acks => all_isr,
308+
partitioner => Partitioner,
309+
reconnect_delay_ms => 0,
310+
name => Name,
311+
%% 0 means no aut-refresh
312+
partition_count_refresh_interval_seconds => 0
313+
},
314+
{ok, Producers} = wolff:ensure_supervised_producers(ClientId, Topic, ProducerCfg),
315+
?assertEqual(Partitions, length(ets:tab2list(Name))),
316+
Msg = #{key => ?KEY, value => <<"value">>},
317+
?assertMatch({ThePartition, _}, wolff:send_sync(Producers, [Msg], 3000)),
318+
%% mock a bad
319+
%% Kill partition leader connection
320+
Pid = get_partition_leader_connection(ClientPid, Topic, ThePartition),
321+
exit(Pid, kill),
322+
%% mock kafka_protcol to return metadata with ThePartition missing
323+
meck:new(kpro, [passthrough, no_history]),
324+
meck:expect(kpro, request_sync,
325+
fun(Connection, Req, Timeout) ->
326+
{ok, Rsp} = meck:passthrough([Connection, Req, Timeout]),
327+
case Rsp of
328+
#kpro_rsp{msg = #{topic_metadata := [#{partition_metadata := PM0} = TopicMeta]} = Meta} ->
329+
PM = lists:filter(fun(#{partition := P}) -> P =/= ThePartition end, PM0),
330+
NewRsp = Rsp#kpro_rsp{msg =Meta#{topic_metadata := [TopicMeta#{partition_metadata := PM}]}},
331+
{ok, NewRsp};
332+
_ ->
333+
{ok, Rsp}
334+
end
335+
end),
336+
?assertNot(is_pid(get_partition_leader_connection(ClientPid, Topic, ThePartition))),
337+
%% the request will be buffered, but the call times out
338+
Msg2 = #{key => ?KEY, value => <<"v">>},
339+
?assertError(timeout, wolff:send_sync(Producers, [Msg2], 100)),
340+
%% Wait for auto recover
341+
Tester = self(),
342+
meck:expect(kpro, send,
343+
fun(Conn, Req) ->
344+
Tester ! {sent, Conn},
345+
meck:passthrough([Conn, Req])
346+
end),
347+
%% Now remove the injected error (malformed metadata response)
348+
%% wolff_producer should now be able to recover
349+
meck:expect(kpro, request_sync,
350+
fun(Connection, Req, Timeout) ->
351+
meck:passthrough([Connection, Req, Timeout])
352+
end),
353+
receive
354+
{sent, NewConn} ->
355+
?assertEqual(NewConn, get_partition_leader_connection(ClientPid, Topic, ThePartition))
356+
after
357+
2000 ->
358+
%% wolff_producer retry delay is 0, but it randomize with extra 0-1000ms
359+
error(timeout)
360+
end,
361+
meck:unload(kpro),
362+
ok = fetch_and_match(ClientPid, Topic, ThePartition, 0, [Msg, Msg2]),
363+
%% cleanup
364+
ok = wolff:stop_and_delete_supervised_producers(ClientId, Topic, Name),
365+
?assertEqual([], supervisor:which_children(wolff_producers_sup)),
366+
ok = wolff:stop_and_delete_supervised_client(ClientId),
367+
?assertEqual([], supervisor:which_children(wolff_client_sup)),
368+
ok = application:stop(wolff),
369+
ok.
370+
371+
get_partition_leader_connection(Client, Topic, Partition) ->
372+
ok = wolff_client:recv_leader_connection(Client, Topic, Partition, self()),
373+
receive
374+
{leader_connection, Pid} ->
375+
Pid
376+
after
377+
20000 ->
378+
error(timeout)
379+
end.
380+
381+
topic_recreate_test_() ->
277382
{timeout, 30, %% it takes time to alter topic via cli in docker container
278383
fun test_topic_recreate/0}.
279384

0 commit comments

Comments
 (0)