PhiAccrualAmqp.Consumer (phi_accrual_amqp v0.2.0)

View Source

AMQP consumer that feeds broker deliveries into the PhiAccrual core detector.

Opens an AMQP connection, opens a channel, subscribes to one configured queue, and on every delivery calls PhiAccrual.observe(detector_key, receipt_ts) where receipt_ts comes from :erlang.monotonic_time(:millisecond) at the instant the delivery is processed — that is, when it is dequeued from this GenServer's mailbox, not when it arrived there. Under a backlog the two differ by the queueing delay. The detector key is extracted from the envelope by PhiAccrualAmqp.Envelope.extract/2.

Clock discipline (read this)

phi_accrual's estimator works on local monotonic time only. The publisher's BasicProperties.timestamp and any broker-stamped header (e.g., from the rabbitmq_message_timestamp plugin) are cross-process wall clocks; using them to feed the EWMA breaks the detector. This module passes them through as diagnostic telemetry metadata but never as the value handed to observe/2.

Liveness caveat (read this too)

In AMQP, "delivery received" proves three things are alive in combination: publisher, broker, and the network paths between them and you. A high phi value does NOT pin the fault on the publisher. If you need publisher-only liveness, choose a transport with no intermediary (e.g., phi_accrual_udp).

Options

  • :queue (required) — the queue to consume from. Must already exist; this package declares no topology.
  • :url — broker URL, default "amqp://localhost".
  • :connection_opts — a URL or a keyword list passed to AMQP.Connection.open/1,2. Takes precedence over :url: when set, :url is ignored entirely. A keyword list is merged over the connection defaults, so anything given there wins; a binary is a URL, leaving the defaults in force.
  • :key_resolver(meta -> PhiAccrual.detector_key() | nil), default Envelope.default_key_resolver/1.

  • :reconnect_min_ms — backoff floor, default 1000.
  • :reconnect_max_ms — backoff ceiling, default 30_000. Must not be below :reconnect_min_ms.
  • :max_tracked_keys — cap on remembered detector keys, default 1000.
  • :name — registers the process. Omitted, the consumer runs unnamed so several can coexist in one supervision tree.
  • :connect — whether to connect on start, default true. Setting it to false starts a consumer that opens no connection; it exists so the delivery and lifecycle paths can be exercised without a broker.

Unknown keys, mistyped values and a :reconnect_min_ms above :reconnect_max_ms raise ArgumentError from start_link/1.

Mapping deliveries to detector keys

See PhiAccrualAmqp.Envelope for the resolver contract. The default extracts meta.routing_key. For static N-queues-per-node topologies pass a constant resolver per consumer:

Consumer.start_link(
  queue: "heartbeats.node_a",
  key_resolver: fn _meta -> :node_a end
)

Connection lifecycle

The consumer manages its own connection, channel, and subscription. On startup it schedules an async :connect so the supervisor can come up before the broker is reachable. On any failure — broker unreachable, channel error, server-initiated basic.cancel, connection or channel process death — the consumer tears down what it has and reconnects with jittered exponential backoff between :reconnect_min_ms and :reconnect_max_ms. The ceiling doubles per attempt; the delay is drawn uniformly between the floor and that ceiling, so consumers attached to a restarting broker spread their retries instead of arriving in lockstep.

This deliberately differs from PhiAccrualUdp.Listener's fail-fast socket open: an AMQP connection is a remote-broker contract that can blip during normal operation, while a UDP socket open is a local syscall that essentially never fails after success.

Telemetry

[:phi_accrual_amqp, :connection, :up]
  measurements: %{system_time}
  metadata:     %{queue}

[:phi_accrual_amqp, :connection, :down]
  measurements: %{tracked}
  metadata:     %{queue, reason, keys}
  # also fires on a server-initiated cancel, with
  # reason: :server_cancelled, since that path tears the
  # connection down too
  # tracked counts what :keys lists. The list is for policy; the
  # count is for a gauge, since Telemetry.Metrics cannot aggregate
  # a list.
  # keys lists the detector keys this consumer was feeding when
  # delivery stopped. φ for those keys will climb while the
  # transport is down; a policy layer can use this to decide the
  # excursion is not evidence about the entities themselves.

