mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 09:08:38 +00:00
1242 lines
44 KiB
Python
1242 lines
44 KiB
Python
import asyncio
|
|
import json
|
|
import multiprocessing
|
|
import os
|
|
import stat
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from deerflow.sandbox.sandbox_provider import SandboxReconciliationResult
|
|
from deerflow.uploads.layout import conversion_path_for_upload, conversion_virtual_path, upload_virtual_path
|
|
from deerflow.uploads.manager import (
|
|
RemoteDeletionCommitRequiredError,
|
|
cleanup_stale_upload_staging_files,
|
|
delete_file_safe,
|
|
publish_upload_bytes,
|
|
)
|
|
from deerflow.uploads.sandbox_sync import (
|
|
_deletion_hook_for_sandbox as _raw_deletion_hook_for_sandbox,
|
|
)
|
|
from deerflow.uploads.sandbox_sync import (
|
|
_sandbox_provider_reconciliation_key,
|
|
make_upload_paths_available,
|
|
make_upload_paths_available_async,
|
|
prepare_upload_deletion,
|
|
prepare_upload_deletion_async,
|
|
reconcile_pending_remote_deletions,
|
|
)
|
|
|
|
|
|
def _deletion_hook_for_sandbox(sandbox, **kwargs):
|
|
if kwargs.get("sandbox_id") is not None:
|
|
kwargs.setdefault("backend_namespace", "tests.backend")
|
|
kwargs.setdefault("incarnation_id", "tests.incarnation")
|
|
return _raw_deletion_hook_for_sandbox(sandbox, **kwargs)
|
|
|
|
|
|
def _publish_upload_in_process(uploads_dir, filename, payload, outcomes):
|
|
try:
|
|
published = publish_upload_bytes(Path(uploads_dir), filename, payload)
|
|
outcomes.put(published.name)
|
|
except BaseException as exc: # pragma: no cover - surfaced in the parent
|
|
outcomes.put(repr(exc))
|
|
|
|
|
|
class _ExactProviderMixin:
|
|
def reconnect_sandbox_for_reconciliation(
|
|
self,
|
|
sandbox_id,
|
|
*,
|
|
thread_id,
|
|
user_id,
|
|
identity,
|
|
):
|
|
del thread_id, user_id
|
|
assert identity.backend_namespace == "tests.backend"
|
|
assert identity.incarnation_id == "tests.incarnation"
|
|
sandbox = self.get(sandbox_id)
|
|
return SandboxReconciliationResult.found(sandbox) if sandbox is not None else SandboxReconciliationResult.unknown()
|
|
|
|
|
|
def test_failed_compensation_deletes_paths_that_were_already_republished(tmp_path):
|
|
primary_virtual = "/mnt/user-data/uploads/report.pdf"
|
|
conversion_virtual = "/mnt/user-data/.upload-conversions/report.pdf.md"
|
|
|
|
class PartialFailureSandbox:
|
|
def __init__(self) -> None:
|
|
self.files = {
|
|
primary_virtual: b"pdf",
|
|
conversion_virtual: b"generated",
|
|
}
|
|
self.remove_calls: list[str] = []
|
|
self.conversion_failure_injected = False
|
|
|
|
def remove_file(self, path: str) -> None:
|
|
self.remove_calls.append(path)
|
|
self.files.pop(path, None)
|
|
if path == conversion_virtual and not self.conversion_failure_injected:
|
|
self.conversion_failure_injected = True
|
|
raise OSError("conversion response lost")
|
|
|
|
def update_file(self, path: str, content: bytes) -> None:
|
|
if path == primary_virtual:
|
|
raise OSError("primary compensation unavailable")
|
|
self.files[path] = content
|
|
|
|
primary = tmp_path / "report.pdf"
|
|
primary.write_bytes(b"pdf")
|
|
conversion = tmp_path / "report.pdf.md"
|
|
conversion.write_bytes(b"generated")
|
|
sandbox = PartialFailureSandbox()
|
|
|
|
with pytest.raises(RemoteDeletionCommitRequiredError):
|
|
_deletion_hook_for_sandbox(sandbox)("report.pdf", primary, conversion)
|
|
|
|
assert sandbox.files == {}
|
|
assert sandbox.remove_calls == [
|
|
primary_virtual,
|
|
conversion_virtual,
|
|
primary_virtual,
|
|
conversion_virtual,
|
|
]
|
|
|
|
|
|
def test_unconfirmed_remote_delete_is_persisted_and_retried_after_restart(tmp_path):
|
|
primary_virtual = "/mnt/user-data/uploads/report.pdf"
|
|
conversion_virtual = "/mnt/user-data/.upload-conversions/report.pdf.md"
|
|
|
|
class RestartableSandbox:
|
|
def __init__(self) -> None:
|
|
self.files = {
|
|
primary_virtual: b"pdf",
|
|
conversion_virtual: b"generated",
|
|
}
|
|
self.unavailable = True
|
|
|
|
def remove_file(self, path: str) -> None:
|
|
if path == primary_virtual:
|
|
self.files.pop(path, None)
|
|
return
|
|
if self.unavailable:
|
|
self.files.pop(path, None)
|
|
raise OSError("conversion deletion unconfirmed")
|
|
self.files.pop(path, None)
|
|
|
|
def update_file(self, _path: str, _content: bytes) -> None:
|
|
raise OSError("compensation unavailable")
|
|
|
|
class Provider(_ExactProviderMixin):
|
|
def __init__(self, sandbox) -> None:
|
|
self.sandbox = sandbox
|
|
|
|
def get(self, sandbox_id: str):
|
|
assert sandbox_id == "sandbox-1"
|
|
return self.sandbox
|
|
|
|
uploads = tmp_path / "users" / "alice" / "threads" / "thread-1" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / "report.pdf"
|
|
primary.write_bytes(b"pdf")
|
|
conversion = conversion_path_for_upload(primary)
|
|
conversion.parent.mkdir()
|
|
conversion.write_bytes(b"generated")
|
|
sandbox = RestartableSandbox()
|
|
deletion_hook = _deletion_hook_for_sandbox(
|
|
sandbox,
|
|
sandbox_id="sandbox-1",
|
|
thread_id="thread-1",
|
|
user_id="alice",
|
|
provider_key=_sandbox_provider_reconciliation_key(Provider(sandbox)),
|
|
)
|
|
|
|
with pytest.raises(RemoteDeletionCommitRequiredError):
|
|
delete_file_safe(uploads, primary.name, delete_remote_copy=deletion_hook)
|
|
|
|
assert not primary.exists()
|
|
assert not conversion.exists()
|
|
journals = list(conversion.parent.glob(".upload-delete-*.part/.remote-delete.json"))
|
|
assert len(journals) == 1
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 0
|
|
|
|
sandbox.unavailable = False
|
|
provider = Provider(sandbox)
|
|
assert (
|
|
reconcile_pending_remote_deletions(
|
|
sandbox_provider_factory=lambda: provider,
|
|
base_dir=tmp_path,
|
|
)
|
|
== 1
|
|
)
|
|
assert sandbox.files == {}
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 1
|
|
assert not list(conversion.parent.glob(".upload-delete-*.part"))
|
|
|
|
|
|
@pytest.mark.parametrize("unlink_first", [False, True], ids=["unlink", "directory-fsync"])
|
|
def test_remote_delete_finalize_failure_never_restores_deleted_host_generation(tmp_path, unlink_first):
|
|
import deerflow.uploads.sandbox_sync as sandbox_sync_module
|
|
|
|
primary_virtual = upload_virtual_path("report.pdf")
|
|
conversion_virtual = conversion_virtual_path("report.pdf")
|
|
|
|
class Sandbox:
|
|
def __init__(self) -> None:
|
|
self.files = {
|
|
primary_virtual: b"pdf",
|
|
conversion_virtual: b"generated",
|
|
}
|
|
|
|
def remove_file(self, path: str) -> None:
|
|
self.files.pop(path, None)
|
|
|
|
uploads = tmp_path / "users" / "alice" / "threads" / "thread-1" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / "report.pdf"
|
|
primary.write_bytes(b"pdf")
|
|
conversion = conversion_path_for_upload(primary)
|
|
conversion.parent.mkdir()
|
|
conversion.write_bytes(b"generated")
|
|
sandbox = Sandbox()
|
|
deletion_hook = _deletion_hook_for_sandbox(
|
|
sandbox,
|
|
sandbox_id="sandbox-1",
|
|
thread_id="thread-1",
|
|
user_id="alice",
|
|
provider_key="tests.Provider",
|
|
)
|
|
real_unlink = sandbox_sync_module._unlink_journal_durably
|
|
|
|
def fail_finalize(journal_path):
|
|
if unlink_first:
|
|
real_unlink(journal_path)
|
|
raise OSError("remote deletion journal finalize failed")
|
|
|
|
with patch.object(sandbox_sync_module, "_unlink_journal_durably", side_effect=fail_finalize):
|
|
with pytest.raises(OSError, match="journal finalize failed"):
|
|
delete_file_safe(uploads, primary.name, delete_remote_copy=deletion_hook)
|
|
|
|
assert sandbox.files == {}
|
|
assert not primary.exists()
|
|
assert not conversion.exists()
|
|
transactions = list(conversion.parent.glob(".upload-delete-*.part"))
|
|
if unlink_first:
|
|
assert transactions == []
|
|
else:
|
|
assert len(transactions) == 1
|
|
assert (transactions[0] / ".remote-delete.json").is_file()
|
|
assert (transactions[0] / "primary" / primary.name).read_bytes() == b"pdf"
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 0
|
|
|
|
|
|
def test_finalize_guard_removal_failure_retains_host_tombstone_and_basename(tmp_path):
|
|
import deerflow.uploads.sandbox_sync as sandbox_sync_module
|
|
|
|
primary_virtual = upload_virtual_path("report.pdf")
|
|
|
|
class Sandbox:
|
|
def __init__(self) -> None:
|
|
self.files = {primary_virtual: b"pdf"}
|
|
|
|
def remove_file(self, path: str) -> None:
|
|
self.files.pop(path, None)
|
|
|
|
uploads = tmp_path / "users" / "alice" / "threads" / "thread-1" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / "report.pdf"
|
|
primary.write_bytes(b"pdf")
|
|
sandbox = Sandbox()
|
|
deletion_hook = _deletion_hook_for_sandbox(
|
|
sandbox,
|
|
sandbox_id="sandbox-1",
|
|
thread_id="thread-1",
|
|
user_id="alice",
|
|
provider_key="tests.Provider",
|
|
)
|
|
real_unlink_control = sandbox_sync_module._unlink_deletion_control_durably
|
|
|
|
def fail_guard_removal(path):
|
|
if path.name == ".remote-delete.finalizing":
|
|
raise OSError("finalize guard removal failed")
|
|
return real_unlink_control(path)
|
|
|
|
with patch.object(
|
|
sandbox_sync_module,
|
|
"_unlink_deletion_control_durably",
|
|
side_effect=fail_guard_removal,
|
|
):
|
|
with pytest.raises(OSError, match="finalize guard removal failed"):
|
|
delete_file_safe(uploads, primary.name, delete_remote_copy=deletion_hook)
|
|
|
|
transaction = next((uploads.parent / ".upload-conversions").glob(".upload-delete-*.part"))
|
|
assert not (transaction / ".remote-delete.json").exists()
|
|
assert (transaction / ".remote-delete.finalizing").is_file()
|
|
assert (transaction / "primary" / primary.name).read_bytes() == b"pdf"
|
|
assert publish_upload_bytes(uploads, primary.name, b"new").name == "report_1.pdf"
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 1
|
|
|
|
|
|
def test_directory_fsync_failure_restores_journal_and_reserves_old_basename(tmp_path):
|
|
import deerflow.uploads.manager as upload_manager_module
|
|
import deerflow.uploads.sandbox_sync as sandbox_sync_module
|
|
|
|
primary_virtual = upload_virtual_path("report.pdf")
|
|
conversion_virtual = conversion_virtual_path("report.pdf")
|
|
|
|
class Sandbox:
|
|
def __init__(self) -> None:
|
|
self.files = {
|
|
primary_virtual: b"old primary",
|
|
conversion_virtual: b"old conversion",
|
|
}
|
|
|
|
def remove_file(self, path: str) -> None:
|
|
self.files.pop(path, None)
|
|
|
|
class Provider(_ExactProviderMixin):
|
|
def __init__(self, sandbox) -> None:
|
|
self.sandbox = sandbox
|
|
|
|
def get(self, sandbox_id: str):
|
|
assert sandbox_id == "sandbox-1"
|
|
return self.sandbox
|
|
|
|
uploads = tmp_path / "users" / "alice" / "threads" / "thread-1" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / "report.pdf"
|
|
primary.write_bytes(b"old primary")
|
|
conversion = conversion_path_for_upload(primary)
|
|
conversion.parent.mkdir()
|
|
conversion.write_bytes(b"old conversion")
|
|
identity = upload_manager_module.UploadIdentity.from_path(primary)
|
|
staged_path, stage_lease = upload_manager_module._stage_primary_deletion(
|
|
uploads,
|
|
primary,
|
|
identity,
|
|
recover_on_crash=True,
|
|
conversion_path=conversion,
|
|
)
|
|
sandbox = Sandbox()
|
|
provider = Provider(sandbox)
|
|
hook = _deletion_hook_for_sandbox(
|
|
sandbox,
|
|
sandbox_id="sandbox-1",
|
|
thread_id="thread-1",
|
|
user_id="alice",
|
|
provider_key=_sandbox_provider_reconciliation_key(provider),
|
|
)
|
|
hook.prepare(
|
|
primary.name,
|
|
staged_path,
|
|
upload_manager_module._staged_conversion_path(staged_path),
|
|
)
|
|
upload_manager_module._mark_staged_deletion_committed(staged_path)
|
|
stage_lease.release()
|
|
journal_path = next(conversion.parent.glob(".upload-delete-*.part/.remote-delete.json"))
|
|
journal_payload = journal_path.read_bytes()
|
|
real_fsync = sandbox_sync_module.os.fsync
|
|
fsync_calls = 0
|
|
|
|
def fail_journal_unlink_directory_fsync(descriptor):
|
|
nonlocal fsync_calls
|
|
fsync_calls += 1
|
|
if fsync_calls == 3:
|
|
raise OSError("directory fsync failed")
|
|
return real_fsync(descriptor)
|
|
|
|
with patch.object(
|
|
sandbox_sync_module.os,
|
|
"fsync",
|
|
side_effect=fail_journal_unlink_directory_fsync,
|
|
):
|
|
with pytest.raises(OSError, match="directory fsync failed"):
|
|
sandbox_sync_module._unlink_journal_durably(journal_path)
|
|
|
|
assert journal_path.read_bytes() == journal_payload
|
|
finalize_guard = journal_path.with_name(".remote-delete.finalizing")
|
|
assert finalize_guard.read_bytes() == journal_payload
|
|
replacement = publish_upload_bytes(uploads, "report.pdf", b"new primary")
|
|
assert replacement.name == "report_1.pdf"
|
|
|
|
assert (
|
|
reconcile_pending_remote_deletions(
|
|
sandbox_provider_factory=lambda: provider,
|
|
base_dir=tmp_path,
|
|
)
|
|
== 1
|
|
)
|
|
assert sandbox.files == {}
|
|
assert replacement.read_bytes() == b"new primary"
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 1
|
|
assert not finalize_guard.exists()
|
|
|
|
|
|
def test_failed_journal_restore_keeps_cross_process_name_reservation(tmp_path):
|
|
import deerflow.uploads.manager as upload_manager_module
|
|
import deerflow.uploads.sandbox_sync as sandbox_sync_module
|
|
|
|
uploads = tmp_path / "users" / "alice" / "threads" / "thread-1" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / "report.pdf"
|
|
primary.write_bytes(b"old primary")
|
|
conversion = conversion_path_for_upload(primary)
|
|
conversion.parent.mkdir()
|
|
conversion.write_bytes(b"old conversion")
|
|
identity = upload_manager_module.UploadIdentity.from_path(primary)
|
|
staged_path, stage_lease = upload_manager_module._stage_primary_deletion(
|
|
uploads,
|
|
primary,
|
|
identity,
|
|
recover_on_crash=True,
|
|
conversion_path=conversion,
|
|
)
|
|
hook = _deletion_hook_for_sandbox(
|
|
object(),
|
|
sandbox_id="sandbox-1",
|
|
thread_id="thread-1",
|
|
user_id="alice",
|
|
provider_key="tests.Provider",
|
|
)
|
|
hook.prepare(
|
|
primary.name,
|
|
staged_path,
|
|
upload_manager_module._staged_conversion_path(staged_path),
|
|
)
|
|
upload_manager_module._mark_staged_deletion_committed(staged_path)
|
|
stage_lease.release()
|
|
journal_path = next(conversion.parent.glob(".upload-delete-*.part/.remote-delete.json"))
|
|
real_open = sandbox_sync_module.os.open
|
|
real_fsync = sandbox_sync_module.os.fsync
|
|
fsync_calls = 0
|
|
|
|
def fail_directory_fsync(descriptor):
|
|
nonlocal fsync_calls
|
|
fsync_calls += 1
|
|
# The finalize guard's file and parent directory are persisted first.
|
|
# Fail the journal-unlink directory fsync that follows them.
|
|
if fsync_calls == 3:
|
|
raise OSError("directory fsync failed")
|
|
return real_fsync(descriptor)
|
|
|
|
def fail_journal_recreation(path, flags, mode=0o600):
|
|
if os.path.abspath(path) == os.path.abspath(journal_path) and flags & os.O_CREAT:
|
|
raise OSError("journal recreation failed")
|
|
return real_open(path, flags, mode)
|
|
|
|
with (
|
|
patch.object(sandbox_sync_module.os, "fsync", side_effect=fail_directory_fsync),
|
|
patch.object(sandbox_sync_module.os, "open", side_effect=fail_journal_recreation),
|
|
):
|
|
with pytest.raises(OSError, match="directory fsync failed"):
|
|
sandbox_sync_module._unlink_journal_durably(journal_path)
|
|
|
|
finalize_guard = journal_path.with_name(".remote-delete.finalizing")
|
|
assert not journal_path.exists()
|
|
assert finalize_guard.is_file()
|
|
|
|
context = multiprocessing.get_context("spawn")
|
|
outcomes = context.Queue()
|
|
worker = context.Process(
|
|
target=_publish_upload_in_process,
|
|
args=(str(uploads), "report.pdf", b"new primary", outcomes),
|
|
)
|
|
worker.start()
|
|
try:
|
|
outcome = outcomes.get(timeout=10)
|
|
worker.join(10)
|
|
finally:
|
|
if worker.is_alive():
|
|
worker.terminate()
|
|
worker.join(2)
|
|
|
|
assert worker.exitcode == 0
|
|
assert outcome == "report_1.pdf"
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 1
|
|
assert not finalize_guard.exists()
|
|
assert (uploads / "report_1.pdf").read_bytes() == b"new primary"
|
|
|
|
|
|
@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"])
|
|
def test_subsequent_deletion_preparation_cleans_real_guard_only_transaction(tmp_path, use_async):
|
|
import deerflow.uploads.manager as upload_manager_module
|
|
import deerflow.uploads.sandbox_sync as sandbox_sync_module
|
|
|
|
uploads = tmp_path / "users" / "alice" / "threads" / "thread-1" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / "report.pdf"
|
|
primary.write_bytes(b"old primary")
|
|
identity = upload_manager_module.UploadIdentity.from_path(primary)
|
|
staged_path, stage_lease = upload_manager_module._stage_primary_deletion(
|
|
uploads,
|
|
primary,
|
|
identity,
|
|
recover_on_crash=True,
|
|
)
|
|
upload_manager_module._mark_staged_deletion_committed(staged_path)
|
|
transaction_dir = staged_path.parent.parent
|
|
finalize_guard = transaction_dir / upload_manager_module._UPLOAD_DELETION_FINALIZE_GUARD
|
|
finalize_guard.write_text("{}", encoding="utf-8")
|
|
stage_lease.release()
|
|
|
|
class MountedProvider:
|
|
uses_thread_data_mounts = True
|
|
|
|
paths = SimpleNamespace(base_dir=tmp_path)
|
|
with (
|
|
patch.object(upload_manager_module, "get_paths", return_value=paths),
|
|
patch.object(sandbox_sync_module, "get_paths", return_value=paths),
|
|
):
|
|
if use_async:
|
|
assert asyncio.run(prepare_upload_deletion_async(MountedProvider(), "thread-1", user_id="alice")) is None
|
|
else:
|
|
assert prepare_upload_deletion(MountedProvider(), "thread-1", user_id="alice") is None
|
|
|
|
assert not transaction_dir.exists()
|
|
assert not primary.exists()
|
|
|
|
|
|
@pytest.mark.parametrize("unlink_first", [False, True], ids=["unlink", "directory-fsync"])
|
|
def test_compensated_remote_delete_restores_host_even_when_journal_abort_fails(tmp_path, unlink_first):
|
|
import deerflow.uploads.sandbox_sync as sandbox_sync_module
|
|
|
|
primary_virtual = upload_virtual_path("report.pdf")
|
|
conversion_virtual = conversion_virtual_path("report.pdf")
|
|
|
|
class Sandbox:
|
|
def __init__(self) -> None:
|
|
self.files = {
|
|
primary_virtual: b"pdf",
|
|
conversion_virtual: b"generated",
|
|
}
|
|
self.failed = False
|
|
|
|
def remove_file(self, path: str) -> None:
|
|
self.files.pop(path, None)
|
|
if path == conversion_virtual and not self.failed:
|
|
self.failed = True
|
|
raise OSError("conversion deletion failed")
|
|
|
|
def update_file(self, path: str, content: bytes) -> None:
|
|
self.files[path] = content
|
|
|
|
uploads = tmp_path / "users" / "alice" / "threads" / "thread-1" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / "report.pdf"
|
|
primary.write_bytes(b"pdf")
|
|
conversion = conversion_path_for_upload(primary)
|
|
conversion.parent.mkdir()
|
|
conversion.write_bytes(b"generated")
|
|
sandbox = Sandbox()
|
|
deletion_hook = _deletion_hook_for_sandbox(
|
|
sandbox,
|
|
sandbox_id="sandbox-1",
|
|
thread_id="thread-1",
|
|
user_id="alice",
|
|
provider_key="tests.Provider",
|
|
)
|
|
real_unlink = sandbox_sync_module._unlink_journal_durably
|
|
|
|
def fail_abort(journal_path):
|
|
if unlink_first:
|
|
real_unlink(journal_path)
|
|
raise OSError("remote deletion journal abort failed")
|
|
|
|
with patch.object(sandbox_sync_module, "_unlink_journal_durably", side_effect=fail_abort):
|
|
with pytest.raises(OSError, match="journal abort failed"):
|
|
delete_file_safe(uploads, primary.name, delete_remote_copy=deletion_hook)
|
|
|
|
assert sandbox.files == {
|
|
primary_virtual: b"pdf",
|
|
conversion_virtual: b"generated",
|
|
}
|
|
assert primary.read_bytes() == b"pdf"
|
|
assert conversion.read_bytes() == b"generated"
|
|
|
|
|
|
def test_pending_remote_deletion_reserves_name_until_exact_remote_generation_is_gone(tmp_path):
|
|
old_primary_virtual = upload_virtual_path("report.pdf")
|
|
old_conversion_virtual = conversion_virtual_path("report.pdf")
|
|
|
|
class RestartableSandbox:
|
|
def __init__(self) -> None:
|
|
self.files = {
|
|
old_primary_virtual: b"old pdf",
|
|
old_conversion_virtual: b"old generated",
|
|
}
|
|
self.unavailable = True
|
|
|
|
def remove_file(self, path: str) -> None:
|
|
self.files.pop(path, None)
|
|
if self.unavailable and path == old_conversion_virtual:
|
|
raise OSError("old conversion deletion unconfirmed")
|
|
|
|
def update_file(self, _path: str, _content: bytes) -> None:
|
|
raise OSError("compensation unavailable")
|
|
|
|
class Provider(_ExactProviderMixin):
|
|
uses_thread_data_mounts = False
|
|
|
|
def __init__(self, sandbox) -> None:
|
|
self.sandbox = sandbox
|
|
|
|
def get(self, sandbox_id: str):
|
|
assert sandbox_id == "sandbox-1"
|
|
return self.sandbox
|
|
|
|
uploads = tmp_path / "users" / "alice" / "threads" / "thread-1" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / "report.pdf"
|
|
primary.write_bytes(b"old pdf")
|
|
conversion = conversion_path_for_upload(primary)
|
|
conversion.parent.mkdir()
|
|
conversion.write_bytes(b"old generated")
|
|
sandbox = RestartableSandbox()
|
|
deletion_hook = _deletion_hook_for_sandbox(
|
|
sandbox,
|
|
sandbox_id="sandbox-1",
|
|
thread_id="thread-1",
|
|
user_id="alice",
|
|
provider_key=_sandbox_provider_reconciliation_key(Provider(sandbox)),
|
|
)
|
|
|
|
with pytest.raises(RemoteDeletionCommitRequiredError):
|
|
delete_file_safe(uploads, primary.name, delete_remote_copy=deletion_hook)
|
|
|
|
replacement = publish_upload_bytes(uploads, "report.pdf", b"new pdf")
|
|
assert replacement.name == "report_1.pdf"
|
|
new_primary_virtual = upload_virtual_path(replacement.name)
|
|
new_conversion_virtual = conversion_virtual_path(replacement.name)
|
|
sandbox.files[new_primary_virtual] = b"new pdf"
|
|
sandbox.files[new_conversion_virtual] = b"new generated"
|
|
|
|
sandbox.unavailable = False
|
|
assert (
|
|
reconcile_pending_remote_deletions(
|
|
sandbox_provider_factory=lambda: Provider(sandbox),
|
|
base_dir=tmp_path,
|
|
)
|
|
== 1
|
|
)
|
|
assert sandbox.files == {
|
|
new_primary_virtual: b"new pdf",
|
|
new_conversion_virtual: b"new generated",
|
|
}
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 1
|
|
assert not list(conversion.parent.glob(".upload-delete-*.part"))
|
|
|
|
|
|
def test_reconciliation_never_redirects_old_journal_to_new_sandbox(tmp_path):
|
|
primary_virtual = upload_virtual_path("report.pdf")
|
|
conversion_virtual = conversion_virtual_path("report.pdf")
|
|
|
|
class OldSandbox:
|
|
def remove_file(self, path: str) -> None:
|
|
if path == conversion_virtual:
|
|
raise OSError("old sandbox unavailable")
|
|
|
|
def update_file(self, _path: str, _content: bytes) -> None:
|
|
raise OSError("old sandbox compensation unavailable")
|
|
|
|
class NewSandbox:
|
|
def __init__(self) -> None:
|
|
self.files = {
|
|
primary_virtual: b"new primary",
|
|
conversion_virtual: b"new conversion",
|
|
}
|
|
|
|
def remove_file(self, path: str) -> None:
|
|
self.files.pop(path, None)
|
|
|
|
class RollingProvider(_ExactProviderMixin):
|
|
uses_thread_data_mounts = False
|
|
|
|
def __init__(self, new_sandbox) -> None:
|
|
self.new_sandbox = new_sandbox
|
|
|
|
def get(self, sandbox_id: str):
|
|
if sandbox_id == "sandbox-old":
|
|
return None
|
|
assert sandbox_id == "sandbox-new"
|
|
return self.new_sandbox
|
|
|
|
def acquire(self, thread_id: str, *, user_id: str | None = None) -> str:
|
|
assert thread_id == "thread-1"
|
|
assert user_id == "alice"
|
|
return "sandbox-new"
|
|
|
|
uploads = tmp_path / "users" / "alice" / "threads" / "thread-1" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / "report.pdf"
|
|
primary.write_bytes(b"old primary")
|
|
conversion = conversion_path_for_upload(primary)
|
|
conversion.parent.mkdir()
|
|
conversion.write_bytes(b"old conversion")
|
|
provider = RollingProvider(new_sandbox=None)
|
|
deletion_hook = _deletion_hook_for_sandbox(
|
|
OldSandbox(),
|
|
sandbox_id="sandbox-old",
|
|
thread_id="thread-1",
|
|
user_id="alice",
|
|
provider_key=_sandbox_provider_reconciliation_key(provider),
|
|
)
|
|
with pytest.raises(RemoteDeletionCommitRequiredError):
|
|
delete_file_safe(uploads, primary.name, delete_remote_copy=deletion_hook)
|
|
|
|
new_sandbox = NewSandbox()
|
|
provider.new_sandbox = new_sandbox
|
|
assert (
|
|
reconcile_pending_remote_deletions(
|
|
sandbox_provider_factory=lambda: provider,
|
|
base_dir=tmp_path,
|
|
)
|
|
== 0
|
|
)
|
|
assert new_sandbox.files == {
|
|
primary_virtual: b"new primary",
|
|
conversion_virtual: b"new conversion",
|
|
}
|
|
assert len(list(conversion.parent.glob(".upload-delete-*.part/.remote-delete.json"))) == 1
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 0
|
|
|
|
|
|
def test_reconciliation_never_crosses_provider_boundary_when_raw_id_matches(tmp_path):
|
|
primary_virtual = upload_virtual_path("report.pdf")
|
|
conversion_virtual = conversion_virtual_path("report.pdf")
|
|
|
|
class OldProvider:
|
|
pass
|
|
|
|
class OldSandbox:
|
|
def remove_file(self, path: str) -> None:
|
|
if path == conversion_virtual:
|
|
raise OSError("old provider unavailable")
|
|
|
|
def update_file(self, _path: str, _content: bytes) -> None:
|
|
raise OSError("old provider compensation unavailable")
|
|
|
|
class NewSandbox:
|
|
def __init__(self) -> None:
|
|
self.files = {
|
|
primary_virtual: b"new primary",
|
|
conversion_virtual: b"new conversion",
|
|
}
|
|
|
|
def remove_file(self, path: str) -> None:
|
|
self.files.pop(path, None)
|
|
|
|
class NewProvider:
|
|
uses_thread_data_mounts = False
|
|
|
|
def __init__(self, sandbox) -> None:
|
|
self.sandbox = sandbox
|
|
|
|
def get(self, sandbox_id: str):
|
|
assert sandbox_id == "same-id"
|
|
return None
|
|
|
|
def acquire(self, thread_id: str, *, user_id: str | None = None) -> str:
|
|
assert (thread_id, user_id) == ("thread-1", "alice")
|
|
return "same-id"
|
|
|
|
uploads = tmp_path / "users" / "alice" / "threads" / "thread-1" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / "report.pdf"
|
|
primary.write_bytes(b"old primary")
|
|
conversion = conversion_path_for_upload(primary)
|
|
conversion.parent.mkdir()
|
|
conversion.write_bytes(b"old conversion")
|
|
deletion_hook = _deletion_hook_for_sandbox(
|
|
OldSandbox(),
|
|
sandbox_id="same-id",
|
|
thread_id="thread-1",
|
|
user_id="alice",
|
|
provider_key=_sandbox_provider_reconciliation_key(OldProvider()),
|
|
)
|
|
with pytest.raises(RemoteDeletionCommitRequiredError):
|
|
delete_file_safe(uploads, primary.name, delete_remote_copy=deletion_hook)
|
|
|
|
new_sandbox = NewSandbox()
|
|
assert (
|
|
reconcile_pending_remote_deletions(
|
|
sandbox_provider_factory=lambda: NewProvider(new_sandbox),
|
|
base_dir=tmp_path,
|
|
)
|
|
== 0
|
|
)
|
|
assert new_sandbox.files == {
|
|
primary_virtual: b"new primary",
|
|
conversion_virtual: b"new conversion",
|
|
}
|
|
assert len(list(conversion.parent.glob(".upload-delete-*.part/.remote-delete.json"))) == 1
|
|
|
|
|
|
def test_reconciliation_finalizes_when_provider_confirms_exact_sandbox_absent(tmp_path):
|
|
conversion_virtual = conversion_virtual_path("report.pdf")
|
|
|
|
class Provider(_ExactProviderMixin):
|
|
def reconnect_sandbox_for_reconciliation(
|
|
self,
|
|
sandbox_id: str,
|
|
*,
|
|
thread_id: str,
|
|
user_id: str | None,
|
|
identity,
|
|
):
|
|
assert identity.backend_namespace == "tests.backend"
|
|
assert identity.incarnation_id == "tests.incarnation"
|
|
assert (sandbox_id, thread_id, user_id) == ("sandbox-old", "thread-1", "alice")
|
|
from deerflow.sandbox.sandbox_provider import SandboxReconciliationResult
|
|
|
|
return SandboxReconciliationResult.absent()
|
|
|
|
class OldSandbox:
|
|
def remove_file(self, path: str) -> None:
|
|
if path == conversion_virtual:
|
|
raise OSError("old sandbox unavailable")
|
|
|
|
def update_file(self, _path: str, _content: bytes) -> None:
|
|
raise OSError("old sandbox compensation unavailable")
|
|
|
|
provider = Provider()
|
|
uploads = tmp_path / "users" / "alice" / "threads" / "thread-1" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / "report.pdf"
|
|
primary.write_bytes(b"old primary")
|
|
conversion = conversion_path_for_upload(primary)
|
|
conversion.parent.mkdir()
|
|
conversion.write_bytes(b"old conversion")
|
|
deletion_hook = _deletion_hook_for_sandbox(
|
|
OldSandbox(),
|
|
sandbox_id="sandbox-old",
|
|
thread_id="thread-1",
|
|
user_id="alice",
|
|
provider_key=_sandbox_provider_reconciliation_key(provider),
|
|
)
|
|
with pytest.raises(RemoteDeletionCommitRequiredError):
|
|
delete_file_safe(uploads, primary.name, delete_remote_copy=deletion_hook)
|
|
|
|
assert (
|
|
reconcile_pending_remote_deletions(
|
|
sandbox_provider_factory=lambda: provider,
|
|
base_dir=tmp_path,
|
|
)
|
|
== 1
|
|
)
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 1
|
|
assert not list(conversion.parent.glob(".upload-delete-*.part"))
|
|
|
|
|
|
def test_reconciliation_rejects_journal_that_does_not_match_retained_generation(tmp_path):
|
|
report_virtual = upload_virtual_path("report.pdf")
|
|
report_conversion_virtual = conversion_virtual_path("report.pdf")
|
|
other_virtual = upload_virtual_path("other.pdf")
|
|
other_conversion_virtual = conversion_virtual_path("other.pdf")
|
|
|
|
class Sandbox:
|
|
def __init__(self) -> None:
|
|
self.files = {
|
|
report_virtual: b"report",
|
|
report_conversion_virtual: b"report conversion",
|
|
other_virtual: b"other",
|
|
other_conversion_virtual: b"other conversion",
|
|
}
|
|
self.unavailable = True
|
|
|
|
def remove_file(self, path: str) -> None:
|
|
if self.unavailable and path == report_conversion_virtual:
|
|
raise OSError("report conversion deletion unconfirmed")
|
|
self.files.pop(path, None)
|
|
|
|
def update_file(self, _path: str, _content: bytes) -> None:
|
|
raise OSError("compensation unavailable")
|
|
|
|
class Provider(_ExactProviderMixin):
|
|
uses_thread_data_mounts = False
|
|
|
|
def __init__(self, sandbox) -> None:
|
|
self.sandbox = sandbox
|
|
|
|
def get(self, sandbox_id: str):
|
|
assert sandbox_id == "sandbox-1"
|
|
return self.sandbox
|
|
|
|
uploads = tmp_path / "users" / "alice" / "threads" / "thread-1" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / "report.pdf"
|
|
primary.write_bytes(b"report")
|
|
conversion = conversion_path_for_upload(primary)
|
|
conversion.parent.mkdir()
|
|
conversion.write_bytes(b"report conversion")
|
|
sandbox = Sandbox()
|
|
deletion_hook = _deletion_hook_for_sandbox(
|
|
sandbox,
|
|
sandbox_id="sandbox-1",
|
|
thread_id="thread-1",
|
|
user_id="alice",
|
|
provider_key=_sandbox_provider_reconciliation_key(Provider(sandbox)),
|
|
)
|
|
with pytest.raises(RemoteDeletionCommitRequiredError):
|
|
delete_file_safe(uploads, primary.name, delete_remote_copy=deletion_hook)
|
|
|
|
journal_path = next(conversion.parent.glob(".upload-delete-*.part/.remote-delete.json"))
|
|
journal = json.loads(journal_path.read_text(encoding="utf-8"))
|
|
journal["filename"] = "other.pdf"
|
|
journal["virtual_paths"] = [other_virtual, other_conversion_virtual]
|
|
journal_path.write_text(json.dumps(journal), encoding="utf-8")
|
|
sandbox.unavailable = False
|
|
|
|
assert (
|
|
reconcile_pending_remote_deletions(
|
|
sandbox_provider_factory=lambda: Provider(sandbox),
|
|
base_dir=tmp_path,
|
|
)
|
|
== 0
|
|
)
|
|
assert sandbox.files[other_virtual] == b"other"
|
|
assert sandbox.files[other_conversion_virtual] == b"other conversion"
|
|
assert journal_path.is_file()
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX legacy filenames are not representable on Windows")
|
|
@pytest.mark.parametrize("filename", ["CON", "report?.pdf", "trailing ", r"report\draft.pdf"])
|
|
def test_reconciliation_accepts_exact_legacy_posix_filename(tmp_path, filename):
|
|
primary_virtual = upload_virtual_path(filename)
|
|
conversion_virtual = conversion_virtual_path(filename)
|
|
|
|
class RestartableSandbox:
|
|
def __init__(self) -> None:
|
|
self.files = {
|
|
primary_virtual: b"legacy primary",
|
|
conversion_virtual: b"legacy conversion",
|
|
}
|
|
self.unavailable = True
|
|
|
|
def remove_file(self, path: str) -> None:
|
|
self.files.pop(path, None)
|
|
if self.unavailable and path == conversion_virtual:
|
|
raise OSError("legacy conversion deletion unconfirmed")
|
|
|
|
def update_file(self, _path: str, _content: bytes) -> None:
|
|
raise OSError("legacy compensation unavailable")
|
|
|
|
class Provider(_ExactProviderMixin):
|
|
uses_thread_data_mounts = False
|
|
|
|
def __init__(self, sandbox) -> None:
|
|
self.sandbox = sandbox
|
|
|
|
def get(self, sandbox_id: str):
|
|
assert sandbox_id == "sandbox-1"
|
|
return self.sandbox
|
|
|
|
uploads = tmp_path / "users" / "alice" / "threads" / "thread-1" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / filename
|
|
primary.write_bytes(b"legacy primary")
|
|
conversion = conversion_path_for_upload(primary)
|
|
conversion.parent.mkdir()
|
|
conversion.write_bytes(b"legacy conversion")
|
|
sandbox = RestartableSandbox()
|
|
deletion_hook = _deletion_hook_for_sandbox(
|
|
sandbox,
|
|
sandbox_id="sandbox-1",
|
|
thread_id="thread-1",
|
|
user_id="alice",
|
|
provider_key=_sandbox_provider_reconciliation_key(Provider(sandbox)),
|
|
)
|
|
|
|
with pytest.raises(RemoteDeletionCommitRequiredError):
|
|
delete_file_safe(uploads, filename, delete_remote_copy=deletion_hook)
|
|
|
|
sandbox.unavailable = False
|
|
assert (
|
|
reconcile_pending_remote_deletions(
|
|
sandbox_provider_factory=lambda: Provider(sandbox),
|
|
base_dir=tmp_path,
|
|
)
|
|
== 1
|
|
)
|
|
assert sandbox.files == {}
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 1
|
|
|
|
|
|
def test_prepared_remote_journal_without_commit_marker_restores_host(tmp_path):
|
|
import deerflow.uploads.manager as upload_manager_module
|
|
|
|
uploads = tmp_path / "users" / "alice" / "threads" / "thread-1" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / "report.pdf"
|
|
primary.write_bytes(b"pdf")
|
|
conversion = conversion_path_for_upload(primary)
|
|
conversion.parent.mkdir()
|
|
conversion.write_bytes(b"generated")
|
|
identity = upload_manager_module.UploadIdentity.from_path(primary)
|
|
staged_path, stage_lease = upload_manager_module._stage_primary_deletion(
|
|
uploads,
|
|
primary,
|
|
identity,
|
|
recover_on_crash=True,
|
|
conversion_path=conversion,
|
|
)
|
|
hook = _deletion_hook_for_sandbox(
|
|
object(),
|
|
sandbox_id="sandbox-1",
|
|
thread_id="thread-1",
|
|
user_id="alice",
|
|
provider_key="tests.Provider",
|
|
)
|
|
hook.prepare(primary.name, staged_path, upload_manager_module._staged_conversion_path(staged_path))
|
|
|
|
assert (
|
|
reconcile_pending_remote_deletions(
|
|
sandbox_provider_factory=lambda: (_ for _ in ()).throw(AssertionError("live journal must be skipped")),
|
|
base_dir=tmp_path,
|
|
)
|
|
== 0
|
|
)
|
|
stage_lease.release()
|
|
assert (
|
|
reconcile_pending_remote_deletions(
|
|
sandbox_provider_factory=lambda: (_ for _ in ()).throw(AssertionError("uncommitted journal must be skipped")),
|
|
base_dir=tmp_path,
|
|
)
|
|
== 0
|
|
)
|
|
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 1
|
|
assert primary.read_bytes() == b"pdf"
|
|
assert conversion.read_bytes() == b"generated"
|
|
assert not list(conversion.parent.glob(".upload-delete-*.part"))
|
|
|
|
|
|
def test_reconciliation_finishes_crash_after_first_remote_delete(tmp_path):
|
|
import deerflow.uploads.manager as upload_manager_module
|
|
|
|
primary_virtual = upload_virtual_path("report.pdf")
|
|
conversion_virtual = conversion_virtual_path("report.pdf")
|
|
|
|
class Sandbox:
|
|
def __init__(self) -> None:
|
|
self.files = {
|
|
primary_virtual: b"pdf",
|
|
conversion_virtual: b"generated",
|
|
}
|
|
|
|
def remove_file(self, path: str) -> None:
|
|
self.files.pop(path, None)
|
|
|
|
class Provider(_ExactProviderMixin):
|
|
uses_thread_data_mounts = False
|
|
|
|
def __init__(self, sandbox) -> None:
|
|
self.sandbox = sandbox
|
|
|
|
def get(self, sandbox_id: str):
|
|
assert sandbox_id == "sandbox-1"
|
|
return self.sandbox
|
|
|
|
uploads = tmp_path / "users" / "alice" / "threads" / "thread-1" / "user-data" / "uploads"
|
|
uploads.mkdir(parents=True)
|
|
primary = uploads / "report.pdf"
|
|
primary.write_bytes(b"pdf")
|
|
conversion = conversion_path_for_upload(primary)
|
|
conversion.parent.mkdir()
|
|
conversion.write_bytes(b"generated")
|
|
identity = upload_manager_module.UploadIdentity.from_path(primary)
|
|
staged_path, stage_lease = upload_manager_module._stage_primary_deletion(
|
|
uploads,
|
|
primary,
|
|
identity,
|
|
recover_on_crash=True,
|
|
conversion_path=conversion,
|
|
)
|
|
sandbox = Sandbox()
|
|
hook = _deletion_hook_for_sandbox(
|
|
sandbox,
|
|
sandbox_id="sandbox-1",
|
|
thread_id="thread-1",
|
|
user_id="alice",
|
|
provider_key=_sandbox_provider_reconciliation_key(Provider(sandbox)),
|
|
)
|
|
hook.prepare(
|
|
primary.name,
|
|
staged_path,
|
|
upload_manager_module._staged_conversion_path(staged_path),
|
|
)
|
|
upload_manager_module._mark_staged_deletion_committed(staged_path)
|
|
sandbox.remove_file(primary_virtual)
|
|
stage_lease.release()
|
|
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 0
|
|
assert (
|
|
reconcile_pending_remote_deletions(
|
|
sandbox_provider_factory=lambda: Provider(sandbox),
|
|
base_dir=tmp_path,
|
|
)
|
|
== 1
|
|
)
|
|
assert sandbox.files == {}
|
|
assert cleanup_stale_upload_staging_files(tmp_path) == 1
|
|
assert not list(conversion.parent.glob(".upload-delete-*.part"))
|
|
|
|
|
|
class _ProviderThatBecomesMounted:
|
|
def __init__(self) -> None:
|
|
self.uses_thread_data_mounts = False
|
|
self.get_calls = 0
|
|
|
|
def refresh_thread_data_mount_capabilities(self) -> bool:
|
|
return False
|
|
|
|
def acquire(self, _thread_id: str, *, user_id: str | None = None) -> str:
|
|
self.uses_thread_data_mounts = True
|
|
return "mounted-sandbox"
|
|
|
|
async def acquire_async(self, _thread_id: str, *, user_id: str | None = None) -> str:
|
|
self.uses_thread_data_mounts = True
|
|
return "mounted-sandbox"
|
|
|
|
def get(self, _sandbox_id: str):
|
|
self.get_calls += 1
|
|
raise AssertionError("mounted mode must not explicitly synchronize files")
|
|
|
|
|
|
def test_prepare_deletion_rechecks_mount_mode_after_acquire():
|
|
provider = _ProviderThatBecomesMounted()
|
|
|
|
assert prepare_upload_deletion(provider, "thread-1", user_id="alice") is None
|
|
assert provider.get_calls == 0
|
|
|
|
|
|
def test_prepare_deletion_fails_before_mutation_without_restart_safe_identity():
|
|
class Sandbox:
|
|
def __init__(self) -> None:
|
|
self.remove_calls: list[str] = []
|
|
self.update_calls: list[str] = []
|
|
|
|
def remove_file(self, path: str) -> None:
|
|
self.remove_calls.append(path)
|
|
|
|
def update_file(self, path: str, _content: bytes) -> None:
|
|
self.update_calls.append(path)
|
|
|
|
class Provider:
|
|
uses_thread_data_mounts = False
|
|
|
|
def __init__(self) -> None:
|
|
self.sandbox = Sandbox()
|
|
|
|
def refresh_thread_data_mount_capabilities(self) -> bool:
|
|
return False
|
|
|
|
def acquire(self, thread_id: str, *, user_id: str | None = None) -> str:
|
|
assert (thread_id, user_id) == ("thread-1", "alice")
|
|
return "sandbox-1"
|
|
|
|
def get(self, sandbox_id: str):
|
|
assert sandbox_id == "sandbox-1"
|
|
return self.sandbox
|
|
|
|
provider = Provider()
|
|
|
|
with pytest.raises(RuntimeError, match="restart-safe backend and incarnation identity"):
|
|
prepare_upload_deletion(provider, "thread-1", user_id="alice")
|
|
|
|
assert provider.sandbox.remove_calls == []
|
|
assert provider.sandbox.update_calls == []
|
|
|
|
|
|
def test_prepare_deletion_cleans_guard_only_transactions_after_reconciliation():
|
|
import deerflow.uploads.sandbox_sync as sandbox_sync_module
|
|
|
|
provider = _ProviderThatBecomesMounted()
|
|
provider.uses_thread_data_mounts = True
|
|
events: list[str] = []
|
|
|
|
with (
|
|
patch.object(
|
|
sandbox_sync_module,
|
|
"reconcile_pending_remote_deletions",
|
|
side_effect=lambda **_kwargs: events.append("reconcile") or 0,
|
|
),
|
|
patch.object(
|
|
sandbox_sync_module,
|
|
"cleanup_stale_upload_staging_files",
|
|
side_effect=lambda: events.append("cleanup") or 1,
|
|
),
|
|
):
|
|
assert prepare_upload_deletion(provider, "thread-1", user_id="alice") is None
|
|
|
|
assert events == ["reconcile", "cleanup"]
|
|
|
|
|
|
def test_prepare_deletion_async_rechecks_mount_mode_after_acquire():
|
|
provider = _ProviderThatBecomesMounted()
|
|
|
|
assert asyncio.run(prepare_upload_deletion_async(provider, "thread-1", user_id="alice")) is None
|
|
assert provider.get_calls == 0
|
|
|
|
|
|
def test_prepare_deletion_async_cleans_guard_only_transactions_after_reconciliation():
|
|
import deerflow.uploads.sandbox_sync as sandbox_sync_module
|
|
|
|
provider = _ProviderThatBecomesMounted()
|
|
provider.uses_thread_data_mounts = True
|
|
events: list[str] = []
|
|
|
|
with (
|
|
patch.object(
|
|
sandbox_sync_module,
|
|
"reconcile_pending_remote_deletions",
|
|
side_effect=lambda **_kwargs: events.append("reconcile") or 0,
|
|
),
|
|
patch.object(
|
|
sandbox_sync_module,
|
|
"cleanup_stale_upload_staging_files",
|
|
side_effect=lambda: events.append("cleanup") or 1,
|
|
),
|
|
):
|
|
assert asyncio.run(prepare_upload_deletion_async(provider, "thread-1", user_id="alice")) is None
|
|
|
|
assert events == ["reconcile", "cleanup"]
|
|
|
|
|
|
def test_make_paths_available_rechecks_mount_mode_after_acquire(tmp_path):
|
|
provider = _ProviderThatBecomesMounted()
|
|
path = tmp_path / "notes.txt"
|
|
path.write_text("notes", encoding="utf-8")
|
|
path.chmod(0o600)
|
|
|
|
receipt = make_upload_paths_available(
|
|
provider,
|
|
"thread-1",
|
|
user_id="alice",
|
|
paths=[(path, "/mnt/user-data/uploads/notes.txt")],
|
|
)
|
|
|
|
assert receipt.sandbox is None
|
|
assert stat.S_IMODE(path.stat().st_mode) == 0o644
|
|
assert provider.get_calls == 0
|
|
|
|
|
|
def test_make_paths_available_async_rechecks_mount_mode_after_acquire(tmp_path):
|
|
provider = _ProviderThatBecomesMounted()
|
|
path = tmp_path / "notes.txt"
|
|
path.write_text("notes", encoding="utf-8")
|
|
path.chmod(0o600)
|
|
|
|
receipt = asyncio.run(
|
|
make_upload_paths_available_async(
|
|
provider,
|
|
"thread-1",
|
|
user_id="alice",
|
|
paths=[(path, "/mnt/user-data/uploads/notes.txt")],
|
|
)
|
|
)
|
|
|
|
assert receipt.sandbox is None
|
|
assert stat.S_IMODE(path.stat().st_mode) == 0o644
|
|
assert provider.get_calls == 0
|