Skip to content

Commit c69c7e6

Browse files
improve retention controller performance and test organization
1 parent 55c77a3 commit c69c7e6

11 files changed

Lines changed: 630 additions & 115 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,11 @@ jobs:
6464
- name: Check compilation warnings
6565
run: mix compile --warnings-as-errors
6666

67-
- name: Run tests
68-
run: mix cmd mix test
67+
- name: Run unit tests
68+
run: mix cmd mix test --exclude integration
69+
70+
- name: Run integration tests
71+
run: mix cmd mix test --only integration
6972

7073
- name: Check for unused dependencies
7174
run: mix deps.unlock --check-unused

apps/kafkaesque_core/lib/kafkaesque/topic/retention_controller.ex

Lines changed: 156 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -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,

apps/kafkaesque_core/test/integration/fault_tolerance_test.exs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,25 @@ defmodule Kafkaesque.Integration.FaultToleranceTest do
22
use ExUnit.Case, async: false
33
import Kafkaesque.TestHelpers
44

5+
@moduletag :integration
56
@moduletag timeout: 60_000 # 1 minute for integration tests
67

78
alias Kafkaesque.Pipeline.Producer
89
alias Kafkaesque.Storage.SingleFile
910
alias Kafkaesque.Topic.LogReader
1011
alias Kafkaesque.Topic.Supervisor, as: TopicSupervisor
1112

13+
setup_all do
14+
# Clean up all topics before running integration tests
15+
if Process.whereis(TopicSupervisor) do
16+
topics = TopicSupervisor.list_topics()
17+
Enum.each(topics, fn topic ->
18+
TopicSupervisor.delete_topic(topic.name)
19+
end)
20+
end
21+
:ok
22+
end
23+
1224
setup do
1325
setup_test_env()
1426
on_exit(fn -> clean_test_dirs() end)

apps/kafkaesque_core/test/pipeline/integration_test.exs renamed to apps/kafkaesque_core/test/integration/pipeline_integration_test.exs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
11
defmodule Kafkaesque.Pipeline.IntegrationTest do
22
use Kafkaesque.TestCase, async: false
3+
@moduletag :integration
34

45
alias Kafkaesque.Pipeline.Producer
56
alias Kafkaesque.Storage.SingleFile
67
alias Kafkaesque.Topic.Supervisor, as: TopicSupervisor
78

89
import Kafkaesque.TestHelpers, only: [wait_for_messages: 4]
910

11+
setup_all do
12+
# Clean up all topics before running integration tests
13+
if Process.whereis(TopicSupervisor) do
14+
topics = TopicSupervisor.list_topics()
15+
Enum.each(topics, fn topic ->
16+
TopicSupervisor.delete_topic(topic.name)
17+
end)
18+
end
19+
:ok
20+
end
21+
1022
setup do
1123
# Ensure clean state before test
1224
ensure_clean_state()

apps/kafkaesque_core/test/integration/produce_consume_test.exs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,25 @@ defmodule Kafkaesque.Integration.ProduceConsumeTest do
22
use ExUnit.Case, async: false
33
import Kafkaesque.TestHelpers
44

5+
@moduletag :integration
56
@moduletag timeout: 60_000 # 1 minute for integration tests
67

78
alias Kafkaesque.Offsets.DetsOffset
89
alias Kafkaesque.Pipeline.Producer
910
alias Kafkaesque.Storage.SingleFile
1011
alias Kafkaesque.Topic.LogReader
12+
alias Kafkaesque.Topic.Supervisor, as: TopicSupervisor
13+
14+
setup_all do
15+
# Clean up all topics before running integration tests
16+
if Process.whereis(TopicSupervisor) do
17+
topics = TopicSupervisor.list_topics()
18+
Enum.each(topics, fn topic ->
19+
TopicSupervisor.delete_topic(topic.name)
20+
end)
21+
end
22+
:ok
23+
end
1124

1225
setup do
1326
setup_test_env()

0 commit comments

Comments
 (0)