Coverage for src/local_deep_research/web/research_state.py: 98%

167 statements  

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

1""" 

2Thread-safe global state management. 

3 

4Wraps two module-level dicts (`_active_research`, `_termination_flags`) 

5with accessor functions protected by a single ``threading.RLock``. 

6All external code should use the accessor functions instead of touching the 

7dicts directly. 

8""" 

9 

10import threading 

11from contextlib import contextmanager 

12 

13# --------------------------------------------------------------------------- 

14# Internal state — never import these directly from other modules 

15# --------------------------------------------------------------------------- 

16_active_research: dict[int, dict] = {} 

17_termination_flags: dict[int, bool] = {} 

18 

19# A single lock protects both dicts. They have strongly correlated lifecycles 

20# (entries are created/destroyed together) and operations are fast dict lookups, 

21# so a single lock is simpler and eliminates any deadlock risk from lock ordering. 

22_lock = threading.RLock() 

23 

24# Per-user gate serialising a user's password-change rekey against that user's 

25# own newly-starting research threads. Deliberately NOT the process-global 

26# ``_lock`` above: the SQLCipher ``PRAGMA rekey`` can take seconds, and holding 

27# ``_lock`` across it would freeze EVERY user's research (every log-append / 

28# progress-update / status read takes ``_lock``). Keyed by username; 

29# ``check_and_start_research`` acquires the gate for the research's owner, and 

30# ``change_password`` (web/routers/auth.py) holds it across its "is research 

31# active?" check + rekey, so a rekey blocks only the SAME user's new-research 

32# registration. A dedicated small lock guards the dict so gate lookups never 

33# touch ``_lock`` — keeping the two consistently orderable: a caller always 

34# takes a gate BEFORE ``_lock``, never the reverse. 

35# 

36# INTENTIONALLY NEVER POPPED. This is a per-user mutual-exclusion primitive 

37# that may be HELD across a multi-second SQLCipher ``PRAGMA rekey``. A gate 

38# that may be held across a long operation cannot be safely removed from its 

39# registry: if a logout / idle sweep popped a gate while one tab holds it 

40# across the rekey, a concurrent same-user ``check_and_start_research`` would 

41# find no entry, CREATE A SECOND ``threading.Lock`` instance, acquire it 

42# uncontended, and start a worker that writes the user's DB concurrently with 

43# the in-flight rekey — defeating the exact exclusion this gate exists to 

44# provide. Any lookup-then-acquire scheme has this lookup->acquire gap, so the 

45# only correct policy is to never remove the gate. It also serialises the 

46# per-user capacity count -> DB claim used by fresh requests and queue replay; 

47# an ``RLock`` is required because the outer admission section can reach 

48# ``check_and_start_research``, which takes the same gate before spawning. 

49# The dict is bounded by the user population (one small lock per distinct 

50# username), so it does not grow unbounded. 

51 

52 

53class _ReentrantUserLock: 

54 """Small ``RLock`` adapter with ``locked()`` on supported Python 3.12. 

55 

56 Native ``threading.RLock.locked`` was only added in Python 3.14, while the 

57 project and CI support 3.12. Tracking acquisition depth preserves the 

58 existing lock-introspection contract without relying on private runtime 

59 methods. 

60 """ 

61 

62 def __init__(self) -> None: 

63 self._lock = threading.RLock() 

64 self._state_lock = threading.Lock() 

65 self._depth = 0 

66 

67 def acquire(self, blocking: bool = True, timeout: float = -1) -> bool: 

68 if timeout == -1: 68 ↛ 71line 68 didn't jump to line 71 because the condition on line 68 was always true

69 acquired = self._lock.acquire(blocking) 

70 else: 

71 acquired = self._lock.acquire(blocking, timeout) 

72 if acquired: 

73 with self._state_lock: 

74 self._depth += 1 

75 return acquired 

76 

77 def release(self) -> None: 

78 self._lock.release() 

79 with self._state_lock: 

80 self._depth -= 1 

81 

82 def locked(self) -> bool: 

83 with self._state_lock: 

84 return self._depth > 0 

85 

86 def __enter__(self): 

87 self.acquire() 

88 return self 

89 

90 def __exit__(self, exc_type, exc_value, traceback) -> None: 

91 del exc_type, exc_value, traceback 

92 self.release() 

93 

94 

95_user_research_start_gates: dict[str, _ReentrantUserLock] = {} 

96_user_research_start_gates_lock = threading.Lock() 

97 

98 

99# =================================================================== 

