Coverage for src/local_deep_research/database/thread_local_session.py: 92%

194 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1""" 

2Thread-local database session management. 

3Each thread gets its own database session, reused until its owned cleanup boundary. 

4""" 

5 

6import functools 

7import threading 

8from contextlib import ContextDecorator 

9from typing import Optional, Dict, Tuple 

10from sqlalchemy import text 

11from sqlalchemy.exc import PendingRollbackError 

12from sqlalchemy.orm import Session 

13from loguru import logger 

14 

15from .encrypted_db import db_manager 

16 

17 

18class ThreadLocalSessionManager: 

19 """ 

20 Manages database sessions per thread. 

21 Each thread gets its own session, reused until the request or task finishes. 

22 """ 

23 

24 def __init__(self): 

25 # Thread-local storage for sessions 

26 self._local = threading.local() 

27 # Track credentials per thread ID (for cleanup) 

28 self._thread_credentials: Dict[int, Tuple[str, str]] = {} 

29 self._lock = threading.Lock() 

30 

31 def enter_scope(self) -> None: 

32 """Mark entry into a ``get_user_db_session`` block on this thread. 

33 

34 ``get_session`` uses the depth to tell a left-over transaction 

35 from a *completed* block (safe and necessary to roll back — it 

36 holds a SQLite SHARED lock) apart from an *enclosing* block that 

37 is still active (rolling back would destroy the caller's 

38 uncommitted writes).""" 

39 self._local.scope_depth = getattr(self._local, "scope_depth", 0) + 1 

40 

41 def exit_scope(self) -> None: 

42 """Mark exit from a ``get_user_db_session`` block on this thread.""" 

43 self._local.scope_depth = max( 

44 0, getattr(self._local, "scope_depth", 0) - 1 

45 ) 

46 

47 def in_scope(self) -> bool: 

48 """Whether an enclosing ``get_user_db_session`` block is active 

49 on this thread.""" 

50 return getattr(self._local, "scope_depth", 0) > 0 

51 

52 def get_session(self, username: str, password: str) -> Optional[Session]: 

53 """ 

54 Get or create a database session for the current thread. 

55 

56 The session is created once per thread and reused for all subsequent calls. 

57 This avoids the expensive SQLCipher decryption on every database access. 

58 """ 

59 thread_id = threading.get_ident() 

60 

61 # Check if we already have a session for this thread 

62 if hasattr(self._local, "session") and self._local.session: 

63 # SECURITY: ensure cached session belongs to the requesting user 

64 if getattr(self._local, "username", None) != username: 

65 logger.warning( 

66 f"Thread {thread_id}: Session username mismatch " 

67 f"(cached={self._local.username!r}, requested={username!r}), " 

68 "clearing stale cross-user session" 

69 ) 

70 self._cleanup_thread_session() 

71 else: 

72 # Verify it's still valid 

73 try: 

74 self._local.session.execute(text("SELECT 1")) 

75 if not self.in_scope(): 

76 # Under DEFERRED isolation the validation SELECT 

77 # opens a transaction that holds a SHARED lock on 

78 # SQLite until an explicit commit/rollback. A 

79 # long-lived thread-local session reused across 

80 # requests would keep that lock held and block 

81 # the first writer. Roll it back so subsequent 

82 # callers start fresh. 

83 self._local.session.rollback() 

84 # else: re-entrant call from inside an enclosing 

85 # get_user_db_session block on this thread — the 

86 # validation SELECT joined the caller's open 

87 # transaction, and rolling back here would silently 

88 # destroy the caller's uncommitted writes (concrete 

89 # data loss in library_rag_service.index_document, 

90 # whose nested helpers open their own sessions). 

91 return self._local.session 

92 except PendingRollbackError: 

93 # Session has a pending rollback (e.g. from a previous database lock error). 

94 # Attempt rollback to recover without destroying the session. 

95 logger.debug( 

96 f"Thread {thread_id}: PendingRollbackError, attempting rollback recovery" 

97 ) 

