Changelog
View SourceAll 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_idoption (required) — operator-supplied non-zerou64. Becomes the default node identity at the receiver, decoupling identity from ephemeral source port / NAT / container IP. Missing or zero raisesArgumentErroratstart_link/1.- Parallel per-tick sends in
Sender. Each target's:gen_udp.send/4runs in its ownTaskviaTask.async_stream/3; a slow target only delays its own send. New options::max_send_concurrency(default64) — caps per-tick fanout.:send_timeout_ms(defaultmax(50, div(interval_ms, 2))) — per-target send timeout. Must be strictly less than:interval_msorstart_link/1raises.
- IPv6 and interface binding on both
ListenerandSender::inet6(defaultfalse) — whentrue, 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. OnSender, sets the source address for outbound packets (affects kernel routing); onListener, filters incoming traffic. Dual-stack deployments run two instances per family.Sendervalidates target IP-tuple shapes against:inet6atstart_link/1; hostname/family mismatches surface per-send via[:sender, :send, :error]telemetry.
child_spec/1overrides on bothListenerandSenderhonor the standard supervisor options:id,:restart, and:shutdowndirectly 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 withsender_id == 0(reserved at the wire-format level). - New telemetry events:
[:phi_accrual_udp, :sample, :rejected]— emitted when the:node_resolverreturns{: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:okvariant 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_idon[:sender, :started]and[:sender, :tick].:inet6and:ipon[:listener, :started]and[:sender, :started].:max_send_concurrencyand:send_timeout_mson[:sender, :started].
- New
:sender, :tickmeasurements:timeoutsand:durationalongside the existing:sentand:errors.sent + errors + timeouts == target_count.durationis wall-clock of the parallel send phase, native time units. Packet.current_version/0returns2.- Documentation: UPGRADING.md covers the receiver-first upgrade order, configuration changes, default identity shape change, migration tracking, and deprecation timeline.
- CI:
.github/workflows/ci.ymlrunsmix hex.audit,mix compile --warnings-as-errors,mix test,mix credo, andmix 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
Listeneris 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.- v2 packets →
:node_resolversignature is now 3-arity:(ip, port, sender_id | nil) -> term | {:reject, reason}Third argument is
nilfor v1 packets and the integersender_idfor v2.Listener.start_link/1raisesArgumentErrorwhen a 2-arity resolver is supplied.Packet.encode/2is nowPacket.encode/3:Packet.encode(sender_id, timestamp_ms, opts \\ []).Packet.size/0is removed in favor ofPacket.size/1taking:v1or:v2.
Changed (internal)
@speccontracts tightened acrossPacketto match dialyzer success typing — narrowings only, no behavior change:decode/1returns{:error, decode_reason()}enumerated alias;encode/3returns<<_::160>>literal binary size;size/1returns12 | 20literal union;current_version/0returns the literal2;%Packet{}:flagsfield is typed0.Listener.handle_info({:udp_passive, _}, _)now pattern-matches:okfrom: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
Listenerthroughout1.xfor migration. The v1 decoder will be removed in2.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_resolveras the standard production setup. - DNS resolution cost and failure modes in Sender. Recommends pre-resolved IP tuples for deployments with unreliable DNS.
- Sender restart producing a new
- Listener moduledoc reframes
:node_resolveras 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.Listenernow opens its UDP socket withactive: N(defaultN=100, configurable via the:active_countoption) instead ofactive: 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 consumingactive_countpackets. 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 magic0xCEA6, version0x01, 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, callsPhiAccrual.observe/2with 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_resolverfunction 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.