Skip to content

Commit 1d261f5

Browse files
committed
feat: add configurable unmatched action handling for reducers
Add handle_unmatched_action/2 and handle_unmatched_async/3 callbacks to allow customization of behavior when actions don't match any pattern in reducers. Changes: - Add unmatched_action_handler config option (:log, :warn, :silent, or custom function/3) - Update :reducer macro to provide default implementations that respect global config - Add handle_unmatched_action/2 callback for sync actions (overridable per-reducer) - Add handle_unmatched_async/3 callback for async actions (overridable per-reducer) - Default behavior logs debug message suggesting use of @action_prefix - Add comprehensive test suite (6 new tests) - Update CLAUDE.md documentation with usage examples and configuration This helps developers debug action routing issues and optimize reducer performance by identifying when too many unmatched actions are being processed.
1 parent fe84532 commit 1d261f5

File tree

4 files changed

+494
-10
lines changed

4 files changed

+494
-10
lines changed

CLAUDE.md

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,13 @@ The library is organized into several logical groups:
269269
- `dispatch_async/4` is an alias for `dispatch(id, type, payload, [meta | async: true])`
270270
- `handle_async/3` MUST return cancellation callback `(() -> any())` for internal use
271271

272+
- **Unmatched Action Handling**:
273+
- Reducers can override `handle_unmatched_action/2` to customize behavior for unmatched actions
274+
- Reducers can override `handle_unmatched_async/3` to customize behavior for unmatched async actions
275+
- Global handler configured via `unmatched_action_handler` config option (:log, :warn, :silent, or custom function)
276+
- Default behavior logs debug message suggesting use of `@action_prefix` to limit action routing
277+
- Useful for debugging action routing issues in complex applications
278+
272279
- **LiveView Integration**:
273280
- Use `Phoenix.SessionProcess.LiveView.mount_store/4` for direct subscriptions
274281
- Selector-based updates for efficiency
@@ -286,14 +293,20 @@ config :phoenix_session_process,
286293
session_process: MySessionProcess, # Default session module
287294
max_sessions: 10_000, # Maximum concurrent sessions
288295
session_ttl: 3_600_000, # Session TTL in milliseconds (1 hour)
289-
rate_limit: 100 # Sessions per minute limit
296+
rate_limit: 100, # Sessions per minute limit
297+
unmatched_action_handler: :log # How to handle unmatched actions (:log, :warn, :silent, or function)
290298
```
291299

292300
Configuration options:
293301
- `session_process`: Default module for session processes (defaults to `Phoenix.SessionProcess.DefaultSessionProcess`)
294302
- `max_sessions`: Maximum concurrent sessions (defaults to 10,000)
295303
- `session_ttl`: Session TTL in milliseconds (defaults to 1 hour)
296304
- `rate_limit`: Sessions per minute limit (defaults to 100)
305+
- `unmatched_action_handler`: How to handle actions that don't match any pattern in reducers (defaults to `:log`)
306+
- `:log` - Log debug messages for unmatched actions
307+
- `:warn` - Log warning messages for unmatched actions
308+
- `:silent` - No logging
309+
- Custom function with arity 3: `fn action, reducer_module, reducer_name -> ... end`
297310

298311
## Usage in Phoenix Applications
299312

@@ -406,7 +419,8 @@ end
406419
config :phoenix_session_process,
407420
session_process: MyApp.SessionProcess,
408421
max_sessions: 10_000,
409-
session_ttl: 3_600_000
422+
session_ttl: 3_600_000,
423+
unmatched_action_handler: :log # Optional: :log | :warn | :silent | custom function
410424

411425
# lib/my_app/application.ex
412426
def start(_type, _args) do
@@ -647,3 +661,25 @@ Use `Phoenix.SessionProcess.Error.message/1` for human-readable error messages.
647661
# Client-side selection - transfers full state
648662
count = SessionProcess.get_state(session_id, fn s -> s.counter.count end)
649663
```
664+
665+
7. **Unmatched Action Handling**:
666+
```elixir
667+
# Default behavior: logs debug message for unmatched actions
668+
def handle_action(action, state) do
669+
case action do
670+
%Action{type: "known"} -> # handle action
671+
_ -> handle_unmatched_action(action, state) # Logs debug message
672+
end
673+
end
674+
675+
# Override to customize behavior
676+
def handle_unmatched_action(action, state) do
677+
# Custom logic, e.g., track unmatched actions
678+
MyApp.Metrics.track_unmatched(action)
679+
state
680+
end
681+
682+
# Or configure globally
683+
config :phoenix_session_process,
684+
unmatched_action_handler: :warn # :log | :warn | :silent | custom function
685+
```

