Coverage for src/local_deep_research/web/queue/processor_v2.py: 93%

666 statements  

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

1""" 

2Queue processor v2 - uses encrypted user databases instead of service.db 

3Supports both direct execution and queue modes. 

4""" 

5 

6import threading 

7import time 

8import uuid 

9from dataclasses import dataclass 

10from enum import Enum, auto 

11from typing import Any, Dict, Optional 

12 

13from loguru import logger 

14from sqlalchemy.orm import Session 

15 

16from ...constants import ResearchStatus 

17from ...database.encrypted_db import db_manager 

18from ...database.models import ( 

19 QueuedResearch, 

20 ResearchHistory, 

21 TaskMetadata, 

22 UserActiveResearch, 

23) 

24from ...database.queue_service import UserQueueService 

25from ...database.session_context import get_user_db_session 

26from ...database.session_passwords import session_password_store 

27from ...exceptions import ( 

28 DuplicateResearchError, 

29 InvalidQueuedResearchOverridesError, 

30 SystemAtCapacityError, 

31) 

32from ...security.log_sanitizer import redact_secrets 

33from ...notifications.queue_helpers import ( 

34 send_research_completed_notification_from_session, 

35 send_research_failed_notification_from_session, 

36) 

37from .lifecycle_cleanup import ( 

38 cleanup_queued_research_state, 

39 reconcile_research_queue_status, 

40) 

41from ..routes.research_validation import ( 

42 validate_research_query_length, 

43 validate_search_overrides, 

44) 

45from ..research_state import get_user_research_start_lock 

46from ..services.research_service import ( 

47 clamp_user_max_concurrent, 

48 run_research_process, 

49 start_research_process, 

50) 

51 

52# Retry configuration constants for notification database queries 

53MAX_RESEARCH_LOOKUP_RETRIES = 3 

54INITIAL_RESEARCH_LOOKUP_DELAY = 0.5 # seconds 

55RETRY_BACKOFF_MULTIPLIER = 2 

56 

57# Give up on a queued research after this many consecutive spawn failures. 

58# Each failure leaves is_processing=False so the next loop tick retries. 

59SPAWN_RETRY_LIMIT = 3 

60 

61 

62@dataclass(frozen=True, slots=True) 

63class QueueSweepResult: 

64 can_dispatch: bool 

65 cleaned_ids: frozenset[str] 

66 

67 

68class _DirectStartOutcome(Enum): 

69 """Outcome of the direct-start attempt used by the queue handoff. 

70 

71 ``notify_research_queued`` must know whether it should return because the 

72 research is running/terminal, or fall through to create queue metadata and 

73 register the user for a later dispatch. A bare ``None`` return cannot make 

74 that distinction and previously stranded rows on setup and global-capacity 

75 failures. 

76 """ 

77 

78 STARTED = auto() 

79 ALREADY_RUNNING = auto() 

80 QUEUE_FALLBACK = auto() 

81 TERMINAL_FAILURE = auto() 

82 

83 

84class QueueProcessorV2: 

85 """ 

86 Processes queued researches using encrypted user databases. 

87 This replaces the service.db approach. 

88 """ 

89 

90 def __init__(self, check_interval=10): 

91 """ 

92 Initialize the queue processor. 

93 

94 Args: 

95 check_interval: How often to check for work (seconds) 

96 """ 

97 self.check_interval = check_interval 

98 self.running = False 

99 self.thread = None 

100 self._loop_iteration = 0 

101 # Wakes the loop out of its inter-iteration wait so stop() returns 

102 # in milliseconds instead of blocking for up to check_interval. 

103 # The test suite stops the processor after every app test 

104 # (tests/conftest.py reset_singletons), so a blocking stop() adds 

105 # ~check_interval seconds of teardown to each of those tests. 

106 self._stop_event = threading.Event() 

107 

108 # Per-user settings will be retrieved from each user's database 

109 # when processing their queue using SettingsManager 

110 logger.info( 

111 "Queue processor v2 initialized - will use per-user settings from SettingsManager" 

112 ) 

113 

114 # Track which users we should check 

115 self._users_to_check: set[tuple[str, str]] = set() 

116 self._users_lock = threading.Lock() 

117 

118 # Track pending operations from background threads 

119 self.pending_operations = {} 

120 self._pending_operations_lock = threading.Lock() 

121 

122 # Compatibility view of the shared per-user admission locks. The 

123 # canonical registry lives in research_state so fresh HTTP starts and 

124 # queue replay use the SAME lock for count -> DB claim; a queue-local 

125 # lock would still let those two entry points race each other. 

126 # 

127 # Size is bounded by distinct-usernames-ever-seen, not by session 

128 # count. Entries are tiny (~40 bytes) and deliberately retain stable 

129 # identity for the process lifetime; see pop_user_critical_lock(). 

130 self._user_critical_locks: Dict[str, threading.Lock] = {} 

131 self._user_critical_locks_lock = threading.Lock() 

132 

133 # Count consecutive spawn failures per research_id. Entries are 

134 # popped on success or after hitting SPAWN_RETRY_LIMIT (then the 

135 # research is marked FAILED). In-memory is sufficient: a restart 

136 # resets the counter and the research gets a fresh N retries, 

137 # which is the desired behavior if the underlying system issue 

138 # (thread pool, memory) cleared. 

139 # Access is guarded by _spawn_retry_counts_lock because the 

140 # increment path is a read-modify-write and the loop and direct 

141 # request paths can interleave. 

142 self._spawn_retry_counts: dict[str, int] = {} 

143 self._spawn_retry_counts_lock = threading.Lock() 

144 

145 def _get_user_critical_lock(self, username: str) -> threading.Lock: 

146 """Get (or lazily create) the per-user lock used to serialise the 

147 count-active-and-start-direct critical section for a given user. 

148 """ 

149 lock = get_user_research_start_lock(username) 

150 with self._user_critical_locks_lock: 

151 # Keep the compatibility/introspection view synchronized with the 

152 # canonical research_state registry (tests may snapshot/restore 

153 # that registry between cases). 

154 self._user_critical_locks[username] = lock 

155 return lock 

156 

157 def pop_user_critical_lock(self, username: str) -> None: 

158 """Compatibility no-op: per-user critical locks keep stable identity. 

159 

160 A caller receives the lock from ``_get_user_critical_lock`` before it 

161 acquires it. Removing even an apparently-unlocked entry therefore has 

162 an unavoidable lookup-to-acquire race: another request can retain the 

163 old lock while a later request creates and acquires a second one. If a 

164 held lock is removed, the race is immediate and the count-to-start 

165 critical section is no longer serialised. 

166 

167 Keep one tiny lock per distinct username for the process lifetime, 

168 matching the deliberately persistent rekey gate in ``research_state``. 

169 The method remains as a no-op so existing user-close cleanup callers do 

170 not need a special-case import path. 

171 """ 

172 del username 

173 

174 def _bump_spawn_retry_count(self, research_id: str) -> int: 

175 """Atomically increment and return the spawn-retry counter for 

176 ``research_id``. Extracted so tests can exercise the real 

177 locked increment path instead of duplicating the lock in the 

178 test worker (which would be a tautology). 

179 """ 

180 with self._spawn_retry_counts_lock: 

181 attempts = self._spawn_retry_counts.get(research_id, 0) + 1 

182 self._spawn_retry_counts[research_id] = attempts 

183 return attempts 

184 

185 @staticmethod 

186 def _commit_with_safe_rollback(db_session: Session, context: str) -> bool: 

187 """Commit ``db_session`` with best-effort rollback on failure. 

188 

189 Returns ``True`` on success, ``False`` if the commit raised. 

190 The failure path logs via ``logger.exception`` and attempts a 

191 rollback, itself guarded so a subsequent rollback failure is 

192 logged at debug level rather than propagated. 

193 

194 Extracted because the ``try: commit / except: log + try: 

195 rollback`` idiom repeats at ≥5 sites in this module; inlining 

196 hides the defensive structure behind nested ``try`` blocks and 

197 makes each callsite longer than the work it describes. 

198 """ 

199 try: 

200 db_session.commit() 

201 return True 

202 except Exception: 

203 logger.exception(f"Commit failed: {context}") 

204 try: 

205 db_session.rollback() 

206 except Exception: 

207 logger.debug( 

208 f"Rollback after commit failure ({context})", 

209 exc_info=True, 

210 ) 

211 return False 

212 

213 def _delete_queue_row_safely( 

214 self, db_session: Session, username: str, research_id: str 

215 ) -> None: 

216 """Best-effort delete of the ``QueuedResearch`` row for 

217 ``(username, research_id)``. 

218 

219 Rolls back any pending state first (the session may be in 

220 ``PendingRollbackError`` from a failed commit inside 

221 ``_start_research``), re-queries the row fresh, deletes it if 

222 present, and commits via ``_commit_with_safe_rollback``. 

223 

224 Use this for ``DuplicateResearchError`` cleanup where the goal 

225 is "drop the queue row regardless of session state." Do NOT 

226 use it for paths that need the delete to be atomic with other 

227 writes (e.g. the terminal FAILED path bundles 

228 ``ResearchHistory.status = FAILED`` with the queue-row delete 

229 in a single commit — that stays inline). 

230 """ 

231 try: 

232 db_session.rollback() 

233 except Exception: 

234 logger.debug( 

235 f"Rollback before queue-row delete for {research_id}", 

236 exc_info=True, 

237 ) 

238 try: 

239 fresh_queued = ( 

240 db_session.query(QueuedResearch) 

241 .filter_by(username=username, research_id=research_id) 

242 .first() 

243 ) 

244 if fresh_queued: 

245 db_session.delete(fresh_queued) 

246 self._commit_with_safe_rollback( 

247 db_session, 

248 f"queue-row delete for research {research_id}", 

249 ) 

250 except Exception: 

251 logger.exception( 

252 f"Failed to query/delete queue row for {research_id}" 

253 ) 

254 try: 

255 db_session.rollback() 

256 except Exception: 

257 logger.debug( 

258 f"Rollback after queue-row delete failure for {research_id}", 

259 exc_info=True, 

260 ) 

261 

262 def start(self): 

