Skip to content

Commit 1364d81

Browse files
add async flush, fix memory leak and race condition in client
1 parent 922cf84 commit 1364d81

5 files changed

Lines changed: 829 additions & 20 deletions

File tree

apps/kafkaesque_client/lib/kafkaesque_client/consumer.ex

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ defmodule KafkaesqueClient.Consumer do
2525
:stream_task,
2626
:consumer_group,
2727
:last_commit_time,
28-
:metrics
28+
:metrics,
29+
:closed,
30+
:auto_commit_timer
2931
]
3032

3133
@type t :: %__MODULE__{
@@ -40,7 +42,9 @@ defmodule KafkaesqueClient.Consumer do
4042
stream_task: Task.t() | nil,
4143
consumer_group: pid() | nil,
4244
last_commit_time: integer(),
43-
metrics: map()
45+
metrics: map(),
46+
closed: boolean(),
47+
auto_commit_timer: reference() | nil
4448
}
4549

4650
# Client API
@@ -164,7 +168,9 @@ defmodule KafkaesqueClient.Consumer do
164168
polls: 0,
165169
commits: 0,
166170
errors: 0
167-
}
171+
},
172+
closed: false,
173+
auto_commit_timer: nil
168174
}
169175

170176
# Start consumer group coordinator if we have a group
@@ -177,8 +183,11 @@ defmodule KafkaesqueClient.Consumer do
177183
end
178184

179185
# Start auto-commit timer if enabled
180-
if config.enable_auto_commit do
181-
Process.send_after(self(), :auto_commit, config.auto_commit_interval_ms)
186+
state = if config.enable_auto_commit do
187+
timer_ref = Process.send_after(self(), :auto_commit, config.auto_commit_interval_ms)
188+
%{state | auto_commit_timer: timer_ref}
189+
else
190+
state
182191
end
183192

184193
{:ok, state}
@@ -272,6 +281,14 @@ defmodule KafkaesqueClient.Consumer do
272281
end
273282

274283
def handle_call(:close, _from, state) do
284+
# Mark as closed to prevent further operations
285+
state = %{state | closed: true}
286+
287+
# Cancel auto-commit timer if active
288+
if state.auto_commit_timer do
289+
Process.cancel_timer(state.auto_commit_timer)
290+
end
291+
275292
# Commit offsets if auto-commit is enabled
276293
if state.config.enable_auto_commit do
277294
commit_current_offsets(state)
@@ -288,15 +305,20 @@ defmodule KafkaesqueClient.Consumer do
288305
end
289306

290307
@impl true
308+
def handle_info(:auto_commit, %{closed: true} = state) do
309+
# Consumer is closed, ignore auto-commit
310+
{:noreply, state}
311+
end
312+
291313
def handle_info(:auto_commit, state) do
292314
if should_auto_commit?(state) do
293315
commit_current_offsets(state)
294316
end
295317

296318
# Schedule next auto-commit
297-
Process.send_after(self(), :auto_commit, state.config.auto_commit_interval_ms)
319+
timer_ref = Process.send_after(self(), :auto_commit, state.config.auto_commit_interval_ms)
298320

299-
{:noreply, %{state | last_commit_time: System.monotonic_time(:millisecond)}}
321+
{:noreply, %{state | last_commit_time: System.monotonic_time(:millisecond), auto_commit_timer: timer_ref}}
300322
end
301323

302324
def handle_info({:batch_received, batch}, state) do
@@ -531,6 +553,7 @@ defmodule KafkaesqueClient.Consumer do
531553

532554
defp should_auto_commit?(state) do
533555
state.config.enable_auto_commit &&
556+
not state.closed &&
534557
MapSet.size(state.subscriptions) > 0 &&
535558
map_size(state.position) > 0
536559
end

apps/kafkaesque_client/lib/kafkaesque_client/producer.ex

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,19 @@ defmodule KafkaesqueClient.Producer do
2020
:batch_timer,
2121
:callbacks,
2222
:metrics,
23-
:flush_waiters
23+
:flush_waiters,
24+
:cleanup_timer
2425
]
2526

2627
@type t :: %__MODULE__{
2728
config: map(),
2829
pool: atom() | pid(),
2930
batch: [ProducerRecord.t()],
3031
batch_timer: reference() | nil,
31-
callbacks: %{reference() => function()},
32+
callbacks: %{reference() => {function() | {:sync, GenServer.from()}, integer()}},
3233
metrics: map(),
33-
flush_waiters: [GenServer.from()]
34+
flush_waiters: [GenServer.from()],
35+
cleanup_timer: reference() | nil
3436
}
3537

