mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 12:11:12 +01:00
+15
-11
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user