aprs.me/test/aprsme/packet_producer_backpressure_test.exs
Graham McIntire fa89c6d3ca
Add TCP backpressure to packet pipeline
When the PacketProducer buffer crosses 80% capacity, signal Aprsme.Is
to switch its TCP socket to passive mode, leveraging TCP flow control
to slow APRS-IS intake. Resume active mode when buffer drains below 30%.

A 30-second safety valve timer auto-resumes if backpressure gets stuck.
Disconnect/reconnect/terminate paths all clear backpressure state.
2026-02-19 15:31:51 -06:00

193 lines
5.5 KiB
Elixir

defmodule Aprsme.PacketProducerBackpressureTest do
use ExUnit.Case, async: false
alias Aprsme.PacketProducer
# We register self() as Aprsme.Is to receive backpressure messages
setup do
Process.register(self(), Aprsme.Is)
on_exit(fn ->
try do
Process.unregister(Aprsme.Is)
rescue
_ -> :ok
end
end)
:ok
end
describe "init/1 water marks" do
test "computes water marks from config ratios" do
# Config has high_water_ratio: 0.8, low_water_ratio: 0.3
{:producer, state} = PacketProducer.init(max_buffer_size: 1000)
assert state.high_water_mark == 800
assert state.low_water_mark == 300
assert state.backpressure_active == false
assert state.is_monitor_ref == nil
end
test "uses defaults when config ratios absent" do
# Temporarily clear the config
original = Application.get_env(:aprsme, :packet_pipeline)
pipeline_without_ratios = Keyword.drop(original, [:high_water_ratio, :low_water_ratio])
Application.put_env(:aprsme, :packet_pipeline, pipeline_without_ratios)
{:producer, state} = PacketProducer.init(max_buffer_size: 1000)
# Defaults: 0.8 and 0.3
assert state.high_water_mark == 800
assert state.low_water_mark == 300
Application.put_env(:aprsme, :packet_pipeline, original)
end
end
describe "backpressure activation on packet buffering" do
test "sends activate when buffer crosses high water mark" do
buffer = build_queue(799)
state = base_state(800, 300, buffer: buffer, buffer_size: 799)
# Adding one more packet brings us to 800 == high_water_mark
{:noreply, [], new_state} =
PacketProducer.handle_cast({:packet, %{sender: "TRIGGER"}}, state)
assert_received {:backpressure, :activate}
assert new_state.backpressure_active == true
assert new_state.is_monitor_ref
end
test "does not send duplicate activate when already active" do
buffer = build_queue(800)
state =
base_state(800, 300,
buffer: buffer,
buffer_size: 800,
backpressure_active: true,
is_monitor_ref: make_ref()
)
# Buffer overflows (drops oldest), but backpressure already active
{:noreply, [], new_state} =
PacketProducer.handle_cast({:packet, %{sender: "EXTRA"}}, state)
refute_received {:backpressure, :activate}
assert new_state.backpressure_active == true
end
test "skips activation when Aprsme.Is is not running" do
# Unregister so Process.whereis returns nil
Process.unregister(Aprsme.Is)
buffer = build_queue(799)
state = base_state(800, 300, buffer: buffer, buffer_size: 799)
{:noreply, [], new_state} =
PacketProducer.handle_cast({:packet, %{sender: "TRIGGER"}}, state)
# No message sent, but state should still reflect we tried
refute new_state.backpressure_active
end
end
describe "backpressure deactivation on demand" do
test "sends deactivate when demand drains buffer below low water mark" do
# Buffer at 301, demand of 2 will drain to 299 (below low_water 300)
buffer = build_queue(301)
state =
base_state(800, 300,
buffer: buffer,
buffer_size: 301,
backpressure_active: true,
is_monitor_ref: make_ref()
)
{:noreply, _events, new_state} = PacketProducer.handle_demand(2, state)
assert_received {:backpressure, :deactivate}
assert new_state.backpressure_active == false
assert new_state.is_monitor_ref == nil
end
test "does not deactivate when buffer still above low water mark" do
buffer = build_queue(305)
state =
base_state(800, 300,
buffer: buffer,
buffer_size: 305,
backpressure_active: true,
is_monitor_ref: make_ref()
)
# Demand of 2 drains to 303, still above 300
{:noreply, _events, new_state} = PacketProducer.handle_demand(2, state)
refute_received {:backpressure, :deactivate}
assert new_state.backpressure_active == true
end
end
describe "handle_info :DOWN" do
test "resets backpressure state when Is process dies" do
ref = make_ref()
state =
base_state(800, 300,
backpressure_active: true,
is_monitor_ref: ref
)
{:noreply, [], new_state} =
PacketProducer.handle_info({:DOWN, ref, :process, self(), :normal}, state)
assert new_state.backpressure_active == false
assert new_state.is_monitor_ref == nil
end
test "ignores DOWN for unrelated monitors" do
ref = make_ref()
unrelated_ref = make_ref()
state =
base_state(800, 300,
backpressure_active: true,
is_monitor_ref: ref
)
{:noreply, [], new_state} =
PacketProducer.handle_info({:DOWN, unrelated_ref, :process, self(), :normal}, state)
# Should not reset — different ref
assert new_state.backpressure_active == true
assert new_state.is_monitor_ref == ref
end
end
# Helper to build a queue of N dummy packets
defp build_queue(n) do
Enum.reduce(1..n, :queue.new(), fn i, q ->
:queue.in(%{sender: "P#{i}"}, q)
end)
end
defp base_state(high, low, overrides) do
defaults = %{
demand: 0,
buffer: :queue.new(),
buffer_size: 0,
max_buffer_size: 1000,
high_water_mark: high,
low_water_mark: low,
backpressure_active: false,
is_monitor_ref: nil
}
Enum.reduce(overrides, defaults, fn {k, v}, acc -> Map.put(acc, k, v) end)
end
end