3638
@type send_callback :: (
@@ -52,6 +54,7 @@ defmodule KafkaesqueClient.Producer do
5254
- `:linger_ms` - Time to wait before sending incomplete batches (default: 100)
5355
- `:max_retries` - Maximum number of retries (default: 3)
5456
- `:retry_backoff_ms` - Backoff between retries (default: 100)
57+
- `:callback_timeout_ms` - Timeout for callbacks before cleanup (default: 30000)
5558
"""
5659
def start_link(config) when is_map(config) do
5760
case Config.validate_producer_config(config) do
@@ -81,13 +84,22 @@ defmodule KafkaesqueClient.Producer do
8184
end
8285

8386
@doc """
84-
Flushes any pending records in the batch.
87+
Flushes any pending records in the batch synchronously.
8588
"""
8689
@spec flush(GenServer.server(), timeout()) :: :ok
8790
def flush(producer, timeout \\ 5_000) do
8891
GenServer.call(producer, :flush, timeout)
8992
end
9093

94+
@doc """
95+
Flushes any pending records in the batch asynchronously.
96+
Returns immediately without waiting for the batch to be sent.
97+
"""
98+
@spec flush_async(GenServer.server()) :: :ok
99+
def flush_async(producer) do
100+
GenServer.cast(producer, :flush_async)
101+
end
102+
91103
@doc """
92104
Closes the producer, flushing any pending records.
93105
"""
@@ -118,14 +130,20 @@ defmodule KafkaesqueClient.Producer do
118130
records_sent: 0,
119131
batches_sent: 0,
120132
errors: 0,
121-
retries: 0
133+
retries: 0,
134+
callbacks_timeout: 0
122135
},
123-
flush_waiters: []
136+
flush_waiters: [],
137+
cleanup_timer: nil
124138
}
125139

126140
# Start batch timer if linger_ms > 0
127141
state = maybe_start_batch_timer(state)
128142

143+
# Start callback cleanup timer
144+
cleanup_timer = Process.send_after(self(), :cleanup_callbacks, 5_000)
145+
state = %{state | cleanup_timer: cleanup_timer}
146+
129147
{:ok, state}
130148
end
131149

@@ -135,7 +153,7 @@ defmodule KafkaesqueClient.Producer do
135153

136154
new_callbacks =
137155
if callback do
138-
Map.put(state.callbacks, ref, callback)
156+
Map.put(state.callbacks, ref, {callback, System.monotonic_time(:millisecond)})
139157
else
140158
state.callbacks
141159
end
@@ -154,12 +172,20 @@ defmodule KafkaesqueClient.Producer do
154172
end
155173
end
156174

175+
def handle_cast(:flush_async, state) do
176+
if Enum.empty?(state.batch) do
177+
{:noreply, state}
178+
else
179+
{:noreply, send_batch(state)}
180+
end
181+
end
182+
157183
@impl true
158184
def handle_call({:send_sync, record}, from, state) do
159185
ref = make_ref()
160186

161187
# Store the caller to reply when batch is sent
162-
new_callbacks = Map.put(state.callbacks, ref, {:sync, from})
188+
new_callbacks = Map.put(state.callbacks, ref, {{:sync, from}, System.monotonic_time(:millisecond)})
163189

164190
record_with_ref = {record, ref}
165191
new_batch = [record_with_ref | state.batch]
@@ -187,10 +213,13 @@ defmodule KafkaesqueClient.Producer do
187213
# Send any pending batch
188214
final_state = if Enum.empty?(state.batch), do: state, else: send_batch(state)
189215

190-
# Cancel timer if active
216+
# Cancel timers if active
191217
if final_state.batch_timer do
192218
Process.cancel_timer(final_state.batch_timer)
193219
end
220+
if final_state.cleanup_timer do
221+
Process.cancel_timer(final_state.cleanup_timer)
222+
end
194223

195224
{:stop, :normal, :ok, final_state}
196225
end
@@ -204,6 +233,33 @@ defmodule KafkaesqueClient.Producer do
204233
{:noreply, send_batch(%{state | batch_timer: nil})}
205234
end
206235

236+
def handle_info(:cleanup_callbacks, state) do
237+
now = System.monotonic_time(:millisecond)
238+
timeout_ms = Map.get(state.config, :callback_timeout_ms, 30_000)
239+
240+
{expired, active} =
241+
Map.split_with(state.callbacks, fn {_ref, {_cb, timestamp}} ->
242+
now - timestamp > timeout_ms
243+
end)
244+
245+
# Execute expired callbacks with timeout error
246+
Enum.each(expired, fn {_ref, {callback, _timestamp}} ->
247+
execute_callback_with_error(callback, :timeout)
248+
end)
249+
250+
# Update metrics
251+
new_metrics = if map_size(expired) > 0 do
252+
%{state.metrics | callbacks_timeout: state.metrics.callbacks_timeout + map_size(expired)}
253+
else
254+
state.metrics
255+
end
256+
257+
# Schedule next cleanup
258+
cleanup_timer = Process.send_after(self(), :cleanup_callbacks, 5_000)
259+
260+
{:noreply, %{state | callbacks: active, cleanup_timer: cleanup_timer, metrics: new_metrics}}
261+
end
262+
207263
# Private Functions
208264

209265
defp should_send_batch?(state) do
@@ -330,10 +386,10 @@ defmodule KafkaesqueClient.Producer do
330386
case Map.get(state.callbacks, ref) do
331387
nil -> :ok
332388

333-
{:sync, from} ->
389+
{{:sync, from}, _timestamp} ->
334390
GenServer.reply(from, {:ok, metadata})
335391

336-
callback when is_function(callback, 1) ->
392+
{callback, _timestamp} when is_function(callback, 1) ->
337393
Task.start(fn -> callback.({:ok, metadata}) end)
338394
end
339395
end)
@@ -345,15 +401,23 @@ defmodule KafkaesqueClient.Producer do
345401
case Map.get(state.callbacks, ref) do
346402
nil -> :ok
347403

348-
{:sync, from} ->
404+
{{:sync, from}, _timestamp} ->
349405
GenServer.reply(from, {:error, reason})
350406

351-
callback when is_function(callback, 1) ->
407+
{callback, _timestamp} when is_function(callback, 1) ->
352408
Task.start(fn -> callback.({:error, reason}) end)
353409
end
354410
end)
355411
end
356412

413+
defp execute_callback_with_error({:sync, from}, reason) do
414+
GenServer.reply(from, {:error, reason})
415+
end
416+
417+
defp execute_callback_with_error(callback, reason) when is_function(callback, 1) do
418+
Task.start(fn -> callback.({:error, reason}) end)
419+
end
420+
357421
defp map_acks(:none), do: :ACKS_NONE
358422
defp map_acks(:leader), do: :ACKS_LEADER
359423
defp map_acks(:all), do: :ACKS_LEADER

0 commit comments

Comments
 (0)