Skip to content

Commit f2fe628

Browse files
fix race conditions in server and add parameter validation
1 parent 1364d81 commit f2fe628

4 files changed

Lines changed: 479 additions & 138 deletions

File tree

.credo.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
{Credo.Check.Readability.WithSingleClause, []},
5656
{Credo.Check.Refactor.Apply, []},
5757
{Credo.Check.Refactor.CondStatements, []},
58-
{Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 20]},
58+
{Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 25]},
5959
{Credo.Check.Refactor.FunctionArity, [max_arity: 10]},
6060
{Credo.Check.Refactor.LongQuoteBlocks, []},
6161
{Credo.Check.Refactor.MatchInCondition, []},

apps/kafkaesque_server/lib/grpc/kafkaesque_service.ex

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -325,20 +325,21 @@ defmodule Kafkaesque.GRPC.Service do
325325

326326
case safe_send_reply(stream, batch) do
327327
:ok ->
328-
# Auto-commit if enabled
328+
# Calculate last offset first
329329
last_offset = offset + length(records) - 1
330330

331-
if auto_commit and group != "" do
332-
DetsOffset.commit(topic, partition, group, last_offset)
333-
end
334-
335-
# Update state and continue
331+
# Update state before committing
336332
new_state = %{
337333
state
338334
| messages_sent: state.messages_sent + length(records),
339335
last_heartbeat: now
340336
}
341337

