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

692 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +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 import or_, select 

15from sqlalchemy.orm import Session 

16 

17from ...constants import ResearchStatus 

18from ...database.encrypted_db import db_manager 

19from ...database.models import ( 

20 QueuedResearch, 

21 ResearchHistory, 

22 TaskMetadata, 

23 UserActiveResearch, 

24) 

25from ...database.queue_service import UserQueueService 

26from ...database.session_context import get_user_db_session 

27from ...database.session_passwords import session_password_store 

28from ...exceptions import ( 

29 DuplicateResearchError, 

30 InvalidQueuedResearchOverridesError, 

31 SystemAtCapacityError, 

32) 

33from ...security.log_sanitizer import redact_secrets 

34from ...notifications.queue_helpers import ( 

35 send_research_completed_notification_from_session, 

36 send_research_failed_notification_from_session, 

37) 

38from .lifecycle_cleanup import ( 

39 cleanup_queued_research_state, 

40 reconcile_research_queue_status, 

41) 

42from ..routes.research_validation import ( 

43 validate_research_query_length, 

44 validate_search_overrides, 

45) 

46from ..research_state import get_user_research_start_lock 

47from ..services.research_service import ( 

48 _parse_research_metadata, 

49 clamp_user_max_concurrent, 

50 run_research_process, 

51 start_research_process, 

52) 

53 

54# Retry configuration constants for notification database queries 

55MAX_RESEARCH_LOOKUP_RETRIES = 3 

56INITIAL_RESEARCH_LOOKUP_DELAY = 0.5 # seconds 

57RETRY_BACKOFF_MULTIPLIER = 2 

58 

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

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

61SPAWN_RETRY_LIMIT = 3 

62 

63 

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

65class QueueSweepResult: 

66 can_dispatch: bool 

67 cleaned_ids: frozenset[str] 

68 

69 

70class _DirectStartOutcome(Enum): 

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

72 

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

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

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

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

77 failures. 

78 """ 

79 

80 STARTED = auto() 

81 ALREADY_RUNNING = auto() 

82 QUEUE_FALLBACK = auto() 

83 TERMINAL_FAILURE = auto() 

84 

85 

86class QueueProcessorV2: 

87 """ 

88 Processes queued researches using encrypted user databases. 

89 This replaces the service.db approach. 

90 """ 

91 

92 def __init__(self, check_interval=10): 

93 """ 

94 Initialize the queue processor. 

95 

96 Args: 

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

98 """ 

99 self.check_interval = check_interval 

100 self.running = False 

101 self.thread = None 

102 self._loop_iteration = 0 

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

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

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

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

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

108 self._stop_event = threading.Event() 

109 

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

111 # when processing their queue using SettingsManager 

112 logger.info( 

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

114 ) 

115 

116 # Track which users we should check 

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

118 self._users_lock = threading.Lock() 

119 

120 # Track pending operations from background threads 

121 self.pending_operations = {} 

122 self._pending_operations_lock = threading.Lock() 

123 

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

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

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

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

128 # 

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

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

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

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

133 self._user_critical_locks_lock = threading.Lock() 

134 

135 # Count consecutive spawn failures per research_id. Entries are 

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

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

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

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

140 # (thread pool, memory) cleared. 

141 # Access is guarded by _spawn_retry_counts_lock because the 

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

143 # request paths can interleave. 

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

145 self._spawn_retry_counts_lock = threading.Lock() 

146 

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

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

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

150 """ 

151 lock = get_user_research_start_lock(username) 

152 with self._user_critical_locks_lock: 

153 # Keep the compatibility/introspection view synchronized with the 

154 # canonical research_state registry (tests may snapshot/restore 

155 # that registry between cases). 

156 self._user_critical_locks[username] = lock 

157 return lock 

158 

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

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

161 

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

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

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

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

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

167 critical section is no longer serialised. 

168 

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

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

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

172 not need a special-case import path. 

173 """ 

174 del username 

175 

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

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

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

179 locked increment path instead of duplicating the lock in the 

180 test worker (which would be a tautology). 

181 """ 

182 with self._spawn_retry_counts_lock: 

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

184 self._spawn_retry_counts[research_id] = attempts 

185 return attempts 

186 

187 @staticmethod 

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

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

190 

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

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

193 rollback, itself guarded so a subsequent rollback failure is 

194 logged at debug level rather than propagated. 

195 

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

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

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

199 makes each callsite longer than the work it describes. 

200 """ 

201 try: 

202 db_session.commit() 

203 return True 

204 except Exception: 

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

206 try: 

207 db_session.rollback() 

208 except Exception: 

209 logger.debug( 

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

211 exc_info=True, 

212 ) 

213 return False 

214 

215 def _delete_queue_row_safely( 

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

217 ) -> None: 

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

219 ``(username, research_id)``. 

220 

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

222 ``PendingRollbackError`` from a failed commit inside 

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

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

225 

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

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

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

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

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

231 in a single commit — that stays inline). 

232 """ 

233 try: 

234 db_session.rollback() 

235 except Exception: 

236 logger.debug( 

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

238 exc_info=True, 

239 ) 

240 try: 

241 fresh_queued = ( 

242 db_session.query(QueuedResearch) 

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

244 .first() 

245 ) 

246 if fresh_queued: 

247 db_session.delete(fresh_queued) 

248 self._commit_with_safe_rollback( 

249 db_session, 

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

251 ) 

252 except Exception: 

253 logger.exception( 

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

255 ) 

256 try: 

257 db_session.rollback() 

258 except Exception: 

259 logger.debug( 

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

261 exc_info=True, 

262 ) 

263 

264 def start(self): 

265 """Start the queue processor thread.""" 

266 if self.running: 

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

268 return 

269 

270 self.running = True 

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

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

273 # teardown stops it. 

274 self._stop_event.clear() 

275 self.thread = threading.Thread( 

276 target=self._process_queue_loop, daemon=True 

277 ) 

278 self.thread.start() 

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

280 

281 def stop(self): 

282 """Stop the queue processor thread.""" 

283 self.running = False 

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

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

286 self._stop_event.set() 

287 if self.thread: 

288 self.thread.join(timeout=10) 

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

290 

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

292 """ 

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

294 

295 Args: 

296 username: The username 

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

298 """ 

299 with self._users_lock: 

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

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

302 

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

304 """ 

305 Notify that a research was queued. 

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

307 In queue mode, it adds to the queue. 

308 

309 Args: 

310 username: The username 

311 research_id: The research ID 

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

313 """ 

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

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

316 password = None 

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

318 if kwargs: 

319 session_id = kwargs.get("session_id") 

320 if session_id: 

321 # Check if we can start it directly 

322 password = session_password_store.get_session_password( 

323 username, session_id 

324 ) 

325 if password: 

326 # TOCTOU guard against post-logout DB resurrection. 

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

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

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

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

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

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

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

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

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

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

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

338 # different subsystems and are not jointly lockable without a 

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

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

341 # and _start_research_directly. On abort we fall through to 

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

343 # cleanly when the user is logged out. 

344 current_password = ( 

345 session_password_store.get_session_password( 

346 username, session_id 

347 ) 

348 ) 

