Changelog

View Source

All notable changes to this project are documented here. The format follows Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

[1.0.0] - 2026-05-14

Upgrade receivers before senders. 0.1.x receivers reject the new wire format with :unsupported_version. See UPGRADING.md for the full migration playbook.

This is the 1.0 consolidation release — public API, wire format, telemetry schema, and default node-term shape are the committed 1.0 contract under the versioning policy in the README.

Added

  • Wire format v2 (PhiAccrualUdp.Packet) — 20-byte format with <<magic::16, version::8, flags::8, sender_id::64, timestamp::64>>. Senders shipped with this release emit v2 only.
  • Sender :sender_id option (required) — operator-supplied non-zero u64. Becomes the default node identity at the receiver, decoupling identity from ephemeral source port / NAT / container IP. Missing or zero raises ArgumentError at start_link/1.
  • Parallel per-tick sends in Sender. Each target's :gen_udp.send/4 runs in its own Task via Task.async_stream/3; a slow target only delays its own send. New options:
    • :max_send_concurrency (default 64) — caps per-tick fanout.
    • :send_timeout_ms (default max(50, div(interval_ms, 2))) — per-target send timeout. Must be strictly less than :interval_ms or start_link/1 raises.
  • IPv6 and interface binding on both Listener and Sender:
    • :inet6 (default false) — when true, the socket is opened with the IPv6 family AND {:ipv6_v6only, true} is set explicitly (no reliance on platform defaults).
    • :ip — bind address, IP tuple. On Sender, sets the source address for outbound packets (affects kernel routing); on Listener, filters incoming traffic. Dual-stack deployments run two instances per family. Sender validates target IP-tuple shapes against :inet6 at start_link/1; hostname/family mismatches surface per-send via [:sender, :send, :error] telemetry.
  • child_spec/1 overrides on both Listener and Sender honor the standard supervisor options :id, :restart, and :shutdown directly in the keyword list — useful for the dual-stack pattern (one Listener per family under one supervisor).
  • New decode error reason :reserved_sender_id, emitted when a v2 packet arrives with sender_id == 0 (reserved at the wire-format level).
  • New telemetry events:
    • [:phi_accrual_udp, :sample, :rejected] — emitted when the :node_resolver returns {:reject, reason}. Metadata: %{peer, sender_id, reason, wire_version}.
    • [:phi_accrual_udp, :sender, :send, :ok | :error | :timeout] — one event per target per tick. Measurements: %{duration} in native time units. The :ok variant is high-volume; subscribe only if you need per-target latency histograms.

  • New telemetry metadata keys:
    • :wire_version (1 | 2) on [:sample, :received] and [:sample, :rejected]. Group by this field to track fleet migration progress.

    • :sender_id on [:sender, :started] and [:sender, :tick].
    • :inet6 and :ip on [:listener, :started] and [:sender, :started].
    • :max_send_concurrency and :send_timeout_ms on [:sender, :started].
  • New :sender, :tick measurements :timeouts and :duration alongside the existing :sent and :errors. sent + errors + timeouts == target_count. duration is wall-clock of the parallel send phase, native time units.
  • Packet.current_version/0 returns 2.
  • Documentation: UPGRADING.md covers the receiver-first upgrade order, configuration changes, default identity shape change, migration tracking, and deprecation timeline.
  • CI: .github/workflows/ci.yml runs mix hex.audit, mix compile --warnings-as-errors, mix test, mix credo, and mix dialyzer (strict mode) across a three-job matrix: {Elixir 1.15, OTP 26}, {1.19, OTP 26}, {1.19, OTP 28}.

