# Future Improvements ## Broadway + Oban: High-Volume Metric Ingestion ### Status: Planning **Target**: When reaching 100+ agents or experiencing API bottlenecks **Effort**: Medium (2-3 weeks) **Priority**: Medium (not urgent with current scale) --- ## Problem Statement Current architecture uses synchronous agent metric submission: - Agent POSTs metrics → API validates → Direct DB insert → 200 OK - At scale (100s of agents, 1000s of devices), this creates: - **Thundering herd**: All agents polling on similar intervals - **API timeouts**: DB writes block the HTTP response - **Poor throughput**: One DB transaction per agent request - **No backpressure**: Peaks overwhelm the system ### Current Performance Profile ``` 500 agents × 60s interval = 8.3 req/sec average Peak (synchronized): 500 concurrent requests Each request: 10-50ms (DB insert + processing) Risk: Thundering herd, timeouts during peaks ``` --- ## Proposed Solution: Broadway Message Processing Pipeline ### Architecture Overview ``` ┌─────────────────────────────────────────────────────────────────┐ │ CURRENT (Oban) │ ├─────────────────────────────────────────────────────────────────┤ │ DevicePollerWorker (Oban) ──┐ │ │ DeviceMonitorWorker (Oban) ─┼─→ Direct SNMP → DB writes │ │ Maintenance Workers (Oban) ─┘ │ └─────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────┐ │ WITH BROADWAY (Proposed) │ ├─────────────────────────────────────────────────────────────────┤ │ Agent POST /metrics ──→ Message Queue ──→ 202 Accepted │ │ ↓ │ │ Broadway Pipeline │ │ ├─ Consumer (50 workers) │ │ ├─ Batcher (1000 msgs) │ │ └─ Bulk DB Insert │ │ │ │ Oban continues: │ │ ├─ DevicePollerWorker (scheduled SNMP) │ │ ├─ DeviceMonitorWorker (health checks) │ │ └─ Maintenance workers (cleanup, etc.) │ └─────────────────────────────────────────────────────────────────┘ ``` ### Role Separation **Broadway** (async, high-volume, batchable): - ✅ Agent metric submissions (`POST /api/v1/agent/metrics`) - ✅ Agent heartbeats (optional - could batch these too) - ✅ High-throughput data ingestion - ✅ Batch database operations **Oban** (scheduled, low-frequency, complex logic): - ✅ DevicePollerWorker (scheduled SNMP polling) - ✅ DeviceMonitorWorker (health checks) - ✅ Maintenance workers (cleanup, evaluators) - ✅ Discovery jobs (complex multi-step operations) ### Performance Characteristics After Broadway ``` Agent POST: <5ms (just enqueue) Broadway pipeline: - 50 processor workers (parse/validate) - 10 batcher workers (1000 rows/batch) - Single INSERT for 1000 rows: ~50-100ms Throughput: 10k-50k metrics/sec (limited by DB, not API) Backpressure: Queue absorbs burst traffic ``` --- ## Message Queue Options ### Option A: PostgreSQL-backed (Recommended Start) **Pros**: - ✅ No new infrastructure (use existing PostgreSQL) - ✅ Transactional guarantees - ✅ Simple deployment - ✅ Easy development/testing **Cons**: - ❌ Not ideal for extremely high throughput (10k+ msg/sec) - ❌ Adds load to primary database **Implementation**: Custom Broadway producer reading from `agent_metrics_queue` table **When to use**: Initial implementation, <100 agents, proof of concept --- ### Option B: RabbitMQ (Recommended Production) **Pros**: - ✅ Battle-tested for high throughput - ✅ Built-in clustering and persistence - ✅ Management UI for monitoring - ✅ Proven at scale (100k+ msg/sec) **Cons**: - ❌ Adds infrastructure dependency - ❌ Another service to monitor - ❌ Requires Kubernetes setup **Implementation**: `broadway_rabbitmq` package with existing RabbitMQ cluster **When to use**: 100+ agents, need guaranteed throughput, production workloads --- ### Option C: AWS SQS **Pros**: - ✅ Fully managed, scales automatically - ✅ No infrastructure to maintain - ✅ Pay-per-use pricing **Cons**: - ❌ Requires AWS - ❌ Potential latency (network calls) - ❌ Visibility timeout complexity **Implementation**: `off_broadway_sqs` package **When to use**: Running on AWS, want fully managed solution --- ## Implementation Details ### 1. Dependencies ```elixir # mix.exs defp deps do [ {:broadway, "~> 1.0"}, # Choose one: {:broadway_rabbitmq, "~> 0.8"}, # Option B {:off_broadway_sqs, "~> 0.7"}, # Option C # Option A: custom producer (see below) # ... existing deps ] end ``` ### 2. Broadway Pipeline ```elixir # lib/towerops/pipelines/agent_metrics_pipeline.ex defmodule Towerops.Pipelines.AgentMetricsPipeline do use Broadway alias Broadway.Message alias Towerops.Snmp def start_link(_opts) do Broadway.start_link(__MODULE__, name: __MODULE__, producer: [ module: { BroadwayRabbitMQ.Producer, queue: "agent_metrics", connection: [ host: Application.get_env(:towerops, :rabbitmq_host), username: Application.get_env(:towerops, :rabbitmq_username), password: Application.get_env(:towerops, :rabbitmq_password) ], on_failure: :reject_and_requeue }, concurrency: 1 ], processors: [ default: [ concurrency: 50 # Parallel message processing ] ], batchers: [ default: [ batch_size: 1000, # Max messages per batch batch_timeout: 2_000, # Max wait time (ms) concurrency: 10 # Parallel batch processors ] ] ) end @impl true def handle_message(_, %Message{data: data} = message, _context) do # Decode and validate the message # No DB writes here - just prepare data metric = Jason.decode!(data) message |> Message.update_data(fn _ -> metric end) |> Message.put_batcher(:default) end @impl true def handle_batch(:default, messages, _batch_info, _context) do # Extract all metrics from the batch metrics = Enum.map(messages, & &1.data) # Single multi-row insert for entire batch case Snmp.insert_metrics_batch(metrics) do {:ok, _} -> messages # All successful {:error, reason} -> # Mark all messages as failed (will be retried) Enum.map(messages, &Message.failed(&1, reason)) end end end ``` ### 3. Batch Insert Function ```elixir # lib/towerops/snmp.ex defmodule Towerops.Snmp do alias Towerops.Repo alias Towerops.Snmp.SensorReading @doc """ Insert a batch of sensor readings in a single transaction. Expected input format from agents: [ %{ "equipment_id" => "uuid", "sensor_id" => "uuid", "value" => 42.5, "timestamp" => "2026-02-01T12:00:00Z" }, ... ] """ def insert_metrics_batch(metrics) when is_list(metrics) do now = DateTime.utc_now() # Transform to Ecto schema format entries = Enum.map(metrics, fn metric -> %{ equipment_id: metric["equipment_id"], sensor_id: metric["sensor_id"], value: Decimal.new(metric["value"]), timestamp: parse_timestamp(metric["timestamp"]), inserted_at: now, updated_at: now } end) # Single INSERT with multiple rows Repo.insert_all(SensorReading, entries, on_conflict: :nothing, returning: false ) end defp parse_timestamp(ts) when is_binary(ts) do case DateTime.from_iso8601(ts) do {:ok, dt, _} -> dt _ -> DateTime.utc_now() end end end ``` ### 4. Async API Endpoint ```elixir # lib/towerops_web/controllers/api/v1/agent_controller.ex defmodule ToweropsWeb.API.V1.AgentController do use ToweropsWeb, :controller require Logger # Current synchronous version (keep during transition) def metrics_sync(conn, %{"metrics" => metrics}) do # ... existing implementation end # New async version with Broadway def metrics_async(conn, %{"metrics" => metrics}) do agent = conn.assigns.current_agent # Quick validation only with :ok <- validate_agent_active(agent), :ok <- validate_metrics_schema(metrics) do # Enqueue to message queue (non-blocking) message = Jason.encode!(%{ agent_id: agent.id, organization_id: agent.organization_id, metrics: metrics, received_at: DateTime.utc_now() }) # This is fast - just writes to queue :ok = publish_to_queue("agent_metrics", message) # Return immediately conn |> put_status(:accepted) |> json(%{status: "accepted", queued: length(metrics)}) else {:error, reason} -> conn |> put_status(:bad_request) |> json(%{error: reason}) end end defp publish_to_queue(queue, message) do # RabbitMQ example AMQP.Basic.publish( ToweropsWeb.RabbitMQ.channel(), "", queue, message, persistent: true ) end defp validate_agent_active(%{active: true}), do: :ok defp validate_agent_active(_), do: {:error, "agent inactive"} defp validate_metrics_schema(metrics) when is_list(metrics) do # Quick schema validation only if Enum.all?(metrics, &valid_metric?/1) do :ok else {:error, "invalid metric schema"} end end defp valid_metric?(%{"equipment_id" => _, "sensor_id" => _, "value" => _}), do: true defp valid_metric?(_), do: false end ``` ### 5. Application Supervision Tree ```elixir # lib/towerops/application.ex defmodule Towerops.Application do def start(_type, _args) do children = [ # ... existing children (Repo, PubSub, Endpoint, Oban, etc.) # Add Broadway pipeline Towerops.Pipelines.AgentMetricsPipeline, ] opts = [strategy: :one_for_one, name: Towerops.Supervisor] Supervisor.start_link(children, opts) end end ``` ### 6. Router Updates ```elixir # lib/towerops_web/router.ex scope "/api/v1/agent", ToweropsWeb.API.V1 do pipe_through [:api, :api_agent_auth] # Phase 1: Dual endpoints during migration post "/metrics", AgentController, :metrics_sync # Current post "/metrics/async", AgentController, :metrics_async # New # Phase 3: After full migration # post "/metrics", AgentController, :metrics_async end ``` --- ## Migration Path ### Phase 1: Dual Mode (Parallel Run) **Duration**: 2-4 weeks **Goal**: Validate Broadway pipeline with real traffic 1. Deploy both endpoints (`/metrics` and `/metrics/async`) 2. Update 10% of agents to use `/metrics/async` 3. Monitor: - Queue depth - Processing latency - Error rates - Database performance **Success Criteria**: - Zero data loss - <100ms p95 latency for queue insertion - <5s p95 latency for batch processing - No queue backlog growth --- ### Phase 2: Gradual Rollout **Duration**: 2-4 weeks **Goal**: Full traffic migration 1. Increase to 25% of agents 2. Monitor for 1 week 3. Increase to 50% of agents 4. Monitor for 1 week 5. Increase to 100% of agents **Rollback Plan**: - Keep sync endpoint active - Simple config change in agent to switch back - No data loss (queue persists messages) --- ### Phase 3: Deprecate Synchronous Endpoint **Duration**: 1 week **Goal**: Clean up legacy code 1. Switch `/metrics` route to async handler 2. Monitor for 1 week 3. Remove `metrics_sync/2` function 4. Update documentation --- ## Infrastructure Requirements ### PostgreSQL-backed (Phase 1) **Database Schema**: ```sql CREATE TABLE agent_metrics_queue ( id BIGSERIAL PRIMARY KEY, payload JSONB NOT NULL, inserted_at TIMESTAMP NOT NULL DEFAULT NOW(), processed_at TIMESTAMP, retry_count INTEGER DEFAULT 0, error TEXT ); CREATE INDEX idx_agent_metrics_queue_unprocessed ON agent_metrics_queue (inserted_at) WHERE processed_at IS NULL; ``` **No additional services required** --- ### RabbitMQ (Production) **Development** (`docker-compose.yml`): ```yaml services: rabbitmq: image: rabbitmq:3.13-management-alpine ports: - "5672:5672" # AMQP - "15672:15672" # Management UI environment: RABBITMQ_DEFAULT_USER: towerops RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD} volumes: - rabbitmq_data:/var/lib/rabbitmq volumes: rabbitmq_data: ``` **Production** (Kubernetes): ```yaml # k8s/rabbitmq/helmrelease.yaml apiVersion: helm.toolkit.fluxcd.io/v2beta1 kind: HelmRelease metadata: name: rabbitmq namespace: towerops spec: chart: spec: chart: rabbitmq version: 12.x.x sourceRef: kind: HelmRepository name: bitnami values: auth: username: towerops existingPasswordSecret: rabbitmq-password persistence: enabled: true size: 20Gi clustering: enabled: true replicaCount: 3 metrics: enabled: true serviceMonitor: enabled: true ``` **Secrets** (1Password → Kubernetes): ```bash # Create secret from 1Password kubectl create secret generic rabbitmq-password \ --from-literal=rabbitmq-password=$(op read "op://towerops/rabbitmq/password") \ -n towerops ``` --- ## Monitoring & Observability ### Telemetry Metrics ```elixir # lib/towerops_web/telemetry.ex defp periodic_measurements do [ # Existing metrics... # Broadway pipeline metrics {Broadway, :metrics, [Towerops.Pipelines.AgentMetricsPipeline]}, # Queue depth (RabbitMQ) {ToweropsWeb.Metrics, :queue_depth, ["agent_metrics"]}, # Processing rate {ToweropsWeb.Metrics, :processing_rate, []}, ] end ``` ### LiveDashboard Additions Add to `/admin/dashboard`: - **Queue Depth** - Messages waiting to be processed - **Processing Rate** - Messages/second throughput - **Batch Sizes** - Average messages per batch - **Failed Messages** - Retry queue depth - **Latency** - p50/p95/p99 processing time ### Alerts Add to monitoring system (Prometheus/Grafana): - Queue depth > 10,000 messages (backlog warning) - Processing rate < 100 msg/sec (pipeline degraded) - Failed message rate > 5% (data quality issue) - Consumer crashes (Broadway supervisor restarts) --- ## Testing Strategy ### Unit Tests ```elixir # test/towerops/pipelines/agent_metrics_pipeline_test.exs defmodule Towerops.Pipelines.AgentMetricsPipelineTest do use Towerops.DataCase alias Broadway.Message alias Towerops.Pipelines.AgentMetricsPipeline describe "handle_message/3" do test "parses valid metric JSON" do data = Jason.encode!(%{ "equipment_id" => "uuid", "sensor_id" => "uuid", "value" => 42.5 }) message = %Message{data: data} result = AgentMetricsPipeline.handle_message(:default, message, %{}) assert result.batcher == :default assert result.data["value"] == 42.5 end end describe "handle_batch/4" do test "inserts batch of metrics" do messages = [ %Message{data: %{"equipment_id" => "uuid1", ...}}, %Message{data: %{"equipment_id" => "uuid2", ...}} ] result = AgentMetricsPipeline.handle_batch(:default, messages, %{}, %{}) assert length(result) == 2 assert Enum.all?(result, &match?(%Message{status: :ok}, &1)) end end end ``` ### Integration Tests ```elixir # test/towerops_web/controllers/api/v1/agent_controller_test.exs describe "POST /api/v1/agent/metrics/async" do test "enqueues metrics and returns 202", %{conn: conn, agent: agent} do metrics = [%{ "equipment_id" => equipment.id, "sensor_id" => sensor.id, "value" => 42.5, "timestamp" => DateTime.utc_now() |> DateTime.to_iso8601() }] conn = post(conn, ~p"/api/v1/agent/metrics/async", %{"metrics" => metrics}) assert %{"status" => "accepted", "queued" => 1} = json_response(conn, 202) # Verify message in queue assert_queue_length("agent_metrics", 1) end end ``` ### Load Tests ```elixir # test/load/agent_metrics_load_test.exs # Use :fuse or similar to simulate 500 concurrent agents defmodule Towerops.Load.AgentMetricsTest do use ExUnit.Case @tag timeout: :infinity @tag :load_test test "handles 500 concurrent agent submissions" do agents = create_agents(500) # Simulate all agents posting at once tasks = Enum.map(agents, fn agent -> Task.async(fn -> submit_metrics(agent, generate_metrics(10)) end) end) results = Task.await_many(tasks, 30_000) # All should succeed assert Enum.all?(results, &match?({:ok, _}, &1)) # Verify queue processed everything wait_for_queue_empty("agent_metrics", timeout: 60_000) # Verify all metrics in database assert Repo.aggregate(SensorReading, :count) == 5000 end end ``` --- ## Performance Tuning ### Database Optimization **Batch Insert Settings**: ```elixir # config/runtime.exs config :towerops, Towerops.Repo, # Increase connection pool for Broadway workers pool_size: 20, # Up from 10 # Optimize for batch inserts prepare: :unnamed, timeout: 30_000 ``` **TimescaleDB Tuning**: ```sql -- Increase batch size for continuous aggregates ALTER MATERIALIZED VIEW sensor_readings_1h SET ( timescaledb.materialized_only = true, timescaledb.compress_after = '7 days' ); -- Add indexes for common queries CREATE INDEX CONCURRENTLY idx_sensor_readings_equipment_timestamp ON sensor_readings (equipment_id, timestamp DESC); ``` ### Broadway Tuning ```elixir # Processors: CPU-bound (parsing, validation) # Start with: processors_concurrency = num_cores * 2 processors: [default: [concurrency: System.schedulers_online() * 2]] # Batchers: I/O-bound (database writes) # Start with: batch_size = 1000, tune based on DB performance batchers: [ default: [ batch_size: 1000, # Increase if DB can handle larger batches batch_timeout: 2_000, # Decrease for lower latency concurrency: 10 # Increase if batchers become bottleneck ] ] ``` ### RabbitMQ Tuning ```elixir # Prefetch: How many unacked messages per consumer # Higher = better throughput, lower = better distribution connection: [ prefetch_count: 100 # Start with 100, tune based on message size ] ``` --- ## Cost Analysis ### Infrastructure Costs (Estimated) **PostgreSQL-backed**: - $0/month (uses existing database) - Adds ~10-20% CPU load to DB server **RabbitMQ (Self-hosted Kubernetes)**: - 3 nodes × 2 vCPU × 4 GB RAM = ~$120/month - 20 GB persistent storage = ~$10/month - **Total**: ~$130/month **AWS SQS**: - 1M requests = $0.40 - 500 agents × 1 req/min × 60 min × 24 hrs × 30 days = 21.6M requests/month - **Total**: ~$8.64/month (plus data transfer costs) ### Development Costs - **Phase 1** (PostgreSQL-backed): 1 week development + 1 week testing - **Phase 2** (RabbitMQ migration): 1 week development + 1 week testing - **Phase 3** (Cleanup): 2-3 days **Total**: 4-5 weeks engineering time --- ## Decision Points ### When to Implement? Implement Broadway when you hit **any** of these thresholds: 1. **Agent Count**: 100+ agents (current: <10) 2. **API Latency**: p95 > 500ms for `/api/v1/agent/metrics` 3. **Timeout Rate**: >1% of agent requests timing out 4. **Database Load**: DB CPU > 70% during agent polling windows 5. **Business Need**: SLA requires <100ms API response time ### Which Message Queue? | Criteria | PostgreSQL | RabbitMQ | AWS SQS | |----------|-----------|----------|---------| | **Agents** | <100 | 100-1000 | Any | | **Throughput** | <1k msg/sec | <100k msg/sec | Unlimited | | **Infrastructure** | None | Kubernetes | AWS only | | **Ops Complexity** | Low | Medium | Low | | **Cost** | $0 | ~$130/mo | ~$9/mo | **Recommended Path**: 1. Start with **PostgreSQL-backed** (proof of concept) 2. Migrate to **RabbitMQ** when hitting 100 agents 3. Consider **SQS** only if already on AWS --- ## References - [Broadway Documentation](https://hexdocs.pm/broadway) - [Building Custom Producers](https://samuelmullen.com/articles/building-custom-producers-with-elixirs-broadway) - [Broadway RabbitMQ](https://hexdocs.pm/broadway_rabbitmq) - [OffBroadway SQS](https://hexdocs.pm/off_broadway_sqs) - [Batch Insert Performance](https://hexdocs.pm/ecto/Ecto.Repo.html#c:insert_all/3) --- ## Related Documents - `CLAUDE.md` - Project-specific development guidelines - `AGENTS.md` - Elixir/Phoenix/LiveView patterns - `docs/API.md` - Agent API documentation