Skip to content

Commit 205a960

Browse files
move protobuf definitions to become dependency of both client and server
1 parent c69c7e6 commit 205a960

18 files changed

Lines changed: 422 additions & 54 deletions

File tree

.formatter.exs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
plugins: [Phoenix.LiveView.HTMLFormatter],
55
inputs: [
66
"*.{heex,ex,exs}",
7-
"{config,lib,test}/**/*.{heex,ex,exs}",
8-
"apps/*/lib/**/*.{heex,ex,exs}"
7+
"{config,lib,test}/**/*.{heex,ex,exs}"
98
]
109
]

apps/kafkaesque_client/mix.exs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ defmodule KafkaesqueClient.MixProject do
2828

2929
defp deps do
3030
[
31-
{:grpc, "~> 0.7"},
32-
{:protobuf, "~> 0.12"},
31+
{:kafkaesque_proto, in_umbrella: true},
3332
{:gun, "~> 2.0"},
3433
{:jason, "~> 1.4"},
3534
{:telemetry, "~> 1.2"},

apps/kafkaesque_core/lib/kafkaesque/storage/single_file.ex

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ defmodule Kafkaesque.Storage.SingleFile do
8282
GenServer.call(via_tuple(topic, partition), :close)
8383
end
8484

85+
def get_index_table(topic, partition) do
86+
GenServer.call(via_tuple(topic, partition), :get_index_table)
87+
end
88+
8589
@impl true
8690
def init(opts) do
8791
topic = Keyword.fetch!(opts, :topic)
@@ -240,6 +244,11 @@ defmodule Kafkaesque.Storage.SingleFile do
240244
{:reply, {:ok, offsets}, state}
241245
end
242246

247+
@impl true
248+
def handle_call(:get_index_table, _from, state) do
249+
{:reply, {:ok, state.index.table}, state}
250+
end
251+
243252
@impl true
244253
def handle_call(:close, _from, state) do
245254
# Flush and close file

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

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,6 @@ defmodule Kafkaesque.Topic.RetentionController do
202202
cutoff_time = current_time - state.retention_ms
203203

204204
# Find the offset of the first message after the cutoff time
205-
# In a real implementation, this would scan the index
206205
find_offset_after_timestamp(state, cutoff_time)
207206
end
208207
end
@@ -212,7 +211,6 @@ defmodule Kafkaesque.Topic.RetentionController do
212211
0
213212
else
214213
# Calculate total size and find offset where we exceed retention
215-
# In a real implementation, this would use the index and file stats
216214
find_offset_for_size_limit(state, state.retention_bytes)
217215
end
218216
end
@@ -231,8 +229,19 @@ defmodule Kafkaesque.Topic.RetentionController do
231229
# Get offsets range first
232230
case SingleFile.get_offsets(state.topic, state.partition) do
233231
{: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)
232+
# Try to get index table for more efficient search
233+
index_table = get_index_table(state)
234+
235+
# Use index to narrow search if available
236+
{search_start, search_end} =
237+
if index_table do
238+
narrow_search_with_index(index_table, earliest, latest, cutoff_time)
239+
else
240+
{earliest, latest}
241+
end
242+
243+
# Binary search to find the first offset after cutoff time
244+
binary_search_timestamp(state.topic, state.partition, search_start, search_end, cutoff_time)
236245

237246
_ ->
238247
# No messages or error
@@ -290,19 +299,27 @@ defmodule Kafkaesque.Topic.RetentionController do
290299
# We need to keep only size_limit_bytes, so calculate how much to remove
291300
bytes_to_remove = file_size - size_limit_bytes
292301

302+
# Try to use index for more efficient size calculation
303+
index_table = get_index_table(state)
304+
293305
# 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)
306+
find_size_based_offset(state.topic, state.partition, bytes_to_remove, index_table)
295307

296308
_ ->
297309
# File is smaller than limit, keep everything
298310
0
299311
end
300312
end
301313

302-
defp find_size_based_offset(topic, partition, bytes_to_remove) do
314+
defp find_size_based_offset(topic, partition, bytes_to_remove, index_table) do
303315
case SingleFile.get_offsets(topic, partition) do
304316
{:ok, %{earliest: earliest, latest: latest}} when latest > earliest ->
305-
scan_for_size_limit_recursive(topic, partition, earliest, latest, bytes_to_remove, 0)
317+
# Use index entries to jump more efficiently if available
318+
if index_table do
319+
scan_with_index(topic, partition, index_table, bytes_to_remove, earliest, latest)
320+
else
321+
scan_for_size_limit_recursive(topic, partition, earliest, latest, bytes_to_remove, 0)
322+
end
306323

307324
_ ->
308325
0
@@ -475,4 +492,85 @@ defmodule Kafkaesque.Topic.RetentionController do
475492
newest_timestamp: System.system_time(:millisecond)
476493
})
477494
end
495+
496+
defp get_index_table(state) do
497+
# Get the index table from SingleFile
498+
case SingleFile.get_index_table(state.topic, state.partition) do
499+
{:ok, table} -> table
500+
_ -> nil
501+
end
502+
rescue
503+
_ -> nil
504+
end
505+
506+
defp narrow_search_with_index(index_table, earliest, latest, _cutoff_time) do
507+
# Use index entries to narrow binary search range
508+
# Since we don't have timestamps in index yet, we can at least use
509+
# the index to skip large ranges
510+
entries = :ets.tab2list(index_table) |> Enum.sort()
511+
512+
# For now, just use index to segment the search space
513+
# TODO: We could later enhance index to store timestamps
514+
if length(entries) > 10 do
515+
# Use middle third of the range for search (heuristic)
516+
range = latest - earliest
517+
search_start = earliest + div(range, 3)
518+
search_end = latest - div(range, 10)
519+
{search_start, search_end}
520+
else
521+
{earliest, latest}
522+
end
523+
end
524+
525+
defp scan_with_index(topic, partition, index_table, bytes_to_remove, earliest, latest) do
526+
# Use index entries to jump through the file more efficiently
527+
entries = :ets.tab2list(index_table) |> Enum.sort()
528+
529+
# Calculate approximate bytes per offset using index positions
530+
if length(entries) > 1 do
531+
# Use index to estimate where size limit is reached
532+
scan_with_index_entries(topic, partition, entries, bytes_to_remove, 0)
533+
else
534+
# Fall back to regular scan
535+
scan_for_size_limit_recursive(topic, partition, earliest, latest, bytes_to_remove, 0)
536+
end
537+
end
538+
539+
defp scan_with_index_entries(topic, partition, entries, bytes_to_remove, accumulated) do
540+
case entries do
541+
[{offset, {position, _frame_len}} | rest] when rest != [] ->
542+
[{next_offset, {next_position, _}} | _] = rest
543+
544+
# Approximate size between these index entries
545+
segment_size = next_position - position
546+
new_accumulated = accumulated + segment_size
547+
548+
if new_accumulated >= bytes_to_remove do
549+
# Found the region, scan this segment in detail
550+
scan_for_exact_offset_in_segment(topic, partition, offset, next_offset,
551+
bytes_to_remove, accumulated)
552+
else
553+
scan_with_index_entries(topic, partition, rest, bytes_to_remove, new_accumulated)
554+
end
555+
556+
_ ->
557+
# End of index or single entry
558+
0
559+
end
560+
end
561+
562+
defp scan_for_exact_offset_in_segment(topic, partition, start_offset, end_offset,
563+
bytes_to_remove, accumulated_before) do
564+
# Scan the specific segment to find exact offset
565+
batch_size = min(50, end_offset - start_offset + 1)
566+
567+
case SingleFile.read(topic, partition, start_offset, batch_size * 100) do
568+
{:ok, messages} when messages != [] ->
569+
# Find exact cutoff in this batch
570+
find_exact_size_cutoff(messages, start_offset, bytes_to_remove, accumulated_before)
571+
572+
_ ->
573+
start_offset
574+
end
575+
end
478576
end

