Coverage for src/local_deep_research/research_library/deletion/utils/cascade_helper.py: 96%

275 statements  

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

1""" 

2Cascade helper for deletion operations. 

3 

4Handles cleanup of related records that don't have proper FK constraints: 

5- DocumentChunk (source_id has no FK constraint) 

6- Local vector-index files (FAISS) 

7- Server-backed vector collections (dropped before their RAGIndex row goes) 

8- Filesystem files 

9""" 

10 

11import time 

12from pathlib import Path 

13from typing import Any, Dict, Iterable, List, NamedTuple, Optional, Type, Union 

14 

15from loguru import logger 

16from sqlalchemy.orm import Session 

17 

18from ....constants import FILE_PATH_SENTINELS 

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

20 Document, 

21 DocumentBlob, 

22 DocumentChunk, 

23 DocumentCollection, 

24 RAGIndex, 

25) 

26from ....utilities.formatting import short_id 

27 

28# A <name>.*.tmp younger than this is assumed to be a concurrent writer's live 

29# persist() temp (not yet os.replace()d into place); deleting it would crash 

30# that write. Matches FaissVectorStore.persist()'s own stale-temp sweep so both 

31# paths agree on when a temp is certainly crash-orphaned rather than in-flight. 

32_STALE_TMP_AGE_SECONDS = 3600 

33 

34 

35class RemoteVectorStoreDropError(RuntimeError): 

36 """A server-backed vector collection could not be dropped. 

37 

38 Raised by :meth:`CascadeHelper.delete_rag_indices_for_collection` when a 

39 ``RAGIndex`` row belongs to a server-backed (not local-file) store and its 

40 collection could not be dropped. 

41 

42 This is deliberately an exception rather than a logged warning: it is 

43 raised BEFORE any ``RAGIndex`` row is deleted, so it aborts the caller's 

44 transaction and leaves ``index_hash`` -- the only handle by which the 

45 remote collection can be addressed -- intact. Reporting the deletion as 

46 successful here is what turns retained vectors into *unrecoverable* 

47 vectors (issue #5765). 

48 

49 The message is fixed text plus the provider key, an ``index_hash`` prefix 

50 and the *type name* of the underlying error -- never that error's own 

51 text, which for a network client can carry a backend URI or credential. 

52 For the same reason the underlying error is not chained (``from None``), 

53 and callers log :meth:`log_detail` without a traceback. 

54 

55 Attributes: 

56 retryable: True only when the failure is known to be transient: the 

57 backend could not be reached or timed out (``ConnectionError`` / 

58 ``TimeoutError``, or an error carrying ``retryable = True`` -- see 

59 :meth:`BaseVectorStore.drop_collection`). False for failures a 

60 retry cannot fix: no username, a row with no ``index_hash``, an 

61 unknown provider, a store with no drop implementation, an 

62 authentication/permission error, or anything unclassified. 

63 dropped: how many remote collections this call had ALREADY dropped 

64 (or found absent) before the failure. Remote drops cannot be made 

65 all-or-nothing: when this is non-zero those collections are gone 

66 although the caller's rollback restores their ``RAGIndex`` rows 

67 (and the collection's ``indexed`` flags), so search over them 

68 returns nothing until the operation is retried -- ``drop_collection`` 

69 is idempotent, so a retried delete completes -- or the collection 

70 is force re-indexed. A normal (non-force) re-index does NOT 

71 rebuild them, because the restored rows still read as indexed. 

72 cause_type: type name of the underlying error, if there was one. 

73 """ 

74 

75 def __init__( 

76 self, 

77 message: str, 

78 *, 

79 retryable: bool = False, 

80 dropped: int = 0, 

81 cause_type: Optional[str] = None, 

82 ) -> None: 

83 super().__init__(message) 

84 self.retryable = retryable 

85 self.dropped = dropped 

86 self.cause_type = cause_type 

87 

88 def log_detail(self) -> str: 

89 """A log line for this failure built only from sanitized fields. 

90 

91 Callers log this -- at WARNING, with no traceback -- instead of the 

92 exception itself: the message is fixed text plus a provider key, an 

93 ``index_hash`` prefix and a type name, and nothing else about the 

94 underlying error is recorded. 

95 """ 

96 return ( 

97 f"{self.args[0]} (retryable={self.retryable}, " 

98 f"already dropped={self.dropped})" 

99 ) 

100 

101 

102def _is_transient_drop_failure(exc: BaseException) -> bool: 

103 """Whether a ``drop_collection`` failure is worth retrying. 

104 

105 An explicit boolean ``retryable`` attribute on the error wins, so a 

106 backend can classify its own client's exceptions. Otherwise only the 

107 built-in transport failures count as transient; everything else 

108 (``PermissionError``, ``NotImplementedError``, auth errors, unknown 

109 exceptions) is reported as permanent rather than inviting an endless 

110 retry loop. 

111 """ 

112 flag = getattr(exc, "retryable", None) 

113 if isinstance(flag, bool): 

114 return flag 

115 return isinstance(exc, (ConnectionError, TimeoutError)) 

116 

117 

