Current section
Files
Jump to
Current section
Files
lib/release_tasks/reinit.ex
defmodule KaufmannEx.ReleaseTasks.ReInit do
@moduledoc """
A Release Task that can be used by implementing services to reinit their internal state
Do not depend on the `target_offset` argument as an absolute. Events offsets are committed in batches, the final batch may include more than the final event.
Caveat: This task has not been tested in a true production environment. It likely still has some bugs.
"""
alias KaufmannEx.ReleaseTasks.ReInit
defmodule PublishNothing do
@moduledoc """
dummy publisher. Does nothing.
"""
def produce(name, _pl), do: IO.puts(name)
end
defmodule StateStore do
@moduledoc """
ETS table for storing target offset.
"""
def init() do
:ets.new(:reinit_store, [:named_table, :public])
:ok
end
def set_target_offset(offset) do
:ets.insert(:reinit_store, {:target_offset, offset})
end
def get_target_offset do
:ets.lookup(:reinit_store, :target_offset)
end
end
defmodule GenConsumer do
@moduledoc """
GenConsumer which terminates after target_offset is observed
"""
use KafkaEx.GenConsumer
def init(_topic, _partition) do
{:ok, StateStore.get_target_offset()}
end
def handle_message_set(message_set, state) do
# If all messages at offset >= target, shut it all down
case Enum.any?(message_set, fn m -> m.offset >= state[:target_offset] - 1 end) do
true ->
message_set
|> Enum.filter(fn m -> m.offset < state[:target_offset] - 1 end)
|> KaufmannEx.Stages.Producer.notify()
# System.stop()
send(:reinit, :shutdown)
{:sync_commit, state}
false ->
KaufmannEx.Stages.Producer.notify(message_set)
{:async_commit, state}
end
end
def handle_cast(:shutdown, state) do
IO.puts("Shutdown")
{:stop, :shutdown, state}
end
end
defmodule Config do
@moduledoc false
defstruct starting_offset: 0,
target_offset: :latest,
publish: false,
consumer_group: nil,
default_topic: nil,
worker: nil,
metadata: nil
end
def run(app, starting_offset \\ 0, target_offset \\ :latest, publish \\ false) do
starting_offset
|> reset_offsets(target_offset, publish)
|> consume_queued_messages(app)
end
def reset_offsets(starting_offset \\ 0, target_offset \\ :latest, publish \\ false) do
start_services()
starting_offset
|> build_args(target_offset, publish)
|> override_default_producer()
|> configure_kafka_consumer_group()
|> stop_services
end
def consume_queued_messages(%Config{} = _reinit, application) do
ensure_loaded(application)
:ok = Application.start(:kafka_ex)
:ok = Application.start(application, :transient)
Process.register(self(), :reinit)
await_shutdown(application)
# Application will run using ReInit.GenConsumer, which will terminate when target_offset is reached
end
def await_shutdown(application) do
receive do
:shutdown ->
Process.sleep(500)
Application.stop(application)
after
5000 ->
await_shutdown(application)
end
end
def start_services do
ensure_loaded(:kaufmann_ex)
:ok = Application.ensure_started(:kafka_ex)
:ok = StateStore.init()
override_default_gen_consumer()
end
defp build_args(starting_offset, target_offset, publish) do
%Config{
starting_offset: starting_offset,
target_offset: target_offset,
publish: publish,
consumer_group: KaufmannEx.Config.consumer_group(),
default_topic: KaufmannEx.Config.default_topic()
}
end
@doc """
Sets Publisher in kaufmann_ex Application Env to `PublishNothing`
"""
def override_default_producer(%Config{publish: true} = args), do: args
def override_default_producer(%Config{publish: false} = args) do
Application.put_env(
:kaufmann_ex,
:producer_mod,
KaufmannEx.ReleaseTasks.ReInit.PublishNothing
)
args
end
# Overwrites the configured GenConsumer with our custom GenServer that aborts when target_offset is reached
defp override_default_gen_consumer do
Application.put_env(
:kaufmann_ex,
:gen_consumer_mod,
KaufmannEx.ReleaseTasks.ReInit.GenConsumer
)
end
@doc """
Set Offsets of consumergroup to 0 or ealiest available offset
"""
def configure_kafka_consumer_group(%Config{} = args) do
args
|> create_worker()
|> get_metadata()
|> commit_earliest_offsets()
|> destroy_worker()
|> store_target_offset()
end
def stop_services(%Config{} = reinit) do
:ok = Application.stop(:kafka_ex)
reinit
end
defp create_worker(%Config{consumer_group: consumer_group} = args) do
{:ok, worker} = KafkaEx.create_worker(:pr, consumer_group: consumer_group)
%Config{args | worker: worker}
end
def get_metadata(%Config{default_topic: default_topic} = args) do
metadata = KafkaEx.metadata(topic: default_topic, worker_name: :pr)
earliest_offsets = Enum.flat_map(metadata.topic_metadatas, &get_earliest_offsets/1)
latest_offsets = Enum.flat_map(metadata.topic_metadatas, &get_latest_offsets/1)
target_offset =
case args.target_offset do
:latest -> Enum.max(latest_offsets)
x -> Enum.max([x | latest_offsets])
end
starting_offset =
case args.starting_offset do
:earliest -> Enum.min(earliest_offsets)
x -> Enum.min([x | earliest_offsets])
end
%Config{
args
| metadata: metadata,
starting_offset: starting_offset,
target_offset: target_offset
}
end
defp get_earliest_offsets(%KafkaEx.Protocol.Metadata.TopicMetadata{} = topic_data) do
topic_data.partition_metadatas
|> Enum.flat_map(&KafkaEx.earliest_offset(topic_data.topic, &1.partition_id))
|> Enum.flat_map(&extract_partition_offsets/1)
end
defp get_latest_offsets(%KafkaEx.Protocol.Metadata.TopicMetadata{partition_metadatas: md})
when is_list(md) and md == [],
do: [0]
defp get_latest_offsets(%KafkaEx.Protocol.Metadata.TopicMetadata{} = topic_data) do
topic_data.partition_metadatas
|> Enum.flat_map(&KafkaEx.latest_offset(topic_data.topic, &1.partition_id))
|> Enum.flat_map(&extract_partition_offsets/1)
end
defp extract_partition_offsets(response) do
response.partition_offsets
|> Enum.flat_map(fn offset -> offset.offset end)
end
defp commit_earliest_offsets(%Config{} = reinit) do
reinit.metadata.topic_metadatas
|> Enum.flat_map(&Map.get(&1, :partition_metadatas))
|> Enum.map(&Map.get(&1, :partition_id))
|> Enum.each(fn partition_id ->
KafkaEx.offset_commit(reinit.worker, %KafkaEx.Protocol.OffsetCommit.Request{
consumer_group: reinit.consumer_group,
topic: reinit.default_topic,
offset: reinit.starting_offset,
partition: partition_id
})
end)
reinit
end
defp destroy_worker(%Config{} = args) do
KafkaEx.stop_worker(args.worker)
%Config{args | worker: nil}
end
defp store_target_offset(%Config{target_offset: target_offset} = reinit) do
StateStore.set_target_offset(target_offset)
reinit
end
defp ensure_loaded(app) do
case Application.load(app) do
:ok -> nil
{:error, {:already_loaded, _}} -> nil
x -> raise RuntimeError, x
end
end
# def fetch_offset(partition) do
# [
# %KafkaEx.Protocol.OffsetFetch.Response{
# partitions: [%{error_code: :no_error, offset: offset}],
# topic: "rapids"
# }
# ] =
# KafkaEx.offset_fetch(:kafka_ex, %KafkaEx.Protocol.OffsetFetch.Request{
# consumer_group: Config.consumer_group(),
# topic: "rapids",
# partition: 0
# })
# end
end