PhiAccrualUdp.Packet (phi_accrual_udp v1.0.0)

View Source

Wire format codec for phi_accrual_udp heartbeats.

Formats

v2 (current, 20 bytes)

<<magic::16, version::8, flags::8, sender_id::64-unsigned, timestamp::64-unsigned>>
  • magic0xCEA6. Two-byte magic identifying a phi_accrual UDP heartbeat. Anything else is rejected at decode time before reaching the estimator.
  • version0x02 for this format. Receivers reject unknown versions at decode time.
  • flags — reserved, MUST be 0 in v2. All 8 bits unassigned; future versions may allocate them.
  • sender_id — operator-supplied non-zero unsigned 64-bit identifier for this node. 0 is reserved and rejected at decode time (:reserved_sender_id). The listener uses sender_id (not packet source IP/port) as the default node identity, removing the ephemeral-port coupling that bites under restarts and NAT timeouts.
  • timestamp — 64-bit unsigned milliseconds, sender's choice of clock. The receiver does NOT use this for the EWMA — it uses local monotonic receipt time. The packet timestamp is diagnostic-only (e.g., one-way delay when NTP-synced).

v1 (legacy, 12 bytes)

<<magic::16, version::8, flags::8, timestamp::64-unsigned>>

Retained for dual-decode through the entire 1.x series. Removed in 2.0. Listener accepts v1 packets gracefully; senders shipped with phi_accrual_udp 1.x do not emit v1.

Decoded v1 packets carry sender_id: nil to signal absence of identity. The default node resolver maps these to {:peer, ip, port}.

Why magic + version + flags

Three bytes of overhead earn:

  • Early reject of garbage. A misconfigured sender or stray UDP packet on the listener port doesn't corrupt estimator state — it gets dropped with a [:phi_accrual_udp, :decode, :error] telemetry event.
  • Format evolution. Adding fields means bumping version and adding a new decode clause. v1 sat in production through the alpha and is now transitioning to v2 the same way v2 will transition to v3 if needed.
  • Operator visibility. tcpdump -X shows cea6 02... for v2 traffic — distinguishable from random UDP noise at a glance.

Clock discipline (read this)

The packet timestamp is provenance, not signal. The receiver calls :erlang.monotonic_time(:millisecond) at receipt time and passes that to PhiAccrual.observe/2. Using the packet timestamp for the EWMA breaks clock discipline (sender and receiver clocks are uncorrelated in general) and corrupts the detector.

Summary

Functions

Current wire version emitted by encode/3.

Decode a heartbeat packet. Dispatches by size, then by version.

Encode a v2 heartbeat packet.

Wire size in bytes for a given wire version.

Types

decode_reason()

@type decode_reason() ::
  :wrong_size
  | :bad_magic
  | :unsupported_version
  | :reserved_flags_set
  | :reserved_sender_id

t()

@type t() :: %PhiAccrualUdp.Packet{
  flags: 0,
  sender_id: non_neg_integer() | nil,
  timestamp_ms: non_neg_integer(),
  version: wire_version()
}

wire_version()

@type wire_version() :: 1 | 2

Functions

current_version()

@spec current_version() :: 2

Current wire version emitted by encode/3.

decode(bin)

@spec decode(binary()) :: {:ok, t()} | {:error, decode_reason()}

Decode a heartbeat packet. Dispatches by size, then by version.

Returns {:ok, %Packet{}} on success, or {:error, reason} for malformed input.

Reasons:

  • :wrong_size — packet is not 12 (v1) or 20 (v2) bytes
  • :bad_magic — first two bytes do not match 0xCEA6
  • :unsupported_version — version byte does not match the size (or is otherwise unknown)
  • :reserved_flags_set — flag bits other than 0
  • :reserved_sender_id — v2 packet with sender_id == 0

encode(sender_id, timestamp_ms, opts \\ [])

@spec encode(pos_integer(), non_neg_integer(), keyword()) :: <<_::160>>

Encode a v2 heartbeat packet.

sender_id must be a non-zero unsigned 64-bit integer. 0 is reserved at the wire-format level and will be rejected by any receiver.

timestamp_ms is whatever the sender records for diagnostic purposes — typically :erlang.system_time(:millisecond) if NTP-synced operators want one-way delay. Receivers do not use this field for the EWMA.

size(atom)

@spec size(:v1 | :v2) :: 12 | 20

Wire size in bytes for a given wire version.

iex> PhiAccrualUdp.Packet.size(:v1)
12
iex> PhiAccrualUdp.Packet.size(:v2)
20