Coverage for src/local_deep_research/research_library/deletion/services/document_deletion.py: 97%

203 statements  

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

1""" 

2Document deletion service. 

3 

4Handles: 

5- Full document deletion with proper cascade cleanup 

6- Blob-only deletion (remove PDF, keep text) 

7- Remove from collection (unlink or delete if orphaned) 

8""" 

9 

10import threading 

11from contextlib import contextmanager 

12from typing import Any, Dict, Iterator, List, Tuple 

13 

14from loguru import logger 

15 

16from ....constants import FILE_PATH_BLOB_DELETED 

17from ....database.models.library import ( 

18 Collection, 

19 Document, 

20 DocumentChunk, 

21 DocumentCollection, 

22 RagDocumentStatus, 

23 SourceType, 

24) 

25from ....database.session_context import get_user_db_session 

26from ....database.session_passwords import capture_request_db_password 

27from ..utils.cascade_helper import CascadeHelper 

28 

29 

30# Document deletion is a read-then-delete workflow with filesystem and FAISS 

31# side effects around the database transaction. Serialize it per user/document 

32# across service instances so duplicate browser requests cannot both pass the 

33# initial existence check. The registry is ref-counted so completed document 

34# IDs do not accumulate for the lifetime of the process. 

35_document_delete_locks: Dict[Tuple[str, str], threading.Lock] = {} 

36_document_delete_lock_refs: Dict[Tuple[str, str], int] = {} 

37_document_delete_locks_guard = threading.Lock() 

38 

39 

40@contextmanager 

41def _hold_document_delete_lock( 

42 username: str, document_id: str 

43) -> Iterator[None]: 

44 """Hold the process-local lock for one user's document deletion. 

45 

46 The lock is intentionally non-reentrant. Callers must not start a nested 

47 deletion for the same user and document while this context is active. 

48 """ 

49 key = (username, document_id) 

50 with _document_delete_locks_guard: 

51 lock = _document_delete_locks.get(key) 

52 if lock is None: 

53 lock = threading.Lock() 

54 _document_delete_locks[key] = lock 

55 _document_delete_lock_refs[key] = ( 

56 _document_delete_lock_refs.get(key, 0) + 1 

57 ) 

58 

59 try: 

60 with lock: 

61 yield 

62 finally: 

63 with _document_delete_locks_guard: 

64 remaining = _document_delete_lock_refs[key] - 1 

65 if remaining: 

66 _document_delete_lock_refs[key] = remaining 

67 else: 

68 _document_delete_lock_refs.pop(key, None) 

69 # Identity check protects against future registry maintenance 

70 # replacing the lock while this holder is winding down. 

71 if _document_delete_locks.get(key) is lock: 71 ↛ exitline 71 didn't jump to the function exit

72 _document_delete_locks.pop(key, None) 

73 

74 

75def _is_note_document(session, document) -> bool: 

76 """Return True if the document represents a note (source_type='note'). 

77 

78 Notes have their own deletion API at /api/notes/<id>. Letting them be 

79 deleted via the generic document-deletion endpoints would bypass the 

80 B1 protection that was specifically built to prevent note data loss 

81 (see PROTECTED_COLLECTION_TYPES in collection_deletion.py). 

82 

83 The check is local-by-design — keep this module free of imports from 

84 the notes package to avoid an import cycle. 

85 """ 

86 note_source = session.query(SourceType).filter_by(name="note").first() 

87 return bool(note_source) and document.source_type_id == note_source.id 

88 

89 

90class DocumentDeletionService: 

91 """Service for document deletion operations.""" 

92 

93 def __init__(self, username: str): 

94 """ 

95 Initialize document deletion service. 

96 

97 Args: 

98 username: Username for database session 

99 """ 

100 self.username = username 

101 

102 def delete_document(self, document_id: str) -> Dict[str, Any]: 

103 """Delete one document, serializing duplicate in-process requests.""" 

104 with _hold_document_delete_lock(self.username, document_id): 

105 result, rag_collection_ids = self._delete_document_locked( 

106 document_id 

107 ) 

108 

109 # Post-commit FAISS + chunk-row cleanup. Runs after the DB transaction 

110 # has committed AND the keyed lock is released, allowing concurrent 