349 if current_password is None: 

350 logger.info( 

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

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

353 "leaving research queued" 

354 ) 

355 password = current_password 

356 if password: 

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

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

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

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

361 # pool exhaustion with a gate holder waiting for a 

362 # connection. The inner admission section below is 

363 # intentionally retained: the shared gate is reentrant and 

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

365 admission_lock = self._get_user_critical_lock(username) 

366 admission_lock.acquire() 

367 try: 

368 # Open database and check settings + active count 

369 engine = db_manager.open_user_database( 

370 username, password 

371 ) 

372 if engine: 

373 with get_user_db_session(username) as db_session: 

374 # Get user's settings using SettingsManager 

375 from ...settings.manager import SettingsManager 

376 

377 settings_manager = SettingsManager(db_session) 

378 

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

380 queue_mode = settings_manager.get_setting( 

381 "app.queue_mode", "direct" 

382 ) 

383 

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

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

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

387 max_concurrent = clamp_user_max_concurrent( 

388 settings_manager.get_setting( 

389 "app.max_concurrent_researches", 3 

390 ) 

391 ) 

392 

393 logger.debug( 

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

395 f"max_concurrent={max_concurrent}" 

396 ) 

397 

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

399 if queue_mode == "direct": 

400 # Serialise the count→check→start critical 

401 # section at the application layer. Two 

402 # concurrent submissions for the same user 

403 # must not both observe the same active 

404 # count and both start — that would exceed 

405 # max_concurrent. A per-user Python lock 

406 # gives us that atomicity independent of 

407 # the DB isolation level. 

408 with self._get_user_critical_lock(username): 

409 active_count = ( 

410 db_session.query(UserActiveResearch) 

411 .filter_by( 

412 username=username, 

413 status=ResearchStatus.IN_PROGRESS, 

414 ) 

415 .count() 

416 ) 

417 

418 if active_count < max_concurrent: 

419 # We have slots - start directly! 

420 logger.info( 

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

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

423 ) 

424 

425 # Start the research directly 

426 outcome = ( 

427 self._start_research_directly( 

428 username, 

429 research_id, 

430 password, 

431 **kwargs, 

432 ) 

433 ) 

434 if ( 

435 outcome 

436 is not _DirectStartOutcome.QUEUE_FALLBACK 

437 ): 

438 return 

439 logger.info( 

440 "Direct start deferred for " 

441 f"{research_id}; queueing for " 

442 "a later dispatch" 

443 ) 

444 else: 

445 logger.info( 

446 "Direct mode: Max concurrent " 

447 f"reached ({active_count}/" 

448 f"{max_concurrent}), queueing " 

449 f"{research_id}" 

450 ) 

451 else: 

452 logger.info( 

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

454 f"queueing research {research_id}" 

455 ) 

456 except Exception as e: 

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

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

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

460 # locals (see the generic handler in 

461 # _start_research_directly for the full rationale). 

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

463 logger.warning( 

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

465 ) 

466 finally: 

467 admission_lock.release() 

468 

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

470 try: 

471 with get_user_db_session(username) as session: 

472 queue_service = UserQueueService(session) 

473 queue_service.add_task_metadata( 

474 task_id=research_id, 

475 task_type="research", 

476 priority=0, 

477 ) 

478 logger.info( 

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

480 ) 

481 

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

483 # dispatches users present in _users_to_check; under Flask that 

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

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

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

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

488 # drains, so this single registration also covers the 

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

490 # tick dispatches the queued one. 

491 session_id = kwargs.get("session_id") 

492 if session_id: 

493 self.notify_user_activity(username, session_id) 

494 else: 

495 logger.warning( 

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

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

498 "until the user logs in again" 

499 ) 

500 except Exception as e: 

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

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

503 logger.warning( 

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

505 ) 

506 

507 def _start_research_directly( 

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

509 ) -> _DirectStartOutcome: 

510 """ 

511 Start a research directly without queueing. 

512 

513 Args: 

514 username: The username 

515 research_id: The research ID 

516 password: The user's password 

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

518 """ 

519 query = kwargs.get("query") 

520 mode = kwargs.get("mode") 

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

522 

523 # Create active research record 

524 active_record_persisted = False 

525 try: 

526 with get_user_db_session(username) as db_session: 

527 active_record = UserActiveResearch( 

528 username=username, 

529 research_id=research_id, 

530 status=ResearchStatus.IN_PROGRESS, 

531 thread_id="pending", 

532 settings_snapshot=settings_snapshot, 

533 ) 

534 db_session.add(active_record) 

535 db_session.commit() 

536 active_record_persisted = True 

537 

538 # Update task status if it exists 

539 queue_service = UserQueueService(db_session) 

540 queue_service.update_task_status(research_id, "processing") 

541 except Exception as e: 

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

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

544 # generic handler below). 

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

546 logger.warning( 

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

548 ) 

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

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

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

552 if active_record_persisted: 

553 try: 

554 with get_user_db_session(username) as db_session: 

555 active_record = ( 

556 db_session.query(UserActiveResearch) 

557 .filter_by( 

558 username=username, research_id=research_id 

559 ) 

560 .first() 

561 ) 

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

563 db_session.delete(active_record) 

564 research_row = ( 

565 db_session.query(ResearchHistory) 

566 .filter_by(id=research_id) 

567 .first() 

568 ) 

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

570 research_row.status = ResearchStatus.QUEUED 

571 db_session.commit() 

572 except Exception as cleanup_error: 

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

574 logger.warning( 

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

576 f"{research_id}: {safe_msg}" 

577 ) 

578 return _DirectStartOutcome.QUEUE_FALLBACK 

579 

580 # Extract parameters from kwargs 

581 model_provider = kwargs.get("model_provider") 

582 model = kwargs.get("model") 

583 custom_endpoint = kwargs.get("custom_endpoint") 

584 search_engine = kwargs.get("search_engine") 

585 

586 # Start the research process 

587 try: 

588 research_thread = start_research_process( 

589 research_id, 

590 query, 

591 mode, 

592 run_research_process, 

593 username=username, 

594 user_password=password, 

595 model_provider=model_provider, 

596 model=model, 

597 custom_endpoint=custom_endpoint, 

598 search_engine=search_engine, 

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

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

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

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

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

604 settings_snapshot=settings_snapshot, 

605 ) 

606 

607 # Update thread ID 

608 try: 

609 with get_user_db_session(username) as db_session: 

610 active_record = ( 

611 db_session.query(UserActiveResearch) 

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

613 .first() 

614 ) 

615 if active_record: 615 ↛ 625line 615 didn't jump to line 625

616 active_record.thread_id = str(research_thread.ident) 

617 db_session.commit() 

618 except Exception as e: 

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

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

621 logger.warning( 

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

623 ) 

624 

625 logger.info( 

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

627 f"in thread {research_thread.ident}" 

628 ) 

629 return _DirectStartOutcome.STARTED 

630 

631 except DuplicateResearchError: 

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

633 # the UserActiveResearch row or mark ResearchHistory FAILED — 

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

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

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

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

638 logger.warning( 

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

640 "in direct mode; leaving state intact" 

641 ) 

642 return _DirectStartOutcome.ALREADY_RUNNING 

