Skip to content

Commit 98f8f09

Browse files
Refactor main into app modules and route registrars
Split large main.py into focused application modules and move app setup logic out of the monolith. Added an app factory, service container, consolidated lifespan (startup/shutdown) logic, ASGI request-logging middleware, and route registrars (internal, public_v1, MCP mount). Introduced health endpoints (liveness + OpenSearch readiness), service initialization wiring, startup orchestrator and helper utilities (default docs service, JWT keygen, OpenSearch init, URL content fetcher). main.py was slimmed to re-export essentials and delegate app creation to app.factory.create_app. This improves modularity, testability, and separates concerns for lifecycle, routing, and service construction.
1 parent 68363c2 commit 98f8f09

16 files changed

Lines changed: 2548 additions & 2443 deletions

src/api/health.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Liveness and readiness probes."""
2+
import asyncio
3+
4+
import httpx
5+
from fastapi import Request
6+
from fastapi.responses import JSONResponse
7+
8+
from config.settings import clients
9+
from utils.logging_config import get_logger
10+
11+
logger = get_logger(__name__)
12+
13+
14+
async def health_check(request: Request):
15+
"""Simple liveness probe: Indicates that the OpenRAG Backend service is online and running."""
16+
return JSONResponse({"status": "ok"}, status_code=200)
17+
18+
19+
async def opensearch_health_ready(request):
20+
"""Readiness probe: verifies OpenSearch dependency is reachable."""
21+
from config.settings import IBM_AUTH_ENABLED, OPENSEARCH_URL
22+
23+
if IBM_AUTH_ENABLED:
24+
logger.debug(
25+
"[OPENSEARCH] OpenSearch auth mode enabled, health check per-request"
26+
)
27+
# In IBM auth mode we cannot rely on the global OpenSearch client
28+
# (auth is established per-request), so perform a lightweight,
29+
# unauthenticated connectivity check against the OpenSearch endpoint.
30+
opensearch_url = OPENSEARCH_URL.rstrip("/")
31+
try:
32+
timeout = httpx.Timeout(5.0)
33+
async with httpx.AsyncClient(timeout=timeout) as client:
34+
resp = await client.get(f"{opensearch_url}/")
35+
if resp.status_code < 500:
36+
logger.debug("[OPENSEARCH] OpenSearch health check successful")
37+
return JSONResponse(
38+
{
39+
"status": "ready",
40+
"dependencies": {"opensearch": "up"},
41+
"note": "OpenSearch auth mode - connectivity verified via unauthenticated probe",
42+
},
43+
status_code=200,
44+
)
45+
else:
46+
logger.debug("[OPENSEARCH] OpenSearch health check failed")
47+
return JSONResponse(
48+
{
49+
"status": "not_ready",
50+
"dependencies": {"opensearch": "down"},
51+
"error": f"Unexpected status from OpenSearch: {resp.status_code}",
52+
},
53+
status_code=503,
54+
)
55+
except Exception as e:
56+
logger.error(
57+
"[OPENSEARCH] OpenSearch health check failed", error=str(e)
58+
)
59+
return JSONResponse(
60+
{
61+
"status": "not_ready",
62+
"dependencies": {"opensearch": "down"},
63+
"error": "OpenSearch health check failed",
64+
},
65+
status_code=503,
66+
)
67+
68+
try:
69+
await asyncio.wait_for(clients.opensearch.info(), timeout=5.0)
70+
return JSONResponse(
71+
{"status": "ready", "dependencies": {"opensearch": "up"}},
72+
status_code=200,
73+
)
74+
except Exception as e:
75+
logger.error(
76+
"[OPENSEARCH] OpenSearch health check failed", error=str(e)
77+
)
78+
return JSONResponse(
79+
{
80+
"status": "not_ready",
81+
"dependencies": {"opensearch": "down"},
82+
"error": "OpenSearch health check failed",
83+
},
84+
status_code=503,
85+
)

src/app/__init__.py

Whitespace-only changes.

src/app/container.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""Service container — constructs all application services and wires their dependencies.
2+
3+
Returns a dict consumed by routes (via FastAPI Depends) and the lifespan hook.
4+
"""
5+
import os
6+
7+
from api.connector_router import ConnectorRouter
8+
from config.settings import (
9+
INGESTION_TIMEOUT,
10+
SESSION_SECRET,
11+
clients,
12+
config_manager,
13+
get_embedding_model,
14+
get_index_name,
15+
is_no_auth_mode,
16+
)
17+
from connectors.langflow_connector_service import LangflowConnectorService
18+
from connectors.service import ConnectorService
19+
from services.api_key_service import APIKeyService
20+
from services.auth_service import AuthService
21+
from services.chat_service import ChatService
22+
from services.document_service import DocumentService
23+
from services.flows_service import FlowsService
24+
from services.knowledge_filter_service import KnowledgeFilterService
25+
from services.langflow_file_service import LangflowFileService
26+
from services.langflow_mcp_service import LangflowMCPService
27+
from services.models_service import ModelsService
28+
from services.monitor_service import MonitorService
29+
from services.search_service import SearchService, register_search_service
30+
from services.task_service import TaskService
31+
from session_manager import SessionManager
32+
from utils.jwt_keygen import generate_jwt_keys
33+
from utils.logging_config import get_logger
34+
from utils.telemetry import TelemetryClient, Category, MessageId
35+
36+
logger = get_logger(__name__)
37+
38+
39+
async def initialize_services():
40+
"""Initialize all services and their dependencies"""
41+
await TelemetryClient.send_event(
42+
Category.SERVICE_INITIALIZATION, MessageId.ORB_SVC_INIT_START
43+
)
44+
from config.settings import IBM_AUTH_ENABLED
45+
46+
if IBM_AUTH_ENABLED:
47+
logger.info("IBM auth mode enabled — JWT validation delegated to Traefik")
48+
49+
# Generate JWT keys if they don't exist, a JWT signing key isn't specified,
50+
# and IBM auth is not enabled (IBM mode delegates all auth to Traefik)
51+
if not os.getenv("JWT_SIGNING_KEY") and not IBM_AUTH_ENABLED:
52+
generate_jwt_keys()
53+
54+
# Initialize clients (now async to generate Langflow API key)
55+
try:
56+
await clients.initialize()
57+
except Exception as e:
58+
logger.error("Failed to initialize clients", error=str(e))
59+
await TelemetryClient.send_event(
60+
Category.SERVICE_INITIALIZATION, MessageId.ORB_SVC_OS_CLIENT_FAIL
61+
)
62+
raise
63+
64+
session_manager = SessionManager(SESSION_SECRET)
65+
66+
models_service = ModelsService()
67+
document_service = DocumentService(
68+
session_manager=session_manager,
69+
models_service=models_service,
70+
docling_service=clients.docling_service,
71+
)
72+
search_service = SearchService(session_manager, models_service)
73+
register_search_service(search_service)
74+
task_service = TaskService(
75+
document_service,
76+
models_service,
77+
ingestion_timeout=INGESTION_TIMEOUT,
78+
docling_service=clients.docling_service,
79+
)
80+
flows_service = FlowsService()
81+
chat_service = ChatService(flows_service=flows_service)
82+
knowledge_filter_service = KnowledgeFilterService(session_manager)
83+
monitor_service = MonitorService(session_manager)
84+
langflow_file_service = LangflowFileService(
85+
flows_service=flows_service,
86+
docling_service=clients.docling_service,
87+
)
88+
langflow_mcp_service = LangflowMCPService()
89+
90+
langflow_connector_service = LangflowConnectorService(
91+
task_service=task_service,
92+
session_manager=session_manager,
93+
flows_service=flows_service,
94+
docling_service=clients.docling_service,
95+
)
96+
openrag_connector_service = ConnectorService(
97+
patched_async_client=clients,
98+
embed_model=get_embedding_model(),
99+
index_name=get_index_name(),
100+
task_service=task_service,
101+
session_manager=session_manager,
102+
models_service=models_service,
103+
document_service=document_service,
104+
docling_service=clients.docling_service,
105+
)
106+
107+
connector_service = ConnectorRouter(
108+
langflow_connector_service=langflow_connector_service,
109+
openrag_connector_service=openrag_connector_service,
110+
)
111+
112+
auth_service = AuthService(
113+
session_manager,
114+
connector_service,
115+
flows_service,
116+
langflow_mcp_service=langflow_mcp_service,
117+
)
118+
119+
# Load persisted connector connections at startup so webhooks and syncs
120+
# can resolve existing subscriptions immediately after server boot
121+
# Skip in no-auth mode since connectors require OAuth
122+
if not is_no_auth_mode():
123+
try:
124+
await connector_service.initialize()
125+
loaded_count = len(connector_service.connection_manager.connections)
126+
logger.info(
127+
"Loaded persisted connector connections on startup",
128+
loaded_count=loaded_count,
129+
)
130+
except Exception as e:
131+
logger.error(
132+
"Failed to load persisted connections on startup", error=str(e)
133+
)
134+
await TelemetryClient.send_event(
135+
Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_LOAD_FAILED
136+
)
137+
else:
138+
logger.info("[CONNECTORS] Skipping connection loading in no-auth mode")
139+
140+
await TelemetryClient.send_event(
141+
Category.SERVICE_INITIALIZATION, MessageId.ORB_SVC_INIT_SUCCESS
142+
)
143+
144+
api_key_service = APIKeyService(session_manager)
145+
146+
# ===== RBAC service =====
147+
# We do NOT open the SQL engine here. `create_app()` runs inside an
148+
# `asyncio.run(...)` whose loop is closed BEFORE uvicorn starts its
149+
# own loop — any AsyncEngine bound to this loop would be dead by
150+
# then. Instead, RBACService takes a *lazy* session-factory getter
151+
# that resolves `db.engine.SessionLocal` at call time — that
152+
# attribute is filled in by the lifespan startup event running on
153+
# uvicorn's loop. Alembic upgrade is run synchronously from __main__
154+
# before `asyncio.run(create_app())` so the schema is in place by
155+
# the time the lifespan opens the engine.
156+
from db import engine as _db_engine_mod
157+
from services.rbac_service import RBACService
158+
from services.workspace_config_service import WorkspaceConfigService
159+
160+
def _lazy_session_factory():
161+
sl = _db_engine_mod.SessionLocal
162+
if sl is None:
163+
raise RuntimeError(
164+
"DB engine not yet initialized. RBACService called before lifespan startup."
165+
)
166+
return sl()
167+
168+
rbac_service = RBACService(_lazy_session_factory)
169+
170+
# WorkspaceConfigService — DB-first reads of what config.yaml holds,
171+
# with the legacy ConfigManager kept as the yaml fallback during
172+
# Phase B (dual-write).
173+
workspace_config_service = WorkspaceConfigService(
174+
config_manager=config_manager,
175+
session_factory=_lazy_session_factory,
176+
)
177+
178+
# Plumb the session factory into the two chat-history services
179+
# (session_ownership + conversation_persistence). They lazy-resolve
180+
# `db.engine.SessionLocal` as a fallback, but setting it here makes
181+
# the wiring explicit and avoids the import path on the hot loop.
182+
from services.session_ownership_service import session_ownership_service
183+
from services.conversation_persistence_service import conversation_persistence
184+
session_ownership_service._session_factory = _lazy_session_factory
185+
conversation_persistence._session_factory = _lazy_session_factory
186+
187+
return {
188+
"document_service": document_service,
189+
"search_service": search_service,
190+
"task_service": task_service,
191+
"chat_service": chat_service,
192+
"flows_service": flows_service,
193+
"langflow_file_service": langflow_file_service,
194+
"auth_service": auth_service,
195+
"connector_service": connector_service,
196+
"knowledge_filter_service": knowledge_filter_service,
197+
"models_service": models_service,
198+
"monitor_service": monitor_service,
199+
"session_manager": session_manager,
200+
"api_key_service": api_key_service,
201+
"langflow_mcp_service": langflow_mcp_service,
202+
"docling_service": clients.docling_service,
203+
"rbac_service": rbac_service,
204+
"workspace_config_service": workspace_config_service,
205+
}

