diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 55ff434..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(mix test)", - "Bash(mix test:*)", - "Bash(iex:*)", - "Bash(mix compile:*)", - "Bash(ls:*)", - "Bash(find:*)", - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(mix docs:*)", - "Bash(mix format:*)", - "Bash(mix xref:*)", - "Bash(mix help:*)", - "Bash(mix deps.get:*)", - "Bash(mix run:*)" - ], - "deny": [] - } -} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..261067e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: [ '*' ] + +jobs: + test: + name: Build and test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: '1.18' + otp-version: '28' + + - name: Restore dependencies cache + uses: actions/cache@v4 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix- + + - name: Install dependencies + run: mix deps.get + + - name: Compile + run: mix compile --warnings-as-errors + + - name: Run Credo + run: mix credo --strict + + - name: Build Dialyzer PLT (cached) + id: plt + uses: actions/cache@v4 + with: + path: priv/plts + key: dialyzer-${{ hashFiles('mix.lock') }} + + - name: Run Dialyzer + run: mix dialyzer --halt-exit-status + diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml deleted file mode 100644 index bd34c60..0000000 --- a/.github/workflows/elixir.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Elixir CI - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -permissions: - contents: read - -jobs: - build: - - name: Build and test - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up Elixir - uses: erlef/setup-beam@v1 - with: - elixir-version: "1.16.2" - otp-version: "26.0" - - - name: Install dependencies - run: mix deps.get - - - name: Run tests - run: mix test diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d41fa29 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: '1.18' + otp-version: '28' + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix- + + - name: Install dependencies + run: | + mix local.hex --force + mix local.rebar --force + mix deps.get + + - name: Run tests + run: mix test diff --git a/CLAUDE.md b/CLAUDE.md index 164e832..e4c83ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,10 +14,11 @@ This is Phoenix.SessionProcess, an Elixir library that creates a process for eac - `mix test test/path/to/specific_test.exs` - Run a specific test file - `mix compile` - Compile the project - `mix docs` - Generate documentation +- `mix format` - Format code - `mix hex.publish` - Publish to Hex.pm (requires authentication) ### Testing -The test suite uses ExUnit. Tests are located in the `test/` directory. The test helper starts the supervisor automatically. +The test suite uses ExUnit. Tests are located in the `test/` directory. The test helper (test/test_helper.exs:3) automatically starts the supervisor. ### Development Environment The project uses `devenv` for development environment setup with Nix. Key configuration: @@ -42,34 +43,53 @@ Expected performance: 1. **Phoenix.SessionProcess** (lib/phoenix/session_process.ex:1) - Main module providing the public API - Delegates to ProcessSupervisor for actual process management - - Provides macros for creating session processes + - Provides two macros: `:process` (basic) and `:process_link` (with LiveView monitoring) -2. **Phoenix.SessionProcess.Supervisor** (lib/phoenix/session_process/superviser.ex) - - Top-level supervisor that manages the Registry and ProcessSupervisor +2. **Phoenix.SessionProcess.Supervisor** (lib/phoenix/session_process/superviser.ex:1) + - Top-level supervisor that manages the Registry, ProcessSupervisor, and Cleanup - Must be added to the application's supervision tree -3. **Phoenix.SessionProcess.ProcessSupervisor** (lib/phoenix/session_process/process_superviser.ex) +3. **Phoenix.SessionProcess.ProcessSupervisor** (lib/phoenix/session_process/process_superviser.ex:1) - DynamicSupervisor that manages individual session processes - Handles starting, terminating, and communicating with session processes + - Performs session validation and limit checks 4. **Phoenix.SessionProcess.SessionId** (lib/phoenix/session_process/session_id.ex) - Plug that generates unique session IDs - Must be placed after `:fetch_session` plug +5. **Phoenix.SessionProcess.Cleanup** (lib/phoenix/session_process/cleanup.ex:1) + - Automatic TTL-based session cleanup + - Schedules session expiration on creation + +6. **Phoenix.SessionProcess.Redux** (lib/phoenix/session_process/redux.ex:1) + - Redux-style state management with actions and reducers + - Provides time-travel debugging, middleware support, and action history + +7. **Phoenix.SessionProcess.State** (lib/phoenix/session_process/state.ex:1) + - Agent-based state storage with Redux-style dispatch support + - Used for simpler state management scenarios + ### Process Management Flow 1. Session ID generation via the SessionId plug 2. Process creation through `Phoenix.SessionProcess.start/1-3` -3. Processes are registered in `Phoenix.SessionProcess.Registry` -4. Communication via `call/2-3` and `cast/2` -5. Automatic cleanup when processes terminate +3. Validation checks (session ID format, session limits) +4. Processes are registered in `Phoenix.SessionProcess.Registry` with two entries: + - `{session_id, pid}` for session lookup + - `{pid, module}` for module tracking +5. TTL-based cleanup is scheduled for each session +6. Communication via `call/2-3` and `cast/2` +7. Automatic cleanup when processes terminate or TTL expires ### Key Design Patterns -- Uses Registry for process lookup by session ID +- Uses Registry for bidirectional lookups (session_id ↔ pid, pid ↔ module) - DynamicSupervisor for on-demand process creation -- Provides two macros: `:process` (basic) and `:process_link` (with LiveView monitoring) -- Session processes can monitor LiveView processes and notify them on termination +- Macros inject GenServer boilerplate and provide `get_session_id/0` helper +- `:process_link` macro adds LiveView monitoring: sessions monitor LiveView processes and send `:session_expired` message on termination +- Telemetry events for all lifecycle operations (start, stop, call, cast, cleanup, errors) +- Comprehensive error handling with Phoenix.SessionProcess.Error module ## Configuration @@ -77,9 +97,9 @@ The library uses application configuration: ```elixir config :phoenix_session_process, session_process: MySessionProcess, # Default session module - max_sessions: 10_000, # Maximum concurrent sessions - session_ttl: 3_600_000, # Session TTL in milliseconds (1 hour) - rate_limit: 100 # Sessions per minute limit + max_sessions: 10_000, # Maximum concurrent sessions + session_ttl: 3_600_000, # Session TTL in milliseconds (1 hour) + rate_limit: 100 # Sessions per minute limit ``` Configuration options: @@ -91,20 +111,33 @@ Configuration options: ## Usage in Phoenix Applications 1. Add supervisor to application supervision tree -2. Add SessionId plug after fetch_session -3. Define custom session process modules using the provided macros +2. Add SessionId plug after fetch_session in router +3. Define custom session process modules using `:process` or `:process_link` macros 4. Start processes with session IDs 5. Communicate using call/cast operations +## State Management Options + +The library provides three state management approaches: + +1. **Basic GenServer** - Full control with standard GenServer callbacks +2. **Phoenix.SessionProcess.State** - Agent-based with simple get/put and Redux dispatch +3. **Phoenix.SessionProcess.Redux** - Full Redux pattern with actions, reducers, middleware, time-travel debugging + ## Telemetry and Error Handling ### Telemetry Events The library emits comprehensive telemetry events for monitoring: - `[:phoenix, :session_process, :start]` - Session starts - `[:phoenix, :session_process, :stop]` - Session stops +- `[:phoenix, :session_process, :start_error]` - Session start errors - `[:phoenix, :session_process, :call]` - Call operations - `[:phoenix, :session_process, :cast]` - Cast operations +- `[:phoenix, :session_process, :communication_error]` - Communication errors - `[:phoenix, :session_process, :cleanup]` - Session cleanup +- `[:phoenix, :session_process, :cleanup_error]` - Cleanup errors + +Events include metadata (session_id, module, pid) and measurements (duration in native time units). ### Error Types Common error responses: @@ -113,4 +146,4 @@ Common error responses: - `{:error, {:session_not_found, session_id}}` - Session doesn't exist - `{:error, {:timeout, timeout}}` - Operation timed out -Use `Phoenix.SessionProcess.Error.message/1` for human-readable error messages. \ No newline at end of file +Use `Phoenix.SessionProcess.Error.message/1` for human-readable error messages. diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 50bdd54..0000000 --- a/GEMINI.md +++ /dev/null @@ -1,73 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -This is Phoenix.SessionProcess, an Elixir library that creates a process for each user session in Phoenix applications. All user requests go through their dedicated session process, providing session isolation and state management. - -## Key Commands - -### Development Commands -- `mix deps.get` - Install dependencies -- `mix test` - Run all tests -- `mix test test/path/to/specific_test.exs` - Run a specific test file -- `mix compile` - Compile the project -- `mix docs` - Generate documentation -- `mix hex.publish` - Publish to Hex.pm (requires authentication) - -### Testing -The test suite uses ExUnit. Tests are located in the `test/` directory. The test helper starts the supervisor automatically. - -## Architecture - -### Core Components - -1. **Phoenix.SessionProcess** (lib/phoenix/session_process.ex:1) - - Main module providing the public API - - Delegates to ProcessSupervisor for actual process management - - Provides macros for creating session processes - -2. **Phoenix.SessionProcess.Supervisor** (lib/phoenix/session_process/superviser.ex) - - Top-level supervisor that manages the Registry and ProcessSupervisor - - Must be added to the application's supervision tree - -3. **Phoenix.SessionProcess.ProcessSupervisor** (lib/phoenix/session_process/process_superviser.ex) - - DynamicSupervisor that manages individual session processes - - Handles starting, terminating, and communicating with session processes - -4. **Phoenix.SessionProcess.SessionId** (lib/phoenix/session_process/session_id.ex) - - Plug that generates unique session IDs - - Must be placed after `:fetch_session` plug - -### Process Management Flow - -1. Session ID generation via the SessionId plug -2. Process creation through `Phoenix.SessionProcess.start/1-3` -3. Processes are registered in `Phoenix.SessionProcess.Registry` -4. Communication via `call/2-3` and `cast/2` -5. Automatic cleanup when processes terminate - -### Key Design Patterns - -- Uses Registry for process lookup by session ID -- DynamicSupervisor for on-demand process creation -- Provides two macros: `:process` (basic) and `:process_link` (with LiveView monitoring) -- Session processes can monitor LiveView processes and notify them on termination - -## Configuration - -The library uses application configuration: -```elixir -config :phoenix_session_process, session_process: MySessionProcess -``` - -This sets the default module to use when starting session processes without specifying a module. - -## Usage in Phoenix Applications - -1. Add supervisor to application supervision tree -2. Add SessionId plug after fetch_session -3. Define custom session process modules using the provided macros -4. Start processes with session IDs -5. Communicate using call/cast operations \ No newline at end of file diff --git a/devenv.nix b/devenv.nix index d62e0eb..aad0ddd 100644 --- a/devenv.nix +++ b/devenv.nix @@ -1,4 +1,3 @@ -{ pkgs, lib, config, inputs, ... }: let pkgs-stable = import inputs.nixpkgs-stable { system = pkgs.stdenv.system; }; diff --git a/lib/phoenix/session_process.ex b/lib/phoenix/session_process.ex index 7e4d149..6b495b2 100644 --- a/lib/phoenix/session_process.ex +++ b/lib/phoenix/session_process.ex @@ -1,62 +1,199 @@ defmodule Phoenix.SessionProcess do @moduledoc """ - Documentation for `Phoenix.SessionProcess`. + Main API for managing isolated session processes in Phoenix applications. - Add superviser to process tree + This module provides a high-level interface for creating, managing, and communicating + with dedicated GenServer processes for each user session. Each session runs in its own + isolated process, enabling real-time session state management without external dependencies. - [ - ... - {Phoenix.SessionProcess.Supervisor, []} - ] + ## Features - Add this after the `:fetch_session` plug to generate a unique session ID. + - **Session Isolation**: Each user session runs in a dedicated GenServer process + - **Automatic Cleanup**: TTL-based session expiration and garbage collection + - **LiveView Integration**: Built-in support for monitoring LiveView processes + - **High Performance**: 10,000+ sessions/second creation rate + - **Zero Dependencies**: No Redis, databases, or external services required + - **Comprehensive Telemetry**: Built-in observability for all operations - plug :fetch_session - plug Phoenix.SessionProcess.SessionId + ## Quick Start - Start a session process with a session ID. + ### 1. Add to Supervision Tree - Phoenix.SessionProcess.start("session_id") + Add the supervisor to your application's supervision tree in `lib/my_app/application.ex`: - This will start a session process using the module defined with + def start(_type, _args) do + children = [ + # ... other children ... + {Phoenix.SessionProcess.Supervisor, []} + ] - config :phoenix_session_process, session_process: MySessionProcess + Supervisor.start_link(children, strategy: :one_for_one) + end + + ### 2. Configure Session ID Generation + + Add the SessionId plug after `:fetch_session` in your router: + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug Phoenix.SessionProcess.SessionId # Add this + # ... other plugs ... + end + + ### 3. Use in Controllers and LiveViews + + defmodule MyAppWeb.PageController do + use MyAppWeb, :controller + + def index(conn, _params) do + session_id = conn.assigns.session_id + + # Start session process + {:ok, _pid} = Phoenix.SessionProcess.start(session_id) + + # Store data + Phoenix.SessionProcess.cast(session_id, {:put, :user_id, 123}) + + # Retrieve data + {:ok, state} = Phoenix.SessionProcess.call(session_id, :get_state) + + render(conn, "index.html", state: state) + end + end + + ## Configuration + + Configure the library in `config/config.exs`: + + config :phoenix_session_process, + session_process: MyApp.SessionProcess, # Default session module + max_sessions: 10_000, # Maximum concurrent sessions + session_ttl: 3_600_000, # Session TTL (1 hour) + rate_limit: 100 # Sessions per minute + + ## Creating Custom Session Processes + + ### Basic Session Process + + defmodule MyApp.SessionProcess do + use Phoenix.SessionProcess, :process + + @impl true + def init(_init_arg) do + {:ok, %{user_id: nil, cart: [], preferences: %{}}} + end + + @impl true + def handle_call(:get_user, _from, state) do + {:reply, state.user_id, state} + end + + @impl true + def handle_cast({:set_user, user_id}, state) do + {:noreply, %{state | user_id: user_id}} + end + end - Or you can start a session process with a specific module. + ### With LiveView Integration - Phoenix.SessionProcess.start("session_id", MySessionProcess) - # or - Phoenix.SessionProcess.start("session_id", MySessionProcess, arg) + defmodule MyApp.SessionProcessWithLiveView do + use Phoenix.SessionProcess, :process_link - Check if a session process is started. + @impl true + def init(_init_arg) do + {:ok, %{user: nil, live_views: []}} + end + + # Automatically monitors LiveView processes + # Sends :session_expired message when session terminates + end + + ## API Overview - Phoenix.SessionProcess.started?("session_id") + ### Session Management + - `start/1`, `start/2`, `start/3` - Start session processes + - `started?/1` - Check if session exists + - `terminate/1` - Stop session process + - `find_session/1` - Find session by ID - Terminate a session process. + ### Communication + - `call/2`, `call/3` - Synchronous requests + - `cast/2` - Asynchronous messages - Phoenix.SessionProcess.terminate("session_id") + ### Inspection + - `list_session/0` - List all sessions + - `session_info/0` - Get session count and modules + - `session_stats/0` - Get memory and performance stats + - `list_sessions_by_module/1` - Filter sessions by module - Genserver call on a session process. + ## Error Handling - Phoenix.SessionProcess.call("session_id", request) + All operations return structured error tuples: - Genserver cast on a session process. + {:error, {:invalid_session_id, session_id}} + {:error, {:session_limit_reached, max_sessions}} + {:error, {:session_not_found, session_id}} + {:error, {:timeout, timeout}} - Phoenix.SessionProcess.cast("session_id", request) + Use `Phoenix.SessionProcess.Error.message/1` for human-readable errors. - List all session processes. + ## Performance - Phoenix.SessionProcess.list_session() + Expected performance metrics: + - Session Creation: 10,000+ sessions/sec + - Memory Usage: ~10KB per session + - Registry Lookups: 100,000+ lookups/sec + + See the benchmarking guide at `bench/README.md` for details. """ + alias Phoenix.SessionProcess.{Config, ProcessSupervisor} + alias Phoenix.SessionProcess.Registry, as: SessionRegistry + + @doc """ + Starts a session process using the default configured module. + + The session process is registered in the Registry and scheduled for automatic + cleanup based on the configured TTL. + + ## Parameters + - `session_id` - Unique binary identifier for the session + + ## Returns + - `{:ok, pid}` - Session process started successfully + - `{:error, {:already_started, pid}}` - Session already exists + - `{:error, {:invalid_session_id, id}}` - Invalid session ID format + - `{:error, {:session_limit_reached, max}}` - Maximum sessions exceeded + + ## Examples + + {:ok, pid} = Phoenix.SessionProcess.start("user_123") + {:error, {:already_started, pid}} = Phoenix.SessionProcess.start("user_123") + """ @spec start(binary()) :: {:ok, pid()} | {:error, term()} defdelegate start(session_id), to: Phoenix.SessionProcess.ProcessSupervisor, as: :start_session @doc """ - Start a session process with a specific module. + Starts a session process using a custom module. + + This allows you to use a specific session process implementation instead of + the default configured module. + + ## Parameters + - `session_id` - Unique binary identifier for the session + - `module` - Module implementing the session process behavior + + ## Returns + - `{:ok, pid}` - Session process started successfully + - `{:error, {:already_started, pid}}` - Session already exists + - `{:error, {:invalid_session_id, id}}` - Invalid session ID format + - `{:error, {:session_limit_reached, max}}` - Maximum sessions exceeded ## Examples + {:ok, pid} = Phoenix.SessionProcess.start("user_123", MyApp.CustomSessionProcess) + iex> result = Phoenix.SessionProcess.start("valid_session", Phoenix.SessionProcess.DefaultSessionProcess) iex> match?({:ok, _pid}, result) or match?({:error, {:already_started, _pid}}, result) true @@ -70,10 +207,30 @@ defmodule Phoenix.SessionProcess do as: :start_session @doc """ - Start a session process with a specific module and initialization arguments. + Starts a session process with a custom module and initialization arguments. + + The initialization arguments are passed to the module's `init/1` callback, + allowing you to set up initial state or configuration. + + ## Parameters + - `session_id` - Unique binary identifier for the session + - `module` - Module implementing the session process behavior + - `arg` - Initialization argument(s) passed to `init/1` + + ## Returns + - `{:ok, pid}` - Session process started successfully + - `{:error, {:already_started, pid}}` - Session already exists + - `{:error, {:invalid_session_id, id}}` - Invalid session ID format + - `{:error, {:session_limit_reached, max}}` - Maximum sessions exceeded ## Examples + # With map argument + {:ok, pid} = Phoenix.SessionProcess.start("user_123", MyApp.SessionProcess, %{user_id: 123}) + + # With keyword list + {:ok, pid} = Phoenix.SessionProcess.start("user_456", MyApp.SessionProcess, [debug: true]) + iex> result = Phoenix.SessionProcess.start("valid_session_with_args", Phoenix.SessionProcess.DefaultSessionProcess, %{user_id: 123}) iex> match?({:ok, _pid}, result) or match?({:error, {:already_started, _pid}}, result) true @@ -87,21 +244,98 @@ defmodule Phoenix.SessionProcess do to: Phoenix.SessionProcess.ProcessSupervisor, as: :start_session + @doc """ + Checks if a session process is currently running. + + ## Parameters + - `session_id` - Unique binary identifier for the session + + ## Returns + - `true` - Session process exists and is running + - `false` - Session process does not exist + + ## Examples + + {:ok, _pid} = Phoenix.SessionProcess.start("user_123") + true = Phoenix.SessionProcess.started?("user_123") + false = Phoenix.SessionProcess.started?("nonexistent") + """ @spec started?(binary()) :: boolean() defdelegate started?(session_id), to: Phoenix.SessionProcess.ProcessSupervisor, as: :session_process_started? + @doc """ + Terminates a session process. + + This gracefully shuts down the session process and removes it from the Registry. + Emits telemetry events for session stop. + + ## Parameters + - `session_id` - Unique binary identifier for the session + + ## Returns + - `:ok` - Session terminated successfully + - `{:error, :not_found}` - Session does not exist + + ## Examples + + {:ok, _pid} = Phoenix.SessionProcess.start("user_123") + :ok = Phoenix.SessionProcess.terminate("user_123") + {:error, :not_found} = Phoenix.SessionProcess.terminate("user_123") + """ @spec terminate(binary()) :: :ok | {:error, :not_found} defdelegate terminate(session_id), to: Phoenix.SessionProcess.ProcessSupervisor, as: :terminate_session - @spec call(binary(), any(), :infinity | non_neg_integer()) :: {:ok, any()} | {:error, term()} + @doc """ + Makes a synchronous call to a session process. + + Sends a synchronous request to the session process and waits for a response. + The request is handled by the session process's `handle_call/3` callback. + + ## Parameters + - `session_id` - Unique binary identifier for the session + - `request` - The request message to send + - `timeout` - Maximum time to wait for response in milliseconds (default: 15,000) + + ## Returns + - Response from the session process's `handle_call/3` callback + - `{:error, {:session_not_found, id}}` - Session does not exist + - `{:error, {:timeout, timeout}}` - Request timed out + + ## Examples + + {:ok, _pid} = Phoenix.SessionProcess.start("user_123") + {:ok, state} = Phoenix.SessionProcess.call("user_123", :get_state) + {:ok, :pong} = Phoenix.SessionProcess.call("user_123", :ping, 5_000) + """ + @spec call(binary(), any(), :infinity | non_neg_integer()) :: any() defdelegate call(session_id, request, timeout \\ 15_000), to: Phoenix.SessionProcess.ProcessSupervisor, as: :call_on_session + @doc """ + Sends an asynchronous message to a session process. + + Sends a fire-and-forget message to the session process. The message is handled + by the session process's `handle_cast/2` callback. Does not wait for a response. + + ## Parameters + - `session_id` - Unique binary identifier for the session + - `request` - The message to send + + ## Returns + - `:ok` - Message sent successfully + - `{:error, {:session_not_found, id}}` - Session does not exist + + ## Examples + + {:ok, _pid} = Phoenix.SessionProcess.start("user_123") + :ok = Phoenix.SessionProcess.cast("user_123", {:put, :user_id, 123}) + :ok = Phoenix.SessionProcess.cast("user_123", {:delete, :old_key}) + """ @spec cast(binary(), any()) :: :ok | {:error, term()} defdelegate cast(session_id, request), to: Phoenix.SessionProcess.ProcessSupervisor, @@ -117,10 +351,10 @@ defmodule Phoenix.SessionProcess do # Returns list of {session_id, pid} tuples, or empty list if no sessions exist """ - @spec list_session() :: [{binary(), pid()}, ...] - def list_session() do - Registry.select(Phoenix.SessionProcess.Registry, [ - {{:":$1", :":$2", :_}, [], [{{:":$1", :":$2"}}]} + @spec list_session :: [{binary(), pid()}] + def list_session do + Registry.select(SessionRegistry, [ + {{:"$1", :"$2", :_}, [{:is_binary, :"$1"}], [{{:"$1", :"$2"}}]} ]) end @@ -141,16 +375,16 @@ defmodule Phoenix.SessionProcess do - `%{count: integer(), modules: list(module())}` - A map containing the total number of active sessions and a list of unique session process modules. """ - @spec session_info() :: %{count: integer(), modules: list(module())} - def session_info() do + @spec session_info :: %{count: integer(), modules: list(module())} + def session_info do sessions = list_session() modules = sessions |> Enum.map(fn {_session_id, pid} -> - case Registry.lookup(Phoenix.SessionProcess.Registry, pid) do + case Registry.lookup(SessionRegistry, pid) do [{_, module}] -> module - _ -> Phoenix.SessionProcess.Config.session_process() + _ -> Config.session_process() end end) |> Enum.uniq() @@ -182,11 +416,14 @@ defmodule Phoenix.SessionProcess do """ @spec list_sessions_by_module(module()) :: [binary()] def list_sessions_by_module(module) do - Registry.select(Phoenix.SessionProcess.Registry, [ - {{:"$1", :"$2", :"$_"}, [], [{{:"$1", :"$2", :"$_"}}]} - ]) - |> Enum.filter(fn {_session_id, _pid, mod} -> mod == module end) - |> Enum.map(fn {session_id, _pid, _mod} -> session_id end) + list_session() + |> Enum.filter(fn {_session_id, pid} -> + case Registry.lookup(SessionRegistry, pid) do + [{_, ^module}] -> true + _ -> false + end + end) + |> Enum.map(fn {session_id, _pid} -> session_id end) end @doc """ @@ -215,7 +452,7 @@ defmodule Phoenix.SessionProcess do """ @spec find_session(binary()) :: {:ok, pid()} | {:error, :not_found} def find_session(session_id) do - case Phoenix.SessionProcess.ProcessSupervisor.session_process_pid(session_id) do + case ProcessSupervisor.session_process_pid(session_id) do nil -> {:error, :not_found} pid -> {:ok, pid} end @@ -245,30 +482,16 @@ defmodule Phoenix.SessionProcess do - `memory_usage` - Total memory usage in bytes for all session processes - `avg_memory_per_session` - Average memory usage per session in bytes """ - @spec session_stats() :: %{ + @spec session_stats :: %{ total_sessions: integer(), memory_usage: integer(), avg_memory_per_session: integer() } - def session_stats() do + def session_stats do sessions = list_session() total_sessions = length(sessions) - - memory_usage = - if total_sessions > 0 do - sessions - |> Enum.map(fn {_session_id, pid} -> - case :erlang.process_info(pid, :memory) do - {:memory, memory} -> memory - _ -> 0 - end - end) - |> Enum.sum() - else - 0 - end - - avg_memory = if total_sessions > 0, do: div(memory_usage, total_sessions), else: 0 + memory_usage = calculate_total_memory(sessions) + avg_memory = calculate_average_memory(memory_usage, total_sessions) %{ total_sessions: total_sessions, @@ -277,6 +500,24 @@ defmodule Phoenix.SessionProcess do } end + defp calculate_total_memory([]), do: 0 + + defp calculate_total_memory(sessions) do + sessions + |> Enum.map(&get_process_memory/1) + |> Enum.sum() + end + + defp get_process_memory({_session_id, pid}) do + case :erlang.process_info(pid, :memory) do + {:memory, memory} -> memory + _ -> 0 + end + end + + defp calculate_average_memory(_memory, 0), do: 0 + defp calculate_average_memory(memory, total), do: div(memory, total) + defmacro __using__(:process) do quote do use GenServer @@ -287,11 +528,11 @@ defmodule Phoenix.SessionProcess do GenServer.start_link(__MODULE__, arg, name: name) end - def get_session_id() do + def get_session_id do current_pid = self() - Registry.select(Phoenix.SessionProcess.Registry, [ - {{:":$1", :":$2", :_}, [{:==, :":$2", current_pid}], [{{:":$1", :":$2"}}]} + Registry.select(unquote(SessionRegistry), [ + {{:"$1", :"$2", :_}, [{:==, :"$2", current_pid}], [{{:"$1", :"$2"}}]} ]) |> Enum.at(0) |> elem(0) @@ -309,11 +550,11 @@ defmodule Phoenix.SessionProcess do GenServer.start_link(__MODULE__, args, name: name) end - def get_session_id() do + def get_session_id do current_pid = self() - Registry.select(Phoenix.SessionProcess.Registry, [ - {{:":$1", :":$2", :_}, [{:==, :":$2", current_pid}], [{{:":$1", :":$2"}}]} + Registry.select(unquote(SessionRegistry), [ + {{:"$1", :"$2", :_}, [{:==, :"$2", current_pid}], [{{:"$1", :"$2"}}]} ]) |> Enum.at(0) |> elem(0) @@ -327,6 +568,7 @@ defmodule Phoenix.SessionProcess do {:noreply, new_state} end + @impl true def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do new_state = state @@ -335,6 +577,7 @@ defmodule Phoenix.SessionProcess do {:noreply, new_state} end + @impl true def terminate(_reason, state) do state |> Map.get(:__live_view__, []) diff --git a/lib/phoenix/session_process/cleanup.ex b/lib/phoenix/session_process/cleanup.ex index 54558ad..fd302cf 100644 --- a/lib/phoenix/session_process/cleanup.ex +++ b/lib/phoenix/session_process/cleanup.ex @@ -74,6 +74,8 @@ defmodule Phoenix.SessionProcess.Cleanup do use GenServer require Logger + alias Phoenix.SessionProcess.{Config, Helpers, ProcessSupervisor, Telemetry} + # 1 minute @cleanup_interval 60_000 @@ -96,20 +98,26 @@ defmodule Phoenix.SessionProcess.Cleanup do @impl true def handle_info({:cleanup_session, session_id}, state) do - if Phoenix.SessionProcess.ProcessSupervisor.session_process_started?(session_id) do - session_pid = Phoenix.SessionProcess.ProcessSupervisor.session_process_pid(session_id) - Phoenix.SessionProcess.Telemetry.emit_auto_cleanup_event(session_id, Phoenix.SessionProcess.Helpers.get_session_module(session_pid), session_pid) + if ProcessSupervisor.session_process_started?(session_id) do + session_pid = ProcessSupervisor.session_process_pid(session_id) + + Telemetry.emit_auto_cleanup_event( + session_id, + Helpers.get_session_module(session_pid), + session_pid + ) + Phoenix.SessionProcess.terminate(session_id) end {:noreply, state} end - defp schedule_cleanup() do + defp schedule_cleanup do Process.send_after(self(), :cleanup, @cleanup_interval) end - defp cleanup_expired_sessions() do + defp cleanup_expired_sessions do # This could be enhanced to track last activity # For now, sessions are cleaned up based on TTL from creation :ok @@ -120,7 +128,7 @@ defmodule Phoenix.SessionProcess.Cleanup do """ @spec schedule_session_cleanup(binary()) :: :ok def schedule_session_cleanup(session_id) do - ttl = Phoenix.SessionProcess.Config.session_ttl() + ttl = Config.session_ttl() Process.send_after(__MODULE__, {:cleanup_session, session_id}, ttl) :ok end diff --git a/lib/phoenix/session_process/config.ex b/lib/phoenix/session_process/config.ex index 8d79868..de2f728 100644 --- a/lib/phoenix/session_process/config.ex +++ b/lib/phoenix/session_process/config.ex @@ -87,8 +87,8 @@ defmodule Phoenix.SessionProcess.Config do config :phoenix_session_process, session_process: MyApp.CustomSessionProcess """ - @spec session_process() :: module() - def session_process() do + @spec session_process :: module() + def session_process do Application.get_env(:phoenix_session_process, :session_process, @default_session_process) end @@ -114,8 +114,8 @@ defmodule Phoenix.SessionProcess.Config do config :phoenix_session_process, max_sessions: 50_000 """ - @spec max_sessions() :: integer() - def max_sessions() do + @spec max_sessions :: integer() + def max_sessions do Application.get_env(:phoenix_session_process, :max_sessions, @default_max_sessions) end @@ -145,8 +145,8 @@ defmodule Phoenix.SessionProcess.Config do Sessions are automatically cleaned up after being idle for this duration. """ - @spec session_ttl() :: integer() - def session_ttl() do + @spec session_ttl :: integer() + def session_ttl do Application.get_env(:phoenix_session_process, :session_ttl, @default_session_ttl) end @@ -176,8 +176,8 @@ defmodule Phoenix.SessionProcess.Config do This helps prevent session creation abuse and protects against DoS attacks. """ - @spec rate_limit() :: integer() - def rate_limit() do + @spec rate_limit :: integer() + def rate_limit do Application.get_env(:phoenix_session_process, :rate_limit, @default_rate_limit) end diff --git a/lib/phoenix/session_process/helpers.ex b/lib/phoenix/session_process/helpers.ex index 733fa55..0174273 100644 --- a/lib/phoenix/session_process/helpers.ex +++ b/lib/phoenix/session_process/helpers.ex @@ -7,6 +7,8 @@ defmodule Phoenix.SessionProcess.Helpers do """ alias Phoenix.SessionProcess + alias Phoenix.SessionProcess.Config + alias Phoenix.SessionProcess.Registry, as: SessionRegistry @doc """ Start sessions for multiple session IDs in parallel. @@ -68,8 +70,8 @@ defmodule Phoenix.SessionProcess.Helpers do iex> Phoenix.SessionProcess.Helpers.session_health() %{healthy: 10, crashed: 0, total: 10} """ - @spec session_health() :: %{healthy: integer(), crashed: integer(), total: integer()} - def session_health() do + @spec session_health :: %{healthy: integer(), crashed: integer(), total: integer()} + def session_health do sessions = SessionProcess.list_session() {healthy, crashed} = @@ -183,9 +185,9 @@ defmodule Phoenix.SessionProcess.Helpers do """ @spec get_session_module(pid()) :: module() def get_session_module(pid) do - case Registry.lookup(Phoenix.SessionProcess.Registry, pid) do + case Registry.lookup(SessionRegistry, pid) do [{_, module}] -> module - _ -> Phoenix.SessionProcess.Config.session_process() + _ -> Config.session_process() end end end diff --git a/lib/phoenix/session_process/process_superviser.ex b/lib/phoenix/session_process/process_superviser.ex index 8ea41ea..dd37e0a 100644 --- a/lib/phoenix/session_process/process_superviser.ex +++ b/lib/phoenix/session_process/process_superviser.ex @@ -85,7 +85,8 @@ defmodule Phoenix.SessionProcess.ProcessSupervisor do # Automatically defines child_spec/1 use DynamicSupervisor - alias Phoenix.SessionProcess.{Telemetry, Error} + alias Phoenix.SessionProcess.{Cleanup, Config, Error, Telemetry} + alias Phoenix.SessionProcess.Registry, as: SessionRegistry @doc """ Starts the process supervisor. @@ -126,7 +127,7 @@ defmodule Phoenix.SessionProcess.ProcessSupervisor do """ def start_child(worker, worker_arg) do worker_spec = {worker, worker_arg} - Phoenix.SessionProcess.Telemetry.emit_worker_start(worker_spec) + Telemetry.emit_worker_start(worker_spec) DynamicSupervisor.start_child(__MODULE__, worker_spec) end @@ -143,7 +144,7 @@ defmodule Phoenix.SessionProcess.ProcessSupervisor do - `{:error, reason}` - Failed to terminate child process """ def terminate_child(pid) do - Phoenix.SessionProcess.Telemetry.emit_worker_terminate(pid) + Telemetry.emit_worker_terminate(pid) DynamicSupervisor.terminate_child(__MODULE__, pid) end @@ -176,7 +177,7 @@ defmodule Phoenix.SessionProcess.ProcessSupervisor do - `{:error, reason}` - Failed to start session process """ def start_session(session_id) do - start_session_with_module(session_id, Phoenix.SessionProcess.Config.session_process()) + start_session_with_module(session_id, Config.session_process()) end @doc """ @@ -255,7 +256,7 @@ defmodule Phoenix.SessionProcess.ProcessSupervisor do """ @spec terminate_session(binary()) :: :ok | {:error, :not_found} def terminate_session(session_id) do - Phoenix.SessionProcess.Telemetry.emit_session_end_event(session_id) + Telemetry.emit_session_end_event(session_id) start_time = System.monotonic_time() case session_process_pid(session_id) do @@ -306,50 +307,46 @@ defmodule Phoenix.SessionProcess.ProcessSupervisor do end defp do_call_on_session(session_id, pid, module, request, timeout, start_time) do - try do - result = GenServer.call(pid, request, timeout) + result = GenServer.call(pid, request, timeout) + duration = System.monotonic_time() - start_time + Telemetry.emit_session_call(session_id, module, pid, request, duration: duration) + result + catch + :exit, {:timeout, _} -> duration = System.monotonic_time() - start_time - Telemetry.emit_session_call(session_id, module, pid, request, duration: duration) - result - catch - :exit, {:timeout, _} -> - duration = System.monotonic_time() - start_time - Telemetry.emit_communication_error(session_id, module, :call, :timeout, - duration: duration - ) + Telemetry.emit_communication_error(session_id, module, :call, :timeout, + duration: duration + ) - Error.timeout(timeout) + Error.timeout(timeout) - :exit, reason -> - duration = System.monotonic_time() - start_time - Telemetry.emit_communication_error(session_id, module, :call, reason, duration: duration) - Error.call_failed(module, :call, {request}, reason) - end + :exit, reason -> + duration = System.monotonic_time() - start_time + Telemetry.emit_communication_error(session_id, module, :call, reason, duration: duration) + Error.call_failed(module, :call, {request}, reason) end defp do_cast_on_session(session_id, pid, module, request, start_time) do - try do - result = GenServer.cast(pid, request) + result = GenServer.cast(pid, request) + duration = System.monotonic_time() - start_time + Telemetry.emit_session_cast(session_id, module, pid, request, duration: duration) + result + catch + :exit, reason -> duration = System.monotonic_time() - start_time - Telemetry.emit_session_cast(session_id, module, pid, request, duration: duration) - result - catch - :exit, reason -> - duration = System.monotonic_time() - start_time - Telemetry.emit_communication_error(session_id, module, :cast, reason, duration: duration) - Error.cast_failed(module, :cast, {request}, reason) - end + Telemetry.emit_communication_error(session_id, module, :cast, reason, duration: duration) + Error.cast_failed(module, :cast, {request}, reason) end - @spec child_name(binary()) :: {:via, Registry, {Phoenix.SessionProcess.Registry, binary()}} + @spec child_name(binary()) :: {:via, Registry, {SessionRegistry, binary()}} def child_name(session_id) do - {:via, Registry, {Phoenix.SessionProcess.Registry, session_id}} + {:via, Registry, {SessionRegistry, session_id}} end @spec session_process_pid(binary()) :: nil | pid() def session_process_pid(session_id) do - case Registry.whereis_name({Phoenix.SessionProcess.Registry, session_id}) do + case Registry.whereis_name({SessionRegistry, session_id}) do :undefined -> nil pid -> pid end @@ -360,7 +357,7 @@ defmodule Phoenix.SessionProcess.ProcessSupervisor do with :ok <- validate_session_id(session_id), :ok <- check_session_limits() do - Phoenix.SessionProcess.Telemetry.emit_session_start_event(session_id) + Telemetry.emit_session_start_event(session_id) worker_args = if arg, do: [name: child_name(session_id), arg: arg], else: [name: child_name(session_id)] @@ -369,15 +366,15 @@ defmodule Phoenix.SessionProcess.ProcessSupervisor do case DynamicSupervisor.start_child(__MODULE__, spec) do {:ok, pid} = result -> - Registry.register(Phoenix.SessionProcess.Registry, pid, module) - Phoenix.SessionProcess.Cleanup.schedule_session_cleanup(session_id) + Registry.register(SessionRegistry, pid, module) + Cleanup.schedule_session_cleanup(session_id) duration = System.monotonic_time() - start_time Telemetry.emit_session_start(session_id, module, pid, duration: duration) result {:ok, pid, _info} = result -> - Registry.register(Phoenix.SessionProcess.Registry, pid, module) - Phoenix.SessionProcess.Cleanup.schedule_session_cleanup(session_id) + Registry.register(SessionRegistry, pid, module) + Cleanup.schedule_session_cleanup(session_id) duration = System.monotonic_time() - start_time Telemetry.emit_session_start(session_id, module, pid, duration: duration) result @@ -403,7 +400,7 @@ defmodule Phoenix.SessionProcess.ProcessSupervisor do Error.invalid_session_id(session_id) {:error, :session_limit_reached} -> - max_sessions = Phoenix.SessionProcess.Config.max_sessions() + max_sessions = Config.max_sessions() duration = System.monotonic_time() - start_time Telemetry.emit_session_start_error( @@ -418,23 +415,23 @@ defmodule Phoenix.SessionProcess.ProcessSupervisor do end defp get_session_module(pid) do - case Registry.lookup(Phoenix.SessionProcess.Registry, pid) do + case Registry.lookup(SessionRegistry, pid) do [{_, module}] -> module - _ -> Phoenix.SessionProcess.Config.session_process() + _ -> Config.session_process() end end defp validate_session_id(session_id) do - if Phoenix.SessionProcess.Config.valid_session_id?(session_id) do + if Config.valid_session_id?(session_id) do :ok else {:error, :invalid_session_id} end end - defp check_session_limits() do - max_sessions = Phoenix.SessionProcess.Config.max_sessions() - current_sessions = Registry.count(Phoenix.SessionProcess.Registry) + defp check_session_limits do + max_sessions = Config.max_sessions() + current_sessions = Registry.count(SessionRegistry) if current_sessions < max_sessions do :ok diff --git a/lib/phoenix/session_process/redux.ex b/lib/phoenix/session_process/redux.ex index 483efee..f7810df 100644 --- a/lib/phoenix/session_process/redux.ex +++ b/lib/phoenix/session_process/redux.ex @@ -274,7 +274,7 @@ defmodule Phoenix.SessionProcess.Redux do @doc """ Create a middleware for logging actions. """ - @spec logger_middleware() :: middleware() + @spec logger_middleware :: middleware() def logger_middleware do fn action, state, next -> IO.puts("[Redux] Action: #{inspect(action)}") diff --git a/lib/phoenix/session_process/session_id.ex b/lib/phoenix/session_process/session_id.ex index f7b1856..ebc4082 100644 --- a/lib/phoenix/session_process/session_id.ex +++ b/lib/phoenix/session_process/session_id.ex @@ -90,8 +90,8 @@ defmodule Phoenix.SessionProcess.SessionId do - `binary()` - A 32-character URL-safe session ID """ - @spec generate_unique_session_id() :: binary() - def generate_unique_session_id() do + @spec generate_unique_session_id :: binary() + def generate_unique_session_id do # Use 24 bytes (192 bits) for URL-safe session ID :crypto.strong_rand_bytes(24) |> Base.url_encode64(padding: false) diff --git a/lib/phoenix/session_process/telemetry.ex b/lib/phoenix/session_process/telemetry.ex index eef0646..a139ae6 100644 --- a/lib/phoenix/session_process/telemetry.ex +++ b/lib/phoenix/session_process/telemetry.ex @@ -209,7 +209,7 @@ defmodule Phoenix.SessionProcess.Telemetry do @doc """ Emits a telemetry event for session process start. """ - @spec emit_session_start_event(String.t(), atom(), pid(), keyword()) :: :ok + @spec emit_session_start_event(String.t(), keyword()) :: :ok def emit_session_start_event(session_id, measurements \\ []) do :telemetry.execute( [:phoenix, :session_process, :session_start], @@ -221,7 +221,7 @@ defmodule Phoenix.SessionProcess.Telemetry do @doc """ Emits a telemetry event for session process end. """ - @spec emit_session_end_event(String.t(), atom(), pid(), keyword()) :: :ok + @spec emit_session_end_event(String.t(), keyword()) :: :ok def emit_session_end_event(session_id, measurements \\ []) do :telemetry.execute( [:phoenix, :session_process, :session_end], diff --git a/lib/phoenix/session_process/telemetry_logger.ex b/lib/phoenix/session_process/telemetry_logger.ex index abe22c6..9a9ff5b 100644 --- a/lib/phoenix/session_process/telemetry_logger.ex +++ b/lib/phoenix/session_process/telemetry_logger.ex @@ -38,6 +38,8 @@ defmodule Phoenix.SessionProcess.TelemetryLogger do ], &MyApp.SessionLogger.handle_event/4, nil) """ + require Logger + @type level :: :debug | :info | :warn | :error @doc """ @@ -47,19 +49,24 @@ defmodule Phoenix.SessionProcess.TelemetryLogger do def attach_default_logger(opts \\ []) do level = Keyword.get(opts, :level, :info) - :telemetry.attach_many("phoenix-session-process-default-logger", [ - [:phoenix, :session_process, :start], - [:phoenix, :session_process, :stop], - [:phoenix, :session_process, :start_error], - [:phoenix, :session_process, :communication_error], - [:phoenix, :session_process, :call], - [:phoenix, :session_process, :cast], - [:phoenix, :session_process, :auto_cleanup], - [:phoenix, :session_process, :cleanup], - [:phoenix, :session_process, :cleanup_error] - ], fn _event, measurements, metadata -> - handle_default_event(_event, measurements, metadata, level) - end, %{level: level}) + :telemetry.attach_many( + "phoenix-session-process-default-logger", + [ + [:phoenix, :session_process, :start], + [:phoenix, :session_process, :stop], + [:phoenix, :session_process, :start_error], + [:phoenix, :session_process, :communication_error], + [:phoenix, :session_process, :call], + [:phoenix, :session_process, :cast], + [:phoenix, :session_process, :auto_cleanup], + [:phoenix, :session_process, :cleanup], + [:phoenix, :session_process, :cleanup_error] + ], + fn event, measurements, metadata, _config -> + handle_default_event(event, measurements, metadata, level) + end, + %{level: level} + ) end @doc """ @@ -69,12 +76,17 @@ defmodule Phoenix.SessionProcess.TelemetryLogger do def attach_worker_events(opts \\ []) do level = Keyword.get(opts, :level, :debug) - :telemetry.attach_many("phoenix-session-process-worker-logger", [ - [:phoenix, :session_process, :worker_start], - [:phoenix, :session_process, :worker_terminate] - ], fn _event, measurements, metadata -> - handle_worker_event(_event, measurements, metadata, level) - end, %{level: level}) + :telemetry.attach_many( + "phoenix-session-process-worker-logger", + [ + [:phoenix, :session_process, :worker_start], + [:phoenix, :session_process, :worker_terminate] + ], + fn event, measurements, metadata, _config -> + handle_worker_event(event, measurements, metadata, level) + end, + %{level: level} + ) end @doc """ @@ -84,12 +96,17 @@ defmodule Phoenix.SessionProcess.TelemetryLogger do def attach_session_events(opts \\ []) do level = Keyword.get(opts, :level, :info) - :telemetry.attach_many("phoenix-session-process-session-logger", [ - [:phoenix, :session_process, :start], - [:phoenix, :session_process, :stop] - ], fn _event, measurements, metadata -> - handle_session_event(_event, measurements, metadata, level) - end, %{level: level}) + :telemetry.attach_many( + "phoenix-session-process-session-logger", + [ + [:phoenix, :session_process, :start], + [:phoenix, :session_process, :stop] + ], + fn event, measurements, metadata, _config -> + handle_session_event(event, measurements, metadata, level) + end, + %{level: level} + ) end @doc """ @@ -99,16 +116,21 @@ defmodule Phoenix.SessionProcess.TelemetryLogger do def attach_communication_events(opts \\ []) do level = Keyword.get(opts, :level, :info) - :telemetry.attach_many("phoenix-session-process-communication-logger", [ - [:phoenix, :session_process, :start], - [:phoenix, :session_process, :stop], - [:phoenix, :session_process, :call], - [:phoenix, :session_process, :cast], - [:phoenix, :session_process, :start_error], - [:phoenix, :session_process, :communication_error] - ], fn _event, measurements, metadata -> - handle_communication_event(_event, measurements, metadata, level) - end, %{level: level}) + :telemetry.attach_many( + "phoenix-session-process-communication-logger", + [ + [:phoenix, :session_process, :start], + [:phoenix, :session_process, :stop], + [:phoenix, :session_process, :call], + [:phoenix, :session_process, :cast], + [:phoenix, :session_process, :start_error], + [:phoenix, :session_process, :communication_error] + ], + fn event, measurements, metadata, _config -> + handle_communication_event(event, measurements, metadata, level) + end, + %{level: level} + ) end @doc """ @@ -118,20 +140,25 @@ defmodule Phoenix.SessionProcess.TelemetryLogger do def attach_cleanup_events(opts \\ []) do level = Keyword.get(opts, :level, :debug) - :telemetry.attach_many("phoenix-session-process-cleanup-logger", [ - [:phoenix, :session_process, :auto_cleanup], - [:phoenix, :session_process, :cleanup], - [:phoenix, :session_process, :cleanup_error] - ], fn _event, measurements, metadata -> - handle_cleanup_event(_event, measurements, metadata, level) - end, %{level: level}) + :telemetry.attach_many( + "phoenix-session-process-cleanup-logger", + [ + [:phoenix, :session_process, :auto_cleanup], + [:phoenix, :session_process, :cleanup], + [:phoenix, :session_process, :cleanup_error] + ], + fn event, measurements, metadata, _config -> + handle_cleanup_event(event, measurements, metadata, level) + end, + %{level: level} + ) end @doc """ Detaches all telemetry logger handlers. """ - @spec detach_all_loggers() :: :ok - def detach_all_loggers() do + @spec detach_all_loggers :: :ok + def detach_all_loggers do :telemetry.detach("phoenix-session-process-default-logger") :telemetry.detach("phoenix-session-process-worker-logger") :telemetry.detach("phoenix-session-process-session-logger") @@ -141,85 +168,118 @@ defmodule Phoenix.SessionProcess.TelemetryLogger do # Private handler functions - defp handle_default_event(_event, measurements, metadata, level) do + defp handle_default_event(event, _measurements, metadata, level) do if should_log?(level, metadata) do - case _event do - [:phoenix, :session_process, :start] -> - session_id = Map.get(metadata, :session_id, "unknown") - Logger.info("Session #{session_id} started") - - [:phoenix, :session_process, :stop] -> - session_id = Map.get(metadata, :session_id, "unknown") - Logger.info("Session #{session_id} stopped") - - [:phoenix, :session_process, :start_error] -> - session_id = Map.get(metadata, :session_id, "unknown") - reason = Map.get(metadata, :reason, "unknown") - Logger.error("Session start error #{session_id}: #{inspect(reason)}") - - [:phoenix, :session_process, :communication_error] -> - session_id = Map.get(metadata, :session_id, "unknown") - reason = Map.get(metadata, :reason, "unknown") - Logger.error("Session communication error #{session_id}: #{inspect(reason)}") - - [:phoenix, :session_process, :call] -> - session_id = Map.get(metadata, :session_id, "unknown") - message = Map.get(metadata, :message, "unknown") - Logger.info("Session call #{session_id}: #{inspect(message)}") - - [:phoenix, :session_process, :cast] -> - session_id = Map.get(metadata, :session_id, "unknown") - message = Map.get(metadata, :message, "unknown") - Logger.info("Session cast #{session_id}: #{inspect(message)}") - - [:phoenix, :session_process, :auto_cleanup] -> - session_id = Map.get(metadata, :session_id, "unknown") - Logger.debug("Auto-cleanup expired session: #{session_id}") - - [:phoenix, :session_process, :cleanup] -> - session_id = Map.get(metadata, :session_id, "unknown") - Logger.debug("Cleanup session: #{session_id}") - - [:phoenix, :session_process, :cleanup_error] -> - session_id = Map.get(metadata, :session_id, "unknown") - reason = Map.get(metadata, :reason, "unknown") - Logger.error("Cleanup error #{session_id}: #{inspect(reason)}") - - _ -> - :ok - end + log_event(event, metadata) end end - defp handle_worker_event([:phoenix, :session_process, :worker_start], _measurements, metadata, level) do + defp log_event([:phoenix, :session_process, :start], metadata) do + session_id = Map.get(metadata, :session_id, "unknown") + Logger.info("Session #{session_id} started") + end + + defp log_event([:phoenix, :session_process, :stop], metadata) do + session_id = Map.get(metadata, :session_id, "unknown") + Logger.info("Session #{session_id} stopped") + end + + defp log_event([:phoenix, :session_process, :start_error], metadata) do + session_id = Map.get(metadata, :session_id, "unknown") + reason = Map.get(metadata, :reason, "unknown") + Logger.error("Session start error #{session_id}: #{inspect(reason)}") + end + + defp log_event([:phoenix, :session_process, :communication_error], metadata) do + session_id = Map.get(metadata, :session_id, "unknown") + reason = Map.get(metadata, :reason, "unknown") + Logger.error("Session communication error #{session_id}: #{inspect(reason)}") + end + + defp log_event([:phoenix, :session_process, :call], metadata) do + session_id = Map.get(metadata, :session_id, "unknown") + message = Map.get(metadata, :message, "unknown") + Logger.info("Session call #{session_id}: #{inspect(message)}") + end + + defp log_event([:phoenix, :session_process, :cast], metadata) do + session_id = Map.get(metadata, :session_id, "unknown") + message = Map.get(metadata, :message, "unknown") + Logger.info("Session cast #{session_id}: #{inspect(message)}") + end + + defp log_event([:phoenix, :session_process, :auto_cleanup], metadata) do + session_id = Map.get(metadata, :session_id, "unknown") + Logger.debug("Auto-cleanup expired session: #{session_id}") + end + + defp log_event([:phoenix, :session_process, :cleanup], metadata) do + session_id = Map.get(metadata, :session_id, "unknown") + Logger.debug("Cleanup session: #{session_id}") + end + + defp log_event([:phoenix, :session_process, :cleanup_error], metadata) do + session_id = Map.get(metadata, :session_id, "unknown") + reason = Map.get(metadata, :reason, "unknown") + Logger.error("Cleanup error #{session_id}: #{inspect(reason)}") + end + + defp log_event(_event, _metadata), do: :ok + + defp handle_worker_event( + [:phoenix, :session_process, :worker_start], + _measurements, + metadata, + level + ) do if should_log?(level, metadata) do worker_spec = Map.get(metadata, :worker_spec, "unknown") Logger.debug("Worker start: #{worker_spec}") end end - defp handle_worker_event([:phoenix, :session_process, :worker_terminate], _measurements, metadata, level) do + defp handle_worker_event( + [:phoenix, :session_process, :worker_terminate], + _measurements, + metadata, + level + ) do if should_log?(level, metadata) do pid = Map.get(metadata, :pid, "unknown") Logger.debug("Worker terminate: #{inspect(pid)}") end end - defp handle_session_event([:phoenix, :session_process, :session_start], _measurements, metadata, level) do + defp handle_session_event( + [:phoenix, :session_process, :session_start], + _measurements, + metadata, + level + ) do if should_log?(level, metadata) do session_id = Map.get(metadata, :session_id, "unknown") Logger.info("Session start: #{session_id}") end end - defp handle_session_event([:phoenix, :session_process, :session_end], _measurements, metadata, level) do + defp handle_session_event( + [:phoenix, :session_process, :session_end], + _measurements, + metadata, + level + ) do if should_log?(level, metadata) do session_id = Map.get(metadata, :session_id, "unknown") Logger.info("Session end: #{session_id}") end end - defp handle_communication_event([:phoenix, :session_process, :call], _measurements, metadata, level) do + defp handle_communication_event( + [:phoenix, :session_process, :call], + _measurements, + metadata, + level + ) do if should_log?(level, metadata) do session_id = Map.get(metadata, :session_id, "unknown") message = Map.get(metadata, :message, "unknown") @@ -227,7 +287,12 @@ defmodule Phoenix.SessionProcess.TelemetryLogger do end end - defp handle_communication_event([:phoenix, :session_process, :cast], _measurements, metadata, level) do + defp handle_communication_event( + [:phoenix, :session_process, :cast], + _measurements, + metadata, + level + ) do if should_log?(level, metadata) do session_id = Map.get(metadata, :session_id, "unknown") message = Map.get(metadata, :message, "unknown") @@ -235,7 +300,12 @@ defmodule Phoenix.SessionProcess.TelemetryLogger do end end - defp handle_communication_event([:phoenix, :session_process, :start_error], _measurements, metadata, level) do + defp handle_communication_event( + [:phoenix, :session_process, :start_error], + _measurements, + metadata, + level + ) do if should_log?(level, metadata) do session_id = Map.get(metadata, :session_id, "unknown") reason = Map.get(metadata, :reason, "unknown") @@ -243,7 +313,12 @@ defmodule Phoenix.SessionProcess.TelemetryLogger do end end - defp handle_communication_event([:phoenix, :session_process, :communication_error], _measurements, metadata, level) do + defp handle_communication_event( + [:phoenix, :session_process, :communication_error], + _measurements, + metadata, + level + ) do if should_log?(level, metadata) do session_id = Map.get(metadata, :session_id, "unknown") reason = Map.get(metadata, :reason, "unknown") @@ -251,21 +326,36 @@ defmodule Phoenix.SessionProcess.TelemetryLogger do end end - defp handle_cleanup_event([:phoenix, :session_process, :auto_cleanup], _measurements, metadata, level) do + defp handle_cleanup_event( + [:phoenix, :session_process, :auto_cleanup], + _measurements, + metadata, + level + ) do if should_log?(level, metadata) do session_id = Map.get(metadata, :session_id, "unknown") Logger.debug("Auto-cleanup expired session: #{session_id}") end end - defp handle_cleanup_event([:phoenix, :session_process, :cleanup], _measurements, metadata, level) do + defp handle_cleanup_event( + [:phoenix, :session_process, :cleanup], + _measurements, + metadata, + level + ) do if should_log?(level, metadata) do session_id = Map.get(metadata, :session_id, "unknown") Logger.debug("Cleanup session: #{session_id}") end end - defp handle_cleanup_event([:phoenix, :session_process, :cleanup_error], _measurements, metadata, level) do + defp handle_cleanup_event( + [:phoenix, :session_process, :cleanup_error], + _measurements, + metadata, + level + ) do if should_log?(level, metadata) do session_id = Map.get(metadata, :session_id, "unknown") reason = Map.get(metadata, :reason, "unknown") @@ -273,13 +363,13 @@ defmodule Phoenix.SessionProcess.TelemetryLogger do end end - defp should_log?(event_level, metadata) do + defp should_log?(_event_level, _metadata) do configured_level = Application.get_env(:phoenix_session_process, :telemetry_log_level, :info) - log_level_priority(configured_level) <= level_priority(:info) + level_priority(configured_level) <= level_priority(:info) end defp level_priority(:debug), do: 0 defp level_priority(:info), do: 1 defp level_priority(:warn), do: 2 defp level_priority(:error), do: 3 -end \ No newline at end of file +end diff --git a/mix.exs b/mix.exs index 09ca531..e0db7fa 100644 --- a/mix.exs +++ b/mix.exs @@ -25,7 +25,8 @@ defmodule Phoenix.SessionProcess.MixProject do description: "Session isolation and state management for Phoenix applications" ], deps: deps(), - docs: docs() + docs: docs(), + aliases: aliases() ] end @@ -41,7 +42,9 @@ defmodule Phoenix.SessionProcess.MixProject do [ {:plug, "~> 1.0"}, {:telemetry, "~> 1.0"}, - {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} + {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} ] end @@ -69,7 +72,6 @@ defmodule Phoenix.SessionProcess.MixProject do defp docs do [ name: "Phoenix.SessionProcess", - logo: "logo.png", # Optional: add logo if available source_ref: "v#{@version}", main: "readme", source_url: @source_url, @@ -80,10 +82,10 @@ defmodule Phoenix.SessionProcess.MixProject do "LICENSE" ], groups_for_extras: [ - "Guides": [ + Guides: [ "README.md" ], - "Reference": [ + Reference: [ "CHANGELOG.md", "LICENSE" ] @@ -93,19 +95,19 @@ defmodule Phoenix.SessionProcess.MixProject do Phoenix.SessionProcess, Phoenix.SessionProcess.SessionId ], - "Configuration": [ + Configuration: [ Phoenix.SessionProcess.Config ], "Error Handling": [ Phoenix.SessionProcess.Error ], - "Internals": [ + Internals: [ Phoenix.SessionProcess.Supervisor, Phoenix.SessionProcess.ProcessSupervisor, Phoenix.SessionProcess.Cleanup, Phoenix.SessionProcess.DefaultSessionProcess ], - "Utilities": [ + Utilities: [ Phoenix.SessionProcess.Helpers, Phoenix.SessionProcess.Telemetry, Phoenix.SessionProcess.State, @@ -116,4 +118,10 @@ defmodule Phoenix.SessionProcess.MixProject do skip_undefined_reference_warnings_on: ["CHANGELOG.md"] ] end + + defp aliases do + [ + lint: ["credo --strict", "dialyzer"] + ] + end end diff --git a/mix.lock b/mix.lock index f17862d..0439de6 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,13 @@ %{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, + "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, + "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, "ex_doc": {:hex, :ex_doc, "0.31.1", "8a2355ac42b1cc7b2379da9e40243f2670143721dd50748bf6c3b1184dae2089", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3178c3a407c557d8343479e1ff117a96fd31bafe52a039079593fb0524ef61b0"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "machete": {:hex, :machete, "0.3.11", "64769196d9cf7a6d6192739c11152bf1c0ba12ca04029180c77188d8e292f962", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d58062c7bcf344645aba574d59f6547793d8a1d3413389b1b5d6084d05cae77"}, "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.3", "d684f4bac8690e70b06eb52dad65d26de2eefa44cd19d64a8095e1417df7c8fd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "b78dc853d2e670ff6390b605d807263bf606da3c82be37f9d7f68635bd886fc9"}, @@ -9,4 +16,5 @@ "plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [: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", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"}, "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "tesla": {:hex, :tesla, "1.15.3", "3a2b5c37f09629b8dcf5d028fbafc9143c0099753559d7fe567eaabfbd9b8663", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "98bb3d4558abc67b92fb7be4cd31bb57ca8d80792de26870d362974b58caeda7"}, } diff --git a/test/phoenix/session_process/state_test.exs b/test/phoenix/session_process/state_test.exs index 46a8de7..dd1b7c9 100644 --- a/test/phoenix/session_process/state_test.exs +++ b/test/phoenix/session_process/state_test.exs @@ -1,6 +1,6 @@ defmodule Phoenix.SessionProcess.StateTest do use ExUnit.Case, async: true - alias Phoenix.SessionProcess.State + alias Phoenix.SessionProcess.{Redux, State} describe "start_link/1" do test "starts with default state" do @@ -199,14 +199,14 @@ defmodule Phoenix.SessionProcess.StateTest do {:ok, pid} = State.start_link(%{redux: nil}) # This tests the interaction pattern - redux = Phoenix.SessionProcess.Redux.init_state(%{count: 0}) + redux = Redux.init_state(%{count: 0}) reducer = fn state, {:increment, val} -> %{state | count: state.count + val} end - new_redux = Phoenix.SessionProcess.Redux.dispatch(redux, {:increment, 5}, reducer) + new_redux = Redux.dispatch(redux, {:increment, 5}, reducer) State.update_state(pid, fn _ -> %{redux: new_redux} end) state = State.get_state(pid) - assert Phoenix.SessionProcess.Redux.current_state(state.redux).count == 5 + assert Redux.current_state(state.redux).count == 5 end end end diff --git a/test/phoenix/session_process/telemetry_test.exs b/test/phoenix/session_process/telemetry_test.exs index 5d9e763..8ce1b61 100644 --- a/test/phoenix/session_process/telemetry_test.exs +++ b/test/phoenix/session_process/telemetry_test.exs @@ -2,7 +2,7 @@ defmodule Phoenix.SessionProcess.TelemetryTest do use ExUnit.Case, async: false alias Phoenix.SessionProcess - alias Phoenix.SessionProcess.{Telemetry, Error} + alias Phoenix.SessionProcess.{Error, Telemetry} test "telemetry events are emitted for session lifecycle" do session_id = "telemetry_test_session" diff --git a/test/phoenix/session_process_test.exs b/test/phoenix/session_process_test.exs index c227702..ca8f5bf 100644 --- a/test/phoenix/session_process_test.exs +++ b/test/phoenix/session_process_test.exs @@ -3,6 +3,7 @@ defmodule Phoenix.SessionProcessTest do # doctest Phoenix.SessionProcess # Disabled to avoid test interference alias Phoenix.SessionProcess + alias Phoenix.SessionProcess.SessionId setup do # Ensure supervisor is started @@ -25,7 +26,7 @@ defmodule Phoenix.SessionProcessTest do end test "test start session process" do - session_id = Phoenix.SessionProcess.SessionId.generate_unique_session_id() + session_id = SessionId.generate_unique_session_id() assert SessionProcess.started?(session_id) == false {:ok, pid} = SessionProcess.start(session_id, TestProcess, %{value: 0}) assert is_pid(pid) @@ -33,7 +34,7 @@ defmodule Phoenix.SessionProcessTest do end test "test terminate session process" do - session_id = Phoenix.SessionProcess.SessionId.generate_unique_session_id() + session_id = SessionId.generate_unique_session_id() {:ok, _pid} = SessionProcess.start(session_id, TestProcess, %{value: 0}) assert SessionProcess.started?(session_id) == true SessionProcess.terminate(session_id) @@ -41,16 +42,150 @@ defmodule Phoenix.SessionProcessTest do end test "test call on session process" do - session_id = Phoenix.SessionProcess.SessionId.generate_unique_session_id() + session_id = SessionId.generate_unique_session_id() {:ok, _pid} = SessionProcess.start(session_id, TestProcess, %{value: 123}) assert SessionProcess.call(session_id, :get_value) == 123 end test "test cast on session process" do - session_id = Phoenix.SessionProcess.SessionId.generate_unique_session_id() + session_id = SessionId.generate_unique_session_id() {:ok, _pid} = SessionProcess.start(session_id, TestProcess, %{value: 0}) assert SessionProcess.call(session_id, :get_value) == 0 SessionProcess.cast(session_id, :add_one) assert SessionProcess.call(session_id, :get_value) == 1 end + + describe "list_session/0" do + test "returns list type" do + assert is_list(SessionProcess.list_session()) + end + + test "returns list of session_id and pid tuples" do + session_id1 = SessionId.generate_unique_session_id() + session_id2 = SessionId.generate_unique_session_id() + + {:ok, pid1} = SessionProcess.start(session_id1, TestProcess, %{value: 1}) + {:ok, pid2} = SessionProcess.start(session_id2, TestProcess, %{value: 2}) + + sessions = SessionProcess.list_session() + # Check that our created sessions are in the list + assert {session_id1, pid1} in sessions + assert {session_id2, pid2} in sessions + + # Cleanup + SessionProcess.terminate(session_id1) + SessionProcess.terminate(session_id2) + end + + test "removes session when terminated" do + session_id = SessionId.generate_unique_session_id() + {:ok, pid} = SessionProcess.start(session_id, TestProcess, %{value: 0}) + + # Check session exists in list + sessions_before = SessionProcess.list_session() + assert {session_id, pid} in sessions_before + + # Terminate and verify it's removed + SessionProcess.terminate(session_id) + sessions_after = SessionProcess.list_session() + assert {session_id, pid} not in sessions_after + end + end + + describe "list_sessions_by_module/1" do + test "returns list type" do + assert is_list(SessionProcess.list_sessions_by_module(TestProcess)) + end + + test "returns session IDs for specific module" do + session_id1 = SessionId.generate_unique_session_id() + session_id2 = SessionId.generate_unique_session_id() + + {:ok, _pid1} = SessionProcess.start(session_id1, TestProcess, %{value: 1}) + {:ok, _pid2} = SessionProcess.start(session_id2, TestProcess, %{value: 2}) + + sessions = SessionProcess.list_sessions_by_module(TestProcess) + # Check that our created sessions are in the list + assert session_id1 in sessions + assert session_id2 in sessions + + # Cleanup + SessionProcess.terminate(session_id1) + SessionProcess.terminate(session_id2) + end + + test "filters sessions by module correctly" do + session_id1 = SessionId.generate_unique_session_id() + session_id2 = SessionId.generate_unique_session_id() + + {:ok, _pid1} = SessionProcess.start(session_id1, TestProcess, %{value: 1}) + {:ok, _pid2} = + SessionProcess.start(session_id2, TestProcessLink, %{value: 2}) + + test_sessions = SessionProcess.list_sessions_by_module(TestProcess) + link_sessions = SessionProcess.list_sessions_by_module(TestProcessLink) + + # Check that each session appears in the correct module list + assert session_id1 in test_sessions + assert session_id1 not in link_sessions + assert session_id2 in link_sessions + assert session_id2 not in test_sessions + + # Cleanup + SessionProcess.terminate(session_id1) + SessionProcess.terminate(session_id2) + end + end + + describe "session_info/0" do + test "returns map with count and modules keys" do + info = SessionProcess.session_info() + assert is_map(info) + assert Map.has_key?(info, :count) + assert Map.has_key?(info, :modules) + assert is_integer(info.count) + assert is_list(info.modules) + end + + test "includes modules of active sessions" do + session_id1 = SessionId.generate_unique_session_id() + session_id2 = SessionId.generate_unique_session_id() + + {:ok, _pid1} = SessionProcess.start(session_id1, TestProcess, %{value: 1}) + {:ok, _pid2} = + SessionProcess.start(session_id2, TestProcessLink, %{value: 2}) + + info = SessionProcess.session_info() + # Check that both modules are in the list + assert TestProcess in info.modules + assert TestProcessLink in info.modules + # Check that count is at least 2 (could be more from other tests) + assert info.count >= 2 + + # Cleanup + SessionProcess.terminate(session_id1) + SessionProcess.terminate(session_id2) + end + end + + describe "get_session_id/0 in :process macro" do + test "returns correct session_id from within session process" do + session_id = SessionId.generate_unique_session_id() + {:ok, _pid} = SessionProcess.start(session_id, TestProcess, %{value: 0}) + + # Call a function that uses get_session_id internally + result = SessionProcess.call(session_id, :get_my_session_id) + assert result == session_id + end + end + + describe "get_session_id/0 in :process_link macro" do + test "returns correct session_id from within session process" do + session_id = SessionId.generate_unique_session_id() + {:ok, _pid} = SessionProcess.start(session_id, TestProcessLink, %{value: 0}) + + result = SessionProcess.call(session_id, :get_my_session_id) + assert result == session_id + end + end end diff --git a/test/support/test_process.ex b/test/support/test_process.ex index 9b97e5c..0d82e7f 100644 --- a/test/support/test_process.ex +++ b/test/support/test_process.ex @@ -1,23 +1,35 @@ defmodule TestProcess do - @doc false + @moduledoc """ + Test helper module for session process testing. + + This module provides a simple session process implementation used in tests + to verify session process functionality, state management, and lifecycle operations. + """ use Phoenix.SessionProcess, :process + alias Phoenix.SessionProcess.State + @impl true def init(init_arg \\ %{}) do - {:ok, agent} = Phoenix.SessionProcess.State.start_link(init_arg) + {:ok, agent} = State.start_link(init_arg) {:ok, %{agent: agent}} end @impl true def handle_call(:get_value, _from, state) do - {:reply, Phoenix.SessionProcess.State.get(state.agent, :value), state} + {:reply, State.get(state.agent, :value), state} + end + + @impl true + def handle_call(:get_my_session_id, _from, state) do + {:reply, get_session_id(), state} end @impl true def handle_cast(:add_one, state) do - value = Phoenix.SessionProcess.State.get(state.agent, :value) - Phoenix.SessionProcess.State.put(state.agent, :value, value + 1) + value = State.get(state.agent, :value) + State.put(state.agent, :value, value + 1) {:noreply, state} end end diff --git a/test/support/test_process_link.ex b/test/support/test_process_link.ex new file mode 100644 index 0000000..3b89401 --- /dev/null +++ b/test/support/test_process_link.ex @@ -0,0 +1,35 @@ +defmodule TestProcessLink do + @moduledoc """ + Test helper module for session process with LiveView link testing. + + This module provides a session process implementation using the :process_link + option to verify LiveView monitoring functionality and get_session_id behavior. + """ + + use Phoenix.SessionProcess, :process_link + + alias Phoenix.SessionProcess.State + + @impl true + def init(init_arg \\ %{}) do + {:ok, agent} = State.start_link(init_arg) + {:ok, %{agent: agent}} + end + + @impl true + def handle_call(:get_value, _from, state) do + {:reply, State.get(state.agent, :value), state} + end + + @impl true + def handle_call(:get_my_session_id, _from, state) do + {:reply, get_session_id(), state} + end + + @impl true + def handle_cast(:add_one, state) do + value = State.get(state.agent, :value) + State.put(state.agent, :value, value + 1) + {:noreply, state} + end +end