Current section

Files

Jump to
jetstream lib off_broadway acknowledger.ex
Raw

lib/off_broadway/acknowledger.ex

with {:module, _} <- Code.ensure_compiled(Broadway) do
defmodule OffBroadway.Jetstream.Acknowledger do
@moduledoc false
alias Broadway.Acknowledger
@behaviour Acknowledger
@typedoc """
Acknowledgement data to be placed in `Broadway.Message`.
"""
@type ack_data :: %{
:reply_to => String.t(),
optional(:on_failure) => ack_option,
optional(:on_success) => ack_option
}
@typedoc """
An acknowledgement action.
## Options
* `ack` - Acknowledges a message was completely handled.
* `nack` - Signals that the message will not be processed now and will be redelivered.
* `term` - Tells the server to stop redelivery of a message without acknowledging it.
"""
@type ack_option :: :ack | :nack | :term
@type ack_ref :: reference()
@type t :: %__MODULE__{
connection_name: String.t(),
on_failure: ack_option,
on_success: ack_option
}
@enforce_keys [:connection_name]
defstruct [:connection_name, on_failure: :nack, on_success: :ack]
@doc """
Initializes the acknowledger.
## Options
* `connection_name` - The name of Gnat process or Gnat connection supervisor.
* `on_success` - The action to perform on successful messages. Defaults to `:ack`.
* `on_failure` - The action to perform on unsuccessful messages. Defaults to `:nack`.
"""
@spec init(opts :: keyword()) :: {:ok, ack_ref()} | {:error, message :: binary()}
def init(opts) do
with {:ok, on_success} <- validate(opts, :on_success, :ack),
{:ok, on_failure} <- validate(opts, :on_failure, :nack) do
state = %__MODULE__{
connection_name: opts[:connection_name],
on_success: on_success,
on_failure: on_failure
}
ack_ref = make_ref()
put_config(ack_ref, state)
{:ok, ack_ref}
end
end
defp put_config(reference, state) do
:persistent_term.put({__MODULE__, reference}, state)
end
@doc """
Returns an `acknowledger` to be put in `Broadway.Message`.
"""
@spec builder(ack_ref()) :: (String.t() -> {__MODULE__, ack_ref(), ack_data()})
def builder(ack_ref) do
&{__MODULE__, ack_ref, %{reply_to: &1}}
end
def get_config(reference) do
:persistent_term.get({__MODULE__, reference})
end
@impl Acknowledger
def ack(ack_ref, successful, failed) do
config = get_config(ack_ref)
# Creating maps where actions (`:ack`, `:term`, ...) are the keys and lists of reply
# subjects are the values (the default `on_success` and `on_failure` action can be
# modified for individual messages).
success_actions = group_reply_topics_by_actions(successful, :on_success, config)
failure_actions = group_reply_topics_by_actions(failed, :on_failure, config)
# Merge the maps of actions and acknowledge messages. At this point we don't care which
# messages have succeeded and which have failed, we only care about the action (`:ack`,
# `:term`, ...) which can be same for a succeeded and a failed message.
success_actions
|> Map.merge(failure_actions, fn _, success_reply_subjects, failure_reply_subjects ->
success_reply_subjects ++ failure_reply_subjects
end)
|> ack_messages(config)
:ok
end
defp group_reply_topics_by_actions(messages, key, config) do
Enum.group_by(messages, &message_action(&1, key, config), &extract_reply_to/1)
end
defp message_action(%{acknowledger: {_, _, ack_data}}, key, config) do
Map.get_lazy(ack_data, key, fn -> default_action_from_config(key, config) end)
end
defp default_action_from_config(:on_success, %{on_success: action}), do: action
defp default_action_from_config(:on_failure, %{on_failure: action}), do: action
defp extract_reply_to(message) do
{_, _, %{reply_to: reply_to}} = message.acknowledger
reply_to
end
defp ack_messages(actions_and_reply_topics, config) do
Enum.each(actions_and_reply_topics, fn {action, reply_topics} ->
reply_topics
|> Enum.each(&apply_ack_func(action, &1, config.connection_name))
end)
end
defp apply_ack_func(:ack, reply_to, connection_name) do
Jetstream.ack(%{gnat: connection_name, reply_to: reply_to})
end
defp apply_ack_func(:nack, reply_to, connection_name) do
Jetstream.nack(%{gnat: connection_name, reply_to: reply_to})
end
defp apply_ack_func(:term, reply_to, connection_name) do
Jetstream.ack_term(%{gnat: connection_name, reply_to: reply_to})
end
@impl Acknowledger
def configure(_ack_ref, ack_data, options) do
options = assert_valid_config!(options)
ack_data = Map.merge(ack_data, Map.new(options))
{:ok, ack_data}
end
defp assert_valid_config!(options) do
Enum.map(options, fn
{:on_success, value} -> {:on_success, validate_option!(:on_success, value)}
{:on_failure, value} -> {:on_failure, validate_option!(:on_failure, value)}
{other, _value} -> raise ArgumentError, "unsupported option #{inspect(other)}"
end)
end
defp validate(opts, key, default) when is_list(opts) do
validate_option(key, opts[key] || default)
end
defp validate_option(action, value) when action in [:on_success, :on_failure] do
case validate_action(value) do
{:ok, result} ->
{:ok, result}
:error ->
{:error, "#{inspect(value)} is not a valid #{inspect(action)} option"}
end
end
defp validate_option(_, value), do: {:ok, value}
defp validate_option!(key, value) do
case validate_option(key, value) do
{:ok, value} -> value
{:error, message} -> raise ArgumentError, message
end
end
defp validate_action(action) when action in [:ack, :nack, :term], do: {:ok, action}
defp validate_action(_), do: :error
end
end