lib/phoenix/session_process.ex

Lines changed: 154 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,14 +1034,96 @@ defmodule Phoenix.SessionProcess do
10341034
%{state | data: data, priority: priority}
10351035
end
10361036
"""
1037-
def handle_action(_action, state), do: state
1037+
def handle_action(action, state) do
1038+
handle_unmatched_action(action, state)
1039+
end
1040+
1041+
@doc """
1042+
Handle unmatched actions.
1043+
1044+
This callback is called when an action doesn't match any pattern in `handle_action/2`.
1045+
Override this to customize behavior for unmatched actions.
1046+
1047+
## Parameters
1048+
1049+
- `action` - The unmatched action (Action struct)
1050+
- `state` - The current state slice for this reducer
1051+
1052+
## Returns
1053+
1054+
- `state` - The state (unchanged by default)
1055+
1056+
## Default Behavior
1057+
1058+
The default implementation logs a debug message based on the global
1059+
`unmatched_action_handler` configuration and returns the state unchanged.
1060+
1061+
## Examples
1062+
1063+
# Custom handler that logs at info level
1064+
def handle_unmatched_action(action, state) do
1065+
require Logger
1066+
Logger.info("MyReducer ignored action: \#{action.type}")
1067+
state
1068+
end
1069+
1070+
# Custom handler that tracks metrics
1071+
def handle_unmatched_action(action, state) do
1072+
MyApp.Metrics.increment("reducer.unmatched_actions", tags: [reducer: :my_reducer])
1073+
state
1074+
end
1075+
1076+
# Silent handler
1077+
def handle_unmatched_action(_action, state), do: state
1078+
"""
1079+
def handle_unmatched_action(action, state) do
1080+
require Logger
1081+
reducer_module = __MODULE__
1082+
reducer_name = __MODULE__.__reducer_name__()
1083+
handler = Phoenix.SessionProcess.Config.unmatched_action_handler()
1084+
1085+
case handler do
1086+
:log ->
1087+
Logger.debug("""
1088+
Reducer #{inspect(reducer_name)} (#{inspect(reducer_module)}) received unmatched action: #{inspect(action.type)}.
1089+
Consider using @action_prefix to limit which actions are routed to this reducer.
1090+
""")
1091+
1092+
:warn ->
1093+
Logger.warning("""
1094+
Reducer #{inspect(reducer_name)} (#{inspect(reducer_module)}) received unmatched action: #{inspect(action.type)}.
1095+
Consider using @action_prefix to limit which actions are routed to this reducer.
1096+
""")
1097+
1098+
:silent ->
1099+
:ok
1100+
1101+
handler when is_function(handler, 3) ->
1102+
handler.(action, reducer_module, reducer_name)
1103+
1104+
_ ->
1105+
Logger.warning(
1106+
"Invalid unmatched_action_handler configuration: #{inspect(handler)}. " <>
1107+
"Expected :log, :warn, :silent, or function/3"
1108+
)
1109+
end
1110+
1111+
state
1112+
end
10381113

10391114
@doc """
10401115
Handle asynchronous actions with dispatch callback.
10411116
10421117
Override this function for actions that require async operations.
10431118
The dispatch callback allows you to dispatch new actions from async context.
10441119
1120+
**Note**: If you define `handle_async/3`, you should include a catch-all clause
1121+
that delegates to `handle_unmatched_async/3` for actions you don't handle:
1122+
1123+
def handle_async(action, dispatch, state) do
1124+
handle_unmatched_async(action, dispatch, state)
1125+
end
1126+
10451127
## Parameters
10461128
10471129
- `action` - The action to process (Action struct)
@@ -1054,7 +1136,6 @@ defmodule Phoenix.SessionProcess do
10541136
## Returns
10551137
10561138
- `cancel_fn` - A cancellation function `(() -> any())` that will be called if the action needs to be cancelled
1057-
- Default implementation returns `fn -> nil end` (no-op cancellation)
10581139
10591140
## Examples
10601141
@@ -1071,7 +1152,7 @@ defmodule Phoenix.SessionProcess do
10711152
fn -> Task.shutdown(task, :brutal_kill) end
10721153
end
10731154
1074-
# With meta
1155+
# With catch-all for unmatched actions
10751156
def handle_async(%Action{type: "load_data"}, dispatch, state) do
10761157
task = Task.async(fn ->
10771158
data = API.load_data()
@@ -1081,18 +1162,84 @@ defmodule Phoenix.SessionProcess do
10811162
fn -> Task.shutdown(task, :brutal_kill) end
10821163
end
10831164
1084-
# Simple case: no cancellation needed
1085-
def handle_async(%Action{type: "log", payload: msg}, _dispatch, _state) do
1086-
Logger.info(msg)
1165+
def handle_async(action, dispatch, state) do
1166+
# Delegate unmatched async actions to default handler
1167+
handle_unmatched_async(action, dispatch, state)
1168+
end
1169+
"""
1170+
1171+
@doc """
1172+
Handle unmatched asynchronous actions.
1173+
1174+
This callback is called when an async action doesn't match any pattern in `handle_async/3`.
1175+
Override this to customize behavior for unmatched async actions.
1176+
1177+
## Parameters
1178+
1179+
- `action` - The unmatched action (Action struct)
1180+
- `dispatch` - Callback function to dispatch new actions
1181+
- `state` - The current state slice for this reducer
1182+
1183+
## Returns
1184+
1185+
- `cancel_fn` - A no-op cancellation function `(() -> nil)` (default)
1186+
1187+
## Default Behavior
1188+
1189+
The default implementation logs a debug message and returns a no-op cancel function.
1190+
1191+
## Examples
1192+
1193+
# Custom handler that logs
1194+
def handle_unmatched_async(action, _dispatch, _state) do
1195+
require Logger
1196+
Logger.info("MyReducer ignored async action: \#{action.type}")
1197+
fn -> nil end
1198+
end
1199+
1200+
# Silent handler
1201+
def handle_unmatched_async(_action, _dispatch, _state) do
10871202
fn -> nil end
10881203
end
10891204
"""
1205+
def handle_unmatched_async(action, _dispatch, state) do
1206+
require Logger
1207+
reducer_module = __MODULE__
1208+
reducer_name = __MODULE__.__reducer_name__()
1209+
handler = Phoenix.SessionProcess.Config.unmatched_action_handler()
1210+
1211+
case handler do
1212+
:log ->
1213+
Logger.debug("""
1214+
Reducer #{inspect(reducer_name)} (#{inspect(reducer_module)}) received unmatched async action: #{inspect(action.type)}.
1215+
Consider using @action_prefix to limit which actions are routed to this reducer.
1216+
""")
1217+
1218+
:warn ->
1219+
Logger.warning("""
1220+
Reducer #{inspect(reducer_name)} (#{inspect(reducer_module)}) received unmatched async action: #{inspect(action.type)}.
1221+
Consider using @action_prefix to limit which actions are routed to this reducer.
1222+
""")
1223+
1224+
:silent ->
1225+
:ok
1226+
1227+
handler when is_function(handler, 3) ->
1228+
handler.(action, reducer_module, reducer_name)
1229+
1230+
_ ->
1231+
:ok
1232+
end
1233+
1234+
fn -> nil end
1235+
end
10901236

10911237
# NOTE: No default implementation for handle_async/3
10921238
# Only export handle_async/3 if explicitly defined by the reducer
10931239
# This ensures function_exported?(module, :handle_async, 3) accurately reflects intent
10941240

1095-
defoverridable init_state: 0, handle_action: 2
1241+
defoverridable init_state: 0, handle_action: 2, handle_unmatched_action: 2,
1242+
handle_unmatched_async: 3
10961243
end
10971244
end
10981245

lib/phoenix/session_process/config.ex

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ defmodule Phoenix.SessionProcess.Config do
1515
session_process: MyApp.SessionProcess, # Default session module
1616
max_sessions: 10_000, # Maximum concurrent sessions
1717
session_ttl: 3_600_000, # Session TTL in milliseconds (1 hour)
18-
rate_limit: 100 # Sessions per minute limit
18+
rate_limit: 100, # Sessions per minute limit
19+
unmatched_action_handler: :log # How to handle unmatched actions (:log, :warn, :silent, or custom function)
1920
```
2021
2122
## Options
@@ -40,6 +41,15 @@ defmodule Phoenix.SessionProcess.Config do
4041
- **Default**: `100`
4142
- **Description**: Maximum number of new sessions that can be created per minute. Prevents abuse.
4243
44+
### `:unmatched_action_handler`
45+
- **Type**: `:log | :warn | :silent | (action, reducer_module, reducer_name -> any())`
46+
- **Default**: `:log`
47+
- **Description**: How to handle actions that don't match any pattern in a reducer's `handle_action/2`:
48+
- `:log` - Log debug message suggesting use of action prefix
49+
- `:warn` - Log warning message (useful for debugging)
50+
- `:silent` - No logging
51+
- Custom function with arity 3: `fun(action, reducer_module, reducer_name)`
52+
4353
## Runtime Configuration
4454
4555
Configuration can be provided in two ways:
@@ -89,6 +99,7 @@ defmodule Phoenix.SessionProcess.Config do
8999
@default_session_ttl 3_600_000
90100
# sessions per minute
91101
@default_rate_limit 100
102+
@default_unmatched_action_handler :log
92103

93104
@doc """
94105
Returns the configured default session process module.
@@ -204,6 +215,44 @@ defmodule Phoenix.SessionProcess.Config do
204215
get_config(:rate_limit, @default_rate_limit)
205216
end
206217

218+
@doc """
219+
Returns the handler for unmatched actions in reducers.
220+
221+
## Examples
222+
223+
iex> Phoenix.SessionProcess.Config.unmatched_action_handler()
224+
:log
225+
226+
## Returns
227+
228+
- `:log` - Log debug messages for unmatched actions (default)
229+
- `:warn` - Log warning messages for unmatched actions
230+
- `:silent` - No logging
231+
- `function/3` - Custom handler function with signature: `fun(action, reducer_module, reducer_name)`
232+
233+
## Configuration
234+
235+
Set in your config file:
236+
237+
config :phoenix_session_process,
238+
unmatched_action_handler: :warn
239+
240+
# Or with custom function:
241+
config :phoenix_session_process,
242+
unmatched_action_handler: fn action, module, name ->
243+
MyApp.Metrics.track_unmatched_action(action, module, name)
244+
end
245+
246+
## Note
247+
248+
This helps debug action routing issues. If you see many unmatched actions,
249+
consider using `@action_prefix` to limit which actions are routed to each reducer.
250+
"""
251+
@spec unmatched_action_handler :: :log | :warn | :silent | function()
252+
def unmatched_action_handler do
253+
get_config(:unmatched_action_handler, @default_unmatched_action_handler)
254+
end
255+
207256
@doc """
208257
Validates whether a session ID has the correct format.
209258

0 commit comments

Comments
 (0)