98 try: 

99 self._local.session.rollback() 

100 self._local.session.execute(text("SELECT 1")) 

101 self._local.session.rollback() 

102 return self._local.session 

103 except Exception: 

104 logger.warning( 

105 f"Thread {thread_id}: Rollback recovery failed, creating new session" 

106 ) 

107 self._cleanup_thread_session() 

108 except Exception: 

109 # Session is invalid, will create a new one 

110 logger.debug( 

111 f"Thread {thread_id}: Existing session invalid, creating new one" 

112 ) 

113 self._cleanup_thread_session() 

114 

115 # Create new session for this thread 

116 logger.debug( 

117 f"Thread {thread_id}: Creating new database session for user {username}" 

118 ) 

119 

120 # Ensure database is open. open_user_database returns None for 

121 # credential failures and raises DatabaseInitializationError when 

122 # the schema can't be initialised; from a worker-thread caller 

123 # both mean "no usable session right now" so collapse them. 

124 from .encrypted_db import DatabaseInitializationError 

125 

126 try: 

127 engine = db_manager.open_user_database(username, password) 

128 except DatabaseInitializationError: 

129 # ``logger.warning`` (no traceback) rather than 

130 # ``logger.exception``: ``password`` is a live local in this 

131 # frame, so rendering a traceback under ``diagnose=True`` 

132 # would dump the plaintext SQLCipher master password 

133 # (unrecoverable — TRUST.md §5). The redacted failure detail 

134 # is already logged at the raise site in 

135 # ``open_user_database`` (#4182). 

136 logger.warning( 

137 f"Thread {thread_id}: database init failed for user {username}" 

138 ) 

139 return None 

140 if not engine: 

141 logger.error( 

142 f"Thread {thread_id}: Failed to open database for user {username}" 

143 ) 

144 return None 

145 

146 # Create session for this thread 

147 session = db_manager.create_thread_safe_session_for_metrics( 

148 username, password 

149 ) 

150 if not session: 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true

151 logger.error( 

152 f"Thread {thread_id}: Failed to create session for user {username}" 

153 ) 

154 return None 

155 

156 # Store in thread-local storage 

157 self._local.session = session 

158 self._local.username = username 

159 

160 # Track credentials for cleanup 

161 with self._lock: 

162 self._thread_credentials[thread_id] = (username, password) 

163 

164 return session 

165 

166 def get_current_session(self) -> Optional[Session]: 

167 """Get the current thread's session if it exists.""" 

168 if hasattr(self._local, "session"): 

169 return self._local.session 

170 return None 

171 

172 def _cleanup_thread_session(self): 

173 """Clean up the current thread's session. 

174 

175 Sessions are bound to the shared per-user QueuePool engine, so 

176 closing the session returns its connection to the pool — there 

177 are no per-thread engines to dispose. 

178 """ 

179 thread_id = threading.get_ident() 

180 

181 if hasattr(self._local, "session") and self._local.session: 

182 try: 

183 self._local.session.rollback() 

184 except Exception: 

185 logger.warning( 

186 f"Thread {thread_id}: Error rolling back session during cleanup" 

187 ) 

188 try: 

189 self._local.session.close() 

190 logger.debug(f"Thread {thread_id}: Closed database session") 

191 except Exception: 

192 logger.warning(f"Thread {thread_id}: Error closing session") 

193 finally: 

194 self._local.session = None 

195 

196 if hasattr(self._local, "username"): 

197 self._local.username = None 

198 

199 # Remove from tracking 

200 with self._lock: 

201 self._thread_credentials.pop(thread_id, None) 

202 

203 def reset_session_if_matches(self, session: "Session | None") -> bool: 

