Fix code review findings: security, correctness, performance, dead code

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
This commit is contained in:
Graham McIntire 2026-02-19 18:43:55 -06:00
parent 0084539bb1
commit d9c04f7e0c
No known key found for this signature in database
15 changed files with 369 additions and 174 deletions

View file

@ -869,6 +869,11 @@ let MapAPRSMap = {
self.removeMarker(data.id); self.removeMarker(data.id);
}); });
// Remove markers in batch
self.handleEvent("remove_markers_batch", (data: { ids: string[] }) => {
data.ids.forEach((id) => self.removeMarker(id));
});
// Clear all markers // Clear all markers
self.handleEvent("clear_markers", () => { self.handleEvent("clear_markers", () => {
self.clearAllMarkers(); self.clearAllMarkers();

View file

@ -35,27 +35,29 @@ defmodule Aprsme.BroadcastTaskSupervisor do
@doc """ @doc """
Broadcasts a message to multiple topics asynchronously using the task pool. Broadcasts a message to multiple topics asynchronously using the task pool.
Uses `Task.Supervisor.start_child/2` for fire-and-forget execution.
The spawned task is not linked to the caller and does not send reply
messages back, avoiding orphaned `{ref, result}` and `:DOWN` messages
in the calling process mailbox.
## Parameters ## Parameters
- topics: List of PubSub topics to broadcast to - topics: List of PubSub topics to broadcast to
- message: The message to broadcast - message: The message to broadcast
- pubsub: The PubSub server (defaults to Aprsme.PubSub) - pubsub: The PubSub server (defaults to Aprsme.PubSub)
## Returns ## Returns
- {:ok, task_ref} where task_ref can be used to await results if needed - {:ok, pid} of the spawned task
""" """
def broadcast_async(topics, message, pubsub \\ Aprsme.PubSub) do def broadcast_async(topics, message, pubsub \\ Aprsme.PubSub) do
task = Task.Supervisor.start_child(@pool_name, fn ->
Task.Supervisor.async_nolink(@pool_name, fn -> # Use Stream for memory efficiency with large topic lists
# Use Stream for memory efficiency with large topic lists topics
topics # Process in chunks to balance load
# Process in chunks to balance load |> Stream.chunk_every(10)
|> Stream.chunk_every(10) |> Enum.each(fn topic_chunk ->
|> Enum.each(fn topic_chunk -> broadcast_to_topics(topic_chunk, message, pubsub)
broadcast_to_topics(topic_chunk, message, pubsub)
end)
end) end)
end)
{:ok, task}
end end
@doc """ @doc """

View file

@ -18,20 +18,28 @@ defmodule Aprsme.Callsign do
def normalize(_), do: "" def normalize(_), do: ""
@doc """ @doc """
Validates if a callsign format is reasonable for amateur radio use. Checks whether a callsign is a non-empty string.
APRS uses a wide variety of identifiers beyond standard amateur radio
callsigns, including tactical callsigns, object names, and item names.
This function intentionally accepts any non-empty, non-whitespace-only
string to accommodate that variety.
## Examples ## Examples
iex> Aprsme.Callsign.valid?("W5ABC") iex> Aprsme.Callsign.valid?("W5ABC")
true true
iex> Aprsme.Callsign.valid?("W5ABC-15") iex> Aprsme.Callsign.valid?("W5ABC-15")
true true
iex> Aprsme.Callsign.valid?("TACTICAL1")
true
iex> Aprsme.Callsign.valid?("") iex> Aprsme.Callsign.valid?("")
false false
iex> Aprsme.Callsign.valid?("A") iex> Aprsme.Callsign.valid?(nil)
false false
""" """
@spec valid?(String.t() | nil) :: boolean() @spec valid?(String.t() | nil) :: boolean()
@ -40,7 +48,8 @@ defmodule Aprsme.Callsign do
def valid?(callsign) when is_binary(callsign) do def valid?(callsign) when is_binary(callsign) do
trimmed = String.trim(callsign) trimmed = String.trim(callsign)
# Accept any non-empty callsign # Accept any non-empty callsign — APRS uses tactical callsigns,
# object names, and other non-standard identifiers
trimmed != "" trimmed != ""
end end

View file

@ -89,6 +89,18 @@ defmodule Aprsme.CircuitBreaker do
def handle_call({:get_state, service_name}, _from, services) do def handle_call({:get_state, service_name}, _from, services) do
service_state = get_service_state(services, service_name) service_state = get_service_state(services, service_name)
current_state = calculate_current_state(service_state) current_state = calculate_current_state(service_state)
# Persist state transitions (e.g., :open -> :half_open) so that
# concurrent callers see the same state and don't all independently
# enter half_open.
services =
if current_state == service_state.state do
services
else
updated_state = %{service_state | state: current_state}
Map.put(services, service_name, updated_state)
end
{:reply, current_state, services} {:reply, current_state, services}
end end

View file

@ -16,34 +16,6 @@ defmodule Aprsme.DbOptimizer do
require Logger require Logger
@doc """
Execute a large batch insert using PostgreSQL COPY command for maximum performance.
This is significantly faster than INSERT for large datasets.
"""
def copy_insert(table_name, columns, rows) when length(rows) > 1000 do
# Convert rows to CSV format for COPY
csv_data = rows_to_csv(rows, columns)
# Use COPY command which bypasses much of the overhead of INSERT
query = """
COPY #{table_name} (#{Enum.join(columns, ", ")})
FROM STDIN WITH (FORMAT csv, HEADER false)
"""
SQL.query!(Repo, query, [csv_data])
length(rows)
rescue
error ->
Logger.error("COPY insert failed: #{inspect(error)}")
# Fall back to regular insert
regular_batch_insert(table_name, columns, rows)
end
def copy_insert(table_name, columns, rows) do
# For smaller batches, use regular insert
regular_batch_insert(table_name, columns, rows)
end
@doc """ @doc """
Optimized batch insert that leverages PostgreSQL configuration Optimized batch insert that leverages PostgreSQL configuration
""" """
@ -103,6 +75,7 @@ defmodule Aprsme.DbOptimizer do
This helps PostgreSQL make better query plans This helps PostgreSQL make better query plans
""" """
def analyze_table(table_name) do def analyze_table(table_name) do
validate_identifier!(table_name)
SQL.query!(Repo, "ANALYZE #{table_name}", []) SQL.query!(Repo, "ANALYZE #{table_name}", [])
:ok :ok
rescue rescue
@ -116,6 +89,7 @@ defmodule Aprsme.DbOptimizer do
Use this after large delete operations Use this after large delete operations
""" """
def vacuum_table(table_name, opts \\ []) do def vacuum_table(table_name, opts \\ []) do
validate_identifier!(table_name)
full = Keyword.get(opts, :full, false) full = Keyword.get(opts, :full, false)
analyze = Keyword.get(opts, :analyze, true) analyze = Keyword.get(opts, :analyze, true)
@ -165,40 +139,13 @@ defmodule Aprsme.DbOptimizer do
# Private functions # Private functions
defp regular_batch_insert(table_name, columns, rows) do defp validate_identifier!(name) when is_binary(name) do
# Convert to maps for Ecto.insert_all if !Regex.match?(~r/\A[a-zA-Z_][a-zA-Z0-9_]*\z/, name) do
entries = raise ArgumentError, "invalid SQL identifier: #{inspect(name)}"
Enum.map(rows, fn row ->
columns |> Enum.zip(row) |> Map.new()
end)
{count, _} =
Repo.insert_all(table_name, entries,
returning: false,
on_conflict: :nothing,
timeout: 60_000
)
count
end
defp rows_to_csv(rows, _columns) do
Enum.map_join(rows, "\n", fn row ->
Enum.map_join(row, ",", &escape_csv_value/1)
end)
end
defp escape_csv_value(nil), do: ""
defp escape_csv_value(value) when is_binary(value) do
if String.contains?(value, [",", "\"", "\n"]) do
"\"#{String.replace(value, "\"", "\"\"")}\""
else
value
end end
end end
defp escape_csv_value(value), do: to_string(value) defp validate_identifier!(name) when is_atom(name), do: validate_identifier!(Atom.to_string(name))
# Default 1KB # Default 1KB
defp estimate_entry_size(nil), do: 1024 defp estimate_entry_size(nil), do: 1024

View file

@ -83,8 +83,12 @@ defmodule Aprsme.ShutdownHandler do
end end
if should_graceful_shutdown and not state.shutting_down do if should_graceful_shutdown and not state.shutting_down do
initiate_shutdown(state) # Mark unhealthy so load balancer stops sending traffic, then
# Give some time for the shutdown process # block for drain_timeout to let existing connections finish.
# Note: Process.send_after is pointless here since no messages
# are processed during terminate — we just sleep directly.
mark_unhealthy()
stop_accepting_connections()
Process.sleep(state.drain_timeout) Process.sleep(state.drain_timeout)
end end

View file

@ -66,20 +66,24 @@ defmodule Aprsme.StreamingPacketsPubSub do
# Monitor subscribers for cleanup # Monitor subscribers for cleanup
Process.flag(:trap_exit, true) Process.flag(:trap_exit, true)
{:ok, %{}} # pid => monitor_ref — track monitors to demonitor on unsubscribe/update
{:ok, %{monitors: %{}}}
end end
@impl true @impl true
def handle_call({:subscribe, pid, bounds}, _from, state) do def handle_call({:subscribe, pid, bounds}, _from, state) do
# Validate bounds # Validate bounds
if valid_bounds?(bounds) do if valid_bounds?(bounds) do
# Demonitor old ref if this pid was already subscribed (bounds update)
state = demonitor_if_exists(state, pid)
# Monitor the subscriber # Monitor the subscriber
Process.monitor(pid) ref = Process.monitor(pid)
# Store in ETS for fast lookup # Store in ETS for fast lookup
:ets.insert(@table_name, {pid, bounds}) :ets.insert(@table_name, {pid, bounds})
{:reply, :ok, state} {:reply, :ok, put_in(state.monitors[pid], ref)}
else else
{:reply, {:error, :invalid_bounds}, state} {:reply, {:error, :invalid_bounds}, state}
end end
@ -87,6 +91,7 @@ defmodule Aprsme.StreamingPacketsPubSub do
@impl true @impl true
def handle_call({:unsubscribe, pid}, _from, state) do def handle_call({:unsubscribe, pid}, _from, state) do
state = demonitor_if_exists(state, pid)
:ets.delete(@table_name, pid) :ets.delete(@table_name, pid)
{:reply, :ok, state} {:reply, :ok, state}
end end
@ -130,11 +135,22 @@ defmodule Aprsme.StreamingPacketsPubSub do
def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
# Clean up subscriber when process dies # Clean up subscriber when process dies
:ets.delete(@table_name, pid) :ets.delete(@table_name, pid)
{:noreply, state} {:noreply, %{state | monitors: Map.delete(state.monitors, pid)}}
end end
# Private functions # Private functions
defp demonitor_if_exists(state, pid) do
case Map.pop(state.monitors, pid) do
{nil, _monitors} ->
state
{ref, monitors} ->
Process.demonitor(ref, [:flush])
%{state | monitors: monitors}
end
end
defp valid_bounds?(%{north: n, south: s, east: e, west: w}) do defp valid_bounds?(%{north: n, south: s, east: e, west: w}) do
all_bounds_numeric?(n, s, e, w) and all_bounds_numeric?(n, s, e, w) and
n >= s and n >= s and

View file

@ -75,14 +75,9 @@ defmodule Aprsme.Telemetry.DatabaseMetrics do
end end
defp report_pool_metrics_estimated(pool_size) do defp report_pool_metrics_estimated(pool_size) do
emit_pool_metrics(%{ # Cannot reliably introspect DBConnection pool state, so report
size: pool_size, # pool_size as total without guessing busy/idle breakdown.
idle: max(0, pool_size - 2), report_pool_metrics_idle(pool_size)
busy: 2,
available: max(0, pool_size - 2),
queue_length: 0,
total: pool_size
})
end end
defp report_pool_metrics_error do defp report_pool_metrics_error do

View file

@ -118,9 +118,7 @@ defmodule AprsmeWeb.MapLive.DisplayManager do
def remove_markers_batch(socket, []), do: socket def remove_markers_batch(socket, []), do: socket
def remove_markers_batch(socket, marker_ids) do def remove_markers_batch(socket, marker_ids) do
Enum.reduce(marker_ids, socket, fn id, acc -> LiveView.push_event(socket, "remove_markers_batch", %{ids: marker_ids})
LiveView.push_event(acc, "remove_marker", %{id: id})
end)
end end
# Placeholder for historical loading function - this should be moved to HistoricalLoader # Placeholder for historical loading function - this should be moved to HistoricalLoader

View file

@ -31,6 +31,10 @@ defmodule AprsmeWeb.Router do
plug RateLimiter, scale: 60_000, limit: 100 plug RateLimiter, scale: 60_000, limit: 100
end end
pipeline :accepts_json do
plug :accepts, ["json"]
end
pipeline :api do pipeline :api do
plug :accepts, ["json"] plug :accepts, ["json"]
plug RateLimiter, scale: 60_000, limit: 100 plug RateLimiter, scale: 60_000, limit: 100
@ -51,9 +55,9 @@ defmodule AprsmeWeb.Router do
end end
end end
# Health/readiness routes — no rate limiting to avoid false K8s probe failures
scope "/", AprsmeWeb do scope "/", AprsmeWeb do
pipe_through :public_api pipe_through :accepts_json
get "/health", PageController, :health
get "/ready", PageController, :ready get "/ready", PageController, :ready
get "/status.json", PageController, :status_json get "/status.json", PageController, :status_json
end end

View file

@ -114,8 +114,6 @@ defmodule AprsmeWeb.Telemetry do
description: "Backpressure state changes" description: "Backpressure state changes"
), ),
# Note: SystemMonitor and InsertOptimizer metrics removed after reverting performance optimizations
# Spatial PubSub Metrics # Spatial PubSub Metrics
last_value("aprsme.spatial_pubsub.clients.count", description: "Number of connected clients"), last_value("aprsme.spatial_pubsub.clients.count", description: "Number of connected clients"),
last_value("aprsme.spatial_pubsub.clients.grid_cells", description: "Number of active grid cells"), last_value("aprsme.spatial_pubsub.clients.grid_cells", description: "Number of active grid cells"),

