Skip to content

Commit 1259372

Browse files
authored
Merge pull request #14 from rhel-lightspeed/perf/lifespan-deferred-imports-3047
perf: defer heavy imports and adopt FastMCP lifespan
2 parents bc50566 + 3f616f7 commit 1259372

5 files changed

Lines changed: 64 additions & 58 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424

2525
### Changed
2626

27+
- Deferred heavy `docs2db-api` imports (torch, transformers) in `engine.py` to reduce module load time
28+
- Migrated startup health check and engine shutdown to FastMCP lifespan hook for proper async lifecycle management
29+
- Simplified `__main__.py` — removed manual `asyncio.run()` calls for health check and cleanup
2730
- Upgraded fastmcp from 2.x to 3.x (`>=3.3.1, <4`)
2831
- Removed standalone `mcp` dependency (pulled in transitively by fastmcp 3.x)
2932
- Updated smoke tests to match FastMCP 3.x decorator behavior (`@mcp.tool` now returns the original function)

pyproject.toml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,3 @@ force-single-line = true
105105
lines-after-imports = 2
106106
lines-between-types = 1
107107
order-by-type = false
108-
109-
110-

src/docs2db_mcp/__main__.py

Lines changed: 1 addition & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Main entry point for docs2db MCP server."""
22

3-
import asyncio
43
import logging
54
import os
65
import sys
@@ -28,43 +27,10 @@
2827

2928
# Import configuration (lightweight, doesn't trigger heavy imports)
3029
from docs2db_mcp.config import CONFIG # noqa: E402
31-
32-
33-
logger = logging.getLogger(__name__)
34-
35-
36-
def _configure_structlog_for_stdio() -> None:
37-
"""Override docs2db-api's structlog configuration to redirect stdout to stderr.
38-
39-
docs2db-api configures structlog at import time to write to stdout, but stdio transport
40-
requires stdout to be reserved exclusively for MCP protocol messages.
41-
"""
42-
import structlog
43-
44-
# Reconfigure structlog to output to stderr instead of stdout
45-
# Keep existing processors, just redirect the output stream
46-
structlog.configure(
47-
logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
48-
cache_logger_on_first_use=False,
49-
)
50-
51-
52-
# Import server and engine modules
53-
# Must happen after reading transport to configure structlog before they import docs2db-api
54-
from docs2db_mcp.engine import health_check # noqa: E402
55-
from docs2db_mcp.engine import shutdown_engine # noqa: E402
5630
from docs2db_mcp.server import mcp # noqa: E402
5731

5832

59-
# docs2db-api has already configured structlog, now override for stdio transport
60-
if transport != "sse":
61-
_configure_structlog_for_stdio()
62-
63-
64-
async def cleanup() -> None:
65-
"""Cleanup resources on shutdown."""
66-
logger.info("Shutting down docs2db MCP server")
67-
await shutdown_engine()
33+
logger = logging.getLogger(__name__)
6834

6935

7036
def main() -> None:
@@ -79,25 +45,13 @@ def main() -> None:
7945
CONFIG.rag_enable_reranking,
8046
)
8147

82-
# Perform startup health check
83-
try:
84-
asyncio.run(health_check())
85-
except Exception as e:
86-
logger.error("Startup health check failed: %s", e)
87-
logger.error("Server will not start - please check database connection and configuration")
88-
sys.exit(1)
89-
9048
try:
91-
# Run the MCP server
9249
mcp.run(transport=CONFIG.transport, **CONFIG.transport_kwargs)
9350
except KeyboardInterrupt:
9451
logger.info("Received keyboard interrupt")
9552
except Exception as e:
9653
logger.error("Server error: %s", e, exc_info=True)
9754
sys.exit(1)
98-
finally:
99-
# Cleanup
100-
asyncio.run(cleanup())
10155

10256

10357
if __name__ == "__main__":

src/docs2db_mcp/engine.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
1-
"""RAG engine singleton wrapper."""
1+
"""RAG engine singleton wrapper.
2+
3+
Heavy dependencies (torch, transformers, docs2db-api) are imported lazily
4+
inside ``get_engine()`` so that importing this module is near-instant.
5+
"""
6+
7+
from __future__ import annotations
28

39
import logging
410

5-
from docs2db_api.rag.engine import RAGConfig
6-
from docs2db_api.rag.engine import UniversalRAGEngine
11+
from typing import TYPE_CHECKING
712

813
from docs2db_mcp.config import CONFIG
914

1015

16+
if TYPE_CHECKING:
17+
from docs2db_api.rag.engine import UniversalRAGEngine
18+
19+
1120
logger = logging.getLogger(__name__)
1221

1322
_engine: UniversalRAGEngine | None = None
@@ -16,6 +25,10 @@
1625
async def get_engine() -> UniversalRAGEngine:
1726
"""Get or create the singleton RAG engine instance.
1827
28+
On first call this imports ``docs2db-api`` (which pulls in torch,
29+
transformers, etc.) and initialises the engine. Subsequent calls
30+
return the cached instance.
31+
1932
Returns:
2033
Initialized UniversalRAGEngine instance
2134
@@ -25,6 +38,9 @@ async def get_engine() -> UniversalRAGEngine:
2538
global _engine
2639