263 """Start the queue processor thread.""" 

264 if self.running: 

265 logger.warning("Queue processor already running") 

266 return 

267 

268 self.running = True 

269 # Re-arm the wait for restart: stop() leaves the event set, and 

270 # create_app() restarts this singleton after the test-suite 

271 # teardown stops it. 

272 self._stop_event.clear() 

273 self.thread = threading.Thread( 

274 target=self._process_queue_loop, daemon=True 

275 ) 

276 self.thread.start() 

277 logger.info("Queue processor v2 started") 

278 

279 def stop(self): 

280 """Stop the queue processor thread.""" 

281 self.running = False 

282 # Wake the loop immediately rather than waiting for the next 

283 # check_interval tick (otherwise shutdown blocks for up to 10s). 

284 self._stop_event.set() 

285 if self.thread: 

286 self.thread.join(timeout=10) 

287 logger.info("Queue processor v2 stopped") 

288 

289 def notify_user_activity(self, username: str, session_id: str): 

290 """ 

291 Notify that a user has activity and their queue should be checked. 

292 

293 Args: 

294 username: The username 

295 session_id: The Flask session ID (for password access) 

296 """ 

297 with self._users_lock: 

298 self._users_to_check.add((username, session_id)) 

299 logger.debug(f"User {username} added to queue check list") 

300 

301 def notify_research_queued(self, username: str, research_id: str, **kwargs): 

302 """ 

303 Notify that a research was queued. 

304 In direct mode, this immediately starts the research if slots are available. 

305 In queue mode, it adds to the queue. 

306 

307 Args: 

308 username: The username 

309 research_id: The research ID 

310 **kwargs: Additional parameters for direct execution (query, mode, etc.) 

311 """ 

312 # Pre-declared so the except handlers below can pass it to 

313 # redact_secrets even on paths where it is never assigned. 

314 password = None 

315 # Check user's queue_mode setting when we have database access 

316 if kwargs: 

317 session_id = kwargs.get("session_id") 

318 if session_id: 

319 # Check if we can start it directly 

320 password = session_password_store.get_session_password( 

321 username, session_id 

322 ) 

323 if password: 

324 # TOCTOU guard against post-logout DB resurrection. 

325 # Between the read above and open_user_database() below the 

326 # user can log out: logout clears the session password store 

327 # (auth/routes.py step 2) and THEN closes the DB. Re-opening 

328 # here with the captured password would resurrect the user's 

329 # decrypted database — and even resume a queued research — 

330 # after logout. Re-read the store immediately before the 

331 # open; if the credential is gone the user has logged out, 

332 # so abort the direct start (``password`` becomes None and 

333 # the block below is skipped). Keeping this re-check adjacent 

334 # to the open shrinks the residual window to a negligible 

335 # remainder — the store clear and the DB open live in 

336 # different subsystems and are not jointly lockable without a 

337 # fragile cross-subsystem lock. Otherwise adopt the freshest 

338 # credential (never the stale captured one) for both the open 

339 # and _start_research_directly. On abort we fall through to 

340 # the queue-mode path below, which re-reads fresh and no-ops 

341 # cleanly when the user is logged out. 

342 current_password = ( 

343 session_password_store.get_session_password( 

344 username, session_id 

345 ) 

346 ) 

347 if current_password is None: 

348 logger.info( 

349 f"Aborting direct-start DB open for {username}: " 

350 "session credential cleared since read (logout?); " 

351 "leaving research queued" 

352 ) 

353 password = current_password 

354 if password: 

355 # The canonical ordering is admission gate -> database. 

356 # Fresh starts, logout/rekey, and library initialisation 

357 # all follow that order. Opening/querying a pooled session 

358 # first and then waiting for the gate can deadlock under 

359 # pool exhaustion with a gate holder waiting for a 

360 # connection. The inner admission section below is 

361 # intentionally retained: the shared gate is reentrant and 

362 # that narrower block documents the count -> claim scope. 

363 admission_lock = self._get_user_critical_lock(username) 

364 admission_lock.acquire() 

365 try: 

366 # Open database and check settings + active count 

367 engine = db_manager.open_user_database( 

368 username, password 

369 ) 

370 if engine: 

371 with get_user_db_session(username) as db_session: 

372 # Get user's settings using SettingsManager 

373 from ...settings.manager import SettingsManager 

374 

375 settings_manager = SettingsManager(db_session) 

376 

377 # Get user's queue_mode setting (env > DB > default) 

378 queue_mode = settings_manager.get_setting( 

379 "app.queue_mode", "direct" 

380 ) 

381 

382 # Get user's max concurrent setting (env > DB > default), 

383 # clamped to the global semaphore ceiling so a stale or 

384 # user-inflated value can't monopolize it. 

385 max_concurrent = clamp_user_max_concurrent( 

386 settings_manager.get_setting( 

387 "app.max_concurrent_researches", 3 

388 ) 

389 ) 

390 

391 logger.debug( 

392 f"User {username} settings: queue_mode={queue_mode}, " 

393 f"max_concurrent={max_concurrent}" 

394 ) 

395 

396 # Only try direct execution if user has queue_mode="direct" 

397 if queue_mode == "direct": 

398 # Serialise the count→check→start critical 

399 # section at the application layer. Two 

400 # concurrent submissions for the same user 

401 # must not both observe the same active 

402 # count and both start — that would exceed 

403 # max_concurrent. A per-user Python lock 

404 # gives us that atomicity independent of 

405 # the DB isolation level. 

406 with self._get_user_critical_lock(username): 

407 active_count = ( 

408 db_session.query(UserActiveResearch) 

409 .filter_by( 

410 username=username, 

411 status=ResearchStatus.IN_PROGRESS, 

412 ) 

413 .count() 

414 ) 

415 

416 if active_count < max_concurrent: 

417 # We have slots - start directly! 

418 logger.info( 

419 f"Direct mode: Starting research {research_id} immediately " 

420 f"(active: {active_count}/{max_concurrent})" 

421 ) 

422 

423 # Start the research directly 

424 outcome = ( 

425 self._start_research_directly( 

426 username, 

427 research_id, 

428 password, 

429 **kwargs, 

430 ) 

431 ) 

432 if ( 

433 outcome 

434 is not _DirectStartOutcome.QUEUE_FALLBACK 

435 ): 

436 return 

437 logger.info( 

438 "Direct start deferred for " 

439 f"{research_id}; queueing for " 

440 "a later dispatch" 

441 ) 

442 else: 

443 logger.info( 

444 "Direct mode: Max concurrent " 

445 f"reached ({active_count}/" 

446 f"{max_concurrent}), queueing " 

447 f"{research_id}" 

448 ) 

449 else: 

450 logger.info( 

451 f"User {username} has queue_mode={queue_mode}, " 

452 f"queueing research {research_id}" 

453 ) 

454 except Exception as e: 

455 # ``password`` is in scope — drop the traceback 

456 # chain and redact str(e) so the SQLCipher master 

457 # password can't leak via diagnose=True frame 

458 # locals (see the generic handler in 

459 # _start_research_directly for the full rationale). 

460 safe_msg = redact_secrets(str(e), password) 

461 logger.warning( 

462 f"Error in direct execution for {username}: {safe_msg}" 

463 ) 

464 finally: 

465 admission_lock.release() 

466 

467 # Fall back to queue mode (or if direct mode failed) 

468 try: 

469 with get_user_db_session(username) as session: 

470 queue_service = UserQueueService(session) 

471 queue_service.add_task_metadata( 

472 task_id=research_id, 

473 task_type="research", 

474 priority=0, 

475 ) 

476 logger.info( 

477 f"Research {research_id} queued for user {username}" 

478 ) 

479 

480 # Register the user with the processing loop. The loop only 

481 # dispatches users present in _users_to_check; under Flask that 

482 # set was fed by a before_request hook on every authenticated 

483 # request (deleted in the FastAPI migration), so without this 

484 # call a queued research would never start. The loop keeps 

485 # re-checking a registered user every tick until their queue 

486 # drains, so this single registration also covers the 

487 # at-capacity case: when a running research finishes, the next 

488 # tick dispatches the queued one. 

489 session_id = kwargs.get("session_id") 

490 if session_id: 

491 self.notify_user_activity(username, session_id) 

492 else: 

493 logger.warning( 

494 f"Research {research_id} queued for {username} without " 

495 "a session_id — the processing loop cannot pick it up " 

496 "until the user logs in again" 

497 ) 

498 except Exception as e: 

499 # ``password`` may be bound above — same redaction rationale. 

500 safe_msg = redact_secrets(str(e), password) 

501 logger.warning( 

502 f"Failed to update queue status for {username}: {safe_msg}" 

503 ) 

504 

505 def _start_research_directly( 

506 self, username: str, research_id: str, password: str, **kwargs 

507 ) -> _DirectStartOutcome: 

508 """ 

509 Start a research directly without queueing. 

510 

511 Args: 

512 username: The username 

513 research_id: The research ID 

514 password: The user's password 

515 **kwargs: Research parameters (query, mode, settings, etc.) 

516 """ 

517 query = kwargs.get("query") 

518 mode = kwargs.get("mode") 

519 settings_snapshot = kwargs.get("settings_snapshot", {}) 

520 

521 # Create active research record 

522 active_record_persisted = False 

523 try: 

524 with get_user_db_session(username) as db_session: 

525 active_record = UserActiveResearch( 

526 username=username, 

527 research_id=research_id, 

528 status=ResearchStatus.IN_PROGRESS, 

529 thread_id="pending", 

530 settings_snapshot=settings_snapshot, 

531 ) 

532 db_session.add(active_record) 

533 db_session.commit() 

534 active_record_persisted = True 

535 

536 # Update task status if it exists 

537 queue_service = UserQueueService(db_session) 

538 queue_service.update_task_status(research_id, "processing") 

539 except Exception as e: 

540 # ``password`` is a parameter of this method — drop the 

541 # traceback chain and redact str(e) (full rationale at the 

542 # generic handler below). 

543 safe_msg = redact_secrets(str(e), password) 

544 logger.warning( 

545 f"Failed to create active research record for {research_id}: {safe_msg}" 

546 ) 

