mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-20 11:36:17 +00:00
336 lines
14 KiB
Python
336 lines
14 KiB
Python
"""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
|