Security: - Add SQL identifier validation to DbOptimizer.analyze_table/vacuum_table - Move health/readiness routes to non-rate-limited pipeline Correctness: - Fix monitor reference leak in StreamingPacketsPubSub (track refs, demonitor on unsubscribe) - Persist circuit breaker half-open state transitions - Simplify ShutdownHandler.terminate to avoid pointless Process.send_after Performance: - Batch marker removal into single WebSocket event (remove_markers_batch) - Use Task.Supervisor.start_child for fire-and-forget broadcasts Dead code removal: - Remove copy_insert/regular_batch_insert/rows_to_csv from DbOptimizer - Remove dead packet_pipeline_integration tests - Remove unused telemetry event detach Other: - Fix Callsign.valid? docstring to match permissive behavior - Fix DatabaseMetrics to delegate instead of hardcoding pool stats - Add circuit breaker and encoding test coverage
190 lines
5.6 KiB
Elixir
190 lines
5.6 KiB
Elixir
defmodule Aprsme.CircuitBreakerTest do
|
|
use ExUnit.Case, async: false
|
|
|
|
import ExUnit.CaptureLog
|
|
|
|
alias Aprsme.CircuitBreaker
|
|
|
|
# Use unique service names per test to avoid cross-test interference
|
|
# since CircuitBreaker is already started by the application.
|
|
# CircuitBreaker.call/3 uses Task.async internally, so errors in the
|
|
# called function produce EXIT signals. We trap exits in the test process.
|
|
|
|
setup do
|
|
Process.flag(:trap_exit, true)
|
|
:ok
|
|
end
|
|
|
|
describe "state transitions" do
|
|
test "starts in closed state for unknown service" do
|
|
assert CircuitBreaker.get_state(:cb_test_closed) == :closed
|
|
end
|
|
|
|
test "transitions to open after reaching failure threshold" do
|
|
capture_log(fn ->
|
|
for _ <- 1..5 do
|
|
CircuitBreaker.call(:cb_test_open, fn -> raise "fail" end)
|
|
end
|
|
end)
|
|
|
|
assert CircuitBreaker.get_state(:cb_test_open) == :open
|
|
end
|
|
|
|
test "stays closed when below failure threshold" do
|
|
capture_log(fn ->
|
|
for _ <- 1..4 do
|
|
CircuitBreaker.call(:cb_test_below, fn -> raise "fail" end)
|
|
end
|
|
end)
|
|
|
|
assert CircuitBreaker.get_state(:cb_test_below) == :closed
|
|
end
|
|
|
|
test "resets failure count on success" do
|
|
capture_log(fn ->
|
|
for _ <- 1..3 do
|
|
CircuitBreaker.call(:cb_test_reset_count, fn -> raise "fail" end)
|
|
end
|
|
end)
|
|
|
|
CircuitBreaker.call(:cb_test_reset_count, fn -> :ok end)
|
|
|
|
stats = CircuitBreaker.get_stats(:cb_test_reset_count)
|
|
assert stats.failure_count == 0
|
|
assert stats.state == :closed
|
|
end
|
|
end
|
|
|
|
describe "half-open state persistence" do
|
|
test "half_open state is persisted after recovery timeout elapses" do
|
|
# Trip the breaker
|
|
capture_log(fn ->
|
|
for _ <- 1..5 do
|
|
CircuitBreaker.call(:cb_test_persist_ho, fn -> raise "fail" end)
|
|
end
|
|
end)
|
|
|
|
assert CircuitBreaker.get_state(:cb_test_persist_ho) == :open
|
|
|
|
# Manipulate last_failure_time to simulate recovery_timeout having passed
|
|
:sys.replace_state(CircuitBreaker, fn services ->
|
|
service_state = Map.get(services, :cb_test_persist_ho)
|
|
|
|
updated =
|
|
Map.put(
|
|
service_state,
|
|
:last_failure_time,
|
|
DateTime.add(DateTime.utc_now(), -60, :second)
|
|
)
|
|
|
|
Map.put(services, :cb_test_persist_ho, updated)
|
|
end)
|
|
|
|
# First call to get_state should return half_open AND persist it
|
|
assert CircuitBreaker.get_state(:cb_test_persist_ho) == :half_open
|
|
|
|
# The persisted state should now be half_open
|
|
stats = CircuitBreaker.get_stats(:cb_test_persist_ho)
|
|
assert stats.state == :half_open
|
|
end
|
|
|
|
test "second get_state call returns half_open from persisted state" do
|
|
# Trip the breaker
|
|
capture_log(fn ->
|
|
for _ <- 1..5 do
|
|
CircuitBreaker.call(:cb_test_second_ho, fn -> raise "fail" end)
|
|
end
|
|
end)
|
|
|
|
# Set last_failure_time to the past
|
|
:sys.replace_state(CircuitBreaker, fn services ->
|
|
service_state = Map.get(services, :cb_test_second_ho)
|
|
|
|
updated =
|
|
Map.put(
|
|
service_state,
|
|
:last_failure_time,
|
|
DateTime.add(DateTime.utc_now(), -60, :second)
|
|
)
|
|
|
|
Map.put(services, :cb_test_second_ho, updated)
|
|
end)
|
|
|
|
# First call transitions to half_open and persists
|
|
assert CircuitBreaker.get_state(:cb_test_second_ho) == :half_open
|
|
|
|
# Second call should also return half_open (from persisted state, not recalculated)
|
|
assert CircuitBreaker.get_state(:cb_test_second_ho) == :half_open
|
|
|
|
stats = CircuitBreaker.get_stats(:cb_test_second_ho)
|
|
assert stats.state == :half_open
|
|
end
|
|
end
|
|
|
|
describe "call/3" do
|
|
test "returns {:ok, result} on success" do
|
|
assert {:ok, 42} = CircuitBreaker.call(:cb_test_call_ok, fn -> 42 end)
|
|
end
|
|
|
|
test "returns {:error, :circuit_open} when circuit is open" do
|
|
capture_log(fn ->
|
|
for _ <- 1..5 do
|
|
CircuitBreaker.call(:cb_test_call_open, fn -> raise "fail" end)
|
|
end
|
|
end)
|
|
|
|
capture_log(fn ->
|
|
assert {:error, :circuit_open} = CircuitBreaker.call(:cb_test_call_open, fn -> :ok end)
|
|
end)
|
|
end
|
|
|
|
test "allows calls in half_open state and closes on success" do
|
|
# Trip the breaker
|
|
capture_log(fn ->
|
|
for _ <- 1..5 do
|
|
CircuitBreaker.call(:cb_test_ho_call, fn -> raise "fail" end)
|
|
end
|
|
end)
|
|
|
|
# Fast-forward past recovery timeout
|
|
:sys.replace_state(CircuitBreaker, fn services ->
|
|
service_state = Map.get(services, :cb_test_ho_call)
|
|
|
|
updated =
|
|
Map.put(
|
|
service_state,
|
|
:last_failure_time,
|
|
DateTime.add(DateTime.utc_now(), -60, :second)
|
|
)
|
|
|
|
Map.put(services, :cb_test_ho_call, updated)
|
|
end)
|
|
|
|
# Should be half_open and allow the call
|
|
assert {:ok, :recovered} = CircuitBreaker.call(:cb_test_ho_call, fn -> :recovered end)
|
|
|
|
# Should be back to closed after success
|
|
assert CircuitBreaker.get_state(:cb_test_ho_call) == :closed
|
|
end
|
|
end
|
|
|
|
describe "reset/1" do
|
|
test "resets circuit to closed state" do
|
|
capture_log(fn ->
|
|
for _ <- 1..5 do
|
|
CircuitBreaker.call(:cb_test_reset, fn -> raise "fail" end)
|
|
end
|
|
end)
|
|
|
|
assert CircuitBreaker.get_state(:cb_test_reset) == :open
|
|
|
|
capture_log(fn ->
|
|
CircuitBreaker.reset(:cb_test_reset)
|
|
end)
|
|
|
|
assert CircuitBreaker.get_state(:cb_test_reset) == :closed
|
|
stats = CircuitBreaker.get_stats(:cb_test_reset)
|
|
assert stats.failure_count == 0
|
|
end
|
|
end
|
|
end
|