View file

@ -15,6 +15,14 @@ defmodule Aprsme.BroadcastTaskSupervisorTest do
end end
describe "broadcast_async/3" do describe "broadcast_async/3" do
test "returns {:ok, pid} for fire-and-forget execution", %{test_topic: test_topic} do
topics = [test_topic]
message = {:test_message, "Hello from broadcast"}
assert {:ok, pid} = BroadcastTaskSupervisor.broadcast_async(topics, message)
assert is_pid(pid)
end
test "broadcasts messages to multiple topics asynchronously", %{test_topic: test_topic} do test "broadcasts messages to multiple topics asynchronously", %{test_topic: test_topic} do
# Create additional test topics # Create additional test topics
topic2 = "test_topic_2_#{System.unique_integer()}" topic2 = "test_topic_2_#{System.unique_integer()}"
@ -26,16 +34,27 @@ defmodule Aprsme.BroadcastTaskSupervisorTest do
topics = [test_topic, topic2, topic3] topics = [test_topic, topic2, topic3]
message = {:test_message, "Hello from broadcast"} message = {:test_message, "Hello from broadcast"}
# Broadcast to all topics # Broadcast to all topics (fire-and-forget)
{:ok, task} = BroadcastTaskSupervisor.broadcast_async(topics, message) {:ok, _pid} = BroadcastTaskSupervisor.broadcast_async(topics, message)
# Wait for the task to complete
Task.await(task)
# Should receive the message on all topics # Should receive the message on all topics
assert_receive {:test_message, "Hello from broadcast"} assert_receive {:test_message, "Hello from broadcast"}, 1000
assert_receive {:test_message, "Hello from broadcast"} assert_receive {:test_message, "Hello from broadcast"}, 1000
assert_receive {:test_message, "Hello from broadcast"} assert_receive {:test_message, "Hello from broadcast"}, 1000
end
test "does not send task ref or DOWN messages to calling process", %{test_topic: test_topic} do
topics = [test_topic]
message = {:test_ref_check, "checking refs"}
{:ok, _pid} = BroadcastTaskSupervisor.broadcast_async(topics, message)
# Wait for the broadcast to complete
assert_receive {:test_ref_check, "checking refs"}, 1000
# Ensure no {ref, result} or {:DOWN, ...} messages leaked into our mailbox
refute_receive {_ref, _result}, 100
refute_receive {:DOWN, _ref, :process, _pid, _reason}, 100
end end
test "handles large topic lists efficiently", %{test_topic: test_topic} do test "handles large topic lists efficiently", %{test_topic: test_topic} do
@ -54,16 +73,14 @@ defmodule Aprsme.BroadcastTaskSupervisorTest do
# Measure time # Measure time
start_time = System.monotonic_time(:millisecond) start_time = System.monotonic_time(:millisecond)
{:ok, task} = BroadcastTaskSupervisor.broadcast_async(topics, message) {:ok, _pid} = BroadcastTaskSupervisor.broadcast_async(topics, message)
Task.await(task, 5000)
# Wait for delivery to our subscribed topic
assert_receive {:bulk_test, "Bulk broadcast"}, 5000
end_time = System.monotonic_time(:millisecond) end_time = System.monotonic_time(:millisecond)
# Should complete in a reasonable time (under 1 second for 101 topics) # Should complete in a reasonable time (under 1 second for 101 topics)
# Using a more lenient timeout to avoid failures on slower systems or CI
assert end_time - start_time < 1000 assert end_time - start_time < 1000
# Verify we received the message
assert_receive {:bulk_test, "Bulk broadcast"}
end end
end end
@ -141,16 +158,11 @@ defmodule Aprsme.BroadcastTaskSupervisorTest do
# Subscribe to only the first topic # Subscribe to only the first topic
Phoenix.PubSub.subscribe(Aprsme.PubSub, List.first(topics)) Phoenix.PubSub.subscribe(Aprsme.PubSub, List.first(topics))
# Launch 100 concurrent broadcasts # Launch 100 concurrent broadcasts (fire-and-forget)
tasks = for i <- 1..100 do
for i <- 1..100 do message = {:perf_test, i}
message = {:perf_test, i} {:ok, _pid} = BroadcastTaskSupervisor.broadcast_async(topics, message)
{:ok, task} = BroadcastTaskSupervisor.broadcast_async(topics, message) end
task
end
# Wait for all tasks to complete
Enum.each(tasks, &Task.await(&1, 5000))
# Each topic should receive 100 messages # Each topic should receive 100 messages
message_count = receive_all_messages() message_count = receive_all_messages()