apps/kafkaesque_core/test/test_helper.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ Application.put_env(:kafkaesque_core, :data_dir, "/tmp/kafkaesque_test/data")
33
Application.put_env(:kafkaesque_core, :offsets_dir, "/tmp/kafkaesque_test/offsets")
44

55
# Configure faster batching for tests
6-
# NOTE: All timeout values are in milliseconds for consistency
6+
# All timeout values are in milliseconds
77
Application.put_env(:kafkaesque_core, :batch_size, 5) # Smaller batches
88
Application.put_env(:kafkaesque_core, :batch_timeout, 100) # 100ms for faster tests
99
Application.put_env(:kafkaesque_core, :fsync_interval_ms, 50) # Faster fsync
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Used by "mix format"
2+
[
3+
inputs: [
4+
"{mix,.formatter}.exs",
5+
"{config,test}/**/*.{ex,exs}",
6+
"lib/kafkaesque_proto.ex"
7+
# lib/kafkaesque.pb.ex is excluded as it's auto-generated
8+
]
9+
]

apps/kafkaesque_proto/.gitignore

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# The directory Mix will write compiled artifacts to.
2+
/_build/
3+
4+
# If you run "mix test --cover", coverage assets end up here.
5+
/cover/
6+
7+
# The directory Mix downloads your dependencies sources to.
8+
/deps/
9+
10+
# Where third-party dependencies like ExDoc output generated docs.
11+
/doc/
12+
13+
# If the VM crashes, it generates a dump, let's ignore it too.
14+
erl_crash.dump
15+
16+
# Also ignore archive artifacts (built via "mix archive.build").
17+
*.ez
18+
19+
# Ignore package tarball (built via "mix hex.build").
20+
kafkaesque_proto-*.tar
21+
22+
# Temporary files, for example, from tests.
23+
/tmp/

