Coverage for src/local_deep_research/research_library/services/collection_rebuild.py: 95%

225 statements  

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

1"""Build a replacement collection index without changing its published state.""" 

2 

3from __future__ import annotations 

4 

5import hashlib 

6import json 

7import uuid 

8from collections.abc import Callable 

9from contextlib import AbstractContextManager 

10from datetime import UTC, datetime 

11from pathlib import Path 

12from typing import TYPE_CHECKING, Any 

13 

14import numpy as np 

15from loguru import logger 

16from sqlalchemy.orm import Session 

17 

18from ...config.paths import get_cache_directory 

19from ...database.models.library import ( 

20 Collection, 

21 Document, 

22 DocumentChunk, 

23 DocumentCollection, 

24 EmbeddingProvider, 

25 RagDocumentStatus, 

26 RAGIndex, 

27 RAGIndexStatus, 

28) 

29from ...database.session_context import safe_rollback 

30from ...vector_stores.config import only_local_file_providers_registered 

31 

32if TYPE_CHECKING: 

33 from .library_rag_service import LibraryRAGService, _PreparedDocument 

34 

35 

36_CONFIG_FIELDS = ( 

37 "embedding_model", 

38 "embedding_model_type", 

39 "embedding_dimension", 

40 "chunk_size", 

41 "chunk_overlap", 

42 "splitter_type", 

43 "text_separators", 

44 "distance_metric", 

45 "normalize_vectors", 

46 "index_type", 

47) 

48 

49 

50def document_fingerprint(document: Document) -> str: 

51 """Identify the source text and metadata used to prepare a document.""" 

52 payload = { 

53 key: getattr(document, key) 

54 for key in ( 

55 "text_content", 

56 "title", 

57 "filename", 

58 "original_url", 

59 "authors", 

60 "published_date", 

61 "doi", 

62 "arxiv_id", 

63 "pmid", 

64 "pmcid", 

65 "extraction_method", 

66 "word_count", 

67 ) 

68 } 

69 return hashlib.sha256( 

70 json.dumps(payload, sort_keys=True, default=str).encode("utf-8") 

71 ).hexdigest() 

72 

73 

74def collection_configuration(collection: Collection) -> str: 

75 """Snapshot the stored settings that must survive an unfinished rebuild.""" 

76 return json.dumps( 

77 {key: getattr(collection, key) for key in _CONFIG_FIELDS}, 

78 sort_keys=True, 

79 default=str, 

80 ) 

81 

82 

83def _index_state(session: Session, collection_name: str) -> list[tuple]: 

84 return sorted( 

85 ( 

86 row.id, 

87 row.index_hash, 

88 row.is_current, 

89 row.chunk_count, 

90 row.total_documents, 

91 row.last_updated_at, 

92 ) 

93 for row in session.query(RAGIndex).filter_by( 

94 collection_name=collection_name 

95 ) 

96 ) 

97 

98 

99def _membership_state(session: Session, collection_id: str) -> list[tuple]: 

100 return sorted( 

101 ( 

102 row.document_id, 

103 row.id, 

104 row.indexed, 

105 row.chunk_count, 

106 row.last_indexed_at, 

107 ) 

108 for row in session.query(DocumentCollection).filter_by( 

109 collection_id=collection_id 

110 ) 

111 ) 

112 

113 

114def rebuild_stage_prefix(collection_id: str) -> str: 

115 """Return the name prefix shared by every rebuild stage of a collection.""" 

116 scope = hashlib.sha256(collection_id.encode()).hexdigest()[:16] 

117 return f"collection_rebuild_{scope}_" 

118 

119 

120class RebuildCancelled(RuntimeError): 

121 """Cancellation observed before publishing a replacement.""" 

122 

123 

124class CollectionRebuild: 

125 """A private generation whose only publication point is a DB commit. 

126 

127 Chunk rows and text-free vector files use a unique staging identity. 

128 Publishing changes their collection identity, the current-index pointer, 

129 collection configuration and document status in one transaction. Files 

130 retain their generation paths, so no filesystem rename can race that 

131 transaction. A process interruption can leave an unpublished generation, 

132 but cannot remove the previously published one. 

133 

134 The caller holds the per-collection rebuild lock through preparation and 

135 cleanup. Ordinary indexing/search need only the shorter collection lock 

136 around publication. Document embedding never holds that lock or a DB 

137 transaction. Interrupted stages stay unselected until the collection is 

138 deleted or its index cleared (``remove_unfinished_rebuilds``); another 

139 process's unfinished generation must not be removed speculatively. 

140 """ 

