"""Trash tier service: restore orchestration, guarded purge, retention sweep. Phase-2 spec §8.2/§8.3. Restore is a database re-point (``stored_relpath`` is projects-root-relative, so no file ever moves, §10.6); the only filesystem work on the restore path is the post-commit merge cleanup of the discarded namespace, best-effort with the sweep as backstop. Purge unlinks the original and ``derived/converted.md`` inside the repository's continuously row-locked transaction — ``FileNotFoundError`` counts as already removed, any other unlink error rolls the row deletion back and keeps the trashed row retryable. The retention sweep runs lazily on the trash listing and once at gateway startup (no daemon, §15.9): it invokes the same guarded purge for expired rows, then reconciles storage (``.staging`` and unreferenced files older than 24 hours only) and detects — never deletes — rows whose content is missing or size-mismatched (§15.17). Every filesystem touch is offloaded through ``run_file_io``; the blocking-IO anchors in ``tests/blocking_io/test_project_trash.py`` pin that. """ from __future__ import annotations import logging import os import shutil from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from pathlib import Path from typing import TYPE_CHECKING from deerflow.config.paths import Paths from deerflow.projects.documents import _content_intact, check_document_content, converted_markdown_path, original_file_path from deerflow.utils.file_io import await_drained, run_file_io from deerflow.utils.time import coerce_iso if TYPE_CHECKING: from deerflow.persistence.projects import ProjectDocumentRepository logger = logging.getLogger(__name__) #: Nothing younger than this is ever collected or flagged, so an in-flight #: upload or a freshly written row can never be swept (§8.3). _ORPHAN_GUARD = timedelta(hours=24) def _rmdir_if_empty(directory: Path) -> None: try: directory.rmdir() except OSError: pass def _unlink_document_files(paths: Paths, *, user_id: str, row: dict) -> None: """Worker-thread: unlink a row's original + derived companion, then rmdir empty parents (best-effort). ``FileNotFoundError`` counts as already removed (§8.3); any other unlink error propagates so the purge transaction rolls back and keeps the trashed row retryable. """ original = original_file_path(paths, user_id=user_id, row=row) derived = converted_markdown_path(paths, user_id=user_id, row=row) for path in (original, derived): try: path.unlink() except FileNotFoundError: pass namespace = paths.project_document_path(user_id, row["stored_relpath"]) documents_dir = namespace.parents[2] for directory in (original.parent, derived.parent, namespace, namespace.parent, namespace.parent.parent): if directory != documents_dir: _rmdir_if_empty(directory) def make_purge_file_remover(paths: Paths, *, user_id: str | None) -> Callable[[dict], Awaitable[None]]: """Build the ``remove_files`` hook for ``ProjectDocumentRepository.purge``. The hook runs inside the purge transaction, continuously under the document-row lock (§6.3). ``user_id=None`` (the startup sweep) resolves the owner per row. """ async def _remove(row: dict) -> None: await run_file_io(_unlink_document_files, paths, user_id=user_id or row["user_id"], row=row) return _remove def _remove_namespace_tree(paths: Paths, *, user_id: str, relpath: str) -> None: """Worker-thread: remove one document's whole namespace + empty parents.""" namespace = paths.project_document_path(user_id, relpath) shutil.rmtree(namespace, ignore_errors=True) documents_dir = namespace.parents[2] parent = namespace.parent while parent != documents_dir: _rmdir_if_empty(parent) parent = parent.parent async def restore_document( repo: ProjectDocumentRepository, paths: Paths, *, user_id: str, document_id: str, target_project_id: str, ) -> tuple[str, dict | None]: """Restore one trashed document into an active target project (§8.2). Thin orchestration over the repository's locked restore: the post-commit merge cleanup (unlinking the discarded source namespace, which no surviving row can reference) is the only filesystem work here — best-effort; a failure is logged and left for the sweep (§8.2/§10.6). Returns the repository's ``(outcome, row)`` pair unchanged. """ # Read-only probe: captures the discarded namespace a merge cleanup must # remove. Every correctness check happens inside the repository's locked # transaction, so this probe decides nothing (§15.5). source = await repo.get(document_id, include_trashed=True, user_id=user_id) async def _check(row: dict) -> bool: return await check_document_content(paths, user_id=user_id, row=row) outcome, row = await repo.restore(document_id, target_project_id=target_project_id, check_content=_check, user_id=user_id) if outcome == "merged" and source is not None: try: await run_file_io(_remove_namespace_tree, paths, user_id=user_id, relpath=source["stored_relpath"]) except Exception: logger.warning( "Merge cleanup of the discarded namespace for document %s failed; the sweep will collect it", document_id, exc_info=True, ) return outcome, row async def purge_all_trashed( repo: ProjectDocumentRepository, paths: Paths, *, user_id: str, ) -> int: """Empty the caller's trash (§8.3): purge every trashed row regardless of age. Empty trash deletes exactly what the user confirmed, so the retention cutoff plays no part here — ``run_trash_retention_sweep`` stays the only age-gated purge. Each row goes through the same guarded row-locked ``purge`` as a single-document delete: bytes first, then the row, in one transaction, so a restore that wins the race leaves the row alone (``purge`` answers ``False`` for a no-longer-trashed row and it is skipped). Rows are not deleted atomically: an unlink error rolls that row back and propagates, leaving it — and every row not yet visited — trashed and retryable. Returns the number of rows actually purged. """ remove_files = make_purge_file_remover(paths, user_id=user_id) purged = 0 for row in await repo.list_all_trashed(user_id=user_id): if await repo.purge(row["id"], remove_files=remove_files, user_id=user_id): purged += 1 return purged @dataclass(slots=True) class SweepReport: """Observable outcome of one retention sweep run.""" purged: int = 0 purge_failures: int = 0 orphans_removed: int = 0 staging_removed: int = 0 content_missing: list[str] = field(default_factory=list) def _sweep_project_documents_dir(documents_dir: Path, *, protected: set[Path], cutoff_ts: float, report: SweepReport) -> None: """Worker-thread: storage reconciliation under one ``documents/`` directory. Removes ``.staging/*`` entries and unreferenced files older than the 24-hour guard; a row's namespace (active or trashed, even after restore re-pointed it to another project) is protected in full (§8.3). Empty parents are rmdir'd best-effort. Nothing younger is ever collected. """ staging = documents_dir / ".staging" if staging.is_dir(): for entry in staging.iterdir(): try: if entry.stat().st_mtime >= cutoff_ts: continue if entry.is_dir(): shutil.rmtree(entry, ignore_errors=True) else: entry.unlink() except FileNotFoundError: continue except OSError: logger.warning("Staging sweep could not remove %s; skipping", entry, exc_info=True) continue report.staging_removed += 1 for dirpath, dirnames, filenames in os.walk(documents_dir): current = Path(dirpath) # Never descend into protected namespaces or .staging. dirnames[:] = [name for name in dirnames if (current / name) not in protected and not (current == documents_dir and name == ".staging")] for filename in filenames: file = current / filename try: if file.stat().st_mtime >= cutoff_ts: continue file.unlink() except FileNotFoundError: continue except OSError: logger.warning("Orphan sweep could not unlink %s; skipping", file, exc_info=True) continue report.orphans_removed += 1 # Rmdir empty parents best-effort (bottom-up), keeping the documents dir # itself and protected namespaces. for dirpath, _dirnames, _filenames in os.walk(documents_dir, topdown=False): current = Path(dirpath) if current != documents_dir and current not in protected: _rmdir_if_empty(current) def _reconcile_storage(paths: Paths, *, user_id: str | None, rows: list[dict], guard_cutoff: datetime, report: SweepReport) -> None: """Worker-thread: storage reconciliation across the swept users' trees.""" cutoff_ts = guard_cutoff.timestamp() protected_by_user: dict[str, set[Path]] = {} for row in rows: owner = row.get("user_id") if not isinstance(owner, str) or not owner: continue try: namespace = paths.project_document_path(owner, row["stored_relpath"]) except (KeyError, ValueError): continue protected_by_user.setdefault(owner, set()).add(namespace) if user_id is not None: user_ids = [user_id] else: users_root = paths.base_dir / "users" user_ids = sorted(entry.name for entry in users_root.iterdir() if entry.is_dir()) if users_root.is_dir() else [] for uid in user_ids: projects_root = paths.user_projects_dir(uid) if not projects_root.is_dir(): continue for project_dir in sorted(projects_root.iterdir()): documents_dir = project_dir / "documents" if documents_dir.is_dir(): _sweep_project_documents_dir(documents_dir, protected=protected_by_user.get(uid, set()), cutoff_ts=cutoff_ts, report=report) def _parse_iso(value: object) -> datetime | None: text = coerce_iso(value) if not text: return None try: parsed = datetime.fromisoformat(text) except ValueError: return None return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC) def _reconcile_rows(paths: Paths, *, rows: list[dict], guard_cutoff: datetime, report: SweepReport) -> None: """Worker-thread: row-side reconciliation — detect, log, NEVER delete. A row older than the 24-hour guard whose original is missing or size-mismatched is surfaced as ``content_missing`` (§8.3/§15.17): the row is the user's only record of the document, so disposal always starts with the user moving it to trash; only explicit purge or eligible retention purge may remove the trashed row. """ for row in rows: created = _parse_iso(row.get("created_at")) if created is None or created > guard_cutoff: continue if not _content_intact(paths, user_id=row["user_id"], row=row): report.content_missing.append(row["id"]) logger.warning( "Shelf document %s (%s) content is missing or size-mismatched; row retained and surfaced as content_missing (§8.3)", row["id"], row.get("name"), ) async def run_trash_retention_sweep( repo: ProjectDocumentRepository, paths: Paths, *, retention_days: int, user_id: str | None, now: datetime | None = None, include_reconciliation: bool = True, ) -> SweepReport: """Run one trash retention sweep (§8.3): expiry purge + reconciliation. Triggered lazily by ``GET /api/trash/documents`` (the caller's user) and once at gateway startup (``user_id=None`` ⇒ every user). No daemon, no scheduler (§15.9). Expired rows go through the same guarded purge as manual purges — the candidate's trash timestamp and the cutoff are revalidated under the purge lock, so a row restored and later re-trashed is not purged on its former expiry. A per-row purge failure keeps that row trashed and retryable without aborting the rest of the sweep. ``include_reconciliation=False`` skips the row/storage reconciliation and leaves it to the reference-aware retention purge, so repeated lazy triggers can stay cheap: the expiry purge is an indexed candidate scan, while reconciliation is O(all rows + all files) and only bounds external interference and orphaned staging behind the 24-hour guard. The retention guarantee itself (expired rows become purgeable) is unaffected. """ now = now or datetime.now(UTC) cutoff = now - timedelta(days=retention_days) report = SweepReport() remove_files = make_purge_file_remover(paths, user_id=user_id) for candidate in await repo.purge_candidates(retention_days, now=now, user_id=user_id): try: purged = await repo.purge( candidate["id"], retention_cutoff=cutoff, expected_trashed_at=candidate.get("trashed_at"), remove_files=remove_files, user_id=user_id, ) except Exception: report.purge_failures += 1 logger.warning( "Retention purge of document %s failed; trashed row retained (retryable)", candidate["id"], exc_info=True, ) continue if purged: report.purged += 1 if include_reconciliation: rows = await repo.list_all_for_sweep(user_id=user_id) guard_cutoff = now - _ORPHAN_GUARD await await_drained(run_file_io(_reconcile_storage, paths, user_id=user_id, rows=rows, guard_cutoff=guard_cutoff, report=report)) await await_drained(run_file_io(_reconcile_rows, paths, rows=rows, guard_cutoff=guard_cutoff, report=report)) return report