338+
# Auto-commit AFTER successful send confirmation
339+
if auto_commit and group != "" do
340+
DetsOffset.commit(topic, partition, group, last_offset)
341+
end
342+
342343
# Continue consuming from the next offset
343344
consume_loop_with_state(
344345
stream,
@@ -429,7 +430,14 @@ defmodule Kafkaesque.GRPC.Service do
429430
GRPC.Server.send_reply(stream, message)
430431
:ok
431432
rescue
433+
error in [RuntimeError, ArgumentError] ->
434+
# These are typically client disconnect errors
435+
Logger.debug("Client stream closed during send: #{inspect(error)}")
436+
{:error, error}
437+
432438
error ->
439+
# Unexpected errors should be logged at warning level
440+
Logger.warning("Unexpected error sending to stream: #{inspect(error)}")
433441
{:error, error}
434442
end
435443
end

apps/kafkaesque_server/lib/kafkaesque_server/controllers/record_controller.ex

Lines changed: 185 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -65,95 +65,122 @@ defmodule KafkaesqueServer.RecordController do
6565
partition = Map.get(params, "partition", 0)
6666
group = Map.get(params, "group", "")
6767
offset = Map.get(params, "offset", "-1") |> parse_offset()
68-
max_bytes = Map.get(params, "max_bytes", "1048576") |> String.to_integer()
69-
max_wait_ms = Map.get(params, "max_wait_ms", "500") |> String.to_integer()
70-
auto_commit = Map.get(params, "auto_commit", "true") == "true"
7168
format = Map.get(params, "format", "json")
7269

73-
Logger.info("REST: Consuming from #{topic}/#{partition} for group #{group}")
74-
75-
# Get starting offset
76-
starting_offset =
77-
if group != "" do
78-
case DetsOffset.fetch(topic, partition, group) do
79-
{:ok, committed_offset} -> committed_offset + 1
80-
{:error, :not_found} -> offset
81-
_ -> offset
82-
end
83-
else
84-
offset
85-
end
86-
87-
if format == "sse" do
88-
# Server-Sent Events for streaming
89-
conn
90-
|> put_resp_header("content-type", "text/event-stream")
91-
|> put_resp_header("cache-control", "no-cache")
92-
|> send_chunked(200)
93-
|> sse_consume_loop(
94-
topic,
95-
partition,
96-
group,
97-
starting_offset,
98-
max_bytes,
99-
max_wait_ms,
100-
auto_commit
101-
)
102-
else
103-
# Single fetch for JSON response
104-
try do
105-
case LogReader.consume(topic, partition, group, starting_offset, max_bytes, max_wait_ms) do
106-
{:ok, records} ->
107-
# Auto-commit if enabled
108-
if auto_commit and group != "" and records != [] do
109-
last_offset = starting_offset + length(records) - 1
110-
DetsOffset.commit(topic, partition, group, last_offset)
70+
# Validate and parse parameters
71+
case validate_consume_params(params) do
72+
{:ok, validated_params} ->
73+
max_bytes = validated_params.max_bytes
74+
max_wait_ms = validated_params.max_wait_ms
75+
auto_commit = validated_params.auto_commit
76+
77+
Logger.info("REST: Consuming from #{topic}/#{partition} for group #{group}")
78+
79+
# Get starting offset
80+
starting_offset =
81+
if group != "" do
82+
case DetsOffset.fetch(topic, partition, group) do
83+
{:ok, committed_offset} -> committed_offset + 1
84+
{:error, :not_found} -> offset
85+
_ -> offset
11186
end
87+
else
88+
offset
89+
end
11290

113-
# Get high watermark
114-
{_, high_watermark} =
115-
case SingleFile.get_offsets(topic, partition) do
116-
{:ok, %{latest: hw}} -> {0, hw}
117-
_ -> {0, starting_offset}
118-
end
119-
120-
# Determine actual base offset for response
121-
actual_base_offset =
122-
case starting_offset do
123-
:earliest ->
124-
0
125-
126-
:latest ->
127-
if records == [], do: high_watermark, else: List.first(records)[:offset] || 0
128-
129-
n when is_integer(n) ->
130-
n
131-
end
132-
133-
json(conn, %{
134-
topic: topic,
135-
partition: partition,
136-
high_watermark: high_watermark,
137-
base_offset: actual_base_offset,
138-
records: Enum.map(records, &format_record/1)
139-
})
140-
141-
{:error, reason} ->
142-
conn
143-
|> put_status(:internal_server_error)
144-
|> json(%{error: "Failed to consume: #{inspect(reason)}"})
145-
end
146-
catch
147-
:exit, {:noproc, _} ->
91+
if format == "sse" do
92+
# Server-Sent Events for streaming
14893
conn
149-
|> put_status(:internal_server_error)
150-
|> json(%{error: "Failed to consume: topic or partition does not exist"})
151-
end
94+
|> put_resp_header("content-type", "text/event-stream")
95+
|> put_resp_header("cache-control", "no-cache")
96+
|> send_chunked(200)
97+
|> sse_consume_loop(
98+
topic,
99+
partition,
100+
group,
101+
starting_offset,
102+
max_bytes,
103+
max_wait_ms,
104+
auto_commit
105+
)
106+
else
107+
# Single fetch for JSON response
108+
try do
109+
case LogReader.consume(topic, partition, group, starting_offset, max_bytes, max_wait_ms) do
110+
{:ok, records} ->
111+
# Auto-commit if enabled
112+
if auto_commit and group != "" and records != [] do
113+
last_offset = starting_offset + length(records) - 1
114+
DetsOffset.commit(topic, partition, group, last_offset)
115+
end
116+
117+
# Get high watermark
118+
{_, high_watermark} =
119+
case SingleFile.get_offsets(topic, partition) do
120+
{:ok, %{latest: hw}} -> {0, hw}
121+
_ -> {0, starting_offset}
122+
end
123+
124+
# Determine actual base offset for response
125+
actual_base_offset =
126+
case starting_offset do
127+
:earliest ->
128+
0
129+
130+
:latest ->
131+
if records == [], do: high_watermark, else: List.first(records)[:offset] || 0
132+
133+
n when is_integer(n) ->
134+
n
135+
end
136+
137+
json(conn, %{
138+
topic: topic,
139+
partition: partition,
140+
high_watermark: high_watermark,
141+
base_offset: actual_base_offset,
142+
records: Enum.map(records, &format_record/1)
143+
})
144+
145+
{:error, reason} ->
146+
conn
147+
|> put_status(:internal_server_error)
148+
|> json(%{error: "Failed to consume: #{inspect(reason)}"})
149+
end
150+
catch
151+
:exit, {:noproc, _} ->
152+
conn
153+
|> put_status(:internal_server_error)
154+
|> json(%{error: "Failed to consume: topic or partition does not exist"})
155+
end
156+
end
157+
158+
{:error, error_msg} ->
159+
conn
160+
|> put_status(:bad_request)
161+
|> json(%{error: error_msg})
152162
end
153163
end
154164

155165
# Private functions
156166

167+
defp validate_consume_params(params) do
168+
max_bytes = Map.get(params, "max_bytes", "1048576") |> String.to_integer()
169+
max_wait_ms = Map.get(params, "max_wait_ms", "500") |> String.to_integer()
170+
auto_commit = Map.get(params, "auto_commit", "true") == "true"
171+
172+
cond do
173+
max_bytes < 0 or max_bytes > 100_000_000 ->
174+
{:error, "Invalid max_bytes: must be between 0 and 100000000"}
175+
176+
max_wait_ms < 0 or max_wait_ms > 60_000 ->
177+
{:error, "Invalid max_wait_ms: must be between 0 and 60000"}
178+
179+
true ->
180+
{:ok, %{max_bytes: max_bytes, max_wait_ms: max_wait_ms, auto_commit: auto_commit}}
181+
end
182+
end
183+
157184
defp convert_headers(headers) when is_list(headers) do
158185
Enum.map(headers, fn
159186
%{"key" => k, "value" => v} -> {k, v}
@@ -202,61 +229,88 @@ defmodule KafkaesqueServer.RecordController do
202229
offset,
203230
max_bytes,
204231
max_wait_ms,
205-
auto_commit
232+
auto_commit,
233+
start_time \\ nil
206234
) do
207-
case LogReader.consume(topic, partition, group, offset, max_bytes, max_wait_ms) do
208-
{:ok, records} when records != [] ->
209-
# Send records as SSE events
210-
Enum.each(records, fn record ->
211-
event_data =
212-
Jason.encode!(%{
213-
topic: topic,
214-
partition: partition,
215-
record: format_record(record)
216-
})
217-
218-
chunk(conn, "data: #{event_data}\n\n")
219-
end)
220-
221-
# Auto-commit if enabled
222-
last_offset = offset + length(records) - 1
235+
start_time = start_time || System.monotonic_time(:millisecond)
236+
max_duration = Application.get_env(:kafkaesque_server, :sse_max_duration_ms, 300_000)
223237

224-
if auto_commit and group != "" do
225-
DetsOffset.commit(topic, partition, group, last_offset)
226-
end
238+
# Check if stream has been running too long
239+
if System.monotonic_time(:millisecond) - start_time > max_duration do
240+
chunk(conn, "event: close\ndata: {\"reason\": \"max_duration_exceeded\"}\n\n")
241+
conn
242+
else
243+
case LogReader.consume(topic, partition, group, offset, max_bytes, max_wait_ms) do
244+
{:ok, records} when records != [] ->
245+
# Send records as SSE events
246+
Enum.each(records, fn record ->
247+
event_data =
248+
Jason.encode!(%{
249+
topic: topic,
250+
partition: partition,
251+
record: format_record(record)
252+
})
253+
254+
case chunk(conn, "data: #{event_data}\n\n") do
255+
{:ok, conn} -> conn
256+
{:error, _reason} ->
257+
# Client disconnected - exit the loop by returning conn
258+
throw({:client_disconnected, conn})
259+
end
260+
end)
261+
262+
# Auto-commit if enabled
263+
last_offset = offset + length(records) - 1
264+
265+
if auto_commit and group != "" do
266+
DetsOffset.commit(topic, partition, group, last_offset)
267+
end
268+
269+
# Continue consuming
270+
sse_consume_loop(
271+
conn,
272+
topic,
273+
partition,
274+
group,
275+
last_offset + 1,
276+
max_bytes,
277+
max_wait_ms,
278+
auto_commit,
279+
start_time
280+
)
281+
282+
{:ok, []} ->
283+
# No records, send heartbeat and continue
284+
case chunk(conn, ":heartbeat\n\n") do
285+
{:ok, conn} ->
286+
Process.sleep(100)
287+
288+
sse_consume_loop(
289+
conn,
290+
topic,
291+
partition,
292+
group,
293+
offset,
294+
max_bytes,
295+
max_wait_ms,
296+
auto_commit,
297+
start_time
298+
)
299+
300+
{:error, _reason} ->
301+
# Client disconnected
302+
conn
303+
end
227304

228-
# Continue consuming
229-
sse_consume_loop(
230-
conn,
231-
topic,
232-
partition,
233-
group,
234-
last_offset + 1,
235-
max_bytes,
236-
max_wait_ms,
237-
auto_commit
238-
)
239-
240-
{:ok, []} ->
241-
# No records, send heartbeat and continue
242-
chunk(conn, ":heartbeat\n\n")
243-
Process.sleep(100)
244-
245-
sse_consume_loop(
246-
conn,
247-
topic,
248-
partition,
249-
group,
250-
offset,
251-
max_bytes,
252-
max_wait_ms,
253-
auto_commit
254-
)
255-
256-
{:error, reason} ->
257-
Logger.error("SSE consume error: #{inspect(reason)}")
258-
chunk(conn, "event: error\ndata: #{Jason.encode!(%{error: inspect(reason)})}\n\n")
259-
conn
305+
{:error, reason} ->
306+
Logger.error("SSE consume error: #{inspect(reason)}")
307+
chunk(conn, "event: error\ndata: #{Jason.encode!(%{error: inspect(reason)})}\n\n")
308+
conn
309+
end
260310
end
311+
catch
312+
{:client_disconnected, conn} ->
313+
Logger.info("SSE client disconnected for #{topic}/#{partition}")
314+
conn
261315
end
262316
end

0 commit comments

Comments
 (0)