141 

142 def __init__( 

143 self, 

144 service: LibraryRAGService, 

145 collection_id: str, 

146 doc_info: list[tuple[str, str]], 

147 *, 

148 session_factory: Callable[[], AbstractContextManager[Session]], 

149 ): 

150 # The generation switch and post-commit retirement below are atomic 

151 # only for local files. A remote drop cannot be rolled back, and the 

152 # current RAGIndex rows do not record which backend owns their data. 

153 # Refuse rebuilding if any remote provider is registered until those 

154 # providers have a separate publication protocol. 

155 if not only_local_file_providers_registered(): 

156 raise RuntimeError( 

157 "Staged collection rebuild requires local-file vector stores" 

158 ) 

159 self.service = service 

160 self.collection_id = collection_id 

161 # nosemgrep: semgrep.rules.sql-string-concatenation, reason: Collection identifier passed as an ORM value; never SQL statement text 

162 self.collection_name = f"collection_{collection_id}" 

163 prefix = rebuild_stage_prefix(collection_id) 

164 # nosemgrep: semgrep.rules.sql-string-concatenation, reason: Collection identifier passed as an ORM value; never SQL statement text 

165 self.stage_name = f"{prefix}{uuid.uuid4().hex}" 

166 self.stage_id = self.stage_name.removeprefix("collection_") 

167 self.vector_index = None 

168 self.index_id = None 

169 self.prepared = {} 

170 self.published = False 

171 self.target_configuration = None 

172 self._session = session_factory 

173 

174 with self._session() as session: 

175 collection = session.get(Collection, collection_id) 

176 if collection is None: 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true

177 raise ValueError("Collection not found") 

178 self.configuration = collection_configuration(collection) 

179 self.original_indices = _index_state(session, self.collection_name) 

180 self.original_memberships = _membership_state( 

181 session, collection_id 

182 ) 

183 self.document_ids = {row[0] for row in self.original_memberships} 

184 if self.document_ids != {doc_id for doc_id, _title in doc_info}: 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true

185 raise ValueError("Collection changed before rebuilding; retry") 

186 # Persist before preparation so cancellation or a process exit 

187 # cannot silently erase the unfinished request. The user DB is 

188 # encrypted; no document content is copied into this marker. 

189 collection.rebuild_pending_since = datetime.now(UTC) 

190 session.commit() 

191 logger.info( 

192 "Rebuilding collection index; the published index, if any, " 

193 "remains in use until the replacement is complete. " 

194 "Cancelling before publication keeps that index and does not " 

195 "retry the rebuild." 

196 ) 

197 

198 def prepare( 

199 self, 

200 document_id: str, 

201 is_cancelled: Callable[[], bool] | None = None, 

202 ) -> dict[str, Any]: 

203 from .library_rag_service import _PreparedDocument 

204 

205 result = self.service._prepare_document( 

206 document_id, self.collection_id, True, is_cancelled 

207 ) 

208 if result.get("status") == "needs_serial_write": 

209 # Empty documents have no replacement chunks. Never dispatch the 

210 # ordinary empty-document purge against the published generation. 

211 return { 

212 "status": "prepared", 

213 "prepared": _PreparedDocument( 

214 document_id=document_id, 

215 collection_id=self.collection_id, 

216 chunk_inputs=[], 

217 vectors=np.empty((0, 0), dtype="float32"), 

218 source_fingerprint=result["source_fingerprint"], 

219 ), 

220 } 

221 return result 

222 

223 def write(self, prepared: _PreparedDocument) -> dict[str, Any]: 

224 """Persist only private chunks; leave published flags and files alone.""" 

225 if prepared.document_id not in self.document_ids: 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true

226 raise ValueError("Document does not belong to this rebuild") 

227 if prepared.chunk_inputs: 

228 if self.vector_index is None: 

229 self.vector_index = self.service._get_vector_index( 

230 self.stage_id, self.stage_name 

231 ) 

232 self.index_id = self.service.rag_index_record.id 

233 with self._session() as session: 

234 try: 

