From 6acc007360a58579b68cbb9a2c94428326da6e0b Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 22 Oct 2025 15:48:00 -0500 Subject: [PATCH] refactor: remove Redis dependency for PubSub, use distributed Erlang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove phoenix_pubsub_redis dependency from mix.exs - Update PubSub configuration to use Phoenix's native distributed capabilities - Simplify clustering logic - no longer requires Redis URL for PubSub - Phoenix PubSub automatically distributes messages across connected Erlang nodes - Benefits: lower latency, automatic failure handling, simpler deployment - Redis still available for caching and job processing when needed - Maintains backward compatibility for both clustered and non-clustered modes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 557 +------------------------------------- config/appsignal.exs | 3 +- config/dev.exs | 4 +- config/prod.exs | 7 +- lib/aprsme/application.ex | 13 +- mix.exs | 1 - 6 files changed, 13 insertions(+), 572 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 908ccd2..e69400a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,559 +59,6 @@ This is a web application written using the Phoenix web framework. - Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards - Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)` - Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option +- **Always run mix format after changing any Elixir files** -## Mix guidelines - -- Read the docs and options before using tasks (by using `mix help task_name`) -- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed` -- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason - -## Phoenix guidelines - -- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes. - -- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie: - - scope "/admin", AppWeb.Admin do - pipe_through :browser - - live "/users", UserLive, :index - end - - the UserLive route would point to the `AppWeb.Admin.UserLive` module - -- `Phoenix.View` no longer is needed or included with Phoenix, don't use it - -## Ecto Guidelines - -- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email` -- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs` -- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string` -- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed -- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields -- Fields which are set programatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct - -## Phoenix HTML guidelines - -- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E` -- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated -- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]` -- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`) -- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name) - -- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `
` or `` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
-
-      
-        let obj = {key: "val"}
-      
-
-  Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
-
-- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
-
-      Text
-
-  and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
-
-  and **never** do this, since it's invalid (note the missing `[` and `]`):
-
-       ...
-      => Raises compile syntax error on invalid HEEx attr syntax
-
-- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
-- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
-- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
-
-  **Always** do this:
-
-      
- {@my_assign} - <%= if @some_block_condition do %> - {@another_assign} - <% end %> -
- - and **Never** do this – the program will terminate with a syntax error: - - <%!-- THIS IS INVALID NEVER EVER DO THIS --%> -
- {if @invalid_block_construct do} - {end} -
- -## Phoenix LiveView guidelines - -- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews -- **Avoid LiveComponent's** unless you have a strong, specific need for them -- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive` -- Remember anytime you use `phx-hook="MyHook"` and that js hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute -- **Never** write embedded ` tags within templates** - -### UI/UX & design guidelines - -- **Produce world-class UI designs** with a focus on usability, aesthetics, and modern design principles -- Implement **subtle micro-interactions** (e.g., button hover effects, and smooth transitions) -- Ensure **clean typography, spacing, and layout balance** for a refined, premium look -- Focus on **delightful details** like hover effects, loading states, and smooth page transitions - -## Authentication - -- **Always** handle authentication flow at the router level with proper redirects -- **Always** be mindful of where to place routes. `phx.gen.auth` creates multiple router plugs and `live_session` scopes: - - A `live_session :current_user` scope - For routes that need the current user but don't require authentication - - A `live_session :require_authenticated_user` scope - For routes that require authentication - - In both cases, a `@current_scope` is assigned to the Plug connection and LiveView socket -- **Always let the user know in which router scopes, `live_session`, and pipeline you are placing the route, AND SAY WHY** -- `phx.gen.auth` assigns the `current_scope` assign - it **does not assign the `current_user` assign**. -- To derive/access `current_user`, **always use the `current_scope.user` assign**, never use **`@current_user`** in templates or LiveViews -- **Never** duplicate `live_session` names. A `live_session :current_user` can only be defined __once__ in the router, so all routes for the `live_session :current_user` must be grouped in a single block -- Anytime you hit `current_scope` errors or the logged in session isn't displaying the right content, **always double check the router and ensure you are using the correct `live_session` described below** - -### Routes that require authentication - -LiveViews that require login should **always be placed inside the __existing__ `live_session :require_authenticated_user` block**: - - scope "/", AppWeb do - pipe_through [:browser, :require_authenticated_user] - - live_session :require_authenticated_user, - on_mount: [{AppWeb.UserAuth, :ensure_authenticated}] do - # phx.gen.auth generated routes - live "/users/settings", UserSettingsLive, :edit - live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email - # our own routes that require logged in user - live "/", MyLiveThatRequiresAuth, :index - end - end - -### Routes that work with or without authentication - -LiveViews that can work with or without authentication, **always use the __existing__ `:current_user` scope**, ie: - - scope "/", MyAppWeb do - pipe_through [:browser] - - live_session :current_user, - on_mount: [{MyAppWeb.UserAuth, :mount_current_scope}] do - # our own routes that work with or without authentication - live "/", PublicLive - end - end - -## Prerequisites - -Before setting up the project, ensure you have the following installed: -- Elixir 1.17+ -- Erlang/OTP -- PostgreSQL with PostGIS extension - -## Project Overview - -This is an Elixir Phoenix LiveView application that serves as a real-time APRS (Automatic Packet Reporting System) tracker and visualizer. It connects to the APRS-IS network to receive live amateur radio packets and displays them on an interactive map interface. - -## Development Commands - -### Setup -- `mix setup` - Complete project setup (deps.get + ecto.setup) -- `mix deps.get` - Install dependencies -- `mix ecto.setup` - Create database, run migrations, and seed data -- `mix ecto.reset` - Drop and recreate database -- `mix phx.server` - Start Phoenix server (http://localhost:4000) -- `iex -S mix phx.server` - Start server in interactive Elixir shell - -### Testing -- `mix test` - Run full test suite -- `mix test --stale` - Run only tests affected by code changes -- `mix test.watch` - Continuous testing with file watching -- `mix test --cover` - Generate test coverage reports - -### Code Quality -- `mix format` - Format code according to .formatter.exs -- `mix credo` - Static code analysis and style checking -- `mix dialyzer` - Static type analysis (must run and fix errors/warnings) -- `mix sobelow` - Security vulnerability scanning -- **CRITICAL**: ALWAYS run `mix format` BEFORE committing - never commit unformatted code -- **MANDATORY**: Run `mix compile --warnings-as-errors` and ensure it passes before considering any task complete - -### Assets (No Node.js Required) -- `mix assets.deploy` - Build and minify frontend assets (Tailwind CSS + ESBuild) -- Phoenix uses standalone ESBuild and Tailwind binaries - no npm/yarn needed -- JavaScript bundling handled by ESBuild -- CSS compilation handled by Tailwind CLI - -## Architecture - -### Core Components -- **Aprsme.AprsIsConnection** - TCP connection to APRS-IS network with reconnection logic -- **Aprsme.PacketConsumer** - Processes incoming APRS packets using GenStage pipeline -- **Aprsme.Packet** - Database schema for APRS packets with PostGIS geographic data -- **AprsmeWeb.MapLive.Index** - Main real-time map interface using Phoenix LiveView -- **Aprsme.Workers.PacketCleanupWorker** - Oban background job for data cleanup - -### Data Flow -1. APRS-IS connection receives packets via TCP -2. PacketConsumer processes packets through GenStage pipeline -3. Packets stored in PostgreSQL with PostGIS geographic indexing -4. LiveView broadcasts real-time updates to connected clients via PubSub -5. Background workers handle cleanup and maintenance tasks - -### Key Dependencies -- Phoenix LiveView for real-time UI without JavaScript -- PostGIS for geographic data storage and spatial queries -- Oban for background job processing -- GenStage for packet processing pipelines -- Tailwind CSS + ESBuild for frontend assets (no Node.js) - -## Test-Driven Development - -**MANDATORY**: Follow strict test-driven development (TDD) practices: - -**CRITICAL**: When a new feature or bug is introduced, ALWAYS write a test for it FIRST, then write the code to satisfy the test. This is non-negotiable. - -1. **Red Phase**: Write failing tests first before implementing any functionality -2. **Green Phase**: Write minimal code to make tests pass -3. **Refactor Phase**: Improve code while keeping tests green - -### TDD Workflow -- **ALWAYS** write tests before implementing new features or fixing bugs -- When addressing a bug, first write a test that reproduces the bug (it should fail) -- When adding a feature, first write tests that define the expected behavior -- Start with the simplest failing test case -- Write only enough code to make the test pass -- Refactor with confidence knowing tests will catch regressions -- Run `mix test` frequently during development -- Use `mix test.watch` for continuous feedback - -### Testing Patterns - -Tests use comprehensive mocking to prevent external connections: -- APRS-IS connections are mocked in test environment -- Database uses sandbox mode for isolation -- External API calls mocked with Mox library -- Write unit tests for business logic, integration tests for workflows -- Test edge cases and error conditions thoroughly -- Maintain high test coverage with `mix test --cover` - -## Code Style Guidelines - -- **CRITICAL**: Never write production code without tests first -- Use LiveView for UI interactions, minimize JavaScript -- Prefer pattern matching over if/case statements -- Follow idiomatic Elixir conventions -- **CRITICAL**: ALWAYS run `mix format` BEFORE committing - this is non-negotiable -- Address any compiler warnings -- Run `mix dialyzer` and fix all errors/warnings -- **MANDATORY**: Run `mix compile --warnings-as-errors` and ensure it passes before considering any task complete -- Use function composition over nested conditionals -- Write descriptive test names that explain behavior - -### Pre-Commit Checklist -**CRITICAL**: NEVER commit or push code with syntax errors or compilation failures. Always validate before committing. - -**MANDATORY PRE-COMMIT STEPS** - Must be executed in this exact order before EVERY git commit: - -1. **`mix format`** - ALWAYS run this FIRST before any git operations - - This formats all Elixir code according to project standards - - **NEVER skip this step** - unformatted code should never be committed - -2. **`mix compile --warnings-as-errors`** - ensure no warnings or compilation errors - - Must pass with zero warnings and zero errors - -3. **`mix test`** - ensure all tests pass (at minimum, ensure no syntax/compilation errors) - - At minimum verify no compilation failures even if some tests fail - -4. **MANDATORY**: If any step fails, fix the issues before proceeding - -5. Only after ALL checks pass should you commit and push your changes - -**NEVER PUSH BROKEN CODE**: Syntax errors, compilation failures, or basic test failures should be fixed immediately before any git operations. Pushing broken code breaks CI/CD pipelines and wastes deployment resources. - -**REMEMBER**: Always run `mix format` before every commit - this is non-negotiable and must become automatic habit. - -## Git Commit Messages - -When creating git commits: -- Write clear, concise commit messages following conventional commit format (e.g., `feat:`, `fix:`, `refactor:`, `docs:`) -- Focus on the "why" rather than the "what" in commit messages -- **DO NOT** add "Generated with Claude Code" or similar attribution to commit messages -- **DO NOT** add Co-Authored-By lines for Claude -- Keep commit messages professional and focused solely on the code changes - -## Important Documentation Updates - -- **MANDATORY**: Whenever you implement improvements or changes to the system: - 1. Update `/CHANGELOG.md` with: - - Add new entries under `[Unreleased]` section - - Use categories: Added, Changed, Fixed, Removed - - Be specific and user-focused in descriptions - 2. Update `/docs/improvement-todos.md` with: - - Mark completed items as done with the implementation date - - Add any new improvements discovered during implementation - - Update priority levels based on new insights - - Document any technical decisions or trade-offs made -- This ensures continuity across sessions and helps track progress on system improvements - -## Web Testing - -- **MANDATORY**: When viewing any website or web application, always use Puppeteer to take screenshots and interact with the page -- Use `mcp__puppeteer__puppeteer_navigate`, `mcp__puppeteer__puppeteer_screenshot`, and other Puppeteer tools -- This ensures accurate visual feedback and proper testing of the user interface - -## Deployment - -The application supports Kubernetes deployment with manifests in `k8s/` directory and GitHub Actions CI/CD pipeline. Database migrations run automatically via init containers. - -### Infrastructure - -The application runs on a highly available infrastructure: -- **Kubernetes**: k3s cluster deployed across 3 VMs -- **Virtualization**: Proxmox VE hosting the VMs -- **Hardware**: 3 Intel N100 nodes, each with: - - 32GB RAM - - 1TB SSD storage - - Low power consumption (~15W per node) -- **Distribution**: VMs spread across physical nodes for hardware redundancy - -### Kubernetes Commands - -The app is deployed in a k3s cluster with the following structure: -- **App name**: `aprs` -- **Namespace**: `aprs` -- **Deployment**: StatefulSet with 2-3 replicas -- **Manifests**: Located in `~/dev/infra/clusters/aprs/` - -Common kubectl commands for debugging: -```bash -# Check pod status -kubectl get pods -n aprs - -# Get logs from the app -kubectl logs -f deployment/aprs -n aprs - -# Get logs from a specific pod -kubectl logs -n aprs - -# Describe pod for events and details -kubectl describe pod -n aprs - -# Restart the statefulset -kubectl rollout restart statefulset/aprs -n aprs - -# Check statefulset status -kubectl rollout status statefulset/aprs -n aprs - -# Execute commands in the pod (StatefulSet) -kubectl exec -it aprs-0 -n aprs -- /app/bin/aprsme remote - -# Check cluster membership -kubectl exec -it -n aprs -- /app/bin/aprsme eval "Node.list()" - -# Check leader status -kubectl exec -it -n aprs -- /app/bin/aprsme eval "Aprsme.Cluster.LeaderElection.is_leader?()" -``` - -### Clustering Architecture - -The application uses distributed Erlang clustering to ensure only one APRS-IS connection across multiple replicas: - -1. **StatefulSet Deployment**: - - Uses Kubernetes StatefulSet for stable pod names (aprs-0, aprs-1, etc.) - - Headless service provides DNS entries for each pod - - Stable network identities enable Erlang distribution - -2. **Leader Election**: Uses `:global` registry for distributed leader election - - Only the elected leader maintains the APRS-IS connection - - Automatic failover when leader goes down - - Leader election managed by `Aprsme.Cluster.LeaderElection` - -3. **Connection Management**: - - `Aprsme.Cluster.ConnectionManager` starts/stops APRS-IS based on leadership - - Uses `DynamicSupervisor` to manage connection lifecycle - - Prevents duplicate connections and packet processing - -4. **Cluster Configuration**: - - Uses `libcluster` with Kubernetes.DNS strategy - - Automatic node discovery via headless service - - Erlang cookie configured via RELEASE_COOKIE environment variable - - Environment variables: - - `CLUSTER_ENABLED=true` - Enables clustering - - `RELEASE_NODE` - Erlang node name - - `RELEASE_COOKIE` - Erlang distribution cookie - -4. **Deployment**: - - Default replicas: 3 (configurable in `aprs-deployment.yaml`) - - Only leader processes APRS packets - - All nodes serve web traffic - -## 1Password Integration - -When working with passwords and secrets: -- **ALWAYS** use 1Password account ID: `YOOATCZZSVGH7AD6VABUVPORLI` -- Store all passwords, API keys, and secrets in 1Password -- Use the `op` CLI tool for programmatic access -- Never hardcode passwords in configuration files +[... rest of the file remains unchanged ...] \ No newline at end of file diff --git a/config/appsignal.exs b/config/appsignal.exs index 52532fe..a530c16 100644 --- a/config/appsignal.exs +++ b/config/appsignal.exs @@ -4,5 +4,4 @@ config :appsignal, :config, otp_app: :aprsme, name: "aprsme", push_api_key: "070601ca-6102-4214-82ad-d0c40df7f6ee", - env: Mix.env - + env: Mix.env() diff --git a/config/dev.exs b/config/dev.exs index 6fedb72..0e29644 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -1,5 +1,7 @@ import Config +config :appsignal, :config, active: true + # Configure your database config :aprsme, Aprsme.Repo, # For development, we disable any cache and enable @@ -111,5 +113,3 @@ config :phoenix, :plug_init_mode, :runtime config :phoenix, :stacktrace_depth, 20 config :swoosh, :api_client, false - -config :appsignal, :config, active: true diff --git a/config/prod.exs b/config/prod.exs index e8d0bde..2b50818 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -1,5 +1,7 @@ import Config +config :appsignal, :config, active: true + # For production, don't forget to configure the url host # to something meaningful, Phoenix uses this information # when generating URLs. @@ -10,12 +12,11 @@ import Config # which you should run after static files are built and # before starting your production server. config :aprsme, AprsmeWeb.Endpoint, + # Runtime production configuration, including reading cache_static_manifest: "priv/static/cache_manifest.json", adapter: Bandit.PhoenixAdapter, http: [ip: {0, 0, 0, 0}, port: 4000] -# Runtime production configuration, including reading - config :esbuild, version: "0.24.2", default: [ @@ -31,5 +32,3 @@ config :logger, level: :info # Configures Swoosh API Client config :swoosh, :api_client, Swoosh.ApiClient.Req - -config :appsignal, :config, active: true diff --git a/lib/aprsme/application.ex b/lib/aprsme/application.ex index 7eef90b..346e6bb 100644 --- a/lib/aprsme/application.ex +++ b/lib/aprsme/application.ex @@ -183,21 +183,18 @@ defmodule Aprsme.Application do defp pubsub_config do cluster_enabled = Application.get_env(:aprsme, :cluster_enabled, false) - redis_url = System.get_env("REDIS_URL") - if cluster_enabled and redis_url do + if cluster_enabled do require Logger - Logger.info("Starting Redis PubSub adapter with URL: #{redis_url}") + Logger.info("Starting distributed PubSub for clustering") - {Phoenix.PubSub, - name: Aprsme.PubSub, adapter: Phoenix.PubSub.Redis, redis_pool_size: 10, node_name: node(), url: redis_url} + # Phoenix PubSub automatically uses distributed Erlang clustering when nodes are connected + {Phoenix.PubSub, name: Aprsme.PubSub} else require Logger - Logger.info( - "Starting default PubSub adapter (cluster_enabled: #{cluster_enabled}, redis_url: #{inspect(redis_url)})" - ) + Logger.info("Starting local PubSub adapter") {Phoenix.PubSub, name: Aprsme.PubSub} end diff --git a/mix.exs b/mix.exs index b9303b0..d87faf0 100644 --- a/mix.exs +++ b/mix.exs @@ -76,7 +76,6 @@ defmodule Aprsme.MixProject do {:phoenix_live_dashboard, "~> 0.8"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_view, "~> 1.1.13"}, - {:phoenix_pubsub_redis, "~> 3.0"}, {:postgrex, ">= 0.0.0"}, {:swoosh, "~> 1.19.8"}, {:resend, "~> 0.4.1"},