204 """If ``session`` is the current thread's cached session, clear it. 

205 

206 Used by ``safe_rollback`` in ``database/session_context.py`` to heal 

207 the thread-local session cache when an error tells us the session 

208 is structurally unusable (a provisioning race or an invalidated 

209 cursor). The next caller on this thread then gets a fresh session 

210 from the shared ``QueuePool`` instead of inheriting the broken 

211 one. 

212 

213 Identity-checked: a caller that hands in a session from a 

214 different thread (or a session that isn't the cached one at all 

215 — e.g. one borrowed from ``g.db_session``) won't accidentally 

216 clear someone else's cache. Returns ``True`` if a reset 

217 actually happened, ``False`` otherwise. 

218 """ 

219 if session is None: 

220 return False 

221 if not hasattr(self._local, "session") or self._local.session is None: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true

222 return False 

223 if self._local.session is not session: 

224 return False 

225 self._cleanup_thread_session() 

226 return True 

227 

228 def cleanup_thread(self, thread_id: Optional[int] = None): 

229 """ 

230 Clean up session for a specific thread or current thread. 

231 Called when a thread is finishing. 

232 """ 

233 if thread_id is None: 

234 thread_id = threading.get_ident() 

235 

236 # If it's the current thread, we can clean up directly 

237 if thread_id == threading.get_ident(): 

238 self._cleanup_thread_session() 

239 else: 

240 # For other threads, just remove from tracking 

241 # The thread-local storage will be cleaned up when the thread ends 

242 with self._lock: 

243 self._thread_credentials.pop(thread_id, None) 

244 

245 def cleanup_dead_threads(self): 

246 """Remove credential entries for threads that are no longer alive. 

247 

248 Handles the abnormal case of threads that died without triggering 

249 their cleanup handler. Uses threading.enumerate() to identify 

250 alive threads and removes credential entries for dead ones. 

251 Sessions on dead threads are garbage-collected normally and 

252 their connections return to the shared per-user QueuePool. 

253 

254 Called from: 

255 - processor_v2.py: every ~60s in the queue loop 

256 - fastapi_app.py: in the request middleware's post-request cleanup 

257 - connection_cleanup.py: in cleanup_idle_connections (every ~300s) 

258 """ 

259 alive_ids = {t.ident for t in threading.enumerate()} 

260 with self._lock: 

261 dead_ids = [ 

262 tid for tid in self._thread_credentials if tid not in alive_ids 

263 ] 

264 for tid in dead_ids: 

265 del self._thread_credentials[tid] 

266 if dead_ids: 

267 logger.debug(f"Swept {len(dead_ids)} dead thread credential(s)") 

268 

269 def clear_credentials_for_user(self, username: str) -> int: 

270 """Drop cached plaintext credentials for one user, on any thread. 

271 

272 Entries here hold the user's plaintext SQLCipher password so a 

273 pooled worker can rebuild its session without a round-trip to the 

274 password store. Synchronous routes normally drop their owner-thread 

275 entry through ``WorkerCleanupAPIRoute``. This username-wide cleanup 

276 is still required on logout because another worker may be executing 

277 background work for the same user, and one thread cannot clear a 

278 different thread's ``threading.local`` state. 

279 

280 Only the tracking dict is cleared. The owning thread's 

281 ``threading.local`` session is deliberately left alone: one thread 

282 cannot reach into another's local storage, and the session is 

283 harmless once the credential is gone — the username re-validation at 

284 the top of ``get_session()`` evicts it before any reuse. In-flight 

285 work is unaffected too, since ``get_session()`` is passed the 

286 password explicitly and never reads it back out of this dict. 

287 

288 Returns the number of entries dropped. 

289 """ 

290 with self._lock: 

291 thread_ids = [ 

292 tid 

293 for tid, (user, _password) in self._thread_credentials.items() 

294 if user == username 

295 ] 

296 for tid in thread_ids: 

297 del self._thread_credentials[tid] 

298 if thread_ids: 

299 logger.debug( 

300 f"Cleared cached credentials on {len(thread_ids)} thread(s)" 

301 ) 

302 return len(thread_ids) 

303 

304 def cleanup_all(self): 

305 """Clean up all tracked sessions (for shutdown).""" 

306 with self._lock: 

