Skip to content

Commit 58c4648

Browse files
committed
⚡ perf(mcp): 隔离同步数据库调用与事件循环
1 parent 5b6d29a commit 58c4648

6 files changed

Lines changed: 464 additions & 137 deletions

File tree

‎src/dbjavagenix/database/atomic_codegen_tools.py‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"""
2424

2525
import json
26+
import asyncio
2627
import logging
2728
from typing import Any, Dict, List
2829

@@ -34,6 +35,20 @@
3435
logger = logging.getLogger(__name__)
3536

3637

38+
async def _run_db_call(callable_obj, *args):
39+
"""Run blocking database work outside the MCP event loop."""
40+
return await asyncio.to_thread(callable_obj, *args)
41+
42+
43+
async def _run_async_db_call(callable_obj, *args, **kwargs):
44+
"""Run the async analyzer in a worker because its introspection is synchronous."""
45+
46+
def run():
47+
return asyncio.run(callable_obj(*args, **kwargs))
48+
49+
return await asyncio.to_thread(run)
50+
51+
3752
# ============================================================
3853
# Tool 定义 - 7 个原子工具
3954
# ============================================================
@@ -208,10 +223,11 @@ async def handle_codegen_build_context(arguments: Dict[str, Any]) -> List[TextCo
208223
database = config.database or "information_schema"
209224

210225
# 收集所有表名用于前缀分析(沿用旧逻辑)
211-
all_table_names = _collect_all_table_names(connection_id, config)
226+
all_table_names = await _run_db_call(_collect_all_table_names, connection_id, config)
212227

213228
analyzer = CodegenAnalyzer(connection_manager)
214-
analysis = await analyzer.analyze_table_for_codegen(
229+
analysis = await _run_async_db_call(
230+
analyzer.analyze_table_for_codegen,
215231
connection_id,
216232
table_name,
217233
all_table_names=all_table_names,
@@ -508,9 +524,8 @@ def _compute_file_path(
508524
def _collect_all_table_names(connection_id: str, config) -> List[str]:
509525
"""收集库内所有表名(用于前缀分析)。失败时返回空列表。"""
510526
try:
511-
conn = connection_manager.get_connection(connection_id)
512-
cursor = conn.cursor()
513-
try:
527+
528+
def collect(cursor) -> List[str]:
514529
if config.type.name == "MYSQL":
515530
cursor.execute("SHOW TABLES")
516531
return [row[0] for row in cursor.fetchall()]
@@ -529,6 +544,16 @@ def _collect_all_table_names(connection_id: str, config) -> List[str]:
529544
)
530545
return [row[0] for row in cursor.fetchall()]
531546
return []
547+
548+
if hasattr(connection_manager, "get_cursor"):
549+
with connection_manager.get_cursor(connection_id) as cursor:
550+
return collect(cursor)
551+
552+
# Preserve the small test-double contract used by older integrations.
553+
connection = connection_manager.get_connection(connection_id)
554+
cursor = connection.cursor()
555+
try:
556+
return collect(cursor)
532557
finally:
533558
cursor.close()
534559
except Exception as e: # noqa: BLE001

‎src/dbjavagenix/database/connection_manager.py‎

Lines changed: 107 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import re
77
from threading import RLock
88
import uuid
9+
import threading
910
from typing import Dict, List, Any, Optional
1011
import pymysql
1112
import sqlite3
@@ -52,6 +53,8 @@ class ConnectionManager:
5253
def __init__(self):
5354
self.connections: Dict[str, Any] = {}
5455
self.connection_configs: Dict[str, DatabaseConfig] = {}
56+
self._registry_lock = threading.RLock()
57+
self._connection_locks: Dict[str, Any] = {}
5558
self._metadata_cache: OrderedDict[
5659
tuple[str, str, Optional[str]], Dict[str, Any]
5760
] = OrderedDict()
@@ -152,16 +155,21 @@ def create_connection(self, config: DatabaseConfig) -> str:
152155
)
153156
connection.autocommit = True
154157
elif config.type == DatabaseType.SQLITE:
155-
connection = sqlite3.connect(config.database)
158+
# MCP database work runs in worker threads. SQLite's default
159+
# thread affinity would reject a connection created elsewhere.
160+
connection = sqlite3.connect(config.database, check_same_thread=False)
156161
connection.row_factory = sqlite3.Row # Enable dict-like access
157162
else:
158163
raise DatabaseConnectionError(f"Unsupported database type: {config.type}")
159164

160-
self.connections[connection_id] = connection
161-
# Store config without sensitive data for reference
162-
safe_config = config.model_copy()
163-
safe_config.password = "***" # Mask password
164-
self.connection_configs[connection_id] = safe_config
165+
# Store the connection and its lock atomically with the registry.
166+
with self._registry_lock:
167+
self.connections[connection_id] = connection
168+
self._connection_locks[connection_id] = threading.RLock()
169+
# Store config without sensitive data for reference
170+
safe_config = config.model_copy()
171+
safe_config.password = "***" # Mask password
172+
self.connection_configs[connection_id] = safe_config
165173

166174
logger.info(f"Created connection {connection_id} to {config.type}://{config.host}:{config.port}")
167175
return connection_id
@@ -184,23 +192,54 @@ def get_connection(self, connection_id: str) -> Any:
184192
Raises:
185193
DatabaseConnectionError: If connection not found
186194
"""
187-
if connection_id not in self.connections:
195+
with self._connection_lock(connection_id):
196+
connection = self._get_connection_unlocked(connection_id)
197+
try:
198+
if getattr(connection, "closed", 0):
199+
raise DatabaseConnectionError("connection is closed")
200+
if hasattr(connection, "ping"):
201+
connection.ping(reconnect=True)
202+
except Exception as exc:
203+
logger.warning("Connection %s is dead, removing: %s", connection_id, exc)
204+
self._close_connection_unlocked(connection_id, connection)
205+
raise DatabaseConnectionError(
206+
f"Connection {connection_id} is no longer valid"
207+
) from exc
208+
return connection
209+
210+
@contextmanager
211+
def _connection_lock(self, connection_id: str):
212+
"""Serialize operations for one connection without blocking the registry."""
213+
with self._registry_lock:
214+
lock = self._connection_locks.get(connection_id)
215+
if lock is None or connection_id not in self.connections:
216+
raise DatabaseConnectionError(f"Connection {connection_id} not found")
217+
with lock:
218+
yield
219+
220+
def _get_connection_unlocked(self, connection_id: str) -> Any:
221+
with self._registry_lock:
222+
connection = self.connections.get(connection_id)
223+
if connection is None:
188224
raise DatabaseConnectionError(f"Connection {connection_id} not found")
189-
190-
connection = self.connections[connection_id]
191-
192-
# Test connection is still alive
193-
try:
194-
if getattr(connection, "closed", 0):
195-
raise DatabaseConnectionError("connection is closed")
196-
if hasattr(connection, 'ping'):
197-
connection.ping(reconnect=True)
198-
except Exception as e:
199-
logger.warning(f"Connection {connection_id} is dead, removing: {e}")
200-
self.close_connection(connection_id)
201-
raise DatabaseConnectionError(f"Connection {connection_id} is no longer valid")
202-
203225
return connection
226+
227+
def _close_connection_unlocked(self, connection_id: str, connection: Any) -> bool:
228+
"""Close and remove a connection while its per-connection lock is held."""
229+
try:
230+
connection.close()
231+
closed = True
232+
except Exception as exc:
233+
logger.error("Error closing connection %s: %s", connection_id, exc)
234+
closed = True
235+
finally:
236+
with self._registry_lock:
237+
self.connections.pop(connection_id, None)
238+
self.connection_configs.pop(connection_id, None)
239+
self._connection_locks.pop(connection_id, None)
240+
self.invalidate_metadata_cache(connection_id)
241+
logger.info("Closed connection %s", connection_id)
242+
return closed
204243

