Spaces:
Sleeping
Sleeping
File size: 14,108 Bytes
b2de8b7 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 f2a407c 858bf77 53b3fdf 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 527670e 858bf77 f2a407c 858bf77 527670e 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 b2de8b7 858bf77 b2de8b7 f2a407c 858bf77 527670e b2de8b7 7d2d28e b2de8b7 7d2d28e b2de8b7 7d2d28e b2de8b7 7d2d28e b2de8b7 52781d3 b2de8b7 7d2d28e b2de8b7 858bf77 527670e 858bf77 7d2d28e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 | import asyncio
from collections import deque
from datetime import datetime, timezone
from typing import Dict, List, Any, Deque, Optional
import psutil
from fastapi import WebSocket
from core.settings.settings import Settings
from core.types.datamodels import ClientInfo, Subscription, Notification
from core.utils.utils import current_utc_iso
class WebSocketManager:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.logger = self.settings.logger
self.active_connections: Dict[str, WebSocket] = {}
self.client_info: Dict[str, ClientInfo] = {}
self.subscriptions: Dict[str, List[Subscription]] = {}
self.notification_history: Deque[Notification] = deque(
maxlen=self.settings.get("MAX_NOTIFICATION_HISTORY", 1000)
)
self._start_time: Optional[datetime] = None
self._is_initialized: bool = False
self._shutdown_event: Optional[asyncio.Event] = None
self._background_tasks: List[asyncio.Task] = []
self._total_messages_sent: int = 0
self._total_connections_handled: int = 0
self._connection_errors: int = 0
async def initialize(self) -> None:
if self._is_initialized:
self.logger.warning("WebSocketManager already initialized")
return
self._start_time = datetime.now(timezone.utc)
self._shutdown_event = asyncio.Event()
self._is_initialized = True
self._background_tasks = [
asyncio.create_task(self._cleanup_expired_notifications()),
asyncio.create_task(self._heartbeat_checker()),
asyncio.create_task(self._stats_collector())
]
self.logger.info("WebSocketManager initialized successfully")
async def shutdown(self) -> None:
if not self._is_initialized:
return
self.logger.info("Shutting down WebSocketManager...")
if self._shutdown_event:
self._shutdown_event.set()
for task in self._background_tasks:
if not task.done():
task.cancel()
if self._background_tasks:
await asyncio.gather(*self._background_tasks, return_exceptions=True)
await self._close_all_connections()
self.active_connections.clear()
self.client_info.clear()
self.subscriptions.clear()
self.notification_history.clear()
self._is_initialized = False
self.logger.info("WebSocketManager shutdown completed")
async def connect(self, websocket: WebSocket, client: ClientInfo) -> str:
await websocket.accept()
session_id = client.session_id
self.active_connections[session_id] = websocket
self.client_info[session_id] = client
self.subscriptions[session_id] = []
self._total_connections_handled += 1
self.logger.info(f"Nouvelle connexion: {session_id}")
await self._send(
websocket,
{
"session_id": session_id,
"type": "connection_established",
"title": "Connexion réussie",
"message": "Connexion au service de notifications établie",
"timestamp": current_utc_iso(),
},
)
return session_id
def disconnect(self, session_id: str) -> None:
if session_id in self.active_connections:
del self.active_connections[session_id]
del self.client_info[session_id]
del self.subscriptions[session_id]
self.logger.info(f"Connexion fermée: {session_id}")
async def send_personal_message(
self, session_id: str, payload: Dict[str, Any]
) -> None:
if ws := self.active_connections.get(session_id):
try:
await self._send(ws, payload)
self._total_messages_sent += 1
except RuntimeError as e:
self.logger.error(f"Erreur d'envoi à {session_id}: {e}")
self._connection_errors += 1
self.disconnect(session_id)
async def broadcast_notification(self, notification: Notification) -> Dict[str, Any]:
if notification.is_expired:
return {"delivered_to": 0}
self._add_to_history(notification)
payload = self._format_notification(notification)
delivered_count = 0
for session_id, subscriptions in self.subscriptions.items():
if not (client := self.client_info.get(session_id)):
continue
if self._should_receive(subscriptions, notification, client):
await self.send_personal_message(session_id, payload)
delivered_count += 1
return {"delivered_to": delivered_count}
async def send_to_user(self, user_id: str, notification: Notification) -> Dict[str, Any]:
if notification.is_expired:
return {"delivered_to": 0}
self._add_to_history(notification)
payload = self._format_notification(notification)
delivered_count = 0
for session_id, client in self.client_info.items():
if client.user_id == user_id:
await self.send_personal_message(session_id, payload)
delivered_count += 1
return {"delivered_to": delivered_count}
async def send_to_role(self, role: str, notification: Notification) -> Dict[str, Any]:
if notification.is_expired:
return {"delivered_to": 0}
self._add_to_history(notification)
payload = self._format_notification(notification)
delivered_count = 0
for session_id, client in self.client_info.items():
if role in client.user_roles:
await self.send_personal_message(session_id, payload)
delivered_count += 1
return {"delivered_to": delivered_count}
def add_subscription(self, session_id: str, subscription: Subscription) -> bool:
if not (sub_list := self.subscriptions.get(session_id)):
return False
for existing in sub_list:
if existing.id == subscription.id:
return False
sub_list.append(subscription)
return True
def update_subscription(self, session_id: str, subscription: Subscription) -> bool:
if not (sub_list := self.subscriptions.get(session_id)):
return False
for i, existing in enumerate(sub_list):
if existing.id == subscription.id:
sub_list[i] = subscription
return True
return False
def remove_subscription(self, session_id: str, subscription_id: str) -> bool:
if not (sub_list := self.subscriptions.get(session_id)):
return False
initial_count = len(sub_list)
self.subscriptions[session_id] = [
sub for sub in sub_list if sub.id != subscription_id
]
return len(self.subscriptions[session_id]) < initial_count
def get_subscriptions(self, session_id: str) -> List[Subscription]:
return self.subscriptions.get(session_id, [])[:]
def get_notification_history(self, limit: int = 100, offset: int = 0) -> List[Notification]:
history = [n for n in list(self.notification_history) if not n.is_expired]
return history[offset:offset + limit]
def get_stats(self) -> Dict[str, Any]:
memory_info = self.get_memory_usage()
return {
"active_connections": len(self.active_connections),
"total_subscriptions": sum(len(s) for s in self.subscriptions.values()),
"total_notifications": len(self.notification_history),
"total_messages_sent": self._total_messages_sent,
"total_connections_handled": self._total_connections_handled,
"connection_errors": self._connection_errors,
"uptime_seconds": self.get_uptime(),
"memory_usage": memory_info,
"timestamp": current_utc_iso(),
}
@staticmethod
def get_current_timestamp() -> str:
return current_utc_iso()
def get_uptime(self) -> str:
if not self._start_time:
return self.format_uptime(0)
return self.format_uptime((datetime.now(timezone.utc) - self._start_time).total_seconds())
def get_memory_usage(self) -> Dict[str, Any]:
try:
process = psutil.Process()
memory_info = process.memory_info()
return {
"resident_set_size_mb": self._format_size(memory_info.rss),
"virtual_memory_size_mb": self._format_size(memory_info.vms),
"percent": self._format_size(process.memory_percent()),
"available_mb": self._format_size(psutil.virtual_memory().available),
"total_mb": self._format_size(psutil.virtual_memory().total),
}
except Exception as e:
self.logger.error(f"Error getting memory usage: {e}")
return {
"resident_set_size_mb": 0,
"virtual_memory_size_mb": 0,
"percent": 0,
"available_mb": 0,
"total_mb": 0,
"error": str(e)
}
async def _cleanup_expired_notifications(self) -> None:
while not self._shutdown_event.is_set():
try:
active_notifications = [
n for n in self.notification_history if not n.is_expired
]
self.notification_history.clear()
self.notification_history.extend(active_notifications)
await asyncio.sleep(self.settings.get("CLEANUP_INTERVAL", 300)) # 5 minutes
except asyncio.CancelledError:
break
except Exception as e:
self.logger.error(f"Error in cleanup task: {e}")
await asyncio.sleep(60)
async def _heartbeat_checker(self) -> None:
while not self._shutdown_event.is_set():
try:
disconnected_sessions = []
for session_id, websocket in self.active_connections.items():
try:
await websocket.send_json(
{"session_id": session_id, "type": "ping", "timestamp": current_utc_iso()})
except Exception as e:
self.logger.warning(f"Connection {session_id} appears to be dead: {e}")
disconnected_sessions.append(session_id)
for session_id in disconnected_sessions:
self.disconnect(session_id)
await asyncio.sleep(self.settings.get("HEARTBEAT_INTERVAL", 30)) # 30 seconds
except asyncio.CancelledError:
break
except Exception as e:
self.logger.error(f"Error in heartbeat checker: {e}")
await asyncio.sleep(60)
async def _stats_collector(self) -> None:
while not self._shutdown_event.is_set():
try:
_ = self.get_stats()
await asyncio.sleep(self.settings.get("STATS_INTERVAL", 60)) # 1 minute
except asyncio.CancelledError:
break
except Exception as e:
self.logger.error(f"Error in stats collector: {e}")
await asyncio.sleep(60)
async def _close_all_connections(self) -> None:
if not self.active_connections:
return
self.logger.info(f"Closing {len(self.active_connections)} active connections...")
shutdown_message = {
"type": "service_shutdown",
"title": "Service en cours d'arrêt",
"message": "Le service de notifications va être arrêté",
"timestamp": current_utc_iso()
}
close_tasks = []
for session_id, websocket in self.active_connections.items():
try:
await self._send(websocket, shutdown_message)
close_tasks.append(websocket.close())
except Exception as e:
self.logger.error(f"Error closing connection {session_id}: {e}")
if close_tasks:
await asyncio.gather(*close_tasks, return_exceptions=True)
async def _send(self, websocket: WebSocket, payload: Dict[str, Any]) -> None:
try:
await websocket.send_json(payload)
except Exception as e:
self.logger.error(f"Failed to send message: {e}")
raise RuntimeError(f"WebSocket send failed: {e}")
def _add_to_history(self, notification: Notification) -> None:
if not notification.is_expired:
self.notification_history.append(notification)
@staticmethod
def _should_receive(
subscriptions: List[Subscription],
notification: Notification,
client: ClientInfo,
) -> bool:
return any(
sub.matches(notification, client.user_id, client.user_roles)
for sub in subscriptions
)
@staticmethod
def _format_notification(notification: Notification) -> Dict[str, Any]:
return {"type": "notification", **notification.model_dump(exclude_unset=True)}
@staticmethod
def _format_size(bytes_value) -> str:
if bytes_value < 1024:
return f"{bytes_value} B"
elif bytes_value < 1024 ** 2:
return f"{bytes_value / 1024:.2f} KB"
elif bytes_value < 1024 ** 3:
return f"{bytes_value / (1024 ** 2):.2f} MB"
else:
return f"{bytes_value / (1024 ** 3):.2f} GB"
@staticmethod
def format_uptime(seconds: float) -> str:
minutes, sec = divmod(int(seconds), 60)
hours, minutes = divmod(minutes, 60)
days, hours = divmod(hours, 24)
parts = []
if days: parts.append(f"{days}d")
if hours: parts.append(f"{hours}h")
if minutes: parts.append(f"{minutes}m")
if sec or not parts: parts.append(f"{sec}s")
return " ".join(parts)
|