307 thread_ids = list(self._thread_credentials.keys()) 

308 

309 for thread_id in thread_ids: 

310 self.cleanup_thread(thread_id) 

311 

312 

313# Global instance 

314thread_session_manager = ThreadLocalSessionManager() 

315 

316 

317def get_metrics_session(username: str, password: str) -> Optional[Session]: 

318 """ 

319 Get a database session for metrics operations in the current thread. 

320 The session is created once and reused until the thread's owned cleanup 

321 boundary (request, task, or worker exit). 

322 

323 Note: This specifically uses create_thread_safe_session_for_metrics internally 

324 and should only be used for metrics-related database operations. 

325 """ 

326 return thread_session_manager.get_session(username, password) 

327 

328 

329def get_current_thread_session() -> Optional[Session]: 

330 """Get the current thread's session if it exists.""" 

331 return thread_session_manager.get_current_session() 

332 

333 

334def cleanup_current_thread(): 

335 """Clean up every database session owned by the current thread. 

336 

337 Also clears any passwords cached by ``metrics_writer`` on this thread — 

338 pooled worker threads must not retain plaintext credentials across tasks. 

339 """ 

340 try: 

341 thread_session_manager.cleanup_thread() 

342 except Exception: 

343 logger.debug( 

344 "cleanup_current_thread: error closing thread-local DB session", 

345 exc_info=True, 

346 ) 

347 try: 

348 # Lazy import avoids the encrypted_db -> thread_local_session import 

349 # cycle during module bootstrap. This is the second session path: 

350 # ambient SettingsManager callers use db_utils' per-thread LRU rather 

351 # than ThreadLocalSessionManager. 

352 from ..utilities.db_utils import ( 

353 cleanup_cached_user_sessions_current_thread, 

354 ) 

355 

356 cleanup_cached_user_sessions_current_thread() 

357 except Exception: 

358 # ERROR, not debug: this is the #6095 owner-thread registry 

359 # cleanup that turns a GC-reclaimable session leak into a 

360 # deterministic release. If it ever breaks (e.g. a cachetools 

361 # upgrade renames ``cache_lock``), the fix silently reverts to the 

362 # pre-#6095 behavior (an evicted session waits on the cyclic 

363 # collector to reclaim its pooled connection) with nothing above 

364 # debug level to notice. 

365 # 

366 # Uses logger.exception(), NOT logger.warning(..., exc_info=True): 

367 # this module imports raw loguru (see the module imports above), 

368 # which has no ``exc_info`` parameter. Passing it to warning() 

369 # makes it an ordinary str.format kwarg that is silently discarded, 

370 # so no traceback is emitted at all -- verified empirically. 

371 # ``logger.exception()`` logs at ERROR, louder than WARNING, which 

372 # suits a silent revert to pre-#6095 behaviour. 

373 # 

374 # It DOES attach the traceback: this is plain loguru, not 

375 # SecureLogger, so nothing gates that on diagnose mode. The 

376 # traceback still does not leave the process. The encrypted-DB sink 

377 # persists ``_exception_context(record) + record["message"]`` -- an 

378 # exception type/value prefix, never the frames -- and the frontend 

379 # sink forwards only ``record["message"]``; see ``database_sink``, 

380 # ``_exception_context`` and the frontend sink in 

381 # ``utilities/log_utils.py``. It reaches the stderr/file sinks, 

382 # which is exactly where it is wanted. 

383 logger.exception( 

384 "cleanup_current_thread: error closing cached DB sessions " 

385 "(owner-thread registry cleanup failed — falling back to " 

386 "GC-reclaimed cleanup for this thread's sessions)" 

387 ) 

388 try: 

389 from .thread_metrics import metrics_writer 

390 

391 metrics_writer.clear_passwords() 

392 except Exception: 

393 logger.debug( 

394 "cleanup_current_thread: error clearing metrics_writer passwords", 

395 exc_info=True, 

396 ) 

397 

398 

399def clear_user_credentials(username: str) -> int: 

