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

@event

Register a class as an Event

@response

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:

  1. @event validates the type is a subclass of Response.

  2. The response field is made init=False (not passed to the constructor).

  3. 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
    

Shareable events

By default, events stay within a single EDRI instance. To allow an event to cross the network to another router (via the Switch), mark it as shareable:

@event(shareable=True)
class CrossNodeTask(Event):
    data: str
    response: CrossNodeResponse

Only shareable events are forwarded through the Connector to the Switch.


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

router_id

UUID | None

The UUID of the Router that sent the event on the current hop.

received

bool

True when the event arrived from the Switch, so the local Router does not echo it back through the Connector.

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

key

str

The API route key.

type

ApiType

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

SPECIFIC

1:1 response to the client that requested the event.

REST, HTML

2

SUBSCRIBED

Sent only to clients subscribed to this event type.

WebSocket, MQTT

4

ALL

Broadcast to every async-capable client, regardless of subscription.

WebSocket, MQTT

Combinations:

Value

Combination

Use case

3

SPECIFIC | SUBSCRIBED

Direct response or subscriber broadcast, depending on context.

5

SPECIFIC | ALL

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.dumps with the default protocol — 5 on every supported Python (3.12+), stable since 3.8. The receiver's pickle.HIGHEST_PROTOCOL must 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 — pickle keys on module.qualname and the Connector matches demands by class identity, so myapp.events.Task and other.events.Task are 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 (_) fields

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.


Inheritance and shared patterns

Marker base classes

A common pattern is to create marker base classes that express requirements:

@dataclass
class AuthenticatedEvent(Event):
    """Requires authentication — middleware checks this."""
    pass

@dataclass
class RateLimitedEvent(Event):
    """Subject to rate limiting."""
    pass

@event
class DeleteAccount(AuthenticatedEvent, RateLimitedEvent):
    user_id: int
    response: DeleteAccountResponse

Shared input fields

@dataclass
class PaginatedEvent(Event):
    page: int = 1
    page_size: int = 20

@event
class ListUsers(PaginatedEvent):
    response: ListUsersResponse

@event
class ListOrders(PaginatedEvent):
    status: str | None = None
    response: ListOrdersResponse

Built-in events

EDRI includes several built-in event families:

Module

Purpose

edri.events.edri.manager

Manager control (Restart, WorkerQuit)

edri.events.edri.router

Router control

edri.events.edri.scheduler

Scheduler control (Set, Update, SetUpdate, Cancel)

edri.events.edri.store

Store control (Set, Get, Delete, GetCallback)

edri.events.edri.group

Marker base classes for event classification (Manager, Router, Scheduler, Store, Switch, Test)

edri.events.api.client

Client management events

edri.events.api.group

API group events

edri.events.api.manage

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).

  • @event validates 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 method field on events is legacy/deprecated and will be removed. Use method=... on @api(...) instead.


See also