Skip to main content

CHANGELOG.md

# Changelog

All notable changes to this project are documented here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [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](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](README.md#versioning-policy).

### 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](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.