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

216 statements  

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

28from ..utils.cascade_helper import CascadeHelper 

29from ...services.pdf_storage_manager import effective_storage_mode 

30 

31 

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

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

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

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

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

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

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

39_document_delete_locks_guard = threading.Lock() 

40 

41 

42@contextmanager 

43def _hold_document_delete_lock( 

44 username: str, document_id: str 

45) -> Iterator[None]: 

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

47 

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

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

50 """ 

51 key = (username, document_id) 

52 with _document_delete_locks_guard: 

53 lock = _document_delete_locks.get(key) 

54 if lock is None: 

55 lock = threading.Lock() 

56 _document_delete_locks[key] = lock 

57 _document_delete_lock_refs[key] = ( 

58 _document_delete_lock_refs.get(key, 0) + 1 

59 ) 

60 

61 try: 

62 with lock: 

63 yield 

64 finally: 

65 with _document_delete_locks_guard: 

66 remaining = _document_delete_lock_refs[key] - 1 

67 if remaining: 

68 _document_delete_lock_refs[key] = remaining 

69 else: 

70 _document_delete_lock_refs.pop(key, None) 

71 # Identity check protects against future registry maintenance 

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

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

74 _document_delete_locks.pop(key, None) 

75 

76 

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

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

79 

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

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

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

83 (see PROTECTED_COLLECTION_TYPES in collection_deletion.py). 

84 

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

86 the notes package to avoid an import cycle. 

87 """ 

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

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

90 

91 

92class DocumentDeletionService: 

93 """Service for document deletion operations.""" 

94 

95 def __init__(self, username: str): 

96 """ 

97 Initialize document deletion service. 

98 

99 Args: 

100 username: Username for database session 

101 """ 

102 self.username = username 

103 

104 def purge_deleted_research_documents( 

105 self, targets: List[Tuple[str, List[str]]] 

106 ) -> None: 

107 """Purge vectors and residual chunks after research deletion commits.""" 

108 for document_id, collection_ids in targets: 

109 # The pre-delete snapshot also contains documents transferred to 

110 # surviving research by the cascade. Only a missing document may 

111 # lose its RAG data; query committed state, not the old ownership. 

112 try: 

113 with get_user_db_session(self.username) as session: 

114 if ( 

115 session.query(Document.id) 

116 .filter_by(id=document_id) 

117 .first() 

118 is not None 

119 ): 

120 continue 

121 except Exception: 

122 logger.warning( 

123 "Could not verify research document removal; " 

124 "skipping RAG cleanup" 

125 ) 

126 continue 

127 self._purge_document_rag( 

128 document_id, collection_ids, full_delete=True 

129 ) 

130 

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

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

133 with _hold_document_delete_lock(self.username, document_id): 

134 result, rag_collection_ids = self._delete_document_locked( 

135 document_id 

136 ) 

137 

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

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

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

141 # for slow vector-store IO. 

142 if result.get("deleted"): 

143 self._purge_document_rag( 

144 document_id, rag_collection_ids, full_delete=True 

145 ) 

146 

147 return result 

148 