235 self.vector_index.index_prepared( 

236 source_type="document", 

237 source_id=prepared.document_id, 

238 chunks=prepared.chunk_inputs, 

239 vectors=prepared.vectors, 

240 replace=True, 

241 session=session, 

242 ) 

243 index = session.get(RAGIndex, self.index_id) 

244 index.status = RAGIndexStatus.REBUILDING 

245 session.commit() 

246 except Exception: 

247 safe_rollback(session, "collection_rebuild.write") 

248 raise 

249 self.prepared[prepared.document_id] = ( 

250 prepared.source_fingerprint, 

251 len(prepared.chunk_inputs), 

252 ) 

253 return { 

254 "status": "success" if prepared.chunk_inputs else "cleared", 

255 "chunk_count": len(prepared.chunk_inputs), 

256 } 

257 

258 def _validate_sources(self, session, collection): 

259 if collection_configuration(collection) != self.configuration: 

260 raise ValueError("Collection configuration changed; retry rebuild") 

261 if _index_state(session, self.collection_name) != self.original_indices: 

262 raise ValueError("Collection index changed; retry rebuild") 

263 # Removing a document from search changes its membership's indexing 

264 # state without changing the collection's RAGIndex counters. Preserve 

265 # that successful removal instead of restoring its staged vectors. 

266 if ( 

267 _membership_state(session, self.collection_id) 

268 != self.original_memberships 

269 ): 

270 raise ValueError("Collection indexing state changed; retry rebuild") 

271 seen = set() 

272 # Bound retained source text during the final validation. A writer 

273 # transaction prevents edits between validation and publication. 

274 documents = ( 

275 session.query(Document) 

276 .join(DocumentCollection) 

277 .filter(DocumentCollection.collection_id == self.collection_id) 

278 .yield_per(1) 

279 ) 

280 for document in documents: 

281 seen.add(document.id) 

282 expected = self.prepared.get(document.id) 

283 if ( 

284 expected is None 

285 or document_fingerprint(document) != expected[0] 

286 ): 

287 raise ValueError("Collection documents changed; retry rebuild") 

288 if seen != self.document_ids or set(self.prepared) != self.document_ids: 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true

289 raise ValueError("Collection documents changed; retry rebuild") 

290 

291 def _validate_vectors(self): 

292 with self._session() as session: 

293 chunk_ids = { 

294 row.id 

295 for row in session.query(DocumentChunk.id).filter_by( 

296 collection_name=self.stage_name 

297 ) 

298 } 

299 # Reopen the durable generation: the in-memory facade can 

300 # still contain every vector after a file was lost or a 

301 # persist failed. Publication requires the bytes on disk. 

302 durable_index = ( 

303 self.service._get_vector_index(self.stage_id, self.stage_name) 

304 if self.vector_index is not None 

305 else None 

306 ) 

307 live_ids = set(durable_index.live_ids()) if durable_index else set() 

308 expected_chunks = sum( 

309 n for _fingerprint, n in self.prepared.values() 

310 ) 

311 if chunk_ids != live_ids or len(chunk_ids) != expected_chunks: 

312 raise RuntimeError("Replacement index is incomplete") 

313 

314 return expected_chunks 

315 

316 def _publish_rows(self, session, expected_chunks): 

317 from ..deletion.utils.cascade_helper import CascadeHelper 

318 

319 # Acquire SQLite's writer lock before reading the state 

320 # that must remain unchanged through the publication. 

321 found = ( 

322 session.query(Collection) 

323 .filter_by(id=self.collection_id) 

324 .update({Collection.id: Collection.id}) 

325 ) 

326 if not found: 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true

327 raise ValueError("Collection was deleted during rebuild") 

328 # Validation, every publication write and the commit must share the 

329 # transaction that took the lock. No callback runs before the commit: 

330 # a callback that opened get_user_db_session on this thread would 

331 # share this session, and its error handler would roll it back. 

332 transaction = session.get_transaction() 

333 session.expire_all() 

334 collection = session.get(Collection, self.collection_id) 

335 self._validate_sources(session, collection) 

336 

337 CascadeHelper.delete_collection_chunks(session, self.collection_name) 

338 retired = CascadeHelper.delete_rag_indices_for_collection( 

339 session, self.collection_name, unlink_files=False 

340 ) 

341 

342 # Explicit deletion also works with SQLite connections 

343 # whose foreign-key enforcement was not configured. 

344 session.query(RagDocumentStatus).filter_by( 

345 collection_id=self.collection_id 

346 ).delete(synchronize_session=False) 

