"""Cliente asíncrono de MongoDB y gestión de la conexión. Se utiliza el driver oficial ``pymongo`` con su API asíncrona (:class:`pymongo.AsyncMongoClient`), disponible de forma estable a partir de PyMongo 4.13. El cliente se crea de forma perezosa: la conexión real se abre en la primera operación, por lo que la aplicación puede arrancar aunque la base de datos no esté disponible todavía (no se bloquea el arranque). """ from __future__ import annotations import logging import socket from pymongo import AsyncMongoClient from pymongo.asynchronous.collection import AsyncCollection from pymongo.asynchronous.database import AsyncDatabase from app.core.config import settings logger = logging.getLogger(__name__) class MongoDB: """Contenedor del cliente y la base de datos de MongoDB. Mantiene una única instancia del cliente durante el ciclo de vida de la aplicación. """ client: AsyncMongoClient | None = None database: AsyncDatabase | None = None async def connect(self) -> None: """Crea el cliente de MongoDB sin bloquear el arranque. No se hace ``ping`` aquí a propósito: el cliente es perezoso y la conexión se abre en la primera operación. Así la app arranca aunque Mongo no responda todavía. """ if self.client is not None: return self.client = AsyncMongoClient( settings.MONGODB_URI, tz_aware=True, serverSelectionTimeoutMS=settings.MONGODB_TIMEOUT_MS, ) self.database = self.client[settings.MONGODB_DB_NAME] async def ping(self) -> bool: """Comprueba si MongoDB responde. No lanza excepción.""" if self.client is None: return False try: await self.client.admin.command("ping") except Exception as exc: # noqa: BLE001 logger.warning("MongoDB no responde: %s", exc) self._diagnostico_red() return False logger.info("MongoDB conectado (db=%s).", settings.MONGODB_DB_NAME) return True def _diagnostico_red(self) -> None: """Registra un diagnóstico de red hacia los nodos de MongoDB. Resuelve el DNS y prueba un socket TCP crudo a cada nodo para distinguir un bloqueo de red de un problema del driver/credenciales. """ try: nodos = self.client.topology_description.server_descriptions() except Exception: # noqa: BLE001 return for host, port in nodos: try: ip = socket.gethostbyname(host) except OSError as exc: logger.warning("DIAG DNS %s -> FALLA (%s)", host, exc) continue sock = socket.socket() sock.settimeout(5) try: sock.connect((host, port)) logger.warning("DIAG TCP %s:%s (%s) -> OK", host, port, ip) except OSError as exc: logger.warning( "DIAG TCP %s:%s (%s) -> BLOQUEADO/timeout (%s)", host, port, ip, type(exc).__name__, ) finally: sock.close() async def close(self) -> None: """Cierra la conexión con MongoDB.""" if self.client is not None: await self.client.close() self.client = None self.database = None logger.info("Conexión a MongoDB cerrada.") mongodb = MongoDB() def get_database() -> AsyncDatabase: """Devuelve la base de datos activa. Pensada para usarse como dependencia de FastAPI. """ if mongodb.database is None: raise RuntimeError( "La base de datos no está inicializada. " "¿Se ejecutó el lifespan de la aplicación?" ) return mongodb.database def get_collection(name: str) -> AsyncCollection: """Devuelve una colección por su nombre.""" return get_database()[name]