Packages
gnat
1.15.2
1.16.0
1.15.2
1.15.1
1.15.0
1.14.1
1.14.0
1.14.0-rc1
1.14.0-rc0
1.13.1
1.13.0
1.12.1
1.12.0
1.11.1
1.11.0
1.10.2
1.10.2-rc1
1.10.2-rc0
1.10.1
1.10.0
1.9.1
1.9.0
1.8.5
1.8.4
1.8.3
1.8.2
1.8.1
1.7.1
1.7.0
1.6.0
1.5.2
1.5.1
1.5.0
1.4.0
1.3.0
1.3.0-rc0
1.2.1
1.2.0
1.1.3
1.1.2
1.1.1
1.1.0
1.0.1
1.0.0
0.7.0
0.6.1
0.6.0
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.2
0.4.1
0.4.0
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.3
0.1.2
0.1.1
0.1.0
A nats client in pure elixir. Resilience, Performance, Ease-of-Use.
Current section
Files
Jump to
Current section
Files
lib/gnat/jetstream/pull_consumer/server.ex
defmodule Gnat.Jetstream.PullConsumer.Server do
@moduledoc false
require Logger
use Connection
alias Gnat.Jetstream.PullConsumer.ConnectionOptions
alias Gnat.Jetstream.API.Util
defstruct [
:connection_options,
:state,
:listening_topic,
:module,
:subscription_id,
:connection_pid,
:connection_monitor_ref,
:consumer_name,
:last_response_at,
current_retry: 0,
buffer: []
]
def init(%{module: module, init_arg: init_arg}) do
_ = Process.put(:"$initial_call", {module, :init, 1})
case module.init(init_arg) do
{:ok, state, connection_options} when is_list(connection_options) ->
Process.flag(:trap_exit, true)
connection_options = ConnectionOptions.validate!(connection_options)
gen_state = %__MODULE__{
connection_options: connection_options,
state: state,
listening_topic: Util.reply_inbox(connection_options.inbox_prefix),
module: module,
consumer_name: connection_options.consumer_name
}
schedule_heartbeat_check(gen_state)
{:connect, :init, gen_state}
:ignore ->
:ignore
{:stop, _} = stop ->
stop
end
end
def connect(
_,
%__MODULE__{
connection_options: %ConnectionOptions{
stream_name: stream_name,
consumer_name: consumer_name,
consumer: consumer,
connection_name: connection_name,
connection_retry_timeout: connection_retry_timeout,
connection_retries: connection_retries,
domain: domain
},
listening_topic: listening_topic,
module: module
} = gen_state
) do
Logger.debug(
"#{__MODULE__} for #{stream_name}.#{gen_state.consumer_name} is connecting to Gnat.",
module: module,
listening_topic: listening_topic,
connection_name: connection_name
)
# Mint a fresh inbox on every (re)connect. Reusing the same topic across
# reconnects lets messages from an abandoned pull on the previous
# connection leak into the new subscription's mailbox; rotating it
# makes those messages addressed to a topic we no longer subscribe to,
# and the per-message topic guard in handle_info/2 will drop any that
# do still arrive (e.g. delivered locally before unsub propagated).
listening_topic =
Util.reply_inbox(gen_state.connection_options.inbox_prefix)
with {:ok, conn} <- connection_pid(connection_name),
monitor_ref = Process.monitor(conn),
{:ok, consumer_info} <-
ensure_consumer_exists(
conn,
stream_name,
consumer_name,
consumer,
domain
),
:ok <- validate_batch_ack_policy(gen_state.connection_options, consumer_info),
final_consumer_name = consumer_info.name,
state = maybe_handle_connected(module, consumer_info, gen_state.state),
{:ok, sid} <- Gnat.sub(conn, self(), listening_topic),
gen_state = %{
gen_state
| subscription_id: sid,
connection_pid: conn,
connection_monitor_ref: monitor_ref,
consumer_name: final_consumer_name,
listening_topic: listening_topic,
state: state
},
:ok <- initial_fetch(gen_state, conn),
gen_state = %{gen_state | current_retry: 0},
gen_state = touch_response(gen_state) do
{:ok, gen_state}
else
{:error, reason} ->
if gen_state.current_retry >= connection_retries do
Logger.error(
"""
#{__MODULE__} for #{stream_name}.#{gen_state.consumer_name} failed to connect to NATS and \
retries limit has been exhausted. Stopping.
""",
module: module,
listening_topic: listening_topic,
connection_name: connection_name
)
{:stop, :timeout, %{gen_state | current_retry: 0}}
else
Logger.debug(
"""
#{__MODULE__} for #{stream_name}.#{gen_state.consumer_name} failed to connect to Gnat \
and will retry. Reason: #{inspect(reason)}
""",
module: module,
listening_topic: listening_topic,
connection_name: connection_name
)
gen_state = Map.update!(gen_state, :current_retry, &(&1 + 1))
{:backoff, connection_retry_timeout, gen_state}
end
end
end
def disconnect(
{:close, from},
%__MODULE__{
connection_options: %ConnectionOptions{
stream_name: stream_name,
connection_name: connection_name
},
listening_topic: listening_topic,
subscription_id: subscription_id,
module: module,
consumer_name: consumer_name
} = gen_state
) do
Logger.debug(
"#{__MODULE__} for #{stream_name}.#{consumer_name} is disconnecting from Gnat.",
module: module,
listening_topic: listening_topic,
subscription_id: subscription_id,
connection_name: connection_name
)
with {:ok, conn} <- connection_pid(connection_name),
true <- Process.demonitor(gen_state.connection_monitor_ref, [:flush]),
:ok <- Gnat.unsub(conn, subscription_id) do
Logger.debug(
"#{__MODULE__} for #{stream_name}.#{consumer_name} is shutting down.",
module: module,
listening_topic: listening_topic,
subscription_id: subscription_id,
connection_name: connection_name
)
Connection.reply(from, :ok)
{:stop, :shutdown, gen_state}
end
end
defp ensure_consumer_exists(gnat, stream_name, consumer_name, nil, domain) do
# Durable consumer case - just check it exists
try do
case Gnat.Jetstream.API.Consumer.info(gnat, stream_name, consumer_name, domain) do
{:ok, consumer_info} -> {:ok, consumer_info}
{:error, reason} -> {:error, reason}
end
catch
:exit, reason -> {:error, {:process_exit, reason}}
kind, reason -> {:error, {kind, reason}}
end
end
defp ensure_consumer_exists(gnat, _stream_name, nil, consumer_struct, _domain) do
# Ephemeral or auto-cleanup durable consumer case - create it
try do
with {:ok, consumer_definition} <- validate_consumer_for_creation(consumer_struct),
{:ok, consumer_info} <- Gnat.Jetstream.API.Consumer.create(gnat, consumer_definition) do
{:ok, consumer_info}
end
catch
:exit, reason -> {:error, {:process_exit, reason}}
kind, reason -> {:error, {kind, reason}}
end
end
defp validate_consumer_for_creation(consumer_definition) do
cond do
consumer_definition.durable_name == nil && consumer_definition.inactive_threshold != nil ->
{:error, "ephemeral consumers (durable_name: nil) cannot have inactive_threshold set"}
consumer_definition.durable_name != nil && consumer_definition.inactive_threshold == nil ->
{:error,
"durable consumers specified via :consumer must have inactive_threshold set for auto-cleanup"}
true ->
{:ok, consumer_definition}
end
end
defp validate_batch_ack_policy(%ConnectionOptions{batch_size: batch_size}, consumer_info)
when batch_size > 1 do
case consumer_info.config.ack_policy do
:all ->
:ok
other ->
{:error,
"batch_size > 1 requires ack_policy: :all on the consumer, " <>
"got: #{inspect(other)}. With ack_policy: :explicit, " <>
"only the last message in each batch would be acknowledged and the " <>
"server would redeliver the rest"}
end
end
defp validate_batch_ack_policy(_connection_options, _consumer_info), do: :ok
defp maybe_handle_connected(module, consumer_info, state) do
if function_exported?(module, :handle_connected, 2) do
{:ok, state} = module.handle_connected(consumer_info, state)
state
else
state
end
end
defp maybe_handle_status(message, %__MODULE__{module: module, state: state} = gen_state) do
if function_exported?(module, :handle_status, 2) do
{:ok, new_state} = module.handle_status(message, state)
%{gen_state | state: new_state}
else
gen_state
end
end
defp connection_pid(connection_name) when is_pid(connection_name) do
if Process.alive?(connection_name) do
{:ok, connection_name}
else
{:error, :not_alive}
end
end
defp connection_pid(connection_name) do
case Process.whereis(connection_name) do
nil -> {:error, :not_found}
pid -> {:ok, pid}
end
end
# -- Drop messages from a stale Gnat subscription. After a reconnect we
# mint a fresh inbox + subscription id (sid), but messages already in
# flight to the prior subscription can still arrive locally. Acking,
# processing, or re-pulling on those would corrupt the new subscription's
# state, so we log and drop.
#
# Gnat's :msg struct doesn't carry the inbox we subscribed on — only the
# original publish topic, the connection pid, and an integer sid. So we
# identify the current subscription by the (gnat_pid, sid) pair. This
# tuple is unique even across Gnat process restarts: a new Gnat process
# has a different pid, so its first sid=1 won't collide with the old
# process's sid=1. --
def handle_info(
{:msg, %{gnat: msg_gnat, sid: msg_sid} = message},
%__MODULE__{connection_pid: conn, subscription_id: sid} = gen_state
)
when is_integer(sid) and (msg_gnat != conn or msg_sid != sid) do
Logger.warning(
"#{__MODULE__} dropping message from stale subscription " <>
"(msg=#{inspect(msg_gnat)}/#{inspect(msg_sid)}, " <>
"current=#{inspect(conn)}/#{inspect(sid)}, " <>
"topic=#{inspect(Map.get(message, :topic))}, " <>
"status=#{inspect(Map.get(message, :status))})",
module: gen_state.module,
listening_topic: gen_state.listening_topic,
connection_name: gen_state.connection_options.connection_name
)
{:noreply, gen_state}
end
# -- 100 is an idle heartbeat — the pull is still alive, do nothing but
# feed the watchdog and invoke the user callback. Applies to both
# single-message and batch modes; do NOT re-pull (issuing a fresh pull on
# every heartbeat causes the server-side pull queue to grow without
# bound). --
def handle_info({:msg, %{status: "100"} = message}, %__MODULE__{} = gen_state) do
gen_state = touch_response(gen_state)
gen_state = maybe_handle_status(message, gen_state)
{:noreply, gen_state}
end
# -- Batch mode: any other status (404/408 terminators, 409 leadership
# change / max_ack_pending / max_waiting / consumer-deleted, etc.) ends
# the outstanding pull request. Process any partial buffer and issue a
# new pull so the consumer doesn't stall. --
def handle_info(
{:msg, %{status: status, gnat: gnat} = message},
%__MODULE__{
connection_options: %ConnectionOptions{batch_size: batch_size},
buffer: buffer
} = gen_state
)
when batch_size > 1 and is_binary(status) and status != "" do
gen_state = touch_response(gen_state)
gen_state = maybe_handle_status(message, gen_state)
case buffer do
[] ->
# Nothing buffered — long-poll for new messages.
request_batch(gnat, gen_state, :tailing)
{:noreply, gen_state}
_messages ->
# Partial batch — process what we have, then try for more.
gen_state = process_and_ack_batch(gen_state)
request_batch(gnat, gen_state, :catching_up)
{:noreply, gen_state}
end
end
# -- Single-message mode: informational status. Drop + re-pull so the
# consumer doesn't stall. Matches the nats.go convention of never exposing
# status messages to the user's message handler. --
def handle_info(
{:msg, %{status: status} = message},
%__MODULE__{} = gen_state
)
when is_binary(status) and status != "" do
gen_state = touch_response(gen_state)
gen_state = maybe_handle_status(message, gen_state)
next_message(message.gnat, gen_state)
{:noreply, gen_state}
end
# -- Batch mode: data message — buffer until batch is full --
def handle_info(
{:msg, message},
%__MODULE__{
connection_options: %ConnectionOptions{batch_size: batch_size},
buffer: buffer
} = gen_state
)
when batch_size > 1 do
gen_state = touch_response(gen_state)
buffer = [message | buffer]
gen_state = %{gen_state | buffer: buffer}
if length(buffer) >= batch_size do
gen_state = process_and_ack_batch(gen_state)
request_batch(message.gnat, gen_state, :catching_up)
{:noreply, gen_state}
else
{:noreply, gen_state}
end
end
# -- Single-message mode (batch_size == 1, the default) --
def handle_info(
{:msg, message},
%__MODULE__{
connection_options: %ConnectionOptions{
stream_name: stream_name,
connection_name: connection_name
},
listening_topic: listening_topic,
subscription_id: subscription_id,
state: state,
module: module,
consumer_name: consumer_name
} = gen_state
) do
gen_state = touch_response(gen_state)
Logger.debug(
"""
#{__MODULE__} for #{stream_name}.#{consumer_name} received a message: \
#{inspect(message, pretty: true)}
""",
module: module,
listening_topic: listening_topic,
subscription_id: subscription_id,
connection_name: connection_name
)
case module.handle_message(message, state) do
{:ack, state} ->
Gnat.Jetstream.ack_next(message, listening_topic)
gen_state = %{gen_state | state: state}
{:noreply, gen_state}
{:nack, state} ->
Gnat.Jetstream.nack(message)
next_message(message.gnat, gen_state)
gen_state = %{gen_state | state: state}
{:noreply, gen_state}
{:term, state} ->
Gnat.Jetstream.ack_term(message)
next_message(message.gnat, gen_state)
gen_state = %{gen_state | state: state}
{:noreply, gen_state}
{:noreply, state} ->
next_message(message.gnat, gen_state)
gen_state = %{gen_state | state: state}
{:noreply, gen_state}
end
end
def handle_info(
{:DOWN, ref, :process, _pid, _reason},
%__MODULE__{
connection_options: %ConnectionOptions{
connection_name: connection_name,
stream_name: stream_name
},
subscription_id: subscription_id,
listening_topic: listening_topic,
module: module,
connection_monitor_ref: monitor_ref,
consumer_name: consumer_name
} = gen_state
)
when ref == monitor_ref do
Logger.debug(
"""
#{__MODULE__} for #{stream_name}.#{consumer_name}:
NATS connection has died. PullConsumer is reconnecting.
""",
module: module,
listening_topic: listening_topic,
subscription_id: subscription_id,
connection_name: connection_name
)
{:connect, :reconnect, reset_to_disconnected(gen_state)}
end
# -- Heartbeat watchdog: periodic check for "have we heard anything from
# the server recently?". Runs on a fixed cadence regardless of connection
# state. While disconnected (last_response_at == nil) it does nothing
# except reschedule itself. While connected, if the gap since the last
# inbound message exceeds `2 * idle_heartbeat`, we treat the pull as
# stuck and force a reconnect — this catches dropped pull requests where
# the TCP connection is otherwise healthy and no 408/409 is forthcoming. --
def handle_info(:heartbeat_check, %__MODULE__{} = gen_state) do
schedule_heartbeat_check(gen_state)
case heartbeat_status(gen_state) do
:ok ->
{:noreply, gen_state}
{:expired, gap_ms, threshold_ms} ->
%__MODULE__{
connection_options: %ConnectionOptions{
connection_name: connection_name,
stream_name: stream_name
},
listening_topic: listening_topic,
subscription_id: subscription_id,
module: module,
consumer_name: consumer_name
} = gen_state
Logger.warning(
"""
#{__MODULE__} for #{stream_name}.#{consumer_name} has not received \
any traffic from the server in #{gap_ms}ms (threshold #{threshold_ms}ms). \
Forcing reconnect.
""",
module: module,
listening_topic: listening_topic,
subscription_id: subscription_id,
connection_name: connection_name
)
:telemetry.execute(
[:gnat, :jetstream, :pull_consumer, :heartbeat_expired],
%{gap_ms: gap_ms, threshold_ms: threshold_ms},
%{
module: module,
stream_name: stream_name,
consumer_name: consumer_name,
connection_name: connection_name
}
)
# Tear down the old subscription and monitor before reconnecting so
# we don't get a stale {:DOWN, ...} or stray inbox messages from
# the connection we're abandoning.
{:connect, :heartbeat_expired, reset_to_disconnected(gen_state)}
end
end
def handle_info(
other,
%__MODULE__{
connection_options: %ConnectionOptions{
connection_name: connection_name,
stream_name: stream_name
},
subscription_id: subscription_id,
listening_topic: listening_topic,
module: module,
consumer_name: consumer_name
} = gen_state
) do
Logger.debug(
"""
#{__MODULE__} for #{stream_name}.#{consumer_name} received
unexpected message: #{inspect(other, pretty: true)}
""",
module: module,
listening_topic: listening_topic,
subscription_id: subscription_id,
connection_name: connection_name
)
{:noreply, gen_state}
end
def handle_call(
:close,
from,
%__MODULE__{
connection_options: %ConnectionOptions{
connection_name: connection_name,
stream_name: stream_name
},
subscription_id: subscription_id,
listening_topic: listening_topic,
module: module,
consumer_name: consumer_name
} = gen_state
) do
Logger.debug("#{__MODULE__} for #{stream_name}.#{consumer_name} received :close call.",
module: module,
listening_topic: listening_topic,
subscription_id: subscription_id,
connection_name: connection_name
)
{:disconnect, {:close, from}, gen_state}
end
defp next_message(conn, gen_state) do
%{
connection_options: %ConnectionOptions{
stream_name: stream_name,
domain: domain,
request_expires: expires,
idle_heartbeat: idle_heartbeat
},
consumer_name: consumer_name,
listening_topic: listening_topic
} = gen_state
# Single-message-mode pulls long-poll the same way batch mode does in
# :tailing — expires bounds the wait, idle_heartbeat keeps the watchdog
# fed during quiet periods.
Gnat.Jetstream.API.Consumer.request_next_message(
conn,
stream_name,
consumer_name,
listening_topic,
domain,
expires: expires,
idle_heartbeat: idle_heartbeat
)
end
defp initial_fetch(gen_state, conn) do
if gen_state.connection_options.batch_size > 1 do
request_batch(conn, gen_state, :catching_up)
else
next_message(conn, gen_state)
end
end
defp request_batch(conn, gen_state, mode) do
%{
connection_options: %ConnectionOptions{
stream_name: stream_name,
batch_size: batch_size,
domain: domain,
request_expires: expires,
idle_heartbeat: idle_heartbeat
},
consumer_name: consumer_name,
listening_topic: listening_topic
} = gen_state
opts =
case mode do
:catching_up ->
# no_wait short-polls — server replies immediately with 404 if
# the stream is empty, so heartbeats are unnecessary.
[batch: batch_size, no_wait: true]
:tailing ->
[batch: batch_size, expires: expires, idle_heartbeat: idle_heartbeat]
end
Gnat.Jetstream.API.Consumer.request_next_message(
conn,
stream_name,
consumer_name,
listening_topic,
domain,
opts
)
end
# ---- Heartbeat watchdog helpers ----
defp touch_response(%__MODULE__{} = gen_state) do
%{gen_state | last_response_at: System.monotonic_time(:millisecond)}
end
defp schedule_heartbeat_check(%__MODULE__{
connection_options: %ConnectionOptions{heartbeat_check_interval: interval}
}) do
Process.send_after(self(), :heartbeat_check, interval)
:ok
end
defp heartbeat_status(%__MODULE__{last_response_at: nil}), do: :ok
defp heartbeat_status(%__MODULE__{
last_response_at: last,
connection_options: %ConnectionOptions{idle_heartbeat: idle_heartbeat_ns}
}) do
threshold_ms = div(idle_heartbeat_ns, 1_000_000) * 2
gap_ms = System.monotonic_time(:millisecond) - last
if gap_ms > threshold_ms do
{:expired, gap_ms, threshold_ms}
else
:ok
end
end
# Tear down everything tied to the current Gnat connection and reset the
# corresponding fields in gen_state. Side effects and state mutation live
# together so callers don't have to remember to do both.
#
# Safe to call from any path:
# * Heartbeat-expired reconnect: connection still alive, demonitor +
# unsub do real work.
# * :DOWN handler: monitor already fired (demonitor is a no-op) and the
# Gnat pid is gone (connection_pid returns :not_found, unsub skipped).
defp reset_to_disconnected(%__MODULE__{} = gen_state) do
# Always flush any buffered messages through the user's handler
# before tearing down. The handler invocation is pure consumer-side
# work and is always safe; the ack is best-effort against the
# original Gnat pid. If the ack fails, the server will redeliver
# after ack_wait and at-least-once is preserved.
#
# Why we MUST do this for batch mode: under ack_policy: :all,
# acking message N moves the consumer's ack_floor to N, covering
# everything ≤ N. If we silently drop a partial batch [101..115]
# and a later batch's ack on seq 125 arrives, the server treats
# 101..115 as acked and never redelivers them — a true loss.
gen_state =
if gen_state.buffer == [] do
gen_state
else
Logger.info(
"[#{__MODULE__}] flushing #{length(gen_state.buffer)} buffered messages " <>
"before reconnect for #{gen_state.connection_options.stream_name}.#{gen_state.consumer_name}"
)
try do
process_and_ack_batch(gen_state)
catch
:exit, reason ->
# Ack publish failed (Gnat pid is dead, dying, or wedged).
# The user's handler already ran for these messages, and
# the server will redeliver after ack_wait — both halves
# of at-least-once are covered.
Logger.info(
"[#{__MODULE__}] ack of flushed batch failed (#{inspect(reason)}); " <>
"messages will be redelivered after ack_wait"
)
%{gen_state | buffer: []}
end
end
if gen_state.connection_monitor_ref do
Process.demonitor(gen_state.connection_monitor_ref, [:flush])
end
if gen_state.subscription_id && is_pid(gen_state.connection_pid) do
# Best-effort unsub against the same Gnat that owns the sid (the
# pid we stored on the successful sub, not Process.whereis(name)
# which could resolve to a fresh-but-wedged Gnat after a restart).
# try/catch covers a dead pid or a slow GenServer.call.
try do
_ = Gnat.unsub(gen_state.connection_pid, gen_state.subscription_id)
catch
:exit, _ -> :ok
end
end
%{
gen_state
| consumer_name: nil,
subscription_id: nil,
connection_pid: nil,
connection_monitor_ref: nil,
listening_topic: nil,
buffer: [],
last_response_at: nil
}
end
defp process_and_ack_batch(%{buffer: buffer, module: module, state: state} = gen_state) do
messages = Enum.reverse(buffer)
new_state =
Enum.reduce(messages, state, fn message, acc_state ->
case module.handle_message(message, acc_state) do
{:ack, updated_state} ->
updated_state
{action, updated_state} ->
Logger.warning(
"PullConsumer batch mode does not support #{inspect(action)}, treating as :ack"
)
updated_state
end
end)
# With ack_policy: :all, acking the last message covers the entire batch
last = List.last(messages)
Gnat.Jetstream.ack(last)
%{gen_state | state: new_state, buffer: []}
end
end