@@ -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\n data: {\" 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\n data: #{ 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\n data: #{ 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
262316end
0 commit comments