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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""
2Document deletion service.
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"""
10import threading
11from contextlib import contextmanager
12from typing import Any, Dict, Iterator, List, Tuple
14from loguru import logger
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
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()
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.
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 )
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)
77def _is_note_document(session, document) -> bool:
78 """Return True if the document represents a note (source_type='note').
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).
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
92class DocumentDeletionService:
93 """Service for document deletion operations."""
95 def __init__(self, username: str):
96 """
97 Initialize document deletion service.
99 Args:
100 username: Username for database session
101 """
102 self.username = username
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 )
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 )
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 )
147 return result
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.
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)
162 Args:
163 document_id: ID of the document to delete
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] = []
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 }, []
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 }, []
210 title = document.title or document.filename or "Untitled"
211 result["title"] = title
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)
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 )
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 )
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
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 )
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")
317 # 5. Update DownloadTracker
318 CascadeHelper.update_download_tracker(session, document)
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 }, []
352 session.commit()
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
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 }, []
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.
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.
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.
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 )
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 )
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 )
454 def delete_blob_only(self, document_id: str) -> Dict[str, Any]:
455 """
456 Delete PDF binary but keep document metadata and text content.
458 This saves database space while preserving searchability.
460 Args:
461 document_id: ID of the document
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 }
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 }
503 result = {
504 "deleted": False,
505 "document_id": document_id,
506 "bytes_freed": 0,
507 "storage_mode_updated": False,
508 }
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 )
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 )
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")
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 }
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
578 session.commit()
579 result["deleted"] = True
581 logger.info(
582 f"Deleted blob for document {short_id(document_id)}: "
583 f"{result['bytes_freed']} bytes freed"
584 )
586 return result
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 }
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.
608 If the document is not in any other collection after removal,
609 it will be completely deleted.
611 Args:
612 document_id: ID of the document
613 collection_id: ID of the collection
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 }
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 )
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 }
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 }
685 result = {
686 "unlinked": False,
687 "document_deleted": False,
688 "document_id": document_id,
689 "collection_id": collection_id,
690 "chunks_deleted": 0,
691 }
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 )
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()
722 # Check if document is in any other collection
723 remaining_count = CascadeHelper.count_document_in_collections(
724 session, document_id
725 )
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]
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 )
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 )
778 # Update DownloadTracker
779 CascadeHelper.update_download_tracker(session, document)
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 )
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")
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
830 session.commit()
831 result["unlinked"] = True
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 )
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 }
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 )
859 return result
861 def get_deletion_preview(self, document_id: str) -> Dict[str, Any]:
862 """
863 Get a preview of what will be deleted.
865 Useful for showing the user what will happen before confirming.
867 Args:
868 document_id: ID of the document
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}
878 collections = CascadeHelper.get_document_collections(
879 session, document_id
880 )
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 )
892 blob_size = CascadeHelper.get_document_blob_size(
893 session, document_id
894 )
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 }