118def remote_drop_failure_message( 

119 exc: RemoteVectorStoreDropError, outcome: str 

120) -> str: 

121 """User-facing text for a failed remote drop. 

122 

123 ``outcome`` states what did NOT happen, e.g. ``"the collection was not 

124 deleted"``. The text is built only from fixed phrases and counts, never 

125 from the underlying error. 

126 """ 

127 if exc.retryable: 

128 message = ( 

129 f"Vector store unreachable — {outcome}. Retry once the vector " 

130 "store is available." 

131 ) 

132 else: 

133 message = ( 

134 f"The vector store could not drop the collection — {outcome}. " 

135 "Retrying will not help until the vector store configuration or " 

136 "permissions are fixed; see the server log." 

137 ) 

138 if exc.dropped: 

139 message += ( 

140 f" {exc.dropped} vector-store collection(s) had already been " 

141 "removed before the failure; their index records were kept, so " 

142 "search over them returns nothing until this is retried " 

143 "successfully or the collection is force re-indexed." 

144 ) 

145 return message 

146 

147 

148def read_vector_cleanup_settings_snapshot(settings_manager: Any) -> dict: 

149 """The user's settings snapshot for a vector cleanup, failing closed. 

150 

151 The cascade resolves each row's provider -- and a server-backed store's 

152 connection config -- from this snapshot (#5765). A non-strict 

153 ``get_all_settings()`` answers a failed settings query with a 

154 defaults-only snapshot, which would silently select the global default 

155 provider and connection config: the drop would miss (or target the wrong 

156 store), be counted as "already absent", and the rows -- the only handle to 

157 the real collection -- would then be deleted. 

158 

159 So the read is strict. On failure: 

160 

161 * while only local-file providers exist 

162 (:func:`~local_deep_research.vector_stores.config.only_local_file_providers_registered`), 

163 the snapshot cannot change the cleanup (every row's files are unlinked, 

164 nothing is dropped), so this degrades to the non-strict read instead of 

165 failing a deletion that would otherwise succeed; 

166 * otherwise it raises :class:`RemoteVectorStoreDropError` (not retryable) 

167 BEFORE any row is touched, so callers abort exactly as for a failed 

168 drop. 

169 

170 ``settings_manager`` is only read from. The caller chooses how it is 

171 built; a caller inside a transaction must use one that does not seed and 

172 commit defaults (``SettingsManager(session, seed_defaults=False)``). 

173 """ 

174 from sqlalchemy.exc import SQLAlchemyError 

175 

176 from ....vector_stores import config as vs_config 

177 

178 try: 

179 return settings_manager.get_all_settings(strict=True) 

180 except (SQLAlchemyError, LookupError) as exc: 

181 cause_type = type(exc).__name__ 

182 if vs_config.only_local_file_providers_registered(): 

183 logger.warning( 

184 "Could not read the user's settings ({}); continuing the vector " 

185 "cleanup with defaults because only local-file vector stores " 

186 "exist", 

187 cause_type, 

188 ) 

189 return settings_manager.get_all_settings() 

190 raise RemoteVectorStoreDropError( 

191 "Could not read the user's settings " 

192 f"({cause_type}), so the vector-store provider and its connection " 

193 "config are unknown; refusing to delete the RAGIndex records that " 

194 "hold the only handle to their vectors.", 

195 retryable=False, 

196 cause_type=cause_type, 

197 ) 

198 

199 

200class _PlannedIndex(NamedTuple): 

201 """One ``RAGIndex`` row with its vector cleanup decided up front.""" 

202 

203 row: RAGIndex 

204 provider: str 

205 # None for a local-file store (unlink its files); the store class for a 

206 # server-backed one (drop its collection). 

207 remote_store: Optional[Type[Any]] 

208 

209 

210class CascadeHelper: 

211 """Helper class for cleaning up related records during deletion.""" 

212 

213 @staticmethod 

214 def delete_document_chunks( 

215 session: Session, 

216 document_id: str, 

217 collection_name: Optional[str] = None, 

218 ) -> int: 

219 """ 

220 Delete DocumentChunks for a document. 

221 

222 Since DocumentChunk.source_id has no FK constraint, we must manually 

223 clean up chunks when deleting a document. 

224 

225 Args: 

226 session: Database session 

227 document_id: The document ID to delete chunks for 

228 collection_name: Optional collection name to limit deletion scope 

229 

230 Returns: 

231 Number of chunks deleted 

232 """ 

233 query = session.query(DocumentChunk).filter( 

234 DocumentChunk.source_id == document_id, 

235 DocumentChunk.source_type == "document", 

236 ) 

237 

238 if collection_name: 

239 query = query.filter( 

240 DocumentChunk.collection_name == collection_name 

241 ) 

242 

243 count = query.delete(synchronize_session=False) 

244 logger.debug( 

245 f"Deleted {count} chunks for document {short_id(document_id)}" 

246 + (f" in collection {collection_name}" if collection_name else "") 

247 ) 

248 return count 

249 

250 @staticmethod 

251 def delete_collection_chunks( 

252 session: Session, 

253 collection_name: str, 

254 ) -> int: 