347 session.query(DocumentCollection).filter_by( 

348 collection_id=self.collection_id 

349 ).update({"indexed": False, "chunk_count": 0}) 

350 session.query(DocumentChunk).filter_by( 

351 collection_name=self.stage_name 

352 ).update({"collection_name": self.collection_name}) 

353 

354 timestamp = datetime.now(UTC) 

355 index = ( 

356 session.get(RAGIndex, self.index_id) 

357 if self.index_id is not None 

358 else None 

359 ) 

360 if index is not None: 

361 index.collection_name = self.collection_name 

362 index.is_current = True 

363 index.status = RAGIndexStatus.ACTIVE 

364 index.chunk_count = expected_chunks 

365 index.total_documents = sum( 

366 n > 0 for _fingerprint, n in self.prepared.values() 

367 ) 

368 index.last_updated_at = timestamp 

369 for document_id, ( 

370 _fingerprint, 

371 count, 

372 ) in self.prepared.items(): 

373 if count: 

374 session.add( 

375 RagDocumentStatus( 

376 document_id=document_id, 

377 collection_id=self.collection_id, 

378 rag_index_id=self.index_id, 

379 chunk_count=count, 

380 indexed_at=timestamp, 

381 ) 

382 ) 

383 session.query(DocumentCollection).filter_by( 

384 document_id=document_id, 

385 collection_id=self.collection_id, 

386 ).update( 

387 { 

388 "indexed": True, 

389 "chunk_count": count, 

390 "last_indexed_at": timestamp, 

391 } 

392 ) 

393 collection.embedding_model = self.service.embedding_model 

394 collection.embedding_model_type = EmbeddingProvider( 

395 self.service.embedding_provider 

396 ) 

397 collection.embedding_dimension = ( 

398 index.embedding_dimension if index else None 

399 ) 

400 collection.chunk_size = self.service.chunk_size 

401 collection.chunk_overlap = self.service.chunk_overlap 

402 collection.splitter_type = self.service.splitter_type 

403 collection.text_separators = self.service.text_separators 

404 collection.distance_metric = self.service.distance_metric 

405 collection.normalize_vectors = self.service.normalize_vectors 

406 collection.index_type = self.service.index_type 

407 

408 # Clear only with the generation switch, including an empty rebuild. 

409 collection.rebuild_pending_since = None 

410 

411 # A rollback since the lock was taken means the writes above run in a 

412 # newer transaction that validation never checked, and may hold only 

413 # part of the publication. Refuse to commit them, before recording 

414 # the target that _is_published() compares after a failure. 

415 if session.get_transaction() is not transaction: 

416 raise RuntimeError( 

417 "Collection publication lost its transaction; retry rebuild" 

418 ) 

419 self.target_configuration = collection_configuration(collection) 

420 return retired["index_paths"] 

421 

422 def publish(self, is_cancelled: Callable[[], bool] | None = None) -> None: 

423 """Publish a complete generation, then remove retired vector files. 

424 

425 Cancellation is decided once, before the publication transaction 

426 opens. After that the publication commits or rolls back as a whole. 

427 """ 

428 expected_chunks = self._validate_vectors() 

429 old_paths = [] 

430 with self.service._collection_transaction_lock(self.collection_id): 

431 # The background task's check reads the task row through 

432 # get_user_db_session. On this thread it gets the session that 

433 # publishes below. Run it while no scope is open, so its 

434 # autoflush and its error rollback cannot reach the publication. 

435 self._check_cancelled(is_cancelled) 

436 try: 

437 with self._session() as session: 

438 try: 

439 old_paths = self._publish_rows(session, expected_chunks) 

440 session.commit() 

441 self.published = True 

442 except Exception: 

443 safe_rollback(session, "collection_rebuild.publish") 

444 raise 

445 except Exception: 

446 # A post-commit callback can raise after publication. Use a 

447 # fresh session: the failed one may still be in COMMITTED 

448 # state, where even rollback and SELECT are unavailable. 

449 with self._session() as session: 

450 self.published = self._is_published(session) 

451 if not self.published: 

452 raise 

453 logger.warning( 

454 "Collection replacement committed despite a commit callback failure" 

455 ) 

456 

457 # Clearing an index and indexing again can reuse a retired path. 

458 # Keep collection ownership through removal so retirement cannot 