547 # The first commit lands before TaskMetadata is transitioned. If 

548 # that later update failed, remove the now-stale active row before 

549 # asking the caller to use the normal queue fallback. 

550 if active_record_persisted: 

551 try: 

552 with get_user_db_session(username) as db_session: 

553 active_record = ( 

554 db_session.query(UserActiveResearch) 

555 .filter_by( 

556 username=username, research_id=research_id 

557 ) 

558 .first() 

559 ) 

560 if active_record: 560 ↛ 562line 560 didn't jump to line 562 because the condition on line 560 was always true

561 db_session.delete(active_record) 

562 research_row = ( 

563 db_session.query(ResearchHistory) 

564 .filter_by(id=research_id) 

565 .first() 

566 ) 

567 if research_row: 567 ↛ 569line 567 didn't jump to line 569 because the condition on line 567 was always true

568 research_row.status = ResearchStatus.QUEUED 

569 db_session.commit() 

570 except Exception as cleanup_error: 

571 safe_msg = redact_secrets(str(cleanup_error), password) 

572 logger.warning( 

573 "Cleanup after direct-start setup failure failed for " 

574 f"{research_id}: {safe_msg}" 

575 ) 

576 return _DirectStartOutcome.QUEUE_FALLBACK 

577 

578 # Extract parameters from kwargs 

579 model_provider = kwargs.get("model_provider") 

580 model = kwargs.get("model") 

581 custom_endpoint = kwargs.get("custom_endpoint") 

582 search_engine = kwargs.get("search_engine") 

583 

584 # Start the research process 

585 try: 

586 research_thread = start_research_process( 

587 research_id, 

588 query, 

589 mode, 

590 run_research_process, 

591 username=username, 

592 user_password=password, 

593 model_provider=model_provider, 

594 model=model, 

595 custom_endpoint=custom_endpoint, 

596 search_engine=search_engine, 

597 max_results=kwargs.get("max_results"), 

598 time_period=kwargs.get("time_period"), 

599 iterations=kwargs.get("iterations"), 

600 questions_per_iteration=kwargs.get("questions_per_iteration"), 

601 strategy=kwargs.get("strategy", "source-based"), 

602 settings_snapshot=settings_snapshot, 

603 ) 

604 

605 # Update thread ID 

606 try: 

607 with get_user_db_session(username) as db_session: 

608 active_record = ( 

609 db_session.query(UserActiveResearch) 

610 .filter_by(username=username, research_id=research_id) 

611 .first() 

612 ) 

613 if active_record: 613 ↛ 623line 613 didn't jump to line 623

614 active_record.thread_id = str(research_thread.ident) 

615 db_session.commit() 

616 except Exception as e: 

617 # ``password`` is in scope — same redaction rationale. 

618 safe_msg = redact_secrets(str(e), password) 

619 logger.warning( 

620 f"Failed to update thread ID for {research_id}: {safe_msg}" 

621 ) 

622 

623 logger.info( 

624 f"Direct execution: Started research {research_id} for user {username} " 

625 f"in thread {research_thread.ident}" 

626 ) 

627 return _DirectStartOutcome.STARTED 

628 

629 except DuplicateResearchError: 

630 # A live thread already owns this research_id. Do NOT delete 

631 # the UserActiveResearch row or mark ResearchHistory FAILED — 

632 # that state belongs to the live thread, and mutating it 

633 # would terminate a running research from the user's 

634 # perspective while it keeps executing. Same contract as the 

635 # queue processor's dedicated dup branch (#3506). 

636 logger.warning( 

637 f"Duplicate live thread detected for {research_id} " 

638 "in direct mode; leaving state intact" 

639 ) 

640 return _DirectStartOutcome.ALREADY_RUNNING 

641 except SystemAtCapacityError: 

642 # System at concurrent-research capacity in the direct-execution 

643 # path. Roll back the IN_PROGRESS active row and mark history 

644 # back to QUEUED so the queue processor can pick it up later. 

645 logger.info( 

646 f"Direct execution hit capacity for {research_id}; re-queueing" 

647 ) 

648 try: 

649 with get_user_db_session(username) as db_session: 

650 active_record = ( 

651 db_session.query(UserActiveResearch) 

652 .filter_by(username=username, research_id=research_id) 

653 .first() 

654 ) 

655 if active_record: 655 ↛ 657line 655 didn't jump to line 657 because the condition on line 655 was always true

656 db_session.delete(active_record) 

657 research_row = ( 

658 db_session.query(ResearchHistory) 

659 .filter_by(id=research_id) 

660 .first() 

661 ) 

662 if research_row: 662 ↛ 664line 662 didn't jump to line 664 because the condition on line 662 was always true

663 research_row.status = ResearchStatus.QUEUED 

664 db_session.commit() 

665 except Exception as e: 

666 # ``password`` is in scope — same redaction rationale. 

667 safe_msg = redact_secrets(str(e), password) 

668 logger.warning( 

669 f"Cleanup after capacity reject failed for " 

670 f"{research_id}; the stale UserActiveResearch row is " 

671 f"recovered by reclaim_stale_user_active_research: {safe_msg}" 

672 ) 

673 # Let notify_research_queued create TaskMetadata through its one 

674 # canonical queue-fallback path and register (username, session_id) 

675 # with the processing loop. Handling metadata here used to make 

676 # the row look queued while silently omitting that registration. 

677 return _DirectStartOutcome.QUEUE_FALLBACK 

678 except Exception as e: 

679 # ``password`` is in lexical scope (function parameter, 

680 # passed through to ``start_research_process``). A 

681 # SQLAlchemy / requests exception from anywhere in 

682 # ``start_research_process`` could carry frame locals that 

683 # include the SQLCipher master password (which is 

684 # unrecoverable — see TRUST.md §5). Drop the traceback chain 

685 # and redact str(e) defensively. 

686 safe_msg = redact_secrets(str(e), password) 

687 logger.warning( 

688 f"Failed to start research {research_id} directly: {safe_msg}" 

689 ) 

690 # Clean up the active record AND mark the research terminal 

691 # FAILED so the user-visible state matches reality (no running 

692 # thread, not IN_PROGRESS). Same contract as the queue 

693 # processor's terminal-failure branch (#3481). 

694 try: 

695 with get_user_db_session(username) as db_session: 

696 active_record = ( 

697 db_session.query(UserActiveResearch) 

698 .filter_by(username=username, research_id=research_id) 

699 .first() 

700 ) 

701 if active_record: 

702 db_session.delete(active_record) 

703 research_row = ( 

704 db_session.query(ResearchHistory) 

705 .filter_by(id=research_id) 

706 .first() 

707 ) 

708 if research_row: 

709 research_row.status = ResearchStatus.FAILED 

710 # This direct attempt originated from an already-persisted 

711 # queue row. A terminal spawn failure must consume that row 

712 # and its TaskMetadata atomically with FAILED, otherwise the 

713 # non-QUEUED parent makes it permanently undispatchable 

714 # while queue counters continue to report it. 

715 cleanup_queued_research_state( 

716 db_session, [research_id], include_claimed=True 

717 ) 

718 db_session.commit() 

719 except Exception as e2: 

720 # ``password`` is in scope — same redaction rationale. 

721 safe_msg = redact_secrets(str(e2), password) 

722 logger.warning( 

723 f"Failed to clean up active research record for {research_id}: {safe_msg}" 

724 ) 

725 return _DirectStartOutcome.TERMINAL_FAILURE 

726 

727 @staticmethod 

728 def _drop_active_research_row(session, username: str, research_id: str): 

729 """Delete the per-user concurrency-cap row for a finished research. 

730 

731 ``UserActiveResearch`` exists to count what a user currently has 

732 running. Main deleted finished rows from a ``before_request`` hook 

733 (``web/auth/cleanup_middleware.cleanup_completed_research``) that 

734 sampled ~1% of requests, walked the user's rows, and dropped any 

735 whose thread was no longer active. That hook has no successor 

736 under FastAPI, and nothing replaced its *delete*: the spawn-failure 

737 branches drop their own row, but neither terminal notification 

738 below did, so every normally-completed research left its row 

739 behind permanently. 

740 

741 The visible effect is not a wrong concurrency count — 

742 ``reclaim_stale_user_active_research`` re-derives that at the 

743 user's next start, so the cap self-heals — but the rows are never 

744 removed, only flipped to FAILED, and only for a user who starts 

745 another research. A user who finishes and stops accumulates rows 

746 with no purge path anywhere in the codebase, and the ones that do 

747 get reclaimed are mislabelled FAILED despite having succeeded. 

748 

749 Deleting at the terminal notification is strictly better than 

750 main's sampled sweep: it is event-driven, so it cannot miss, and 

751 it costs one statement on a path that already holds an open 

752 session — no extra connection, no per-request overhead. 

753 

754 The caller's session context does not commit on exit, so commit 

755 here rather than relying on a later write to flush this one. 

756 """ 

757 from ...database.models import UserActiveResearch 

758 

759 try: 

760 deleted = ( 

761 session.query(UserActiveResearch) 

762 .filter_by(username=username, research_id=research_id) 

763 .delete(synchronize_session=False) 

764 ) 

765 if deleted: 

766 session.commit() 

767 logger.debug( 

768 f"Dropped UserActiveResearch row for {research_id}" 

769 ) 

770 except Exception: 

771 # Never let bookkeeping break the completion notification the 

772 # user is actually waiting on; the row stays and the next 

773 # reclaim sweep will pick it up. 

774 logger.exception( 

775 f"Could not drop UserActiveResearch row for {research_id}" 

776 ) 

777 # Swallowing is not enough: a failed statement leaves the 

778 # session raising PendingRollbackError on everything that 

779 # follows, and the caller runs 

780 # ``send_research_completed_notification_from_session`` on 

781 # *this* session on the very next line. Without the rollback 

782 # the swallow would hand that notification a dead session -- 

783 # exactly what the comment above promises not to do. Main did 

784 # the same (web/auth/cleanup_middleware.py, five rollbacks). 

785 try: 

786 session.rollback() 

787 except Exception: 

788 # A genuinely exhausted pool can fail the rollback too; 

789 # this path is user-visible completion, so still no raise. 