Changed (breaking)

  • Default node identity at the Listener is now a tagged tuple, distinguishing identity source:

    • v2 packets → {:sender_id, sender_id}
    • v1 packets → {:peer, ip, port} (3-tuple, flat)

    Previously v1 packets resolved to a bare {ip, port} 2-tuple. Operators using the default resolver see one cold-start per peer at upgrade (the old {ip, port}-keyed estimator goes :stale; the new tagged estimator warms up over 8 samples). Custom resolvers are unaffected.

  • :node_resolver signature is now 3-arity:

    (ip, port, sender_id | nil) -> term | {:reject, reason}

    Third argument is nil for v1 packets and the integer sender_id for v2. Listener.start_link/1 raises ArgumentError when a 2-arity resolver is supplied.

  • Packet.encode/2 is now Packet.encode/3: Packet.encode(sender_id, timestamp_ms, opts \\ []).

  • Packet.size/0 is removed in favor of Packet.size/1 taking :v1 or :v2.

Changed (internal)

  • @spec contracts tightened across Packet to match dialyzer success typing — narrowings only, no behavior change: decode/1 returns {:error, decode_reason()} enumerated alias; encode/3 returns <<_::160>> literal binary size; size/1 returns 12 | 20 literal union; current_version/0 returns the literal 2; %Packet{} :flags field is typed 0.
  • Listener.handle_info({:udp_passive, _}, _) now pattern-matches :ok from :inet.setopts/2. A failed re-arm crashes the Listener loudly instead of silently dropping flow control.

Deprecated

  • Wire format v1 is dual-decoded by Listener throughout 1.x for migration. The v1 decoder will be removed in 2.0.

[0.1.2] - 2026-05-08

Documentation

  • Operational considerations section added to the README, documenting two production footguns:
    • Sender restart producing a new {ip, port} peer identity from the Listener's perspective (because Sender uses ephemeral source ports). Recommends :node_resolver as the standard production setup.
    • DNS resolution cost and failure modes in Sender. Recommends pre-resolved IP tuples for deployments with unreliable DNS.
  • Listener moduledoc reframes :node_resolver as the recommended production setup, not just a "static topology" option.
  • Sender moduledoc expands the DNS resolution note.

No behaviour change.

[0.1.1] - 2026-05-08

Changed

  • Listener flow control. PhiAccrualUdp.Listener now opens its UDP socket with active: N (default N=100, configurable via the :active_count option) instead of active: true. Re-arms on :udp_passive. This bounds the per-burst mailbox growth under packet floods.

Added

  • Telemetry event [:phi_accrual_udp, :listener, :passive], emitted each time the listener re-arms after consuming active_count packets. Useful for observing ingress saturation.

[0.1.0] - 2026-05-07

Initial public release. Alpha — public API and wire format may change before v1.0 based on real-deployment feedback.

Added

  • Wire format v1 (PhiAccrualUdp.Packet) — 12-byte fixed format with magic 0xCEA6, version 0x01, reserved flags byte (must be zero in v1), and 64-bit unsigned millisecond timestamp.
  • UDP listener (PhiAccrualUdp.Listener) — opens a UDP socket on a configurable port, decodes incoming packets, calls PhiAccrual.observe/2 with local monotonic receipt time. Decode failures emit [:phi_accrual_udp, :decode, :error] telemetry with reason classification.
  • Periodic UDP sender (PhiAccrualUdp.Sender) — sends heartbeat packets to a list of {host, port} targets at a configurable interval. Configurable timestamp source.
  • Custom node resolution — listener accepts a :node_resolver function mapping (ip, port) to user-defined node identifiers. Default: {ip, port} tuple.
  • Telemetry schema[:listener, :started], [:sample, :received], [:decode, :error], [:sender, :started], [:sender, :tick].

Notes

  • Wire format and telemetry schema are not yet committed. Both may change before v1.0. Magic/version/flags structure is deliberately chosen to permit format evolution without breaking on-the-wire compatibility for v1 senders.
  • Receiver-driven clock discipline: the EWMA uses local monotonic receipt time, never the packet timestamp. This preserves phi_accrual's contract that cross-node timestamps are meaningless.