111 # requests to immediately observe "Document not found" without waiting 

112 # for slow vector-store IO. 

113 if result.get("deleted"): 

114 self._purge_document_rag( 

115 document_id, rag_collection_ids, full_delete=True 

116 ) 

117 

118 return result 

119 

120 def _delete_document_locked( 

121 self, document_id: str 

122 ) -> Tuple[Dict[str, Any], List[str]]: 

123 """Delete a document and all related data while its keyed lock is held. 

124 

125 This method ensures complete DB/file cleanup: 

126 - DocumentChunks (no FK constraint, manual cleanup required) 

127 - DocumentBlob (CASCADE handles, but we track for stats) 

128 - Filesystem files 

129 - DownloadTracker update 

130 - DocumentCollection links (CASCADE) 

131 - RagDocumentStatus (CASCADE) 

132 

133 Args: 

134 document_id: ID of the document to delete 

135 

136 Returns: 

137 Tuple of (result_dict, rag_collection_ids) for post-commit purge. 

138 """ 

139 result: Dict[str, Any] = { 

140 "deleted": False, 

141 "document_id": document_id, 

142 "title": None, 

143 "blob_deleted": False, 

144 "blob_size": 0, 

145 "chunks_deleted": 0, 

146 "collections_unlinked": 0, 

147 "file_deleted": False, 

148 } 

149 rag_collection_ids: List[str] = [] 

150 

151 with get_user_db_session(self.username) as session: 

152 try: 

153 # Get document 

154 document = session.get(Document, document_id) 

155 if not document: 

156 return { 

157 "deleted": False, 

158 "document_id": document_id, 

159 "error": "Document not found", 

160 }, [] 

161 

162 # Notes are not deletable via the generic document API. 

163 # The Notes-collection deletion guard only blocks the 

164 # cascade path; without this check a user could wipe an 

165 # individual note by passing its UUID here, and the bulk 

166 # endpoint amplifies the same gap. Route the caller to 

167 # DELETE /api/notes/<id> which goes through NoteService 

168 # with the proper version/link cleanup. 

169 if _is_note_document(session, document): 

170 return { 

171 "deleted": False, 

172 "document_id": document_id, 

173 "title": document.title or "Untitled", 

174 "error": ( 

175 "Notes cannot be deleted via the document API. " 

176 "Use DELETE /api/notes/<id> instead." 

177 ), 

178 "is_note": True, 

179 }, [] 

180 

181 title = document.title or document.filename or "Untitled" 

182 result["title"] = title 

183 

184 # 1. Get collections before deletion for chunk cleanup 

185 collections = CascadeHelper.get_document_collections( 

186 session, document_id 

187 ) 

188 result["collections_unlinked"] = len(collections) 

189 

190 # Every collection that may hold FAISS vectors / chunk rows 

191 # for this document: the UNION of its linked collections and 

192 # the collections that actually have chunk rows. The chunk-row 

193 # half matters because a partial/failed reindex can leave 

194 # chunks in a collection whose join row is gone or was never 

195 # marked indexed. Used by the post-commit RAG purge below. 

196 chunk_collection_rows = ( 

197 session.query(DocumentChunk.collection_name) 

198 .filter_by(source_type="document", source_id=document_id) 

199 .distinct() 

200 .all() 

201 ) 

202 rag_collection_ids = list( 

203 {str(c) for c in collections} 

204 | { 

205 row[0].removeprefix("collection_") 

206 for row in chunk_collection_rows 

207 if row[0] 

208 } 

209 ) 

210 

211 # 2. Count chunks for stats. The rows themselves (and their 

212 # FAISS vectors) are removed in the post-commit RAG cleanup 

213 # phase below — purge_document_vectors resolves the FAISS 

214 # int ids from these rows, so they must outlive this 

215 # transaction (mirrors NoteService.delete_note). 

216 result["chunks_deleted"] = ( 

217 session.query(DocumentChunk) 

218 .filter( 

219 DocumentChunk.source_id == document_id, 

220 DocumentChunk.source_type == "document", 

221 ) 

222 .count() 

223 ) 

224 

225 # 3. Get blob size before deletion (for stats) 

226 result["blob_size"] = CascadeHelper.get_document_blob_size( 

227 session, document_id 

228 ) 