790 logger.warning( 

791 "Could not roll back after a failed UserActiveResearch " 

792 f"delete for {research_id}" 

793 ) 

794 

795 def notify_research_completed( 

796 self, username: str, research_id: str, user_password: str | None = None 

797 ): 

798 """ 

799 Notify that a research completed. 

800 Updates the user's queue status in their database. 

801 

802 Args: 

803 username: The username 

804 research_id: The research ID 

805 user_password: User password for database access. Required for queue 

806 updates and database lookups during notification sending. 

807 Optional only because some callers may not have it 

808 available, in which case only basic updates occur. 

809 """ 

810 try: 

811 # get_user_db_session is already imported at module level (line 19) 

812 # It accepts optional password parameter and returns a context manager 

813 with get_user_db_session(username, user_password) as session: 

814 queue_service = UserQueueService(session) 

815 queue_service.update_task_status( 

816 research_id, ResearchStatus.COMPLETED 

817 ) 

818 self._drop_active_research_row(session, username, research_id) 

819 logger.info( 

820 f"Research {research_id} completed for user {username}" 

821 ) 

822 

823 # Send notification using helper from notification module 

824 send_research_completed_notification_from_session( 

825 username=username, 

826 research_id=research_id, 

827 db_session=session, 

828 ) 

829 

830 except Exception as e: 

831 # ``user_password`` is a parameter of this method — drop the 

832 # traceback chain and redact str(e) so the SQLCipher master 

833 # password can't leak via diagnose=True frame locals. 

834 safe_msg = redact_secrets(str(e), user_password) 

835 logger.warning( 

836 f"Failed to update completion status for {username}: {safe_msg}" 

837 ) 

838 

839 # Auto-convert research to document in History collection. 

840 # Documents only — FAISS indexing is triggered separately by the user 

841 # via "Index All" on the History page. 

842 from ...research_library.search.services.research_history_indexer import ( 

843 auto_convert_research, 

844 ) 

845 

846 auto_convert_research(username, research_id, db_password=user_password) 

847 

848 def notify_research_failed( 

849 self, 

850 username: str, 

851 research_id: str, 

852 error_message: str | None = None, 

853 user_password: str | None = None, 

854 ): 

855 """ 

856 Notify that a research failed. 

857 Updates the user's queue status in their database and sends notification. 

858 

859 Args: 

860 username: The username 

861 research_id: The research ID 

862 error_message: Optional error message 

863 user_password: User password for database access. Required for queue 

864 updates and database lookups during notification sending. 

865 Optional only because some callers may not have it 

866 available, in which case only basic updates occur. 

867 """ 

868 try: 

869 # get_user_db_session is already imported at module level (line 19) 

870 # It accepts optional password parameter and returns a context manager 

871 with get_user_db_session(username, user_password) as session: 

872 queue_service = UserQueueService(session) 

873 queue_service.update_task_status( 

874 research_id, 

875 ResearchStatus.FAILED, 

876 error_message=error_message, 

877 ) 

878 self._drop_active_research_row(session, username, research_id) 

879 logger.info( 

880 f"Research {research_id} failed for user {username}: " 

881 f"{error_message}" 

882 ) 

883 

884 # Send notification using helper from notification module 

885 send_research_failed_notification_from_session( 

886 username=username, 

887 research_id=research_id, 

888 error_message=error_message or "Unknown error", 

889 db_session=session, 

890 ) 

891 

892 except Exception as e: 

893 # ``user_password`` is a parameter of this method — same 

894 # redaction rationale as notify_research_completed. 

895 safe_msg = redact_secrets(str(e), user_password) 

896 logger.warning( 

897 f"Failed to update failure status for {username}: {safe_msg}" 

898 ) 

899 

900 def _process_queue_loop(self): 

901 """Main loop that processes the queue.""" 

902 while self.running: 

903 try: 

904 # Get list of users to check (don't clear immediately) 

905 with self._users_lock: 

906 users_to_check = list(self._users_to_check) 

907 

908 # Process each user's queue 

909 users_to_remove = [] 

910 for user_session in users_to_check: 

911 try: 

912 username, session_id = user_session 

913 # _process_user_queue returns True if queue is empty 

914 queue_empty = self._process_user_queue( 

915 username, session_id 

916 ) 

917 if queue_empty: 

918 users_to_remove.append(user_session) 

919 except Exception: 

920 logger.exception( 

921 f"Error processing queue for user {username}" 

922 ) 

923 # Don't remove on error - the _process_user_queue method 

924 # determines whether to keep checking based on error type 

925 

926 # Only remove users whose queues are now empty 

927 with self._users_lock: 

928 for user_session in users_to_remove: 

929 self._users_to_check.discard(user_session) 

930 

931 # Drain pending operations (progress / error updates queued 

932 # by background threads via queue_progress_update / 

933 # queue_error_update). Without this, FAILED status is never 

934 # persisted unless the user happens to make another HTTP 

935 # request that drains it from a request handler. 

936 self._drain_pending_operations() 

937 

938 except Exception: 

939 logger.exception("Error in queue processor loop") 

940 finally: 

941 # Clean up thread-local database session after each iteration. 

942 # The loop opens a new session each iteration via get_user_db_session(); 

943 # closing it returns the connection to the shared QueuePool promptly. 

944 try: 

945 from ...database.thread_local_session import ( 

946 cleanup_current_thread, 

947 cleanup_dead_threads, 

948 ) 

949 

950 cleanup_current_thread() 

951 except Exception: 

952 logger.debug( 

953 "thread-local cleanup on shutdown", exc_info=True 

954 ) 

955 

956 # Periodic dead-thread credential sweep (every ~60s). 

957 # One of three sweep trigger points (fastapi_app's 

958 # post-request cleanup, connection_cleanup scheduler, 

959 # and here). 

960 self._loop_iteration += 1 

961 if self._loop_iteration % 6 == 0: # Every ~60s (10s × 6) 

962 try: 

963 cleanup_dead_threads() 

964 except Exception: 

965 logger.debug( 

966 "periodic dead-thread sweep", exc_info=True 

967 ) 

968 

969 # Event.wait, not time.sleep: stop() must be able to interrupt 

970 # this pause, otherwise shutdown blocks for up to 

971 # check_interval seconds. 

972 self._stop_event.wait(self.check_interval) 

973 

974 def _drain_pending_operations(self) -> None: 

975 """Per-iteration drain of pending_operations across users. 

976 

977 Background threads (research workers) write into pending_operations 

978 when they need to mark a research FAILED or update progress but 

979 don't have direct DB access (e.g. password lookup failed). We 

980 opportunistically drain by username here using the queue 

981 processor's own DB access. 

982 """ 

983 with self._pending_operations_lock: 

984 usernames = { 

985 op["username"] 

986 for op in self.pending_operations.values() 

987 if op.get("username") 

988 } 

989 if not usernames: 

990 return 

991 

992 for username in usernames: 

993 try: 

994 with get_user_db_session(username) as db_session: 

995 n = self.process_pending_operations_for_user( 

996 username, db_session 

997 ) 

998 if n: 998 ↛ 992line 998 didn't jump to line 992

999 logger.info( 

1000 f"Drained {n} pending operation(s) for {username}" 

1001 ) 

1002 except Exception: 

1003 # Likely no password available for this user yet — leave the 

1004 # ops queued; eviction will reap them after _PENDING_OPS_TTL. 

1005 logger.debug( 

1006 f"Could not drain pending ops for {username} this tick", 

1007 exc_info=True, 

1008 ) 

1009 

1010 def _process_user_queue(self, username: str, session_id: str) -> bool: 

1011 """ 

1012 Process the queue for a specific user. 

1013 

1014 Args: 

1015 username: The username 

1016 session_id: The Flask session ID 

1017 

1018 Returns: 

1019 True if the queue is empty, False if there are still items 

1020 """ 

1021 # Get the user's password from session store 

1022 password = session_password_store.get_session_password( 

1023 username, session_id 

1024 ) 

1025 if not password: 

1026 logger.debug( 

1027 f"No password available for user {username}, skipping queue check" 

1028 ) 

1029 return True # Remove from checking - session expired 

1030 

1031 # Keep the process-wide lock order consistent with fresh starts and 

1032 # logout/rekey: per-user admission gate before database/session work. 

1033 # Taking a pooled session first and then blocking on the gate creates a 

1034 # resource cycle when the gate holder itself needs a connection. 

1035 admission_lock = self._get_user_critical_lock(username) 

1036 admission_lock.acquire() 

1037 

1038 # Open the user's encrypted database 

1039 try: 

1040 # First ensure the database is open 

1041 engine = db_manager.open_user_database(username, password) 

1042 if not engine: 

1043 logger.error(f"Failed to open database for user {username}") 

1044 return False # Keep checking - could be temporary DB issue 

1045 

1046 # Get a session and process the queue 

1047 with get_user_db_session(username, password) as db_session: 

1048 queue_service = UserQueueService(db_session) 

1049 

1050 sweep_result = self._sweep_missing_parent_queue_rows( 

1051 db_session, username 

1052 ) 

1053 if not sweep_result.can_dispatch: 1053 ↛ 1054line 1053 didn't jump to line 1054 because the condition on line 1053 was never true

1054 return False 

1055 

1056 # Get user's settings using SettingsManager 

1057 from ...settings.manager import SettingsManager 

1058 

1059 settings_manager = SettingsManager(db_session) 

1060 

1061 # Get user's max concurrent setting (env > DB > default), 

1062 # clamped to the global semaphore ceiling so a stale or 

1063 # user-inflated value can't monopolize it. 

1064 max_concurrent = clamp_user_max_concurrent( 

1065 settings_manager.get_setting( 

1066 "app.max_concurrent_researches", 3 

1067 ) 

1068 ) 

1069 

1070 # Serialize the live-count -> dispatch handoff with direct 

1071 # starts made through this processor. QueueStatus.active_tasks 

1072 # only counts tasks that previously travelled through the 

1073 # queue, so it misses already-running direct researches and 

1074 # cannot enforce the user's actual concurrency limit. 

1075 with self._get_user_critical_lock(username): 

1076 # A crashed worker can leave an IN_PROGRESS row behind. 

