Coverage for src/lexigram/web/sse/heartbeat.py: 30%
67 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Shared SSE heartbeat scheduler.
3Instead of one asyncio task per SSE connection, a single ``SSEHeartbeatScheduler``
4task fires heartbeats to all active connections. This keeps the number of live
5asyncio tasks proportional to the number of **distinct heartbeat intervals** used,
6not the number of open connections.
8Usage::
10 from lexigram.web.sse.heartbeat import get_heartbeat_scheduler
12 scheduler = get_heartbeat_scheduler(interval=30.0)
13 await scheduler.start() # once, at app startup (or lazily)
15 token = scheduler.register(handler)
16 # ... stream events ...
17 scheduler.unregister(token)
19The ``SSEResponse`` class integrates this automatically.
20"""
22from __future__ import annotations
24import asyncio
25from typing import Any
27from lexigram.logging import get_logger
28from lexigram.primitives import clock as ambient_clock
30logger = get_logger(__name__)
32# Heartbeat payload — a comment line keeps the connection alive without
33# triggering the client's `message` event listener.
34_HEARTBEAT_PAYLOAD = ": heartbeat\n\n"
37class SSEHeartbeatScheduler:
38 """Single-task heartbeat scheduler for multiple SSE connections.
40 Fires a heartbeat comment to all registered ``SSEBackpressureHandler``
41 instances at the configured ``interval``. Uses one background asyncio
42 task regardless of how many connections are active.
44 Args:
45 interval: Seconds between heartbeat messages (default: 30.0).
46 tick_resolution: How often (in seconds) the scheduler checks whether
47 heartbeats are due. Lower values give more accurate timing at
48 the cost of slightly more CPU work (default: 1.0).
49 """
51 def __init__(
52 self,
53 interval: float = 30.0,
54 tick_resolution: float = 1.0,
55 ) -> None:
56 self.interval = interval
57 self.tick_resolution = tick_resolution
58 # Use a plain set with lock rather than WeakSet so we can track
59 # last-sent times without keeping the handler alive.
60 self._handlers: set[Any] = set()
61 self._last_sent: dict[int, float] = {} # id(handler) → last send time
62 self._lock = asyncio.Lock()
63 self._task: asyncio.Task[None] | None = None
64 self._running = False
66 async def start(self) -> None:
67 """Start the background heartbeat loop (idempotent)."""
68 if self._running:
69 return
70 self._running = True
71 self._task = asyncio.create_task(self._loop(), name="sse-heartbeat-scheduler")
73 async def stop(self) -> None:
74 """Stop the background heartbeat loop."""
75 self._running = False
76 if self._task is not None:
77 self._task.cancel()
78 try:
79 await self._task
80 except asyncio.CancelledError:
81 pass
82 self._task = None
84 async def register(self, handler: Any) -> None:
85 """Register an SSE connection handler for heartbeats.
87 Args:
88 handler: An ``SSEBackpressureHandler`` instance.
89 """
90 async with self._lock:
91 self._handlers.add(handler)
92 self._last_sent[id(handler)] = ambient_clock.monotonic()
93 # Start lazily if not already running
94 if not self._running:
95 await self.start()
97 async def unregister(self, handler: Any) -> None:
98 """Unregister a handler when the connection closes.
100 Args:
101 handler: The previously registered handler.
102 """
103 async with self._lock:
104 self._handlers.discard(handler)
105 self._last_sent.pop(id(handler), None)
107 async def _loop(self) -> None:
108 """Main heartbeat loop — runs until stopped."""
109 while self._running:
110 await asyncio.sleep(self.tick_resolution)
111 now = ambient_clock.monotonic()
112 async with self._lock:
113 handlers_snapshot = list(self._handlers)
115 for handler in handlers_snapshot:
116 hid = id(handler)
117 last = self._last_sent.get(hid, now)
118 if now - last >= self.interval:
119 try:
120 await handler.send("heartbeat", _HEARTBEAT_PAYLOAD)
121 async with self._lock:
122 self._last_sent[hid] = now
123 except (OSError, RuntimeError):
124 # Handler may already be closed; ignore silently.
125 pass
127 @property
128 def active_connections(self) -> int:
129 """Number of SSE connections currently registered."""
130 return len(self._handlers)
133# Module-level cache: interval → scheduler
134_schedulers: dict[float, SSEHeartbeatScheduler] = {}
135_scheduler_lock: asyncio.Lock | None = None
138def get_heartbeat_scheduler(
139 interval: float = 30.0,
140) -> SSEHeartbeatScheduler:
141 """Return (creating if necessary) a shared scheduler for ``interval``.
143 All SSE connections with the same heartbeat interval share one scheduler
144 instance and therefore one background asyncio task.
146 Args:
147 interval: Heartbeat interval in seconds.
149 Returns:
150 The shared ``SSEHeartbeatScheduler`` for this interval.
151 """
152 key = interval
153 if key not in _schedulers:
154 _schedulers[key] = SSEHeartbeatScheduler(interval=interval)
155 return _schedulers[key]
158__all__ = ["SSEHeartbeatScheduler", "get_heartbeat_scheduler"]