Packages

Cafex is a pure Elixir implementation of Kafka client.

Current section

Files

Jump to
misha_cafex lib cafex topic server.ex
Raw

lib/cafex/topic/server.ex

defmodule Cafex.Topic.Server do
@moduledoc false
use GenServer
require Logger
defmodule State do
@moduledoc false
defstruct name: nil,
metadata: nil,
feed_brokers: [],
dead_brokers: [],
conn: nil,
client_id: nil,
timer: nil
end
alias Cafex.Connection
alias Cafex.Protocol.Fetch.Request, as: FetchRequest
alias Cafex.Protocol.Offset.Request, as: OffsetRequest
alias Cafex.Protocol.Metadata.Request, as: MetadataRequest
# ===================================================================
# API
# ===================================================================
def start_link(name, brokers, opts \\ []) when is_binary(name) do
GenServer.start_link __MODULE__, [name, brokers, opts]
end
def metadata(pid) do
GenServer.call pid, :metadata
end
def fetch(pid, partition, offset) do
GenServer.call pid, {:fetch, partition, offset}
end
def offset(pid, partition, time, max) do
GenServer.call pid, {:offset, partition, time, max}
end
def stop(pid) do
GenServer.call pid, :stop
end
# ===================================================================
# GenServer callbacks
# ===================================================================
def init([name, brokers, opts]) do
Logger.info fn -> "Starting topic server: #{name}" end
Process.flag(:trap_exit, true)
client_id = Keyword.get(opts, :client_id, "cafex")
:rand.seed(:exs1024)
state = %State{name: name,
feed_brokers: Enum.shuffle(brokers),
client_id: client_id} |> fetch_metadata
{:ok, state}
end
def handle_call(:metadata, _from, state) do
%{metadata: metadata} = state = fetch_metadata(state)
{:reply, metadata, state}
end
def handle_call({:fetch, partition, offset}, _from, state) do
reply = fetch_messages(partition, offset, state)
{:reply, reply, state}
end
def handle_call({:offset, partition, time, max}, _from, state) do
reply = get_offset(partition, time, max, state)
{:reply, reply, state}
end
def handle_call(:stop, _from, state) do
{:stop, :normal, :ok, state}
end
def handle_info({:timeout, tref, :close_conn}, %{timer: tref} = state) do
state = close_conn(%{state | timer: nil})
{:noreply, state}
end
def handle_info({:EXIT, conn, reason}, %{conn: conn} = state) do
Logger.error "Connection closed unexpectedly: #{inspect reason}"
{:noreply, %{state | conn: nil}}
end
def handle_info({:EXIT, _pid, :normal}, state) do
{:noreply, state}
end
def handle_info({:EXIT, _pid, reason}, state) do
Logger.error "Connection closed unexpectedly: #{inspect reason}"
{:noreply, state}
end
def terminate(_reason, state) do
close_conn(state)
:ok
end
# ===================================================================
# Internal functions
# ===================================================================
defp schedule_close_conn(%{timer: nil} = state) do
tref = :erlang.start_timer(5000, self, :close_conn)
%{state | timer: tref}
end
defp schedule_close_conn(%{timer: tref} = state) do
:erlang.cancel_timer(tref)
schedule_close_conn(%{state | timer: nil})
end
defp close_conn(%{conn: nil} = state), do: state
defp close_conn(%{conn: conn, feed_brokers: [broker|_], timer: timer} = state) do
Logger.debug fn -> "Closing connection to: #{inspect broker}" end
Connection.close(conn)
if timer, do: :erlang.cancel_timer(timer)
%{state | conn: nil, timer: nil}
end
defp open_conn(%{conn: nil, feed_brokers: [], dead_brokers: deads} = state) do
:timer.sleep(1000)
open_conn(%{state | feed_brokers: deads,
dead_brokers: []})
end
defp open_conn(%{conn: nil, feed_brokers: [{host, port}|rest],
dead_brokers: deads, client_id: client_id} = state) do
case Connection.start_link(host, port, client_id: client_id) do
{:ok, pid} ->
Logger.debug fn -> "Connection opened: #{host}:#{port}" end
schedule_close_conn(%{state | conn: pid})
{:error, reason} ->
Logger.warn "Failed to open connection to broker: #{host}:#{port}, reason: #{inspect reason}"
open_conn(%{state | conn: nil, feed_brokers: rest,
dead_brokers: [{host, port}|deads]})
end
end
defp open_conn(%{conn: conn} = state) when is_pid(conn) do
schedule_close_conn(state)
end
defp fetch_metadata(state) do
state = %{name: topic,
feed_brokers: [broker|rest],
dead_brokers: deads,
conn: conn_pid} = open_conn(state)
request = %MetadataRequest{ topics: [topic] }
case Connection.request(conn_pid, request) do
{:ok, %{topics: [%{error: :unknown_topic_or_partition}]}} ->
throw :unknown_topic_or_partition
{:ok, %{topics: [%{error: :leader_not_available}]}} ->
:timer.sleep(100)
fetch_metadata(state)
{:ok, metadata} ->
metadata = extract_metadata(metadata)
brokers = metadata.brokers
|> Dict.values
|> Enum.shuffle
%{state | metadata: metadata, feed_brokers: brokers}
{:error, reason} ->
Logger.error "Failed to get metadata from: #{inspect broker}, reason: #{inspect reason}"
%{state | feed_brokers: rest, dead_brokers: [broker|deads]}
|> close_conn
|> fetch_metadata
end
end
defp extract_metadata(metadata) do
brokers = metadata.brokers |> Enum.map(fn b -> {b.node_id, {b.host, b.port}} end)
|> Enum.into(%{})
[%{name: name, partitions: partitions}] = metadata.topics
leaders = partitions |> Enum.map(fn p -> {p.partition_id, p.leader} end)
|> Enum.into(%{})
%{name: name, brokers: brokers, leaders: leaders, partitions: length(partitions)}
end
@wait_time 100
@min_bytes 32 * 1024
@max_bytes 1024 * 1024
defp fetch_messages(partition, _offset, %{partitions: partitions}) when partition >= partitions do
{:error, :unknown_partition}
end
defp fetch_messages(partition, offset, %{name: name, metadata: metadata}) do
{host, port} = leader_for_partition(metadata, partition)
request = %FetchRequest{max_wait_time: @wait_time,
min_bytes: @min_bytes,
topics: [{name, [{partition, offset, @max_bytes}]}]}
{:ok, pid} = Connection.start_link(host, port)
try do
Connection.request(pid, request)
after
Connection.close(pid)
end
end
defp leader_for_partition(%{brokers: brokers, leaders: leaders}, partition) do
leader = Map.get(leaders, partition)
Map.get(brokers, leader)
end
defp get_offset(partition, _time, _max, %{partitions: partitions}) when partition >= partitions do
{:error, :unknown_partition}
end
defp get_offset(partition, time, max, %{name: name, metadata: metadata}) do
{host, port} = leader_for_partition(metadata, partition)
request = %OffsetRequest{topics: [{name, [{partition, time, max}]}]}
{:ok, pid} = Connection.start_link(host, port)
try do
Connection.request(pid, request)
after
Connection.close(pid)
end
end
end