255 """ 

256 Delete all DocumentChunks for a collection. 

257 

258 Args: 

259 session: Database session 

260 collection_name: The collection name (e.g., "collection_<uuid>") 

261 

262 Returns: 

263 Number of chunks deleted 

264 """ 

265 count = ( 

266 session.query(DocumentChunk) 

267 .filter_by(collection_name=collection_name) 

268 .delete(synchronize_session=False) 

269 ) 

270 logger.debug(f"Deleted {count} chunks for collection {collection_name}") 

271 return count 

272 

273 @staticmethod 

274 def get_document_blob_size(session: Session, document_id: str) -> int: 

275 """ 

276 Get the size of a document's blob in bytes. 

277 

278 Args: 

279 session: Database session 

280 document_id: The document ID 

281 

282 Returns: 

283 Size in bytes, or 0 if no blob exists 

284 """ 

285 blob = ( 

286 session.query(DocumentBlob) 

287 .filter_by(document_id=document_id) 

288 .first() 

289 ) 

290 if blob and blob.pdf_binary: 

291 return len(blob.pdf_binary) 

292 return 0 

293 

294 @staticmethod 

295 def delete_document_blob(session: Session, document_id: str) -> int: 

296 """ 

297 Delete a document's blob record. 

298 

299 Note: This is typically handled by CASCADE, but can be called explicitly 

300 for blob-only deletion. 

301 

302 Args: 

303 session: Database session 

304 document_id: The document ID 

305 

306 Returns: 

307 Size of deleted blob in bytes 

308 """ 

309 blob = ( 

310 session.query(DocumentBlob) 

311 .filter_by(document_id=document_id) 

312 .first() 

313 ) 

314 if blob: 

315 size = len(blob.pdf_binary) if blob.pdf_binary else 0 

316 session.delete(blob) 

317 logger.debug( 

318 f"Deleted blob for document {short_id(document_id)} ({size} bytes)" 

319 ) 

320 return size 

321 return 0 

322 

323 @staticmethod 

324 def _resolved_containment_ok( 

325 path: Path, 

326 allowed_root: Optional[Union[str, Path]], 

327 *, 

328 allowed_root_is_resolved: bool = False, 

329 ) -> bool: 

330 """Return True if `path`'s fully-resolved real location stays under 

331 `allowed_root`. 

332 

333 Resolution follows every symlink in the chain -- not just the 

334 final component -- so this also catches an escape via a symlinked 

335 *ancestor* directory (e.g. a library subfolder swapped for a symlink 

336 pointing outside the library root between validation and delete). 

337 It uses `resolve_allow_missing()`, never a bare non-strict 

338 `resolve()`: that one ignores ENAMETOOLONG, so a symlink chain 

339 whose resolved form outgrows PATH_MAX could hide an escape. 

340 

341 `allowed_root` is resolved here too, unless the caller passes 

342 `allowed_root_is_resolved=True`. In that case it was already 

343 resolved before the path was validated, and it is compared as 

344 given. Resolving it again would let a boundary directory swapped 

345 for a symlink after validation move the boundary along with the 

346 file. 

347 

348 When `allowed_root` is None, no containment boundary is enforced 

349 here; callers that can't cheaply supply a root still get the 

350 unconditional is_symlink() check the callers of this helper perform 

351 on the leaf file itself. 

352 """ 

353 if allowed_root is None: 

354 return True 

355 from ....security.path_validator import resolve_allow_missing 

356 

357 try: 

358 resolved_path = resolve_allow_missing(path) 

359 if allowed_root_is_resolved: 

360 resolved_root = Path(allowed_root) 

361 else: 

362 resolved_root = resolve_allow_missing(Path(allowed_root)) 

363 except OSError: 

364 # Can't resolve (e.g. a broken component) -- refuse rather than 

365 # risk unlinking something outside the intended root. 

366 return False 

367 return resolved_path.is_relative_to(resolved_root) 

368 

369 @staticmethod 

370 def delete_filesystem_file( 

371 file_path: Optional[str], 

372 allowed_root: Optional[Union[str, Path]] = None, 

373 *, 

374 allowed_root_is_resolved: bool = False, 

375 ) -> bool: 

376 """ 

377 Delete a file from the filesystem. 

378 

379 Args: 

380 file_path: Path to the file (can be relative or absolute) 

381 allowed_root: If provided, the file's fully-resolved real path 

382 must be contained within this directory or the delete is 

383 refused. Defense-in-depth against a symlink planted at (or 

384 swapped in for) `file_path` redirecting the unlink to an 

385 arbitrary file elsewhere on disk -- see #5481. 

386 allowed_root_is_resolved: True when `allowed_root` was resolved 

387 before `file_path` was validated, as 

388 `get_containment_root_from_settings` returns it. It is then 

389 compared as given instead of being resolved again, so a 

390 boundary directory swapped for a symlink since then cannot 

391 move the boundary along with the file. 

392 

393 Returns: 

394 True if file was deleted, False otherwise 

395 """ 

396 if not file_path: 

397 return False 

398 

399 # Skip special path markers 

400 if file_path in FILE_PATH_SENTINELS: 

401 return False 

402 

403 try: 

404 path = Path(file_path) 