View file

@ -0,0 +1,190 @@
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

View file

@ -0,0 +1,54 @@
defmodule Aprsme.EncodingTest do
use ExUnit.Case
alias Aprsme.Encoding
describe "sanitize_string/1 fast-path for clean strings" do
test "returns clean ASCII string unchanged" do
input = "VALID1>APRS,WIDE1-1:Test packet"
assert Encoding.sanitize_string(input) == input
end
test "returns clean ASCII string with allowed whitespace unchanged" do
input = "Hello\tWorld\nNew line\r\nCRLF"
result = Encoding.sanitize_string(input)
assert result == input
end
test "still cleans strings with control characters" do
# String with null byte (control character that should be removed)
input = "Hello\x00World"
result = Encoding.sanitize_string(input)
assert result == "HelloWorld"
end
test "still cleans strings with DEL character" do
input = "Hello\x7FWorld"
result = Encoding.sanitize_string(input)
assert result == "HelloWorld"
end
test "handles empty string" do
assert Encoding.sanitize_string("") == ""
end
test "handles non-binary input" do
assert Encoding.sanitize_string(nil) == ""
assert Encoding.sanitize_string(123) == ""
end
test "handles valid UTF-8 multi-byte characters" do
input = "Café résumé"
result = Encoding.sanitize_string(input)
assert String.valid?(result)
assert String.contains?(result, "Caf")
end
test "handles latin1 encoded binary" do
# Latin1 bytes for accented characters
invalid_binary = <<85, 78, 73, 211, 78, 32, 80, 65, 78, 65, 77, 69, 209, 65>>
result = Encoding.sanitize_string(invalid_binary)
assert String.valid?(result)
end
end
end

