@@ -228,58 +228,179 @@ defmodule Kafkaesque.Topic.RetentionController do
228228 end
229229
230230 defp find_offset_after_timestamp ( state , cutoff_time ) do
231- # Get the storage and index to find the first offset after cutoff_time
232- case Registry . lookup (
233- Kafkaesque.TopicRegistry ,
234- { :storage , state . topic , state . partition }
235- ) do
236- [ { pid , _ } ] when is_pid ( pid ) ->
237- case SingleFile . get_offsets ( state . topic , state . partition ) do
238- { :ok , % { latest: latest_offset } } when latest_offset > 0 ->
239- # Binary search through offsets to find retention point
240- # For now, estimate based on time proportion
241- # TODO: might scan actual message timestamps
242- current_time = System . system_time ( :millisecond )
243- time_range = current_time - cutoff_time
244-
245- if time_range > 0 and state . retention_ms > 0 do
246- # Estimate offset based on time proportion
247- retention_ratio = min ( 1.0 , time_range / state . retention_ms )
248- round ( latest_offset * ( 1 - retention_ratio ) )
249- else
250- 0
251- end
252-
253- _ ->
254- 0
255- end
231+ # Get offsets range first
232+ case SingleFile . get_offsets ( state . topic , state . partition ) do
233+ { :ok , % { earliest: earliest , latest: latest } } when latest > earliest ->
234+ # Use binary search to find the first offset after cutoff time
235+ binary_search_timestamp ( state . topic , state . partition , earliest , latest , cutoff_time )
256236
257237 _ ->
238+ # No messages or error
258239 0
259240 end
260241 end
261242
262- defp find_offset_for_size_limit ( state , size_limit_bytes ) do
263- # Mock implementation - in reality would calculate cumulative size
264- # Returns an offset that keeps total size under the limit
243+ defp binary_search_timestamp ( topic , partition , low , high , cutoff_time ) do
244+ # Binary search for the first message with timestamp >= cutoff_time
245+ binary_search_timestamp_impl ( topic , partition , low , high , cutoff_time , nil )
246+ end
247+
248+ defp binary_search_timestamp_impl ( topic , partition , low , high , cutoff_time , best_found ) do
249+ if low > high do
250+ # Search complete, return the best offset found
251+ best_found || 0
252+ else
253+ mid = div ( low + high , 2 )
254+
255+ # Read a single message at the midpoint
256+ case SingleFile . read ( topic , partition , mid , 1 ) do
257+ { :ok , [ message ] } ->
258+ message_time = message [ :timestamp_ms ] || 0
259+
260+ if message_time >= cutoff_time do
261+ # This message is after cutoff, search earlier for a better match
262+ new_best = mid
263+ binary_search_timestamp_impl ( topic , partition , low , mid - 1 , cutoff_time , new_best )
264+ else
265+ # This message is before cutoff, search later
266+ binary_search_timestamp_impl ( topic , partition , mid + 1 , high , cutoff_time , best_found )
267+ end
265268
266- file_path = Path . join ( [ state . storage_path , state . topic , "#{ state . partition } .log" ] )
269+ { :ok , [ ] } ->
270+ # No message at this offset, try earlier
271+ binary_search_timestamp_impl ( topic , partition , low , mid - 1 , cutoff_time , best_found )
272+
273+ _ ->
274+ # Error reading, try different range
275+ if mid > low do
276+ binary_search_timestamp_impl ( topic , partition , low , mid - 1 , cutoff_time , best_found )
277+ else
278+ best_found || 0
279+ end
280+ end
281+ end
282+ end
283+
284+ defp find_offset_for_size_limit ( state , size_limit_bytes ) do
285+ # Calculate cumulative size and find offset where we exceed retention
286+ file_path = Path . join ( [ state . storage_path , state . topic , "p-#{ state . partition } .log" ] )
267287
268288 case File . stat ( file_path ) do
269289 { :ok , % { size: file_size } } when file_size > size_limit_bytes ->
270- # Calculate approximate offset based on file size
271- # This is very simplified - real implementation would be precise
272- retention_ratio = size_limit_bytes / file_size
273- estimated_offset = round ( 1000 * ( 1 - retention_ratio ) )
274- max ( 0 , estimated_offset )
290+ # We need to keep only size_limit_bytes, so calculate how much to remove
291+ bytes_to_remove = file_size - size_limit_bytes
292+
293+ # Scan through messages to find where we've accumulated enough bytes to remove
294+ find_size_based_offset ( state . topic , state . partition , bytes_to_remove )
295+
296+ _ ->
297+ # File is smaller than limit, keep everything
298+ 0
299+ end
300+ end
301+
302+ defp find_size_based_offset ( topic , partition , bytes_to_remove ) do
303+ case SingleFile . get_offsets ( topic , partition ) do
304+ { :ok , % { earliest: earliest , latest: latest } } when latest > earliest ->
305+ scan_for_size_limit_recursive ( topic , partition , earliest , latest , bytes_to_remove , 0 )
275306
276307 _ ->
277308 0
278309 end
279310 end
280311
312+ defp scan_for_size_limit_recursive (
313+ topic ,
314+ partition ,
315+ current_offset ,
316+ end_offset ,
317+ bytes_to_remove ,
318+ accumulated
319+ ) do
320+ if current_offset > end_offset or accumulated >= bytes_to_remove do
321+ # We've accumulated enough bytes to remove, return this offset as the new watermark
322+ current_offset
323+ else
324+ batch_size = min ( 50 , end_offset - current_offset + 1 )
325+
326+ case SingleFile . read ( topic , partition , current_offset , batch_size * 100 ) do
327+ { :ok , messages } when messages != [ ] ->
328+ # Calculate size of these messages
329+ batch_bytes = calculate_messages_size ( messages )
330+ new_accumulated = accumulated + batch_bytes
331+ messages_count = length ( messages )
332+ next_offset = current_offset + messages_count
333+
334+ if new_accumulated >= bytes_to_remove do
335+ # Found the cutoff point - scan within this batch for exact offset
336+ find_exact_size_cutoff ( messages , current_offset , bytes_to_remove , accumulated )
337+ else
338+ # Continue scanning
339+ scan_for_size_limit_recursive (
340+ topic ,
341+ partition ,
342+ next_offset ,
343+ end_offset ,
344+ bytes_to_remove ,
345+ new_accumulated
346+ )
347+ end
348+
349+ _ ->
350+ # No more messages or error, return current offset
351+ current_offset
352+ end
353+ end
354+ end
355+
356+ defp find_exact_size_cutoff ( messages , base_offset , bytes_to_remove , accumulated_before ) do
357+ # Find the exact message where we cross the threshold
358+ { offset , _ } =
359+ Enum . reduce_while ( messages , { base_offset , accumulated_before } , fn msg , { offset , acc } ->
360+ msg_size = calculate_message_size ( msg )
361+ new_acc = acc + msg_size
362+
363+ if new_acc >= bytes_to_remove do
364+ { :halt , { offset + 1 , new_acc } }
365+ else
366+ { :cont , { offset + 1 , new_acc } }
367+ end
368+ end )
369+
370+ offset
371+ end
372+
373+ defp calculate_message_size ( msg ) do
374+ key_size = byte_size ( msg [ :key ] || << >> )
375+ value_size = byte_size ( msg [ :value ] || << >> )
376+ headers_size = calculate_headers_size ( msg [ :headers ] || [ ] )
377+
378+ # Frame overhead: 4 (length) + 1 (magic) + 1 (attr) + 8 (timestamp) + 4 (key_len) + 4 (val_len) + 2 (headers_len)
379+ frame_overhead = 24
380+ key_size + value_size + headers_size + frame_overhead
381+ end
382+
383+ defp calculate_messages_size ( messages ) do
384+ Enum . reduce ( messages , 0 , fn msg , acc ->
385+ key_size = byte_size ( msg [ :key ] || << >> )
386+ value_size = byte_size ( msg [ :value ] || << >> )
387+ headers_size = calculate_headers_size ( msg [ :headers ] || [ ] )
388+
389+ # Frame overhead: 4 (length) + 1 (magic) + 1 (attr) + 8 (timestamp) + 4 (key_len) + 4 (val_len) + 2 (headers_len)
390+ frame_overhead = 24
391+ acc + key_size + value_size + headers_size + frame_overhead
392+ end )
393+ end
394+
395+ defp calculate_headers_size ( headers ) do
396+ Enum . reduce ( headers , 0 , fn { k , v } , acc ->
397+ # Each header has: 2 (key_len) + key + 2 (val_len) + value
398+ acc + 4 + byte_size ( to_string ( k ) ) + byte_size ( to_string ( v ) )
399+ end )
400+ end
401+
281402 defp calculate_retention_stats ( state ) do
282- # Get real statistics from storage
403+ # Get statistics from storage
283404 stats =
284405 case Registry . lookup (
285406 Kafkaesque.TopicRegistry ,
0 commit comments