149 def _delete_document_locked( 

150 self, document_id: str 

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

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

153 

154 This method ensures complete DB/file cleanup: 

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

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

157 - Filesystem files 

158 - DownloadTracker update 

159 - DocumentCollection links (CASCADE) 

160 - RagDocumentStatus (CASCADE) 

161 

162 Args: 

163 document_id: ID of the document to delete 

164 

165 Returns: 

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

167 """ 

168 result: Dict[str, Any] = { 

169 "deleted": False, 

170 "document_id": document_id, 

171 "title": None, 

172 "blob_deleted": False, 

173 "blob_size": 0, 

174 "chunks_deleted": 0, 

175 "collections_unlinked": 0, 

176 "file_deleted": False, 

177 } 

178 rag_collection_ids: List[str] = [] 

179 

180 with get_user_db_session(self.username) as session: 

181 try: 

182 # Get document 

183 document = session.get(Document, document_id) 

184 if not document: 

185 return { 

186 "deleted": False, 

187 "document_id": document_id, 

188 "error": "Document not found", 

189 }, [] 

190 

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

192 # The Notes-collection deletion guard only blocks the 

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

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

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

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

197 # with the proper version/link cleanup. 

198 if _is_note_document(session, document): 

199 return { 

200 "deleted": False, 

201 "document_id": document_id, 

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

203 "error": ( 

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

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

206 ), 

207 "is_note": True, 

208 }, [] 

209 

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

211 result["title"] = title 

212 

213 # 1. Get collections before deletion for chunk cleanup 

214 collections = CascadeHelper.get_document_collections( 

215 session, document_id 

216 ) 

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

218 

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

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

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

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

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

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

225 chunk_collection_rows = ( 

226 session.query(DocumentChunk.collection_name) 

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

228 .distinct() 

229 .all() 

230 ) 

231 rag_collection_ids = list( 

232 {str(c) for c in collections} 

233 | { 

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

235 for row in chunk_collection_rows 

236 if row[0] 

237 } 

238 ) 

239 

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

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

242 # phase below — purge_document_vectors resolves the FAISS 

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

244 # transaction (mirrors NoteService.delete_note). 

245 result["chunks_deleted"] = ( 

246 session.query(DocumentChunk) 

247 .filter( 

248 DocumentChunk.source_id == document_id, 

249 DocumentChunk.source_type == "document", 

250 ) 

251 .count() 

252 ) 

253 

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

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

256 session, document_id 

257 ) 

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

259 

260 # 4. Delete filesystem file if exists 

261 if ( 

262 effective_storage_mode(document) == "filesystem" 

263 and document.file_path 

264 ): 

265 from ...utils import ( 

266 get_absolute_path_from_settings, 

267 get_containment_root_from_settings, 

268 ) 

269 

270 try: 

271 # Re-check the unlink against the directory the 

272 # resolver confines file_path to: this user's root, 

273 # or <root>/pdfs for a stored pdfs/... path. The bare 

274 # library root would refuse every PDF in the 

275 # sanctioned layout that symlinks pdfs onto a larger 

276 # disk, and orphan it there. The boundary is resolved 

277 # (pinned) BEFORE the file is, and the re-check does 

278 # not resolve it again (allowed_root_is_resolved), so 

279 # a pdfs directory swapped for a symlink after this 

280 # point moves only the file, which is then refused 

281 # (#5481). The boundary is None only when the root 

282 # lookup or its resolution fails. 

283 # _resolved_containment_ok() treats allowed_root=None 

284 # as "no boundary enforced" for callers that can't 

285 # cheaply supply a root (e.g. the FAISS sweeps), 

286 # which is fine there but would be a silent fail-open 

287 # here. Fail closed instead of unlinking with no 

288 # containment boundary. 

289 allowed_root = get_containment_root_from_settings( 

290 document.file_path, 

291 self.username, 

292 ) 

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

294 # legacy shared root, where a colliding relative path 

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

296 file_path = get_absolute_path_from_settings( 

297 document.file_path, 

298 self.username, 

299 ) 

300 if file_path and allowed_root is not None: 

301 result["file_deleted"] = ( 

302 CascadeHelper.delete_filesystem_file( 

303 str(file_path), 

304 allowed_root=allowed_root, 

305 allowed_root_is_resolved=True, 

306 ) 

307 ) 

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

309 logger.error( 

310 "Refusing to delete filesystem file: " 

311 "could not resolve library root for " 

312 "containment check" 

313 ) 

314 except Exception: 

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

316 

317 # 5. Update DownloadTracker 

318 CascadeHelper.update_download_tracker(session, document) 

319 

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

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

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

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

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

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

326 # affected no document row. 

327 actually_deleted = CascadeHelper.delete_document_completely( 

328 session, document_id 

329 ) 

330 if not actually_deleted: 

331 # Bind the acting user so the record survives the 

332 # database sink's owner resolution and reaches the 

333 # user's app_logs — without it the race signal is 

334 # stderr-only (#5581). The loser just gets an ordinary 

335 # "not found", so this line is the only trace of the 

336 # race. The row has no research_id, so neither the 

337 # research-history cascade nor any retention job ever 

338 # removes it: bind only a rare event like this one, 

339 # not per-delete success lines or failures that can 

340 # repeat for every document of a bulk run. 

341 logger.bind(username=self.username).info( 

342 f"Document {short_id(document_id)} was already removed " 

343 "by a concurrent delete request" 

344 ) 

345 session.rollback() 

346 return { 

347 "deleted": False, 

348 "document_id": document_id, 

349 "error": "Document not found", 

350 }, [] 

351 

352 session.commit() 

353 

354 result["deleted"] = True 

355 logger.info( 

356 f"Deleted document {short_id(document_id)} ({title}): " 

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

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

359 ) 

360 return result, rag_collection_ids 

361 

362 except Exception: 

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

364 session.rollback() 

365 return { 

366 "deleted": False, 

367 "document_id": document_id, 

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

369 }, [] 

370 

371 def _purge_document_rag( 

372 self, 

373 document_id: str, 

374 collection_ids: List[str], 

375 *, 

376 full_delete: bool, 

377 ) -> None: 

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

379 

380 Called after the deletion/unlink transaction has committed — 

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

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

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

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

385 deleted. 

386 

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

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

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

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

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

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

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

394 intact. 

395 

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

397 logged, not surfaced. 

398 """ 

399 if collection_ids: 

400 for collection_id in collection_ids: 

401 try: 

402 dbpw = capture_request_db_password(self.username) 

403 from ...services.rag_service_factory import ( 

404 get_rag_service, 

405 ) 

406 

407 # A fresh service per collection so the factory resolves 

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

409 # shared instance would compute the wrong index hash and 

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

411 with get_rag_service( 

412 self.username, 

413 collection_id=collection_id, 

414 db_password=dbpw, 

415 ) as rag: 

416 rag.purge_document_vectors(document_id, collection_id) 

417 rag.purge_document_chunks(document_id, collection_id) 

418 except Exception: 

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

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

421 # the opt-in LDR_LOGURU_DIAGNOSE dev flag — every 

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

423 # cannot reach a log in normal operation. 

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

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

426 "from collection {}", 

427 document_id, 

428 collection_id, 

429 ) 

