Coverage for src/local_deep_research/database/session_context.py: 98%

118 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Database session context manager and decorator for encrypted databases. 

3Ensures all database access has proper encryption context. 

4""" 

5 

6import functools 

7from contextlib import contextmanager 

8from typing import Callable, Iterator, Optional 

9 

10from loguru import logger 

11from sqlalchemy.exc import SQLAlchemyError 

12from sqlalchemy.orm import Session 

13 

14from ..security.log_sanitizer import scrub_error 

15from ..utilities.thread_context import get_search_context 

16from .encrypted_db import db_manager 

17from .rekey_lock import RekeyOperationalError 

18from .thread_local_session import thread_session_manager 

19 

20# Placeholder password used when accessing unencrypted databases. 

21# This should only be used when LDR_ALLOW_UNENCRYPTED=true is set. 

22UNENCRYPTED_DB_PLACEHOLDER = "unencrypted-mode" 

23 

24 

25class DatabaseSessionError(Exception): 

26 """Raised when database session cannot be established.""" 

27 

28 pass 

29 

30 

31class DatabaseSessionUnavailableError(DatabaseSessionError): 

32 """The session could not be established for an operational reason. 

33 

34 ``get_user_db_session`` raises this instead of the base class when 

35 opening or claiming the user's database failed with a 

36 ``RekeyOperationalError``: the rekey sidecar could not be opened, locked 

37 or released, an epoch file could not be read or first published (an 

38 fd-exhaustion spike, EACCES, EIO), or the durable rekey state needs 

39 recovery first. None of these is a verdict on the credential or on the 

40 session. A request handler that would end the session on a 

41 ``DatabaseSessionError`` answers this one with a retryable 503 and keeps 

42 the session instead (``database_unavailable_exception`` in 

43 ``web/dependencies/auth.py``). 

44 

45 ``reason`` is the underlying fault's type name, for diagnostics. The 

46 fault itself is neither chained nor kept: the frames it was raised 

47 through hold the plaintext password. 

48 """ 

49 

50 def __init__(self, reason: str) -> None: 

51 self.reason = reason 

52 super().__init__("Database session unavailable") 

53 

54 

55def safe_rollback(session: Session, context: str = "") -> None: 

56 """Roll back the session, swallowing and logging any rollback failure. 

57 

58 SQLAlchemy requires explicit rollback after a failed flush/commit before 

59 the session is usable again. Skipping it leaves the session in 

60 PendingRollbackError state and every subsequent ORM operation cascades. 

61 

62 This helper exists so call sites can recover the session in one line 

63 without repeating the try/except/log boilerplate at every except handler. 

64 ``context`` is included in the error log so failed rollbacks can be 

65 traced back to the call site. 

66 

67 Two SQLAlchemy error shapes are treated as "the session is structurally 

68 unusable — give up on it, drop the thread-local cache, and let the next 

69 caller get a fresh one" rather than as loud failures: 

70 