1077 # The fresh-submit route performs this same liveness 

1078 # reconciliation before counting; queue replay must do so 

1079 # as well or a dead row can block the queue forever. 

1080 from ..routes.globals import ( 

1081 reclaim_stale_user_active_research, 

1082 ) 

1083 

1084 if reclaim_stale_user_active_research( 

1085 db_session, username, logger=logger 

1086 ): 

1087 db_session.commit() 

1088 

1089 active_count = ( 

1090 db_session.query(UserActiveResearch) 

1091 .filter_by( 

1092 username=username, 

1093 status=ResearchStatus.IN_PROGRESS, 

1094 ) 

1095 .count() 

1096 ) 

1097 queue_status = queue_service.get_queue_status() or { 

1098 "queued_tasks": 0, 

1099 } 

1100 available_slots = max_concurrent - active_count 

1101 

1102 if available_slots <= 0: 

1103 # No slots available, but queue might not be empty. 

1104 return False # Keep checking 

1105 

1106 if queue_status["queued_tasks"] == 0: 

1107 # Queue is empty. 

1108 return True # Remove from checking 

1109 

1110 logger.info( 

1111 f"Processing queue for {username}: " 

1112 f"{active_count} active, " 

1113 f"{queue_status['queued_tasks']} queued, " 

1114 f"{available_slots} slots available" 

1115 ) 

1116 

1117 # Process queued researches while retaining the same lock 

1118 # used for the capacity decision. Otherwise a direct 

1119 # notification could consume the last slot between the 

1120 # count and the queued spawn. 

1121 self._start_queued_researches( 

1122 db_session, 

1123 queue_service, 

1124 username, 

1125 password, 

1126 available_slots, 

1127 ) 

1128 

1129 # Check if there are still items in queue. 

1130 updated_status = queue_service.get_queue_status() or { 

1131 "queued_tasks": 0 

1132 } 

1133 return bool(updated_status["queued_tasks"] == 0) 

1134 

1135 except Exception as e: 

1136 # ``password`` (from the session store above) is in scope — 

1137 # drop the traceback chain and redact str(e). 

1138 safe_msg = redact_secrets(str(e), password) 

1139 logger.warning( 

1140 f"Error processing queue for user {username}: {safe_msg}" 

1141 ) 

1142 return False # Keep checking - errors might be temporary 

1143 finally: 

1144 admission_lock.release() 

1145 

1146 def _reclaim_stranded_queue_rows( 

1147 self, db_session: Session, username: str 

1148 ) -> int: 

1149 """Reclaim queue rows stranded by a crash or restart. 

1150 

1151 A row is stranded when ``is_processing=True`` but no live thread 

1152 exists in ``_active_research`` for its ``research_id``. This can 

1153 happen after a crash/restart between the pre-spawn IN_PROGRESS 

1154 commit and the queue-row deletion in ``_start_queued_researches`` 

1155 — the row is invisible to the normal ``is_processing=False`` 

1156 query and would never be retried. 

1157 

1158 Reverts only rows whose parent is not database-backed IN_PROGRESS, 

1159 preserving the worker spawn-grace window. Returns the number of 

1160 rows reclaimed. 

1161 """ 

1162 from ..routes.globals import is_research_active 

1163 

1164 stranded = ( 

1165 db_session.query(QueuedResearch) 

1166 .filter_by(username=username, is_processing=True) 

1167 .all() 

1168 ) 

1169 reclaimed = 0 

1170 for row in stranded: 

1171 if is_research_active(row.research_id): 

1172 # A legitimate in-flight claim; don't touch. 

1173 continue 

1174 row.is_processing = False 

1175 research = ( 

1176 db_session.query(ResearchHistory) 

1177 .filter_by(id=row.research_id) 

1178 .first() 

1179 ) 

1180 status_changed = ( 

1181 research is not None 

1182 and research.status == ResearchStatus.IN_PROGRESS 

1183 ) 

1184 if status_changed: 

1185 research.status = ResearchStatus.QUEUED 

1186 reclaimed += 1 

1187 logger.warning( 

1188 f"Reclaimed stranded queue row for research " 

1189 f"{row.research_id} (user {username}): no live thread, " 

1190 "resetting is_processing=False" 

1191 + (" and status=QUEUED" if status_changed else "") 

1192 ) 

1193 if reclaimed: 

1194 if not self._commit_with_safe_rollback( 1194 ↛ 1198line 1194 didn't jump to line 1198 because the condition on line 1194 was never true

1195 db_session, 

1196 f"reclaim of stranded rows for user {username}", 

1197 ): 

1198 return 0 

1199 return reclaimed 

1200 

1201 def _sweep_missing_parent_queue_rows( 

1202 self, db_session: Session, username: str 

1203 ) -> QueueSweepResult: 

1204 queued_orphan_ids = { 

1205 research_id 

1206 for (research_id,) in ( 

1207 db_session.query(QueuedResearch.research_id) 

1208 .outerjoin( 

1209 ResearchHistory, 

1210 QueuedResearch.research_id == ResearchHistory.id, 

1211 ) 

1212 .filter( 

1213 QueuedResearch.username == username, 

1214 ResearchHistory.id.is_(None), 

1215 ) 

1216 .all() 

1217 ) 

1218 } 

1219 metadata_orphan_ids = { 

1220 task_id 

1221 for (task_id,) in ( 

1222 db_session.query(TaskMetadata.task_id) 

1223 .outerjoin( 

1224 ResearchHistory, 

1225 TaskMetadata.task_id == ResearchHistory.id, 

1226 ) 

1227 .filter( 

1228 TaskMetadata.task_type == "research", 

1229 ResearchHistory.id.is_(None), 

1230 ) 

1231 .all() 

1232 ) 

1233 } 

1234 orphaned_research_ids = queued_orphan_ids | metadata_orphan_ids 

1235 if not orphaned_research_ids: 

1236 if reconcile_research_queue_status(db_session): 

1237 if not self._commit_with_safe_rollback( 1237 ↛ 1241line 1237 didn't jump to line 1241 because the condition on line 1237 was never true

1238 db_session, 

1239 f"queue status reconciliation for user {username}", 

1240 ): 

1241 return QueueSweepResult(False, frozenset()) 

1242 return QueueSweepResult(True, frozenset()) 

1243 

1244 cleanup_result = cleanup_queued_research_state( 

1245 db_session, 

1246 orphaned_research_ids, 

1247 include_claimed=True, 

1248 ) 

1249 if not self._commit_with_safe_rollback( 

1250 db_session, 

1251 f"missing-parent queue sweep for user {username}", 

1252 ): 

1253 return QueueSweepResult(False, frozenset()) 

1254 

1255 with self._spawn_retry_counts_lock: 

1256 for research_id in cleanup_result.cleaned_ids: 

1257 self._spawn_retry_counts.pop(research_id, None) 

1258 return QueueSweepResult(True, cleanup_result.cleaned_ids) 

1259 

1260 def _start_queued_researches( 

1261 self, 

1262 db_session: Session, 

1263 queue_service: UserQueueService, 

1264 username: str, 

1265 password: str, 

1266 available_slots: int, 

1267 ): 

1268 """Start queued researches up to available slots.""" 

1269 sweep_result = self._sweep_missing_parent_queue_rows( 

1270 db_session, username 

1271 ) 

1272 if not sweep_result.can_dispatch: 

1273 return 

1274 

1275 # Before picking work, reclaim any rows stranded by a prior 

1276 # crash — otherwise they are invisible to the is_processing=False 

1277 # filter below and would never retry. 

1278 self._reclaim_stranded_queue_rows(db_session, username) 

1279 

1280 # Get queued researches 

1281 queued = ( 

1282 db_session.query(QueuedResearch) 

1283 .filter_by(username=username, is_processing=False) 

1284 .order_by(QueuedResearch.position, QueuedResearch.id) 

1285 .limit(available_slots) 

1286 .all() 

1287 ) 

1288 

1289 for queued_research in queued: 

1290 research_id = queued_research.research_id 

1291 try: 

1292 # Atomically claim this item by flipping is_processing from 

1293 # False to True in a single UPDATE. If another worker has 

1294 # already claimed it since our SELECT above, the UPDATE will 

1295 # match zero rows and we skip. Under non-IMMEDIATE isolation 

1296 # the previous SELECT+assign pattern would race and two 

1297 # workers could both process the same queued item. 

1298 claimed = ( 

1299 db_session.query(QueuedResearch) 

1300 .filter( 

1301 QueuedResearch.id == queued_research.id, 

1302 QueuedResearch.is_processing.is_(False), 

1303 db_session.query(ResearchHistory.id) 

1304 .filter( 

1305 ResearchHistory.id == research_id, 

1306 ResearchHistory.status == ResearchStatus.QUEUED, 

1307 ) 

1308 .exists(), 

1309 ) 

1310 .update( 

1311 {QueuedResearch.is_processing: True}, 

1312 synchronize_session=False, 

1313 ) 

1314 ) 

1315 db_session.commit() 

1316 if not claimed: 

1317 logger.debug( 

1318 f"Queued research {research_id} " 

1319 f"already claimed by another worker; skipping" 

1320 ) 

1321 continue 

1322 # Refresh local object state now that we hold the claim 

1323 db_session.refresh(queued_research) 

1324 

1325 # Update task status 

1326 queue_service.update_task_status(research_id, "processing") 

1327 

1328 # Start the research 

1329 self._start_research( 

1330 db_session, 

1331 username, 

1332 password, 

1333 queued_research, 

1334 ) 

1335 

1336 # Success — clear any prior spawn-failure count and 

1337 # remove the queue row. 

1338 with self._spawn_retry_counts_lock: 

1339 self._spawn_retry_counts.pop(research_id, None) 

1340 db_session.delete(queued_research) 

1341 db_session.commit() 

1342 

1343 logger.info( 

1344 f"Started queued research {research_id} for user {username}" 

1345 ) 

1346 

1347 except DuplicateResearchError: 

1348 # Raised by _start_research when a prior attempt's thread 

1349 # is still live, OR when the ResearchHistory row is in a 

1350 # non-QUEUED state (IN_PROGRESS from a prior attempt's 