400 """Drop ``username`` from the session manager's credential registry. 

401 

402 Called on logout so the plaintext SQLCipher password does not outlive 

403 the session that authorised it. See 

404 ``ThreadLocalSessionManager.clear_credentials_for_user`` for why the 

405 registry cleanup differs from owner-thread cleanup. 

406 

407 This function clears only ``ThreadLocalSessionManager``'s process-wide 

408 username-keyed registry. ``thread_metrics.metrics_writer`` storage is 

409 owner-thread-local; normal owned worker boundaries call 

410 ``cleanup_current_thread``, and direct integrations must do likewise. 

411 """ 

412 try: 

413 return thread_session_manager.clear_credentials_for_user(username) 

414 except Exception: 

415 logger.exception("Failed to clear cached credentials on logout") 

416 return 0 

417 

418 

419def cleanup_dead_threads(): 

420 """Sweep dead-thread credential entries from the session manager.""" 

421 try: 

422 thread_session_manager.cleanup_dead_threads() 

423 except Exception: 

424 logger.warning("Dead-thread session sweep failed") 

425 

426 

427class _ThreadCleanup(ContextDecorator): 

428 """Context manager / decorator for thread-local resource cleanup.""" 

429 

430 def __enter__(self): 

431 return self 

432 

433 def __exit__(self, exc_type, exc_val, exc_tb): 

434 try: 

435 cleanup_current_thread() 

436 except Exception: 

437 logger.debug( 

438 "thread_cleanup: error during DB session cleanup", 

439 exc_info=True, 

440 ) 

441 try: 

442 from ..config.thread_settings import clear_settings_context 

443 

444 clear_settings_context() 

445 except Exception: 

446 logger.debug( 

447 "thread_cleanup: error clearing settings context", 

448 exc_info=True, 

449 ) 

450 try: 

451 from ..utilities.thread_context import clear_search_context 

452 

453 clear_search_context() 

454 except Exception: 

455 logger.debug( 

456 "thread_cleanup: error clearing search context", 

457 exc_info=True, 

458 ) 

459 try: 

460 # Defense-in-depth audit hook (PEP 578). Leaving an active 

461 # EgressContext on a pooled worker thread would let the 

462 # next, unrelated task inherit the previous run's scope. 

463 from ..security.egress.audit_hook import clear_active_context 

464 

465 clear_active_context() 

466 except Exception: 

467 logger.debug( 

468 "thread_cleanup: error clearing egress audit context", 

469 exc_info=True, 

470 ) 

471 return False 

472 

473 

474def thread_cleanup(func=None): 

475 """Ensure all thread-local resources are cleaned up when a function or block exits. 

476 

477 Works as a bare decorator, a decorator factory, or a context manager:: 

478 

479 @thread_cleanup 

480 def worker(): ... 

481 

482 @thread_cleanup() 

483 def worker(): ... 

484 

485 with thread_cleanup(): 

486 ... 

487 

488 executor.submit(thread_cleanup(func), arg) 

489 """ 

490 if func is not None: 

491 

492 @functools.wraps(func) 

493 def wrapper(*args, **kwargs): 

494 with _ThreadCleanup(): 

495 return func(*args, **kwargs) 

496 

497 return wrapper 

498 return _ThreadCleanup() 

499 

500 

501# Context manager for automatic cleanup 

502class ThreadSessionContext: 

503 """ 

504 Context manager that ensures thread session is cleaned up. 

505 Usage: 

506 with ThreadSessionContext(username, password) as session: 

507 # Use session 

508 """ 

509 

510 def __init__(self, username: str, password: str): 

511 self.username = username 

512 self.password = password 

513 self.session = None 

514 

515 def __enter__(self) -> Optional[Session]: 

516 self.session = get_metrics_session(self.username, self.password) 

517 return self.session 

518 

519 def __exit__(self, exc_type, exc_val, exc_tb): 

520 # Don't cleanup here - let the thread keep its session 

521 # Only cleanup when thread ends 

522 pass