Events
- Created
- Versions
- History
Abstract
We will add a synchronous, in-process event dispatcher in which any object can be an event. Listeners will be registered against an event type by handler reference and sealed into an immutable catalogue. A listener will receive every event of its type or a subtype of it, and an event will be able to opt into cancellation, which stops any later listener from running.
Motivation
Something that happens in one part of the panel often matters to another. When a server is deleted, whatever cleans up after it needs to know, and so does whatever writes an audit trail, but the code deleting the server should not have to know about either.
This matters more for a module, which has to react to what the engine and other modules do without either side depending on the other.
Proposal
Events
Any object will be an event. There will be no marker interface to implement and nothing to extend: the event's class is its identity.
The class name will carry the meaning. A name in the past tense, such as ServerDeleted, says the thing has happened
and cannot be stopped. A name in the present participle, such as ServerDeleting, says it is about to happen, and the
event may be cancellable.
An event that needs something back from its listeners will carry an object that collects it, such as a registry or a builder, and listeners will call into that. The dispatcher will not gather or combine anything itself.
Registration
Registration will follow ADR-0003: an
EventRegistry will collect registrations, and its seal() method will produce the EventCatalogue the dispatcher
reads. Registering a listener after the registry has been sealed will throw.
$registry->listen(ServerDeleted::class, PurgeServerFiles::class);
$registry->listen(ServerDeleting::class, [BlockDeletionWhileRunning::class, 'handle']);
$catalogue = $registry->seal();
| Method | Effect |
|---|---|
listen(string $event, string|array $handler) |
Registers an invokable class, or a class and method, as a listener for the event type. |
seal(): EventCatalogue |
Produces the immutable catalogue and closes registration. |
A listener will be one handler for one event type, and the only thing the dispatcher stores and calls. A registration will hold a handler reference, never an instance. A listener for an event that never fires will never be constructed.
A subscriber is a class that declares several listeners through attributes on its methods. It will be expanded into registrations by whatever reads those attributes. That is reflection over attributes at registration time, which belongs to the module system, and the dispatcher will have no concept of a subscriber.
Dispatch
public function dispatch(object $event): object;
The EventDispatcher will pass the event to each listener that matches it, one at a time and in the order the listeners
were registered, and then return the event. Anything the caller wants back, it will read from the event.
Each listener will be resolved through the dependency injection container, as
RFC-0001 describes, the first time an event it matches is dispatched.
Listeners will have the Process lifetime from RFC-0007. A
listener will hold no state belonging to a request, and will reach anything that does through a
provider.
Matching
A listener will match an event whose class is the type the listener is registered against, or extends or implements
that type. A listener registered against object will match every event.
One listener registered against a marker interface will therefore reach every event that implements it, including events a module defines later.
Matching cannot be worked out in advance. Because any object can be an event, the set of event classes is open, and a class is not known until an instance of it is dispatched. The match set for a concrete class will be worked out on its first dispatch and kept for the life of the worker, so later dispatches of that class will read it directly.
Cancellation
An event will opt into cancellation by implementing Cancellable:
interface Cancellable
{
public function cancel(string $reason): void;
public function isCancelled(): bool;
public function reason(): ?string;
}
Cancelling an event will stop propagation: no listener after the one that cancelled it will run. dispatch() will still
return the event, and the caller will read isCancelled() and reason() to find out whether it was cancelled and why.
Because propagation stops at the first cancellation, the event will carry the first reason given.
Listeners that throw
An exception thrown by a listener will propagate. The dispatcher will catch nothing, and no listener after the one that threw will run.
What that means for the caller is the caller's concern. Work inside a transaction is rolled back by that
transaction. Code that dispatches an event inside something it must close, such as a cycle, does
the closing in its own try/finally, so a listener that throws cannot skip it.
Errors
Registering a listener after the registry has been sealed will throw EventLifecycleException. A
handler reference that cannot be resolved will fail when the listener is first
needed, with the exception the container raises, as RFC-0001 describes.
Out of scope
- Which events exist. Code that dispatches an event decides what the event is and what it carries.
- Subscribers and reading their attributes. Reading listener attributes off a class belongs to the module system.
- Listener priority. Listeners will run in the order they were registered, and nothing will reorder them.
- Durable delivery. Delivering an event outside the process, with an outbox to make delivery durable, will be a separate component built on this one.
- An audit trail. Also separate, and most likely a module.
- Collecting contributions from modules. A collector asks every module for contributions when a component chooses to; an event fires at a moment in a flow. They are different mechanisms.
- Dispatching from the container. Whatever owns a boundary dispatches events around it. The container never dispatches an event.
Alternatives considered
The decision this design rests on is recorded separately, with the alternatives it rejected: ADR-0003, under which mutable registries are sealed into immutable catalogues.
Working out every match when the catalogue is sealed. We rejected this because any object can be an event, so the set of event classes is open and there is nothing complete to compute in advance. Working out the matches for each concrete class on its first dispatch, and keeping them, saves the same work without needing the set in advance.
No other alternatives were weighed.
Backwards compatibility
Nothing will break. There is no event dispatcher today.
Open questions
Changelog
- : Reworded in the readable house style, and the project's own vocabulary linked to the glossary. The design is unchanged.
Sources
- Issue #68, Engine - Events, , with no edits and no comments: a synchronous in-process dispatcher, any
object being an event with naming carrying the meaning, events carrying whatever gathers contributions, listeners
and subscribers, registration of handler references through a registry sealed into a catalogue, references rather
than instances,
dispatch(object $event): object, matching over class, parent and interface withobjectmatching everything, matching resolved on first dispatch of a concrete class and kept for the life of the worker,Cancellablehalting propagation, listeners resolved from the container asProcesslifetime and reaching cycle-scoped state through a provider, a throwing listener propagating, and every boundary listed under Out of scope. - Issue #65, Engine - HTTP - Middleware pipeline, : the shape the memoised match set follows, a set composed on first use and kept.
- Issues #51 and #61, Engine - Database - PostgreSQL and its primitives, : the notify primitives and the outbox that durable delivery will be built on, which is why it is a separate component.
- The name
EventDispatcher, thelisten()andseal()registry methods,reason()onCancellablewith the first reason being the one carried,EventLifecycleExceptionfor registration after sealing, and listeners having no priority: first written down on .
Source rfc/0009-events.md · commit 072385a · exported