View Source Trolleybus (trolleybus v0.2.0)
Defines a local, application-level PubSub API for dispatching side effects.
This PubSub mechanism is dedicated to handling side-effects in business logic. Instead of calling side effects directly, an event is published. The event is then routed to one or more handlers, according to declared routing.
example
Example
Let's assume we have a code path in business logic where we want to trigger two side effects after core logic is done.
def accept_invite(...) do
...
App.Emails.notify_invite_accepted(document, inviter, user)
App.Webhooks.notify_auth_update(document, user)
...
end
First, we define the event using Trolleybus.Event
:
defmodule App.Documents.Events.MembershipInviteAccepted do
use Trolleybus.Event
handler App.Documents.EmailEventHandler
handler App.Webhooks.EventHandler
message do
field :membership, %App.Memberships.Membership{}
field :document, %App.Documents.Document{}
field :inviter, %App.Users.User{}, required: false
field :user, %App.Users.User{}
end
end
Next, we define new or extend existing event handlers using
Trolleybus.Handler
:
defmodule App.Documents.EmailEventHandler do
use Trolleybus.Handler
alias App.Documents.Events.MembershipInviteAccepted
def handle_event(%MembershipInviteAccepted{
document: document,
inviter: inviter,
user: user
}) do
App.Emails.notify_invite_accepted(document, inviter, user)
end
...
end
defmodule App.Webhooks.EventHandler do
use Trolleybus.Handler
alias App.Documents.Events.MembershipInviteAccepted
def handle_event(%MembershipInviteAccepted{
document: document,
user: user
}) do
App.Webhooks.notify_auth_update(document, user)
end
...
end
Finally, we publish an event instead of calling Emails
and Webhooks
directly:
alias App.Documents.Events
def accept_invite(...) do
...
Trolleybus.publish(%Events.MembershipInviteAccepted{
membership: membership,
document: document,
inviter: inviter,
user: user
})
...
end
publishing
Publishing
A publish/2
call triggers the dispatch logic for the event. The dispatch
retrieves all handlers provided in event's definition and passes it to
those handlers.
Publishing may be executed in three modes:
- fully synchronous
:full_sync
(default) - executes all handlers sequentially and synchronously, within the same process as caller, - asynchronous
:async
- executes side effects in separate processes not waiting for them to finish executing, - synchronous
:sync
- blocks until all processes executing event handlers complete.
The wait time in case of synchronous publish is limited by timeout setup via
:sync_timeout
option, expressed in milliseconds (defaults to 5000). Any
handler execution process running for longer than the setup timeout is killed.
For simplicity, Trolleybus
is neither concerned with persistence nor retrying
failed handler calls. If a particular case calls for this kind of guarantees,
it can be realised by, for instance, making relevant handler use job processing
library like Oban.
buffering
Buffering
There are cases where publishing events may have to be either deferred or
abandoned completely. This problem can be solved by using one of buffering
wrappers: transaction/1
, buffered/1
or muffled/1
. The underlying
mechanism is the same for all three. Before a block of code is executed, a
buffer is opened. The buffer is an agent process storing a list of events
along with their respective publish options, if any are passed. Each call to
publish/2
inside that block puts the event in that list instead of actually
publishing it. Once the block of code finishes executing, further behavior
depends on the wrapper. buffered/1
and muffled/1
discard the events (the
former returns them too, along with the result). transaction/1
fetches
contents of the current buffer, closes it and re-publishes all the events from
the list. If wrappers are nested, the re-publishing will result by putting the
events in another buffer opened by the outer wrapper. Eventually, the events
get either dispatched to respective handlers or discarded.
Current buffer as well as buffer stack are tracked using process dictionary. This means that buffering will only work for code executed within a single process.
events-inside-transactions
Events inside transactions
In case of more complex logic, there can be multiple events published along a
single code path. This code path can in turn contain multiple subroutines
changing system state, wrapped in one big transaction. It's often hard to
defer all the side effects outside of that transaction without clunky
workarounds. That's where transaction/1
comes into play. It's especially
useful when events are published inside a large, multi-stage Ecto.Multi
based
transaction.
Assuming we have events published somewhere inside a big transaction, like this:
def update_memberships(memberships) do
Ecto.Multi.run(:updated_memberships,
fn _, %{old_memberships: memberships} ->
updated_memberships = Enum.map(memberships, fn membership ->
Trolleybus.publish(%MembershipChanged{...})
...
end)
{:ok, updated_memberships}
end)
end
we can defer actual publishing of all events until after the transaction is
executed by wrapping the top level Ecto
transaction with Trolleybus
transaction:
def process(multi) do
case Trolleybus.transaction(fn -> Repo.transaction(multi) end) do
{:ok, ...} ->
...
end
end
nesting-transactions
Nesting transactions
Buffered transactions wrapped with transaction/1
can be arbitrarily nested
and it's guaranteed that only the outermost one will publish the events:
Trolleybus.transaction(fn -> # all events will be published
# only after this outer block finishes
...
Trolleybus.transaction(fn ->
...
end)
...
end)
reusing-code-publishing-events
Reusing code publishing events
We often want to reuse existing logic as a part of larger, more complex
routines. The problem is that this existing logic may already publish events
specific to that original context. It may often be undesirable, because we
want to emit events specific to the wrapping routine and need existing logic
only for its data manipulation part. Another use case where that may be an
issue is reusing business logic for setting up system state in tests instead
of synthetic factories. In order to make it possible, we can wrap the reused
code with muffled/1
:
{:ok, %{membership: accepted_membership}} = Trolleybus.muffled(fn ->
AcceptInvite.accept_membership_invite(user, membership)
end)
Wrapping it this way will send all the events to a buffer which will be promptly discarded after the code block completes.
testing-events
Testing events
When we want to test what events does a given piece of logic publish
instead of relying on checking side effects, we can use buffered/1
:
{{:ok, %{membership}}, events} = Trolleybus.buffered(fn ->
AcceptInvite.accept_membership_invite(user, membership)
end)
assert [{%MembershipInviteAccepted{}, []}] = events
Events published inside buffered/1
are stored in a buffer, whose contents
are returned along with the result of the code block in a tuple. The events
are then promptly discarded, so no handler gets triggered.
Another potentially handy function is get_buffer/0
, which allows to "peek"
into contents of the buffer at any point when buffer is open. It can be used
inside any of buffered/1
, muffled/1
or transaction/1
blocks:
Trolleybus.muffled(fn ->
...
assert [{%SomeEvent{}, []}] = get_buffer()
...
end)
One important thing to note is that publishing mode can be overridden in all calls using Application configuration:
config :trolleybus, mode_override: :full_sync
This allows users to force all events to be published using a given mode, for
example :full_sync
in config/test.exs
to make testing side effects
simpler. This lets us avoiding any issues related to running handlers
in a separate process, like having to explicitly handle Ecto
sandbox
allowance.
listing-routes
Listing routes
In order to print all events and associated handlers in the project, a dedicated mix task can be run:
mix trolleybus.routes
The output has a following form:
* App.Events.DocumentTransferred
=> App.Webhooks.EventHandler
=> App.Memberships.EmailEventHandler
* App.Events.UserInvitedToDocument
=> App.Memberships.EmailEventHandler
...
Link to this section Summary
Functions
Returns events published in the given function.
Lists all published but not yet dispatched events so far.
Discards events published in the given function without dispatching them to defined handlers.
Publishes event.
Dispatches published events after running the given function.
Link to this section Types
@type publish_mode() :: :full_sync | :async | :sync
@type publish_option() :: {:mode, publish_mode()} | {:sync_timeout, non_neg_integer()}
Link to this section Functions
@spec buffered((() -> result)) :: {result, [{struct(), [publish_option()]}]} when result: term()
Returns events published in the given function.
The events are returned along with the result of the function, without dispatching them to defined handlers.
Order of events in the list is always consistent with order of publishing inside the wrapped function.
example
Example
{"result", [{%SomeEvent{}, []},
{%OtherEvent{}, mode: :async},
{%AnotherEvent{}, mode: :sync, sync_timeout: 1_000}]} =
Trolleybus.buffered(fn ->
Trolleybus.publish(%SomeEvent{name: "value"})
Trolleybus.publish(%OtherEvent{flag: true}, mode: :async)
Trolleybus.publish(%AnotherEvent{number: 123}, mode: :sync, sync_timeout: 1_000)
"result"
end)
@spec get_buffer() :: [{struct(), publish_option()}]
Lists all published but not yet dispatched events so far.
Order of events in the list is always consistent with order of publishing inside the wrapped function.
example
Example
Trolleybus.muffled(fn ->
Trolleybus.publish(%SomeEvent{name: "value"})
Trolleybus.publish(%OtherEvent{flag: true}, mode: :async)
[{%SomeEvent{}, []},
{%OtherEvent{}, mode: :async}] = Trolleybus.get_buffer()
Trolleybus.publish(%AnotherEvent{number: 123}, mode: :sync, sync_timeout: 1_000)
[{%SomeEvent{}, []},
{%OtherEvent{}, mode: :async},
{%AnotherEvent{}, mode: :sync, sync_timeout: 1_000}] = Trolleybus.get_buffer()
end)
@spec muffled((() -> result)) :: result when result: term()
Discards events published in the given function without dispatching them to defined handlers.
example
Example
"result" =
Trolleybus.muffled(fn ->
Trolleybus.publish(%SomeEvent{name: "value"})
"result"
end)
@spec publish(struct(), [publish_option()]) :: :ok
Publishes event.
example
Example
:ok = Trolleybus.publish(%SomeEvent{name: "value"})
:ok = Trolleybus.publish(%OtherEvent{flag: true}, mode: :async)
:ok = Trolleybus.publish(
%AnotherEvent{number: 123}, mode: :sync, sync_timeout: 1_000
)
options
Options
:mode
- Event dispatch mode. Can be one of:full_sync
,:async
or:sync
. See "Dispatch modes" below for detailed explanation. Default::full_sync
.:sync_timeout
- Timeout in milliseconds after which synchronous dispatch is cancelled and handler processes still in progress are killed. Works only for:sync
mode. Default: 5000
disptach-modes
Disptach modes
Publishing may be executed in three modes:
:full_sync
- Executes all handlers sequentially and synchronously, within the same process as caller.:async
- Executes handlers in separate processes fully asynchronously, not waiting for them to finish executing.:sync
- Executes handlers in separate processes in parallel and waits until they complete executing. Waiting time is determined by:sync_timeout
option. Handler processes running past timeout are killed.
@spec transaction((() -> result)) :: result when result: term()
Dispatches published events after running the given function.
Dispatching to event handlers is done only if the result is in
{:ok, ...}
tuple format. Otherwise, events are discarded.
example
Example
{:ok, "result"} =
Trolleybus.transaction(fn ->
Trolleybus.publish(%SomeEvent{name: "value"})
{:ok, "result"}
end)