405 

406 # Refuse to unlink through a symlink. This check is unconditional 

407 # (doesn't require allowed_root) -- is_file()/unlink() both 

408 # follow symlinks, so without it a symlink planted at this exact 

409 # path would redirect the delete to whatever it points at. 

410 if path.is_symlink(): 

411 logger.warning( 

412 f"Refusing to delete symlinked filesystem file: {file_path}" 

413 ) 

414 return False 

415 

416 if not CascadeHelper._resolved_containment_ok( 

417 path, 

418 allowed_root, 

419 allowed_root_is_resolved=allowed_root_is_resolved, 

420 ): 

421 logger.warning( 

422 f"Refusing to delete filesystem file outside allowed root: {file_path}" 

423 ) 

424 return False 

425 

426 if path.is_file(): 

427 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Leaf symlinks are refused and resolved containment is checked above; document callers require an allowed root 

428 path.unlink() 

429 logger.debug(f"Deleted filesystem file: {file_path}") 

430 return True 

431 except Exception: 

432 logger.exception(f"Failed to delete filesystem file: {file_path}") 

433 return False 

434 

435 @staticmethod 

436 def delete_faiss_index_files( 

437 index_path: Optional[str], 

438 allowed_root: Optional[Union[str, Path]] = None, 

439 ) -> bool: 

440 """ 

441 Delete FAISS index files. 

442 

443 FAISS stores indices as .faiss and .pkl files. 

444 

445 Args: 

446 index_path: Path to the FAISS index file (without extension) 

447 allowed_root: If provided, the resolved index family's parent 

448 directory must be contained within this directory or the 

449 delete is refused. See `delete_filesystem_file` for the 

450 symlink-escape rationale. 

451 

452 Returns: 

453 True if files were deleted, False otherwise 

454 """ 

455 if not index_path: 

456 return False 

457 

458 try: 

459 path = Path(index_path) 

460 

461 if not CascadeHelper._resolved_containment_ok(path, allowed_root): 

462 logger.warning( 

463 f"Refusing to delete FAISS index outside allowed root: {index_path}" 

464 ) 

465 return False 

466 

467 deleted_any = False 

468 

469 # FAISS index file 

470 faiss_file = path.with_suffix(".faiss") 

471 if faiss_file.is_symlink(): 

472 logger.warning( 

473 f"Refusing to delete symlinked FAISS index file: {faiss_file}" 

474 ) 

475 elif faiss_file.is_file(): 

476 # nosemgrep: semgrep.rules.path-traversal-risk, reason: The index base passed the resolved root check and this .faiss leaf is not a symlink 

477 faiss_file.unlink() 

478 logger.debug(f"Deleted FAISS index file: {faiss_file}") 

479 deleted_any = True 

480 

481 # Pickle file for metadata (legacy pre-cutover format) 

482 pkl_file = path.with_suffix(".pkl") 

483 if pkl_file.is_symlink(): 

484 logger.warning( 

485 f"Refusing to delete symlinked FAISS pkl file: {pkl_file}" 

486 ) 

487 elif pkl_file.is_file(): 

488 # nosemgrep: semgrep.rules.path-traversal-risk, reason: The index base passed the resolved root check and this .pkl leaf is not a symlink 

489 pkl_file.unlink() 

490 logger.debug(f"Deleted FAISS pkl file: {pkl_file}") 

491 deleted_any = True 

492 

493 # Migration sidecar (.idmap.json) — the text-free position->uuid map 

494 # phase-1 writes and phase-2 consumes. If a collection is deleted 

495 # during the .pkl -> .idmap.json cutover window (phase-1 ran, phase-2 

496 # hasn't), the sidecar would otherwise be orphaned on disk forever. 

497 idmap_file = path.with_suffix(".idmap.json") 

498 if idmap_file.is_symlink(): 

499 logger.warning( 

500 f"Refusing to delete symlinked FAISS idmap sidecar: {idmap_file}" 

501 ) 

502 elif idmap_file.is_file(): 

503 # nosemgrep: semgrep.rules.path-traversal-risk, reason: The index base passed the resolved root check and this id-map leaf is not a symlink 

504 idmap_file.unlink() 

505 logger.debug(f"Deleted FAISS idmap sidecar: {idmap_file}") 

506 deleted_any = True 

507 

508 # Sweep crash-orphaned temp files (persist writes <name>.*.tmp) and 

509 # quarantined siblings (<name>.corrupt-<ns>) for the whole family, 

510 # so deleting a collection doesn't leave them on disk forever — a 

511 # leftover .pkl.corrupt-* is quarantined PLAINTEXT. 

512 parent = path.parent 

513 now = time.time() 

514 for base in (faiss_file, pkl_file, idmap_file): 

515 # .tmp files are AGE-GATED: FaissVectorStore.persist() writes to 

516 # a <name>.*.tmp then os.replace()s it into place, so a live temp 

517 # is a concurrent writer's not-yet-renamed file — unlinking it 

518 # would crash that persist with FileNotFoundError. Only sweep 

519 # temps old enough to be certainly crash-orphaned (mirrors 

