Packages

Event-driven notification system for Ash Framework with multiple transport types

Current section

Files

Jump to
ash_dispatch lib notifier.ex
Raw

lib/notifier.ex

defmodule AshDispatch.Notifier do
@moduledoc """
Single notifier that handles ALL DispatchEvent and counter-broadcast
work for resources using the AshDispatch DSL.
## Why this lives here
AshDispatch's prior approach registered per-action `change` modules
via `Ash.Changeset.after_action/2`, which fires synchronously inside
the action's transaction BEFORE commit/rollback. Wrapping a
dispatch-emitting action in `Ash.transaction/2` therefore allowed
phantom dispatches and counter increments to broadcast for rows that
subsequently rolled back.
Routing through `Ash.Notifier` instead inherits Ash's commit-deferred
semantics for free: notifications accumulate in
`Process.put(:ash_notifications, …)` and fire post-commit, or are
dropped on error (see `deps/ash/lib/ash.ex:3917-3970` in the
consuming Mosis app for the defer-and-fire-or-drop trace).
## Registration
Resources don't register this notifier directly. The transformers
`AshDispatch.Resource.Transformers.InjectDispatchChanges` and
`AshDispatch.Resource.Transformers.InjectCounterBroadcasts` persist
per-action config into the resource's dsl_state AND append this
module to `:simple_notifiers` (mirroring the existing
`InjectEntityNotifier` pattern at line 38 of that transformer).
## Per-action config lookup
Per-action `event_config` and `counter_config` data — formerly
embedded as `change_opts` in the change tuple — is now persisted via
`Spark.Dsl.Transformer.persist/3` and read back via
`AshDispatch.Notifier.Info`. This mirrors `Ash.Notifier.PubSub`'s
approach: a single notifier with a Spark Info module reading
per-action config (per `Ash.Notifier.PubSub.Info.publications/1`).
## Side-effect orchestration
The actual dispatch and counter-broadcast logic is preserved from the
prior `Changes.DispatchEvent` and `Changes.BroadcastCounterUpdate`
modules — moved to `AshDispatch.Notifier.DispatchHandler` and
`AshDispatch.Notifier.CounterHandler` and called from `notify/1`.
Only the registration point changed; all downstream Dispatcher /
EventResolver / ChannelResolver collaborators stay unchanged.
"""
use Ash.Notifier
alias AshDispatch.Notifier.{CounterHandler, DispatchHandler, Info}
require Logger
@impl Ash.Notifier
def notify(%Ash.Notifier.Notification{action: action} = notification)
when not is_nil(action) do
action_name = action.name
# Both lookups return [] when the action has no registered config —
# cheap O(1) map lookup keyed by action name.
event_configs = Info.dispatch_events_for(notification.resource, action_name)
counter_configs = Info.counter_broadcasts_for(notification.resource, action_name)
Enum.each(event_configs, fn event_config ->
safe_dispatch(notification, event_config, :dispatch_event)
end)
Enum.each(counter_configs, fn counter_config ->
safe_dispatch(notification, counter_config, :counter_broadcast)
end)
:ok
end
def notify(_), do: :ok
# ── Internal — error-isolated handler invocations ───────────────
defp safe_dispatch(notification, config, kind) do
case kind do
:dispatch_event -> DispatchHandler.dispatch(notification, config)
:counter_broadcast -> CounterHandler.broadcast(notification, config)
end
rescue
error ->
Logger.error("""
[AshDispatch.Notifier] #{kind} failed for action=#{inspect(notification.action.name)} \
resource=#{inspect(notification.resource)}
Config: #{inspect(config, limit: :infinity)}
Error: #{inspect(error)}
Stacktrace: #{Exception.format_stacktrace(__STACKTRACE__)}
""")
# Don't crash the notification chain — same posture as the prior
# change modules' rescue in dispatch_event.ex:127-137.
:ok
end
end