430 

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

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

433 # collection-scoped rehydration filter already stops an orphaned 

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

435 # rows here keeps the deletion complete and safe. 

436 try: 

437 with get_user_db_session(self.username) as session: 

438 if full_delete: 

439 CascadeHelper.delete_document_chunks(session, document_id) 

440 else: 

441 for collection_id in collection_ids: 

442 CascadeHelper.delete_document_chunks( 

443 session, 

444 document_id, 

445 f"collection_{collection_id}", 

446 ) 

447 session.commit() 

448 except Exception: 

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

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

451 document_id, 

452 ) 

453 

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

455 """ 

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

457 

458 This saves database space while preserving searchability. 

459 

460 Args: 

461 document_id: ID of the document 

462 

463 Returns: 

464 Dict with deletion details: 

465 { 

466 "deleted": True/False, 

467 "document_id": str, 

468 "bytes_freed": int, 

469 "storage_mode_updated": bool, 

470 "error": str (if failed) 

471 } 

472 """ 

473 with get_user_db_session(self.username) as session: 

474 try: 

475 # Get document 

476 document = session.get(Document, document_id) 

477 if not document: 

478 return { 

479 "deleted": False, 

480 "document_id": document_id, 

481 "bytes_freed": 0, 

482 "error": "Document not found", 

483 } 

484 

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

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

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

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

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

490 # loops over this method per-id). 

491 if _is_note_document(session, document): 

492 return { 

493 "deleted": False, 

494 "document_id": document_id, 

495 "bytes_freed": 0, 

496 "error": ( 

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

498 "Use the notes API instead." 

499 ), 

500 "is_note": True, 

501 } 

502 

503 result = { 

504 "deleted": False, 

505 "document_id": document_id, 

506 "bytes_freed": 0, 

507 "storage_mode_updated": False, 

508 } 

509 

510 # Handle based on storage mode. Documents that predate the 

511 # column have it NULL, so infer it like PDFStorageManager. 

512 storage_mode = effective_storage_mode(document) 

513 if storage_mode == "database": 

514 # Delete blob from database 

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

516 session, document_id 

517 ) 

518 

519 elif storage_mode == "filesystem": 

520 # Delete filesystem file 

521 from ...utils import ( 

522 get_absolute_path_from_settings, 

523 get_containment_root_from_settings, 

524 ) 

525 

526 if document.file_path: 

527 try: 

528 # See the boundary and fail-closed comment on the 

529 # equivalent guard in _delete_document_locked() 

530 # above: the boundary is pinned before the file 

531 # is resolved, and a destructive unlink must not 

532 # silently fall back to a boundary-less 

533 # containment check if it is ever None. 

534 allowed_root = get_containment_root_from_settings( 

535 document.file_path, 

536 self.username, 

537 ) 

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

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

540 file_path = get_absolute_path_from_settings( 

541 document.file_path, 

542 self.username, 

543 ) 

544 if ( 

545 file_path 

546 and file_path.is_file() 

547 and allowed_root is not None 

548 ): 

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

550 CascadeHelper.delete_filesystem_file( 

551 str(file_path), 

552 allowed_root=allowed_root, 

553 allowed_root_is_resolved=True, 

554 ) 

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