229 result["blob_deleted"] = result["blob_size"] > 0 

230 

231 # 4. Delete filesystem file if exists 

232 if document.storage_mode == "filesystem" and document.file_path: 

233 from ...utils import get_absolute_path_from_settings 

234 

235 try: 

236 # Delete only within this user's own root — never the 

237 # legacy shared root, where a colliding relative path 

238 # can resolve to another tenant's file (#5521). 

239 file_path = get_absolute_path_from_settings( 

240 document.file_path, 

241 self.username, 

242 allow_legacy_fallback=False, 

243 ) 

244 # get_absolute_path_from_settings("") returns the 

245 # library root (its `if not relative_path` branch), 

246 # never None, so this can't currently trip. Guarded 

247 # anyway: _resolved_containment_ok() treats 

248 # allowed_root=None as "no boundary enforced" for 

249 # callers that can't cheaply supply a root (e.g. the 

250 # FAISS sweeps), which is fine there but would be a 

251 # silent fail-open here. Fail closed instead of 

252 # unlinking with no containment boundary. 

253 allowed_root = get_absolute_path_from_settings("") 

254 if file_path and allowed_root is not None: 

255 result["file_deleted"] = ( 

256 CascadeHelper.delete_filesystem_file( 

257 str(file_path), 

258 allowed_root=allowed_root, 

259 ) 

260 ) 

261 elif file_path: 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true

262 logger.error( 

263 "Refusing to delete filesystem file: " 

264 "could not resolve library root for " 

265 "containment check" 

266 ) 

267 except Exception: 

268 logger.exception("Failed to delete filesystem file") 

269 

270 # 5. Update DownloadTracker 

271 CascadeHelper.update_download_tracker(session, document) 

272 

273 # 6. Delete the document and all related records (leaves the 

274 # DocumentChunk rows for the post-commit cleanup phase). 

275 # The helper's row count is authoritative: a concurrent 

276 # cross-process request can still win after our initial read 

277 # because the keyed lock is intentionally process-local. 

278 # Never report success or purge RAG state when this DELETE 

279 # affected no document row. 

280 actually_deleted = CascadeHelper.delete_document_completely( 

281 session, document_id 

282 ) 

283 if not actually_deleted: 

284 logger.info( 

285 f"Document {document_id[:8]}... was already removed " 

286 "by a concurrent delete request" 

287 ) 

288 session.rollback() 

289 return { 

290 "deleted": False, 

291 "document_id": document_id, 

292 "error": "Document not found", 

293 }, [] 

294 

295 session.commit() 

296 

297 result["deleted"] = True 

298 logger.info( 

299 f"Deleted document {document_id[:8]}... ({title}): " 

300 f"{result['chunks_deleted']} chunks, " 

301 f"{result['blob_size']} bytes blob" 

302 ) 

303 return result, rag_collection_ids 

304 

305 except Exception: 

306 logger.exception(f"Failed to delete document {document_id}") 

307 session.rollback() 

308 return { 

309 "deleted": False, 

310 "document_id": document_id, 

311 "error": "Failed to delete document", 

312 }, [] 

313 

314 def _purge_document_rag( 

315 self, 

316 document_id: str, 

317 collection_ids: List[str], 

318 *, 

319 full_delete: bool, 

320 ) -> None: 

321 """Remove a document's FAISS vectors and chunk rows post-commit. 

322 

323 Called after the deletion/unlink transaction has committed — 

324 releasing SQLite's single write lock — so the slow vector-store IO 

325 here never blocks another writer. For each collection the vectors 

326 are purged BEFORE their chunk rows (``purge_document_vectors`` 

327 resolves the FAISS int ids from those rows), then the rows are 

328 deleted. 

329 

330 A final sweep then guarantees no chunk row survives even when the 

331 per-collection purge could not run — e.g. the DB password is 

332 unavailable on an encrypted-DB install, or a collection has no 

333 current FAISS index. ``full_delete`` selects the sweep scope: True 

334 (the document is gone) sweeps every remaining chunk row for the 

335 document; False (a single unlink) sweeps only the listed 

336 collections, leaving the document's chunks in its other collections 

337 intact. 

338 

339 Best-effort: the DB delete already committed, so failures here are 

340 logged, not surfaced. 

341 """ 