1351 # successful pre-spawn commit; terminal COMPLETED / 

1352 # FAILED / SUSPENDED from a thread that already finished 

1353 # and cleaned up). In every case the correct behavior is 

1354 # the same: clear the stale queue row and the retry 

1355 # counter, and do NOT fall through to the FAILED/notify 

1356 # path — that would terminate-status a live thread or 

1357 # emit a false failure for a completed one. 

1358 logger.warning( 

1359 f"Research {research_id} is already started " 

1360 "(live thread or non-QUEUED status); clearing stale " 

1361 "queue row" 

1362 ) 

1363 with self._spawn_retry_counts_lock: 

1364 self._spawn_retry_counts.pop(research_id, None) 

1365 self._delete_queue_row_safely(db_session, username, research_id) 

1366 continue 

1367 

1368 except SystemAtCapacityError: 

1369 # System hit the global concurrent-research capacity while 

1370 # dispatching this queued item. _start_research already 

1371 # reset the ResearchHistory row back to QUEUED before 

1372 # re-raising. This is a transient condition, NOT a spawn 

1373 # failure, so it must NOT count toward SPAWN_RETRY_LIMIT — 

1374 # otherwise a busy system would wrongly mark a perfectly 

1375 # valid queued research FAILED after a few ticks. Just 

1376 # release the processing claim so the next tick retries. 

1377 # Mirrors the dedicated handler in _start_research_directly. 

1378 logger.info( 

1379 f"System at capacity dispatching queued research " 

1380 f"{research_id}; leaving queued for next tick" 

1381 ) 

1382 # Revert the queued->processing claim from 

1383 # update_task_status("processing") above. The research stays 

1384 # queued for the next tick, so its slot must return to 

1385 # queued_tasks rather than leaking into active_tasks on 

1386 # every capacity-rejected retry. 

1387 queue_service.update_task_status(research_id, "queued") 

1388 fresh_queued = ( 

1389 db_session.query(QueuedResearch) 

1390 .filter_by(username=username, research_id=research_id) 

1391 .first() 

1392 ) 

1393 if fresh_queued: 1393 ↛ 1400line 1393 didn't jump to line 1400 because the condition on line 1393 was always true

1394 fresh_queued.is_processing = False 

1395 self._commit_with_safe_rollback( 

1396 db_session, 

1397 "is_processing reset after capacity reject for " 

1398 f"research {research_id}", 

1399 ) 

1400 continue 

1401 

1402 except InvalidQueuedResearchOverridesError as error: 

1403 logger.warning( 

1404 "Invalid persisted search overrides for queued research " 

1405 f"{research_id}; marking FAILED" 

1406 ) 

1407 with self._spawn_retry_counts_lock: 

1408 self._spawn_retry_counts.pop(research_id, None) 

1409 

1410 research = ( 

1411 db_session.query(ResearchHistory) 

1412 .filter_by(id=research_id) 

1413 .first() 

1414 ) 

1415 fresh_queued = ( 

1416 db_session.query(QueuedResearch) 

1417 .filter_by(username=username, research_id=research_id) 

1418 .first() 

1419 ) 

1420 if research: 1420 ↛ 1422line 1420 didn't jump to line 1422 because the condition on line 1420 was always true

1421 research.status = ResearchStatus.FAILED 

1422 if fresh_queued: 1422 ↛ 1424line 1422 didn't jump to line 1424 because the condition on line 1422 was always true

1423 db_session.delete(fresh_queued) 

1424 self._commit_with_safe_rollback( 

1425 db_session, 

1426 "terminal invalid persisted overrides for research " 

1427 f"{research_id}", 

1428 ) 

1429 self.notify_research_failed( 

1430 username=username, 

1431 research_id=research_id, 

1432 error_message=str(error), 

1433 user_password=password, 

1434 ) 

1435 continue 

1436 

1437 except Exception as e: 

1438 # ``password`` is a parameter of this method — drop the 

1439 # traceback chain and redact str(e). 

1440 safe_msg = redact_secrets(str(e), password) 

1441 logger.warning( 

1442 f"Error starting queued research {research_id}: {safe_msg}" 

1443 ) 

1444 # Session may be in PendingRollbackError state after a 

1445 # failed commit inside _start_research. 

1446 try: 

1447 db_session.rollback() 

1448 except Exception as rb_err: 

1449 # No exc_info: ``password`` is in this frame and a 

1450 # rendered traceback could expose it via 

1451 # diagnose=True frame locals. 

1452 logger.debug( 

1453 "Rollback after start failure: " 

1454 f"{redact_secrets(str(rb_err), password)}" 

1455 ) 

1456 

1457 attempts = self._bump_spawn_retry_count(research_id) 

1458 

1459 # Re-query in case rollback expired the ORM object. 

1460 fresh_queued = ( 

1461 db_session.query(QueuedResearch) 

1462 .filter_by(username=username, research_id=research_id) 

1463 .first() 

1464 ) 

1465 

1466 if attempts < SPAWN_RETRY_LIMIT: 

1467 # Transient failure — allow the next loop tick to 

1468 # retry. _start_research rolls back its own 

1469 # IN_PROGRESS write on spawn failure, so the only 

1470 # fix-up needed here is resetting is_processing. 

1471 logger.warning( 

1472 f"Spawn failed for research {research_id} " 

1473 f"(attempt {attempts}/{SPAWN_RETRY_LIMIT}), " 

1474 "leaving queued for retry" 

1475 ) 

1476 if fresh_queued: 1476 ↛ 1482line 1476 didn't jump to line 1482 because the condition on line 1476 was always true

1477 fresh_queued.is_processing = False 

1478 self._commit_with_safe_rollback( 

1479 db_session, 

1480 f"is_processing reset for research {research_id}", 

1481 ) 

1482 continue 

1483 

1484 # Exhausted retries — mark terminal FAILED, delete the 

1485 # queue row to stop re-dispatch, and notify the user. 

1486 # The spawn failure was already logged (redacted) at the 

1487 # top of this except block; no need to repeat it here. 

1488 logger.warning( 

1489 f"Spawn failed for research {research_id} " 

1490 f"after {attempts} attempts; marking FAILED" 

1491 ) 

1492 with self._spawn_retry_counts_lock: 

1493 self._spawn_retry_counts.pop(research_id, None) 

1494 try: 

1495 research = ( 

1496 db_session.query(ResearchHistory) 

1497 .filter_by(id=research_id) 

1498 .first() 

1499 ) 

1500 if research: 1500 ↛ 1502line 1500 didn't jump to line 1502 because the condition on line 1500 was always true

1501 research.status = ResearchStatus.FAILED 

1502 if fresh_queued: 1502 ↛ 1504line 1502 didn't jump to line 1504 because the condition on line 1502 was always true

1503 db_session.delete(fresh_queued) 

1504 db_session.commit() 

1505 except Exception as e2: 

1506 # ``password`` is in scope — same redaction rationale. 

1507 safe_msg = redact_secrets(str(e2), password) 

1508 logger.warning( 

1509 "Failed to persist terminal FAILED state for " 

1510 f"research {research_id}: {safe_msg}" 

1511 ) 

1512 try: 

1513 db_session.rollback() 

1514 except Exception as rb_err: 

1515 # No exc_info: same frame-locals rationale as the 

1516 # rollback handler above. 

1517 logger.debug( 

1518 "Rollback after terminal update failure: " 

1519 f"{redact_secrets(str(rb_err), password)}" 

1520 ) 

1521 

1522 # notify_research_failed opens its own session and 

1523 # sends the user notification. Called exactly once 

1524 # per research_id because the counter is popped above. 

1525 self.notify_research_failed( 

1526 username=username, 

1527 research_id=research_id, 

1528 error_message=( 

1529 f"Failed to start research after {attempts} attempts" 

1530 ), 

1531 user_password=password, 

1532 ) 

1533 

1534 def _start_research( 

1535 self, 

1536 db_session: Session, 

1537 username: str, 

1538 password: str, 

1539 queued_research, 

1540 ): 

1541 """Start a queued research. 

1542 

1543 Commits ``ResearchHistory.status = IN_PROGRESS`` BEFORE spawning 

1544 the thread. If we did this after, a fast-completing thread 

1545 (which opens its own DB session) could write ``COMPLETED`` and 

1546 then our post-spawn commit would overwrite that with 

1547 ``IN_PROGRESS``, stranding the research as stuck IN_PROGRESS 

1548 after it had already finished. 

1549 

1550 If ``start_research_process`` raises, reset status back to 

1551 ``QUEUED`` and re-raise so the caller's 3-strike retry logic 

1552 handles it. ``DuplicateResearchError`` is re-raised as-is 

1553 because a thread is already running for this research; mutating 

1554 status further would be wrong. 

1555 """ 

1556 research_id = queued_research.research_id 

1557 research = ( 

1558 db_session.query(ResearchHistory).filter_by(id=research_id).first() 

1559 ) 

1560 

1561 if not research: 

1562 raise ValueError(f"Research {research_id} not found") 

1563 

1564 # Guard against re-entering _start_research on a retry when a 

1565 # prior attempt's post-spawn UserActiveResearch commit failed: 

1566 # - IN_PROGRESS means the prior thread is (or was) running. 

1567 # - COMPLETED/FAILED means the prior thread already finished 

1568 # and cleaned itself up out of _active_research, so a bare 

1569 # retry would both overwrite the terminal status with 

1570 # IN_PROGRESS and then spawn a *second* thread (because 

1571 # check_and_start_research sees no live entry), re-running 

1572 # the whole research. 

1573 # In all three cases the correct behavior is the same: raise 

1574 # DuplicateResearchError so the caller's existing dup branch 

1575 # deletes the queue row without mutating status or notifying. 

1576 if research.status != ResearchStatus.QUEUED: 

1577 raise DuplicateResearchError( 

1578 f"Research {research_id} is already started " 

1579 f"(status={research.status})" 

1580 ) 

1581 

1582 # Extract settings 

1583 settings_snapshot = queued_research.settings_snapshot or {} 

1584 

1585 # Handle new vs legacy structure 

1586 if ( 

1587 isinstance(settings_snapshot, dict) 

1588 and "submission" in settings_snapshot 

1589 ): 