2740
if _engine is None:
41+
from docs2db_api.rag.engine import RAGConfig
42+
from docs2db_api.rag.engine import UniversalRAGEngine
43+
2844
logger.info("Initializing UniversalRAGEngine")
2945

3046
# Configure database connection (dict format)
@@ -53,7 +69,7 @@ async def get_engine() -> UniversalRAGEngine:
5369
await _engine.start()
5470
logger.info("UniversalRAGEngine initialized successfully")
5571
except Exception as e:
56-
logger.error(f"Failed to initialize RAG engine: {e}")
72+
logger.error("Failed to initialize RAG engine: %s", e)
5773
_engine = None
5874
raise
5975

@@ -95,11 +111,12 @@ async def health_check() -> None:
95111

96112
# Verify we got a valid response
97113
if result is None:
98-
raise Exception("Health check query returned None")
114+
msg = "Health check query returned None"
115+
raise Exception(msg)
99116

100-
logger.info(f"Test query successful (returned {len(result.documents)} documents)")
117+
logger.info("Test query successful (returned %d documents)", len(result.documents))
101118
logger.info("Health check passed - system is ready")
102119

103120
except Exception as e:
104-
logger.error(f"Health check failed: {e}", exc_info=True)
121+
logger.error("Health check failed: %s", e, exc_info=True)
105122
raise Exception(f"Startup health check failed - cannot connect to database or perform queries: {e}") from e

src/docs2db_mcp/server.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,55 @@
11
"""FastMCP server instance and initialization."""
22

33
import logging
4+
import sys
5+
6+
from collections.abc import AsyncIterator
7+
from contextlib import asynccontextmanager
48

59
from fastmcp import FastMCP
610

11+
from docs2db_mcp.config import CONFIG
12+
from docs2db_mcp.engine import health_check
13+
from docs2db_mcp.engine import shutdown_engine
14+
715

816
logger = logging.getLogger(__name__)
917

10-
# Initialize MCP server
18+
19+
@asynccontextmanager
20+
async def engine_lifespan(server: FastMCP) -> AsyncIterator[dict]:
21+
"""Run startup health check and manage engine lifecycle.
22+
23+
The server will not accept tool calls until the health check passes.
24+
If it fails, the server raises and refuses to start.
25+
"""
26+
await health_check()
27+
28+
# docs2db-api configures structlog to stdout at import time (triggered
29+
# by health_check → get_engine). For non-SSE transports stdout is
30+
# reserved for MCP protocol messages, so redirect structlog to stderr.
31+
if CONFIG.transport != "sse":
32+
import structlog
33+
34+
structlog.configure(
35+
logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
36+
cache_logger_on_first_use=False,
37+
)
38+
39+
try:
40+
yield {}
41+
finally:
42+
await shutdown_engine()
43+
44+
1145
mcp = FastMCP(
1246
"docs2db-rag",
1347
instructions=(
1448
"RAG search using docs2db for RHEL documentation. "
1549
"Use search_documents to find relevant information from "
1650
"RHEL documentation, knowledge base articles, and guides."
1751
),
52+
lifespan=engine_lifespan,
1853
)
1954

2055
# Import tools to register them with the MCP server

0 commit comments

Comments
 (0)