342 if collection_ids: 

343 dbpw = capture_request_db_password(self.username) 

344 from ...services.rag_service_factory import get_rag_service 

345 

346 for collection_id in collection_ids: 

347 try: 

348 # A fresh service per collection so the factory resolves 

349 # THAT collection's stored embedding model/provider — a 

350 # shared instance would compute the wrong index hash and 

351 # purge nothing for non-default-embedding collections. 

352 with get_rag_service( 

353 self.username, 

354 collection_id=collection_id, 

355 db_password=dbpw, 

356 ) as rag: 

357 rag.purge_document_vectors(document_id, collection_id) 

358 rag.purge_document_chunks(document_id, collection_id) 

359 except Exception: 

360 # exception=True keeps the stack trace. dbpw is a live 

361 # local here, but frame-local values only render under 

362 # the opt-in LDR_LOGURU_DIAGNOSE dev flag — every 

363 # persistent sink sets diagnose=False — so the password 

364 # cannot reach a log in normal operation. 

365 logger.opt(exception=True).error( 

366 "Failed to remove RAG entries for document {} " 

367 "from collection {}", 

368 document_id, 

369 collection_id, 

370 ) 

371 

372 # Guaranteed backstop: remove any chunk rows the RAG purge could not 

373 # reach. Their vectors can't be resolved without the rows, but the 

374 # collection-scoped rehydration filter already stops an orphaned 

375 # vector from ever surfacing another document's text, so dropping the 

376 # rows here keeps the deletion complete and safe. 

377 try: 

378 with get_user_db_session(self.username) as session: 

379 if full_delete: 

380 CascadeHelper.delete_document_chunks(session, document_id) 

381 else: 

382 for collection_id in collection_ids: 

383 CascadeHelper.delete_document_chunks( 

384 session, 

385 document_id, 

386 f"collection_{collection_id}", 

387 ) 

388 session.commit() 

389 except Exception: 

390 logger.opt(exception=True).error( 

391 "Failed to sweep residual chunk rows for document {}", 

392 document_id, 

393 ) 

394 

395 def delete_blob_only(self, document_id: str) -> Dict[str, Any]: 

396 """ 

397 Delete PDF binary but keep document metadata and text content. 

398 

399 This saves database space while preserving searchability. 

400 

401 Args: 

402 document_id: ID of the document 

403 

404 Returns: 

405 Dict with deletion details: 

406 { 

407 "deleted": True/False, 

408 "document_id": str, 

409 "bytes_freed": int, 

410 "storage_mode_updated": bool, 

411 "error": str (if failed) 

412 } 

413 """ 

414 with get_user_db_session(self.username) as session: 

415 try: 

416 # Get document 

417 document = session.get(Document, document_id) 

418 if not document: 

419 return { 

420 "deleted": False, 

421 "document_id": document_id, 

422 "bytes_freed": 0, 

423 "error": "Document not found", 

424 } 

425 

426 # Notes are not mutable via the generic blob-delete API. 

427 # Without this guard, a note's storage_mode/file_path can 

428 # be overwritten with the PDF-blob-deletion sentinel via 

429 # either DELETE /library/api/document/<id>/blob OR the 

430 # bulk DELETE /library/api/documents/blobs path (which 

431 # loops over this method per-id). 

432 if _is_note_document(session, document): 

433 return { 

434 "deleted": False, 

435 "document_id": document_id, 

436 "bytes_freed": 0, 

437 "error": ( 

438 "Notes cannot be modified via the document API. " 

439 "Use the notes API instead." 

440 ), 

441 "is_note": True, 

442 } 

443 

444 result = { 

445 "deleted": False, 

446 "document_id": document_id, 

447 "bytes_freed": 0, 

448 "storage_mode_updated": False, 

449 } 

450 

451 # Handle based on storage mode 

452 if document.storage_mode == "database": 

453 # Delete blob from database 

454 result["bytes_freed"] = CascadeHelper.delete_document_blob( 

455 session, document_id 

456 ) 

457 

458 elif document.storage_mode == "filesystem": 

459 # Delete filesystem file 

460 from ...utils import get_absolute_path_from_settings 