src/app/factory.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""FastAPI application factory.
2+
3+
Wires the service container, middleware, routes, and lifespan together
4+
into a ready-to-serve FastAPI app.
5+
"""
6+
from fastapi import FastAPI
7+
from prometheus_fastapi_instrumentator import Instrumentator
8+
9+
from app.container import initialize_services
10+
from app.lifespan import run_shutdown, run_startup
11+
from app.middleware import RequestLoggingMiddleware
12+
from app.routes import register_all_routes
13+
from utils.logging_config import get_logger
14+
from utils.version_utils import OPENRAG_VERSION
15+
16+
logger = get_logger(__name__)
17+
18+
19+
async def create_app():
20+
"""Create and configure the FastAPI application"""
21+
services = await initialize_services()
22+
23+
app = FastAPI(title="OpenRAG API", version=OPENRAG_VERSION, debug=True)
24+
app.state.services = services
25+
app.state.background_tasks = set()
26+
27+
# Wire up ASGI request logging middleware (pure ASGI, not BaseHTTPMiddleware)
28+
app.add_middleware(RequestLoggingMiddleware)
29+
30+
try:
31+
Instrumentator().instrument(app).expose(app)
32+
except Exception as e:
33+
logger.error(f"Failed to instrument app with Prometheus: {str(e)}")
34+
35+
# Register all route handlers and mount the MCP server. The MCP
36+
# http_app's lifespan context manager is stored on app.state so the
37+
# application lifespan can enter/exit it at the right time.
38+
mcp_lifespan_ctx = register_all_routes(app, services)
39+
app.state.mcp_lifespan_ctx = mcp_lifespan_ctx
40+
41+
# Wire startup/shutdown via on_event handlers (not lifespan=) so that
42+
# `app.router.startup()` / `app.router.shutdown()` triggers them — the
43+
# integration tests rely on this contract.
44+
@app.on_event("startup")
45+
async def _startup():
46+
await run_startup(app)
47+
48+
@app.on_event("shutdown")
49+
async def _shutdown():
50+
await run_shutdown(app)
51+
52+
return app

0 commit comments

Comments
 (0)