100# active_research accessors 

101# =================================================================== 

102 

103 

104def is_research_active(research_id): 

105 """Return True if *research_id* is in the active-research dict.""" 

106 with _lock: 

107 return research_id in _active_research 

108 

109 

110def get_active_research_ids(): 

111 """Return a list of all active research IDs (snapshot).""" 

112 with _lock: 

113 return list(_active_research.keys()) 

114 

115 

116def get_active_research_snapshot(research_id): 

117 """Return a safe snapshot of an active-research entry, or ``None``. 

118 

119 The returned dict contains only serialisable fields (``thread`` is 

120 excluded). The ``log`` list is shallow-copied — individual entries 

121 are never mutated after creation, so this is safe. 

122 """ 

123 with _lock: 

124 entry = _active_research.get(research_id) 

125 if entry is None: 

126 return None 

127 return { 

128 "progress": entry.get("progress", 0), 

129 "status": entry.get("status"), 

130 "log": list(entry.get("log", [])), 

131 "settings": dict(s) 

132 if (s := entry.get("settings")) is not None 

133 else None, 

134 } 

135 

136 

137def get_research_field(research_id, field, default=None): 

138 """Return a single field from an active-research entry. 

139 

140 For mutable fields (``list``, ``dict``) a shallow copy is returned so 

141 callers cannot accidentally mutate shared state. 

142 """ 

143 with _lock: 

144 entry = _active_research.get(research_id) 

145 if entry is None: 

146 return default 

147 value = entry.get(field, default) 

148 # We explicitly check for list/dict rather than using copy.copy() 

149 # because entries may contain threading.Thread objects — 

150 # copy.copy(Thread) creates a broken shallow copy sharing the same 

151 # OS thread ident. Scalars (int, str, bool) are immutable and safe. 

152 # For Thread objects, access them via iter_active_research() snapshots. 

153 if isinstance(value, list): 

154 return list(value) 

155 if isinstance(value, dict): 

156 return dict(value) 

157 return value 

158 

159 

160def set_active_research(research_id, data): 

161 """Insert or replace the active-research entry for *research_id*.""" 

162 with _lock: 

163 _active_research[research_id] = data 

164 

165 

166def _get_user_research_start_gate(username: str) -> _ReentrantUserLock: 

167 """Return the per-user research-start gate, creating it on first use.""" 

168 with _user_research_start_gates_lock: 

169 gate = _user_research_start_gates.get(username) 

170 if gate is None: 

171 gate = _ReentrantUserLock() 

172 _user_research_start_gates[username] = gate 

173 return gate 

174 

175 

176def get_user_research_start_lock(username: str): 

177 """Return the stable lock shared by admission and worker registration.""" 

178 return _get_user_research_start_gate(username) 

179 

180 

181@contextmanager 

182def user_research_start_gate(username): 

183 """Hold the per-user research-start gate across a check-then-act sequence. 

184 

185 ``check_and_start_research`` acquires this same per-user gate before it 

186 registers and starts a new research thread for a user. Fresh HTTP starts 

187 and persisted queue replay also hold it across their live-count -> DB-claim 

188 sections, so neither can consume the same last user slot. A caller that 

189 holds the gate across its own "is this user researching?" check and a 

190 password-change rekey likewise closes that TOCTOU window for that user. 

191 

192 Unlike the process-global ``_lock`` this does NOT block other users' 

193 research — essential because the rekey it guards can take seconds and 

194 ``_lock`` is taken on every progress/log update. 

195 

196 ``username`` of ``None`` yields without acquiring anything: there is no user 

197 to gate, and real research always carries an owning username. 

198 """ 

199 if username is None: 

200 yield 

201 return 

202 with _get_user_research_start_gate(username): 

203 yield 

204 

205 

206def check_and_start_research(research_id, data) -> bool: 

207 """Atomically register a research entry iff no live thread exists. 

208 

209 If ``_active_research[research_id]`` already holds an entry whose 

210 ``thread`` is alive, returns ``False`` without mutating state and 

211 without calling ``.start()``. Otherwise, starts ``data['thread']`` 

212 and writes *data* into the active-research dict, then returns ``True``. 

213 

214 The entire check-and-start is done under the single shared lock, so 

215 two concurrent callers with the same *research_id* cannot both pass 

216 the liveness check and end up with two live threads for the same ID. 

217 

218 Registration is additionally wrapped in the research owner's per-user 

219 ``user_research_start_gate``, so a new thread cannot register (and start 

220 writing to the pre-rekey engine) while that user's password-change rekey 

221 is in flight — see ``change_password`` in web/routers/auth.py. 

222 """ 

