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