1590 submission_params = settings_snapshot.get("submission", {}) 

1591 complete_settings = settings_snapshot.get("settings_snapshot", {}) 

1592 allowed_override_keys = ( 

1593 "model_provider", 

1594 "model", 

1595 "custom_endpoint", 

1596 "search_engine", 

1597 "max_results", 

1598 "time_period", 

1599 "iterations", 

1600 "questions_per_iteration", 

1601 "strategy", 

1602 ) 

1603 explicit_override_keys = settings_snapshot.get( 

1604 "submission_overrides" 

1605 ) 

1606 override_keys = ( 

1607 explicit_override_keys 

1608 if isinstance(explicit_override_keys, list) 

1609 else allowed_override_keys 

1610 ) 

1611 runtime_overrides = { 

1612 key: submission_params[key] 

1613 for key in allowed_override_keys 

1614 if key in override_keys and key in submission_params 

1615 } 

1616 else: 

1617 submission_params = settings_snapshot 

1618 # A legacy-flat queued row (enqueued by an older version, pre- 

1619 # "submission" wrapper) carries no settings_snapshot. Seed the run's 

1620 # primary engine from the submitted search_engine so the worker's 

1621 # egress build (resolve_run_primary_engine) doesn't fail closed on 

1622 # an empty snapshot and refuse the run. 

1623 _legacy_engine = submission_params.get("search_engine") 

1624 complete_settings = ( 

1625 {"search.tool": _legacy_engine} if _legacy_engine else {} 

1626 ) 

1627 runtime_overrides = { 

1628 "model_provider": submission_params.get("model_provider"), 

1629 "model": submission_params.get("model"), 

1630 "custom_endpoint": submission_params.get("custom_endpoint"), 

1631 "search_engine": submission_params.get("search_engine"), 

1632 "max_results": submission_params.get("max_results"), 

1633 "time_period": submission_params.get("time_period"), 

1634 "iterations": submission_params.get("iterations"), 

1635 "questions_per_iteration": submission_params.get( 

1636 "questions_per_iteration" 

1637 ), 

1638 "strategy": submission_params.get("strategy", "source-based"), 

1639 } 

1640 

1641 query_validation_error = validate_research_query_length( 

1642 queued_research.query 

1643 ) 

1644 if query_validation_error is not None: 

1645 raise InvalidQueuedResearchOverridesError(query_validation_error) 

1646 

1647 validation_error = validate_search_overrides(runtime_overrides) 

1648 if validation_error is not None: 

1649 raise InvalidQueuedResearchOverridesError(validation_error) 

1650 

1651 # Claim IN_PROGRESS before spawn to close the 

1652 # thread-completes-before-parent-commits race. 

1653 research.status = ResearchStatus.IN_PROGRESS 

1654 db_session.commit() 

1655 

1656 try: 

1657 research_thread = start_research_process( 

1658 research_id, 

1659 queued_research.query, 

1660 queued_research.mode, 

1661 run_research_process, 

1662 username=username, 

1663 user_password=password, # Pass password for metrics 

1664 settings_snapshot=complete_settings, 

1665 **runtime_overrides, 

1666 ) 

1667 except DuplicateResearchError: 

1668 # A live thread already exists for this research_id (e.g. 

1669 # previous attempt's post-spawn commit failed). Do NOT 

1670 # reset status — that would contradict the running thread. 

1671 raise 

1672 except SystemAtCapacityError: 

1673 # System at concurrent-research capacity. No thread was 

1674 # spawned. Reset to QUEUED so the next dispatch tick can try 

1675 # again — this is not a permanent spawn failure and should 

1676 # NOT count toward SPAWN_RETRY_LIMIT. 

1677 logger.info( 

1678 f"System at capacity when dispatching {research_id}; " 

1679 "re-queueing for next tick" 

1680 ) 

1681 research.status = ResearchStatus.QUEUED 

1682 self._commit_with_safe_rollback( 

1683 db_session, 

1684 f"status reset to QUEUED after capacity reject for research {research_id}", 

1685 ) 

1686 raise 

1687 except Exception: 

1688 # Genuine spawn failure: no thread exists. Roll back the 

1689 # IN_PROGRESS claim so the retry sees a clean QUEUED row. 

1690 research.status = ResearchStatus.QUEUED 

1691 self._commit_with_safe_rollback( 

1692 db_session, 

1693 f"status reset to QUEUED after spawn failure for research {research_id}", 

1694 ) 

1695 raise 

1696 

1697 # Thread is running. Record the active-research row. If this 

1698 # commit fails the live thread is unrecorded but still running. 

1699 # Raise DuplicateResearchError instead of letting a generic 

1700 # exception propagate, so the caller's dup branch cleans up the 

1701 # queue row without bumping the retry counter — if we let this 

1702 # count as a spawn failure, three consecutive post-spawn commit 

1703 # failures (or one at LIMIT-1) would push the counter to 

1704 # SPAWN_RETRY_LIMIT and mark a LIVE thread as terminal FAILED. 

1705 active_record = UserActiveResearch( 

1706 username=username, 

1707 research_id=research_id, 

1708 status=ResearchStatus.IN_PROGRESS, 

1709 thread_id=str(research_thread.ident), 

1710 settings_snapshot=queued_research.settings_snapshot, 

1711 ) 

1712 db_session.add(active_record) 

1713 if not self._commit_with_safe_rollback( 

1714 db_session, 

1715 f"UserActiveResearch persist after spawn for research {research_id}", 

1716 ): 

1717 # Thread is live; the commit failing leaves the UAR row 

1718 # unrecorded but the thread running. Raise 

1719 # DuplicateResearchError so the caller's dup branch deletes 

1720 # the queue row without bumping the retry counter — if we 

1721 # let a plain exception count as a spawn failure, a commit 

1722 # failure at SPAWN_RETRY_LIMIT - 1 would mark a LIVE thread 

1723 # as terminal FAILED. 

1724 raise DuplicateResearchError( 

1725 f"Research {research_id} thread is live; " 

1726 "UserActiveResearch commit failed" 

1727 ) 

1728 

1729 # Keep pending_operations bounded: if no request comes in to drain 

1730 # them (user never navigates after a crash), they would otherwise 

1731 # accumulate forever in memory. We drop oldest entries past this cap 

1732 # and entries older than this TTL. 

1733 _PENDING_OPS_MAX = 10_000 

1734 _PENDING_OPS_TTL_SECONDS = 24 * 60 * 60 # 24 hours 

1735 

1736 def reconcile_orphan_active_research( 

1737 self, username: str, db_session 

1738 ) -> int: 

1739 """Mark stale UserActiveResearch rows as FAILED. 

1740 

1741 Called after a user logs in (when their DB is open). If the server 

1742 was restarted mid-research, rows with status=IN_PROGRESS have a 

1743 thread_id pointing to a now-dead thread. Leaving them as IN_PROGRESS 

1744 means the dashboard shows "running forever" with no way to cancel. 

1745 

1746 Only rows whose worker thread is actually DEAD are reconciled. This 

1747 runs on EVERY login, so without the liveness guard a user opening a 

1748 second tab / logging in on another device while a research is still 

1749 running would wrongly flag that live research FAILED — and 

1750 delete_attempt could then hard-delete it out from under the running 

1751 worker. Mirrors the is_research_thread_alive() guard chat.py and the 

1752 research-start reclaim already use. 

1753 

1754 Returns the number of rows reconciled. 

1755 """ 

1756 from datetime import UTC, datetime 

1757 

1758 from ..research_state import cleanup_research, is_research_thread_alive 

1759 

1760 try: 

1761 stale = ( 

1762 db_session.query(UserActiveResearch) 

1763 .filter_by(username=username, status=ResearchStatus.IN_PROGRESS) 

1764 .all() 

1765 ) 

1766 if not stale: 

1767 return 0 

1768 

1769 count = 0 

1770 for record in stale: 

1771 # Skip rows whose worker thread is still alive — those are 

1772 # genuinely running (e.g. a concurrent login), not orphans. 

1773 if is_research_thread_alive(record.research_id): 

1774 continue 

1775 # Also mark the ResearchHistory row as failed 

1776 research = ( 

1777 db_session.query(ResearchHistory) 

1778 .filter_by(id=record.research_id) 

1779 .first() 

1780 ) 

1781 if research and research.status == ResearchStatus.IN_PROGRESS: 1781 ↛ 1789line 1781 didn't jump to line 1789 because the condition on line 1781 was always true

1782 research.status = ResearchStatus.FAILED 

1783 research.completed_at = datetime.now(UTC).isoformat() 

1784 meta = dict(research.research_meta or {}) 

1785 meta["failure_reason"] = ( 

1786 "Server restarted while research was in progress" 

1787 ) 

1788 research.research_meta = meta 

1789 record.status = ResearchStatus.FAILED 

1790 # Drop the dead thread's in-memory entry too, mirroring 

1791 # reclaim_stale_user_active_research. Without this the stale 

1792 # _active_research entry outlives the DB reconciliation, so 

1793 # capacity checks keep counting a research that is already 

1794 # FAILED and its termination flag never clears. 

1795 cleanup_research(record.research_id) 

1796 count += 1 

1797 

1798 db_session.commit() 

1799 logger.info( 

1800 f"Reconciled {count} orphan IN_PROGRESS research records for {username}" 

1801 ) 

1802 return count 

1803 except Exception: 

1804 logger.exception( 

1805 f"Failed to reconcile orphan research records for {username}" 

1806 ) 

1807 return 0 

1808 

1809 def _evict_stale_pending_operations(self) -> None: 

1810 """Drop expired and over-capacity entries. Caller must hold the lock.""" 

1811 now = time.time() 

1812 # TTL eviction 

1813 expired = [ 

1814 op_id 

1815 for op_id, op in self.pending_operations.items() 

1816 if now - op.get("timestamp", now) > self._PENDING_OPS_TTL_SECONDS 

1817 ] 

1818 for op_id in expired: 

1819 del self.pending_operations[op_id] 

1820 # Size cap — drop oldest entries if over 

1821 overflow = len(self.pending_operations) - self._PENDING_OPS_MAX 