[:phi_accrual_amqp, :keys, :evicted]
  measurements: %{tracked}
  metadata:     %{queue, key, incoming_key, max_tracked_keys}
  # emitted when the tracked-key set is at :max_tracked_keys and a
  # new key displaces the least recently seen one.

[:phi_accrual_amqp, :consumer, :registered]
  measurements: %{system_time}
  metadata:     %{queue, consumer_tag}

[:phi_accrual_amqp, :consumer, :cancelled]
  measurements: %{system_time}
  metadata:     %{queue, consumer_tag, reason}

[:phi_accrual_amqp, :sample, :received]
  measurements: %{monotonic_time, system_time}
  # monotonic_time is the exact value handed to
  # PhiAccrual.observe/2, so a handler can derive inter-arrival
  # intervals directly instead of reconstructing them — and the
  # clock-discipline promise becomes inspectable rather than
  # merely documented.
  metadata:     %{detector_key, envelope_timestamp, routing_key, exchange, queue}
  # envelope_timestamp may be nil if the publisher didn't set one;
  # it is NEVER what gets passed to PhiAccrual.observe/2.

[:phi_accrual_amqp, :extract, :error]
  measurements: %{system_time}
  metadata:     %{reason, routing_key, exchange, queue}
  # reason ∈ [:no_detector_key, :resolver_raised]

The :sample, :received event name is shared with phi_accrual_udp, but the payload shape differs (identity key detector_key vs node; timestamp in metadata vs measurements). Handlers are not cross-transport drop-in.

Summary

Functions

Build a supervisor child specification.

Start a consumer.

Current state of the consumer.

Types

opts()

@type opts() :: [
  queue: String.t(),
  url: String.t(),
  connection_opts: keyword() | String.t(),
  key_resolver: PhiAccrualAmqp.Envelope.resolver(),
  reconnect_min_ms: pos_integer(),
  reconnect_max_ms: pos_integer(),
  max_tracked_keys: pos_integer(),
  connect: boolean(),
  name: GenServer.name()
]

Functions

child_spec(init_arg)

@spec child_spec(keyword()) :: Supervisor.child_spec()

Build a supervisor child specification.

The standard supervisor keys :id, :restart and :shutdown are read from the same keyword list as the consumer options and are not passed on to start_link/1.

:id defaults to :name when one is given, and otherwise to {PhiAccrualAmqp.Consumer, queue}. That makes one-consumer-per-queue topologies work without spelling out an :id:

children = [
  {PhiAccrualAmqp.Consumer, queue: "heartbeats.node_a"},
  {PhiAccrualAmqp.Consumer, queue: "heartbeats.node_b"}
]

Defaults otherwise match use GenServer: restart: :permanent, shutdown: 5_000, type: :worker.

start_link(opts)

@spec start_link(opts()) :: GenServer.on_start()

Start a consumer.

Pass :name to register the process under a name. Without it the consumer runs unnamed, so several consumers — one per queue — can coexist in the same supervision tree.

status(server, timeout \\ 5000)

@spec status(GenServer.server(), timeout()) :: %{
  connected?: boolean(),
  queue: String.t(),
  consumer_tag: String.t() | nil,
  backoff_ms: non_neg_integer(),
  disconnected_since: integer() | nil,
  last_delivery_at: integer() | nil,
  keys_tracked: non_neg_integer()
}

Current state of the consumer.

Returns a map with:

  • :connected? — whether a connection and channel are currently open
  • :queue — the configured queue
  • :consumer_tag — the broker-assigned tag, or nil when unsubscribed
  • :backoff_ms — the ceiling the next reconnect draws up to, inclusive; 0 before the first connection attempt
  • :disconnected_since — local monotonic ms at which the current outage began, or nil when connected
  • :last_delivery_at — local monotonic ms of the last delivery that produced a detector key, or nil if none has arrived
  • :keys_tracked — how many detector keys this consumer has seen, bounded by :max_tracked_keys

The two timestamps are local monotonic milliseconds from the same clock as :erlang.monotonic_time(:millisecond), so durations are derived by subtracting from a fresh reading of it. They are not wall clocks and carry no meaning off this node.

Blocking

Connection attempts run synchronously inside the consumer, so a call landing during one waits for it to finish. The 5s connection_timeout default caps that, but a call can still block against an unreachable broker — precisely when a health check is most likely to run. The timeout argument therefore defaults to 5000 rather than :infinity, and callers should be prepared for the exit.