520 # persist()'s own _STALE_TMP_AGE_SECONDS sweep). A younger temp is 

521 # left; a later delete/persist sweeps it once it ages out. 

522 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Enumerate generated temp names beside the root-checked index base 

523 for stray in parent.glob(f"{base.name}.*.tmp"): 

524 try: 

525 if stray.is_symlink(): 525 ↛ 526line 525 didn't jump to line 526 because the condition on line 525 was never true

526 logger.warning( 

527 f"Refusing to remove symlinked stray index file: {stray}" 

528 ) 

529 continue 

530 if now - stray.stat().st_mtime < _STALE_TMP_AGE_SECONDS: 

531 continue 

532 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Only aged index temp files from the bounded parent glob reach this unlink; symlink leaves are skipped 

533 stray.unlink() 

534 logger.debug(f"Removed stray temp index file: {stray}") 

535 deleted_any = True 

536 except FileNotFoundError: 

537 # Raced a concurrent writer's os.replace() — already gone. 

538 continue 

539 except OSError: 

540 logger.warning( 

541 f"Could not remove stray index file {stray}" 

542 ) 

543 # .corrupt-* are quarantine artefacts (and .pkl.corrupt-* is 

544 # PLAINTEXT) — always safe and required to remove, no age gate. 

545 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Enumerate generated quarantine names beside the root-checked index base 

546 for stray in parent.glob(f"{base.name}.corrupt-*"): 

547 try: 

548 if stray.is_symlink(): 548 ↛ 549line 548 didn't jump to line 549 because the condition on line 548 was never true

549 logger.warning( 

550 f"Refusing to remove symlinked stray index file: {stray}" 

551 ) 

552 continue 

553 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Only files from the bounded quarantine glob reach this unlink; symlink leaves are skipped 

554 stray.unlink() 

555 logger.debug(f"Removed quarantined index file: {stray}") 

556 deleted_any = True 

557 except FileNotFoundError: 

558 continue 

559 except OSError: 

560 logger.warning( 

561 f"Could not remove stray index file {stray}" 

562 ) 

563 

564 return deleted_any 

565 except Exception: 

566 logger.exception(f"Failed to delete FAISS files for: {index_path}") 

567 return False 

568 

569 @staticmethod 

570 def _row_provider_key(index: RAGIndex) -> Optional[str]: 

571 """The provider recorded on ``index`` itself, if the model has one. 

572 

573 ``RAGIndex`` has no provider column yet. The first server-backed 

574 provider is expected to add one (the open Milvus PR, #5331, adds 

575 ``vector_store_provider``), and once it exists it -- not the current 

576 setting -- is what built the row's vectors: a user who switched 

577 providers can hold rows of both kinds in one collection. Read 

578 defensively so this works before and after that column lands. 

579 """ 

580 raw = getattr(index, "vector_store_provider", None) 

581 if isinstance(raw, str) and raw.strip(): 

582 return raw 

583 return None 

584 

585 @staticmethod 

586 def _plan_vector_cleanup( 

587 indices: Iterable[RAGIndex], 

588 settings_snapshot: Optional[dict], 

589 ) -> List[_PlannedIndex]: 

590 """Decide, ONCE per call, how each row's vectors are cleaned up. 

591 

592 A row's provider is its recorded provider when it has one, else the 

593 user's configured provider resolved from ``settings_snapshot``. Doing 

594 this in one pass and handing the result to both the drop and the 

595 delete loop means the two can never disagree (a provider resolved 

596 twice could flip between the reads, skip both the drop and the unlink, 

597 and still delete the rows). 

598 """ 

599 from ....vector_stores import config as vs_config 

600 

601 default_key: Optional[str] = None 

602 store_by_key: Dict[str, Optional[Type[Any]]] = {} 

603 plan: List[_PlannedIndex] = [] 

604 for index in indices: 

605 key = CascadeHelper._row_provider_key(index) 

606 if key is None: 

607 if default_key is None: 

608 default_key = vs_config.resolve_provider(settings_snapshot) 

609 key = default_key 

610 if key not in store_by_key: 

611 try: 

612 if vs_config.provider_is_local_file(key, settings_snapshot): 

613 store_by_key[key] = None 

614 else: 

615 store_by_key[key] = vs_config.get_vector_store_class( 

616 key, settings_snapshot 

617 ) 

618 except ValueError: 

619 # Unknown/unregistered provider: we cannot tell whether 

620 # there is a remote collection, let alone drop it, so we 

621 # must not delete the row that names it. 

622 raise RemoteVectorStoreDropError( 

623 f"RAGIndex row for collection {index.collection_name} " 

624 "uses an unknown vector-store provider; cannot clean " 

625 "up its vectors.", 

626 retryable=False, 

627 ) from None 

628 plan.append(_PlannedIndex(index, key, store_by_key[key])) 

629 return plan 

630 

631 @staticmethod 

632 def _drop_planned_remote_collections( 

633 plan: List[_PlannedIndex], 

634 *, 

635 username: Optional[str], 

636 settings_snapshot: Optional[dict], 

637 ) -> int: 

638 """Drop the remote collections of the server-backed rows in ``plan``. 

639 

640 See :meth:`drop_remote_vector_collections` for the contract. 

641 """ 