71 * ``InvalidRequestError("...provisioning a new connection; concurrent 

72 operations are not permitted...")`` — the second thread racing on the 

73 per-user QueuePool. 

74 * ``InterfaceError("Cursor needed to be reset because of commit/rollback 

75 and can no longer be fetched from")`` — a cursor was invalidated by a 

76 commit/rollback that fired between the original ``execute()`` and the 

77 lazy-attribute fetch that followed. 

78 

79 In both cases SQLAlchemy has already marked the session unrecoverable, so 

80 a no-op rollback is correct, the thread-local cache is cleared, and the 

81 failure is logged at DEBUG so the production stderr stream stays clean. 

82 Other ``SQLAlchemyError`` failures still hit the loud ``logger.exception`` 

83 path (unless their message matches a known broken-session signature) — 

84 the session is normally recoverable via rollback and the operator needs 

85 to see them. 

86 """ 

87 if session is None: 

88 return 

89 log_msg = ( 

90 f"Failed to rollback session: {context}" 

91 if context 

92 else "Failed to rollback session" 

93 ) 

94 try: 

95 session.rollback() 

96 except SQLAlchemyError as exc: 

97 msg = str(exc) 

98 # Message-substring matching pinned against SQLAlchemy 2.0+ QueuePool error messages 

99 # ('provisioning a new connection', 'concurrent operations are not permitted'). 

100 is_provisioning_race = ( 

101 "provisioning a new connection" in msg 

102 or "concurrent operations are not permitted" in msg 

103 ) 

104 # Requires 'Cursor needed to be reset' message match so other InterfaceErrors 

105 # (e.g., driver/connectivity failures) are not quietly swallowed into the DEBUG path. 

106 is_cursor_invalidated = "Cursor needed to be reset" in msg 

107 if is_provisioning_race or is_cursor_invalidated: 

108 label = f": {context}" if context else "" 

109 logger.debug(f"safe_rollback — resetting broken session{label}") 

110 # Drop the thread-local cache so the next caller on this 

111 # thread gets a fresh session. Identity-checked inside the 

112 # helper, so a caller that hands in a session that ISN'T 

113 # the cached one (e.g. borrowed from ``g.db_session`` or 

114 # owned by a different thread) won't accidentally clear 

115 # someone else's cache. The reset itself is best-effort — 

116 # never let it raise past ``safe_rollback`` (call sites are 

117 # themselves in except handlers). 

118 try: 

119 thread_session_manager.reset_session_if_matches(session) 

120 except Exception: 

121 logger.debug( 

122 f"safe_rollback: reset_session_if_matches raised for{label}" 

123 ) 

124 return 

125 logger.exception(log_msg) 

126 except Exception: 

127 logger.exception(log_msg) 

128 

129 

130@contextmanager 

131def telemetry_savepoint(session: Session) -> Iterator[None]: 

132 """Write best-effort telemetry in a SAVEPOINT on a borrowed session. 

133 

134 The telemetry joins the caller's transaction only when the caller 

135 already holds an open write transaction; otherwise it commits at once 

136 in a short transaction of its own. The signal is the DBAPI connection's 

137 ``in_transaction`` flag. Production sqlite3/SQLCipher connections use 

138 legacy deferred mode (``isolation_level=""``): the driver opens a 

139 transaction only before INSERT, UPDATE or DELETE and closes it on commit 

140 or rollback, so a SELECT never opens one. ``Session.in_transaction()`` 

141 and the pending ``new``/``dirty``/``deleted`` collections cannot make 

142 this distinction: any query autobegins SQLAlchemy's logical transaction, 

143 and a flushed write leaves those collections empty. 

144 

145 * Open (the caller has written, or began a transaction explicitly as 

146 ``research_cascade._ensure_outer_db_transaction`` does): the SAVEPOINT 

147 nests inside it, so the telemetry commits or rolls back with the 

148 caller, and no second connection waits on this thread's own writer 

149 lock. 

150 * Idle (the scope has only read, or has already committed): SQLite 

151 begins a transaction for the outermost SAVEPOINT and RELEASE commits 

152 it. The telemetry is durable at once, survives the scope's exit 

153 rollback, and holds the writer lock only while it writes. If the 

154 telemetry fails, that transaction is rolled back here, because 

155 ROLLBACK TO alone would keep it (and any writer lock it took) open 

156 until the caller's scope ends. 

157 

158 Other dialects begin their transaction with the first statement, so the 

159 savepoint always nests there. A connection that does not report a 

160 definite ``False`` counts as open and is never rolled back here. 

161 Failures propagate to the caller, which logs and skips the telemetry. 

162 """ 

163 connection = session.connection() 

164 owned_connection = None 

165 if connection.dialect.name == "sqlite": 

166 driver_connection = connection.connection.driver_connection 

167 if getattr(driver_connection, "in_transaction", None) is False: 

168 owned_connection = driver_connection 

169 try: 

170 with session.begin_nested(): 

171 yield 

172 except BaseException: 

173 if owned_connection is not None and owned_connection.in_transaction: 

174 try: 

175 owned_connection.rollback() 

176 except Exception: 

177 logger.debug("Could not end a failed telemetry transaction") 

178 raise 

179 

180 

181@contextmanager 

182def get_user_db_session( 

183 username: Optional[str] = None, 

184 password: Optional[str] = None, 

185 session_id: Optional[str] = None, 

186): 

187 """ 

188 Context manager that ensures proper database session with encryption. 

189 Now uses thread-local sessions for better performance. 

190 

191 Args: 

192 username: Username (required; must be passed explicitly under FastAPI). 

193 password: Password for encrypted database (required for first access). 

194 session_id: Optional session ID for exact per-session password lookup. 

195 Request handlers should pass the current request's session_id so 

196 two concurrent sessions for the same user can't cross-pollinate. 

197 If omitted, the resolver falls back to scanning any active session 

198 for the user — fine for background threads, unsafe for request 

199 handlers. 

200 

201 Yields: 

202 Database session for the user 

203 

204 Raises: 

205 DatabaseSessionUnavailableError: If the session cannot be 

206 established because of a ``RekeyOperationalError`` (a rekey 

207 sidecar or epoch fault, or pending rekey recovery). A subclass 

208 of ``DatabaseSessionError``. 

209 DatabaseSessionError: If session cannot be established 

210 """ 

211 # Import here to avoid circular imports 

212 from .thread_local_session import get_metrics_session 

213 from .session_passwords import session_password_store 

214 

215 if not username: 

216 raise DatabaseSessionError("No authenticated user") 

217 

218 # Resolve password from the provided session_id, then fall back to 

219 # any active session, then the thread context for background workers. 

220 if not password and session_id: 

221 password = session_password_store.get_session_password( 

222 username, session_id 

223 ) 

224 if password: 

225 logger.debug(f"Got password from session store for {username}") 

226 

227 if not password: 

228 # Scan active sessions for this user. Safe for background threads; 

229 # request handlers should pass session_id explicitly. 

230 password = session_password_store.get_any_session_password(username) 

231 

232 if not password: 

233 thread_context = get_search_context() 

234 if thread_context and thread_context.get("user_password"): 

235 password = thread_context["user_password"] 

236 logger.debug(f"Got password from thread context for {username}") 

237 

238 if not password and db_manager.has_encryption: 

239 raise DatabaseSessionError( 

240 f"Encrypted database for {username} requires password" 

241 ) 

242 if not password: 

243 logger.warning( 

244 f"Accessing unencrypted database for {username} - " 

245 "ensure this is intentional (LDR_ALLOW_UNENCRYPTED=true)" 

246 ) 

247 password = UNENCRYPTED_DB_PLACEHOLDER 

248 

249 # A RekeyOperationalError from opening or claiming the database (in 

250 # open_user_database or in create_thread_safe_session_for_metrics, which 

251 # both let it through) is not a credential verdict, so it gets its own 

252 # subclass: request handlers answer it with a retryable 503 that keeps 

253 # the session, where the base class may end it. Raised after the except 

254 # block, so the new error does not even carry the fault as its 

255 # suppressed __context__: the frames it came through hold the password. 

256 session = None 

257 unavailable_reason = None 

258 try: 

259 session = get_metrics_session(username, password) 

260 except RekeyOperationalError as error: 

261 unavailable_reason = type(error).__name__ 

262 if unavailable_reason is not None: 

263 logger.warning( 

264 f"Database session unavailable for {username}: {unavailable_reason}" 

265 ) 

266 raise DatabaseSessionUnavailableError(unavailable_reason) 

267 if not session: 

268 raise DatabaseSessionError( 

269 f"Could not establish session for {username}" 

270 ) 

271 

272 # The session is a reused thread-local session. On normal scope exit 

273 # (outermost block) the checked-out connection is released in the 

274 # ``finally`` below, so the pool isn't pinned for the thread's lifetime. 

275 # We MUST wrap the yield in try/except so an exception inside the ``with`` 

276 # block doesn't leave a half-committed transaction attached to this 

277 # thread's session: the next caller on the same thread would otherwise 

278 # inherit that dirty state. Thread cleanup (``cleanup_current_thread()``) 

279 # remains as a safety net for paths that never exit a scope. 

280 # 

281 # The scope depth tells get_session's re-validation whether an 

282 # enclosing block is still active on this thread: a nested 

283 # get_user_db_session call (e.g. a helper opening its own session 

284 # inside a caller's with-block) must NOT trigger the stale-lock 

285 # rollback, or the caller's uncommitted writes are destroyed. 

286 from .thread_local_session import thread_session_manager 

287 

288 thread_session_manager.enter_scope() 

289 try: 

290 yield session 

291 except Exception: 

292 # The yielded session is a *reused* thread-local session, not a 

293 # fresh one closed on exit. If the caller's ``with`` block raised 

294 # (most importantly a failed ``session.commit()``/``flush()``), 

295 # the session is left in ``PendingRollbackError`` state and the 

296 # next operation on this thread cascades. Roll it back here so an 

297 # unguarded ``with`` block can't poison the thread, then re-raise 

298 # so the original error still surfaces to the caller. 

299 safe_rollback(session, "get_user_db_session") 

300 raise 

301 finally: 

302 thread_session_manager.exit_scope() 

303 if not thread_session_manager.in_scope(): 

304 try: 

305 # If a transaction was left open (e.g. from read queries autobeginning 

306 # a transaction and checking out a connection from QueuePool, or uncommitted writes), 

307 # roll back on outermost exit to return the checked-out connection to QueuePool 

308 # and release SQLite SHARED read locks. 

309 if ( 

310 hasattr(session, "in_transaction") 

311 and session.in_transaction() 

312 ): 

313 safe_rollback(session, "get_user_db_session exit") 

314 except Exception as exc: 

315 logger.debug( 

316 f"safe_rollback on get_user_db_session exit raised: {scrub_error(exc)}" 

317 ) 

318 

319 

320def with_user_database(func: Callable) -> Callable: 

321 """ 

322 Decorator that ensures function has access to user's database. 

323 Injects 'db_session' as first argument to the decorated function. 

324 

325 Usage: 

326 @with_user_database 

327 def get_user_settings(db_session, setting_key): 

328 return db_session.query(Setting).filter_by(key=setting_key).first() 

329 """ 

330 

331 @functools.wraps(func) 

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

333 # Check if username/password provided in kwargs 

334 username = kwargs.pop("_username", None) 

335 password = kwargs.pop("_password", None) 

336 

337 with get_user_db_session(username, password) as db_session: 

338 return func(db_session, *args, **kwargs) 

339 

340 return wrapper 

341 

342 

343class DatabaseAccessMixin: 

344 """ 

345 Mixin class for services that need database access. 

346 Provides convenient methods for database operations. 

347 """ 

348 

349 def get_db_session( 

350 self, username: Optional[str] = None 

351 ) -> Optional[Session]: 

352 """ 

353 DEPRECATED: This method returns a closed session due to context manager exit. 

354 

355 Use `with get_user_db_session(username) as session:` instead. 

356 

357 Raises: 

358 DeprecationWarning: Always raised to prevent usage of broken method. 

359 """ 

360 raise DeprecationWarning( 

361 "get_db_session() is deprecated and returns a closed session. " 

362 "Use `with get_user_db_session(username) as session:` instead." 

363 ) 

364 

365 @with_user_database 

366 def execute_with_db( 

367 self, db_session: Session, query_func: Callable, *args, **kwargs 

368 ): 

369 """Execute a function with database session.""" 

370 return query_func(db_session, *args, **kwargs)