Coverage for src/local_deep_research/web/routes/globals.py: 99%

124 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +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 

11 

12from sqlalchemy.orm import Session 

13 

14# --------------------------------------------------------------------------- 

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

16# --------------------------------------------------------------------------- 

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

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

19 

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

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

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

23_lock = threading.RLock() 

24 

25 

26# =================================================================== 

27# active_research accessors 

28# =================================================================== 

29 

30 

31def is_research_active(research_id): 

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

33 with _lock: 

34 return research_id in _active_research 

35 

36 

37def get_active_research_ids(): 

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

39 with _lock: 

40 return list(_active_research.keys()) 

41 

42 

43def get_active_research_snapshot(research_id): 

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

45 

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

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

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

49 """ 

50 with _lock: 

51 entry = _active_research.get(research_id) 

52 if entry is None: 

53 return None 

54 return { 

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

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

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

58 "settings": dict(s) 

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

60 else None, 

61 } 

62 

63 

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

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

66 

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

68 callers cannot accidentally mutate shared state. 

69 """ 

70 with _lock: 

71 entry = _active_research.get(research_id) 

72 if entry is None: 

73 return default 

74 value = entry.get(field, default) 

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

76 # because entries may contain threading.Thread objects — 

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

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

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

80 if isinstance(value, list): 

81 return list(value) 

82 if isinstance(value, dict): 

83 return dict(value) 

84 return value 

85 

86 

87def set_active_research(research_id, data): 

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

89 with _lock: 

90 _active_research[research_id] = data 

91 

92 

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

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

95 

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

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

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

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

100 

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

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

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

104 """ 

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

106 if thread is None: 

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

108 with _lock: 

109 entry = _active_research.get(research_id) 

110 if entry is not None: 

111 existing = entry.get("thread") 

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

113 return False 

114 thread.start() 

115 _active_research[research_id] = data 

116 return True 

117 

118 

119def update_active_research(research_id, **fields): 

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

121 

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

123 """ 

124 with _lock: 

125 entry = _active_research.get(research_id) 

126 if entry is not None: 

127 entry.update(fields) 

128 

129 

130def append_research_log(research_id, log_entry): 

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

132 

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

134 """ 

135 with _lock: 

136 entry = _active_research.get(research_id) 

137 if entry is not None: 

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

139 

140 

141def update_progress_if_higher(research_id, new_progress): 

142 """Atomically update progress only if *new_progress* exceeds current. 

143 

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

145 not active. 

146 """ 

147 with _lock: 

148 entry = _active_research.get(research_id) 

149 if entry is None: 

150 return None 

151 current = entry.get("progress", 0) 

152 if new_progress is not None and new_progress > current: 

153 entry["progress"] = new_progress 

154 return new_progress 

155 return current 

156 

157 

158def remove_active_research(research_id): 

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

160 with _lock: 

161 _active_research.pop(research_id, None) 

162 

163 

164def iter_active_research(): 

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

166 

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

168 outside the lock. 

169 """ 

170 with _lock: 

171 items = [ 

172 ( 

173 rid, 

174 { 

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

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

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

178 "settings": dict(s) 

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

180 else None, 

181 }, 

182 ) 

183 for rid, entry in _active_research.items() 

184 ] 

185 for rid, data in items: 

186 yield rid, data 

187 

188 

189def get_active_research_count(): 

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

191 with _lock: 

192 return len(_active_research) 

193 

194 

195def get_usernames_with_active_research() -> set: 

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

197 with _lock: 

198 return { 

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

200 for entry in _active_research.values() 

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

202 } 

203 

204 

205# =================================================================== 

206# termination_flags accessors 

207# =================================================================== 

208 

209 

210def is_termination_requested(research_id): 

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

212 with _lock: 

213 return _termination_flags.get(research_id, False) 

214 

215 

216def set_termination_flag(research_id): 

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

218 with _lock: 

219 _termination_flags[research_id] = True 

220 

221 

222def clear_termination_flag(research_id): 

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

224 with _lock: 

225 _termination_flags.pop(research_id, None) 

226 

227 

228# =================================================================== 

229# Compound / cleanup helpers 

230# =================================================================== 

231 

232 

233def is_research_thread_alive(research_id): 

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

235 

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

237 """ 

238 with _lock: 

239 entry = _active_research.get(research_id) 

240 if entry is None: 

241 return False 

242 thread = entry.get("thread") 

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

244 

245 

246def update_progress_and_check_active(research_id, new_progress): 

247 """Atomically update progress (if higher) and check if research is active. 

248 

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

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

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

252 """ 

253 with _lock: 

254 entry = _active_research.get(research_id) 

255 if entry is None: 

256 return (None, False) 

257 current = entry.get("progress", 0) 

258 if new_progress is not None and new_progress > current: 

259 entry["progress"] = new_progress 

260 return (new_progress, True) 

261 return (current, True) 

262 

263 

264def cleanup_research(research_id): 

265 """Remove *research_id* from both active_research and termination_flags 

266 atomically under the single shared lock. 

267 """ 

268 with _lock: 

269 _active_research.pop(research_id, None) 

270 _termination_flags.pop(research_id, None) 

271 

272 

273def reclaim_stale_user_active_research( 

274 db_session: Session, username, *, grace_cutoff_dt=None, logger=None 

275): 

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

277 

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

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

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

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

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

283 

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

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

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

287 

288 Args: 

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

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

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

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

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

294 disables the grace filter — matches the 

295 ``research_routes.start_research`` original behaviour. 

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

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

298 trace why an active-research cap was released. 

299 

300 Returns: 

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

302 ``False``. 

303 """ 

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

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

306 # the ORM models in scope. 

307 from ...constants import ResearchStatus 

308 from ...database.models import UserActiveResearch 

309 

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

311 UserActiveResearch.username == username, 

312 UserActiveResearch.status == ResearchStatus.IN_PROGRESS, 

313 ) 

314 if grace_cutoff_dt is not None: 

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

316 

317 reclaimed = False 

318 for row in query.all(): 

319 if is_research_thread_alive(row.research_id): 

320 continue 

321 if logger is not None: 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true

322 logger.warning( 

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

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

325 short_id=row.research_id[:8], 

326 user=username, 

327 ) 

328 row.status = ResearchStatus.FAILED 

329 cleanup_research(row.research_id) 

330 reclaimed = True 

331 return reclaimed