556 logger.error( 

557 "Refusing to delete filesystem file: " 

558 "could not resolve library root for " 

559 "containment check" 

560 ) 

561 except Exception: 

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

563 

564 else: 

565 # No blob to delete 

566 return { 

567 "deleted": False, 

568 "document_id": document_id, 

569 "bytes_freed": 0, 

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

571 } 

572 

573 # Update document to indicate blob is deleted 

574 document.storage_mode = "none" 

575 document.file_path = FILE_PATH_BLOB_DELETED 

576 result["storage_mode_updated"] = True 

577 

578 session.commit() 

579 result["deleted"] = True 

580 

581 logger.info( 

582 f"Deleted blob for document {short_id(document_id)}: " 

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

584 ) 

585 

586 return result 

587 

588 except Exception: 

589 logger.exception( 

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

591 ) 

592 session.rollback() 

593 return { 

594 "deleted": False, 

595 "document_id": document_id, 

596 "bytes_freed": 0, 

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

598 } 

599 

600 def remove_from_collection( 

601 self, 

602 document_id: str, 

603 collection_id: str, 

604 ) -> Dict[str, Any]: 

605 """ 

606 Remove document from a collection. 

607 

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

609 it will be completely deleted. 

610 

611 Args: 

612 document_id: ID of the document 

613 collection_id: ID of the collection 

614 

615 Returns: 

616 Dict with operation details: 

617 { 

618 "unlinked": True/False, 

619 "document_deleted": bool, 

620 "document_id": str, 

621 "collection_id": str, 

622 "chunks_deleted": int, 

623 "error": str (if failed) 

624 } 

625 """ 

626 with get_user_db_session(self.username) as session: 

627 try: 

628 # Verify document exists 

629 document = session.get(Document, document_id) 

630 if not document: 

631 return { 

632 "unlinked": False, 

633 "document_deleted": False, 

634 "document_id": document_id, 

635 "collection_id": collection_id, 

636 "error": "Document not found", 

637 } 

638 

639 # Verify collection exists and document is in it 

640 doc_collection = ( 

641 session.query(DocumentCollection) 

642 .filter_by( 

643 document_id=document_id, collection_id=collection_id 

644 ) 

645 .first() 

646 ) 

647 

648 if not doc_collection: 

649 return { 

650 "unlinked": False, 

651 "document_deleted": False, 

652 "document_id": document_id, 

653 "collection_id": collection_id, 

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

655 } 

656 

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

658 # note's home (_get_or_create_notes_collection); nothing 

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

660 # silently drop the note from semantic search, the 

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

662 # list_notes still shows it. NoteService.remove_from_collection 

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

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

665 collection = session.get(Collection, collection_id) 

666 if ( 

667 collection is not None 

668 and collection.collection_type == "notes" 

669 and _is_note_document(session, document) 

670 ): 

671 return { 

672 "unlinked": False, 

673 "document_deleted": False, 

674 "document_id": document_id, 

675 "collection_id": collection_id, 

676 "protected": True, 

677 "error": ( 

678 "Cannot remove a note from its notes " 

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

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

681 "the note itself." 

682 ), 

683 } 

684 

685 result = { 

686 "unlinked": False, 

687 "document_deleted": False, 

688 "document_id": document_id, 

689 "collection_id": collection_id, 

690 "chunks_deleted": 0, 

691 } 

692 

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

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

695 # post-commit RAG cleanup phase — purge_document_vectors 

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

697 # outlive this transaction. 

698 collection_name = f"collection_{collection_id}" 

699 result["chunks_deleted"] = ( 

700 session.query(DocumentChunk) 

701 .filter( 

702 DocumentChunk.source_id == document_id, 

703 DocumentChunk.source_type == "document", 

704 DocumentChunk.collection_name == collection_name, 

705 ) 

706 .count() 

707 ) 

708 

709 # Remove the link 

710 session.delete(doc_collection) 

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

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

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

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

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

716 # indexed in this collection. 

717 session.query(RagDocumentStatus).filter_by( 

718 document_id=document_id, collection_id=collection_id 

719 ).delete(synchronize_session=False) 

720 session.flush() 

721 

722 # Check if document is in any other collection 

723 remaining_count = CascadeHelper.count_document_in_collections( 

724 session, document_id 

725 ) 

726 

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

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

729 # chunks in other collections stay intact. 

730 full_delete = False 

731 rag_collection_ids: List[str] = [collection_id] 

732 

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

734 session, document 

735 ): 

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

737 # destructive delete_document_completely path. Notes 

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