223 thread = data.get("thread") if isinstance(data, dict) else None 

224 if thread is None: 

225 raise ValueError("data must contain a 'thread' entry") 

226 # Owning username drives the per-user rekey gate below. Every real research 

227 # start passes settings["username"] (via start_research_process); a 

228 # missing/None username degrades to no gating (nothing to serialise 

229 # against, since the rekey gate is keyed by username). 

230 username = None 

231 if isinstance(data, dict): 231 ↛ 238line 231 didn't jump to line 238 because the condition on line 231 was always true

232 settings = data.get("settings") 

233 if isinstance(settings, dict): 233 ↛ 238line 233 didn't jump to line 238 because the condition on line 233 was always true

234 username = settings.get("username") 

235 # Serialise against an in-flight password-change rekey for this user. The 

236 # gate is per-user, so this never stalls other users' research; the inner 

237 # ``_lock`` still provides the atomic check-and-start for research_id. 

238 with user_research_start_gate(username): 

239 with _lock: 

240 entry = _active_research.get(research_id) 

241 if entry is not None: 

242 existing = entry.get("thread") 

243 if existing is not None and existing.is_alive(): 

244 return False 

245 thread.start() 

246 _active_research[research_id] = data 

247 return True 

248 

249 

250def update_active_research(research_id, **fields): 

251 """Update one or more fields on an existing entry. 

252 

253 Silently does nothing if *research_id* is not active. 

254 """ 

255 with _lock: 

256 entry = _active_research.get(research_id) 

257 if entry is not None: 

258 entry.update(fields) 

259 

260 

261def append_research_log(research_id, log_entry): 

262 """Append *log_entry* to the ``log`` list for *research_id*. 

263 

264 Silently does nothing if *research_id* is not active. 

265 """ 

266 with _lock: 

267 entry = _active_research.get(research_id) 

268 if entry is not None: 

269 entry.setdefault("log", []).append(log_entry) 

270 

271 

272def update_progress_if_higher(research_id, new_progress): 

273 """Atomically update bounded progress only if it exceeds current. 

274 

275 Returns the resulting progress value, or ``None`` if the research is 

276 not active. 

277 """ 

278 return update_progress_and_check_active(research_id, new_progress)[0] 

279 

280 

281def remove_active_research(research_id): 

282 """Remove *research_id* from the active-research dict (if present).""" 

283 with _lock: 

284 _active_research.pop(research_id, None) 

285 

286 

287def iter_active_research(): 

288 """Yield ``(research_id, snapshot)`` pairs for all active research. 

289 

290 Each *snapshot* is a shallow copy of the entry dict, safe to read 

291 outside the lock. 

292 """ 

293 with _lock: 

294 items = [ 

295 ( 

296 rid, 

297 { 

298 "progress": entry.get("progress", 0), 

299 "status": entry.get("status"), 

300 "log": list(entry.get("log", [])), 

301 "settings": dict(s) 

302 if (s := entry.get("settings")) is not None 

303 else None, 

304 }, 

305 ) 

306 for rid, entry in _active_research.items() 

307 ] 

308 for rid, data in items: 

309 yield rid, data 

310 

311 

312def get_active_research_count(): 

313 """Return the number of active research entries.""" 

314 with _lock: 

315 return len(_active_research) 

316 

317 

318def get_usernames_with_active_research() -> set: 

319 """Return set of usernames that have research currently running.""" 

320 with _lock: 

321 return { 

322 entry.get("settings", {}).get("username") 

323 for entry in _active_research.values() 

324 if entry.get("settings", {}).get("username") 

325 } 

326 

327 

328# =================================================================== 

329# termination_flags accessors 

330# =================================================================== 

331 

332 

333def is_termination_requested(research_id): 

334 """Return ``True`` if termination was requested for *research_id*.""" 

335 with _lock: 

336 return _termination_flags.get(research_id, False) 

337 

338 

339def set_termination_flag(research_id): 

340 """Signal that *research_id* should be terminated.""" 

341 with _lock: 

342 _termination_flags[research_id] = True 

343 

344 

345def clear_termination_flag(research_id): 

346 """Remove the termination flag for *research_id* (if present).""" 

347 with _lock: 

348 _termination_flags.pop(research_id, None) 

349 

350 

351# =================================================================== 

352# Compound / cleanup helpers 

353# =================================================================== 

