Skip to content

Commit 0e15df4

Browse files
author
Neo
committed
feat: batch dispatch + SSE event stream + release CLI wrapper
Add POST /api/v1/batch and GET /api/v1/events/stream to finish the mobile-facing gateway contract the MTRX iOS packager expects. gateway/event_broadcaster.py New in-process pub/sub hub with per-subscriber bounded queues, drop-oldest backpressure, and keep-alive tick support. Lossy for slow clients, lossless for fast ones. gateway/service_routes.py - _handle_batch: parses the Swift BatchRequestEnvelope, caps items at BATCH_MAX_ITEMS (25), dispatches sequentially or in parallel with a 20s per-item timeout, aborts cleanly on failure, publishes a batch.completed event to the broadcaster on completion. - _BatchSubRequest + _resolve_batch_route: synthetic aiohttp Request objects so individual items can re-enter the existing route handlers without a second HTTP hop. Regex-compiled route map mirrors all 41 public /api/v1/* routes including {param} paths. - _handle_event_stream: StreamResponse-backed SSE endpoint with a stream.opened hello event, 15s keep-alive comments, component + session + type filters, and clean unregister in finally. - _format_sse, _parse_int_csv, _parse_str_csv helpers. gateway/server.py Expose broadcaster on the GatewayServer so the runtime can push events from anywhere. tests/test_batch_and_events.py 27 new tests covering: - broadcaster fan-out, filters, queue overflow, keep-alive - SSE framing, CSV parser edge cases - batch route resolution (literal, path params, unknown, method) - end-to-end batch (empty, missing key, overflow, parallel, abort, unknown path, path-param route, missing required field, event) - SSE endpoint (hello event, component filter) - _BatchSubRequest sanity Full suite is 292 -> 319 tests, all green. scripts/prepare_release.py Standalone release-prep scanner for this repo. Vendored from the OpenMatrix matrix.cli.prepare_release module so operators can run scan/export/manifest without the iOS checkout. Takes --workspace to target another repo; defaults to 0pnMatrx root.
1 parent daea476 commit 0e15df4

5 files changed

Lines changed: 1600 additions & 3 deletions

File tree

gateway/event_broadcaster.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
"""
2+
gateway/event_broadcaster.py
3+
============================
4+
5+
In-process pub/sub fan-out for the `/api/v1/events/stream` SSE endpoint.
6+
7+
The ``EventBroadcaster`` is intentionally tiny and has **no persistence**
8+
— it's purely a live feed for connected SSE subscribers. Durable events
9+
still go through ``hivemind.events.EventBus``; the broadcaster is the
10+
push side of the house.
11+
12+
Design notes
13+
------------
14+
15+
* Each connected SSE client gets its own ``asyncio.Queue`` (bounded, to
16+
prevent a slow mobile client from pinning gateway memory).
17+
* Publishing is **lossless by default** for fast clients and
18+
**lossy for slow clients** — if a subscriber's queue is full we drop
19+
the oldest event (``QueueFull`` → ``get_nowait``/``put_nowait`` dance)
20+
and increment a dropped counter so operators can see abuse.
21+
* ``publish`` is a synchronous, non-blocking call that can be invoked
22+
from any async context (including middleware and service handlers).
23+
* ``subscribe`` returns a ``Subscription`` context manager that yields
24+
events as an async iterator.
25+
26+
The Packager's SSE parser (see ``MTRXPackager.handleSSEMessage``)
27+
expects each server-sent event to carry a JSON payload with at least
28+
``type`` and ``payload`` fields. This module guarantees that shape.
29+
"""
30+
31+
from __future__ import annotations
32+
33+
import asyncio
34+
import logging
35+
import time
36+
import uuid
37+
from dataclasses import dataclass, field
38+
from typing import Any, AsyncIterator, Dict, Iterable, List, Optional
39+
40+
logger = logging.getLogger(__name__)
41+
42+
43+
# ---------------------------------------------------------------------------
44+
# Public event shape
45+
# ---------------------------------------------------------------------------
46+
47+
48+
@dataclass(frozen=True)
49+
class BroadcastEvent:
50+
"""A single event pushed to every matching SSE subscriber."""
51+
52+
type: str
53+
payload: Dict[str, Any] = field(default_factory=dict)
54+
#: Optional component id — used to filter for clients that only
55+
#: care about one service (e.g. ``?components=3,13``).
56+
component: Optional[int] = None
57+
#: Optional session id — lets clients scope the feed to their own
58+
#: session (``?session=<id>``).
59+
session_id: Optional[str] = None
60+
timestamp: float = field(default_factory=time.time)
61+
event_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
62+
63+
def to_dict(self) -> Dict[str, Any]:
64+
return {
65+
"type": self.type,
66+
"payload": self.payload,
67+
"component": self.component,
68+
"session_id": self.session_id,
69+
"timestamp": self.timestamp,
70+
"event_id": self.event_id,
71+
}
72+
73+
74+
# ---------------------------------------------------------------------------
75+
# Per-subscriber state
76+
# ---------------------------------------------------------------------------
77+
78+
79+
class _Subscriber:
80+
"""One connected SSE client."""
81+
82+
__slots__ = (
83+
"queue",
84+
"components",
85+
"session_id",
86+
"types",
87+
"dropped",
88+
"created_at",
89+
)
90+
91+
def __init__(
92+
self,
93+
*,
94+
max_queue: int,
95+
components: Optional[Iterable[int]],
96+
session_id: Optional[str],
97+
types: Optional[Iterable[str]],
98+
) -> None:
99+
self.queue: asyncio.Queue[BroadcastEvent] = asyncio.Queue(maxsize=max_queue)
100+
self.components = set(components) if components else None
101+
self.session_id = session_id
102+
self.types = set(types) if types else None
103+
self.dropped = 0
104+
self.created_at = time.time()
105+
106+
def matches(self, event: BroadcastEvent) -> bool:
107+
if self.types is not None and event.type not in self.types:
108+
return False
109+
if self.components is not None:
110+
if event.component is None or event.component not in self.components:
111+
return False
112+
if self.session_id is not None:
113+
if event.session_id is not None and event.session_id != self.session_id:
114+
return False
115+
return True
116+
117+
def try_enqueue(self, event: BroadcastEvent) -> None:
118+
"""Non-blocking enqueue; drops the oldest on overflow."""
119+
120+
try:
121+
self.queue.put_nowait(event)
122+
except asyncio.QueueFull:
123+
try:
124+
_ = self.queue.get_nowait()
125+
self.queue.task_done()
126+
except Exception: # pragma: no cover — best effort
127+
pass
128+
try:
129+
self.queue.put_nowait(event)
130+
except asyncio.QueueFull: # pragma: no cover
131+
self.dropped += 1
132+
return
133+
self.dropped += 1
134+
135+
136+
# ---------------------------------------------------------------------------
137+
# The broadcaster itself
138+
# ---------------------------------------------------------------------------
139+
140+
141+
class EventBroadcaster:
142+
"""Fan-out hub used by the SSE endpoint and anyone who wants to push."""
143+
144+
def __init__(self, *, max_queue_per_subscriber: int = 256) -> None:
145+
self._subs: List[_Subscriber] = []
146+
self._lock = asyncio.Lock()
147+
self._max_queue = max_queue_per_subscriber
148+
self._published = 0
149+
150+
# -- subscription lifecycle -----------------------------------------
151+
152+
async def register(
153+
self,
154+
*,
155+
components: Optional[Iterable[int]] = None,
156+
session_id: Optional[str] = None,
157+
types: Optional[Iterable[str]] = None,
158+
) -> _Subscriber:
159+
sub = _Subscriber(
160+
max_queue=self._max_queue,
161+
components=components,
162+
session_id=session_id,
163+
types=types,
164+
)
165+
async with self._lock:
166+
self._subs.append(sub)
167+
logger.debug(
168+
"SSE subscriber registered (total=%d, components=%s, session=%s)",
169+
len(self._subs),
170+
components,
171+
session_id,
172+
)
173+
return sub
174+
175+
async def unregister(self, sub: _Subscriber) -> None:
176+
async with self._lock:
177+
try:
178+
self._subs.remove(sub)
179+
except ValueError:
180+
pass
181+
logger.debug("SSE subscriber gone (remaining=%d, dropped=%d)",
182+
len(self._subs), sub.dropped)
183+
184+
async def iter_events(
185+
self,
186+
sub: _Subscriber,
187+
*,
188+
keepalive_interval: float = 15.0,
189+
) -> AsyncIterator[Optional[BroadcastEvent]]:
190+
"""Yield events for *sub* until cancelled.
191+
192+
``None`` is yielded every ``keepalive_interval`` seconds so the
193+
caller can emit an SSE comment line and keep the TCP connection
194+
warm through NAT / load-balancer idle timeouts.
195+
"""
196+
197+
while True:
198+
try:
199+
event = await asyncio.wait_for(sub.queue.get(), keepalive_interval)
200+
except asyncio.TimeoutError:
201+
yield None
202+
continue
203+
except asyncio.CancelledError:
204+
return
205+
yield event
206+
207+
# -- publish --------------------------------------------------------
208+
209+
def publish(self, event: BroadcastEvent) -> int:
210+
"""Fan *event* out to every matching subscriber.
211+
212+
Safe to call from any async context. Returns the number of
213+
subscribers the event was delivered to.
214+
"""
215+
216+
self._published += 1
217+
delivered = 0
218+
# Snapshot the subscriber list so we don't hold the lock while
219+
# iterating. Registrations during publish are fine — newcomers
220+
# just get later events.
221+
for sub in list(self._subs):
222+
if sub.matches(event):
223+
sub.try_enqueue(event)
224+
delivered += 1
225+
return delivered
226+
227+
def publish_dict(
228+
self,
229+
type_: str,
230+
payload: Dict[str, Any],
231+
*,
232+
component: Optional[int] = None,
233+
session_id: Optional[str] = None,
234+
) -> int:
235+
return self.publish(
236+
BroadcastEvent(
237+
type=type_,
238+
payload=payload,
239+
component=component,
240+
session_id=session_id,
241+
)
242+
)
243+
244+
# -- introspection --------------------------------------------------
245+
246+
def snapshot(self) -> Dict[str, Any]:
247+
return {
248+
"subscribers": len(self._subs),
249+
"published_total": self._published,
250+
"dropped_total": sum(s.dropped for s in self._subs),
251+
"max_queue_per_subscriber": self._max_queue,
252+
}

gateway/server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,10 @@ def create_app(self) -> web.Application:
931931
from gateway.service_routes import ServiceRoutes
932932
service_routes = ServiceRoutes(self.config)
933933
service_routes.register_routes(app)
934+
# Expose the broadcaster so other subsystems (bridge,
935+
# service dispatchers, metrics) can push live events into
936+
# /api/v1/events/stream.
937+
self.event_broadcaster = service_routes.broadcaster
934938
logger.info("Service routes registered successfully.")
935939
except Exception as e:
936940
logger.warning("Service routes registration skipped: %s", e)

0 commit comments

Comments
 (0)