View file

@ -1,64 +1,13 @@
defmodule Aprsme.PacketPipelineIntegrationTest do defmodule Aprsme.PacketPipelineIntegrationTest do
use Aprsme.DataCase, async: false use Aprsme.DataCase, async: false
import ExUnit.CaptureLog
alias Aprsme.PacketProducer
alias Aprsme.Performance.InsertOptimizer alias Aprsme.Performance.InsertOptimizer
alias Aprsme.SystemMonitor
describe "packet pipeline under load" do describe "packet pipeline under load" do
test "system adjusts batch sizes based on load" do
# Test the actual running system without starting new processes
initial_batch_size = SystemMonitor.get_recommended_batch_size()
assert initial_batch_size >= 100
assert initial_batch_size <= 800
# Submit some packets to the running system
log_output =
capture_log(fn ->
for i <- 1..10 do
packet = %{
sender: "TEST-#{i}",
destination: "APRS",
path: "WIDE1-1",
information_field: "Integration test packet #{i}",
data_type: "position",
lat: 40.0 + i / 1000,
lon: -74.0 + i / 1000
}
PacketProducer.submit_packet(packet)
end
# Wait a bit for processing
Process.sleep(100)
end)
# Verify no errors occurred
refute log_output =~ "error"
end
test "insert optimizer provides reasonable batch sizes" do test "insert optimizer provides reasonable batch sizes" do
batch_size = InsertOptimizer.get_optimal_batch_size() batch_size = InsertOptimizer.get_optimal_batch_size()
assert batch_size >= 100 assert batch_size >= 100
assert batch_size <= 800 assert batch_size <= 800
end end
test "system monitor provides metrics" do
metrics = SystemMonitor.get_metrics()
assert is_map(metrics)
assert Map.has_key?(metrics, :memory)
assert Map.has_key?(metrics, :cpu)
assert Map.has_key?(metrics, :processes)
assert Map.has_key?(metrics, :db_pool)
# Check pressure values are reasonable
assert metrics.memory.pressure >= 0.0
assert metrics.memory.pressure <= 1.0
assert metrics.cpu.pressure >= 0.0
assert metrics.cpu.pressure <= 1.0
end
end end
end end