643 except SystemAtCapacityError: 

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

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

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

647 logger.info( 

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

649 ) 

650 try: 

651 with get_user_db_session(username) as db_session: 

652 active_record = ( 

653 db_session.query(UserActiveResearch) 

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

655 .first() 

656 ) 

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

658 db_session.delete(active_record) 

659 research_row = ( 

660 db_session.query(ResearchHistory) 

661 .filter_by(id=research_id) 

662 .first() 

663 ) 

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

665 research_row.status = ResearchStatus.QUEUED 

666 db_session.commit() 

667 except Exception as e: 

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

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

670 logger.warning( 

671 f"Cleanup after capacity reject failed for " 

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

673 f"recovered by reclaim_stale_user_active_research: {safe_msg}" 

674 ) 

675 # Let notify_research_queued create TaskMetadata through its one 

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

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

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

679 return _DirectStartOutcome.QUEUE_FALLBACK 

680 except Exception as e: 

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

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

683 # SQLAlchemy / requests exception from anywhere in 

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

685 # include the SQLCipher master password (which is 

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

687 # and redact str(e) defensively. 

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

689 logger.warning( 

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

691 ) 

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

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

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

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

696 try: 

697 with get_user_db_session(username) as db_session: 

698 active_record = ( 

699 db_session.query(UserActiveResearch) 

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

701 .first() 

702 ) 

703 if active_record: 

704 db_session.delete(active_record) 

705 research_row = ( 

706 db_session.query(ResearchHistory) 

707 .filter_by(id=research_id) 

708 .first() 

709 ) 

710 if research_row: 

711 research_row.status = ResearchStatus.FAILED 

712 # This direct attempt originated from an already-persisted 

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

714 # and its TaskMetadata atomically with FAILED, otherwise the 

715 # non-QUEUED parent makes it permanently undispatchable 

716 # while queue counters continue to report it. 

717 cleanup_queued_research_state( 

718 db_session, 

719 [research_id], 

720 include_claimed=True, 

721 acting_user=username, 

722 ) 

723 db_session.commit() 

724 except Exception as e2: 

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

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

727 logger.warning( 

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

729 ) 

730 return _DirectStartOutcome.TERMINAL_FAILURE 

731 

732 @staticmethod 

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

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

735 

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

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

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

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

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

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

742 branches drop their own row, but neither terminal notification 

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

744 behind permanently. 

745 

746 The visible effect is not a wrong concurrency count — 

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

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

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

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

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

752 get reclaimed are mislabelled FAILED despite having succeeded. 

753 

754 Deleting at the terminal notification is strictly better than 

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

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

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

758 

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

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

761 """ 

762 from ...database.models import UserActiveResearch 

763 

764 try: 

765 deleted = ( 

766 session.query(UserActiveResearch) 

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

768 .delete(synchronize_session=False) 

769 ) 

770 if deleted: 

771 session.commit() 

772 logger.debug( 

773 f"Dropped UserActiveResearch row for {research_id}" 

774 ) 

775 except Exception: 

776 # Never let bookkeeping break the completion notification the 

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

778 # reclaim sweep will pick it up. 

779 logger.exception( 

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

781 ) 

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

783 # session raising PendingRollbackError on everything that 

784 # follows, and the caller runs 

785 # ``send_research_completed_notification_from_session`` on 

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

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

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

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

790 try: 

791 session.rollback() 

792 except Exception: 

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

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

795 logger.warning( 

796 "Could not roll back after a failed UserActiveResearch " 

797 f"delete for {research_id}" 

798 ) 

799 

800 def notify_research_completed( 

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

802 ): 

803 """ 

804 Notify that a research completed. 

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

806 

807 Args: 

808 username: The username 

809 research_id: The research ID 

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

811 updates and database lookups during notification sending. 

812 Optional only because some callers may not have it 

813 available, in which case only basic updates occur. 

814 """ 

815 try: 

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

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

818 with get_user_db_session(username, user_password) as session: 

819 queue_service = UserQueueService(session) 

820 queue_service.update_task_status( 

821 research_id, ResearchStatus.COMPLETED 

822 ) 

823 self._drop_active_research_row(session, username, research_id) 

824 logger.info( 

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

826 ) 

827 

828 # Send notification using helper from notification module 

829 send_research_completed_notification_from_session( 

830 username=username, 

831 research_id=research_id, 

832 db_session=session, 

833 ) 

834 

835 except Exception as e: 

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

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

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

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

840 logger.warning( 

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

842 ) 

843 

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

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

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

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

848 auto_convert_research, 

849 ) 

850 

851 auto_convert_research(username, research_id, db_password=user_password) 

852 

853 def notify_research_failed( 

854 self, 

855 username: str, 

856 research_id: str, 

857 error_message: str | None = None, 

858 user_password: str | None = None, 

859 ): 

860 """ 

861 Notify that a research failed. 

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

863 

864 Args: 

865 username: The username 

866 research_id: The research ID 

867 error_message: Optional error message 

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

869 updates and database lookups during notification sending. 

870 Optional only because some callers may not have it 

871 available, in which case only basic updates occur. 

872 """ 

873 try: 

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

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

876 with get_user_db_session(username, user_password) as session: 

877 queue_service = UserQueueService(session) 

878 queue_service.update_task_status( 

879 research_id, 

880 ResearchStatus.FAILED, 

881 error_message=error_message, 

882 ) 

883 self._drop_active_research_row(session, username, research_id) 

884 logger.info( 

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

886 f"{error_message}" 

887 ) 

888 

889 # Send notification using helper from notification module 

890 send_research_failed_notification_from_session( 

891 username=username, 

892 research_id=research_id, 

893 error_message=error_message or "Unknown error", 

894 db_session=session, 

895 ) 

896 

897 except Exception as e: 

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

899 # redaction rationale as notify_research_completed. 

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

901 logger.warning( 

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

903 ) 

904 

905 def _process_queue_loop(self): 

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

907 while self.running: 

908 try: 

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

910 with self._users_lock: 

911 users_to_check = list(self._users_to_check) 

912 

913 # Process each user's queue 

914 users_to_remove = [] 

915 for user_session in users_to_check: 

916 try: 

917 username, session_id = user_session 

918 # _process_user_queue returns True if queue is empty 

919 queue_empty = self._process_user_queue( 

920 username, session_id 

921 ) 

922 if queue_empty: 

923 users_to_remove.append(user_session) 

924 except Exception: 

925 logger.exception( 

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

927 ) 

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

929 # determines whether to keep checking based on error type 

930 

931 # Only remove users whose queues are now empty 

932 with self._users_lock: 

933 for user_session in users_to_remove: 

934 self._users_to_check.discard(user_session) 

935 

936 # Drain pending operations (progress / error updates queued 

937 # by background threads via queue_progress_update / 

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

939 # persisted unless the user happens to make another HTTP 

940 # request that drains it from a request handler. 

941 self._drain_pending_operations() 

942 

943 except Exception: 

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

945 finally: 

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

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

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

949 try: 

950 from ...database.thread_local_session import ( 

951 cleanup_current_thread, 

952 cleanup_dead_threads, 

953 ) 

954 

955 cleanup_current_thread() 

956 except Exception: 

957 logger.debug( 

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

959 ) 

960 

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

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

963 # post-request cleanup, connection_cleanup scheduler, 

964 # and here). 

965 self._loop_iteration += 1 

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

967 try: 

968 cleanup_dead_threads() 

969 except Exception: 

970 logger.debug( 

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

972 ) 

973 

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

975 # this pause, otherwise shutdown blocks for up to 

976 # check_interval seconds. 

977 self._stop_event.wait(self.check_interval) 

978 

979 def _drain_pending_operations(self) -> None: 

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

981 

982 Background threads (research workers) write into pending_operations 

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

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

985 opportunistically drain by username here using the queue 

986 processor's own DB access. 

987 """ 

