aprs.me/lib/aprsme/performance/insert_optimizer.ex
Graham McIntire c5a82b77c1
refactor: pattern-match over conditionals in Navigation and InsertOptimizer
Swaps `if`/`cond` branches for multi-clause function heads or head-pattern
matching, making the control flow visible in the function signatures:

- Navigation.determine_map_location: resolve_location/4 now dispatches on
  the shape of the geolocation input and whether URL params are explicit.
- Navigation.handle_callsign_tracking: per-case clauses for empty callsign
  and explicit-URL-params short-circuits, plus a final clause that does the
  DB lookup and rescues once.
- Navigation.handle_callsign_search: dispatch_callsign_search/3 separates
  the valid/invalid callsign branches.
- InsertOptimizer.optimize_based_on_performance: pattern-match on
  [_, _, _ | _] to require ≥3 samples without calling length/1.
- InsertOptimizer.calculate_insert_options and throughput helpers split
  into guarded clauses instead of inline `if`.
- maybe_emit_optimization uses same-var pattern matching to skip the
  telemetry hit when nothing changed.

Adds unit tests covering every branch of both modules plus SignalHandler,
LocaleHook, and the InsertOptimizer's :noproc fallback path. Coverage
64.69% → 65.72%.
2026-04-23 13:38:24 -05:00

185 lines
5.7 KiB
Elixir

defmodule Aprsme.Performance.InsertOptimizer do
@moduledoc """
Optimizations specifically for INSERT performance.
This module contains strategies to improve packet insertion performance:
- Optimized batch sizing based on system load
- Reduced index maintenance overhead
- Streamlined packet preparation
- Connection pool optimizations
"""
use GenServer
require Logger
# Configuration for INSERT optimization
@base_batch_size 200
@max_batch_size 800
@min_batch_size 100
# 30 seconds
@optimization_check_interval 30_000
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Get the optimal batch size for current system conditions.
"""
def get_optimal_batch_size do
GenServer.call(__MODULE__, :get_batch_size)
catch
:exit, {:noproc, _} -> @base_batch_size
end
@doc """
Get INSERT optimization settings.
"""
def get_insert_options do
GenServer.call(__MODULE__, :get_insert_options)
catch
:exit, {:noproc, _} -> default_insert_options()
end
@doc """
Record INSERT performance metrics for optimization.
"""
def record_insert_metrics(batch_size, duration_ms, success_count) do
GenServer.cast(__MODULE__, {:record_metrics, batch_size, duration_ms, success_count})
end
@impl true
def init(_opts) do
schedule_optimization_check()
state = %{
current_batch_size: @base_batch_size,
insert_options: default_insert_options(),
performance_history: [],
last_optimization: System.monotonic_time(:millisecond)
}
{:ok, state}
end
@impl true
def handle_call(:get_batch_size, _from, state) do
{:reply, state.current_batch_size, state}
end
@impl true
def handle_call(:get_insert_options, _from, state) do
{:reply, state.insert_options, state}
end
@impl true
def handle_cast({:record_metrics, batch_size, duration_ms, success_count}, state) do
metric = %{
batch_size: batch_size,
duration_ms: duration_ms,
success_count: success_count,
throughput: throughput(success_count, duration_ms),
timestamp: System.monotonic_time(:millisecond)
}
# Keep last 20 metrics for analysis
new_history = Enum.take([metric | state.performance_history], 20)
{:noreply, %{state | performance_history: new_history}}
end
# Packets-per-second, or 0 when the batch completed instantly (avoid div-by-0).
defp throughput(_count, 0), do: 0
defp throughput(_count, duration_ms) when duration_ms < 0, do: 0
defp throughput(count, duration_ms), do: count / (duration_ms / 1000)
@impl true
def handle_info(:optimize_settings, state) do
new_state = optimize_based_on_performance(state)
schedule_optimization_check()
{:noreply, new_state}
end
defp schedule_optimization_check do
Process.send_after(self(), :optimize_settings, @optimization_check_interval)
end
# Require at least three samples before doing anything — match directly on the
# list prefix instead of calling length/1 so we short-circuit for short lists.
defp optimize_based_on_performance(%{performance_history: [_, _, _ | _] = history} = state) do
recent_metrics = Enum.take(history, 5)
sample_size = length(recent_metrics)
avg_throughput = average(recent_metrics, & &1.throughput, sample_size)
avg_duration = average(recent_metrics, & &1.duration_ms, sample_size)
new_batch_size = calculate_optimal_batch_size(avg_throughput, avg_duration, state.current_batch_size)
new_insert_options = calculate_insert_options(avg_duration)
Logger.debug(
"INSERT optimization: batch_size=#{new_batch_size}, avg_throughput=#{Float.round(avg_throughput, 2)} pps"
)
:telemetry.execute([:aprsme, :insert_optimizer, :batch_size], %{value: new_batch_size}, %{})
:telemetry.execute([:aprsme, :insert_optimizer, :throughput], %{value: avg_throughput}, %{})
:telemetry.execute([:aprsme, :insert_optimizer, :duration], %{value: avg_duration}, %{})
maybe_emit_optimization(new_batch_size, state.current_batch_size)
%{
state
| current_batch_size: new_batch_size,
insert_options: new_insert_options,
last_optimization: System.monotonic_time(:millisecond)
}
end
defp optimize_based_on_performance(state), do: state
defp average(items, getter, count) do
items |> Enum.map(getter) |> Enum.sum() |> Kernel./(count)
end
defp maybe_emit_optimization(same, same), do: :ok
defp maybe_emit_optimization(_new, _old) do
:telemetry.execute([:aprsme, :insert_optimizer, :optimizations], %{count: 1}, %{})
end
defp calculate_optimal_batch_size(avg_throughput, avg_duration, current_batch_size) do
cond do
# If throughput is low and duration is high, reduce batch size
avg_throughput < 50 and avg_duration > 5000 ->
max(@min_batch_size, round(current_batch_size * 0.8))
# If throughput is good and duration is acceptable, increase batch size
avg_throughput > 200 and avg_duration < 2000 ->
min(@max_batch_size, round(current_batch_size * 1.2))
# Otherwise keep current size
true ->
current_batch_size
end
end
# When INSERTs are slow (>3s avg), use a longer timeout; otherwise stick with defaults.
defp calculate_insert_options(avg_duration) when avg_duration > 3_000 do
Keyword.merge(default_insert_options(),
returning: false,
on_conflict: :nothing,
timeout: 30_000
)
end
defp calculate_insert_options(_avg_duration), do: default_insert_options()
defp default_insert_options do
[
# Don't return IDs unless needed
returning: false,
# Skip conflicts instead of raising
on_conflict: :nothing,
# 15 second timeout
timeout: 15_000
]
end
end