Architecture Overview¶
The Event Driven Routing Infrastructure (EDRI) is a framework designed to facilitate the development of applications that efficiently utilize parallelism across multiple threads, processes, or even distributed systems. The architecture is built around the concepts of Events and a routing infrastructure to manage communication and task distribution.
Design philosophy¶
EDRI was designed around three core principles:
Events as universal primitives — All communication happens through typed Event objects. Components never call each other directly.
Dispatch by type — The Router delivers events based on their Python type, not their content. This keeps routing simple and predictable.
Explicit concurrency — Managers are single-threaded by default. Concurrency must be explicitly delegated to Workers, making parallelism a conscious design decision.
Process model¶
EDRI uses multiprocessing as its primary isolation mechanism:
flowchart TD
subgraph Main["Main Process (Router)"]
direction TB
RQ["Router Queue<br/>+ Health Checker"]
end
subgraph M1["Manager Process"]
direction TB
W1["Worker (Thread)"]
end
subgraph M2["Manager Process"]
direction TB
W2["Worker (Process)"]
end
subgraph API["API Listener (ASGI Server)"]
direction TB
L["Listener (request parsing)"]
end
subgraph BRK["Broker Process"]
direction TB
BR["Broker (Middleware)"]
end
Main -->|multiprocessing.Connection| M1
Main -->|multiprocessing.Connection| M2
API -->|multiprocessing.Queue| BRK
BRK -->|multiprocessing.Queue| Main
Key points:
The Router runs in the main process.
Each Manager runs in its own child process (started via
multiprocessing.Process).Manager.__init__()is called during construction in the parent process; the child process begins inManager.run().Workers run inside their Manager's process — either as threads (
WorkerThread) or subprocesses (WorkerProcess).The API Listener and the Broker each run in their own child process. The Listener parses requests; the Broker runs middleware before forwarding events to the Router.
The Connector also runs as its own child process with sender/receiver threads inside.
All inter-process communication happens through
multiprocessing.Queueormultiprocessing.Connection(pipes), with events serialized viapickle.
Core components¶
# |
Component |
Process model |
Role |
|---|---|---|---|
1 |
Event |
Data (serializable) |
Universal message type |
2 |
Router |
Main process |
Central dispatcher |
3 |
Manager |
Child process |
Event handler, task distributor |
4 |
Worker |
Thread or subprocess |
Task executor |
5 |
API (Listener) |
Child process |
External protocol adapter |
6 |
Broker |
Child process |
Middleware pipeline |
7 |
Connector |
Child process |
Router ↔ Switch bridge |
8 |
Switch |
Standalone process |
Cross-node router hub |
9 |
Scheduler |
Manager (child process) |
Time-based event emitter |
10 |
Key-Value Store |
Manager (child process) |
In-memory shared storage |
Component interaction diagram¶
graph TB
SW((Switch))
subgraph EDRI 1
direction TB
CN1((Connector A))
R1((Router))
M1((Manager A))
M2((Manager B))
BR1((Broker))
L1((HTTP))
SC1((Scheduler))
KV1((Key-Value Store))
R1 -- IPC <--> M1
R1 -- IPC <--> M2
M1 -- IPC <--> W1A((Worker 1))
M1 -- IPC <--> W2A((Worker 2))
M2 -- IPC <--> W1B((Worker 1))
M2 -- IPC <--> W2B((Worker 2))
R1 -- IPC <--> BR1
R1 -- IPC <--> SC1
R1 -- IPC <--> KV1
BR1 -- IPC <--> L1
end
subgraph EDRI 2
direction TB
CN2((Connector B))
R2((Router))
M3((Manager C))
M4((Manager D))
BR2((Broker))
L2((HTTP))
R2 -- IPC <--> M3
R2 -- IPC <--> M4
M3 -- IPC <--> W1C((Worker 1))
M3 -- IPC <--> W2C((Worker 2))
M4 -- IPC <--> W1D((Worker 1))
M4 -- IPC <--> W2D((Worker 2))
R2 -- IPC <--> BR2
BR2 -- IPC <--> L2
end
SW -- TCP/IP <--> CN1
SW -- TCP/IP <--> CN2
CN1 -- IPC <--> R1
CN2 -- IPC <--> R2
Event flow (detailed)¶
Local flow (no Switch)¶
flowchart TD
P["Producer<br/>(Manager / API / Worker)"] -->|"put(Event) on Router Queue"| R[Router]
R -->|"by Event type<br/>to subscribers"| M[Manager]
M -->|"execute @request<br/>or @response"| H{Handler}
H -. "optional: workers.start(Event, WorkerClass)" .-> W["Worker.do()<br/>event_send(Event)<br/>resolve_callback_worker()"]
H -. "optional: router_queue.put(new_event)" .-> R
H -->|"returns → response auto-sent"| R
Distributed flow (with Switch)¶
flowchart TD
MA["Manager A<br/>(Node 1)<br/>shareable Event"] --> RA[Router A]
RA --> CA[Connector A]
CA -->|TCP/IP| SW[Switch]
SW -->|"looks up destination router UUID"| CB[Connector B]
CB -->|TCP/IP| RB[Router B]
RB --> MB["Manager B (Node 2)"]
Manager lifecycle: construction vs. execution¶
Manager construction (Manager.__init__) happens in the parent (main) process
when EDRI.add_component() or Manager() is called. The actual event loop
starts in a child process after Manager.start() is called. This distinction
matters for what resources are safe to create where:
Operation |
Process |
When |
|---|---|---|
|
Parent |
During |
|
Parent |
During |
|
Parent |
Main event loop |
|
Parent |
During |
|
Child |
Start of |
|
Child |
After resolver preparation |
|
Child |
After subscription |
|
Child |
Event processing loop |
|
Parent |
During shutdown (signals child) |
Safe resource creation¶
Pickle warning:
Managerextendsmultiprocessing.Process. When running under the spawn or forkserver start methods (defaults on macOS, Windows, and Linux since Python 3.14, and also used byWorkerProcesswith a custom Python environment), the Manager object constructed in the parent process is pickled and sent to the child. Any object created in__init__must therefore be pickle-safe or the child will fail to unpickle it. With fork (the default on Linux for Python ≤ 3.13), the child inherits a copy of the parent's memory, so pickle is not involved — but file descriptors, locks, and network connections are still shared in unsafe ways. The safe rule: create heavyweight resources (DB pools, sockets, threads) inafter_start(), not in__init__.
Since multiprocessing uses different start methods depending on platform
and Python version, EDRI does not enforce a specific one. Your application
code must be compatible with fork, forkserver, and spawn:
Platform / Python |
Default start method |
Process object |
Child |
|---|---|---|---|
Linux, Python ≤ 3.13 |
|
Inherited via copy-on-write — no serialisation |
Not called. Child begins at |
Linux, Python 3.14+ |
|
Serialised (pickled) to the server process, then forked |
Not called. Child begins at |
macOS |
|
Serialised (pickled), unpickled in a fresh interpreter |
Not called. Child begins at |
Windows |
|
Serialised (pickled), unpickled in a fresh interpreter |
Not called. Child begins at |
Certain resources must not be created until the child process is running:
Resource |
Safe in |
Safe in |
|---|---|---|
Plain configuration |
Yes |
Not needed |
Logger |
Verify |
Verify |
DB connection |
Typically no |
Yes |
Connection pool |
Typically no |
Yes |
Open socket |
Typically no |
Yes |
Thread |
No |
Yes |
Async client |
Typically no |
Yes |
Pickle-safe dataclass |
Yes |
Not needed |
Lifecycle of a Manager process (diagram)¶
flowchart TD
Start["EDRI.run() called"] --> Router["Router(queue, components)<br/>→ HealthChecker created"]
Router --> CStart["for each component:<br/>component.start(router_queue)<br/>→ multiprocessing.Process.start()"]
subgraph parent["in main process"]
direction TB
Init["Manager.__init__()<br/>(called when EDRI.add_component() or<br/>Manager() is constructed)"]
CStart --> RouterLoop["Router.run()<br/>read queue<br/>dispatch + health checks"]
end
subgraph child["in child process (Manager.run())"]
direction TB
Resolvers["_prepare_resolvers()"] --> Static["_subscribe_static_resolvers()"]
Static --> Sub2["_subscribe()<br/>pipe creation + Subscribe event<br/>→ Router registers subscription"]
Sub2 --> Prepare["_prepare()"]
Prepare --> After["after_start() hook<br/>e.g. create Cache"]
After --> Undelivered["_resolve_undelivered_events()"]
Undelivered --> Loop["run_resolver()<br/>read pipes + resolve"]
end
CStart -.-> Resolvers
RouterLoop -->|KeyboardInterrupt / SIGTERM| Q1["Router.quit()"]
Q1 --> Q2["each component.quit()"]
Q2 --> Q3["Connector.quit() (if started)"]
Q3 --> Q4["Watcher.quit() (if started)"]
Q2 --> Q5["terminate_processes()<br/>→ SIGTERM to all children"]
Data flow for an API request¶
sequenceDiagram
participant C as Client
participant L as API Listener
participant B as Broker
participant R as Router
participant M as Manager
C->>L: HTTP request
Note over L: parse method/path/headers/body<br/>match URL to @api(...) Event<br/>extract fields, run validators (inject)
L->>B: Event (Broker Queue)
Note over B: middleware.process_request(...)
B->>R: Event (Router Queue)
R->>M: dispatch to subscribed Manager
Note over M: execute @request handler<br/>(optionally start Worker)
M->>R: response
R->>B: deliver response
Note over B: middleware.process_response(...)
B->>L: response
Note over L: serialize (JSON / HTML / WS)
L->>C: HTTP response
Component responsibilities¶
Router¶
Maintains a mapping of
Event type → set[Connection](multiprocessing pipes to subscribed Managers).Reads events from the main router queue.
Dispatches each event to all subscribed managers.
If an event is shareable, also sends it through the Connector to the Switch.
Runs a HealthChecker that periodically verifies Manager processes are alive.
Maintains an event cache to survive Switch outages.
Manager¶
Subscribes to event types via
@requestand@responsedecorators.Runs in its own process with its own input queue.
Selects the correct handler method based on event type.
May delegate work to Workers (thread or process).
Sole mediator of Worker traffic. Workers have no direct connection to the Router; every event a Worker emits is read off its pipe by the Manager's resolver loop and may be forwarded to the router queue.
Because all Worker traffic passes through it, the Manager can inspect, tag, modify, drop, or reroute any event before it reaches the rest of the system.
May emit new events back to the Router.
May override
after_start()for one-time initialization.Responses are sent automatically when the handler returns (if
event.responsewas modified).
Worker¶
WorkerThread— runs in the Manager process (shared memory, GIL-bound).WorkerProcess— runs in a subprocess (isolated memory, true parallelism).Receives an event and executes
do().Must call
self.event_send(event)explicitly to send results back.WorkerQuitis emitted automatically whendo()finishes.
API Listener¶
Implements the ASGI protocol.
Supports multiple server backends (uvicorn, hypercorn, daphne).
Parses HTTP requests, WebSocket messages, and serves static assets.
Constructs API Event instances from incoming data.
Runs field validators (
inject(...)).Serializes responses (JSON, HTML, or WebSocket frames).
Broker¶
Sits between the Listener and the Router in a separate child process.
Runs the middleware pipeline (
process_request/process_response) on every event.Manages active WebSocket client subscriptions.
Forwards events that pass middleware to the Router Queue.
Receives responses from the Router and routes them back to the originating Listener socket.
Switch¶
Standalone TCP/IP server.
Maintains a dict of
UUID → Connectionfor connected EDRI instances.Transparently forwards serialized events between EDRI instances.
Preserves queues during disconnection (no event loss on reconnect).
Purely in-memory — no persistence.
Summary¶
EDRI's architecture separates routing (Router), business logic (Manager), execution (Worker), and external communication (API/Connector). This separation allows each concern to scale independently:
Add more API nodes behind a load balancer for HTTP throughput.
Add more Manager processes for parallel event handling.
Add more Workers inside a Manager for concurrent task execution.
Connect multiple EDRI instances through the Switch for geographic distribution.
All communication stays event-driven, making the system loosely coupled and extensible.
Deployment roles and scaling¶
Role |
Replicatable |
Effect of multiple replicas |
Needs leader election |
|---|---|---|---|
API + Broker |
Yes |
Better HTTP throughput behind a load balancer |
No |
Stateless Manager |
No (see note) |
Duplicate event handling — each replica runs every handler |
No |
Stateful / side-effect Manager |
No |
Duplicate side effects — not safe without external coordination |
Yes |
Scheduler |
No |
Duplicate scheduled events — use at most one replica |
Yes |
Store (key-value) |
No |
Inconsistent state — single replica only |
N/A |
Switch |
No |
Single-instance; in-memory only |
N/A |
Stateless Manager note: The Router fans out to all subscribers of a given event type. Two identical Managers will both receive and handle every matching event. This is safe only for idempotent handlers. For competing-consumer semantics, use Workers inside a single Manager.
Singleton operations¶
EDRI has no built-in leader election. For operations that must run exactly once (scheduled jobs, migrations, cleanup, payments, notifications):
Approach |
How |
|---|---|
Run on a single replica |
Use |
External lock |
Use Redis lock, PostgreSQL advisory lock, or similar |
Scheduler on dedicated node |
Run the Scheduler Manager only on one EDRI instance; other instances are workers only |
Idempotent handlers |
Design handlers to tolerate duplicate execution (e.g., use |