Current section

Files

Jump to
broadway_kafka lib broadway_kafka allocator.ex
Raw

lib/broadway_kafka/allocator.ex

defmodule BroadwayKafka.Allocator do
@moduledoc false
# The allocator is responsible to allocate Kafka partitions
# for each layer of stages in Broadway. We have an allocator
# for each processor (vertical) and an allocator for each
# batcher (horizontal).
#
# The allocator is stateful, as it must avoid rellocations,
# as that would imply lost of ordering.
use GenServer
@doc """
Starts a new allocator.
"""
def start_link({name, producers, processors})
when is_atom(name) and producers > 0 and processors > 0 do
GenServer.start_link(__MODULE__, {name, producers, processors}, name: name)
end
@doc """
Fetches the partition for a given `key`.
"""
def fetch!(name, key) when is_atom(name), do: :ets.lookup_element(name, key, 2)
@doc """
Allocates the given `new_entries` for `producer`.
"""
def allocate(name, producer, new_entries)
when is_atom(name) and producer >= 0 and is_list(new_entries),
do: GenServer.call(name, {:allocate, producer, new_entries}, :infinity)
@doc """
Returns the allocation map with partitions as keys and
a list of allocated keys as values. Used for testing.
"""
def to_map(name), do: GenServer.call(name, :to_map, :infinity)
@impl true
def init({name, producers, processors}) when producers > 0 and processors > 0 do
partitions = for i <- 0..(processors - 1), into: %{}, do: {i, %{}}
producers = for i <- 0..(producers - 1), into: %{}, do: {i, %{}}
old_producers = producers
keys = :ets.new(name, [:named_table, :set, :protected, read_concurrency: true])
old_keys = %{}
# `producers` - a map from the producer index to all partitions it holds
# `old_producers` - a map from the producer index to all partitions it previously held
# `partitions` - a map with the processor index pointing with a map as set of partitions
# `keys` - an ets set from the partition pointing to the processor index
# `old_keys` - a map from the partition pointing to its previous processor index
{:ok, {producers, old_producers, partitions, keys, old_keys}}
end
@impl true
def handle_call(:to_map, _from, {_, _, partitions, _, _} = state) do
map =
for {partition, map} <- partitions, into: %{} do
{partition, Map.keys(map)}
end
{:reply, map, state}
end
@impl true
def handle_call(
{:allocate, producer, new_entries},
_from,
{producers, old_producers, partitions, keys, old_keys}
) do
# Update the producer allocation
old_entries = Map.fetch!(old_producers, producer)
current_entries = Map.fetch!(producers, producer)
old_producers = Map.put(producers, producer, current_entries)
producers = Map.put(producers, producer, new_entries)
# We remove the current entries that are no longer used
to_remove_entries = Enum.reject(current_entries, &in_a_producer?(producers, &1))
partitions = remove_unused(to_remove_entries, partitions, keys)
# We remove the old entries that are no longer used
fun = &(in_a_producer?(producers, &1) or in_a_producer?(old_producers, &1))
old_keys = Map.drop(old_keys, Enum.reject(old_entries, fun))
# Now we reject anything that is currently assigned and
# split into unseen and seen entries.
{seen_entries, unseen_entries} =
new_entries
|> Enum.reject(&:ets.member(keys, &1))
|> Enum.split_with(&Map.has_key?(old_keys, &1))
# We first assign the ones that we know the location
# so we can do a better distribution for the unseen ones
partitions = assign_seen_entries(seen_entries, partitions, old_keys, keys)
# Now we assign unseen ones
{_, partitions, old_keys} = assign_unseen_entries(unseen_entries, partitions, old_keys, keys)
{:reply, :ok, {producers, old_producers, partitions, keys, old_keys}}
end
defp in_a_producer?(producers, entry) do
Enum.any?(producers, fn {_, entries} -> entry in entries end)
end
defp remove_unused(to_remove, partitions, keys) do
Enum.reduce(to_remove, partitions, fn entry, partitions ->
[{^entry, partition}] = :ets.take(keys, entry)
{true, partitions} = pop_in(partitions[partition][entry])
partitions
end)
end
defp assign_seen_entries(seen_entries, partitions, old_keys, keys) do
Enum.reduce(seen_entries, partitions, fn entry, partitions ->
partition = Map.fetch!(old_keys, entry)
:ets.insert(keys, {entry, partition})
put_in(partitions[partition][entry], true)
end)
end
defp assign_unseen_entries(unseen_entries, partitions, old_keys, keys) do
# Get the partitions with fewer allocations first and assign from there
sorted = sort_partitions_by_allocation(partitions)
Enum.reduce(unseen_entries, {sorted, partitions, old_keys}, fn
entry, {[{size, partition} | sorted], partitions, old_keys} ->
sorted = add_to_sorted({size + 1, partition}, sorted)
partitions = put_in(partitions[partition][entry], true)
old_keys = put_in(old_keys[entry], partition)
:ets.insert(keys, {entry, partition})
{sorted, partitions, old_keys}
end)
end
defp sort_partitions_by_allocation(partitions) do
partitions
|> Enum.map(fn {partition, map} -> {map_size(map), partition} end)
|> Enum.sort()
end
defp add_to_sorted({size, partition}, [{next_size, _} | _] = rest) when size <= next_size,
do: [{size, partition} | rest]
defp add_to_sorted(tuple, [pair | rest]),
do: [pair | add_to_sorted(tuple, rest)]
defp add_to_sorted(tuple, []),
do: [tuple]
end