Phoenix.SessionProcess
A powerful Phoenix library that enables reusable, composable reducers for managing user session state. Build modular state management logic once, and reuse it across your entire application with Redux-style patterns in isolated per-session processes.
Why Phoenix.SessionProcess?
Traditional session management stores session data in external stores (Redis, database) or relies on plug-based state, making it difficult to create reusable state management patterns. Phoenix.SessionProcess solves this by combining:
- Reusable Reducers - Write state management logic once, reuse everywhere
- Redux-Style Architecture - Familiar patterns: actions, reducers, selectors, and subscriptions
- Session Isolation - Each user gets their own GenServer process with isolated state
- Zero Dependencies - No Redis, no database, pure OTP/Elixir solution
- LiveView Ready - Built-in reactive subscriptions for real-time UIs
The Reducer Advantage
Define your state management logic once as reusable reducers:
# Define once - use everywhere
defmodule MyApp.CartReducer do
use Phoenix.SessionProcess, :reducer
@name :cart
@action_prefix "cart"
def init_state, do: %{items: [], total: 0}
def handle_action(%Action{type: "add_item", payload: item}, state) do
%{state |
items: [item | state.items],
total: state.total + item.price
}
end
end
Then compose multiple reducers in any session:
def combined_reducers do
[MyApp.CartReducer, MyApp.UserReducer, MyApp.PreferencesReducer]
end
Benefits:
- Modularity - Each reducer manages one slice of state
- Reusability - Share reducers across different session types
- Testability - Test reducers in isolation
- Composability - Combine reducers to build complex state
- Type Safety - Compile-time validation of reducer structure
Features
Core Features
- Reusable Reducers (v1.0.0): Define state management logic once, use across multiple session types
- Redux Store Architecture: Built-in Redux with actions, reducers, selectors, and subscriptions
- Session Isolation: Each user runs in their own GenServer process with isolated state
- Composable State: Combine multiple reducers to build complex session state
- Compile-Time Validation: Type checking for reducer names, action types, and structure
Developer Experience
- Familiar Patterns: Redux-style state management developers already know
- Zero Dependencies: Pure OTP/Elixir solution - no Redis, no database
- LiveView Integration: Reactive subscriptions for real-time UI updates
- Easy Testing: Test reducers in isolation without spinning up sessions
- Extensible: Custom session process modules with full GenServer support
Production Ready
- High Performance: 10,000+ sessions/second creation rate
- Automatic Cleanup: TTL-based session expiration and memory management
- Comprehensive Monitoring: Built-in telemetry events for observability
- Rate Limiting: Built-in protection against session abuse
- Error Handling: Detailed error reporting and human-readable messages
Installation
Add phoenix_session_process to your list of dependencies in mix.exs:
def deps do
[
{:phoenix_session_process, "~> 1.0.1"}
]
end
Requirements
- Elixir 1.14+
- Erlang/OTP 24+
- Phoenix 1.6+ (recommended)
Quick Start
1. Add to Supervision Tree
Add the supervisor to your application's supervision tree:
# in lib/my_app/application.ex
def start(_type, _args) do
children = [
# ... other children ...
{Phoenix.SessionProcess, []}
# Or use: {Phoenix.SessionProcess.Supervisor, []}
]
Supervisor.start_link(children, strategy: :one_for_one)
end
2. Configure Session ID Generation
Add the session ID plug after :fetch_session in your router:
# in lib/my_app_web/router.ex
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug Phoenix.SessionProcess.SessionId # Add this line
# ... other plugs ...
end
3. Basic Usage
In your controllers, start and use session processes:
defmodule MyAppWeb.PageController do
use MyAppWeb, :controller
def index(conn, _params) do
session_id = get_session(conn, :session_id)
# Start session process
{:ok, _pid} = Phoenix.SessionProcess.start_session(session_id)
# Store user data
Phoenix.SessionProcess.cast(session_id, {:put, :user_id, conn.assigns.current_user.id})
Phoenix.SessionProcess.cast(session_id, {:put, :last_seen, DateTime.utc_now()})
render(conn, "index.html")
end
end
Configuration
Configure the library in your 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 in milliseconds (1 hour)
rate_limit: 100, # Sessions per minute limit
unmatched_action_handler: :log # How to handle unmatched actions (:log, :warn, :silent, or function)
Usage Examples
Basic Session Process
Create a simple session process to store user state:
defmodule MyApp.SessionProcess do
use Phoenix.SessionProcess, :process
@impl true
def init(_init_arg) do
{:ok, %{user_id: nil, 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
Redux Store API (v1.0.0) - Reusable Reducers
Phoenix.SessionProcess enables reusable reducer patterns - define your state management logic once, and compose it across different session types. SessionProcess IS the Redux store, providing familiar Redux patterns with true modularity.
Defining Reusable Reducers
Create self-contained reducers that can be shared across your application:
defmodule MyApp.CounterReducer do
use Phoenix.SessionProcess, :reducer
@name :counter # MUST be atom
@action_prefix "counter" # MUST be binary
def init_state do
%{count: 0}
end
def handle_action(action, state) do
alias Phoenix.SessionProcess.Action
case action do
%Action{type: "increment"} ->
%{state | count: state.count + 1}
%Action{type: "set", payload: value} ->
%{state | count: value}
_ ->
state
end
end
end
defmodule MyApp.SessionProcess do
use Phoenix.SessionProcess, :process
@impl true
def init_state(_args) do
%{}
end
# Compose reducers - mix and match for different session types
@impl true
def combined_reducers do
[MyApp.CounterReducer]
end
end
# Reuse the same reducers in different session types
defmodule MyApp.AdminSessionProcess do
use Phoenix.SessionProcess, :process
@impl true
def init_state(_args), do: %{}
@impl true
def combined_reducers do
# Admin sessions get counter + additional audit logging
[MyApp.CounterReducer, MyApp.AuditReducer, MyApp.PermissionsReducer]
end
end
defmodule MyApp.GuestSessionProcess do
use Phoenix.SessionProcess, :process
@impl true
def init_state(_args), do: %{}
@impl true
def combined_reducers do
# Guest sessions get minimal state
[MyApp.CounterReducer, MyApp.PreferencesReducer]
end
end
Key Benefit: CounterReducer is defined once but reused across admin, guest, and regular sessions. Each session type composes different reducers to match its needs.
Using the Redux Store
# In your controller:
{:ok, _pid} = Phoenix.SessionProcess.start_session(session_id, module: MyApp.SessionProcess)
# Dispatch actions (MUST use binary types)
:ok = Phoenix.SessionProcess.dispatch(session_id, "counter.increment")
:ok = Phoenix.SessionProcess.dispatch(session_id, "counter.set", 10)
# Async dispatch (convenience - automatically adds async: true)
:ok = Phoenix.SessionProcess.dispatch_async(session_id, "counter.increment")
# Equivalent to:
# :ok = Phoenix.SessionProcess.dispatch(session_id, "counter.increment", nil, async: true)
# Get state (state is namespaced by reducer)
state = Phoenix.SessionProcess.get_state(session_id)
# => %{counter: %{count: 11}}
# Use selectors
count = Phoenix.SessionProcess.get_state(session_id, fn s -> s.counter.count end)
# Server-side selection (more efficient for large states)
count = Phoenix.SessionProcess.select_state(session_id, fn s -> s.counter.count end)
# Subscribe to state changes
{:ok, sub_id} = Phoenix.SessionProcess.subscribe(
session_id,
fn state -> state.counter.count end, # Selector function
:count_changed, # Event name
self() # Subscriber PID (optional)
)
# Receive notifications when count changes
receive do
{:count_changed, new_count} -> IO.inspect(new_count, label: "Count")
end
# Unsubscribe
:ok = Phoenix.SessionProcess.unsubscribe(session_id, sub_id)
LiveView with Redux Store API
defmodule MyAppWeb.DashboardLive do
use Phoenix.LiveView
alias Phoenix.SessionProcess
def mount(_params, %{"session_id" => session_id}, socket) do
# Subscribe to user state
{:ok, _sub_id} = SessionProcess.subscribe(
session_id,
fn state -> state.user end,
:user_changed,
self()
)
# Get initial state
state = SessionProcess.get_state(session_id)
{:ok, assign(socket, session_id: session_id, state: state)}
end
# Receive state updates
def handle_info({:user_changed, user}, socket) do
{:noreply, assign(socket, user: user)}
end
# Dispatch actions
def handle_event("increment", _params, socket) do
SessionProcess.dispatch(socket.assigns.session_id, "increment", nil, async: true)
{:noreply, socket}
end
def terminate(_reason, socket) do
# Subscriptions are automatically cleaned up via process monitoring
:ok
end
end
Benefits of Reusable Reducers:
- Write Once, Use Everywhere: Define state logic once, compose across session types
- Modular State Management: Each reducer manages one concern (cart, user, preferences)
- Easy Testing: Test reducers independently without session overhead
- Compile-Time Safety: Type validation for reducer structure and action types
- Familiar Patterns: Redux architecture developers already understand
- Zero Boilerplate: No Redux struct to manage, SessionProcess IS the store
API Reference
Starting Sessions
# Start with default module
{:ok, pid} = Phoenix.SessionProcess.start_session("session_123")
# Start with custom module
{:ok, pid} = Phoenix.SessionProcess.start_session("session_123", module: MyApp.CustomProcess)
# Start with custom module and arguments
{:ok, pid} = Phoenix.SessionProcess.start_session("session_123",
module: MyApp.CustomProcess,
args: %{user_id: 456})
# Start with default module but custom arguments
{:ok, pid} = Phoenix.SessionProcess.start_session("session_123", args: %{debug: true})
Communication
# Check if session exists
Phoenix.SessionProcess.started?("session_123")
# Call the session process
{:ok, user} = Phoenix.SessionProcess.call("session_123", :get_user)
# Cast to the session process
:ok = Phoenix.SessionProcess.cast("session_123", {:set_user, user})
# Terminate session
:ok = Phoenix.SessionProcess.terminate("session_123")
# Reset session TTL (extend session lifetime)
:ok = Phoenix.SessionProcess.touch("session_123")
# List all sessions
sessions = Phoenix.SessionProcess.list_session()
# Get memory and performance statistics
stats = Phoenix.SessionProcess.session_stats()
# Returns: %{total_memory: ..., session_count: ..., process_info: [...]}
Session Process Helpers
Access session information from within your process:
defmodule MyApp.SessionProcess do
use Phoenix.SessionProcess, :process
@impl true
def init(_init_arg) do
# Get the current session ID
session_id = get_session_id()
{:ok, %{session_id: session_id, data: %{}}}
end
def get_current_session_id() do
get_session_id()
end
end
Redux Store API (v1.0.0)
The built-in Redux Store API provides state management with reducers defined using the :reducer macro:
# See "Redux Store API (v1.0.0)" section above for complete examples
# Dispatch actions (MUST use binary types)
:ok = Phoenix.SessionProcess.dispatch(session_id, "counter.increment")
# Async dispatch (convenience alias)
:ok = Phoenix.SessionProcess.dispatch_async(
session_id,
"user.set",
%{id: 123}
)
# Get current state after dispatch
state = Phoenix.SessionProcess.get_state(session_id)
# Get state with selector (client-side)
user = Phoenix.SessionProcess.get_state(session_id, fn state -> state.user end)
# Server-side selection (more efficient for large states)
user = Phoenix.SessionProcess.select_state(session_id, fn state -> state.user end)
# Subscribe to state changes (with selector)
{:ok, sub_id} = Phoenix.SessionProcess.subscribe(
session_id,
fn state -> state.user end, # Only notified when user changes
:user_changed, # Event name for messages
self() # Subscriber PID
)
# Receive notifications
receive do
{:user_changed, user} -> IO.inspect(user, label: "User changed")
end
# Unsubscribe
:ok = Phoenix.SessionProcess.unsubscribe(session_id, sub_id)
Key Features:
- Dispatch actions: Synchronous or asynchronous state updates
- Selectors: Subscribe to specific state slices with change detection
- Process monitoring: Automatic subscription cleanup
- Immediate delivery: Subscribers receive current state on subscribe
- Reducers: Composable state transformation functions
Advanced Usage
Rate Limiting
The library includes built-in rate limiting to prevent session abuse:
# Configure rate limiting (100 sessions per minute by default)
config :phoenix_session_process,
rate_limit: 200, # 200 sessions per minute
max_sessions: 20_000 # Maximum concurrent sessions
Custom Session State
Store complex data structures and implement custom logic:
defmodule MyApp.ComplexSessionProcess do
use Phoenix.SessionProcess, :process
@impl true
def init(_init_arg) do
{:ok, %{
user: nil,
shopping_cart: [],
preferences: %{},
activity_log: []
}}
end
@impl true
def handle_cast({:add_to_cart, item}, state) do
new_cart = [item | state.shopping_cart]
new_state = %{state | shopping_cart: new_cart}
{:noreply, new_state}
end
@impl true
def handle_call(:get_cart_total, _from, state) do
total = state.shopping_cart
|> Enum.map(& &1.price)
|> Enum.sum()
{:reply, total, state}
end
end
State Management
Phoenix.SessionProcess uses standard GenServer state management. For 95% of use cases, this is all you need:
Standard GenServer State (Recommended)
Use standard GenServer callbacks for full control over state management:
defmodule MyApp.BasicSessionProcess do
use Phoenix.SessionProcess, :process
@impl true
def init(_init_arg) do
{:ok, %{user_id: nil, data: %{}, timestamps: []}}
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
@impl true
def handle_cast({:add_data, key, value}, state) do
new_data = Map.put(state.data, key, value)
{:noreply, %{state | data: new_data}}
end
end
This is idiomatic Elixir and gives you full control over your state transitions.
Configuration Options
| Option | Default | Description |
|---|---|---|
:session_process |
Phoenix.SessionProcess.DefaultSessionProcess |
Default session module |
:max_sessions |
10_000 |
Maximum concurrent sessions |
:session_ttl |
3_600_000 |
Session TTL in milliseconds |
:rate_limit |
100 |
Sessions per minute limit |
:unmatched_action_handler |
:log |
How to handle unmatched actions (:log, :warn, :silent, or custom function) |
Telemetry and Monitoring
The library emits comprehensive telemetry events for monitoring and debugging:
Session Lifecycle Events
[:phoenix, :session_process, :start]- When a session starts[:phoenix, :session_process, :stop]- When a session stops[:phoenix, :session_process, :start_error]- When session start fails
Communication Events
[:phoenix, :session_process, :call]- When a call is made to a session[:phoenix, :session_process, :cast]- When a cast is made to a session[:phoenix, :session_process, :communication_error]- When communication fails
Cleanup Events
[:phoenix, :session_process, :cleanup]- When a session is cleaned up[:phoenix, :session_process, :cleanup_error]- When cleanup fails
Example Telemetry Setup
# Attach telemetry handlers
:telemetry.attach_many("session-handler", [
[:phoenix, :session_process, :start],
[:phoenix, :session_process, :stop]
], fn event, measurements, meta, _ ->
Logger.info("Session event: #{inspect(event)} #{inspect(meta)}")
end, nil)
# Monitor session performance
:telemetry.attach("session-performance", [:phoenix, :session_process, :call], fn
_, %{duration: duration}, %{session_id: session_id}, _ ->
if duration > 1_000_000 do # > 1ms
Logger.warn("Slow session call for #{session_id}: #{duration}µs")
end
end, nil)
Error Handling
The library provides detailed error responses with the Phoenix.SessionProcess.Error module:
Error Types
{:error, {:invalid_session_id, session_id}}- Invalid session ID format{:error, {:session_limit_reached, max_sessions}}- Maximum sessions exceeded{:error, {:session_not_found, session_id}}- Session doesn't exist{:error, {:process_not_found, session_id}}- Process not found{:error, {:timeout, timeout}}- Operation timed out{:error, {:call_failed, {module, function, args, reason}}}- Call operation failed{:error, {:cast_failed, {module, function, args, reason}}}- Cast operation failed
Error Handling Examples
case Phoenix.SessionProcess.start_session(session_id) do
{:ok, pid} ->
# Session started successfully
{:ok, pid}
{:error, {:invalid_session_id, id}} ->
Logger.error("Invalid session ID: #{id}")
{:error, :invalid_session}
{:error, {:session_limit_reached, max}} ->
Logger.warn("Session limit reached: #{max}")
{:error, :too_many_sessions}
{:error, reason} ->
Logger.error("Failed to start session: #{inspect(reason)}")
{:error, :session_start_failed}
end
Human-Readable Error Messages
Use Phoenix.SessionProcess.Error.message/1 to get human-readable error messages:
{:error, error} = Phoenix.SessionProcess.start_session("invalid@session")
Phoenix.SessionProcess.Error.message(error)
# Returns: "Invalid session ID format: \"invalid@session\""
Testing
The library includes comprehensive tests. Run with:
mix test
Benchmarking
Measure the performance of the library with built-in benchmarks:
Quick Benchmark (5-10 seconds)
mix run bench/simple_bench.exs
Comprehensive Benchmark (30-60 seconds)
mix run bench/session_benchmark.exs
Expected Performance
- Session Creation: 10,000+ sessions/sec
- Session Cleanup: 20,000+ sessions/sec
- Memory Usage: ~10KB per session
- Registry Lookups: 100,000+ lookups/sec
See bench/README.md for detailed benchmarking guide and customization options.
Development Setup
- Fork the repository
- Install dependencies:
mix deps.get - Run tests:
mix test - Run benchmarks:
mix run bench/simple_bench.exs
The project uses devenv for development environment management. After installation, run devenv shell to enter the development environment.
Changelog
See CHANGELOG.md for release notes and version history.
License
Credits
Created by Jonathan Gao
Related Projects
- Phoenix LiveView - Real-time user experiences
- Phoenix Channels - Real-time communication