|
| 1 | +# Server Event Injection — End-to-End Design & Rationale |
| 2 | + |
| 3 | +This document explains how **server-level events** (planned outages) are modeled and executed across all layers of the simulation stack. It complements the Edge Event Injection design. |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +## 1) Goals |
| 8 | + |
| 9 | +* Hide outage semantics from the load balancer algorithms: **they see only the current set of edges**. |
| 10 | +* Keep **runtime cost O(1)** per transition (down/up). |
| 11 | +* Preserve determinism and fairness when servers rejoin. |
| 12 | +* Centralize event logic; avoid per-server coroutines and ad-hoc flags. |
| 13 | + |
| 14 | +--- |
| 15 | + |
| 16 | +## 2) Participants (layers) |
| 17 | + |
| 18 | +* **Schema / Validation (Pydantic)**: validates `EventInjection` objects (pairing, order, target existence). |
| 19 | +* **SimulationRunner**: builds runtimes; owns the **single shared** `OrderedDict[str, EdgeRuntime]` used by the LB (`_lb_out_edges`). |
| 20 | +* **EventInjectionRuntime**: central event engine; builds the **server timeline** and a **reverse index** `server_id → (edge_id, EdgeRuntime)`; mutates `_lb_out_edges` at runtime. |
| 21 | +* **LoadBalancerRuntime**: reads `_lb_out_edges` to select the next edge (RR / least-connections). **No outage logic inside.** |
| 22 | +* **EdgeRuntime (LB→Server edges)**: unaffected by server outages; disappears from the LB’s choice set while the server is down. |
| 23 | +* **ServerRuntime**: unaffected structurally; no extra checks for “am I down?”. |
| 24 | +* **SimPy Environment**: schedules the central outage coroutine. |
| 25 | +* **Metric Collector**: optional; observes effects but is not part of the mechanism. |
| 26 | + |
| 27 | +--- |
| 28 | + |
| 29 | +## 3) Data & Structures |
| 30 | + |
| 31 | +* **`_lb_out_edges: OrderedDict[str, EdgeRuntime]`** |
| 32 | + Single shared map of **currently routable** LB→server edges. |
| 33 | + |
| 34 | + * Removal/Insertion/Move are **O(1)**. |
| 35 | + * Aliased into both `LoadBalancerRuntime` and `EventInjectionRuntime`. |
| 36 | + |
| 37 | +* **`_servers_timeline: list[tuple[time, event_id, server_id, mark]]`** |
| 38 | + Absolute timestamps, sorted by `(time, mark == start, event_id, server_id)` so **END precedes START** when equal. |
| 39 | + |
| 40 | +* **`_edge_by_server: dict[str, tuple[str, EdgeRuntime]]`** |
| 41 | + Reverse index built from `_lb_out_edges` at initialization. |
| 42 | + |
| 43 | +--- |
| 44 | + |
| 45 | +## 4) Build-time Responsibilities |
| 46 | + |
| 47 | +* **SimulationRunner** |
| 48 | + |
| 49 | + 1. Build LB and pass it `_lb_out_edges` (empty at first). |
| 50 | + 2. Build edges; when wiring LB→Server, insert that edge into `_lb_out_edges`. |
| 51 | + 3. Build `EventInjectionRuntime`, passing: |
| 52 | + |
| 53 | + * validated `events` |
| 54 | + * `servers` and `edges` (IDs for sanity checks) |
| 55 | + * aliased `_lb_out_edges` |
| 56 | + |
| 57 | +* **EventInjectionRuntime.**init**** |
| 58 | + |
| 59 | + * Partition events; construct ` _servers_timeline`. |
| 60 | + * Sort timeline (END before START at equal `time`). |
| 61 | + * Build ` _edge_by_server` by scanning `_lb_out_edges` (edge target → server\_id). |
| 62 | + |
| 63 | +--- |
| 64 | + |
| 65 | +## 5) Run-time Responsibilities |
| 66 | + |
| 67 | +* **EventInjectionRuntime.\_assign\_server\_state()** |
| 68 | + |
| 69 | + * Iterate the server timeline with absolute→relative waits: `dt = t_event − last_t`, then `yield env.timeout(dt)`. |
| 70 | + * On `SERVER_DOWN` (START): |
| 71 | + `lb_out_edges.pop(edge_id, None)` |
| 72 | + * On `SERVER_UP` (END): |
| 73 | + |
| 74 | + ``` |
| 75 | + lb_out_edges[edge_id] = edge_runtime |
| 76 | + lb_out_edges.move_to_end(edge_id) # fairness on rejoin |
| 77 | + ``` |
| 78 | +
|
| 79 | +* **LoadBalancerRuntime** |
| 80 | +
|
| 81 | + * For each request, read `_lb_out_edges` and apply the chosen algorithm. If a server is down, its edge simply **isn’t there**. |
| 82 | +
|
| 83 | +* **EdgeRuntime & ServerRuntime** |
| 84 | +
|
| 85 | + * No additional work: outage is reflected entirely by presence/absence of the LB→server edge. |
| 86 | +
|
| 87 | +--- |
| 88 | +
|
| 89 | +## 6) Sequence Overview (all layers) |
| 90 | +
|
| 91 | +``` |
| 92 | +User YAML ──► Schema/Validation |
| 93 | + │ (pairing, ordering, target checks) |
| 94 | + ▼ |
| 95 | + SimulationRunner |
| 96 | + │ _lb_out_edges: OrderedDict[...] (shared object) |
| 97 | + │ build LB, edges (LB→S inserted into _lb_out_edges) |
| 98 | + │ build EventInjectionRuntime(..., lb_out_edges=alias) |
| 99 | + │ |
| 100 | + ├─ _start_events() |
| 101 | + │ └─ EventInjectionRuntime.start() |
| 102 | + │ └─ start _assign_server_state() (SimPy proc) |
| 103 | + │ |
| 104 | + ├─ _start_all_processes() |
| 105 | + │ ├─ LoadBalancerRuntime.start() |
| 106 | + │ ├─ EdgeRuntime.start() (if any process) |
| 107 | + │ └─ ServerRuntime.start() |
| 108 | + │ |
| 109 | + └─ env.run(until=T) |
| 110 | + |
| 111 | +Runtime progression (example): |
| 112 | +t=5s EventInjectionRuntime: SERVER_DOWN(S1) |
| 113 | + └─ _edge_by_server[S1] -> (edge-S1, edge_rt) |
| 114 | + └─ _lb_out_edges.pop("edge-S1") # O(1) |
| 115 | + |
| 116 | +t=7s LoadBalancerRuntime picks next edge |
| 117 | + └─ "edge-S1" not present → never selected |
| 118 | + |
| 119 | +t=10s EventInjectionRuntime: SERVER_UP(S1) |
| 120 | + └─ _lb_out_edges["edge-S1"] = edge_rt # O(1) |
| 121 | + └─ _lb_out_edges.move_to_end("edge-S1") # fairness |
| 122 | + |
| 123 | +t>10s LoadBalancerRuntime now sees edge-S1 again |
| 124 | + └─ RR/LC proceeds as usual |
| 125 | +``` |
| 126 | +
|
| 127 | +--- |
| 128 | +
|
| 129 | +## 7) Correctness & Determinism |
| 130 | +
|
| 131 | +* **Exact timing**: absolute→relative conversion ensures transitions happen at precise timestamps. |
| 132 | +* **END before START** at identical times prevents spuriously “stuck down” outcomes for back-to-back events. |
| 133 | +* **Fair rejoin**: `move_to_end` reintroduces the server in a predictable RR position (least recently used). |
| 134 | + (Least-connections remains deterministic because the edge reappears with its current connection count.) |
| 135 | +* **Availability constraint**: schema can enforce “at least one server up,” avoiding degenerate LB states. |
| 136 | +
|
| 137 | +--- |
| 138 | +
|
| 139 | +## 8) Design Choices & Rationale |
| 140 | +
|
| 141 | +* **Mutate the edge set, not the algorithm** |
| 142 | + Removing/adding the LB→server edge keeps LB code **pure** and reusable; no conditional branches for “down servers”. |
| 143 | +* **Single shared `OrderedDict`** |
| 144 | +
|
| 145 | + * O(1) for remove/insert/rotate. |
| 146 | + * Aliasing between LB and injector removes the need for signaling or copies. |
| 147 | +* **Centralized coroutine** |
| 148 | + One SimPy process for server outages scales better than per-server processes; simpler mental model. |
| 149 | +* **Reverse index `server_id → edge`** |
| 150 | + Constant-time resolution; avoids coupling servers to LB or vice-versa. |
| 151 | +
|
| 152 | +--- |
| 153 | +
|
| 154 | +## 9) Performance |
| 155 | +
|
| 156 | +* **Build**: |
| 157 | +
|
| 158 | + * Timeline construction: O(#server-events) |
| 159 | + * Sort: O(#server-events · log #server-events) |
| 160 | +* **Run**: |
| 161 | +
|
| 162 | + * Each transition: O(1) (pop/set/move) |
| 163 | + * LB pick: unchanged (RR O(1), LC O(n)) |
| 164 | +* **Space**: |
| 165 | +
|
| 166 | + * Reverse index: O(#servers with LB edges) |
| 167 | + * Timeline: O(#server-events) |
| 168 | +
|
| 169 | +--- |
| 170 | +
|
| 171 | +## 10) Failure Modes & Guards |
| 172 | +
|
| 173 | +* Unknown server in an event → rejected by schema (or ignored with a log if you prefer leniency). |
| 174 | +* Concurrent DOWN/UP at same timestamp → resolved by timeline ordering (END first). |
| 175 | +* All servers down → disallowed by schema (or handled by LB guard if you opt in later). |
| 176 | +* Missing reverse mapping (no LB) → injector safely no-ops. |
| 177 | +
|
| 178 | +--- |
| 179 | +
|
| 180 | +## 11) Extensibility |
| 181 | +
|
| 182 | +* **Multiple LB instances**: make the reverse index `(lb_id, server_id) → edge_id`, or pass per-LB `lb_out_edges`. |
| 183 | +* **Partial capacity**: instead of removing edges, attach capacity/weight and have the LB respect it (requires extending LB policy). |
| 184 | +* **Dynamic scale-out**: adding new servers at runtime is the same operation as “UP” with a previously unseen edge. |
| 185 | +
|
| 186 | +--- |
| 187 | +
|
| 188 | +## 12) Operational Notes |
| 189 | +
|
| 190 | +* Start the **event coroutine** before LB to avoid off-by-one delivery at `t_start`. |
| 191 | +* Keep `_lb_out_edges` the **only source of truth** for routable edges. |
| 192 | +* If you also use edge-level spikes, both coroutines can run concurrently; they are independent. |
| 193 | +
|
| 194 | +--- |
| 195 | +
|
| 196 | +## 13) Summary |
| 197 | +
|
| 198 | +We model server outages by **mutating the LB’s live edge set** via a centralized event runtime: |
| 199 | +
|
| 200 | +* **O(1)** down/up transitions by `pop`/`set` on a shared `OrderedDict`. |
| 201 | +* LB algorithms remain untouched and deterministic. |
| 202 | +* A single SimPy coroutine drives the timeline; a reverse index resolves targets in constant time. |
| 203 | +* The design is minimal, performant, and easy to extend to richer failure models. |
0 commit comments