988 with self._pending_operations_lock: 

989 usernames = { 

990 op["username"] 

991 for op in self.pending_operations.values() 

992 if op.get("username") 

993 } 

994 if not usernames: 

995 return 

996 

997 for username in usernames: 

998 try: 

999 with get_user_db_session(username) as db_session: 

1000 n = self.process_pending_operations_for_user( 

1001 username, db_session 

1002 ) 

1003 if n: 1003 ↛ 997line 1003 didn't jump to line 997

1004 logger.info( 

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

1006 ) 

1007 except Exception: 

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

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

1010 logger.debug( 

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

1012 exc_info=True, 

1013 ) 

1014 

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

1016 """ 

1017 Process the queue for a specific user. 

1018 

1019 Args: 

1020 username: The username 

1021 session_id: The Flask session ID 

1022 

1023 Returns: 

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

1025 """ 

1026 # Get the user's password from session store 

1027 password = session_password_store.get_session_password( 

1028 username, session_id 

1029 ) 

1030 if not password: 

1031 logger.debug( 

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

1033 ) 

1034 return True # Remove from checking - session expired 

1035 

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

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

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

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

1040 # The acquire blocks without a timeout on purpose. Direct starts, 

1041 # follow-ups and chat send/retry hold this gate until their worker is 

1042 # registered (or its spawn-failure cleanup finishes), so a slow DB or 

1043 # spawn there delays this loop, and with it queue dispatch and pending 

1044 # progress/error writes for every user, until that holder returns. The 

1045 # wait is bounded by that start. A timeout that then carried on without 

1046 # the gate would let this tick reclaim a committed claim before its 

1047 # worker is registered. 

1048 admission_lock = self._get_user_critical_lock(username) 

1049 admission_lock.acquire() 

1050 

1051 # Open the user's encrypted database 

1052 try: 

1053 # First ensure the database is open 

1054 engine = db_manager.open_user_database(username, password) 

1055 if not engine: 

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

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

1058 

1059 # Get a session and process the queue 

1060 with get_user_db_session(username, password) as db_session: 

1061 queue_service = UserQueueService(db_session) 

1062 

1063 sweep_result = self._sweep_missing_parent_queue_rows( 

1064 db_session, username 

1065 ) 

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

1067 return False 

1068 

1069 # Get user's settings using SettingsManager 

1070 from ...settings.manager import SettingsManager 

1071 

1072 settings_manager = SettingsManager(db_session) 

1073 

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

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

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

1077 max_concurrent = clamp_user_max_concurrent( 

1078 settings_manager.get_setting( 

1079 "app.max_concurrent_researches", 3 

1080 ) 

1081 ) 

1082 

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

1084 # starts made through this processor. QueueStatus.active_tasks 

1085 # only counts tasks that previously travelled through the 

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

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

1088 with self._get_user_critical_lock(username): 

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

1090 # The fresh-submit route performs this same liveness 

1091 # reconciliation before counting; queue replay must do so 

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

1093 from ..routes.globals import ( 

1094 reclaim_stale_user_active_research, 

1095 ) 

1096 

1097 if reclaim_stale_user_active_research( 

1098 db_session, username, logger=logger 

1099 ): 

1100 db_session.commit() 

1101 

1102 active_count = ( 

1103 db_session.query(UserActiveResearch) 

1104 .filter_by( 

1105 username=username, 

1106 status=ResearchStatus.IN_PROGRESS, 

1107 ) 

1108 .count() 

1109 ) 

1110 queue_status = queue_service.get_queue_status() or { 

1111 "queued_tasks": 0, 

1112 } 

1113 available_slots = max_concurrent - active_count 

1114 

1115 if available_slots <= 0: 

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

1117 return False # Keep checking 

1118 

1119 if queue_status["queued_tasks"] == 0 and ( 

1120 db_session.query(QueuedResearch.id) 

1121 .filter_by(username=username) 

1122 .first() 

1123 is None 

1124 ): 

1125 # A crashed claim still has a queue row but counts 

1126 # as processing, not queued. Let dispatch reclaim it 

1127 # before deciding this user has no pending work. 

1128 return True # Remove from checking 

1129 

1130 logger.info( 

1131 f"Processing queue for {username}: " 

1132 f"{active_count} active, " 

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

1134 f"{available_slots} slots available" 

1135 ) 

1136 

1137 # Process queued researches while retaining the same lock 

1138 # used for the capacity decision. Otherwise a direct 

1139 # notification could consume the last slot between the 

1140 # count and the queued spawn. 

1141 self._start_queued_researches( 

1142 db_session, 

1143 queue_service, 

1144 username, 

1145 password, 

1146 available_slots, 

1147 ) 

1148 

1149 # Check if there are still items in queue. 

1150 updated_status = queue_service.get_queue_status() or { 

1151 "queued_tasks": 0 

1152 } 

1153 return bool( 

1154 updated_status["queued_tasks"] == 0 

1155 and db_session.query(QueuedResearch.id) 

1156 .filter_by(username=username) 

1157 .first() 

1158 is None 

1159 ) 

1160 

1161 except Exception as e: 

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

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

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

1165 logger.warning( 

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

1167 ) 

1168 return False # Keep checking - errors might be temporary 

1169 finally: 

1170 admission_lock.release() 

1171 

1172 def _reclaim_stranded_queue_rows( 

1173 self, db_session: Session, username: str 

1174 ) -> int: 

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

1176 

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

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

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

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

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

1182 query and would never be retried. 

1183 

1184 Reclaims the ``ResearchHistory`` status and the 

1185 ``TaskMetadata`` claim (``processing`` -> ``queued``) alongside 

1186 ``is_processing``, so ``reconcile_research_queue_status`` keeps 