apps/kafkaesque_proto/README.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# KafkaesqueProto
2+
3+
Shared protobuf definitions for the Kafkaesque distributed log system.
4+
5+
## Purpose
6+
7+
This umbrella app contains the protobuf definitions that are shared between:
8+
- `kafkaesque_server` - The gRPC server implementation
9+
- `kafkaesque_client` - The gRPC client SDK
10+
11+
By centralizing the protobuf definitions in a single app, we ensure:
12+
- Single source of truth for API contracts
13+
- Automatic synchronization between client and server
14+
- No code duplication or version drift
15+
16+
## Structure
17+
18+
```
19+
apps/kafkaesque_proto/
20+
├── lib/
21+
│ └── kafkaesque.pb.ex # Generated protobuf Elixir code
22+
├── priv/
23+
│ └── protos/
24+
│ └── kafkaesque.proto # Source protobuf definitions
25+
└── mix.exs
26+
```
27+
28+
## Usage
29+
30+
### Generating Protobuf Code
31+
32+
From the project root:
33+
```bash
34+
mix proto.gen
35+
```
36+
37+
Or from this app directory with PATH set correctly:
38+
```bash
39+
export PATH=$PATH:~/.mix/escripts
40+
mix proto.gen
41+
```
42+
43+
### Using in Other Apps
44+
45+
Add to your `mix.exs` dependencies:
46+
```elixir
47+
{:kafkaesque_proto, in_umbrella: true}
48+
```
49+
50+
Then use the generated modules:
51+
```elixir
52+
alias Kafkaesque.{ProduceRequest, ProduceResponse}
53+
alias Kafkaesque.Kafkaesque.Stub # For client
54+
alias Kafkaesque.Kafkaesque.Service # For server
55+
```
56+
57+
## API Messages
58+
59+
The protobuf definitions include:
60+
- **Topic Management**: CreateTopicRequest, ListTopicsRequest/Response
61+
- **Producing**: ProduceRequest/Response with Acks enum
62+
- **Consuming**: ConsumeRequest, FetchedBatch (streaming)
63+
- **Offsets**: CommitOffsetsRequest/Response, GetOffsetsRequest/Response
64+
- **Core Types**: Topic, Record, RecordHeader
65+
66+
## Development
67+
68+
When modifying the proto file:
69+
1. Edit `priv/protos/kafkaesque.proto`
70+
2. Run `mix proto.gen` to regenerate code
71+
3. Recompile dependent apps with `mix compile --force`
72+
73+
## Prerequisites
74+
75+
Ensure you have:
76+
- `protoc` (Protocol Buffers compiler) installed
77+
- `protoc-gen-elixir` plugin installed:
78+
```bash
79+
mix escript.install hex protobuf
80+
export PATH=$PATH:~/.mix/escripts
81+
```

apps/kafkaesque_server/lib/grpc/kafkaesque.pb.ex renamed to apps/kafkaesque_proto/lib/kafkaesque.pb.ex

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ defmodule Kafkaesque.CreateTopicRequest do
137137
field(:partitions, 2, type: :int32)
138138
end
139139

140+
defmodule Kafkaesque.ListTopicsRequest do
141+
@moduledoc false
142+
143+
use Protobuf, protoc_gen_elixir_version: "0.15.0", syntax: :proto3
144+
end
145+
140146
defmodule Kafkaesque.ListTopicsResponse do
141147
@moduledoc false
142148

@@ -152,7 +158,7 @@ defmodule Kafkaesque.Kafkaesque.Service do
152158

153159
rpc(:CreateTopic, Kafkaesque.CreateTopicRequest, Kafkaesque.Topic)
154160

155-
rpc(:ListTopics, Google.Protobuf.Empty, Kafkaesque.ListTopicsResponse)
161+
rpc(:ListTopics, Kafkaesque.ListTopicsRequest, Kafkaesque.ListTopicsResponse)
156162

157163
rpc(:Produce, Kafkaesque.ProduceRequest, Kafkaesque.ProduceResponse)
158164

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
defmodule KafkaesqueProto do
2+
@moduledoc """
3+
Documentation for `KafkaesqueProto`.
4+
"""
5+
6+
@doc """
7+
Hello world.
8+
9+
## Examples
10+
11+
iex> KafkaesqueProto.hello()
12+
:world
13+
14+
"""
15+
def hello do
16+
:world
17+
end
18+
end

0 commit comments

Comments
 (0)