642 remote = [ 

643 (item, item.remote_store) 

644 for item in plan 

645 if item.remote_store is not None 

646 ] 

647 if not remote: 

648 return 0 

649 

650 if not username: 

651 # A server-backed collection's name is derived from the username 

652 # plus index_hash; with no username we cannot name -- hence cannot 

653 # delete -- the collection. Fail loudly rather than delete the rows 

654 # and strand vectors nothing can address. 

655 raise RemoteVectorStoreDropError( 

656 f"Cannot drop {remote[0][0].provider} vector collections without " 

657 "a username; refusing to delete the RAGIndex rows that hold " 

658 "the only handle to them.", 

659 retryable=False, 

660 ) 

661 

662 # Validate EVERY row's index_hash before dropping anything. This must 

663 # stay a separate pass ahead of the drop loop below, not a check 

664 # inside it: a bad row sorted after good ones would otherwise let the 

665 # loop drop their collections first and only then abort, destroying 

666 # collections whose rows survive the rollback. Failing here, before 

667 # any drop_collection call, keeps a bad row's abort a true no-op. 

668 for item, _store in remote: 

669 if not item.row.index_hash: 

670 raise RemoteVectorStoreDropError( 

671 f"RAGIndex row for collection {item.row.collection_name} " 

672 f"has no index_hash; cannot address its {item.provider} " 

673 "collection.", 

674 retryable=False, 

675 ) 

676 

677 dropped = 0 

678 completed = 0 

679 for item, store_class in remote: 

680 index_hash = item.row.index_hash 

681 try: 

682 if store_class.drop_collection( 

683 username=username, 

684 index_hash=index_hash, 

685 settings_snapshot=settings_snapshot, 

686 ): 

687 dropped += 1 

688 except Exception as exc: 

689 # Includes NotImplementedError: a store that declares itself 

690 # server-backed but never implemented a drop path would 

691 # otherwise silently orphan every collection it created. 

692 # 

693 # ``from None``: the cause's text can carry a backend URI or 

694 # token, and a chained cause is printed by any traceback 

695 # logger. Only its type name is kept. 

696 raise RemoteVectorStoreDropError( 

697 f"Failed to drop {item.provider} collection for index " 

698 f"{short_id(index_hash, 12)}: {type(exc).__name__}. The index " 

699 "record was NOT deleted.", 

700 retryable=_is_transient_drop_failure(exc), 

701 dropped=completed, 

702 cause_type=type(exc).__name__, 

703 ) from None 

704 completed += 1 

705 

706 logger.info( 

707 f"Dropped {dropped} vector collection(s) " 

708 f"({completed - dropped} already absent) before deleting their " 

709 "RAGIndex records" 

710 ) 

711 return dropped 

712 

713 @staticmethod 

714 def drop_remote_vector_collections( 

715 indices: Iterable[RAGIndex], 

716 *, 

717 username: Optional[str], 

718 settings_snapshot: Optional[dict] = None, 

719 ) -> int: 

720 """Drop the server-side collections backing ``indices``. 

721 

722 Rows whose provider is a local-file store are skipped -- those are 

723 cleaned up by unlinking their index files instead -- so this returns 0 

724 for an all-FAISS input. Each row's provider is its recorded provider 

725 when the model has one, else the provider configured in 

726 ``settings_snapshot`` (see :meth:`_plan_vector_cleanup`). 

727 

728 MUST be called while the ``RAGIndex`` rows still exist: the backend 

729 collection name is derived from ``(username, index_hash)`` and 

730 ``index_hash`` lives on the row. See #5765. 

731 

732 Remote drops are not transactional: if the Nth drop fails, the first 

733 N-1 collections are already gone. The error's ``dropped`` attribute 

734 says how many, so callers can say so instead of "nothing was deleted". 

735 

736 Returns: 

737 The number of collections actually dropped (an already-absent 

738 collection counts as 0 — ``drop_collection`` is idempotent). 

739 

740 Raises: 

741 RemoteVectorStoreDropError: if a server-backed collection could 

742 not be dropped, or cannot be addressed (no ``username``, a row 

743 with no ``index_hash``, an unknown provider). Its 

744 ``retryable`` attribute says whether a retry can help. The 

745 caller must abort — and roll back — rather than delete rows. 

746 """ 

747 # Any iterable, a generator included: the plan is a list built in ONE 

748 # pass, and it -- not ``indices`` -- is what the validation pass and 

749 # the drop loop both walk. (Walking a generator twice would exhaust 

750 # it on the first pass and silently skip the validation guard.) 

751 plan = CascadeHelper._plan_vector_cleanup(indices, settings_snapshot) 

752 return CascadeHelper._drop_planned_remote_collections( 

753 plan, username=username, settings_snapshot=settings_snapshot 

754 ) 

755 

756 @staticmethod 

757 def delete_rag_indices_for_collection( 

758 session: Session, 

759 collection_name: str, 

760 *, 

761 unlink_files: bool = True, 

762 username: Optional[str] = None, 

763 settings_snapshot: Optional[dict] = None, 

764 ) -> Dict[str, Any]: 