461 

462 if document.file_path: 

463 try: 

464 # Own-root only: never unlink via the legacy 

465 # shared root (cross-tenant collision, #5521). 

466 file_path = get_absolute_path_from_settings( 

467 document.file_path, 

468 self.username, 

469 allow_legacy_fallback=False, 

470 ) 

471 # See the fail-closed comment on the equivalent 

472 # guard in _delete_document_locked() above: 

473 # allowed_root should never be None here, but a 

474 # destructive unlink must not silently fall back 

475 # to a boundary-less containment check if it 

476 # ever were. 

477 allowed_root = get_absolute_path_from_settings("") 

478 if ( 

479 file_path 

480 and file_path.is_file() 

481 and allowed_root is not None 

482 ): 

483 result["bytes_freed"] = file_path.stat().st_size 

484 CascadeHelper.delete_filesystem_file( 

485 str(file_path), 

486 allowed_root=allowed_root, 

487 ) 

488 elif file_path and file_path.is_file(): 488 ↛ 489line 488 didn't jump to line 489 because the condition on line 488 was never true

489 logger.error( 

490 "Refusing to delete filesystem file: " 

491 "could not resolve library root for " 

492 "containment check" 

493 ) 

494 except Exception: 

495 logger.exception("Failed to delete filesystem file") 

496 

497 else: 

498 # No blob to delete 

499 return { 

500 "deleted": False, 

501 "document_id": document_id, 

502 "bytes_freed": 0, 

503 "error": "Document has no stored PDF (storage_mode is 'none')", 

504 } 

505 

506 # Update document to indicate blob is deleted 

507 document.storage_mode = "none" 

508 document.file_path = FILE_PATH_BLOB_DELETED 

509 result["storage_mode_updated"] = True 

510 

511 session.commit() 

512 result["deleted"] = True 

513 

514 logger.info( 

515 f"Deleted blob for document {document_id[:8]}...: " 

516 f"{result['bytes_freed']} bytes freed" 

517 ) 

518 

519 return result 

520 

521 except Exception: 

522 logger.exception( 

523 f"Failed to delete blob for document {document_id}" 

524 ) 

525 session.rollback() 

526 return { 

527 "deleted": False, 

528 "document_id": document_id, 

529 "bytes_freed": 0, 

530 "error": "Failed to delete document blob", 

531 } 

532 

533 def remove_from_collection( 

534 self, 

535 document_id: str, 

536 collection_id: str, 

537 ) -> Dict[str, Any]: 

538 """ 

539 Remove document from a collection. 

540 

541 If the document is not in any other collection after removal, 

542 it will be completely deleted. 

543 

544 Args: 

545 document_id: ID of the document 

546 collection_id: ID of the collection 

547 

548 Returns: 

549 Dict with operation details: 

550 { 

551 "unlinked": True/False, 

552 "document_deleted": bool, 

553 "document_id": str, 

554 "collection_id": str, 

555 "chunks_deleted": int, 

556 "error": str (if failed) 

557 } 

558 """ 

559 with get_user_db_session(self.username) as session: 

560 try: 

561 # Verify document exists 

562 document = session.get(Document, document_id) 

563 if not document: 

564 return { 

565 "unlinked": False, 

566 "document_deleted": False, 

567 "document_id": document_id, 

568 "collection_id": collection_id, 

569 "error": "Document not found", 

570 } 

571 

572 # Verify collection exists and document is in it 

573 doc_collection = ( 

574 session.query(DocumentCollection) 

575 .filter_by( 

576 document_id=document_id, collection_id=collection_id 

577 ) 

578 .first() 

579 ) 

580 

581 if not doc_collection: 

582 return { 

583 "unlinked": False, 

584 "document_deleted": False, 

585 "document_id": document_id, 

586 "collection_id": collection_id, 

587 "error": "Document not in this collection", 

588 } 

589 

590 # Permanent-home guard: the Notes collection is every 

591 # note's home (_get_or_create_notes_collection); nothing 

592 # re-links a note after an unlink, so removal here would 

593 # silently drop the note from semantic search, the 

594 # collection view, and the auto-reindex worker — while 

595 # list_notes still shows it. NoteService.remove_from_collection 