739 # and are still discoverable via list_notes() which 

740 # filters by source_type_id, not collection membership. 

741 # The Notes collection itself acts as the persistent 

742 # home for every note via _get_or_create_notes_collection. 

743 logger.info( 

744 f"Note {short_id(document_id)} orphaned from collection — " 

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

746 ) 

747 elif remaining_count == 0: 

748 # Document is orphaned - delete it completely 

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

750 # directly rather than calling delete_document() 

751 logger.info( 

752 f"Document {short_id(document_id)} is orphaned, deleting" 

753 ) 

754 

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

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

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

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

759 # phase so purge_document_vectors can resolve their ids. 

760 chunk_collection_rows = ( 

761 session.query(DocumentChunk.collection_name) 

762 .filter_by( 

763 source_type="document", source_id=document_id 

764 ) 

765 .distinct() 

766 .all() 

767 ) 

768 full_delete = True 

769 rag_collection_ids = list( 

770 {collection_id} 

771 | { 

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

773 for row in chunk_collection_rows 

774 if row[0] 

775 } 

776 ) 

777 

778 # Update DownloadTracker 

779 CascadeHelper.update_download_tracker(session, document) 

780 

781 # Delete filesystem file if applicable 

782 if ( 

783 effective_storage_mode(document) == "filesystem" 

784 and document.file_path 

785 ): 

786 from ...utils import ( 

787 get_absolute_path_from_settings, 

788 get_containment_root_from_settings, 

789 ) 

790 

791 try: 

792 # See the boundary and fail-closed comment on the 

793 # equivalent guard in _delete_document_locked() 

794 # above: the boundary is pinned before the file 

795 # is resolved, and a destructive unlink must not 

796 # silently fall back to a boundary-less 

797 # containment check if it is ever None. 

798 allowed_root = get_containment_root_from_settings( 

799 document.file_path, 

800 self.username, 

801 ) 

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

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

804 file_path = get_absolute_path_from_settings( 

805 document.file_path, 

806 self.username, 

807 ) 

808 if file_path and allowed_root is not None: 

809 CascadeHelper.delete_filesystem_file( 

810 str(file_path), 

811 allowed_root=allowed_root, 

812 allowed_root_is_resolved=True, 

813 ) 

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

815 logger.error( 

816 "Refusing to delete filesystem file: " 

817 "could not resolve library root for " 

818 "containment check" 

819 ) 

820 except Exception: 

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

822 

823 # Delete document and all related records (leaves the 

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

825 CascadeHelper.delete_document_completely( 

826 session, document_id 

827 ) 

828 result["document_deleted"] = True 

829 

830 session.commit() 

831 result["unlinked"] = True 

832 

833 logger.info( 

834 f"Removed document {short_id(document_id)} from collection " 

835 f"{short_id(collection_id)} " 

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

837 ) 

838 

839 except Exception: 

840 logger.exception( 

841 f"Failed to remove document {document_id} " 

842 f"from collection {collection_id}" 

843 ) 

844 session.rollback() 

845 return { 

846 "unlinked": False, 

847 "document_deleted": False, 

848 "document_id": document_id, 

849 "collection_id": collection_id, 

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

851 } 

852 

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

854 # committed and released SQLite's write lock. 

855 self._purge_document_rag( 

856 document_id, rag_collection_ids, full_delete=full_delete 

857 ) 

858 

859 return result 

860 

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

862 """ 

863 Get a preview of what will be deleted. 

864 

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

866 

867 Args: 

868 document_id: ID of the document 

869 

870 Returns: 

871 Dict with preview information 

872 """ 

873 with get_user_db_session(self.username) as session: 

874 document = session.get(Document, document_id) 

875 if not document: 

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

877 

878 collections = CascadeHelper.get_document_collections( 

879 session, document_id 

880 ) 

881 

882 # Count chunks 

883 total_chunks = ( 

884 session.query(DocumentChunk) 

885 .filter( 

886 DocumentChunk.source_id == document_id, 

887 DocumentChunk.source_type == "document", 

888 ) 

889 .count() 

890 ) 

891 

892 blob_size = CascadeHelper.get_document_blob_size( 

893 session, document_id 

894 ) 

895 

896 return { 

897 "found": True, 

898 "document_id": document_id, 

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

900 "file_type": document.file_type, 

901 "storage_mode": document.storage_mode, 

902 "has_blob": blob_size > 0, 

903 "blob_size": blob_size, 

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

905 "collections_count": len(collections), 

906 "chunks_count": total_chunks, 

907 }