defmodule Elsa.Consumer.Worker do @moduledoc """ Defines the worker GenServer that is managed by the DynamicSupervisor. Workers are instantiated and assigned to a specific topic/partition and process messages according to the specified message handler module passed in from the manager before calling the ack function to notify the cluster the messages have been successfully processed. ## Self-healing on `offset_out_of_range` Under brod's default `offset_reset_policy: reset_by_subscriber`, when a committed offset falls outside the available log (topic recreated, retention truncation, cluster cutover), brod casts a `kafka_fetch_error` with `error_code: :offset_out_of_range` and suspends fetching. The worker handles this by **re-subscribing in place** at `config[:begin_offset]` (default `:latest`), which un-suspends brod and resumes consumption without a process restart. Because the reset jumps to the policy target, **messages between the stale committed offset and the reset target may be skipped**. Callers running non-idempotent handlers should be aware that a reset can cause records to be dropped (with `:latest`) — and conversely, replay/double-processing is possible if you choose `:earliest`. Callers needing replay should set `begin_offset: :earliest` and design their handlers to tolerate reprocessing. This self-heal only applies under `reset_by_subscriber`. With the `:reset_to_latest` / `:reset_to_earliest` policies brod resets internally and never casts the error, so the worker's `offset_out_of_range` clause never fires. If re-subscribe fails after exhausting retries, the worker stops with `{:resubscribe_failed, reason}` (fail loud — never silently stall). Group consumers recover via `Elsa.Group.Manager`'s monitor, which restarts the worker. **Simple consumers (`Elsa.Consumer.Worker.Initializer`) are `restart: :temporary` with no monitor, so a stopped worker is not restarted automatically — they require external supervision.** This is an accepted, documented limitation: the in-place re-subscribe happy path is what protects simple consumers; the `:stop` is only the failure path. """ use GenServer, restart: :temporary, shutdown: 10_000 import Elsa.ElsaSupervisor, only: [registry: 1] import Record, only: [defrecord: 2, extract: 2] alias Elsa.ElsaRegistry alias Elsa.Group.Acknowledger require Logger defrecord :kafka_message_set, extract(:kafka_message_set, from_lib: "brod/include/brod.hrl") defrecord :kafka_fetch_error, extract(:kafka_fetch_error, from_lib: "brod/include/brod.hrl") @subscribe_delay 200 @subscribe_retries 20 @start_failure_delay 5_000 defmodule State do @moduledoc """ The running state of the worker process. """ defstruct [ :connection, :topic, :partition, :generation_id, :offset, :handler, :handler_init_args, :handler_state, :config, :consumer_pid ] end @type init_opts :: [ connection: Elsa.connection(), topic: Elsa.topic(), partition: Elsa.partition(), generation_id: non_neg_integer, begin_offset: non_neg_integer, handler: module, handler_init_args: term, config: :brod.consumer_config() ] @doc """ Start the worker process and init the state with the given config. """ @spec start_link(init_opts) :: GenServer.on_start() def start_link(init_args) do GenServer.start_link(__MODULE__, init_args) end def init(init_args) do Process.flag(:trap_exit, true) state = %State{ connection: Keyword.fetch!(init_args, :connection), topic: Keyword.fetch!(init_args, :topic), partition: Keyword.fetch!(init_args, :partition), generation_id: Keyword.get(init_args, :generation_id), offset: Keyword.fetch!(init_args, :begin_offset), handler: Keyword.fetch!(init_args, :handler), handler_init_args: Keyword.get(init_args, :handler_init_args, []), config: Keyword.get(init_args, :config, []) } Process.put(:elsa_connection, state.connection) Process.put(:elsa_topic, state.topic) Process.put(:elsa_partition, state.partition) Process.put(:elsa_generation_id, state.generation_id) ElsaRegistry.register_name({registry(state.connection), :"worker_#{state.topic}_#{state.partition}"}, self()) {:ok, state, {:continue, :subscribe}} end def handle_continue(:subscribe, state) do registry = registry(state.connection) with {:ok, consumer_pid} <- start_consumer(state.connection, state.topic, state.partition, state.config), :yes <- ElsaRegistry.register_name({registry, :"consumer_#{state.topic}_#{state.partition}"}, consumer_pid), :ok <- subscribe(consumer_pid, state) do {:ok, handler_state} = state.handler.init(state.handler_init_args) {:noreply, %{state | consumer_pid: consumer_pid, handler_state: handler_state}} else {:error, reason} -> Logger.warn( "Unable to subscribe to topic/partition/offset(#{state.topic}/#{state.partition}/#{state.offset}), reason #{inspect(reason)}" ) Process.sleep(@start_failure_delay) {:stop, reason, state} end end def handle_info({_consumer_pid, kafka_message_set(topic: topic, partition: partition, messages: messages)}, state) do transformed_messages = transform_messages(topic, partition, messages, state) case send_messages_to_handler(transformed_messages, state) do {ack, new_handler_state} when ack in [:ack, :acknowledge] -> offset = transformed_messages |> List.last() |> Map.get(:offset) ack_messages(topic, partition, offset, state) {:noreply, %{state | offset: offset, handler_state: new_handler_state}} {ack, offset, new_handler_state} when ack in [:ack, :acknowledge] -> ack_messages(topic, partition, offset, state) {:noreply, %{state | offset: offset, handler_state: new_handler_state}} {no_ack, new_handler_state} when no_ack in [:no_ack, :noop] -> {:noreply, %{state | handler_state: new_handler_state}} {:continue, new_handler_state} -> offset = transformed_messages |> List.last() |> Map.get(:offset) :ok = :brod_consumer.ack(state.consumer_pid, offset) {:noreply, %{state | handler_state: new_handler_state}} end end def handle_info({consumer_pid, kafka_fetch_error(error_code: :offset_out_of_range)}, state) when consumer_pid == state.consumer_pid do Logger.warning( "Offset out of range for #{state.topic}/#{state.partition} " <> "(was #{inspect(state.offset)}); resetting to " <> "#{inspect(Keyword.get(state.config, :begin_offset, :latest))} per policy and re-subscribing" ) # :undefined makes determine_subscriber_opts/1 fall back to config begin_offset. new_state = %{state | offset: :undefined} case subscribe(consumer_pid, new_state) do :ok -> {:noreply, new_state} {:error, reason} -> {:stop, {:resubscribe_failed, reason}, state} end end def handle_info( {consumer_pid, kafka_fetch_error(error_code: code, error_desc: desc)}, state ) when consumer_pid == state.consumer_pid do Logger.error("Unhandled kafka_fetch_error #{inspect(code)} for #{state.topic}/#{state.partition}: #{inspect(desc)}") # For these codes brod stops the (linked) consumer, so the worker then stops # via the {:EXIT, ...} clause below; this {:noreply, state} just logs first. {:noreply, state} end def handle_info({:EXIT, _pid, reason}, state) do {:stop, reason, state} end def terminate(_reason, %{consumer_pid: nil} = state) do state end def terminate(reason, state) do _ = :brod_consumer.unsubscribe(state.consumer_pid, self()) Process.exit(state.consumer_pid, reason) state end defp transform_messages(topic, partition, messages, state) do Enum.map(messages, &Elsa.Message.new(&1, topic: topic, partition: partition, generation_id: state.generation_id)) end defp send_messages_to_handler(messages, state) do state.handler.handle_messages(messages, state.handler_state) end defp ack_messages(_topic, _partition, offset, %{generation_id: nil} = state) do :brod_consumer.ack(state.consumer_pid, offset) end defp ack_messages(topic, partition, offset, state) do Acknowledger.ack(state.connection, topic, partition, state.generation_id, offset) end defp start_consumer(connection, topic, partition, config) do registry = registry(connection) brod_client = ElsaRegistry.whereis_name({registry, :brod_client}) :brod_consumer.start_link(brod_client, topic, partition, config) end defp subscribe(consumer_pid, state) do retries = Keyword.get(state.config, :subscribe_retries, @subscribe_retries) do_subscribe(consumer_pid, state, retries) end defp do_subscribe(_consumer_pid, _state, 0) do {:error, :failed_subscription} end defp do_subscribe(consumer_pid, state, retries) do opts = determine_subscriber_opts(state) case :brod_consumer.subscribe(consumer_pid, self(), opts) do {:error, reason} -> Logger.warn( "Retrying to subscribe to topic #{state.topic} parition #{state.partition} offset #{state.offset} reason #{inspect(reason)}" ) Process.sleep(Keyword.get(state.config, :subscribe_delay, @subscribe_delay)) do_subscribe(consumer_pid, state, retries - 1) :ok -> Logger.info("Subscribing to topic #{state.topic} partition #{state.partition} offset #{state.offset}") :ok end end defp determine_subscriber_opts(state) do begin_offset = case state.offset do :undefined -> Keyword.get(state.config, :begin_offset, :latest) offset -> offset end Keyword.put(state.config, :begin_offset, begin_offset) end end