Skip to content

Commit b471385

Browse files
committed
feat: make allow_auto_topic_creation configurable
1 parent fcd562c commit b471385

3 files changed

Lines changed: 230 additions & 23 deletions

File tree

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
- Fix 'failed' telemetry counter double-increment due to race condition. [#102](https://github.com/kafka4beam/wolff/pull/102)
33
- Added producer process label `{wolff_producer, KafkaClientId, Topic, Partition}` [#102](https://github.com/kafka4beam/wolff/pull/102)
44
- Upgrade to `kafka_protocol-4.2.9` for better CRC32C performance.
5+
- Add client config `allow_auto_topic_creation` (default = false).
56

67
* 4.0.13 (merge 1.5.19)
78
- Handle `record_list_too_large` error returned from Kafka.

src/wolff_client.erl

Lines changed: 65 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ check_connectivity(Hosts, ConnConfig) when Hosts =/= [] ->
129129
-spec check_if_topic_exists([host()], kpro:conn_config(), topic()) ->
130130
ok | {error, unknown_topic_or_partition | [#{host := binary(), reason := term()}] | any()}.
131131
check_if_topic_exists(Hosts, ConnConfig, Topic) when Hosts =/= [] ->
132-
case get_metadata(Hosts, ConnConfig, Topic) of
132+
case get_metadata(Hosts, ConnConfig, Topic, _IsAutoCreateAllowed = false) of
133133
{ok, {Pid, _}} ->
134134
ok = close_connection(Pid);
135135
{error, Errors} ->
@@ -148,7 +148,10 @@ safe_call(Pid, Call) ->
148148
catch exit : Reason -> {error, Reason}
149149
end.
150150

151-
%% request client to send Pid the leader connection.
151+
%% @doc request client to send Pid the leader connection.
152+
%% The caller will receive message `{leader_connection, Pid}' if the leader is successfully connected,
153+
%% otherwise `{leader_connection, {down, Reason}}'.
154+
-spec recv_leader_connection(pid(), ?NO_GROUP | producer_group(), topic(), partition(), pid(), all_partitions | pos_integer()) -> ok.
152155
recv_leader_connection(Client, Group, Topic, Partition, Caller, MaxPartitions) ->
153156
gen_server:cast(Client, {recv_leader_connection, Group, Topic, Partition, Caller, MaxPartitions}).
154157

@@ -288,7 +291,7 @@ ensure_metadata_conn(#{seed_hosts := Hosts, conn_config := ConnConfig, metadata_
288291
end.
289292

290293
check_if_topic_exists2(Pid, Topic, Timeout) when is_pid(Pid) ->
291-
case do_get_metadata(Pid, Topic, Timeout) of
294+
case do_get_metadata(Pid, Topic, Timeout, _IsAutoCreateAllowed = false) of
292295
{ok, _} ->
293296
ok;
294297
{error, Reason} ->
@@ -394,9 +397,13 @@ ensure_leader_connections(St, Group, Topic, MaxPartitions) ->
394397

395398
-spec ensure_leader_connections2(state(), producer_group(), topic(), max_partitions()) ->
396399
{ok, state()} | {error, term()}.
397-
ensure_leader_connections2(#{metadata_conn := Pid, conn_config := ConnConfig} = St, Group, Topic, MaxPartitions) when is_pid(Pid) ->
400+
ensure_leader_connections2(#{metadata_conn := Pid,
401+
conn_config := ConnConfig,
402+
config := Config
403+
} = St, Group, Topic, MaxPartitions) when is_pid(Pid) ->
398404
Timeout = maps:get(request_timeout, ConnConfig, ?DEFAULT_METADATA_TIMEOUT),
399-
case do_get_metadata(Pid, Topic, Timeout) of
405+
IsAutoCreateAllowed = maps:get(allow_auto_topic_creation, Config, false),
406+
case do_get_metadata(Pid, Topic, Timeout, IsAutoCreateAllowed) of
400407
{ok, {Brokers, PartitionMetaList}} ->
401408
ensure_leader_connections3(St, Group, Topic, Pid, Brokers, PartitionMetaList, MaxPartitions);
402409
{error, _Reason} ->
@@ -406,8 +413,11 @@ ensure_leader_connections2(#{metadata_conn := Pid, conn_config := ConnConfig} =
406413
ensure_leader_connections2(St#{metadata_conn => down}, Group, Topic, MaxPartitions)
407414
end;
408415
ensure_leader_connections2(#{conn_config := ConnConfig,
409-
seed_hosts := SeedHosts} = St, Group, Topic, MaxPartitions) ->
410-
case get_metadata(SeedHosts, ConnConfig, Topic, []) of
416+
seed_hosts := SeedHosts,
417+
config := Config
418+
} = St, Group, Topic, MaxPartitions) ->
419+
IsAutoCreateAllowed = maps:get(allow_auto_topic_creation, Config, false),
420+
case get_metadata(SeedHosts, ConnConfig, Topic, IsAutoCreateAllowed, []) of
411421
{ok, {ConnPid, {Brokers, PartitionMetaList}}} ->
412422
ensure_leader_connections3(St, Group, Topic, ConnPid, Brokers, PartitionMetaList, MaxPartitions);
413423
{error, unknown_topic_or_partition} ->
@@ -572,22 +582,22 @@ split_config(Config) ->
572582
{ConnCfg, MyCfg} = lists:partition(Pred, maps:to_list(Config)),
573583
{maps:from_list(ConnCfg), maps:from_list(MyCfg)}.
574584

575-
-spec get_metadata([_Host], _ConnConfig, topic()) ->
585+
-spec get_metadata([_Host], _ConnConfig, topic(), boolean()) ->
576586
{ok, {pid(), term()}} | {error, term()}.
577-
get_metadata(Hosts, _ConnectFun, _Topic) when Hosts =:= [] ->
587+
get_metadata(Hosts, _ConnectFun, _Topic, _IsAutoCreateAllowed) when Hosts =:= [] ->
578588
{error, no_hosts};
579-
get_metadata(Hosts, ConnectFun, Topic) ->
580-
get_metadata(Hosts, ConnectFun, Topic, []).
589+
get_metadata(Hosts, ConnectFun, Topic, IsAutoCreateAllowed) ->
590+
get_metadata(Hosts, ConnectFun, Topic, IsAutoCreateAllowed, []).
581591

582-
-spec get_metadata([_Host], _ConnConfig, topic(), [Error]) ->
592+
-spec get_metadata([_Host], _ConnConfig, topic(), boolean(), [Error]) ->
583593
{ok, {pid(), term()}} | {error, [Error] | term()}.
584-
get_metadata([], _ConnConfig, _Topic, Errors) ->
594+
get_metadata([], _ConnConfig, _Topic, _IsAutoCreateAllowed, Errors) ->
585595
{error, Errors};
586-
get_metadata([Host | Rest], ConnConfig, Topic, Errors) ->
596+
get_metadata([Host | Rest], ConnConfig, Topic, IsAutoCreateAllowed, Errors) ->
587597
case do_connect(Host, ConnConfig) of
588598
{ok, Pid} ->
589599
Timeout = maps:get(request_timeout, ConnConfig, ?DEFAULT_METADATA_TIMEOUT),
590-
case do_get_metadata(Pid, Topic, Timeout) of
600+
case do_get_metadata(Pid, Topic, Timeout, IsAutoCreateAllowed) of
591601
{ok, Result} ->
592602
{ok, {Pid, Result}};
593603
{error, Reason} ->
@@ -596,23 +606,55 @@ get_metadata([Host | Rest], ConnConfig, Topic, Errors) ->
596606
{error, Reason}
597607
end;
598608
{error, Reason} ->
599-
get_metadata(Rest, ConnConfig, Topic, [Reason | Errors])
609+
get_metadata(Rest, ConnConfig, Topic, IsAutoCreateAllowed, [Reason | Errors])
600610
end.
601611

602-
-spec do_get_metadata(connection(), topic(), timeout()) ->
612+
-spec do_get_metadata(connection(), topic(), timeout(), boolean()) ->
603613
{ok, {_Brokers, _Partitions}} | {error, term()}.
604-
do_get_metadata(Connection, Topic, Timeout) ->
614+
do_get_metadata(Connection, Topic, Timeout, IsAutoCreateAllowed) ->
605615
case kpro:get_api_versions(Connection) of
606616
{ok, Vsns} ->
607617
{_, Vsn} = maps:get(metadata, Vsns),
608-
do_get_metadata2(Vsn, Connection, Topic, Timeout);
618+
do_get_metadata2(Vsn, Connection, Topic, Timeout, IsAutoCreateAllowed);
609619
{error, Reason} ->
610620
{error, Reason}
611621
end.
612622

613-
-spec do_get_metadata2(_Vsn, connection(), topic(), timeout()) -> {ok, {_, _}} | {error, term()}.
614-
do_get_metadata2(Vsn, Connection, Topic, Timeout) ->
615-
Req = kpro_req_lib:metadata(Vsn, [Topic], _IsAutoCreateAllowed = false),
623+
-spec retry(fun(() -> {ok, term()} | {error, term()}), timeout()) -> {ok, term()} | {error, term()}.
624+
retry(Fun, Timeout) ->
625+
Deadline = erlang:monotonic_time(millisecond) + Timeout,
626+
retry_loop(Fun, Deadline).
627+
628+
-spec retry_loop(fun(() -> {ok, term()} | {error, term()}), integer()) -> {ok, term()} | {error, term()}.
629+
retry_loop(Fun, Deadline) ->
630+
case Fun() of
631+
{ok, Result} ->
632+
{ok, Result};
633+
{error, R} when R =:= unknown_topic_or_partition orelse R =:= leader_not_available ->
634+
CurrentTime = erlang:monotonic_time(millisecond),
635+
case CurrentTime >= Deadline of
636+
true ->
637+
{error, timeout};
638+
false ->
639+
%% Sleep for a short interval before retrying
640+
RemainingTime = Deadline - CurrentTime,
641+
SleepTime = min(1000, RemainingTime),
642+
timer:sleep(SleepTime),
643+
retry_loop(Fun, Deadline)
644+
end;
645+
{error, Reason} ->
646+
{error, Reason}
647+
end.
648+
649+
-spec do_get_metadata2(_Vsn, connection(), topic(), timeout(), boolean()) -> {ok, {_, _}} | {error, term()}.
650+
do_get_metadata2(Vsn, Connection, Topic, Timeout, _IsAutoCreateAllowed = true) ->
651+
retry(fun() -> do_get_metadata3(Vsn, Connection, Topic, Timeout, true) end, Timeout);
652+
do_get_metadata2(Vsn, Connection, Topic, Timeout, _IsAutoCreateAllowed = false) ->
653+
do_get_metadata3(Vsn, Connection, Topic, Timeout, false).
654+
655+
-spec do_get_metadata3(_Vsn, connection(), topic(), timeout(), boolean()) -> {ok, {_, _}} | {error, term()}.
656+
do_get_metadata3(Vsn, Connection, Topic, Timeout, IsAutoCreateAllowed) ->
657+
Req = kpro_req_lib:metadata(Vsn, [Topic], IsAutoCreateAllowed),
616658
case kpro:request_sync(Connection, Req, Timeout) of
617659
{ok, #kpro_rsp{msg = Meta}} ->
618660
BrokersMeta = kpro:find(brokers, Meta),
@@ -671,7 +713,7 @@ parse_broker_meta(BrokerMeta) ->
671713
Port = kpro:find(port, BrokerMeta),
672714
{BrokerId, {Host, Port}}.
673715

674-
log_warn(Msg, Report) -> logger:warning(Report#{msg => Msg}).
716+
log_warn(Msg, Report) -> logger:warning(Report#{msg => Msg, pid => self()}).
675717

676718
do_connect(Host, ConnConfig) ->
677719
case kpro:connect(Host, ConnConfig) of

test/wolff_client_SUITE.erl

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
-module(wolff_client_SUITE).
2+
3+
-compile([export_all, nowarn_export_all]).
4+
5+
-include("wolff.hrl").
6+
-include_lib("kafka_protocol/include/kpro.hrl").
7+
-include_lib("eunit/include/eunit.hrl").
8+
9+
-define(HOSTS, [{"localhost", 9092}]).
10+
-define(config(Key), proplists:get_value(Key, Config)).
11+
12+
all() ->
13+
Exports = ?MODULE:module_info(exports),
14+
[F || {F, _} <- Exports, lists:prefix("t_", atom_to_list(F))].
15+
16+
init_per_suite(Config) ->
17+
_ = application:stop(wolff),
18+
application:ensure_all_started(wolff),
19+
Config.
20+
21+
end_per_suite(_Config) ->
22+
application:stop(wolff),
23+
ok.
24+
25+
init_per_testcase(Case, Config) ->
26+
?MODULE:Case({init, Config}).
27+
28+
end_per_testcase(Case, Config) ->
29+
?MODULE:Case({'end', Config}).
30+
31+
t_recv_leader_connection_normal({init, Config}) ->
32+
ClientId = atom_to_binary(?FUNCTION_NAME),
33+
ClientCfg = client_config(),
34+
{ok, ClientPid} = wolff:ensure_supervised_client(ClientId, ?HOSTS, ClientCfg),
35+
[{clientid, ClientId}, {client, ClientPid} | Config];
36+
t_recv_leader_connection_normal({'end', Config}) ->
37+
wolff:stop_and_delete_supervised_client(?config(clientid));
38+
t_recv_leader_connection_normal(Config) ->
39+
Client = ?config(client),
40+
Topic = <<"test-topic">>,
41+
wolff_client:recv_leader_connection(Client, ?NO_GROUP, Topic, 0, self(), ?all_partitions),
42+
receive
43+
{leader_connection, Pid} ->
44+
?assert(is_pid(Pid))
45+
end,
46+
ok.
47+
48+
t_recv_leader_connection_with_auto_create_retry_success({init, Config}) ->
49+
ClientId = atom_to_binary(?FUNCTION_NAME),
50+
ClientCfg = client_config_with_auto_create(),
51+
{ok, ClientPid} = wolff:ensure_supervised_client(ClientId, ?HOSTS, ClientCfg),
52+
[{clientid, ClientId}, {client, ClientPid} | Config];
53+
t_recv_leader_connection_with_auto_create_retry_success({'end', Config}) ->
54+
wolff:stop_and_delete_supervised_client(?config(clientid));
55+
t_recv_leader_connection_with_auto_create_retry_success(Config) ->
56+
%% Test retry mechanism when auto-creation is enabled and topic doesn't exist initially
57+
ok = meck:new(kpro, [non_strict, no_history, no_link, passthrough]),
58+
OldRetryCount = get(metadata_retry_count),
59+
try
60+
put(metadata_retry_count, 0),
61+
meck:expect(kpro, request_sync,
62+
fun(Connection, #kpro_req{api = metadata} = Req, Timeout) ->
63+
case get(metadata_retry_count) of
64+
0 ->
65+
put(metadata_retry_count, 1),
66+
{error, unknown_topic_or_partition};
67+
1 ->
68+
put(metadata_retry_count, 2),
69+
{error, unknown_topic_or_partition};
70+
_ ->
71+
%% Return success after retries - use passthrough for real response
72+
meck:passthrough([Connection, Req, Timeout])
73+
end;
74+
(Connection, Req, Timeout) ->
75+
%% Passthrough all non-metadata requests
76+
meck:passthrough([Connection, Req, Timeout])
77+
end),
78+
Client = ?config(client),
79+
Topic = <<"test-topic">>,
80+
wolff_client:recv_leader_connection(Client, ?NO_GROUP, Topic, 0, self(), ?all_partitions),
81+
receive
82+
{leader_connection, Pid} when is_pid(Pid) ->
83+
?assert(is_pid(Pid));
84+
{leader_connection, {down, Reason}} ->
85+
%% May get error if topic doesn't exist on real Kafka
86+
?assertNotEqual(undefined, Reason)
87+
after
88+
15000 ->
89+
?assert(false, "Expected leader connection message")
90+
end,
91+
ok
92+
after
93+
meck:unload(kpro),
94+
case OldRetryCount of
95+
undefined -> erase(metadata_retry_count);
96+
_ -> put(metadata_retry_count, OldRetryCount)
97+
end
98+
end.
99+
100+
t_recv_leader_connection_with_auto_create_retry_timeout({init, Config}) ->
101+
ClientId = atom_to_binary(?FUNCTION_NAME),
102+
ClientCfg = client_config_with_auto_create(),
103+
{ok, ClientPid} = wolff:ensure_supervised_client(ClientId, ?HOSTS, ClientCfg),
104+
[{clientid, ClientId}, {client, ClientPid} | Config];
105+
t_recv_leader_connection_with_auto_create_retry_timeout({'end', Config}) ->
106+
wolff:stop_and_delete_supervised_client(?config(clientid));
107+
t_recv_leader_connection_with_auto_create_retry_timeout(Config) ->
108+
%% Test retry mechanism timeout when auto-creation is enabled
109+
ok = meck:new(kpro, [non_strict, no_history, no_link, passthrough]),
110+
try
111+
meck:expect(kpro, request_sync,
112+
fun(_Connection, #kpro_req{api = metadata} = _Req, _Timeout) ->
113+
%% Always return unknown_topic_or_partition to trigger retry timeout
114+
{error, unknown_topic_or_partition};
115+
(Connection, Req, Timeout) ->
116+
%% Passthrough all non-metadata requests
117+
meck:passthrough([Connection, Req, Timeout])
118+
end),
119+
Client = ?config(client),
120+
Topic = <<"test-topic-timeout">>,
121+
wolff_client:recv_leader_connection(Client, ?NO_GROUP, Topic, 0, self(), ?all_partitions),
122+
receive
123+
{leader_connection, {down, timeout}} ->
124+
%% Should receive down message due to timeout
125+
ok;
126+
{leader_connection, {down, Reason}} ->
127+
%% May receive timeout or another error reason depending on timing
128+
?assert(is_atom(Reason))
129+
after
130+
5000 ->
131+
?assert(false, "Expected leader connection down message with timeout")
132+
end,
133+
ok
134+
after
135+
meck:unload(kpro)
136+
end.
137+
138+
client_config() -> #{}.
139+
140+
client_config_with_auto_create() ->
141+
#{allow_auto_topic_creation => true,
142+
request_timeout => 2000
143+
}.
144+
145+
client_config_without_auto_create() ->
146+
#{allow_auto_topic_creation => false,
147+
request_timeout => 1000
148+
}.
149+
150+
%% Helper function for meck mocking (similar to pattern used in other test files)
151+
with_meck(Mod, FnName, MockedFn, Action) ->
152+
ok = meck:new(Mod, [non_strict, no_history, no_link, passthrough]),
153+
ok = meck:expect(Mod, FnName, MockedFn),
154+
try
155+
Action()
156+
after
157+
meck:unload(Mod)
158+
end.
159+
160+
%% Helper function to create a mock connection for testing
161+
mock_connection() ->
162+
%% Create a mock connection that can be used in tests
163+
%% This is a simplified mock - in real tests you might want to use a more sophisticated mock
164+
{mock_connection, self()}.

0 commit comments

Comments
 (0)