1822 if overflow > 0: 1822 ↛ 1823line 1822 didn't jump to line 1823 because the condition on line 1822 was never true

1823 oldest = sorted( 

1824 self.pending_operations.items(), 

1825 key=lambda kv: kv[1].get("timestamp", 0), 

1826 )[:overflow] 

1827 for op_id, _ in oldest: 

1828 del self.pending_operations[op_id] 

1829 

1830 def queue_progress_update( 

1831 self, username: str, research_id: str, progress: float 

1832 ): 

1833 """ 

1834 Queue a progress update that needs database access. 

1835 For compatibility with old processor during migration. 

1836 

1837 Args: 

1838 username: The username 

1839 research_id: The research ID 

1840 progress: The progress value (0-100) 

1841 """ 

1842 # In processor_v2, we can update directly if we have database access 

1843 # or queue it for later processing 

1844 operation_id = str(uuid.uuid4()) 

1845 with self._pending_operations_lock: 

1846 self.pending_operations[operation_id] = { 

1847 "username": username, 

1848 "operation_type": "progress_update", 

1849 "research_id": research_id, 

1850 "progress": progress, 

1851 "timestamp": time.time(), 

1852 } 

1853 self._evict_stale_pending_operations() 

1854 logger.debug( 

1855 f"Queued progress update for research {research_id}: {progress}%" 

1856 ) 

1857 

1858 def queue_error_update( 

1859 self, 

1860 username: str, 

1861 research_id: str, 

1862 status: str, 

1863 error_message: str, 

1864 metadata: Dict[str, Any], 

1865 completed_at: str, 

1866 report_path: Optional[str] = None, 

1867 ): 

1868 """ 

1869 Queue an error status update that needs database access. 

1870 For compatibility with old processor during migration. 

1871 

1872 Args: 

1873 username: The username 

1874 research_id: The research ID 

1875 status: The status to set (failed, suspended, etc.) 

1876 error_message: The error message 

1877 metadata: Research metadata 

1878 completed_at: Completion timestamp 

1879 report_path: Optional path to error report 

1880 """ 

1881 operation_id = str(uuid.uuid4()) 

1882 with self._pending_operations_lock: 

1883 self.pending_operations[operation_id] = { 

1884 "username": username, 

1885 "operation_type": "error_update", 

1886 "research_id": research_id, 

1887 "status": status, 

1888 "error_message": error_message, 

1889 "metadata": metadata, 

1890 "completed_at": completed_at, 

1891 "report_path": report_path, 

1892 "timestamp": time.time(), 

1893 } 

1894 self._evict_stale_pending_operations() 

1895 logger.info( 

1896 f"Queued error update for research {research_id} with status {status}" 

1897 ) 

1898 

1899 def process_pending_operations_for_user( 

1900 self, username: str, db_session: Session 

1901 ) -> int: 

1902 """ 

1903 Process pending operations for a user when we have database access. 

1904 Called from request context where encrypted database is accessible. 

1905 For compatibility with old processor during migration. 

1906 

1907 Args: 

1908 username: Username to process operations for 

1909 db_session: Active database session for the user 

1910 

1911 Returns: 

1912 Number of operations cleared from the queue 

1913 """ 

1914 # Find pending operations for this user (with lock) 

1915 raw_operations = [] 

1916 with self._pending_operations_lock: 

1917 for op_id, op_data in list(self.pending_operations.items()): 

1918 if op_data["username"] == username: 

1919 raw_operations.append((op_id, op_data)) 

1920 # Remove immediately to prevent duplicate processing 

1921 del self.pending_operations[op_id] 

1922 

1923 if not raw_operations: 

1924 return 0 

1925 

1926 # Deduplicate and consolidate operations by research_id to minimize DB locks 

1927 latest_progress = {} 

1928 latest_errors = {} 

1929 other_ops = [] 

1930 

1931 for op_id, op_data in raw_operations: 

1932 op_type = op_data.get("operation_type") 

1933 rid = op_data.get("research_id") 

1934 ts = op_data.get("timestamp", 0) 

1935 

1936 if op_type == "progress_update" and rid: 

1937 if rid not in latest_progress or ts >= latest_progress[rid][ 

1938 1 

1939 ].get("timestamp", 0): 

1940 latest_progress[rid] = (op_id, op_data) 

1941 elif op_type == "error_update" and rid: 1941 ↛ 1947line 1941 didn't jump to line 1947 because the condition on line 1941 was always true

1942 if rid not in latest_errors or ts >= latest_errors[rid][1].get( 1942 ↛ 1931line 1942 didn't jump to line 1931 because the condition on line 1942 was always true

1943 "timestamp", 0 

1944 ): 

1945 latest_errors[rid] = (op_id, op_data) 

1946 else: 

1947 other_ops.append((op_id, op_data)) 

1948 

1949 operations_to_process = ( 

1950 list(latest_progress.values()) 

1951 + list(latest_errors.values()) 

1952 + other_ops 

1953 ) 

1954 

1955 from sqlalchemy.exc import ( 

1956 OperationalError, 

1957 PendingRollbackError, 

1958 TimeoutError, 

1959 ) 

1960 

1961 from ...database.models import ResearchHistory 

1962 

1963 max_retries = 3 

1964 backoff = 0.05 

1965 max_requeue_attempts = 10 

1966 # Track the raw count of operations being flushed so the returned 

1967 # count reflects "operations cleared from the queue" rather than 

1968 # "deduplicated batches applied". Set after a successful commit 

1969 # so retries don't double-count. 

1970 processed_count = 0 

1971 

1972 for attempt in range(max_retries): 

1973 try: 

1974 # Consolidate DB queries: fetch all relevant ResearchHistory rows in a single batch 

1975 rids = list( 

1976 { 

1977 op_data["research_id"] 

1978 for _, op_data in operations_to_process 

1979 if op_data.get("research_id") 

1980 } 

1981 ) 

1982 if rids: 1982 ↛ 1990line 1982 didn't jump to line 1990 because the condition on line 1982 was always true

1983 researches = ( 

1984 db_session.query(ResearchHistory) 

1985 .filter(ResearchHistory.id.in_(rids)) 

1986 .all() 

1987 ) 

1988 research_map = {r.id: r for r in researches} 

1989 else: 

1990 research_map = {} 

1991 

1992 for op_id, op_data in operations_to_process: 

1993 op_type = op_data.get("operation_type") 

1994 rid = op_data.get("research_id") 

1995 research = research_map.get(rid) 

1996 

1997 if not research: 

1998 continue 

1999 

2000 if op_type == "progress_update": 

2001 research.progress = op_data.get("progress") 

2002 elif op_type == "error_update": 2002 ↛ 1992line 2002 didn't jump to line 1992 because the condition on line 2002 was always true

2003 research.status = op_data.get("status", "failed") 

2004 research.error_message = op_data.get("error_message") 

2005 research.research_meta = op_data.get("metadata") 

2006 research.completed_at = op_data.get("completed_at") 

2007 report_path = op_data.get("report_path") 

2008 if report_path: 

2009 research.report_path = report_path 

2010 

2011 # Single commit for the entire batch of consolidated updates. 

2012 # Count is set AFTER the commit succeeds so retries don't 

2013 # double-count the raw operations. 

2014 db_session.commit() 

2015 processed_count = len(raw_operations) 

2016 break 

2017 

2018 except (OperationalError, PendingRollbackError, TimeoutError) as e: 

2019 try: 

2020 db_session.rollback() 

2021 except Exception as rollback_err: 

2022 logger.debug(f"Queue flush rollback failed: {rollback_err}") 

2023 

2024 if attempt < max_retries - 1: 

2025 logger.debug( 

2026 f"Database locked during queue flush for {username} (attempt {attempt + 1}/{max_retries}); retrying in {backoff * 1000:.0f}ms: {type(e).__name__}" 

2027 ) 

2028 time.sleep(backoff) 

2029 backoff *= 2 

2030 else: 

2031 logger.warning( 

2032 f"Failed to commit queue operations for {username} after {max_retries} retries: {type(e).__name__}" 

2033 ) 

2034 # Re-queue raw operations so data is not lost, up to max_requeue_attempts 

2035 with self._pending_operations_lock: 

2036 for op_id, op_data in raw_operations: 

2037 retry_count = op_data.get("retry_count", 0) + 1 

2038 if retry_count > max_requeue_attempts: 

2039 logger.exception( 

2040 f"Dropping queued operation {op_id} ({op_data.get('operation_type')}) for user {username} " 

2041 f"after exceeding maximum requeue attempts ({max_requeue_attempts})" 

2042 ) 

2043 elif op_id not in self.pending_operations: 2043 ↛ 2036line 2043 didn't jump to line 2036 because the condition on line 2043 was always true

2044 op_data["retry_count"] = retry_count 

2045 self.pending_operations[op_id] = op_data 

2046 processed_count = 0 

2047 

2048 except Exception: 

2049 logger.exception( 

2050 f"Unexpected error committing queue operations for {username}" 

2051 ) 

2052 try: 

2053 db_session.rollback() 

2054 except Exception as rollback_err: 

2055 logger.debug(f"Queue flush rollback failed: {rollback_err}") 

2056 # Re-queue raw operations up to max_requeue_attempts 

2057 with self._pending_operations_lock: 

2058 for op_id, op_data in raw_operations: 

2059 retry_count = op_data.get("retry_count", 0) + 1 

2060 if retry_count > max_requeue_attempts: 2060 ↛ 2061line 2060 didn't jump to line 2061 because the condition on line 2060 was never true

2061 logger.exception( 

2062 f"Dropping queued operation {op_id} ({op_data.get('operation_type')}) for user {username} " 

2063 f"after exceeding maximum requeue attempts ({max_requeue_attempts})" 

2064 ) 

2065 elif op_id not in self.pending_operations: 2065 ↛ 2058line 2065 didn't jump to line 2058 because the condition on line 2065 was always true

2066 op_data["retry_count"] = retry_count 

2067 self.pending_operations[op_id] = op_data 

2068 processed_count = 0 

2069 break 

2070 

2071 return processed_count 

2072 

2073 

2074# Global queue processor instance 

2075queue_processor = QueueProcessorV2()