765 """ 

766 Delete RAGIndex records (and their vectors) for a collection. 

767 

768 Vector cleanup is provider-aware, decided per row (see 

769 :meth:`_plan_vector_cleanup`): 

770 

771 * Local-file store (FAISS): the ``.faiss``/``.pkl``/``.idmap.json`` 

772 files are unlinked — here when ``unlink_files`` is True, otherwise by 

773 the caller after its commit (see ``index_paths`` below). 

774 * Server-backed store: the remote collection is dropped **before** the 

775 ``RAGIndex`` rows are deleted, because its name is derived from 

776 ``index_hash`` which only exists on those rows. If a drop fails, 

777 :class:`RemoteVectorStoreDropError` propagates before any row is 

778 deleted and the caller must roll back. Collections dropped before 

779 the failing one stay dropped (see the error's ``dropped``) — see 

780 #5765. 

781 

782 The remote drops run inside the caller's transaction (they must 

783 happen while the rows exist, before they are deleted in that same 

784 transaction). If the caller has already written in that transaction, 

785 the user's database write lock is held across those network calls, so 

786 call this before the transaction's first write where possible: the 

787 collection-deletion service does. Collection rebuilds 

788 (``collection_rebuild.py``) run only while every registered provider 

789 stores local files, so their calls make no network call. Only 

790 server-backed stores make network calls here (FAISS never does), and 

791 their ``drop_collection`` is expected to bound each call with a 

792 client timeout. 

793 

794 Args: 

795 session: Database session 

796 collection_name: Name of the collection (e.g., "collection_<uuid>") 

797 unlink_files: When True (default), unlink the FAISS files here. 

798 When False, only the RAGIndex rows are deleted and the index 

799 paths are returned in ``index_paths`` so the caller can unlink 

800 them AFTER its own commit. Unlinking before the commit is 

801 unsafe when more DB work follows before the commit: a later 

802 rollback restores the RAGIndex rows but cannot restore the 

803 already-unlinked files, leaving the collection pointing at a 

804 missing index (silently-broken search). Ignored for 

805 server-backed rows, which have no files. 

806 username: Owner of the indices. Required when a row's provider is 

807 server-backed (it is half of the derived remote collection 

808 name); unused for local-file providers, which is why it stays 

809 optional. 

810 settings_snapshot: The user's settings snapshot. Production 

811 callers MUST pass it: it selects the configured provider for 

812 rows that do not record their own, and carries the connection 

813 config ``drop_collection`` needs. Without it the provider 

814 falls back to the global default (FAISS). 

815 

816 Returns: 

817 Dict with deletion results (``index_paths`` lists local-file rows 

818 only, and only when ``unlink_files`` is False). 

819 

820 Raises: 

821 RemoteVectorStoreDropError: a server-backed collection could not be 

822 dropped or addressed. Deliberately NOT swallowed: completing 

823 the row delete anyway is what makes the leftover vectors 

824 unaddressable. 

825 """ 

826 indices = ( 

827 session.query(RAGIndex) 

828 .filter_by(collection_name=collection_name) 

829 .all() 

830 ) 

831 

832 # Resolved ONCE; both the drop and the delete loop below read it. 

833 plan = CascadeHelper._plan_vector_cleanup(indices, settings_snapshot) 

834 

835 # ORDERING IS THE FIX (#5765): drop the remote collections FIRST, 

836 # while index_hash is still readable from the rows deleted below. A 

837 # failure raises, so the caller's transaction rolls back with the 

838 # rows — and therefore the vectors' only name — intact. 

839 # 

840 # A partial failure (collection 2 of 3 unreachable) leaves the 

841 # already-dropped collections gone while their rows (and the 

842 # documents' indexed flags) survive the rollback; the error's 

843 # ``dropped`` count lets the caller say so. That direction is 

844 # recoverable — a retried delete or a force re-index reconciles it 

845 # (``drop_collection`` is idempotent) — the opposite order is not. 

846 CascadeHelper._drop_planned_remote_collections( 

847 plan, username=username, settings_snapshot=settings_snapshot 

848 ) 

849 

850 deleted_indices = 0 

851 deleted_files = 0 

852 index_paths: List[str] = [] 

853 

854 # Only needed on the unlink_files=True path (below) -- computed 

855 # lazily so the unlink_files=False path (every current external 

856 # caller) doesn't pay for it. Same containment root those callers 

857 # pass to their own post-commit delete_faiss_index_files() call 

858 # (see collection_deletion.py, collection_rebuild.py), computed here too so 

859 # unlink_files=True gets the same symlinked-ancestor defense -- 

860 # currently unreachable (all callers pass unlink_files=False) but 

861 # kept in sync to avoid a latent gap if that changes. 

862 # 

863 # NOTE: this is the COARSE shared rag_indices/ root, not the tighter 

864 # per-user rag_indices/<sha256(user)>/ subdir. It is intentionally 

865 # NOT narrowed: pre-per-user-scoping (legacy) indexes live DIRECTLY 

866 # in this shared root (see 

867 # library_rag_service._migrate_legacy_index_files), so a per-user 

