| 11 |
11 |
* server → `{"type":"heartbeat","ts":…,"seq":…}` every 20 s; client `{"action":"ping"}` → `{"type":"pong"}`; |
| 12 |
12 |
* each delivered `filing` message is charged 1 row through `stream.accounting.charge(principal, 1)`. |
| 13 |
13 |
|
|
14 |
+Plumbing: the principal comes from the rate-limit middleware (`scope["state"]`, see auth.py); live sockets per |
|
15 |
+principal are a Redis ZSET `stream:conns:{principal}` (member = connection id, score = last heartbeat; members |
|
16 |
+silent for 90 s are pruned, so a worker dying without cleanup never leaves phantom slots); delivery is one |
|
17 |
+blocking `XREAD` per subscribed socket on `filings:stream` (no polling). Every Redis call inside the coroutine |
|
18 |
+goes through the async client; the sync accounting counter runs in a worker thread. |
|
19 |
+ |
| 14 |
20 |
Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 15 |
21 |
""" |
| 16 |
22 |
from __future__ import annotations |
| 19 |
25 |
import json |
| 20 |
26 |
import logging |
| 21 |
27 |
import time |
|
28 |
+import uuid |
| 22 |
29 |
from datetime import datetime, timezone |
| 23 |
30 |
from typing import Any |
| 24 |
31 |
|
| 37 |
44 |
router = APIRouter(tags=["stream"]) |
| 38 |
45 |
|
| 39 |
46 |
HEARTBEAT_SECONDS = 20.0 |
| 40 |
|
−POLL_SECONDS = 0.25 |
| 41 |
47 |
MAX_CONNECTIONS_PER_KEY = 5 |
| 42 |
|
−CONN_TTL_SECONDS = 3600 |
|
48 |
+CONN_TTL_SECONDS = 3600 # safety TTL of the ZSET itself |
|
49 |
+CONN_HEARTBEAT_SECONDS = 30.0 # how often a live socket refreshes its ZSET score |
|
50 |
+CONN_STALE_SECONDS = 90.0 # members older than this are pruned at every count |
|
51 |
+READ_BLOCK_MS = 1000 # XREAD BLOCK per subscribed socket |
| 43 |
52 |
CLOSE_AUTH, CLOSE_LIMIT, CLOSE_PROTOCOL, CLOSE_UNAUTHENTICATED = 4001, 4029, 4400, 4401 |
| 44 |
53 |
CHANNELS = ("filings",) |
| 45 |
54 |
|
| 116 |
125 |
await ws.close(code=CLOSE_AUTH, reason="AUTH_REQUIRED") |
| 117 |
126 |
return |
| 118 |
127 |
log.debug("stream connection %s tier=%s", principal, tier) |
| 119 |
|
− r = broker.get_redis() |
| 120 |
|
− conn_key = f"stream:conns:{principal}" |
| 121 |
|
− n = int(r.incr(conn_key)) |
| 122 |
|
− r.expire(conn_key, CONN_TTL_SECONDS) |
|
128 |
+ ar = broker.get_async_redis() |
|
129 |
+ conn_id = uuid.uuid4().hex |
|
130 |
+ try: |
|
131 |
+ n = await register_connection(ar, principal, conn_id) |
|
132 |
+ except Exception as e: # Redis down: refuse rather than stream unaccounted |
|
133 |
+ log.warning("stream: redis unavailable (%s)", e) |
|
134 |
+ await ws.send_json(_err("INTERNAL_ERROR", "The stream is temporarily unavailable. Please retry.")) |
|
135 |
+ await ws.close(code=CLOSE_PROTOCOL, reason="stream unavailable") |
|
136 |
+ return |
| 123 |
137 |
if n > MAX_CONNECTIONS_PER_KEY: |
| 124 |
|
− r.decr(conn_key) |
|
138 |
+ await release_connection(ar, principal, conn_id) |
| 125 |
139 |
await ws.send_json(_err("STREAM_CONNECTION_LIMIT", CODES["STREAM_CONNECTION_LIMIT"], limit=MAX_CONNECTIONS_PER_KEY)) |
| 126 |
140 |
await ws.close(code=CLOSE_LIMIT, reason="STREAM_CONNECTION_LIMIT") |
| 127 |
141 |
return |
| 128 |
|
− ar = broker.get_async_redis() |
| 129 |
142 |
sub = Subscription() |
| 130 |
|
− last_seq = broker.current_seq() |
|
143 |
+ last_seq = await broker.current_seq_async(ar) |
| 131 |
144 |
inbox: asyncio.Queue = asyncio.Queue() |
|
145 |
+ reader_task: asyncio.Task | None = None |
|
146 |
+ reader_stop: asyncio.Event | None = None |
| 132 |
147 |
|
| 133 |
148 |
async def receiver(): |
| 134 |
149 |
try: |
| 135 |
150 |
while True: |
| 136 |
151 |
msg = await ws.receive_text() |
| 137 |
|
− await inbox.put(msg) |
| 138 |
|
− except WebSocketDisconnect: |
| 139 |
|
− await inbox.put(None) |
| 140 |
|
− except Exception: |
| 141 |
|
− await inbox.put(None) |
|
152 |
+ await inbox.put(("msg", msg)) |
|
153 |
+ except (WebSocketDisconnect, Exception): |
|
154 |
+ await inbox.put(("closed", None)) |
|
155 |
+ |
|
156 |
+ def stop_reader() -> None: |
|
157 |
+ nonlocal reader_task, reader_stop |
|
158 |
+ if reader_stop is not None: |
|
159 |
+ reader_stop.set() |
|
160 |
+ if reader_task is not None: |
|
161 |
+ reader_task.cancel() |
|
162 |
+ reader_task = reader_stop = None |
|
163 |
+ |
|
164 |
+ def restart_reader(from_seq: int) -> None: |
|
165 |
+ nonlocal reader_task, reader_stop |
|
166 |
+ stop_reader() |
|
167 |
+ reader_stop = asyncio.Event() |
|
168 |
+ reader_task = asyncio.create_task(_reader(from_seq, inbox, reader_stop)) |
| 142 |
169 |
|
| 143 |
170 |
recv_task = asyncio.create_task(receiver()) |
| 144 |
171 |
try: |
| 145 |
172 |
await ws.send_json({"type": "hello", "seq": last_seq, "heartbeat_seconds": HEARTBEAT_SECONDS, |
| 146 |
173 |
"max_connections_per_key": MAX_CONNECTIONS_PER_KEY, "buffer": broker.MAXLEN, "ts": _now()}) |
| 147 |
|
− last_beat = time.monotonic() |
|
174 |
+ last_beat = last_touch = time.monotonic() |
| 148 |
175 |
while True: |
|
176 |
+ wait = max(0.0, HEARTBEAT_SECONDS - (time.monotonic() - last_beat)) |
| 149 |
177 |
try: |
| 150 |
|
− raw = await asyncio.wait_for(inbox.get(), timeout=POLL_SECONDS) |
| 151 |
|
− if raw is None: |
| 152 |
|
− break |
| 153 |
|
− seq_box = [last_seq] |
| 154 |
|
− await _handle(ws, raw, sub, ar, principal, seq_box) |
| 155 |
|
− last_seq = seq_box[0] |
|
178 |
+ kind, payload = await asyncio.wait_for(inbox.get(), timeout=wait) |
| 156 |
179 |
except asyncio.TimeoutError: |
| 157 |
|
− pass |
| 158 |
|
− if sub.channel: |
| 159 |
|
− events = await broker.read_since(ar, last_seq) |
| 160 |
|
− for ev in events: |
| 161 |
|
− last_seq = max(last_seq, int(ev.get("seq", last_seq))) |
| 162 |
|
− if sub.matches(ev): |
| 163 |
|
− await ws.send_json(ev) |
| 164 |
|
− accounting.charge(principal, accounting.REDUCED_RATE_ROWS_PER_MESSAGE) |
| 165 |
|
− if time.monotonic() - last_beat >= HEARTBEAT_SECONDS: |
| 166 |
180 |
await ws.send_json({"type": "heartbeat", "ts": _now(), "seq": last_seq}) |
| 167 |
|
− r.expire(conn_key, CONN_TTL_SECONDS) |
| 168 |
181 |
last_beat = time.monotonic() |
|
182 |
+ if last_beat - last_touch >= CONN_HEARTBEAT_SECONDS: |
|
183 |
+ await touch_connection(ar, principal, conn_id) |
|
184 |
+ last_touch = last_beat |
|
185 |
+ continue |
|
186 |
+ if kind == "closed": |
|
187 |
+ break |
|
188 |
+ if kind == "event": |
|
189 |
+ ev = payload |
|
190 |
+ last_seq = max(last_seq, int(ev.get("seq", last_seq))) |
|
191 |
+ if sub.channel and sub.matches(ev): |
|
192 |
+ await ws.send_json(ev) |
|
193 |
+ await asyncio.to_thread(accounting.charge, principal, accounting.REDUCED_RATE_ROWS_PER_MESSAGE) |
|
194 |
+ continue |
|
195 |
+ seq_box = [last_seq] |
|
196 |
+ action = await _handle(ws, payload, sub, seq_box) |
|
197 |
+ if action == "subscribe": |
|
198 |
+ last_seq = seq_box[0] |
|
199 |
+ restart_reader(last_seq) |
|
200 |
+ elif action == "unsubscribe": |
|
201 |
+ stop_reader() |
| 169 |
202 |
except WebSocketDisconnect: |
| 170 |
203 |
pass |
| 171 |
204 |
except Exception as e: # pragma: no cover |
| 172 |
205 |
log.exception("stream error: %s", e) |
| 173 |
206 |
finally: |
| 174 |
207 |
recv_task.cancel() |
|
208 |
+ pending = reader_task |
|
209 |
+ stop_reader() |
|
210 |
+ if pending is not None: |
|
211 |
+ try: |
|
212 |
+ await pending |
|
213 |
+ except (asyncio.CancelledError, Exception): |
|
214 |
+ pass |
| 175 |
215 |
try: |
| 176 |
|
− r.decr(conn_key) |
|
216 |
+ await release_connection(ar, principal, conn_id) |
| 177 |
217 |
except Exception: |
| 178 |
218 |
pass |
| 179 |
219 |
try: |
| 187 |
227 |
pass |
| 188 |
228 |
|
| 189 |
229 |
|
| 190 |
|
−async def _handle(ws: WebSocket, raw: str, sub: Subscription, ar, principal: str, seq_box: list[int]) -> None: |
|
230 |
+# ------------------------------------------------------------------------------------ connection registry |
|
231 |
+def conn_key(principal: str) -> str: |
|
232 |
+ return f"stream:conns:{principal}" |
|
233 |
+ |
|
234 |
+ |
|
235 |
+async def register_connection(ar, principal: str, conn_id: str) -> int: |
|
236 |
+ """Add the socket to the per-principal ZSET (score = heartbeat timestamp), prune members silent for more than |
|
237 |
+ `CONN_STALE_SECONDS` (worker killed without releasing) and return the live count — the 6th gets 4029.""" |
|
238 |
+ key, now = conn_key(principal), time.time() |
|
239 |
+ await ar.zremrangebyscore(key, "-inf", now - CONN_STALE_SECONDS) |
|
240 |
+ await ar.zadd(key, {conn_id: now}) |
|
241 |
+ await ar.expire(key, CONN_TTL_SECONDS) |
|
242 |
+ return int(await ar.zcard(key)) |
|
243 |
+ |
|
244 |
+ |
|
245 |
+async def touch_connection(ar, principal: str, conn_id: str) -> None: |
|
246 |
+ await ar.zadd(key := conn_key(principal), {conn_id: time.time()}) |
|
247 |
+ await ar.expire(key, CONN_TTL_SECONDS) |
|
248 |
+ |
|
249 |
+ |
|
250 |
+async def release_connection(ar, principal: str, conn_id: str) -> None: |
|
251 |
+ await ar.zrem(conn_key(principal), conn_id) |
|
252 |
+ |
|
253 |
+ |
|
254 |
+async def live_connections(ar, principal: str) -> int: |
|
255 |
+ """Live sockets of a principal (stale members pruned) — for tests / the admin console.""" |
|
256 |
+ key = conn_key(principal) |
|
257 |
+ await ar.zremrangebyscore(key, "-inf", time.time() - CONN_STALE_SECONDS) |
|
258 |
+ return int(await ar.zcard(key)) |
|
259 |
+ |
|
260 |
+ |
|
261 |
+# ------------------------------------------------------------------------------------ delivery |
|
262 |
+async def _reader(from_seq: int, inbox: asyncio.Queue, stop: asyncio.Event) -> None: |
|
263 |
+ """Blocking `XREAD` loop (one per subscribed socket, no polling): every entry after `from_seq` is queued |
|
264 |
+ to the connection loop, which filters and delivers. Redis hiccups back off one second and resume. |
|
265 |
+ |
|
266 |
+ The reader owns a dedicated Redis connection, closed with it: a blocking read cancelled mid-flight can leave a |
|
267 |
+ late reply on its connection (fakeredis does, redis-py disconnects), and a shared connection would hand that |
|
268 |
+ reply to the next command — duplicates on resubscribe. `stop` is also checked on every turn.""" |
|
269 |
+ last = int(from_seq) |
|
270 |
+ ar = broker.get_async_redis() |
|
271 |
+ try: |
|
272 |
+ while not stop.is_set(): |
|
273 |
+ try: |
|
274 |
+ events = await broker.read_block(ar, last, block_ms=READ_BLOCK_MS) |
|
275 |
+ except asyncio.CancelledError: |
|
276 |
+ raise |
|
277 |
+ except Exception as e: |
|
278 |
+ if stop.is_set(): |
|
279 |
+ return |
|
280 |
+ log.warning("stream reader: %s", e) |
|
281 |
+ await asyncio.sleep(1.0) |
|
282 |
+ continue |
|
283 |
+ if stop.is_set(): |
|
284 |
+ return |
|
285 |
+ for ev in events: |
|
286 |
+ last = max(last, int(ev.get("seq", last))) |
|
287 |
+ await inbox.put(("event", ev)) |
|
288 |
+ finally: |
|
289 |
+ try: |
|
290 |
+ await ar.aclose() if hasattr(ar, "aclose") else await ar.close() |
|
291 |
+ except Exception: |
|
292 |
+ pass |
|
293 |
+ |
|
294 |
+ |
|
295 |
+async def _handle(ws: WebSocket, raw: str, sub: Subscription, seq_box: list[int]) -> str | None: |
|
296 |
+ """Process one client message; returns the action applied ('subscribe' | 'unsubscribe' | 'ping' | None).""" |
| 191 |
297 |
try: |
| 192 |
298 |
msg = json.loads(raw) |
| 193 |
299 |
except json.JSONDecodeError: |
| 194 |
300 |
await ws.send_json(_err("VALIDATION_ERROR", "messages must be JSON objects")) |
| 195 |
|
− return |
|
301 |
+ return None |
| 196 |
302 |
if not isinstance(msg, dict): |
| 197 |
303 |
await ws.send_json(_err("VALIDATION_ERROR", "messages must be JSON objects")) |
| 198 |
|
− return |
|
304 |
+ return None |
| 199 |
305 |
action = str(msg.get("action") or msg.get("type") or "").lower() |
| 200 |
306 |
if action == "ping": |
| 201 |
307 |
await ws.send_json({"type": "pong", "ts": _now(), "seq": seq_box[0]}) |
| 202 |
|
− return |
|
308 |
+ return "ping" |
| 203 |
309 |
if action == "unsubscribe": |
| 204 |
310 |
sub.channel = None |
| 205 |
311 |
await ws.send_json({"type": "unsubscribed", "ts": _now()}) |
| 206 |
|
− return |
|
312 |
+ return "unsubscribe" |
| 207 |
313 |
if action != "subscribe": |
| 208 |
314 |
await ws.send_json(_err("VALIDATION_ERROR", f"unknown action '{action}' (subscribe, unsubscribe, ping)")) |
| 209 |
|
− return |
|
315 |
+ return None |
| 210 |
316 |
channel = str(msg.get("channel") or "filings").lower() |
| 211 |
317 |
if channel not in CHANNELS: |
| 212 |
318 |
await ws.send_json(_err("NOT_FOUND", f"unknown channel '{channel}'", channels=list(CHANNELS))) |
| 213 |
|
− return |
|
319 |
+ return None |
| 214 |
320 |
tickers = msg.get("tickers", "all") |
| 215 |
321 |
forms = msg.get("forms") |
| 216 |
322 |
if isinstance(tickers, str) and tickers.lower() == "all": |
| 219 |
325 |
sub.tickers = {t.upper() for t in tickers} |
| 220 |
326 |
else: |
| 221 |
327 |
await ws.send_json(_err("VALIDATION_ERROR", "tickers must be a non-empty list of strings or \"all\"")) |
| 222 |
|
− return |
|
328 |
+ return None |
| 223 |
329 |
if forms in (None, "all"): |
| 224 |
330 |
sub.forms = None |
| 225 |
331 |
elif isinstance(forms, list) and all(isinstance(f, str) for f in forms) and forms: |
| 226 |
332 |
sub.forms = {f.upper() for f in forms} |
| 227 |
333 |
else: |
| 228 |
334 |
await ws.send_json(_err("VALIDATION_ERROR", "forms must be a non-empty list of strings or omitted")) |
| 229 |
|
− return |
| 230 |
|
− sub.channel = channel |
|
335 |
+ return None |
| 231 |
336 |
resume = msg.get("resume_token") |
| 232 |
337 |
if resume is not None: |
| 233 |
338 |
try: |
| 234 |
339 |
seq_box[0] = int(resume) |
| 235 |
340 |
except (TypeError, ValueError): |
| 236 |
341 |
await ws.send_json(_err("VALIDATION_ERROR", "resume_token must be an integer (last seq received)")) |
| 237 |
|
− return |
|
342 |
+ return None |
|
343 |
+ sub.channel = channel |
| 238 |
344 |
await ws.send_json({"type": "subscribed", "channel": channel, "tickers": sorted(sub.tickers) if sub.tickers else "all", |
| 239 |
345 |
"forms": sorted(sub.forms) if sub.forms else "all", "resume_from": seq_box[0], "ts": _now()}) |
|
346 |
+ return "subscribe" |