# 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:
1. **Events as universal primitives** — All communication happens through typed Event objects. Components never call each other directly.
2. **Dispatch by type** — The Router delivers events based on their Python type, not their content. This keeps routing simple and predictable.
3. **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:
```mermaid
flowchart TD
subgraph Main["Main Process (Router)"]
direction TB
RQ["Router Queue
+ 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 in `Manager.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.Queue` or `multiprocessing.Connection` (pipes), with events serialized via `pickle`.
---
## 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
```mermaid
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)
```mermaid
flowchart TD
P["Producer
(Manager / API / Worker)"] -->|"put(Event) on Router Queue"| R[Router]
R -->|"by Event type
to subscribers"| M[Manager]
M -->|"execute @request
or @response"| H{Handler}
H -. "optional: workers.start(Event, WorkerClass)" .-> W["Worker.do()
event_send(Event)
resolve_callback_worker()"]
H -. "optional: router_queue.put(new_event)" .-> R
H -->|"returns → response auto-sent"| R
```
### Distributed flow (with Switch)
```mermaid
flowchart TD
MA["Manager A
(Node 1)
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 |
|-----------|---------|------|
| `Manager.__init__()` | Parent | During `EDRI.add_component()` |
| `Router` construction | Parent | During `EDRI.run()` |
| `Router.run()` | Parent | Main event loop |
| `Manager.start()` → child process | Parent | During `EDRI.run()` |
| `Manager._prepare_resolvers()` | Child | Start of `Manager.run()` |
| `Manager._subscribe()` | Child | After resolver preparation |
| `Manager.after_start()` | Child | After subscription |
| `Manager.run_resolver()` | Child | Event processing loop |
| `Manager.quit()` | Parent | During shutdown (signals child) |
### Safe resource creation
> **Pickle warning:** `Manager` extends `multiprocessing.Process`. When running under the **spawn** or **forkserver** start methods (defaults on macOS, Windows, and Linux since Python 3.14, and also used by `WorkerProcess` with 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) in `after_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 `__init__` |
|-------------------|---------------------|----------------|-----------------|
| Linux, Python ≤ 3.13 | ``fork`` | Inherited via copy-on-write — no serialisation | Not called. Child begins at ``run()`` |
| Linux, Python 3.14+ | ``forkserver`` | Serialised (pickled) to the server process, then forked | Not called. Child begins at ``run()`` |
| macOS | ``spawn`` | Serialised (pickled), unpickled in a fresh interpreter | Not called. Child begins at ``run()`` |
| Windows | ``spawn`` | Serialised (pickled), unpickled in a fresh interpreter | Not called. Child begins at ``run()`` |
Certain resources must not be created until the child process is running:
| Resource | Safe in `__init__` (parent) | Safe in `after_start()` (child) |
|----------|:---------------------------:|:-------------------------------:|
| 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)
```mermaid
flowchart TD
Start["EDRI.run() called"] --> Router["Router(queue, components)
→ HealthChecker created"]
Router --> CStart["for each component:
component.start(router_queue)
→ multiprocessing.Process.start()"]
subgraph parent["in main process"]
direction TB
Init["Manager.__init__()
(called when EDRI.add_component() or
Manager() is constructed)"]
CStart --> RouterLoop["Router.run()
read queue
dispatch + health checks"]
end
subgraph child["in child process (Manager.run())"]
direction TB
Resolvers["_prepare_resolvers()"] --> Static["_subscribe_static_resolvers()"]
Static --> Sub2["_subscribe()
pipe creation + Subscribe event
→ Router registers subscription"]
Sub2 --> Prepare["_prepare()"]
Prepare --> After["after_start() hook
e.g. create Cache"]
After --> Undelivered["_resolve_undelivered_events()"]
Undelivered --> Loop["run_resolver()
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()
→ SIGTERM to all children"]
```
---
## Data flow for an API request
```mermaid
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
match URL to @api(...) Event
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
(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 `@request` and `@response` decorators.
* 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.response` was 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.
* `WorkerQuit` is emitted automatically when `do()` 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 → Connection` for 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)=
### 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 `replicas: 1` in Kubernetes, or run a single EDRI instance |
| 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 `INSERT ... ON CONFLICT DO NOTHING`)
## See also
* {doc}`/event` — defining events and responses
* {doc}`/router` — routing, subscriptions, caching
* {doc}`/manager` — manager lifecycle, workers, caching
* {doc}`/worker` — thread vs process workers
* {doc}`/connector` — connecting to the Switch
* {doc}`/switch` — the distributed router hub
* {doc}`/api` — REST, WebSocket, HTML API layer