868 # allowed_root would refuse to delete a legacy-layout index and 

869 # orphan its files. Kept consistent with the external callers above. 

870 rag_indices_root = None 

871 if unlink_files and any(item.remote_store is None for item in plan): 

872 from ....config.paths import get_cache_directory 

873 

874 rag_indices_root = get_cache_directory() / "rag_indices" 

875 

876 for item in plan: 

877 index = item.row 

878 # index_path is only meaningful for a local-file store. For a 

879 # server-backed row it names no file, so neither unlinking it here 

880 # nor handing it to the caller's post-commit unlink would do 

881 # anything but log a confusing miss — its vectors were already 

882 # dropped above. 

883 if item.remote_store is None: 

884 path = str(index.index_path) 

885 if unlink_files: 

886 if CascadeHelper.delete_faiss_index_files( 

887 path, allowed_root=rag_indices_root 

888 ): 

889 deleted_files += 1 

890 else: 

891 index_paths.append(path) 

892 

893 session.delete(index) 

894 deleted_indices += 1 

895 

896 logger.debug( 

897 f"Deleted {deleted_indices} RAGIndex records and {deleted_files} " 

898 f"index files for collection {collection_name}" 

899 ) 

900 

901 return { 

902 "deleted_indices": deleted_indices, 

903 "deleted_files": deleted_files, 

904 "index_paths": index_paths, 

905 } 

906 

907 @staticmethod 

908 def update_download_tracker( 

909 session: Session, 

910 document: Document, 

911 ) -> bool: 

912 """ 

913 Update DownloadTracker when a document is deleted. 

914 

915 The FK has SET NULL, but we also need to update is_downloaded flag. 

916 

917 Args: 

918 session: Database session 

919 document: The document being deleted 

920 

921 Returns: 

922 True if tracker was updated 

923 """ 

924 if not document.original_url: 

925 return False 

926 

927 # URL-equivalent trackers can belong to different PDF versions. 

928 # Reset only a tracker whose content hash or document link matches, 

929 # retaining the legacy URL-hash fallback for the deleted document. 

930 from ...utils import find_tracker_for_document 

931 

932 try: 

933 tracker = find_tracker_for_document(session, document) 

934 

935 if tracker: 

936 tracker.is_downloaded = False # type: ignore[assignment] 

937 tracker.file_path = None # type: ignore[assignment] 

938 logger.debug( 

939 f"Updated DownloadTracker for document {short_id(document.id)}" 

940 ) 

941 return True 

942 except Exception: 

943 logger.exception("Failed to update DownloadTracker") 

944 return False 

945 

946 @staticmethod 

947 def count_document_in_collections( 

948 session: Session, 

949 document_id: str, 

950 ) -> int: 

951 """ 

952 Count how many collections a document is in. 

953 

954 Args: 

955 session: Database session 

956 document_id: The document ID 

957 

958 Returns: 

959 Number of collections the document is in 

960 """ 

961 return ( 

962 session.query(DocumentCollection) 

963 .filter_by(document_id=document_id) 

964 .count() 

965 ) 

966 

967 @staticmethod 

968 def get_document_collections( 

969 session: Session, 

970 document_id: str, 

971 ) -> List[str]: 

972 """ 

973 Get all collection IDs a document belongs to. 

974 

975 Args: 

976 session: Database session 

977 document_id: The document ID 

978 

979 Returns: 

980 List of collection IDs 

981 """ 

982 doc_collections = ( 

983 session.query(DocumentCollection.collection_id) 

984 .filter_by(document_id=document_id) 

985 .all() 

986 ) 

987 return [dc.collection_id for dc in doc_collections] 

988 

989 @staticmethod 

990 def delete_document_completely( 

991 session: Session, 

992 document_id: str, 

993 ) -> bool: 

994 """ 

995 Delete a document and all related records using query-based deletes. 

996 

997 This avoids ORM cascade issues where SQLAlchemy tries to set 

998 DocumentBlob.document_id to NULL (which fails because it's a PK). 

999 

1000 Deletes in order: 

1001 1. DocumentBlob 

1002 2. DocumentCollection links 

1003 3. Document itself 

1004 

1005 Note: DocumentChunks should be deleted separately before calling this, 

1006 as they may need collection-specific handling. 

1007 

1008 Args: 

1009 session: Database session 

1010 document_id: The document ID to delete 

1011 

1012 Returns: 

1013 True if document was deleted 

1014 """ 

1015 # Delete blob (has document_id as PK, can't be nulled by cascade) 

1016 session.query(DocumentBlob).filter_by(document_id=document_id).delete( 

1017 synchronize_session=False 

1018 ) 

1019 

1020 # Delete collection links 

1021 session.query(DocumentCollection).filter_by( 

1022 document_id=document_id 

1023 ).delete(synchronize_session=False) 

1024 

1025 # Delete document itself 

1026 deleted = ( 

1027 session.query(Document) 

1028 .filter_by(id=document_id) 

1029 .delete(synchronize_session=False) 

1030 ) 

1031 

1032 if deleted: 

1033 logger.debug(f"Deleted document {short_id(document_id)} completely") 

1034 

1035 return deleted > 0