1187 reporting the row in ``queued_tasks`` (#6147). Removes leftovers 

1188 whose parent is already terminal, including previously released 

1189 claims, so they cannot block later queued work. Preserves claims 

1190 whose worker is still live. Returns the number of rows recovered. 

1191 """ 

1192 from ..routes.globals import is_research_active 

1193 

1194 terminal_statuses = ( 

1195 ResearchStatus.COMPLETED, 

1196 ResearchStatus.SUSPENDED, 

1197 ResearchStatus.FAILED, 

1198 ResearchStatus.ERROR, 

1199 ) 

1200 terminal_research_ids = select(ResearchHistory.id).where( 

1201 ResearchHistory.status.in_(terminal_statuses) 

1202 ) 

1203 stranded = ( 

1204 db_session.query(QueuedResearch) 

1205 .filter_by(username=username) 

1206 .filter( 

1207 QueuedResearch.is_processing.is_(True) 

1208 | QueuedResearch.research_id.in_(terminal_research_ids) 

1209 ) 

1210 .all() 

1211 ) 

1212 reclaimed = 0 

1213 finished_ids: set[str] = set() 

1214 for row in stranded: 

1215 if is_research_active(row.research_id): 

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

1217 continue 

1218 research = ( 

1219 db_session.query(ResearchHistory) 

1220 .filter_by(id=row.research_id) 

1221 .first() 

1222 ) 

1223 if research is not None and research.status in terminal_statuses: 

1224 finished_ids.add(row.research_id) 

1225 continue 

1226 row.is_processing = False 

1227 status_changed = ( 

1228 research is not None 

1229 and research.status == ResearchStatus.IN_PROGRESS 

1230 ) 

1231 if status_changed: 

1232 research.status = ResearchStatus.QUEUED 

1233 metadata = ( 

1234 db_session.query(TaskMetadata) 

1235 .filter_by(task_id=row.research_id, task_type="research") 

1236 .first() 

1237 ) 

1238 metadata_changed = ( 

1239 metadata is not None and metadata.status == "processing" 

1240 ) 

1241 if metadata_changed: 

1242 # A stranded claim has no live worker, so a 'processing' 

1243 # TaskMetadata row is stale by definition. Reverting it 

1244 # keeps queued_tasks accurate so the dispatch loop does 

1245 # not deregister the user before retrying this row 

1246 # (#6147). 

1247 metadata.status = "queued" 

1248 metadata.started_at = None 

1249 reclaimed += 1 

1250 logger.warning( 

1251 f"Reclaimed stranded queue row for research " 

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

1253 "resetting is_processing=False" 

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

1255 + ( 

1256 " and TaskMetadata.status=queued" 

1257 if metadata_changed 

1258 else "" 

1259 ) 

1260 ) 

1261 if finished_ids: 

1262 cleanup_queued_research_state( 

1263 db_session, 

1264 finished_ids, 

1265 include_claimed=True, 

1266 acting_user=username, 

1267 ) 

1268 if reclaimed or finished_ids: 

1269 reconcile_research_queue_status(db_session, force=True) 

1270 if not self._commit_with_safe_rollback( 

1271 db_session, 

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

1273 ): 

1274 return 0 

1275 # Discard retry bookkeeping only after the database cleanup is 

1276 # durable. A rollback must leave the whole claim recoverable. 

1277 with self._spawn_retry_counts_lock: 

1278 for research_id in finished_ids: 

1279 self._spawn_retry_counts.pop(research_id, None) 

1280 return reclaimed + len(finished_ids) 

1281 

1282 def _sweep_missing_parent_queue_rows( 

1283 self, db_session: Session, username: str 

1284 ) -> QueueSweepResult: 

1285 queued_orphan_ids = { 

1286 research_id 

1287 for (research_id,) in ( 

1288 db_session.query(QueuedResearch.research_id) 

1289 .outerjoin( 

1290 ResearchHistory, 

1291 QueuedResearch.research_id == ResearchHistory.id, 

1292 ) 

1293 .filter( 

1294 QueuedResearch.username == username, 

1295 ResearchHistory.id.is_(None), 

1296 ) 

1297 .all() 

1298 ) 

1299 } 

1300 # TaskMetadata has no owner column. A row that another user's queue 

1301 # entry points at is theirs, so it is left out here, and 

1302 # cleanup_queued_research_state leaves it too when given the acting 

1303 # user. The queue row stands in for the owner only because 

1304 # queued_researches.research_id is UNIQUE; a test in 

1305 # test_queued_research_lifecycle_cleanup.py pins that. 

1306 metadata_orphan_ids = { 

1307 task_id 

1308 for (task_id,) in ( 

1309 db_session.query(TaskMetadata.task_id) 

1310 .outerjoin( 

1311 ResearchHistory, 

1312 TaskMetadata.task_id == ResearchHistory.id, 

1313 ) 

1314 .outerjoin( 

1315 QueuedResearch, 

1316 TaskMetadata.task_id == QueuedResearch.research_id, 

1317 ) 

1318 .filter( 

1319 TaskMetadata.task_type == "research", 

1320 ResearchHistory.id.is_(None), 

1321 or_( 

1322 QueuedResearch.research_id.is_(None), 

1323 QueuedResearch.username == username, 

1324 ), 

1325 ) 

1326 .all() 

1327 ) 

1328 } 

1329 orphaned_research_ids = queued_orphan_ids | metadata_orphan_ids 

1330 if not orphaned_research_ids: 

1331 if reconcile_research_queue_status(db_session): 

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

1333 db_session, 

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

1335 ): 

1336 return QueueSweepResult(False, frozenset()) 

1337 return QueueSweepResult(True, frozenset()) 

1338 

1339 cleanup_result = cleanup_queued_research_state( 

1340 db_session, 

1341 orphaned_research_ids, 

1342 include_claimed=True, 

1343 acting_user=username, 

1344 ) 

1345 if not self._commit_with_safe_rollback( 

1346 db_session, 

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

1348 ): 

1349 return QueueSweepResult(False, frozenset()) 

1350 

1351 with self._spawn_retry_counts_lock: 

1352 for research_id in cleanup_result.cleaned_ids: 

1353 self._spawn_retry_counts.pop(research_id, None) 

1354 return QueueSweepResult(True, cleanup_result.cleaned_ids) 

1355 

1356 def _start_queued_researches( 

1357 self, 

1358 db_session: Session, 

1359 queue_service: UserQueueService, 

1360 username: str, 

1361 password: str, 

1362 available_slots: int, 

1363 ): 

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

1365 sweep_result = self._sweep_missing_parent_queue_rows( 

1366 db_session, username 

1367 ) 

1368 if not sweep_result.can_dispatch: 

1369 return 

1370 

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

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

1373 # filter below and would never retry. 

1374 self._reclaim_stranded_queue_rows(db_session, username) 

1375 

1376 # Get queued researches 

1377 queued = ( 

1378 db_session.query(QueuedResearch) 

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

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

1381 .limit(available_slots) 

1382 .all() 

1383 ) 

1384 

1385 for queued_research in queued: 

1386 research_id = queued_research.research_id 

1387 try: 

1388 # Atomically claim this item by flipping is_processing from 

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

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

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

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

1393 # workers could both process the same queued item. 

1394 claimed = ( 

1395 db_session.query(QueuedResearch) 

1396 .filter( 

1397 QueuedResearch.id == queued_research.id, 

1398 QueuedResearch.is_processing.is_(False), 

1399 db_session.query(ResearchHistory.id) 

1400 .filter( 

1401 ResearchHistory.id == research_id, 

1402 ResearchHistory.status == ResearchStatus.QUEUED, 

1403 ) 

1404 .exists(), 

1405 ) 

1406 .update( 

1407 {QueuedResearch.is_processing: True}, 

1408 synchronize_session=False, 

1409 ) 

1410 ) 

1411 db_session.commit() 

1412 if not claimed: 

1413 logger.debug( 

1414 f"Queued research {research_id} " 

1415 f"already claimed by another worker; skipping" 

1416 ) 

1417 continue 

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

1419 db_session.refresh(queued_research) 

1420 

1421 # Update task status 

1422 queue_service.update_task_status(research_id, "processing") 

1423 

1424 # Start the research 

1425 self._start_research( 

1426 db_session, 

1427 username, 

1428 password, 

1429 queued_research, 

1430 ) 

1431 

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

1433 # remove the queue row. 

1434 with self._spawn_retry_counts_lock: 

1435 self._spawn_retry_counts.pop(research_id, None) 

1436 db_session.delete(queued_research) 

1437 db_session.commit() 

1438 

1439 logger.info( 

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

1441 ) 

1442 

1443 except DuplicateResearchError: 

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

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

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

1447 # successful pre-spawn commit; terminal COMPLETED / 

1448 # FAILED / SUSPENDED from a thread that already finished 

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

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

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

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

1453 # emit a false failure for a completed one. 

1454 logger.warning( 

1455 f"Research {research_id} is already started " 

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

1457 "queue row" 

1458 ) 

1459 with self._spawn_retry_counts_lock: 

1460 self._spawn_retry_counts.pop(research_id, None) 

1461 self._delete_queue_row_safely(db_session, username, research_id) 

1462 continue 

1463 

1464 except SystemAtCapacityError: 

1465 # System hit the global concurrent-research capacity while 

1466 # dispatching this queued item. _start_research already 

1467 # reset the ResearchHistory row back to QUEUED before 

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

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

1470 # otherwise a busy system would wrongly mark a perfectly 

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

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

1473 # Mirrors the dedicated handler in _start_research_directly. 

1474 logger.info( 

1475 f"System at capacity dispatching queued research " 

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

1477 ) 

1478 # Revert the queued->processing claim from 

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

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

1481 # queued_tasks rather than leaking into active_tasks on 

1482 # every capacity-rejected retry. 

1483 queue_service.update_task_status(research_id, "queued") 

1484 fresh_queued = ( 

1485 db_session.query(QueuedResearch) 

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

1487 .first() 

1488 ) 

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

1490 fresh_queued.is_processing = False 

1491 self._commit_with_safe_rollback( 

1492 db_session, 

1493 "is_processing reset after capacity reject for " 

1494 f"research {research_id}", 

1495 ) 

1496 continue 

1497 

1498 except InvalidQueuedResearchOverridesError as error: 

1499 logger.warning( 

1500 "Invalid persisted search overrides for queued research " 

1501 f"{research_id}; marking FAILED" 

1502 ) 

1503 with self._spawn_retry_counts_lock: 

1504 self._spawn_retry_counts.pop(research_id, None) 

1505 

1506 research = ( 

1507 db_session.query(ResearchHistory) 

1508 .filter_by(id=research_id) 

1509 .first() 

1510 ) 

1511 fresh_queued = ( 

1512 db_session.query(QueuedResearch) 

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

1514 .first() 

1515 ) 

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

1517 research.status = ResearchStatus.FAILED 

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

1519 db_session.delete(fresh_queued) 

1520 self._commit_with_safe_rollback( 

1521 db_session, 

1522 "terminal invalid persisted overrides for research " 

1523 f"{research_id}", 

1524 ) 

1525 self.notify_research_failed( 

1526 username=username, 

1527 research_id=research_id, 

1528 error_message=str(error), 

1529 user_password=password, 

1530 ) 

1531 continue 

1532 

1533 except Exception as e: 

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

1535 # traceback chain and redact str(e). 

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

1537 logger.warning( 

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

1539 ) 

1540 # Session may be in PendingRollbackError state after a 

1541 # failed commit inside _start_research. 

1542 try: 

1543 db_session.rollback() 

1544 except Exception as rb_err: 

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

1546 # rendered traceback could expose it via 

1547 # diagnose=True frame locals. 

1548 logger.debug( 

1549 "Rollback after start failure: " 

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

1551 ) 

1552 

1553 attempts = self._bump_spawn_retry_count(research_id) 

1554 

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

1556 fresh_queued = ( 

1557 db_session.query(QueuedResearch) 

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

1559 .first() 

1560 ) 

1561 

1562 if attempts < SPAWN_RETRY_LIMIT: 

1563 # Transient failure — allow the next loop tick to 

1564 # retry. _start_research rolls back its own 

1565 # IN_PROGRESS write on spawn failure, so the fix-ups 

1566 # needed here are resetting is_processing and 

1567 # reverting the TaskMetadata claim from 

1568 # update_task_status("processing") above. Without 

1569 # the revert, reconcile_research_queue_status 

1570 # under-reports queued_tasks and 

1571 # _process_user_queue deregisters the user before 

1572 # this row is retried (#6147). 

1573 logger.warning( 

1574 f"Spawn failed for research {research_id} " 

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

1576 "leaving queued for retry" 

1577 ) 

1578 # Mirror the SystemAtCapacityError branch: revert 

1579 # the queued->processing claim so the slot returns 

1580 # to queued_tasks instead of leaking into 

1581 # active_tasks on every failed attempt. 

1582 queue_service.update_task_status(research_id, "queued") 

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

1584 fresh_queued.is_processing = False 

1585 reconcile_research_queue_status(db_session, force=True) 

1586 self._commit_with_safe_rollback( 

1587 db_session, 

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

1589 ) 

1590 continue 

1591 

1592 # Exhausted retries — mark terminal FAILED, delete the 

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

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

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

1596 logger.warning( 

1597 f"Spawn failed for research {research_id} " 

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

1599 ) 

1600 with self._spawn_retry_counts_lock: 

1601 self._spawn_retry_counts.pop(research_id, None) 

1602 try: 

1603 research = ( 

1604 db_session.query(ResearchHistory) 

1605 .filter_by(id=research_id) 

1606 .first() 

1607 ) 

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

1609 research.status = ResearchStatus.FAILED 

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

1611 db_session.delete(fresh_queued) 

1612 db_session.commit() 

1613 except Exception as e2: 

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

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

1616 logger.warning( 

1617 "Failed to persist terminal FAILED state for " 

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

1619 ) 

1620 try: 

1621 db_session.rollback() 

1622 except Exception as rb_err: 

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

1624 # rollback handler above. 

1625 logger.debug( 

1626 "Rollback after terminal update failure: " 

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

1628 ) 

1629 

1630 # notify_research_failed opens its own session and 

1631 # sends the user notification. Called exactly once 

1632 # per research_id because the counter is popped above. 

1633 self.notify_research_failed( 

1634 username=username, 

1635 research_id=research_id, 

1636 error_message=( 

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

1638 ), 

1639 user_password=password, 

1640 ) 

1641 

1642 def _start_research( 

1643 self, 

1644 db_session: Session, 

1645 username: str, 

1646 password: str, 

1647 queued_research, 

1648 ): 

1649 """Start a queued research. 

1650 

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

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

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

1654 then our post-spawn commit would overwrite that with 

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

1656 after it had already finished. 

1657 

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

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

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

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

1662 status further would be wrong. 

1663 """ 

1664 research_id = queued_research.research_id 

1665 research = ( 

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

1667 ) 

1668 

1669 if not research: 

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

1671 

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

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

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

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

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

1677 # retry would both overwrite the terminal status with 

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

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

1680 # the whole research. 

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

1682 # DuplicateResearchError so the caller's existing dup branch 

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

1684 if research.status != ResearchStatus.QUEUED: 

1685 raise DuplicateResearchError( 

1686 f"Research {research_id} is already started " 

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

1688 ) 

1689 

1690 # Extract settings 

1691 settings_snapshot = queued_research.settings_snapshot or {} 

1692 

1693 # Handle new vs legacy structure 

1694 if ( 

1695 isinstance(settings_snapshot, dict) 

1696 and "submission" in settings_snapshot 

1697 ): 

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

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

1700 allowed_override_keys = ( 

1701 "model_provider", 

1702 "model", 

1703 "custom_endpoint", 

1704 "search_engine", 

1705 "max_results", 

1706 "time_period", 

1707 "iterations", 

1708 "questions_per_iteration", 

1709 "strategy", 

1710 ) 

1711 explicit_override_keys = settings_snapshot.get( 

1712 "submission_overrides" 

1713 ) 

1714 override_keys = ( 

1715 explicit_override_keys 

1716 if isinstance(explicit_override_keys, list) 

1717 else allowed_override_keys 

1718 ) 

1719 runtime_overrides = { 

1720 key: submission_params[key] 

1721 for key in allowed_override_keys 

1722 if key in override_keys and key in submission_params 

1723 } 

1724 else: 

1725 submission_params = settings_snapshot 

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

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

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

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

1730 # an empty snapshot and refuse the run. 

1731 _legacy_engine = submission_params.get("search_engine") 

1732 complete_settings = ( 

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

1734 ) 

1735 runtime_overrides = { 

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

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

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

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

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

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

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

1743 "questions_per_iteration": submission_params.get( 

1744 "questions_per_iteration" 

1745 ), 

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

1747 } 

1748 

1749 query_validation_error = validate_research_query_length( 

1750 queued_research.query 

1751 ) 

1752 if query_validation_error is not None: 

1753 raise InvalidQueuedResearchOverridesError(query_validation_error) 

1754 

1755 validation_error = validate_search_overrides(runtime_overrides) 

1756 if validation_error is not None: 

1757 raise InvalidQueuedResearchOverridesError(validation_error) 

1758 

1759 # Claim IN_PROGRESS before spawn to close the 

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

1761 research.status = ResearchStatus.IN_PROGRESS 

1762 db_session.commit() 

1763 

1764 try: 

1765 research_thread = start_research_process( 

1766 research_id, 

1767 queued_research.query, 

1768 queued_research.mode, 

1769 run_research_process, 

1770 username=username, 

1771 user_password=password, # Pass password for metrics 

1772 settings_snapshot=complete_settings, 

1773 **runtime_overrides, 

1774 ) 

1775 except DuplicateResearchError: 

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

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

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

1779 raise 

1780 except SystemAtCapacityError: 

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

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

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

1784 # NOT count toward SPAWN_RETRY_LIMIT. 

1785 logger.info( 

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

1787 "re-queueing for next tick" 

1788 ) 

1789 research.status = ResearchStatus.QUEUED 

1790 self._commit_with_safe_rollback( 

1791 db_session, 

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

1793 ) 

1794 raise 

1795 except Exception: 

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

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

1798 research.status = ResearchStatus.QUEUED 

1799 self._commit_with_safe_rollback( 

1800 db_session, 

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

1802 ) 

1803 raise 

1804 

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

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

1807 # Raise DuplicateResearchError instead of letting a generic 

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

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

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

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

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

1813 active_record = UserActiveResearch( 

1814 username=username, 

1815 research_id=research_id, 

1816 status=ResearchStatus.IN_PROGRESS, 

1817 thread_id=str(research_thread.ident), 

1818 settings_snapshot=queued_research.settings_snapshot, 

1819 ) 

1820 db_session.add(active_record) 

1821 if not self._commit_with_safe_rollback( 

1822 db_session, 

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

1824 ): 

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

1826 # unrecorded but the thread running. Raise 

1827 # DuplicateResearchError so the caller's dup branch deletes 

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

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

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

1831 # as terminal FAILED. 

1832 raise DuplicateResearchError( 

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

1834 "UserActiveResearch commit failed" 

1835 ) 

1836 

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

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

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

1840 # and entries older than this TTL. 

1841 _PENDING_OPS_MAX = 10_000 

1842 _PENDING_OPS_TTL_SECONDS = 24 * 60 * 60 # 24 hours 

1843 

1844 def reconcile_orphan_active_research( 

1845 self, username: str, db_session 

1846 ) -> int: 

1847 """Mark stale UserActiveResearch rows as FAILED. 

1848 

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

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

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

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

1853 

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

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

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

1857 running would wrongly flag that live research FAILED — and 

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

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

1860 research-start reclaim already use. 

1861 

1862 Returns the number of rows reconciled. 

1863 """ 

1864 from datetime import UTC, datetime 

1865 

1866 from ..research_state import cleanup_research, is_research_thread_alive 

1867 

1868 try: 

1869 stale = ( 

1870 db_session.query(UserActiveResearch) 

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

1872 .all() 

1873 ) 

1874 if not stale: 

1875 return 0 

1876 

1877 count = 0 

1878 for record in stale: 

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

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

1881 if is_research_thread_alive(record.research_id): 

1882 continue 

1883 # Also mark the ResearchHistory row as failed 

1884 research = ( 

1885 db_session.query(ResearchHistory) 

1886 .filter_by(id=record.research_id) 

1887 .first() 

1888 ) 

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

1890 research.status = ResearchStatus.FAILED 

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

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

1893 meta["failure_reason"] = ( 

1894 "Server restarted while research was in progress" 

1895 ) 

1896 research.research_meta = meta 

1897 record.status = ResearchStatus.FAILED 

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

1899 # reclaim_stale_user_active_research. Without this the stale 

1900 # _active_research entry outlives the DB reconciliation, so 

1901 # capacity checks keep counting a research that is already 

1902 # FAILED and its termination flag never clears. 

1903 cleanup_research(record.research_id) 

1904 count += 1 

1905 

1906 db_session.commit() 

1907 logger.info( 

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

1909 ) 

1910 return count 

1911 except Exception: 

1912 logger.exception( 

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

1914 ) 

1915 return 0 

1916 

1917 def _evict_stale_pending_operations(self) -> None: 

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

1919 now = time.time() 

1920 # TTL eviction 

1921 expired = [ 

1922 op_id 

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

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

1925 ] 

1926 for op_id in expired: 

1927 del self.pending_operations[op_id] 

1928 # Size cap — drop oldest entries if over 

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

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

1931 oldest = sorted( 

1932 self.pending_operations.items(), 

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

1934 )[:overflow] 

1935 for op_id, _ in oldest: 

1936 del self.pending_operations[op_id] 

1937 

1938 def queue_progress_update( 

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

1940 ): 

1941 """ 

1942 Queue a progress update that needs database access. 

1943 For compatibility with old processor during migration. 

1944 

1945 Args: 

1946 username: The username 

1947 research_id: The research ID 

1948 progress: The progress value (0-100) 

1949 """ 

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

1951 # or queue it for later processing 

1952 operation_id = str(uuid.uuid4()) 

1953 with self._pending_operations_lock: 

1954 self.pending_operations[operation_id] = { 

1955 "username": username, 

1956 "operation_type": "progress_update", 

1957 "research_id": research_id, 

1958 "progress": progress, 

1959 "timestamp": time.time(), 

1960 } 

1961 self._evict_stale_pending_operations() 

1962 logger.debug( 

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

1964 ) 

1965 

1966 def queue_error_update( 

1967 self, 

1968 username: str, 

1969 research_id: str, 

1970 status: str, 

1971 error_message: str, 

1972 metadata: Dict[str, Any], 

1973 completed_at: str, 

1974 report_path: Optional[str] = None, 

1975 ): 

1976 """ 

1977 Queue an error status update that needs database access. 

1978 For compatibility with old processor during migration. 

1979 

1980 Args: 

1981 username: The username 

1982 research_id: The research ID 

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

1984 error_message: The error message 

1985 metadata: Research metadata 

1986 completed_at: Completion timestamp 

1987 report_path: Optional path to error report 

1988 """ 

1989 operation_id = str(uuid.uuid4()) 

1990 with self._pending_operations_lock: 

1991 self.pending_operations[operation_id] = { 

1992 "username": username, 

1993 "operation_type": "error_update", 

1994 "research_id": research_id, 

1995 "status": status, 

1996 "error_message": error_message, 

1997 "metadata": metadata, 

1998 "completed_at": completed_at, 

1999 "report_path": report_path, 

2000 "timestamp": time.time(), 

2001 } 

2002 self._evict_stale_pending_operations() 

2003 logger.info( 

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

2005 ) 

2006 

2007 def process_pending_operations_for_user( 

2008 self, username: str, db_session: Session 

2009 ) -> int: 

2010 """ 

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

2012 Called from request context where encrypted database is accessible. 

2013 For compatibility with old processor during migration. 

2014 

2015 Args: 

2016 username: Username to process operations for 

2017 db_session: Active database session for the user 

2018 

2019 Returns: 

2020 Number of operations cleared from the queue 

2021 """ 

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

2023 raw_operations = [] 

2024 with self._pending_operations_lock: 

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

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

2027 raw_operations.append((op_id, op_data)) 

2028 # Remove immediately to prevent duplicate processing 

2029 del self.pending_operations[op_id] 

2030 

2031 if not raw_operations: 

2032 return 0 

2033 

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

2035 latest_progress = {} 

2036 latest_errors = {} 

2037 other_ops = [] 

2038 

2039 for op_id, op_data in raw_operations: 

2040 op_type = op_data.get("operation_type") 

2041 rid = op_data.get("research_id") 

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

2043 

2044 if op_type == "progress_update" and rid: 

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

2046 1 

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

2048 latest_progress[rid] = (op_id, op_data) 

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

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

2051 "timestamp", 0 

2052 ): 

2053 latest_errors[rid] = (op_id, op_data) 

2054 else: 

2055 other_ops.append((op_id, op_data)) 

2056 

2057 operations_to_process = ( 

2058 list(latest_progress.values()) 

2059 + list(latest_errors.values()) 

2060 + other_ops 

2061 ) 

2062 

2063 from sqlalchemy.exc import ( 

2064 OperationalError, 

2065 PendingRollbackError, 

2066 TimeoutError, 

2067 ) 

2068 

2069 from ...database.models import ResearchHistory 

2070 

2071 max_retries = 3 

2072 backoff = 0.05 

2073 max_requeue_attempts = 10 

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

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

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

2077 # so retries don't double-count. 

2078 processed_count = 0 

2079 

2080 for attempt in range(max_retries): 

2081 try: 

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

2083 rids = list( 

2084 { 

2085 op_data["research_id"] 

2086 for _, op_data in operations_to_process 

2087 if op_data.get("research_id") 

2088 } 

2089 ) 

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

2091 researches = ( 

2092 db_session.query(ResearchHistory) 

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

2094 .all() 

2095 ) 

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

2097 else: 

2098 research_map = {} 

2099 

2100 for op_id, op_data in operations_to_process: 

2101 op_type = op_data.get("operation_type") 

2102 rid = op_data.get("research_id") 

2103 research = research_map.get(rid) 

2104 

2105 if not research: 

2106 continue 

2107 

2108 if op_type == "progress_update": 

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

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

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

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

2113 incoming_meta = op_data.get("metadata") 

2114 existing_meta = research.research_meta 

2115 if isinstance(incoming_meta, dict): 2115 ↛ 2131line 2115 didn't jump to line 2131 because the condition on line 2115 was always true

2116 # Merge, never wholesale-replace: a bare 

2117 # {"terminated_at": ...} from handle_termination 

2118 # must not wipe the row's settings_snapshot (and 

2119 # submission block) that view/export rendering 

2120 # resolves report.uncited_sources_mode from. 

2121 # Callers that already merged (the failure path) 

2122 # are unaffected: re-merging is idempotent. 

2123 # Read legacy JSON-string metadata using the 

2124 # same normalization as completion updates. 

2125 merged_meta = _parse_research_metadata( 

2126 existing_meta 

2127 ) 

2128 merged_meta.update(incoming_meta) 

2129 research.research_meta = merged_meta 

2130 else: 

2131 research.research_meta = incoming_meta 

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

2133 report_path = op_data.get("report_path") 

2134 if report_path: 

2135 research.report_path = report_path 

2136 

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

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

2139 # double-count the raw operations. 

2140 db_session.commit() 

2141 processed_count = len(raw_operations) 

2142 break 

2143 

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

2145 try: 

2146 db_session.rollback() 

2147 except Exception as rollback_err: 

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

2149 

2150 if attempt < max_retries - 1: 

2151 logger.debug( 

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

2153 ) 

2154 time.sleep(backoff) 

2155 backoff *= 2 

2156 else: 

2157 logger.warning( 

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

2159 ) 

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

2161 with self._pending_operations_lock: 

2162 for op_id, op_data in raw_operations: 

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

2164 if retry_count > max_requeue_attempts: 

2165 logger.exception( 

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

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

2168 ) 

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

2170 op_data["retry_count"] = retry_count 

2171 self.pending_operations[op_id] = op_data 

2172 processed_count = 0 

2173 

2174 except Exception: 

2175 logger.exception( 

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

2177 ) 

2178 try: 

2179 db_session.rollback() 

2180 except Exception as rollback_err: 

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

2182 # Re-queue raw operations up to max_requeue_attempts 

2183 with self._pending_operations_lock: 

2184 for op_id, op_data in raw_operations: 

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

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

2187 logger.exception( 

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

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

2190 ) 

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

2192 op_data["retry_count"] = retry_count 

2193 self.pending_operations[op_id] = op_data 

2194 processed_count = 0 

2195 break 

2196 

2197 return processed_count 

2198 

2199 

2200# Global queue processor instance 

2201queue_processor = QueueProcessorV2()