205244
def close_connection(self, connection_id: str) -> bool:
206245
"""
@@ -212,25 +251,19 @@ def close_connection(self, connection_id: str) -> bool:
212251
Returns:
213252
True if connection was closed, False if not found
214253
"""
215-
if connection_id not in self.connections:
216-
self.invalidate_metadata_cache(connection_id)
217-
return False
218-
219-
try:
220-
connection = self.connections[connection_id]
221-
connection.close()
222-
self.connections.pop(connection_id, None)
223-
self.connection_configs.pop(connection_id, None)
224-
self.invalidate_metadata_cache(connection_id)
225-
logger.info(f"Closed connection {connection_id}")
226-
return True
227-
except Exception as e:
228-
logger.error(f"Error closing connection {connection_id}: {e}")
229-
# Remove from dict anyway
230-
self.connections.pop(connection_id, None)
231-
self.connection_configs.pop(connection_id, None)
232-
self.invalidate_metadata_cache(connection_id)
233-
return True
254+
with self._registry_lock:
255+
if connection_id not in self.connections:
256+
self.invalidate_metadata_cache(connection_id)
257+
return False
258+
lock = self._connection_locks.setdefault(connection_id, threading.RLock())
259+
with lock:
260+
# A preceding close may have removed the connection while this
261+
# caller was waiting for the per-connection lock.
262+
with self._registry_lock:
263+
current = self.connections.get(connection_id)
264+
if current is None:
265+
return False
266+
return self._close_connection_unlocked(connection_id, current)
234267

