mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-16 17:46:20 +00:00
793 lines
32 KiB
Python
793 lines
32 KiB
Python
"""Provider-aware publication of authoritative upload paths to sandboxes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import inspect
|
|
import json
|
|
import logging
|
|
import os
|
|
import stat
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from deerflow.config.paths import get_paths
|
|
from deerflow.sandbox.sandbox_provider import (
|
|
SandboxReconciliationIdentity,
|
|
SandboxReconciliationResult,
|
|
sandbox_provider_sandbox_uses_thread_data_mounts,
|
|
sandbox_provider_uses_thread_data_mounts,
|
|
sandbox_provider_uses_thread_data_mounts_async,
|
|
)
|
|
from deerflow.uploads.async_helpers import run_upload_io_cancellation_safe, wait_for_task_completion
|
|
from deerflow.uploads.layout import conversion_virtual_path
|
|
from deerflow.uploads.lease import UploadNameLease, UploadStageLease
|
|
from deerflow.uploads.manager import (
|
|
_UPLOAD_DELETION_COMMIT_MARKER,
|
|
_UPLOAD_DELETION_FINALIZE_GUARD,
|
|
_UPLOAD_DELETION_PRIMARY_DIRNAME,
|
|
RemoteDeletionCommitRequiredError,
|
|
RemoteDeletionCompensatedError,
|
|
_deletion_transaction_metadata,
|
|
_normalize_existing_filename,
|
|
_staged_deletion_remote_journal,
|
|
_unlink_deletion_control_durably,
|
|
cleanup_stale_upload_staging_files,
|
|
make_upload_file_sandbox_readable,
|
|
upload_virtual_path,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_REMOTE_DELETE_CONVERGENCE_ATTEMPTS = 3
|
|
_REMOTE_DELETE_JOURNAL_MAX_BYTES = 4096
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SandboxSyncReceipt:
|
|
"""Remote paths created by one completed sandbox synchronization."""
|
|
|
|
sandbox: Any | None
|
|
virtual_paths: tuple[str, ...] = ()
|
|
|
|
|
|
def _make_paths_readable(paths: tuple[tuple[Path, str], ...]) -> None:
|
|
for physical_path, _ in paths:
|
|
make_upload_file_sandbox_readable(physical_path)
|
|
|
|
|
|
def _remove_remote_paths(sandbox: Any, virtual_paths: tuple[str, ...]) -> None:
|
|
first_error: BaseException | None = None
|
|
seen: set[str] = set()
|
|
for virtual_path in reversed(virtual_paths):
|
|
if virtual_path in seen:
|
|
continue
|
|
seen.add(virtual_path)
|
|
try:
|
|
sandbox.remove_file(virtual_path)
|
|
except BaseException as exc:
|
|
if first_error is None:
|
|
first_error = exc
|
|
logger.warning("Failed to remove synchronized sandbox upload: %s", virtual_path, exc_info=True)
|
|
if first_error is not None:
|
|
raise first_error
|
|
|
|
|
|
def _fsync_parent_directory(path: Path) -> None:
|
|
directory_descriptor = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
try:
|
|
os.fsync(directory_descriptor)
|
|
finally:
|
|
os.close(directory_descriptor)
|
|
|
|
|
|
def _write_all(descriptor: int, payload: bytes, *, error_message: str) -> None:
|
|
view = memoryview(payload)
|
|
while view:
|
|
written = os.write(descriptor, view)
|
|
if written <= 0:
|
|
raise OSError(error_message)
|
|
view = view[written:]
|
|
|
|
|
|
def _ensure_finalize_guard_durable(
|
|
guard_path: Path,
|
|
journal_data: dict[str, Any],
|
|
journal_payload: bytes,
|
|
) -> None:
|
|
try:
|
|
descriptor = os.open(guard_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
except FileExistsError:
|
|
if _read_remote_delete_journal(guard_path) != journal_data:
|
|
raise ValueError("remote deletion finalization guard does not match journal")
|
|
else:
|
|
try:
|
|
_write_all(
|
|
descriptor,
|
|
journal_payload,
|
|
error_message="Failed to write remote deletion finalization guard",
|
|
)
|
|
os.fsync(descriptor)
|
|
except BaseException:
|
|
try:
|
|
os.close(descriptor)
|
|
finally:
|
|
try:
|
|
_unlink_deletion_control_durably(guard_path)
|
|
except BaseException:
|
|
logger.error(
|
|
"Failed to remove an incomplete remote deletion finalization guard: %s",
|
|
guard_path,
|
|
exc_info=True,
|
|
)
|
|
raise
|
|
else:
|
|
os.close(descriptor)
|
|
# Existing guards may come from an attempt whose directory fsync failed.
|
|
# Persist the reservation before making the journal unlink visible.
|
|
_fsync_parent_directory(guard_path)
|
|
|
|
|
|
def _unlink_journal_durably(journal_path: Path) -> None:
|
|
try:
|
|
journal_data = _read_remote_delete_journal(journal_path)
|
|
except FileNotFoundError:
|
|
# A persistent finalization guard, when present, stays reserved until
|
|
# startup cleanup durably confirms this observed journal absence.
|
|
return
|
|
filename = journal_data.get("filename")
|
|
if not isinstance(filename, str):
|
|
raise ValueError("remote deletion journal has no filename")
|
|
journal_payload = json.dumps(
|
|
journal_data,
|
|
separators=(",", ":"),
|
|
sort_keys=True,
|
|
).encode("utf-8")
|
|
guard_path = journal_path.with_name(_UPLOAD_DELETION_FINALIZE_GUARD)
|
|
_ensure_finalize_guard_durable(guard_path, journal_data, journal_payload)
|
|
try:
|
|
journal_path.unlink(missing_ok=True)
|
|
_fsync_parent_directory(journal_path)
|
|
except BaseException:
|
|
# If unlink became visible but its directory fsync failed, allowing the
|
|
# basename reservation to disappear would let a new generation reuse
|
|
# the remote path before a crash resurrected the old journal. Recreate
|
|
# the same journal entry when possible; the already-durable guard keeps
|
|
# every process fail-closed even when recreation also fails.
|
|
try:
|
|
descriptor = os.open(journal_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
except FileExistsError:
|
|
pass
|
|
except BaseException:
|
|
logger.error("Failed to restore a remote deletion journal after durable unlink failed: %s", journal_path, exc_info=True)
|
|
else:
|
|
restored = False
|
|
try:
|
|
_write_all(
|
|
descriptor,
|
|
journal_payload,
|
|
error_message="Failed to restore remote deletion journal",
|
|
)
|
|
os.fsync(descriptor)
|
|
restored = True
|
|
except BaseException:
|
|
logger.error("Failed to rewrite a remote deletion journal after durable unlink failed: %s", journal_path, exc_info=True)
|
|
finally:
|
|
os.close(descriptor)
|
|
if restored:
|
|
try:
|
|
_fsync_parent_directory(journal_path)
|
|
except BaseException:
|
|
logger.error("Failed to persist a restored remote deletion journal directory entry: %s", journal_path, exc_info=True)
|
|
raise
|
|
_unlink_deletion_control_durably(guard_path)
|
|
|
|
|
|
def _read_remote_delete_journal(journal_path: Path) -> dict[str, Any]:
|
|
path_stat = os.lstat(journal_path)
|
|
if not stat.S_ISREG(path_stat.st_mode) or path_stat.st_nlink != 1:
|
|
raise ValueError("journal is not an exclusive regular file")
|
|
descriptor = os.open(journal_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
try:
|
|
descriptor_stat = os.fstat(descriptor)
|
|
if not stat.S_ISREG(descriptor_stat.st_mode) or descriptor_stat.st_nlink != 1 or (descriptor_stat.st_dev, descriptor_stat.st_ino) != (path_stat.st_dev, path_stat.st_ino):
|
|
raise ValueError("journal changed identity while opening")
|
|
chunks: list[bytes] = []
|
|
remaining = _REMOTE_DELETE_JOURNAL_MAX_BYTES + 1
|
|
while remaining:
|
|
chunk = os.read(descriptor, remaining)
|
|
if not chunk:
|
|
break
|
|
chunks.append(chunk)
|
|
remaining -= len(chunk)
|
|
payload = b"".join(chunks)
|
|
finally:
|
|
os.close(descriptor)
|
|
if len(payload) > _REMOTE_DELETE_JOURNAL_MAX_BYTES:
|
|
raise ValueError("journal exceeds maximum size")
|
|
data = json.loads(payload.decode("utf-8"))
|
|
if not isinstance(data, dict):
|
|
raise ValueError("journal payload is not an object")
|
|
return data
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _SandboxDeletionHook:
|
|
sandbox: Any
|
|
sandbox_id: str | None = None
|
|
thread_id: str | None = None
|
|
user_id: str | None = None
|
|
provider_key: str | None = None
|
|
backend_namespace: str | None = None
|
|
incarnation_id: str | None = None
|
|
_journal_path: Path | None = None
|
|
_prepared_targets: tuple[tuple[str, bytes | None], ...] | None = None
|
|
|
|
def _remove_journal(self) -> None:
|
|
journal_path = self._journal_path
|
|
if journal_path is None:
|
|
return
|
|
_unlink_journal_durably(journal_path)
|
|
self._journal_path = None
|
|
|
|
def prepare(
|
|
self,
|
|
filename: str,
|
|
primary_path: Path,
|
|
conversion_path: Path | None,
|
|
) -> None:
|
|
"""Persist enough context to retry an interrupted remote deletion."""
|
|
if not self.sandbox_id or not self.thread_id or not self.provider_key or not self.backend_namespace or not self.incarnation_id:
|
|
raise RuntimeError("Durable remote deletion requires provider, backend, sandbox, and incarnation identities")
|
|
targets = (
|
|
(upload_virtual_path(filename), primary_path.read_bytes()),
|
|
(
|
|
conversion_virtual_path(filename),
|
|
conversion_path.read_bytes() if conversion_path is not None else None,
|
|
),
|
|
)
|
|
journal_path = _staged_deletion_remote_journal(primary_path)
|
|
payload = json.dumps(
|
|
{
|
|
"version": 3,
|
|
"sandbox_id": self.sandbox_id,
|
|
"thread_id": self.thread_id,
|
|
"user_id": self.user_id,
|
|
"provider_key": self.provider_key,
|
|
"backend_namespace": self.backend_namespace,
|
|
"incarnation_id": self.incarnation_id,
|
|
"filename": filename,
|
|
"virtual_paths": [path for path, _bytes in targets],
|
|
},
|
|
separators=(",", ":"),
|
|
sort_keys=True,
|
|
).encode("utf-8")
|
|
descriptor = os.open(journal_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
try:
|
|
view = memoryview(payload)
|
|
while view:
|
|
written = os.write(descriptor, view)
|
|
if written <= 0:
|
|
raise OSError("Failed to write remote upload deletion journal")
|
|
view = view[written:]
|
|
os.fsync(descriptor)
|
|
except BaseException:
|
|
os.close(descriptor)
|
|
journal_path.unlink(missing_ok=True)
|
|
raise
|
|
else:
|
|
os.close(descriptor)
|
|
try:
|
|
directory_descriptor = os.open(journal_path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
try:
|
|
os.fsync(directory_descriptor)
|
|
finally:
|
|
os.close(directory_descriptor)
|
|
except BaseException:
|
|
journal_path.unlink(missing_ok=True)
|
|
raise
|
|
self._journal_path = journal_path
|
|
self._prepared_targets = targets
|
|
|
|
def abort_prepared(self) -> None:
|
|
"""Forget a journal when no remote side effect was allowed to start."""
|
|
self._remove_journal()
|
|
self._prepared_targets = None
|
|
|
|
def _converge_to_deleted(self, virtual_paths: tuple[str, ...]) -> tuple[str, ...]:
|
|
"""Retry idempotent removal and return paths still not confirmed absent."""
|
|
pending = list(dict.fromkeys(virtual_paths))
|
|
for _attempt in range(_REMOTE_DELETE_CONVERGENCE_ATTEMPTS):
|
|
still_pending: list[str] = []
|
|
for virtual_path in pending:
|
|
try:
|
|
self.sandbox.remove_file(virtual_path)
|
|
except BaseException:
|
|
still_pending.append(virtual_path)
|
|
logger.warning(
|
|
"Failed to converge sandbox upload deletion: %s",
|
|
virtual_path,
|
|
exc_info=True,
|
|
)
|
|
pending = still_pending
|
|
if not pending:
|
|
break
|
|
return tuple(pending)
|
|
|
|
def __call__(
|
|
self,
|
|
filename: str,
|
|
primary_path: Path,
|
|
conversion_path: Path | None,
|
|
) -> None:
|
|
targets = self._prepared_targets
|
|
if targets is None:
|
|
targets = (
|
|
(upload_virtual_path(filename), primary_path.read_bytes()),
|
|
(
|
|
conversion_virtual_path(filename),
|
|
conversion_path.read_bytes() if conversion_path is not None else None,
|
|
),
|
|
)
|
|
removed: list[tuple[str, bytes | None]] = []
|
|
for virtual_path, authoritative_bytes in targets:
|
|
try:
|
|
self.sandbox.remove_file(virtual_path)
|
|
except BaseException as delete_error:
|
|
# A remote failure is ambiguous: the server may have removed
|
|
# the file before the client observed the error. Re-publish
|
|
# the current target and every prior success from the staged
|
|
# authoritative bytes before allowing the host rollback.
|
|
compensation_failed = False
|
|
for rollback_path, rollback_bytes in reversed([*removed, (virtual_path, authoritative_bytes)]):
|
|
if rollback_bytes is None:
|
|
continue
|
|
try:
|
|
self.sandbox.update_file(rollback_path, rollback_bytes)
|
|
except BaseException:
|
|
compensation_failed = True
|
|
logger.warning(
|
|
"Failed to compensate partially deleted sandbox upload: %s",
|
|
rollback_path,
|
|
exc_info=True,
|
|
)
|
|
if compensation_failed:
|
|
# Compensation is no longer an all-or-nothing rollback.
|
|
# Drive every target, including paths successfully written
|
|
# back above, to the deletion outcome before committing the
|
|
# authoritative host deletion. Repeated remove_file calls
|
|
# are idempotent and narrow transient transport failures.
|
|
pending = self._converge_to_deleted(tuple(path for path, _bytes in targets))
|
|
if not pending:
|
|
self._remove_journal()
|
|
detail = f"; unconfirmed remote paths: {pending!r}" if pending else ""
|
|
raise RemoteDeletionCommitRequiredError(f"Remote upload deletion could not be compensated{detail}") from delete_error
|
|
raise RemoteDeletionCompensatedError(str(delete_error)) from delete_error
|
|
removed.append((virtual_path, authoritative_bytes))
|
|
self._remove_journal()
|
|
|
|
|
|
def _deletion_hook_for_sandbox(
|
|
sandbox: Any,
|
|
*,
|
|
sandbox_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
user_id: str | None = None,
|
|
provider_key: str | None = None,
|
|
backend_namespace: str | None = None,
|
|
incarnation_id: str | None = None,
|
|
) -> _SandboxDeletionHook:
|
|
return _SandboxDeletionHook(
|
|
sandbox=sandbox,
|
|
sandbox_id=sandbox_id,
|
|
thread_id=thread_id,
|
|
user_id=user_id,
|
|
provider_key=provider_key,
|
|
backend_namespace=backend_namespace,
|
|
incarnation_id=incarnation_id,
|
|
)
|
|
|
|
|
|
def _declared_callable(instance: Any, name: str) -> Callable[..., Any] | None:
|
|
"""Return a real declared callable without trusting dynamic mock attributes."""
|
|
try:
|
|
inspect.getattr_static(instance, name)
|
|
except AttributeError:
|
|
return None
|
|
candidate = getattr(instance, name, None)
|
|
return candidate if callable(candidate) else None
|
|
|
|
|
|
def _sandbox_provider_reconciliation_key(sandbox_provider: Any) -> str:
|
|
"""Return the stable provider type key recorded by durable journals."""
|
|
key_factory = _declared_callable(sandbox_provider, "reconciliation_provider_key")
|
|
if key_factory is None:
|
|
provider_type = type(sandbox_provider)
|
|
key = f"{provider_type.__module__}.{provider_type.__qualname__}"
|
|
else:
|
|
key = key_factory()
|
|
if not isinstance(key, str) or not key or len(key.encode("utf-8")) > 512:
|
|
raise ValueError("Sandbox reconciliation provider key must be a non-empty string of at most 512 bytes")
|
|
return key
|
|
|
|
|
|
def _prepare_sandbox_reconciliation_identity(
|
|
sandbox_provider: Any,
|
|
sandbox_id: str,
|
|
) -> SandboxReconciliationIdentity:
|
|
identity_factory = _declared_callable(
|
|
sandbox_provider,
|
|
"prepare_sandbox_reconciliation_identity",
|
|
)
|
|
identity = identity_factory(sandbox_id) if identity_factory is not None else None
|
|
if not isinstance(identity, SandboxReconciliationIdentity):
|
|
raise RuntimeError("The active sandbox provider cannot establish a restart-safe backend and incarnation identity for upload deletion")
|
|
if identity.provider_key != _sandbox_provider_reconciliation_key(sandbox_provider):
|
|
raise ValueError("Sandbox reconciliation identity does not match its provider")
|
|
for field in (
|
|
identity.provider_key,
|
|
identity.backend_namespace,
|
|
identity.incarnation_id,
|
|
):
|
|
if not isinstance(field, str) or not field or len(field.encode("utf-8")) > 512:
|
|
raise ValueError("Sandbox reconciliation identity fields must be non-empty strings of at most 512 bytes")
|
|
return identity
|
|
|
|
|
|
def _reconnect_sandbox_for_reconciliation(
|
|
sandbox_provider: Any,
|
|
sandbox_id: str,
|
|
*,
|
|
thread_id: str,
|
|
user_id: str | None,
|
|
identity: SandboxReconciliationIdentity,
|
|
) -> SandboxReconciliationResult:
|
|
"""Resolve one exact old sandbox without creating or redirecting it."""
|
|
resolver = _declared_callable(sandbox_provider, "reconnect_sandbox_for_reconciliation")
|
|
if resolver is not None:
|
|
result = resolver(
|
|
sandbox_id,
|
|
thread_id=thread_id,
|
|
user_id=user_id,
|
|
identity=identity,
|
|
)
|
|
else:
|
|
result = SandboxReconciliationResult.unknown()
|
|
if not isinstance(result, SandboxReconciliationResult):
|
|
raise TypeError("Sandbox reconciliation returned an invalid result")
|
|
if result.status == "found" and result.sandbox is None:
|
|
raise ValueError("Found sandbox reconciliation result has no sandbox")
|
|
if result.status != "found" and (result.sandbox is not None or result.close_after):
|
|
raise ValueError("Non-found sandbox reconciliation result owns no sandbox")
|
|
return result
|
|
|
|
|
|
def _pending_remote_deletion_journals(base_dir: Path) -> list[Path]:
|
|
patterns = (
|
|
"threads/*/user-data/.upload-conversions/.upload-delete-*.part/.remote-delete.json",
|
|
"users/*/threads/*/user-data/.upload-conversions/.upload-delete-*.part/.remote-delete.json",
|
|
)
|
|
journals: list[Path] = []
|
|
for pattern in patterns:
|
|
for path in base_dir.glob(pattern):
|
|
try:
|
|
primary_dir_stat = os.lstat(path.parent / _UPLOAD_DELETION_PRIMARY_DIRNAME)
|
|
commit_stat = os.lstat(path.parent / _UPLOAD_DELETION_COMMIT_MARKER)
|
|
except FileNotFoundError:
|
|
continue
|
|
if stat.S_ISDIR(primary_dir_stat.st_mode) and not stat.S_ISLNK(primary_dir_stat.st_mode) and stat.S_ISREG(commit_stat.st_mode) and commit_stat.st_nlink == 1:
|
|
journals.append(path)
|
|
return sorted(journals)
|
|
|
|
|
|
def _validate_remote_delete_tombstone(journal_path: Path, filename: str) -> Path:
|
|
"""Validate that a journal still names its retained host generation."""
|
|
transaction_dir = journal_path.parent
|
|
metadata = _deletion_transaction_metadata(transaction_dir.name)
|
|
if metadata is None:
|
|
raise ValueError("invalid remote deletion transaction name")
|
|
expected_inode, _recover_on_crash = metadata
|
|
primary_dir = transaction_dir / _UPLOAD_DELETION_PRIMARY_DIRNAME
|
|
primary_dir_stat = os.lstat(primary_dir)
|
|
if stat.S_ISLNK(primary_dir_stat.st_mode) or not stat.S_ISDIR(primary_dir_stat.st_mode):
|
|
raise ValueError("invalid remote deletion primary directory")
|
|
with os.scandir(primary_dir) as entries:
|
|
primary_entries = list(entries)
|
|
if len(primary_entries) != 1:
|
|
raise ValueError("remote deletion transaction must retain one primary")
|
|
primary_entry = primary_entries[0]
|
|
primary_stat = primary_entry.stat(follow_symlinks=False)
|
|
if primary_entry.name != filename or not stat.S_ISREG(primary_stat.st_mode) or primary_stat.st_nlink != 1 or primary_stat.st_ino != expected_inode:
|
|
raise ValueError("remote deletion journal does not match its primary generation")
|
|
return transaction_dir.parent.parent / "uploads"
|
|
|
|
|
|
def reconcile_pending_remote_deletions(
|
|
*,
|
|
sandbox_provider_factory: Callable[[], Any],
|
|
base_dir: Path | str | None = None,
|
|
) -> int:
|
|
"""Retry durable remote upload deletions before host transaction cleanup."""
|
|
root = Path(base_dir) if base_dir is not None else get_paths().base_dir
|
|
journals = _pending_remote_deletion_journals(root)
|
|
if not journals:
|
|
return 0
|
|
sandbox_provider: Any | None = None
|
|
reconciled = 0
|
|
for journal_path in journals:
|
|
transaction_dir = journal_path.parent
|
|
stage_lease = UploadStageLease.try_acquire(transaction_dir.parent, transaction_dir.name)
|
|
if stage_lease is None:
|
|
continue
|
|
try:
|
|
try:
|
|
data = _read_remote_delete_journal(journal_path)
|
|
if data.get("version") != 3:
|
|
raise ValueError("unsupported journal version")
|
|
filename = data.get("filename")
|
|
thread_id = data.get("thread_id")
|
|
user_id = data.get("user_id")
|
|
sandbox_id = data.get("sandbox_id")
|
|
provider_key = data.get("provider_key")
|
|
backend_namespace = data.get("backend_namespace")
|
|
incarnation_id = data.get("incarnation_id")
|
|
virtual_paths = data.get("virtual_paths")
|
|
expected_paths = [
|
|
upload_virtual_path(filename) if isinstance(filename, str) else None,
|
|
conversion_virtual_path(filename) if isinstance(filename, str) else None,
|
|
]
|
|
if (
|
|
not isinstance(filename, str)
|
|
or not isinstance(thread_id, str)
|
|
or not thread_id
|
|
or (user_id is not None and not isinstance(user_id, str))
|
|
or not isinstance(sandbox_id, str)
|
|
or not sandbox_id
|
|
or not isinstance(provider_key, str)
|
|
or not provider_key
|
|
or len(provider_key.encode("utf-8")) > 512
|
|
or not isinstance(backend_namespace, str)
|
|
or not backend_namespace
|
|
or len(backend_namespace.encode("utf-8")) > 512
|
|
or not isinstance(incarnation_id, str)
|
|
or not incarnation_id
|
|
or len(incarnation_id.encode("utf-8")) > 512
|
|
or virtual_paths != expected_paths
|
|
):
|
|
raise ValueError("invalid remote deletion journal fields")
|
|
if _normalize_existing_filename(filename) != filename:
|
|
raise ValueError("invalid remote deletion journal filename")
|
|
uploads_dir = _validate_remote_delete_tombstone(journal_path, filename)
|
|
|
|
if sandbox_provider is None:
|
|
sandbox_provider = sandbox_provider_factory()
|
|
if _sandbox_provider_reconciliation_key(sandbox_provider) != provider_key:
|
|
logger.warning(
|
|
"Pending remote upload deletion belongs to provider namespace %r, not the active namespace; leaving journal pending",
|
|
provider_key,
|
|
)
|
|
continue
|
|
identity = SandboxReconciliationIdentity(
|
|
provider_key=provider_key,
|
|
backend_namespace=backend_namespace,
|
|
incarnation_id=incarnation_id,
|
|
)
|
|
name_lease = UploadNameLease.try_acquire(
|
|
uploads_dir,
|
|
filename,
|
|
allow_legacy_posix_filename=True,
|
|
)
|
|
if name_lease is None:
|
|
continue
|
|
try:
|
|
result = _reconnect_sandbox_for_reconciliation(
|
|
sandbox_provider,
|
|
sandbox_id,
|
|
thread_id=thread_id,
|
|
user_id=user_id,
|
|
identity=identity,
|
|
)
|
|
if result.status == "unknown":
|
|
logger.warning(
|
|
"Exact sandbox %r could not be resolved for pending remote upload deletion; leaving journal pending",
|
|
sandbox_id,
|
|
)
|
|
continue
|
|
if result.status == "absent":
|
|
_unlink_journal_durably(journal_path)
|
|
reconciled += 1
|
|
continue
|
|
sandbox = result.sandbox
|
|
try:
|
|
pending = _SandboxDeletionHook(sandbox)._converge_to_deleted(tuple(virtual_paths))
|
|
if pending:
|
|
logger.warning(
|
|
"Remote upload deletion remains pending for %s: %s",
|
|
journal_path,
|
|
pending,
|
|
)
|
|
continue
|
|
_unlink_journal_durably(journal_path)
|
|
reconciled += 1
|
|
finally:
|
|
if result.close_after:
|
|
try:
|
|
sandbox.close()
|
|
except BaseException:
|
|
logger.warning("Failed to close a transient reconciliation sandbox client", exc_info=True)
|
|
finally:
|
|
name_lease.release()
|
|
except BaseException:
|
|
logger.warning(
|
|
"Failed to reconcile remote upload deletion journal: %s",
|
|
journal_path,
|
|
exc_info=True,
|
|
)
|
|
finally:
|
|
stage_lease.release()
|
|
return reconciled
|
|
|
|
|
|
def prepare_upload_deletion(
|
|
sandbox_provider: Any,
|
|
thread_id: str,
|
|
*,
|
|
user_id: str | None,
|
|
) -> Callable[[str, Path, Path | None], None] | None:
|
|
"""Return a lease-safe remote deletion hook for an explicitly synced sandbox."""
|
|
reconcile_pending_remote_deletions(sandbox_provider_factory=lambda: sandbox_provider)
|
|
# Guard-only transactions are intentionally absent from journal replay.
|
|
# Always run ordinary cleanup so it can fsync-confirm the journal absence
|
|
# and release a finalization guard without waiting for a process restart.
|
|
cleanup_stale_upload_staging_files()
|
|
if sandbox_provider_uses_thread_data_mounts(sandbox_provider):
|
|
return None
|
|
sandbox_id = sandbox_provider.acquire(thread_id, user_id=user_id)
|
|
if sandbox_provider_sandbox_uses_thread_data_mounts(sandbox_provider, sandbox_id):
|
|
return None
|
|
sandbox = sandbox_provider.get(sandbox_id)
|
|
if sandbox is None:
|
|
raise RuntimeError(f"Sandbox {sandbox_id!r} not found after acquire")
|
|
identity = _prepare_sandbox_reconciliation_identity(
|
|
sandbox_provider,
|
|
sandbox_id,
|
|
)
|
|
return _deletion_hook_for_sandbox(
|
|
sandbox,
|
|
sandbox_id=sandbox_id,
|
|
thread_id=thread_id,
|
|
user_id=user_id,
|
|
provider_key=identity.provider_key,
|
|
backend_namespace=identity.backend_namespace,
|
|
incarnation_id=identity.incarnation_id,
|
|
)
|
|
|
|
|
|
async def prepare_upload_deletion_async(
|
|
sandbox_provider: Any,
|
|
thread_id: str,
|
|
*,
|
|
user_id: str | None,
|
|
) -> Callable[[str, Path, Path | None], None] | None:
|
|
"""Async counterpart that keeps remote acquisition off the event loop."""
|
|
await asyncio.to_thread(
|
|
reconcile_pending_remote_deletions,
|
|
sandbox_provider_factory=lambda: sandbox_provider,
|
|
)
|
|
await asyncio.to_thread(cleanup_stale_upload_staging_files)
|
|
if await sandbox_provider_uses_thread_data_mounts_async(sandbox_provider):
|
|
return None
|
|
sandbox_id = await sandbox_provider.acquire_async(thread_id, user_id=user_id)
|
|
if sandbox_provider_sandbox_uses_thread_data_mounts(sandbox_provider, sandbox_id):
|
|
return None
|
|
sandbox = sandbox_provider.get(sandbox_id)
|
|
if sandbox is None:
|
|
raise RuntimeError(f"Sandbox {sandbox_id!r} not found after acquire")
|
|
identity = await asyncio.to_thread(
|
|
_prepare_sandbox_reconciliation_identity,
|
|
sandbox_provider,
|
|
sandbox_id,
|
|
)
|
|
return _deletion_hook_for_sandbox(
|
|
sandbox,
|
|
sandbox_id=sandbox_id,
|
|
thread_id=thread_id,
|
|
user_id=user_id,
|
|
provider_key=identity.provider_key,
|
|
backend_namespace=identity.backend_namespace,
|
|
incarnation_id=identity.incarnation_id,
|
|
)
|
|
|
|
|
|
def rollback_sandbox_sync(receipt: SandboxSyncReceipt) -> None:
|
|
"""Remove every exact remote path recorded by *receipt*."""
|
|
if receipt.sandbox is not None and receipt.virtual_paths:
|
|
_remove_remote_paths(receipt.sandbox, receipt.virtual_paths)
|
|
|
|
|
|
async def rollback_sandbox_sync_async(receipt: SandboxSyncReceipt) -> None:
|
|
"""Cancellation-safely remove every remote path recorded by *receipt*."""
|
|
cleanup_task = asyncio.create_task(
|
|
asyncio.to_thread(rollback_sandbox_sync, receipt),
|
|
name="rollback-sandbox-upload-paths",
|
|
)
|
|
await wait_for_task_completion(cleanup_task)
|
|
cleanup_task.result()
|
|
|
|
|
|
def _sync_remote_paths(sandbox: Any, paths: tuple[tuple[Path, str], ...]) -> SandboxSyncReceipt:
|
|
attempted: list[str] = []
|
|
completed: list[str] = []
|
|
try:
|
|
for physical_path, virtual_path in paths:
|
|
attempted.append(virtual_path)
|
|
sandbox.update_file(virtual_path, physical_path.read_bytes())
|
|
completed.append(virtual_path)
|
|
except BaseException:
|
|
try:
|
|
_remove_remote_paths(sandbox, tuple(attempted))
|
|
except BaseException:
|
|
pass
|
|
raise
|
|
return SandboxSyncReceipt(sandbox=sandbox, virtual_paths=tuple(completed))
|
|
|
|
|
|
def make_upload_paths_available(
|
|
sandbox_provider: Any,
|
|
thread_id: str,
|
|
*,
|
|
user_id: str | None,
|
|
paths: list[tuple[Path, str]],
|
|
) -> SandboxSyncReceipt:
|
|
"""Synchronously make exact host upload paths available to one provider."""
|
|
sync_paths = tuple((Path(path), virtual_path) for path, virtual_path in paths)
|
|
if sandbox_provider_uses_thread_data_mounts(sandbox_provider):
|
|
_make_paths_readable(sync_paths)
|
|
return SandboxSyncReceipt(sandbox=None)
|
|
|
|
sandbox_id = sandbox_provider.acquire(thread_id, user_id=user_id)
|
|
if sandbox_provider_sandbox_uses_thread_data_mounts(sandbox_provider, sandbox_id):
|
|
_make_paths_readable(sync_paths)
|
|
return SandboxSyncReceipt(sandbox=None)
|
|
sandbox = sandbox_provider.get(sandbox_id)
|
|
if sandbox is None:
|
|
raise RuntimeError(f"Sandbox {sandbox_id!r} not found after acquire")
|
|
return _sync_remote_paths(sandbox, sync_paths)
|
|
|
|
|
|
async def make_upload_paths_available_async(
|
|
sandbox_provider: Any,
|
|
thread_id: str,
|
|
*,
|
|
user_id: str | None,
|
|
paths: list[tuple[Path, str]],
|
|
) -> SandboxSyncReceipt:
|
|
"""Cancellation-safely expose exact upload paths to a mounted or remote sandbox."""
|
|
sync_paths = tuple((Path(path), virtual_path) for path, virtual_path in paths)
|
|
if await sandbox_provider_uses_thread_data_mounts_async(sandbox_provider):
|
|
await run_upload_io_cancellation_safe(_make_paths_readable, sync_paths)
|
|
return SandboxSyncReceipt(sandbox=None)
|
|
|
|
sandbox_id = await sandbox_provider.acquire_async(thread_id, user_id=user_id)
|
|
if sandbox_provider_sandbox_uses_thread_data_mounts(sandbox_provider, sandbox_id):
|
|
await run_upload_io_cancellation_safe(_make_paths_readable, sync_paths)
|
|
return SandboxSyncReceipt(sandbox=None)
|
|
sandbox = sandbox_provider.get(sandbox_id)
|
|
if sandbox is None:
|
|
raise RuntimeError(f"Sandbox {sandbox_id!r} not found after acquire")
|
|
|
|
sync_task = asyncio.create_task(
|
|
asyncio.to_thread(_sync_remote_paths, sandbox, sync_paths),
|
|
name=f"sync-upload-paths:{thread_id}",
|
|
)
|
|
cancelled = await wait_for_task_completion(sync_task)
|
|
receipt = sync_task.result()
|
|
if cancelled:
|
|
try:
|
|
await rollback_sandbox_sync_async(receipt)
|
|
except BaseException:
|
|
logger.warning("Failed to roll back sandbox uploads after cancellation", exc_info=True)
|
|
raise asyncio.CancelledError
|
|
return receipt
|