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.
193 lines
5.5 KiB
Elixir
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
|