Event¶
An Event is the core message type in EDRI. Everything in the system communicates through events.
What is an Event?¶
Events are transport-agnostic — the same event model works for REST, WebSocket, HTML, scheduled tasks, and internal communication.
All events inherit from
Event(a dataclass base class).Events are routed by the Router to subscribed Managers based on their Python type and direction — the Router keeps separate subscription maps for requests and responses, so a Manager can subscribe to a given event type as a request, as a response, or both.
Events can carry a Response (for request/reply patterns) or be fire-and-forget (notifications).
flowchart LR
C["Component (Producer)<br/>put(Event)"] -->|dispatch by type| R[Router] --> M["Manager (Subscriber)<br/>@request"]
Defining events and responses¶
Events and responses are registered using decorators:
Decorator |
Purpose |
|---|---|
|
Register a class as an Event |
|
Register a class as a Response |
Minimal example¶
@response
class Pong(Response):
pass
@event
class Ping(Event):
response: Pong
Event with payload¶
@response
class SearchResponse(Response):
results: list[dict]
total: int
@event
class Search(Event):
query: str
limit: int = 10
response: SearchResponse
Fire-and-forget event (no response)¶
@event
class LogMessage(Event):
level: str
message: str
source: str
Fields¶
Event fields are standard Python dataclass fields:
from dataclasses import field
from enum import Enum, auto
from uuid import UUID, uuid4
from edri.dataclass.event import Event, event
class Priority(Enum):
LOW = auto()
NORMAL = auto()
HIGH = auto()
@event
class Task(Event):
name: str # required
id: UUID = field(default_factory=uuid4) # generated per instance
priority: Priority = Priority.NORMAL
tags: list[str] | None = None # optional
metadata: dict = field(default_factory=dict) # mutable default
Keyword-only fields¶
Framework-supplied fields (headers, cookies, scope) are typically declared as kw_only=True:
from dataclasses import dataclass, field
@dataclass
class AuthenticatedEvent(Event):
user_id: int
bearer_token: list[str] | None = field(
default=None,
kw_only=True,
)
This prevents positional-argument ordering problems with dataclass inheritance.
The response field¶
When an event declares a response field:
@eventvalidates the type is a subclass ofResponse.The
responsefield is madeinit=False(not passed to the constructor).It is auto-instantiated via
default_factory.
@event
class MyEvent(Event):
response: MyResponse
# Usage:
event = MyEvent(other_field="value")
event.response.result = "done" # auto-instantiated, no need to create it
Event lifecycle¶
Simple model (request/response)¶
flowchart TD
S1["1. Component creates Event"] --> S2["2. Event put on Router Queue"]
S2 --> S3["3. Router looks up<br/>subscribers by Event type"]
S3 --> S4["4. Router delivers Event<br/>to subscribed Managers"]
S4 --> S5["5. Manager selects @request handler method"]
S5 --> S6["6. Handler runs<br/>read fields<br/>set response<br/>return"]
S6 --> S7["7. Manager auto-sends<br/>modified response"]
S7 --> S8["8. Router delivers response back to origin"]
Fire-and-forget model¶
flowchart TD
F1["1. Component creates Event<br/>(no response field)"] --> F2["2. Event put on Router Queue"]
F2 --> F3["3. Router delivers to subscribed Managers"]
F3 --> F4["4. Handler runs"]
F4 --> F5["5. No response<br/>event is discarded"]
Nested event model¶
sequenceDiagram
participant Caller
participant MA as ManagerA
participant R as Router
participant MB as ManagerB
Caller->>MA: EventA
Note over MA: creates EventB, embeds EventA inside
MA->>R: put EventB
R->>MB: deliver EventB
Note over MB: processes EventB, sets response
MB->>R: EventB response
R->>MA: EventB response
Note over MA: completes EventA using EventB's response
MA->>R: put completed EventA
R->>Caller: EventA response
Transport metadata¶
Every event carries up to two optional internal metadata objects that track per-hop transport state:
SwitchInfo¶
Populated on every shareable event. Tracks the current hop through the Switch:
Attribute |
Type |
Description |
|---|---|---|
|
|
The UUID of the Router that sent the event on the current hop. |
|
|
|
Both attributes are re-stamped on every send/receive and cleared when the event is turned into a response. The event's stable identity (used for cache replay and Switch resume markers) is Event._id — every event carries one, regardless of shareability.
ApiInfo¶
Populated when an event is dispatched through the API layer (REST, WebSocket, HTML):
Attribute |
Type |
Description |
|---|---|---|
|
|
The API route key. |
|
|
Enum value (REST, WebSocket, HTML, etc.). |
Both SwitchInfo and ApiInfo are concealed (prefixed with _) from JSON output and managed automatically by the framework.
Event handling types (EventHandlingType)¶
EventHandlingType is an :class:~enum.IntFlag that controls how events are dispatched to clients by the API broker. Multiple modes can be combined with bitwise operations.
Value |
Name |
Behaviour |
Example APIs |
|---|---|---|---|
1 |
|
1:1 response to the client that requested the event. |
REST, HTML |
2 |
|
Sent only to clients subscribed to this event type. |
WebSocket, MQTT |
4 |
|
Broadcast to every async-capable client, regardless of subscription. |
WebSocket, MQTT |
Combinations:
Value |
Combination |
Use case |
|---|---|---|
3 |
|
Direct response or subscriber broadcast, depending on context. |
5 |
|
Direct response or global broadcast, depending on context. |
Serialization¶
Events cross process boundaries via multiprocessing.Queue, which uses pickle for serialization.
Rules¶
All event field values must be picklable.
Standard Python types (
int,str,list,dict,None,UUID,datetime,Enum) are safe.Custom objects must implement
__getstate__/__setstate__or be pickle-compatible.Lambdas, generators, file handles, and most function references are not picklable.
Cross-node serialization¶
For events that leave the local appliance (shareable events), the same pickle mechanism is used over TCP between the two Connectors — the Switch itself never unpickles, it forwards bytes. Two things must line up:
Pickle protocol. EDRI calls
pickle.dumpswith the default protocol — 5 on every supported Python (3.12+), stable since 3.8. The receiver'spickle.HIGHEST_PROTOCOLmust be at least what the sender emits, so a node may not run a Python far behind its peers. The Python versions themselves need not match.Event classes. Every shared event class must resolve to the same import path on both nodes —
picklekeys onmodule.qualnameand the Connector matches demands by class identity, somyapp.events.Taskandother.events.Taskare unrelated types that will not route.
When the producer and consumer are separate deployments (in practice, separate repositories), the shared event and response definitions cannot just be copied into each — they must come from a single package both sides depend on, installed at the same import path and released in lockstep. Adding, removing or renaming a field on a shareable event is a breaking change for every peer still on the old definition. Keep that package free of @api events: importing it would register HTTP routes on nodes that only need the wire type.
Concealed fields (API output)¶
When an event or its response is serialized for an API client, any field whose name starts with an underscore (_) is concealed — omitted from the output. The framework relies on this for its own internal fields (_timing, _switch, _worker, …), but the same convention is available to application code: name a field _something to keep it out of client payloads.
Concealment is not uniform across transports — it depends on which renderer handles the response:
Transport |
Concealed ( |
|---|---|
REST (JSON) |
stripped |
WebSocket (JSON) |
stripped |
HTML template |
kept — passed to the Jinja template |
This asymmetry is deliberate and useful: a field like _db_row or _internal_score can be set by a Manager, rendered into an HTML page by a template, yet never leak into the JSON/WebSocket representation of the same event. It is purely a name-prefix convention — there is no decorator or annotation involved.
Note: concealment is independent of pickle serialization above. Concealed fields do cross process and node boundaries normally (the Router, Workers, and the Switch all see them); they are only filtered at the final API-rendering step.
Built-in events¶
EDRI includes several built-in event families:
Module |
Purpose |
|---|---|
|
Manager control (Restart, WorkerQuit) |
|
Router control |
|
Scheduler control (Set, Update, SetUpdate, Cancel) |
|
Store control (Set, Get, Delete, GetCallback) |
|
Marker base classes for event classification ( |
|
Client management events |
|
API group events |
|
API management events |
Gotchas & Notes¶
Events are not inherently API events. API exposure is added via
@api(...)— see API Event.The response field is optional. Many events (notifications, scheduled tasks) do not declare one.
If a response is declared, its field name must be
response(lowercase).@eventvalidates the response type at class definition time.@event(shareable=True)allows the event to cross router boundaries.Events must be picklable for inter-process communication.
Event creation can happen anywhere in the system (Managers, Workers, API, Middleware).
Nested events are powerful for multi-step workflows — keep ownership and lifecycle clear.
The
methodfield on events is legacy/deprecated and will be removed. Usemethod=...on@api(...)instead.
See also¶
API Event — exposing events through the API layer
Response — response status, directives, lifecycle
Router — how events are dispatched
Manager — how managers handle events
Validation & Injection — field validation with
inject(...)