Fix FUSE copy fallback (#957)

Fixes #949
This commit is contained in:
Alex
2026-05-08 11:17:49 +01:00
committed by GitHub
parent 196578fb18
commit b10a5a35ca
3 changed files with 61 additions and 11 deletions
+15 -11
View File
@@ -229,6 +229,10 @@ def _is_permission_error(e: Exception) -> bool:
return isinstance(e, PermissionError) or (isinstance(e, OSError) and e.errno == errno.EPERM)
def _should_fallback_to_content_copy(error: Exception) -> bool:
return _is_permission_error(error) or (isinstance(error, OSError) and error.errno == errno.EIO)
def _system_op(op: str, source: Path, dest: Path) -> None:
"""Execute system command (mv or cp) as final fallback."""
logger.warning("Attempting system %s as final fallback: %s -> %s", op, source, dest)
@@ -463,9 +467,9 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
try:
run_blocking_io(shutil.copy2, str(source_path), str(temp_path))
except (PermissionError, OSError) as copy_error:
if _is_permission_error(copy_error):
if _should_fallback_to_content_copy(copy_error):
logger.debug(
"Permission error during move-copy, falling back to copyfile (%s -> %s): %s",
"copy2 failed during move-copy, falling back to copyfile (%s -> %s): %s",
source_path,
temp_path,
copy_error,
@@ -631,16 +635,16 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
try:
run_blocking_io(shutil.copy2, str(source_path), str(temp_path))
except (PermissionError, OSError) as e:
# Handle NFS permission errors immediately here
if _is_permission_error(e):
log_transfer_permission_context(
"atomic_copy",
source=source_path,
dest=temp_path,
error=e,
)
if _should_fallback_to_content_copy(e):
if _is_permission_error(e):
log_transfer_permission_context(
"atomic_copy",
source=source_path,
dest=temp_path,
error=e,
)
logger.debug(
"Permission error during copy, falling back to copyfile (%s -> %s): %s",
"copy2 failed during copy, falling back to copyfile (%s -> %s): %s",
source_path,
temp_path,
e,
+20
View File
@@ -240,6 +240,26 @@ class TestAtomicCopy:
assert result.exists()
assert result.read_text() == "content"
def test_copy_falls_back_when_copy2_hits_fuse_eio(self, tmp_path):
"""Fall back to content copy when FUSE rejects xattr metadata reads."""
import errno
from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("content")
dest = tmp_path / "dest.txt"
with patch(
"shelfmark.download.fs.shutil.copy2",
side_effect=OSError(errno.EIO, "Input/output error"),
):
result = _atomic_copy(source, dest)
assert result == dest
assert result.exists()
assert result.read_text() == "content"
def test_copy_tolerates_post_publish_estale(self, tmp_path, monkeypatch):
"""Treat ESTALE on the final destination as a successful NFS publish."""
import errno
+26
View File
@@ -407,6 +407,32 @@ class TestAtomicMove:
assert mock_copy.called
assert mock_fallback.called
def test_cross_filesystem_move_falls_back_when_copy2_hits_fuse_eio(self, tmp_path, monkeypatch):
"""Falls back to content copy when FUSE rejects xattr metadata reads."""
import errno
from shelfmark.download.fs import atomic_move as _atomic_move
source = tmp_path / "source.txt"
source.write_text("content")
dest = tmp_path / "dest.txt"
def _raise_exdev(*_args, **_kwargs):
raise OSError(errno.EXDEV, "Cross-device link")
monkeypatch.setattr(os, "rename", _raise_exdev)
with patch(
"shelfmark.download.fs.shutil.copy2",
side_effect=OSError(errno.EIO, "Input/output error"),
):
result = _atomic_move(source, dest)
assert result == dest
assert not source.exists()
assert dest.exists()
assert dest.read_text() == "content"
def test_cross_filesystem_move_recovers_when_metadata_step_hits_enoent(
self, tmp_path, monkeypatch
):