235268
def get_connection_info(self, connection_id: str) -> Optional[DatabaseConfig]:
236269
"""
@@ -242,7 +275,8 @@ def get_connection_info(self, connection_id: str) -> Optional[DatabaseConfig]:
242275
Returns:
243276
Database configuration or None if not found
244277
"""
245-
config = self.connection_configs.get(connection_id)
278+
with self._registry_lock:
279+
config = self.connection_configs.get(connection_id)
246280
return config.model_copy(deep=True) if config else None
247281

248282
def list_connections(self) -> Dict[str, Dict[str, Any]]:
@@ -252,15 +286,18 @@ def list_connections(self) -> Dict[str, Dict[str, Any]]:
252286
Returns:
253287
Dict of connection_id -> connection_info
254288
"""
289+
with self._registry_lock:
290+
configs = list(self.connection_configs.items())
291+
active_ids = set(self.connections)
255292
result = {}
256-
for conn_id, config in self.connection_configs.items():
293+
for conn_id, config in configs:
257294
result[conn_id] = {
258295
"type": config.type,
259296
"host": config.host,
260297
"port": config.port,
261298
"database": config.database,
262299
"username": config.username,
263-
"status": "active" if conn_id in self.connections else "closed"
300+
"status": "active" if conn_id in active_ids else "closed"
264301
}
265302
return result
266303

@@ -275,12 +312,33 @@ def get_cursor(self, connection_id: str):
275312
Yields:
276313
Database cursor
277314
"""
278-
connection = self.get_connection(connection_id)
279-
cursor = connection.cursor()
280-
try:
281-
yield cursor
282-
finally:
283-
cursor.close()
315+
with self._connection_lock(connection_id):
316+
connection = self._get_connection_unlocked(connection_id)
317+
try:
318+
if getattr(connection, "closed", 0):
319+
raise DatabaseConnectionError("connection is closed")
320+
if hasattr(connection, "ping"):
321+
connection.ping(reconnect=True)
322+
except Exception as exc:
323+
logger.warning("Connection %s is dead, removing: %s", connection_id, exc)
324+
self._close_connection_unlocked(connection_id, connection)
325+
raise DatabaseConnectionError(
326+
f"Connection {connection_id} is no longer valid"
327+
) from exc
328+
try:
329+
cursor = connection.cursor()
330+
except Exception as exc:
331+
logger.warning("Connection %s could not create a cursor: %s", connection_id, exc)
332+
self._close_connection_unlocked(connection_id, connection)
333+
raise DatabaseConnectionError(
334+
f"Connection {connection_id} is no longer valid"
335+
) from exc
336+
try:
337+
# Exceptions from the caller's SQL body must propagate without
338+
# evicting a healthy connection from the registry.
339+
yield cursor
340+
finally:
341+
cursor.close()
284342

285343
def execute_query(self, connection_id: str, query: str, params: Optional[tuple] = None) -> List[Dict[str, Any]]:
286344
"""

0 commit comments

Comments
 (0)