596 # enforces this for the notes API; this mirrors it for the 

597 # generic collection-document API (single + bulk). 

598 collection = session.get(Collection, collection_id) 

599 if ( 

600 collection is not None 

601 and collection.collection_type == "notes" 

602 and _is_note_document(session, document) 

603 ): 

604 return { 

605 "unlinked": False, 

606 "document_deleted": False, 

607 "document_id": document_id, 

608 "collection_id": collection_id, 

609 "protected": True, 

610 "error": ( 

611 "Cannot remove a note from its notes " 

612 "collection — it is the note's permanent " 

613 "home. Use DELETE /api/notes/<id> to delete " 

614 "the note itself." 

615 ), 

616 } 

617 

618 result = { 

619 "unlinked": False, 

620 "document_deleted": False, 

621 "document_id": document_id, 

622 "collection_id": collection_id, 

623 "chunks_deleted": 0, 

624 } 

625 

626 # Count chunks for this document in this collection (for 

627 # stats). Their rows and FAISS vectors are removed in the 

628 # post-commit RAG cleanup phase — purge_document_vectors 

629 # resolves the FAISS int ids from these rows, so they must 

630 # outlive this transaction. 

631 collection_name = f"collection_{collection_id}" 

632 result["chunks_deleted"] = ( 

633 session.query(DocumentChunk) 

634 .filter( 

635 DocumentChunk.source_id == document_id, 

636 DocumentChunk.source_type == "document", 

637 DocumentChunk.collection_name == collection_name, 

638 ) 

639 .count() 

640 ) 

641 

642 # Remove the link 

643 session.delete(doc_collection) 

644 # Clear the RagDocumentStatus row for this (document, collection) 

645 # — the "indexed" marker read by get_rag_stats / the RAG status 

646 # route. Its FK cascades fire only when the Document, Collection, 

647 # or RAGIndex is deleted; a mere unlink deletes none of those, so 

648 # a leftover row reports the just-removed document as still 

649 # indexed in this collection. 

650 session.query(RagDocumentStatus).filter_by( 

651 document_id=document_id, collection_id=collection_id 

652 ).delete(synchronize_session=False) 

653 session.flush() 

654 

655 # Check if document is in any other collection 

656 remaining_count = CascadeHelper.count_document_in_collections( 

657 session, document_id 

658 ) 

659 

660 # Default (unlink, and note-orphan): purge only THIS 

661 # collection's vectors/chunks; the document (or note) and its 

662 # chunks in other collections stay intact. 

663 full_delete = False 

664 rag_collection_ids: List[str] = [collection_id] 

665 

666 if remaining_count == 0 and _is_note_document( 666 ↛ 676line 666 didn't jump to line 676 because the condition on line 666 was never true

667 session, document 

668 ): 

669 # Note that was orphaned by this removal: skip the 

670 # destructive delete_document_completely path. Notes 

671 # live behind their own deletion API (DELETE /api/notes/<id>) 

672 # and are still discoverable via list_notes() which 

673 # filters by source_type_id, not collection membership. 

674 # The Notes collection itself acts as the persistent 

675 # home for every note via _get_or_create_notes_collection. 

676 logger.info( 

677 f"Note {document_id[:8]}... orphaned from collection — " 

678 "skipping orphan delete; use DELETE /api/notes/<id> to remove." 

679 ) 

680 elif remaining_count == 0: 

681 # Document is orphaned - delete it completely 

682 # Note: We're already in a session, so we need to do this 

683 # directly rather than calling delete_document() 

684 logger.info( 

685 f"Document {document_id[:8]}... is orphaned, deleting" 

686 ) 

687 

688 # Full delete: purge vectors/chunks across EVERY collection 

689 # that still holds chunk rows for this document, not just 

690 # the one being removed (a partial reindex can strand 

691 # chunks elsewhere). Rows are deleted in the post-commit 

692 # phase so purge_document_vectors can resolve their ids. 

693 chunk_collection_rows = ( 

694 session.query(DocumentChunk.collection_name) 

695 .filter_by( 

696 source_type="document", source_id=document_id 

697 ) 

698 .distinct() 

699 .all() 

700 ) 

701 full_delete = True 

