Coverage for src/lexigram/graphql/subscriptions/manager.py: 49%
89 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"""Subscription Manager for GraphQL subscriptions.
3Manages active subscription streams per connection.
4"""
6from __future__ import annotations
8import asyncio
9import contextlib
10from typing import TYPE_CHECKING, Any
12from lexigram.logging import get_logger
14if TYPE_CHECKING:
15 from collections.abc import AsyncGenerator
17logger = get_logger(__name__)
20class SubscriptionManager:
21 """Manages active subscription streams per connection.
23 Tracks subscriptions per connection and provides lifecycle management.
24 Supports optional keepalive pings to prevent load-balancer idle timeouts
25 on long-lived WebSocket connections.
26 """
28 def __init__(self, keepalive_interval: float = 25.0) -> None:
29 # connection_id -> {subscription_id -> task}
30 self._subscriptions: dict[str, dict[str, asyncio.Task]] = {}
31 # connection_id -> {subscription_id -> AsyncIterator}
32 self._streams: dict[str, dict[str, AsyncGenerator]] = {}
33 # connection_id -> keepalive task
34 self._keepalive_tasks: dict[str, asyncio.Task] = {}
35 self._keepalive_interval = keepalive_interval
37 async def subscribe(
38 self,
39 connection_id: str,
40 subscription_id: str,
41 stream: AsyncGenerator,
42 ) -> None:
43 """Register a new subscription.
45 Args:
46 connection_id: The WebSocket connection ID.
47 subscription_id: The unique subscription ID.
48 stream: The async generator yielding subscription results.
49 """
50 if connection_id not in self._subscriptions:
51 self._subscriptions[connection_id] = {}
52 self._streams[connection_id] = {}
54 # Create task to run the stream
55 async def run_stream() -> None:
56 try:
57 async for _ in stream:
58 pass # Results are consumed; clients pull from _streams directly
59 except asyncio.CancelledError:
60 logger.debug("Subscription %s cancelled", subscription_id)
61 raise
62 except Exception as _sub_err: # noqa: BLE001 — subscription stream workers must log any error before re-raising
63 logger.exception("Error in subscription %s", subscription_id)
64 raise
66 task: asyncio.Task[None] = asyncio.create_task(run_stream())
67 self._subscriptions[connection_id][subscription_id] = task
68 self._streams[connection_id][subscription_id] = stream
70 logger.debug(
71 "Subscription %s started for connection %s", subscription_id, connection_id
72 )
74 async def unsubscribe(
75 self,
76 connection_id: str,
77 subscription_id: str,
78 ) -> None:
79 """Unsubscribe from a specific subscription.
81 Args:
82 connection_id: The WebSocket connection ID.
83 subscription_id: The subscription ID to cancel.
84 """
85 if connection_id not in self._subscriptions:
86 return
88 if subscription_id in self._subscriptions[connection_id]:
89 task = self._subscriptions[connection_id][subscription_id]
90 task.cancel()
92 with contextlib.suppress(asyncio.CancelledError):
93 await task
95 del self._subscriptions[connection_id][subscription_id]
97 if subscription_id in self._streams[connection_id]:
98 del self._streams[connection_id][subscription_id]
100 logger.debug(
101 "Subscription %s stopped for connection %s",
102 subscription_id,
103 connection_id,
104 )
106 # Clean up empty connections
107 if not self._subscriptions[connection_id]:
108 del self._subscriptions[connection_id]
109 if connection_id in self._streams:
110 del self._streams[connection_id]
112 async def disconnect(self, connection_id: str) -> None:
113 """Clean up all subscriptions and keepalive for a connection.
115 Args:
116 connection_id: The WebSocket connection ID to disconnect.
117 """
118 await self.stop_keepalive(connection_id)
120 if connection_id not in self._subscriptions:
121 return
123 # Cancel all subscriptions
124 for subscription_id in list(self._subscriptions[connection_id].keys()):
125 await self.unsubscribe(connection_id, subscription_id)
127 logger.debug("All subscriptions cleaned up for connection %s", connection_id)
129 async def start_keepalive(
130 self,
131 connection_id: str,
132 send_ping: Any,
133 ) -> None:
134 """Start a keepalive loop for a WebSocket connection.
136 Sends a ping every ``keepalive_interval`` seconds so load-balancer
137 idle-connection timeouts do not silently drop long-lived subscriptions.
139 Args:
140 connection_id: The WebSocket connection ID.
141 send_ping: Async callable that sends the keepalive message to the
142 client (e.g., a WebSocket ``send_json`` partial). It will be
143 called with no arguments on each tick.
144 """
145 if connection_id in self._keepalive_tasks:
146 return # already running
148 task: asyncio.Task[None] = asyncio.create_task(
149 self._keepalive_loop(connection_id, send_ping),
150 name=f"keepalive:{connection_id}",
151 )
152 self._keepalive_tasks[connection_id] = task
153 task.add_done_callback(
154 lambda _t: self._keepalive_tasks.pop(connection_id, None)
155 )
156 logger.debug("Keepalive started for connection %s", connection_id)
158 async def stop_keepalive(self, connection_id: str) -> None:
159 """Cancel the keepalive loop for a connection.
161 Args:
162 connection_id: The WebSocket connection ID.
163 """
164 task = self._keepalive_tasks.pop(connection_id, None)
165 if task is not None:
166 task.cancel()
167 with contextlib.suppress(asyncio.CancelledError):
168 await task
169 logger.debug("Keepalive stopped for connection %s", connection_id)
171 async def _keepalive_loop(self, connection_id: str, send_ping: Any) -> None:
172 """Send periodic pings until cancelled.
174 Args:
175 connection_id: Used only for log messages.
176 send_ping: Async callable invoked on each keepalive tick.
177 """
178 while True:
179 await asyncio.sleep(self._keepalive_interval)
180 try:
181 await send_ping()
182 logger.debug("Keepalive ping sent for connection %s", connection_id)
183 except asyncio.CancelledError:
184 raise
185 except (OSError, RuntimeError) as e:
186 logger.warning(
187 "Keepalive ping failed for connection %s; stopping loop: %s",
188 connection_id,
189 e,
190 )
191 break
193 def get_active_subscriptions(self, connection_id: str) -> list[str]:
194 """Get list of active subscription IDs for a connection.
196 Args:
197 connection_id: The WebSocket connection ID.
199 Returns:
200 List of active subscription IDs.
201 """
202 if connection_id not in self._subscriptions:
203 return []
204 return list(self._subscriptions[connection_id].keys())
206 @property
207 def active_count(self) -> int:
208 """Get total number of active subscriptions across all connections."""
209 return sum(len(subs) for subs in self._subscriptions.values())
211 @property
212 def connection_count(self) -> int:
213 """Get number of active connections."""
214 return len(self._subscriptions)
217__all__ = ["SubscriptionManager"]