459 # delete the new writer's file. The DB commit has already finished. 

460 self._unlink(old_paths) 

461 from ...web_search_engines.search_engines_config import ( 

462 invalidate_collection_engines_cache, 

463 ) 

464 

465 invalidate_collection_engines_cache(self.service.username) 

466 

467 @staticmethod 

468 def _check_cancelled(is_cancelled): 

469 if is_cancelled is not None and is_cancelled(): 

470 raise RebuildCancelled("Collection rebuild was cancelled") 

471 

472 def _is_published(self, session): 

473 if self.index_id is None: 

474 collection = session.get(Collection, self.collection_id) 

475 return bool( 

476 self.target_configuration is not None 

477 and collection is not None 

478 and collection.rebuild_pending_since is None 

479 and collection_configuration(collection) 

480 == self.target_configuration 

481 and not _index_state(session, self.collection_name) 

482 ) 

483 index = session.get(RAGIndex, self.index_id) 

484 return bool(index and index.collection_name == self.collection_name) 

485 

486 @staticmethod 

487 def _unlink(paths): 

488 from ..deletion.utils.cascade_helper import CascadeHelper 

489 

490 for path in paths: 

491 CascadeHelper.delete_faiss_index_files( 

492 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Deletion helper enforces containment beneath the explicit rag_indices root 

493 Path(path), 

494 allowed_root=get_cache_directory() / "rag_indices", 

495 ) 

496 

497 def close(self): 

498 """Remove this unpublished generation after its workers have drained.""" 

499 from ..deletion.utils.cascade_helper import CascadeHelper 

500 

501 if self.published: 

502 return 

503 try: 

504 with self._session() as session: 

505 if self._is_published(session): 505 ↛ 506line 505 didn't jump to line 506 because the condition on line 505 was never true

506 self.published = True 

507 return 

508 logger.warning( 

509 "Collection rebuild did not publish a replacement. The " 

510 "previous index, if any, remains in use and may be " 

511 "outdated. Automatic indexing does not retry full " 

512 "rebuilds; run Re-Index All again." 

513 ) 

514 CascadeHelper.delete_collection_chunks(session, self.stage_name) 

515 retired = CascadeHelper.delete_rag_indices_for_collection( 

516 session, self.stage_name, unlink_files=False 

517 ) 

518 session.commit() 

519 self._unlink(retired["index_paths"]) 

520 except Exception: 

521 logger.exception( 

522 "Could not remove an unpublished collection rebuild" 

523 ) 

524 

525 def __enter__(self): 

526 return self 

527 

528 def __exit__(self, exc_type, exc_value, traceback): 

529 self.close() 

530 return False 

531 

532 

533def remove_unfinished_rebuilds( 

534 username: str, 

535 collection_id: str, 

536 session_factory: Callable[[], AbstractContextManager[Session]], 

537) -> int: 

538 """Remove the stages that interrupted rebuilds of a collection left. 

539 

540 Collection deletion and index clearing call this after their commit. 

541 Nothing selects a stage again, and its chunk rows hold document text. 

542 Each stage is removed the way ``CollectionRebuild.close`` removes its 

543 own. A rebuild of the collection running in this process holds the 

544 rebuild lock and removes its stage when it cannot publish, so while that 

545 lock is held every stage is left alone. Returns the number removed. 

546 """ 

547 from ..deletion.utils.cascade_helper import CascadeHelper 

548 from .library_rag_service import _hold_collection_lock 

549 

550 with _hold_collection_lock( 

551 username, collection_id, purpose="rebuild", blocking=False 

552 ) as held: 

553 if not held: 

554 return 0 

555 prefix = rebuild_stage_prefix(collection_id) 

556 paths = [] 

557 with session_factory() as session: 

558 names = { 

559 name 

560 for model in (DocumentChunk, RAGIndex) 

561 for (name,) in session.query(model.collection_name) 

562 .filter( 

563 model.collection_name.startswith(prefix, autoescape=True) 

564 ) 

565 .distinct() 

566 } 

567 for name in sorted(names): 

568 CascadeHelper.delete_collection_chunks(session, name) 

569 paths += CascadeHelper.delete_rag_indices_for_collection( 

570 session, name, unlink_files=False 

571 )["index_paths"] 

572 session.commit() 

573 CollectionRebuild._unlink(paths) 

574 return len(names)