702 rag_collection_ids = list( 

703 {collection_id} 

704 | { 

705 row[0].removeprefix("collection_") 

706 for row in chunk_collection_rows 

707 if row[0] 

708 } 

709 ) 

710 

711 # Update DownloadTracker 

712 CascadeHelper.update_download_tracker(session, document) 

713 

714 # Delete filesystem file if applicable 

715 if ( 

716 document.storage_mode == "filesystem" 

717 and document.file_path 

718 ): 

719 from ...utils import get_absolute_path_from_settings 

720 

721 try: 

722 # Own-root only: never unlink via the legacy 

723 # shared root (cross-tenant collision, #5521). 

724 file_path = get_absolute_path_from_settings( 

725 document.file_path, 

726 self.username, 

727 allow_legacy_fallback=False, 

728 ) 

729 # See the fail-closed comment on the equivalent 

730 # guard in _delete_document_locked() above: 

731 # allowed_root should never be None here, but a 

732 # destructive unlink must not silently fall back 

733 # to a boundary-less containment check if it 

734 # ever were. 

735 allowed_root = get_absolute_path_from_settings("") 

736 if file_path and allowed_root is not None: 

737 CascadeHelper.delete_filesystem_file( 

738 str(file_path), 

739 allowed_root=allowed_root, 

740 ) 

741 elif file_path: 741 ↛ 742line 741 didn't jump to line 742 because the condition on line 741 was never true

742 logger.error( 

743 "Refusing to delete filesystem file: " 

744 "could not resolve library root for " 

745 "containment check" 

746 ) 

747 except Exception: 

748 logger.exception("Failed to delete filesystem file") 

749 

750 # Delete document and all related records (leaves the 

751 # DocumentChunk rows for the post-commit cleanup phase). 

752 CascadeHelper.delete_document_completely( 

753 session, document_id 

754 ) 

755 result["document_deleted"] = True 

756 

757 session.commit() 

758 result["unlinked"] = True 

759 

760 logger.info( 

761 f"Removed document {document_id[:8]}... from collection " 

762 f"{collection_id[:8]}... " 

763 f"(deleted={result['document_deleted']})" 

764 ) 

765 

766 except Exception: 

767 logger.exception( 

768 f"Failed to remove document {document_id} " 

769 f"from collection {collection_id}" 

770 ) 

771 session.rollback() 

772 return { 

773 "unlinked": False, 

774 "document_deleted": False, 

775 "document_id": document_id, 

776 "collection_id": collection_id, 

777 "error": "Failed to remove document from collection", 

778 } 

779 

780 # Post-commit FAISS + chunk-row cleanup, after the transaction has 

781 # committed and released SQLite's write lock. 

782 self._purge_document_rag( 

783 document_id, rag_collection_ids, full_delete=full_delete 

784 ) 

785 

786 return result 

787 

788 def get_deletion_preview(self, document_id: str) -> Dict[str, Any]: 

789 """ 

790 Get a preview of what will be deleted. 

791 

792 Useful for showing the user what will happen before confirming. 

793 

794 Args: 

795 document_id: ID of the document 

796 

797 Returns: 

798 Dict with preview information 

799 """ 

800 with get_user_db_session(self.username) as session: 

801 document = session.get(Document, document_id) 

802 if not document: 

803 return {"found": False, "document_id": document_id} 

804 

805 collections = CascadeHelper.get_document_collections( 

806 session, document_id 

807 ) 

808 

809 # Count chunks 

810 total_chunks = ( 

811 session.query(DocumentChunk) 

812 .filter( 

813 DocumentChunk.source_id == document_id, 

814 DocumentChunk.source_type == "document", 

815 ) 

816 .count() 

817 ) 

818 

819 blob_size = CascadeHelper.get_document_blob_size( 

820 session, document_id 

821 ) 

822 

823 return { 

824 "found": True, 

825 "document_id": document_id, 

826 "title": document.title or document.filename or "Untitled", 

827 "file_type": document.file_type, 

828 "storage_mode": document.storage_mode, 

829 "has_blob": blob_size > 0, 

830 "blob_size": blob_size, 

831 "has_text": bool(document.text_content), 

832 "collections_count": len(collections), 

833 "chunks_count": total_chunks, 

834 }