Coverage:
- Adds focused tests across 13 files covering previously-uncovered
branches: Packet changeset normalisation (course, wind_direction,
struct data_extended); Packets store_packet edge cases (nested
position shapes, ssid handling, string-keyed raw_packet);
DeviceIdentification HTTP success/404 via Req plug stub; ResendAdapter
catch-all body, deliver_many, nil/binary formatters; ApiDocsLive
packets with missing fields; AprsSymbol normalize helpers;
BadPacketsLive refresh and postgres_notify; MobileChannel handle_info
catch-all and postgres_packet path; PacketProcessor existing-marker
movement detection; PacketReplay sanitize_value type clauses;
InsertOptimizer GenServer lifecycle and negative-duration metric;
PageController shutdown-handler integration; UrlParams delegated
helpers.
- mix.exs: configure test_coverage with summary threshold of 87 and
ignore_modules for test fixtures and macro-only template modules.
Bug fix:
- MapLive.Index handle_info: add a clause for the raw {:new_deployment,
_} tuple. Phoenix.PubSub.broadcast delivers the raw payload to plain
subscribers, but the existing handler only matched the %Broadcast{}
fast-lane wrapper. DeploymentNotifier broadcasts via the raw path,
which crashed the LiveView under test concurrency.
Submodule:
- Update .gitmodules vendor/aprs URL from
https://github.com/aprsme/aprs.git to
ssh://git@codeberg.org/gmcintire/aprs.git.
181 lines
6.6 KiB
Elixir
181 lines
6.6 KiB
Elixir
defmodule Aprsme.Performance.InsertOptimizerTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Aprsme.Performance.InsertOptimizer
|
|
|
|
defp fresh_state do
|
|
%{
|
|
current_batch_size: 200,
|
|
insert_options: [returning: false, on_conflict: :nothing, timeout: 15_000],
|
|
performance_history: [],
|
|
last_optimization: System.monotonic_time(:millisecond)
|
|
}
|
|
end
|
|
|
|
defp metric(throughput, duration_ms) do
|
|
%{
|
|
batch_size: 200,
|
|
duration_ms: duration_ms,
|
|
success_count: 100,
|
|
throughput: throughput,
|
|
timestamp: System.monotonic_time(:millisecond)
|
|
}
|
|
end
|
|
|
|
describe "GenServer callbacks" do
|
|
test "handle_call :get_batch_size returns current batch size" do
|
|
state = fresh_state()
|
|
|
|
assert {:reply, 200, ^state} =
|
|
InsertOptimizer.handle_call(:get_batch_size, self(), state)
|
|
end
|
|
|
|
test "handle_call :get_insert_options returns insert_options" do
|
|
state = fresh_state()
|
|
opts = state.insert_options
|
|
assert {:reply, ^opts, ^state} = InsertOptimizer.handle_call(:get_insert_options, self(), state)
|
|
end
|
|
|
|
test "handle_cast records a new metric in performance_history" do
|
|
state = fresh_state()
|
|
|
|
{:noreply, new_state} =
|
|
InsertOptimizer.handle_cast({:record_metrics, 200, 1_000, 100}, state)
|
|
|
|
assert [entry | _] = new_state.performance_history
|
|
assert entry.batch_size == 200
|
|
assert entry.duration_ms == 1_000
|
|
assert entry.success_count == 100
|
|
# Throughput = 100 / (1000/1000) = 100 pps
|
|
assert entry.throughput == 100.0
|
|
end
|
|
|
|
test "handle_cast with zero duration avoids div-by-0, throughput is 0" do
|
|
state = fresh_state()
|
|
|
|
{:noreply, new_state} =
|
|
InsertOptimizer.handle_cast({:record_metrics, 100, 0, 50}, state)
|
|
|
|
assert [entry | _] = new_state.performance_history
|
|
assert entry.throughput == 0
|
|
end
|
|
|
|
test "performance history is capped at 20 entries" do
|
|
state = fresh_state()
|
|
|
|
final =
|
|
Enum.reduce(1..25, state, fn _, s ->
|
|
{:noreply, s2} = InsertOptimizer.handle_cast({:record_metrics, 200, 1000, 100}, s)
|
|
s2
|
|
end)
|
|
|
|
assert length(final.performance_history) == 20
|
|
end
|
|
end
|
|
|
|
describe "handle_info :optimize_settings" do
|
|
test "with fewer than 3 metrics, state is unchanged except for re-scheduled timer" do
|
|
state = %{fresh_state() | performance_history: [metric(100, 1000), metric(90, 1100)]}
|
|
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
|
|
# Batch size shouldn't have moved.
|
|
assert new_state.current_batch_size == state.current_batch_size
|
|
assert new_state.performance_history == state.performance_history
|
|
end
|
|
|
|
test "grows batch size when throughput is high and duration low" do
|
|
# With avg_throughput > 200 and avg_duration < 2000, batch size should scale up
|
|
# from 200 → min(@max, round(200 * 1.2)) = 240.
|
|
history =
|
|
Enum.map(1..5, fn _ -> metric(300, 1000) end)
|
|
|
|
state = %{fresh_state() | performance_history: history}
|
|
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
|
|
assert new_state.current_batch_size == 240
|
|
# Fast INSERTs keep default options.
|
|
assert Keyword.get(new_state.insert_options, :timeout) == 15_000
|
|
end
|
|
|
|
test "shrinks batch size when throughput is low and duration high" do
|
|
# avg_throughput < 50 and avg_duration > 5000 → batch_size drops by 20%.
|
|
history = Enum.map(1..5, fn _ -> metric(10, 6000) end)
|
|
state = %{fresh_state() | performance_history: history}
|
|
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
|
|
assert new_state.current_batch_size == 160
|
|
# Slow INSERTs bump timeout.
|
|
assert Keyword.get(new_state.insert_options, :timeout) == 30_000
|
|
end
|
|
|
|
test "keeps batch size when performance is middling" do
|
|
# 100 pps at 3000ms falls in neither bucket → no change to batch size.
|
|
history = Enum.map(1..5, fn _ -> metric(100, 3000) end)
|
|
state = %{fresh_state() | performance_history: history}
|
|
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
|
|
assert new_state.current_batch_size == 200
|
|
end
|
|
|
|
test "never drops below the minimum batch size" do
|
|
state = %{
|
|
fresh_state()
|
|
| current_batch_size: 110,
|
|
performance_history: Enum.map(1..5, fn _ -> metric(5, 10_000) end)
|
|
}
|
|
|
|
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
|
|
assert new_state.current_batch_size == 100
|
|
end
|
|
|
|
test "never exceeds the maximum batch size" do
|
|
state = %{
|
|
fresh_state()
|
|
| current_batch_size: 750,
|
|
performance_history: Enum.map(1..5, fn _ -> metric(500, 500) end)
|
|
}
|
|
|
|
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
|
|
assert new_state.current_batch_size == 800
|
|
end
|
|
end
|
|
|
|
describe "client API when GenServer isn't running" do
|
|
test "get_optimal_batch_size falls back to base size on :noproc" do
|
|
# The supervised instance isn't started in the app tree, so this should
|
|
# hit the catch clause and return the base batch size (200).
|
|
assert InsertOptimizer.get_optimal_batch_size() == 200
|
|
end
|
|
|
|
test "get_insert_options falls back to default options on :noproc" do
|
|
opts = InsertOptimizer.get_insert_options()
|
|
assert Keyword.get(opts, :returning) == false
|
|
assert Keyword.get(opts, :on_conflict) == :nothing
|
|
assert Keyword.get(opts, :timeout) == 15_000
|
|
end
|
|
end
|
|
|
|
describe "GenServer process lifecycle" do
|
|
test "start_link/init/record_insert_metrics/get_optimal_batch_size" do
|
|
# Stop any existing instance so start_link/0 doesn't conflict.
|
|
if pid = Process.whereis(InsertOptimizer) do
|
|
GenServer.stop(pid)
|
|
end
|
|
|
|
assert {:ok, pid} = InsertOptimizer.start_link([])
|
|
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
|
|
|
|
# Exercises the cast path that records metrics into performance_history.
|
|
InsertOptimizer.record_insert_metrics(200, 1500, 100)
|
|
# Sync via call so the cast above is processed first.
|
|
assert is_integer(InsertOptimizer.get_optimal_batch_size())
|
|
end
|
|
|
|
test "handle_cast records a metric with negative duration as zero throughput" do
|
|
state = fresh_state()
|
|
|
|
# Negative duration should hit `throughput(_count, duration_ms) when duration_ms < 0, do: 0`.
|
|
assert {:noreply, new_state} =
|
|
InsertOptimizer.handle_cast({:record_metrics, 200, -5, 100}, state)
|
|
|
|
[first | _] = new_state.performance_history
|
|
assert first.throughput == 0
|
|
end
|
|
end
|
|
end
|