From 51e068f3f1b19132d2cc89b764725c387eaa0f47 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 8 May 2026 10:41:06 -0500 Subject: [PATCH] Add PromEx Prometheus exporter and /metrics endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up prom_ex with built-in Application, Beam, Phoenix, PhoenixLiveView, and Ecto plugins, plus a custom plugin that exposes the existing app-specific telemetry (packet pipeline, spatial PubSub, repo pool, postgres stats, insert optimizer) as Prometheus metrics. Mount PromEx.Plug at /metrics — internal-only, intended to be scraped by the cluster's external Prometheus through the kube-apiserver pod proxy. K8s pod template now carries prometheus.io/{scrape,port,path} annotations so the existing kubernetes-pods scrape job picks it up automatically. PromEx is disabled in the test environment. Also harden the packet cleanup and partition manager tests with Repo.delete_all setup hooks so they aren't poisoned by residual rows committed outside a sandbox transaction in earlier runs (which were inflating delete counts and triggering ATTACH PARTITION check_violation errors against packets_default). --- config/config.exs | 7 + config/test.exs | 3 + k8s/deployment.yaml | 4 + lib/aprsme/application.ex | 2 + lib/aprsme/prom_ex.ex | 42 +++ lib/aprsme/prom_ex/plugins/aprsme.ex | 334 ++++++++++++++++++ lib/aprsme_web/router.ex | 4 + mix.exs | 1 + mix.lock | 6 + test/aprsme/packets_test.exs | 13 + test/aprsme/partition_manager_test.exs | 9 + .../workers/packet_cleanup_worker_test.exs | 9 + 12 files changed, 434 insertions(+) create mode 100644 lib/aprsme/prom_ex.ex create mode 100644 lib/aprsme/prom_ex/plugins/aprsme.ex diff --git a/config/config.exs b/config/config.exs index 3e187d0..05b08c5 100644 --- a/config/config.exs +++ b/config/config.exs @@ -16,6 +16,13 @@ import Config # at the `config/runtime.exs`. config :aprsme, Aprsme.Mailer, adapter: Swoosh.Adapters.Local +# PromEx — Prometheus metrics exporter. Disabled in test by config/test.exs. +config :aprsme, Aprsme.PromEx, + manual_metrics_start_delay: :no_delay, + drop_metrics_groups: [], + grafana: :disabled, + metrics_server: :disabled + # Configures the endpoint config :aprsme, AprsmeWeb.Endpoint, url: [host: "localhost"], diff --git a/config/test.exs b/config/test.exs index 51998ac..ad0fb60 100644 --- a/config/test.exs +++ b/config/test.exs @@ -16,6 +16,9 @@ pool_size = # In test we don't send emails. config :aprsme, Aprsme.Mailer, adapter: Swoosh.Adapters.Test +# Disable PromEx in test environment +config :aprsme, Aprsme.PromEx, disabled: true + config :aprsme, Aprsme.Repo, username: "postgres", password: "postgres", diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 39724bb..cedd97e 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -19,6 +19,10 @@ spec: metadata: labels: app: aprs + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "4000" + prometheus.io/path: "/metrics" spec: priorityClassName: system-cluster-critical terminationGracePeriodSeconds: 90 diff --git a/lib/aprsme/application.ex b/lib/aprsme/application.ex index 137a074..1a1ab60 100644 --- a/lib/aprsme/application.ex +++ b/lib/aprsme/application.ex @@ -18,6 +18,8 @@ defmodule Aprsme.Application do children = [ # Start the Telemetry supervisor AprsmeWeb.Telemetry, + # Start the Prometheus metrics exporter + Aprsme.PromEx, # Start the Ecto repository Aprsme.Repo, # Start the PubSub system diff --git a/lib/aprsme/prom_ex.ex b/lib/aprsme/prom_ex.ex new file mode 100644 index 0000000..7c413c7 --- /dev/null +++ b/lib/aprsme/prom_ex.ex @@ -0,0 +1,42 @@ +defmodule Aprsme.PromEx do + @moduledoc """ + PromEx supervision module for the aprs.me application. + + Built-in plugins provide metrics for Phoenix, Phoenix LiveView, Ecto, the BEAM + VM, and the Application/release. The custom `Aprsme.PromEx.Plugins.Aprsme` + plugin exposes the app-specific telemetry events emitted by the GenStage + packet pipeline, SpatialPubSub, and the periodic Postgres collectors. + + Metrics are scraped at `GET /metrics` on the Phoenix endpoint via + `PromEx.Plug` (mounted in the router). + """ + + use PromEx, otp_app: :aprsme + + alias PromEx.Plugins + + @impl true + def plugins do + [ + Plugins.Application, + Plugins.Beam, + {Plugins.Phoenix, router: AprsmeWeb.Router, endpoint: AprsmeWeb.Endpoint}, + Plugins.PhoenixLiveView, + {Plugins.Ecto, otp_app: :aprsme, repos: [Aprsme.Repo]}, + Aprsme.PromEx.Plugins.Aprsme + ] + end + + @impl true + def dashboard_assigns do + [ + datasource_id: "Prometheus", + default_selected_interval: "30s" + ] + end + + @impl true + def dashboards do + [] + end +end diff --git a/lib/aprsme/prom_ex/plugins/aprsme.ex b/lib/aprsme/prom_ex/plugins/aprsme.ex new file mode 100644 index 0000000..557a7f7 --- /dev/null +++ b/lib/aprsme/prom_ex/plugins/aprsme.ex @@ -0,0 +1,334 @@ +defmodule Aprsme.PromEx.Plugins.Aprsme do + @moduledoc """ + PromEx plugin exposing app-specific telemetry as Prometheus metrics. + + Mirrors the metrics already defined for LiveDashboard in + `AprsmeWeb.Telemetry.metrics/0`, grouping them by subsystem: + + * GenStage packet pipeline (`[:aprsme, :packet_pipeline, :batch]`) + * Producer backpressure (`[:aprsme, :packet_producer, :backpressure]`) + * Spatial PubSub (`[:aprsme, :spatial_pubsub, ...]`) + * Database connection pool (`[:aprsme, :repo, :pool]`) + * Postgres database stats (`[:aprsme, :postgres, ...]`) + * Insert optimizer (`[:aprsme, :insert_optimizer, ...]`) + """ + + use PromEx.Plugin + + @duration_buckets [10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10_000] + + @impl true + def event_metrics(_opts) do + [ + packet_pipeline_metrics(), + producer_backpressure_metrics(), + spatial_pubsub_metrics(), + repo_pool_metrics(), + postgres_metrics(), + insert_optimizer_metrics() + ] + end + + defp packet_pipeline_metrics do + Event.build( + :aprsme_packet_pipeline_event_metrics, + [ + counter( + [:aprsme, :packet_pipeline, :batch, :count], + event_name: [:aprsme, :packet_pipeline, :batch], + measurement: :count, + description: "Total number of packets processed in batches" + ), + counter( + [:aprsme, :packet_pipeline, :batch, :success], + event_name: [:aprsme, :packet_pipeline, :batch], + measurement: :success, + description: "Total number of successful inserts" + ), + counter( + [:aprsme, :packet_pipeline, :batch, :error], + event_name: [:aprsme, :packet_pipeline, :batch], + measurement: :error, + description: "Total number of insert errors" + ), + distribution( + [:aprsme, :packet_pipeline, :batch, :duration_milliseconds], + event_name: [:aprsme, :packet_pipeline, :batch], + measurement: :duration_ms, + description: "Batch insert duration", + unit: :millisecond, + reporter_options: [buckets: @duration_buckets] + ) + ] + ) + end + + defp producer_backpressure_metrics do + Event.build( + :aprsme_packet_producer_event_metrics, + [ + counter( + [:aprsme, :packet_producer, :backpressure, :count], + event_name: [:aprsme, :packet_producer, :backpressure], + measurement: :count, + description: "Backpressure state changes in the packet producer" + ) + ] + ) + end + + defp spatial_pubsub_metrics do + Event.build( + :aprsme_spatial_pubsub_event_metrics, + [ + last_value( + [:aprsme, :spatial_pubsub, :clients, :count], + event_name: [:aprsme, :spatial_pubsub, :clients], + measurement: :count, + description: "Number of connected spatial PubSub clients" + ), + last_value( + [:aprsme, :spatial_pubsub, :clients, :grid_cells], + event_name: [:aprsme, :spatial_pubsub, :clients], + measurement: :grid_cells, + description: "Number of active grid cells" + ), + last_value( + [:aprsme, :spatial_pubsub, :clients, :avg_clients_per_cell], + event_name: [:aprsme, :spatial_pubsub, :clients], + measurement: :avg_clients_per_cell, + description: "Average clients per grid cell" + ), + counter( + [:aprsme, :spatial_pubsub, :broadcasts, :total], + event_name: [:aprsme, :spatial_pubsub, :broadcasts], + measurement: :total, + description: "Total spatial broadcasts sent" + ), + counter( + [:aprsme, :spatial_pubsub, :broadcasts, :filtered], + event_name: [:aprsme, :spatial_pubsub, :broadcasts], + measurement: :filtered, + description: "Broadcasts filtered out by viewport" + ), + counter( + [:aprsme, :spatial_pubsub, :broadcasts, :packets], + event_name: [:aprsme, :spatial_pubsub, :broadcasts], + measurement: :packets, + description: "Total packets processed by spatial PubSub" + ), + last_value( + [:aprsme, :spatial_pubsub, :efficiency, :ratio], + event_name: [:aprsme, :spatial_pubsub, :efficiency], + measurement: :ratio, + description: "Broadcast efficiency ratio (0-1)" + ), + counter( + [:aprsme, :spatial_pubsub, :efficiency, :saved_broadcasts], + event_name: [:aprsme, :spatial_pubsub, :efficiency], + measurement: :saved_broadcasts, + description: "Number of broadcasts saved by viewport filtering" + ) + ] + ) + end + + defp repo_pool_metrics do + Event.build( + :aprsme_repo_pool_event_metrics, + [ + last_value( + [:aprsme, :repo, :pool, :size], + event_name: [:aprsme, :repo, :pool], + measurement: :size, + description: "Configured DB connection pool size" + ), + last_value( + [:aprsme, :repo, :pool, :idle], + event_name: [:aprsme, :repo, :pool], + measurement: :idle, + description: "Idle DB connections" + ), + last_value( + [:aprsme, :repo, :pool, :busy], + event_name: [:aprsme, :repo, :pool], + measurement: :busy, + description: "Busy DB connections" + ), + last_value( + [:aprsme, :repo, :pool, :available], + event_name: [:aprsme, :repo, :pool], + measurement: :available, + description: "Available DB connections" + ), + last_value( + [:aprsme, :repo, :pool, :queue_length], + event_name: [:aprsme, :repo, :pool], + measurement: :queue_length, + description: "Processes waiting on a DB connection" + ), + last_value( + [:aprsme, :repo, :pool, :total], + event_name: [:aprsme, :repo, :pool], + measurement: :total, + description: "Total connections in the DB pool" + ) + ] + ) + end + + defp postgres_metrics do + Event.build( + :aprsme_postgres_event_metrics, + [ + last_value( + [:aprsme, :postgres, :database, :size_bytes], + event_name: [:aprsme, :postgres, :database], + measurement: :size_bytes, + description: "Total Postgres database size in bytes", + unit: :byte + ), + last_value( + [:aprsme, :postgres, :connections, :total], + event_name: [:aprsme, :postgres, :connections], + measurement: :total, + description: "Total Postgres connections" + ), + last_value( + [:aprsme, :postgres, :connections, :active], + event_name: [:aprsme, :postgres, :connections], + measurement: :active, + description: "Active Postgres connections" + ), + last_value( + [:aprsme, :postgres, :connections, :idle], + event_name: [:aprsme, :postgres, :connections], + measurement: :idle, + description: "Idle Postgres connections" + ), + last_value( + [:aprsme, :postgres, :connections, :idle_in_transaction], + event_name: [:aprsme, :postgres, :connections], + measurement: :idle_in_transaction, + description: "Connections idle in transaction" + ), + last_value( + [:aprsme, :postgres, :connections, :waiting], + event_name: [:aprsme, :postgres, :connections], + measurement: :waiting, + description: "Postgres connections waiting on locks" + ), + last_value( + [:aprsme, :postgres, :packets_table, :live_tuples], + event_name: [:aprsme, :postgres, :packets_table], + measurement: :live_tuples, + description: "Live rows in the packets table" + ), + last_value( + [:aprsme, :postgres, :packets_table, :dead_tuples], + event_name: [:aprsme, :postgres, :packets_table], + measurement: :dead_tuples, + description: "Dead rows in the packets table" + ), + counter( + [:aprsme, :postgres, :packets_table, :total_inserts], + event_name: [:aprsme, :postgres, :packets_table], + measurement: :total_inserts, + description: "Total inserts into the packets table" + ), + counter( + [:aprsme, :postgres, :packets_table, :total_updates], + event_name: [:aprsme, :postgres, :packets_table], + measurement: :total_updates, + description: "Total updates on the packets table" + ), + counter( + [:aprsme, :postgres, :packets_table, :total_deletes], + event_name: [:aprsme, :postgres, :packets_table], + measurement: :total_deletes, + description: "Total deletes from the packets table" + ), + last_value( + [:aprsme, :postgres, :packets_table, :table_size_bytes], + event_name: [:aprsme, :postgres, :packets_table], + measurement: :table_size_bytes, + description: "Packets table size in bytes", + unit: :byte + ), + last_value( + [:aprsme, :postgres, :packets_table, :indexes_size_bytes], + event_name: [:aprsme, :postgres, :packets_table], + measurement: :indexes_size_bytes, + description: "Packets indexes size in bytes", + unit: :byte + ), + counter( + [:aprsme, :postgres, :query_stats, :total_calls], + event_name: [:aprsme, :postgres, :query_stats], + measurement: :total_calls, + description: "Total Postgres queries executed (pg_stat_statements)" + ), + last_value( + [:aprsme, :postgres, :query_stats, :total_time_ms], + event_name: [:aprsme, :postgres, :query_stats], + measurement: :total_time_ms, + description: "Total Postgres query execution time", + unit: :millisecond + ), + last_value( + [:aprsme, :postgres, :query_stats, :avg_time_ms], + event_name: [:aprsme, :postgres, :query_stats], + measurement: :avg_time_ms, + description: "Average Postgres query execution time", + unit: :millisecond + ), + last_value( + [:aprsme, :postgres, :query_stats, :max_time_ms], + event_name: [:aprsme, :postgres, :query_stats], + measurement: :max_time_ms, + description: "Maximum Postgres query execution time", + unit: :millisecond + ), + last_value( + [:aprsme, :postgres, :replication, :lag_seconds], + event_name: [:aprsme, :postgres, :replication], + measurement: :lag_seconds, + description: "Postgres replication lag in seconds", + unit: :second + ) + ] + ) + end + + defp insert_optimizer_metrics do + Event.build( + :aprsme_insert_optimizer_event_metrics, + [ + last_value( + [:aprsme, :insert_optimizer, :batch_size], + event_name: [:aprsme, :insert_optimizer, :batch_size], + measurement: :value, + description: "Current batch size chosen by the insert optimizer" + ), + last_value( + [:aprsme, :insert_optimizer, :throughput], + event_name: [:aprsme, :insert_optimizer, :throughput], + measurement: :value, + description: "Average insert throughput observed by the optimizer" + ), + last_value( + [:aprsme, :insert_optimizer, :duration], + event_name: [:aprsme, :insert_optimizer, :duration], + measurement: :value, + description: "Average insert duration observed by the optimizer" + ), + counter( + [:aprsme, :insert_optimizer, :optimizations], + event_name: [:aprsme, :insert_optimizer, :optimizations], + measurement: :count, + description: "Number of times the optimizer adjusted batch parameters" + ) + ] + ) + end +end diff --git a/lib/aprsme_web/router.ex b/lib/aprsme_web/router.ex index 011eba5..e4f9eb4 100644 --- a/lib/aprsme_web/router.ex +++ b/lib/aprsme_web/router.ex @@ -72,6 +72,10 @@ defmodule AprsmeWeb.Router do get "/status.json", PageController, :status_json end + # Prometheus metrics endpoint — internal-only, scraped by the cluster's + # prometheus via the kube-apiserver pod proxy. Not exposed via Ingress. + forward "/metrics", PromEx.Plug, prom_ex_module: Aprsme.PromEx + # Agent/crawler discovery endpoints scope "/", AprsmeWeb do get "/sitemap.xml", PageController, :sitemap diff --git a/mix.exs b/mix.exs index 0ccfa1d..0be9005 100644 --- a/mix.exs +++ b/mix.exs @@ -98,6 +98,7 @@ defmodule Aprsme.MixProject do {:swoosh, "~> 1.23"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, + {:prom_ex, "~> 1.11"}, aprs_dep(), {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.4.0", runtime: Mix.env() == :dev}, diff --git a/mix.lock b/mix.lock index a9da9f2..85a8dd2 100644 --- a/mix.lock +++ b/mix.lock @@ -3,6 +3,7 @@ "bandit": {:hex, :bandit, "1.11.0", "dbdd9c9963f146ee9da9860d1ee5b0ffd65cea51fe2aab3f3273df84329d133a", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c949d93a325a28da2333dde5a9ab61986ad2c2b7226347db6a28303b9139865e"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, @@ -37,6 +38,8 @@ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "octo_fetch": {:hex, :octo_fetch, "0.5.0", "f50701568b9fc752656367f82cc134d5fbefff37c5a0e8ddfcceb02ceee3f5fc", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "6226cc3c14ca948ee9f25fb0446322e5c288e215da9beba7899b6b5f4cd3ccb0"}, + "peep": {:hex, :peep, "3.5.0", "9f6ead7b0f2c684494200c8fc02e7e62e8c459afe861b29bd859e4c96f402ed8", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5a73a99c6e60062415efeb7e536a663387146463a3d3df1417da31fd665ac210"}, "phoenix": {:hex, :phoenix, "1.8.6", "7106a0da114619c4b12b056bbaef39fdbc75d3d0cf9cf24af683364064c12dc3", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d0d0b7931916c8196b6903a1efa118b5da28487e7a75ad32a54dfd77de59d421"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, @@ -48,13 +51,16 @@ "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.22.1", "b3665ad17e15441557da8f45eeebfcd56e4a2b0b98538b855679a13d05e5cc5d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "df59f828b167b49a5853f645b65f57eb1bc5f3b230497ceaca7af5d8ac05afef"}, + "prom_ex": {:hex, :prom_ex, "1.11.0", "1f6d67f2dead92224cb4f59beb3e4d319257c5728d9638b4a5e8ceb51a4f9c7e", [:mix], [{:absinthe, ">= 1.7.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.11.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "76b074bc3730f0802978a7eb5c7091a65473eaaf07e99ec9e933138dcc327805"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, "styler": {:hex, :styler, "1.11.0", "35010d970689a23c2bcc8e97bd8bf7d20e3561d60c49be84654df5c37d051a9c", [:mix], [], "hexpm", "70f36165d0cf238a32b7a456fdef6a9c72e77e657d7ac4a0ace33aeba3f2b8c0"}, "swoosh": {:hex, :swoosh, "1.25.1", "569fcff34817da8a03f28775146b3c8b71b4c9b14f8f78d37ff3ef422862a18b", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "58b3e8db6406fe417a89b5042358d2e8f15d32a3317d4f8581d7a3ae501e410b"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"}, diff --git a/test/aprsme/packets_test.exs b/test/aprsme/packets_test.exs index 175921b..3573bc0 100644 --- a/test/aprsme/packets_test.exs +++ b/test/aprsme/packets_test.exs @@ -997,6 +997,14 @@ defmodule Aprsme.PacketsTest do end describe "clean_old_packets/0" do + setup do + # Defensive: ensure a clean slate so residual rows from prior runs + # (e.g. data committed outside a sandbox transaction) don't inflate + # the deletion count. + Repo.delete_all(Packet) + :ok + end + test "deletes packets older than retention period" do now = DateTime.utc_now() @@ -1024,6 +1032,11 @@ defmodule Aprsme.PacketsTest do end describe "clean_packets_older_than/1" do + setup do + Repo.delete_all(Packet) + :ok + end + test "deletes packets older than specified days" do now = DateTime.utc_now() diff --git a/test/aprsme/partition_manager_test.exs b/test/aprsme/partition_manager_test.exs index 8e20b7d..9343bcc 100644 --- a/test/aprsme/partition_manager_test.exs +++ b/test/aprsme/partition_manager_test.exs @@ -1,8 +1,17 @@ defmodule Aprsme.PartitionManagerTest do use Aprsme.DataCase, async: false + alias Aprsme.Packet alias Aprsme.PartitionManager + setup do + # Defensive: any rows in packets_default block ATTACH PARTITION on + # overlapping ranges with a check_violation. Wipe the parent table + # so partition tests can freely create/drop partitions. + Repo.delete_all(Packet) + :ok + end + describe "partition_name/1" do test "generates correct name for a date" do assert PartitionManager.partition_name(~D[2026-02-20]) == "packets_20260220" diff --git a/test/aprsme/workers/packet_cleanup_worker_test.exs b/test/aprsme/workers/packet_cleanup_worker_test.exs index 54b04de..b20912a 100644 --- a/test/aprsme/workers/packet_cleanup_worker_test.exs +++ b/test/aprsme/workers/packet_cleanup_worker_test.exs @@ -2,9 +2,18 @@ defmodule Aprsme.Workers.PacketCleanupWorkerTest do use Aprsme.DataCase, async: false alias Aprsme.BadPacket + alias Aprsme.Packet alias Aprsme.PartitionManager alias Aprsme.Workers.PacketCleanupWorker + setup do + # Defensive: rows in packets_default whose received_at falls inside a + # range we're about to ATTACH would trigger a check_violation. Wipe + # the parent so partition operations are unobstructed. + Repo.delete_all(Packet) + :ok + end + describe "perform/1 with cleanup_days" do test "drops old partitions via PartitionManager" do # Ensure current partitions exist