354 

355 

356def is_research_thread_alive(research_id): 

357 """Return ``True`` if the research thread for *research_id* is alive. 

358 

359 Returns ``False`` if the research is not active or has no thread. 

360 """ 

361 with _lock: 

362 entry = _active_research.get(research_id) 

363 if entry is None: 

364 return False 

365 thread = entry.get("thread") 

366 return thread is not None and thread.is_alive() 

367 

368 

369def update_progress_and_check_active(research_id, new_progress): 

370 """Atomically bound progress to 0..100 and advance it monotonically. 

371 

372 Returns ``(progress_value, is_active)`` where *progress_value* is the 

373 resulting progress (or ``None`` if not active) and *is_active* indicates 

374 whether *research_id* is still in the active-research dict. 

375 """ 

376 with _lock: 

377 entry = _active_research.get(research_id) 

378 if entry is None: 

379 return (None, False) 

380 # A strategy can overshoot its phase allocation. Clamp before the 

381 # monotonic comparison: otherwise an overshoot becomes a permanent 

382 # floor and even the worker's final 100% update cannot correct it. 

383 current = min(100, max(0, entry.get("progress", 0))) 

384 if new_progress is not None: 

385 current = max(current, min(100, max(0, new_progress))) 

386 entry["progress"] = current 

387 return (current, True) 

388 

389 

390def cleanup_research(research_id, *, preserve_termination_flag=False): 

391 """Remove a research's process state atomically under the shared lock. 

392 

393 The normal worker cleanup removes both registries. The HTTP cancellation 

394 path may preserve the flag after removing the active entry so the worker 

395 that is still unwinding can observe the stop request at its next 

396 checkpoint and perform the final cleanup itself. 

397 """ 

398 with _lock: 

399 _active_research.pop(research_id, None) 

400 if not preserve_termination_flag: 

401 _termination_flags.pop(research_id, None) 

402 

403 

404def reclaim_stale_user_active_research( 

405 db_session, username, *, grace_cutoff_dt=None, logger=None 

406): 

407 """Flip ``UserActiveResearch`` rows whose worker thread is dead. 

408 

409 Shared between ``research_routes.start_research`` (no grace window) 

410 and ``chat.routes.send_message`` (30-second grace window) — both 

411 sites historically iterated the same query / status flip / cleanup 

412 pattern inline; consolidating here makes the difference (grace vs. 

413 no grace) explicit instead of two near-duplicate copies drifting. 

414 

415 The helper does NOT commit — callers compose this with surrounding 

416 DB writes and commit once at the end. Returns ``True`` if any rows 

417 were reclaimed so the caller can decide whether a commit is needed. 

418 

419 Args: 

420 db_session: open SQLAlchemy session against the user's DB. 

421 username: the per-user DB owner (used to scope the query). 

422 grace_cutoff_dt: optional ``datetime`` boundary; rows whose 

423 ``started_at`` is at-or-after this are skipped (avoids 

424 killing a sibling request's just-spawned thread). ``None`` 

425 disables the grace filter — matches the 

426 ``research_routes.start_research`` original behaviour. 

427 logger: optional ``loguru``-style logger for the reclaim 

428 audit line. Both call sites log at WARNING so operators can 

429 trace why an active-research cap was released. 

430 

431 Returns: 

432 ``True`` if any row was flipped (caller should commit), else 

433 ``False``. 

434 """ 

435 # Lazy import to avoid pulling SQLAlchemy at module import time; 

436 # this helper is only called from the route paths that already have 

437 # the ORM models in scope. 

438 from ..constants import ResearchStatus 

439 from ..database.models import UserActiveResearch 

440 

441 query = db_session.query(UserActiveResearch).filter( 

442 UserActiveResearch.username == username, 

443 UserActiveResearch.status == ResearchStatus.IN_PROGRESS, 

444 ) 

445 if grace_cutoff_dt is not None: 

446 query = query.filter(UserActiveResearch.started_at < grace_cutoff_dt) 

447 

448 reclaimed = False 

449 for row in query.all(): 

450 if is_research_thread_alive(row.research_id): 

451 continue 

452 if logger is not None: 

453 logger.warning( 

454 "Reclaiming stale UserActiveResearch {short_id}... " 

455 "(thread dead) for user {user}", 

456 short_id=row.research_id[:8], 

457 user=username, 

458 ) 

459 row.status = ResearchStatus.FAILED 

460 cleanup_research(row.research_id) 

461 reclaimed = True 

462 return reclaimed