fix(hotfolder): .part создаётся заново (O_EXCL), fallback os.replace только вокруг os.link (№32)
Старый .part, оставшийся жёсткой ссылкой на опубликованный файл, больше не перезаписывается на месте; ошибка записи поднимается как есть, а не как FileNotFoundError от os.replace; EOPNOTSUPP добавлен к списку ФС без жёстких ссылок. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
c1288d08fb
commit
b956d5746f
@ -29,6 +29,8 @@ _ILLEGAL_CHARS = frozenset(':<>"|?*') | frozenset(chr(code) for code in range(0x
|
|||||||
_FORBIDDEN_CHARS = _ILLEGAL_CHARS | frozenset(_PATH_SEPARATORS)
|
_FORBIDDEN_CHARS = _ILLEGAL_CHARS | frozenset(_PATH_SEPARATORS)
|
||||||
# Расширение входящего файла — суффикс после последней точки, в нижнем регистре, 1–16 символов ASCII [0-9a-z]
|
# Расширение входящего файла — суффикс после последней точки, в нижнем регистре, 1–16 символов ASCII [0-9a-z]
|
||||||
_EXTENSION_RE = re.compile(r"[0-9a-z]{1,16}")
|
_EXTENSION_RE = re.compile(r"[0-9a-z]{1,16}")
|
||||||
|
# errno `os.link`, означающие «ФС не умеет жёсткие ссылки» (запасной путь записи — `os.replace`)
|
||||||
|
_NO_HARD_LINKS = (errno.EPERM, errno.EXDEV, errno.ENOTSUP, errno.EOPNOTSUPP)
|
||||||
|
|
||||||
|
|
||||||
def validate_name(name: str) -> None:
|
def validate_name(name: str) -> None:
|
||||||
@ -211,17 +213,27 @@ class LocalHotFolder:
|
|||||||
self.input_dir.mkdir(exist_ok=True)
|
self.input_dir.mkdir(exist_ok=True)
|
||||||
part = self.tmp_dir / f".{name}.part"
|
part = self.tmp_dir / f".{name}.part"
|
||||||
target = self.input_dir / name
|
target = self.input_dir / name
|
||||||
|
# `.part` создаётся заново (№32): оставшийся от прошлого задания мог быть жёсткой ссылкой на уже опубликованный
|
||||||
|
# файл (удаление после публикации подавляется, R-8), запись «поверх» испортила бы файл, который читает ИС
|
||||||
|
part.unlink(missing_ok=True)
|
||||||
|
fd = os.open(part, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "wb") as handle:
|
||||||
|
handle.write(data)
|
||||||
|
except BaseException:
|
||||||
|
self._drop_part(part)
|
||||||
|
raise
|
||||||
try:
|
try:
|
||||||
part.write_bytes(data)
|
|
||||||
os.link(part, target) # без замены: существующее назначение даёт FileExistsError
|
os.link(part, target) # без замены: существующее назначение даёт FileExistsError
|
||||||
except FileExistsError:
|
except FileExistsError:
|
||||||
self._drop_part(part)
|
self._drop_part(part)
|
||||||
return
|
return
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
# ФС без жёстких ссылок (некоторые тома Docker/сетевые монтирования): EPERM/EXDEV/ENOTSUP — запасной путь
|
# ФС без жёстких ссылок (некоторые тома Docker/сетевые монтирования): запасной путь через replace с
|
||||||
# через replace с проверкой назначения (окно гонки крошечное, повтор задания с тем же job_id редок)
|
# проверкой назначения (окно гонки крошечное, повтор задания с тем же job_id редок). Только для самой
|
||||||
if err.errno in (errno.EPERM, errno.EXDEV, errno.ENOTSUP) and not target.exists():
|
# линковки: ошибка записи выше поднимается как есть
|
||||||
os.replace(part, target)
|
if err.errno in _NO_HARD_LINKS and not target.exists():
|
||||||
|
self._replace_part(part, target)
|
||||||
return
|
return
|
||||||
self._drop_part(part)
|
self._drop_part(part)
|
||||||
raise
|
raise
|
||||||
@ -233,6 +245,13 @@ class LocalHotFolder:
|
|||||||
with contextlib.suppress(OSError):
|
with contextlib.suppress(OSError):
|
||||||
part.unlink(missing_ok=True)
|
part.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _replace_part(self, part: Path, target: Path) -> None:
|
||||||
|
try:
|
||||||
|
os.replace(part, target)
|
||||||
|
except BaseException:
|
||||||
|
self._drop_part(part)
|
||||||
|
raise
|
||||||
|
|
||||||
def _drop_part(self, part: Path) -> None:
|
def _drop_part(self, part: Path) -> None:
|
||||||
if self.cleanup_enabled:
|
if self.cleanup_enabled:
|
||||||
with contextlib.suppress(OSError):
|
with contextlib.suppress(OSError):
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
с готовностью по стабилизации (settle, часы подменены), проверка здоровья, валидация имён.
|
с готовностью по стабилизации (settle, часы подменены), проверка здоровья, валидация имён.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import errno
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -321,7 +322,8 @@ class TestPut:
|
|||||||
|
|
||||||
def unlink_with_failure(path: Any) -> None:
|
def unlink_with_failure(path: Any) -> None:
|
||||||
unlink_calls.append(path)
|
unlink_calls.append(path)
|
||||||
if str(path).endswith(".part"):
|
# перед записью `.part` создаётся заново (№32) — отказ удаления только после публикации файла (R-8)
|
||||||
|
if str(path).endswith(".part") and (hf.input_dir / "a.pdf").exists():
|
||||||
raise PermissionError("удаление запрещено")
|
raise PermissionError("удаление запрещено")
|
||||||
real_unlink(path)
|
real_unlink(path)
|
||||||
|
|
||||||
@ -341,6 +343,8 @@ class TestPut:
|
|||||||
raise OSError("диск недоступен")
|
raise OSError("диск недоступен")
|
||||||
|
|
||||||
def unlink_fails(self: Path, missing_ok: bool = False) -> None:
|
def unlink_fails(self: Path, missing_ok: bool = False) -> None:
|
||||||
|
if missing_ok and not self.exists(): # как настоящий unlink: нет файла — нечего запрещать (№32)
|
||||||
|
return
|
||||||
raise PermissionError("удаление .part запрещено")
|
raise PermissionError("удаление .part запрещено")
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.link", boom)
|
monkeypatch.setattr("src.gateway.hotfolder.os.link", boom)
|
||||||
@ -349,6 +353,107 @@ class TestPut:
|
|||||||
hf.put("a.pdf", b"data")
|
hf.put("a.pdf", b"data")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPartIsCreatedFresh:
|
||||||
|
"""Решение №32: `.part` создаётся заново (удаление старого + `O_EXCL`), fallback — только вокруг `os.link`."""
|
||||||
|
|
||||||
|
def test_stale_part_hard_linked_to_published_file_is_not_truncated(self, tmp_path: Path) -> None:
|
||||||
|
hf = LocalHotFolder(tmp_path)
|
||||||
|
hf.put("job.pdf", b"first")
|
||||||
|
published = hf.input_dir / "job.pdf"
|
||||||
|
stale = hf.tmp_dir / ".job.pdf.part"
|
||||||
|
os.link(published, stale) # подавленное удаление `.part` после публикации оставило жёсткую ссылку
|
||||||
|
assert published.stat().st_nlink == 2
|
||||||
|
hf.put("job.pdf", b"different data") # повтор задания: назначение есть, файл считается доставленным
|
||||||
|
assert published.read_bytes() == b"first" # опубликованный файл не изменился
|
||||||
|
assert stale.read_bytes() == b"different data" # `.part` — новый файл, а не запись в ту же ссылку
|
||||||
|
assert published.stat().st_nlink == 1
|
||||||
|
|
||||||
|
def test_stale_part_is_replaced_before_publication(self, tmp_path: Path) -> None:
|
||||||
|
hf = LocalHotFolder(tmp_path)
|
||||||
|
hf.tmp_dir.mkdir()
|
||||||
|
(hf.tmp_dir / ".job.pdf.part").write_bytes(b"old and longer content")
|
||||||
|
hf.put("job.pdf", b"new")
|
||||||
|
assert (hf.input_dir / "job.pdf").read_bytes() == b"new"
|
||||||
|
assert not (hf.tmp_dir / ".job.pdf.part").exists()
|
||||||
|
|
||||||
|
def test_part_is_opened_exclusively(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
hf = LocalHotFolder(tmp_path)
|
||||||
|
flags: list[int] = []
|
||||||
|
real_open = os.open
|
||||||
|
|
||||||
|
def spy(path: Any, flag: int, mode: int = 0o777, **kwargs: Any) -> int:
|
||||||
|
flags.append(flag)
|
||||||
|
return real_open(path, flag, mode, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.gateway.hotfolder.os.open", spy)
|
||||||
|
hf.put("job.pdf", b"x")
|
||||||
|
assert flags and flags[0] & os.O_EXCL and flags[0] & os.O_CREAT
|
||||||
|
|
||||||
|
def test_write_error_propagates_as_itself(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""EPERM записи не превращается в `FileNotFoundError` от `os.replace` несуществующего `.part`."""
|
||||||
|
hf = LocalHotFolder(tmp_path, cleanup=True)
|
||||||
|
|
||||||
|
class FailingFile:
|
||||||
|
def __enter__(self) -> "FailingFile":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> int:
|
||||||
|
raise OSError(errno.EPERM, "запись запрещена")
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.gateway.hotfolder.os.fdopen", lambda fd, mode: (os.close(fd), FailingFile())[1])
|
||||||
|
with pytest.raises(OSError, match="запись запрещена") as caught:
|
||||||
|
hf.put("job.pdf", b"data")
|
||||||
|
assert caught.value.errno == errno.EPERM and not isinstance(caught.value, FileNotFoundError)
|
||||||
|
assert list(hf.input_dir.iterdir()) == [] and list(hf.tmp_dir.iterdir()) == []
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("code", [errno.EPERM, errno.EXDEV, errno.ENOTSUP, errno.EOPNOTSUPP])
|
||||||
|
def test_fallback_to_replace_when_link_unsupported(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, code: int
|
||||||
|
) -> None:
|
||||||
|
hf = LocalHotFolder(tmp_path)
|
||||||
|
|
||||||
|
def no_links(src: Any, dst: Any) -> None:
|
||||||
|
raise OSError(code, "жёсткие ссылки не поддерживаются")
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.gateway.hotfolder.os.link", no_links)
|
||||||
|
hf.put("job.pdf", b"data")
|
||||||
|
assert (hf.input_dir / "job.pdf").read_bytes() == b"data"
|
||||||
|
assert list(hf.tmp_dir.iterdir()) == [] # `.part` стал назначением
|
||||||
|
|
||||||
|
def test_no_fallback_when_destination_exists(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
hf = LocalHotFolder(tmp_path, cleanup=True)
|
||||||
|
hf.input_dir.mkdir()
|
||||||
|
(hf.input_dir / "job.pdf").write_bytes(b"first")
|
||||||
|
|
||||||
|
def no_links(src: Any, dst: Any) -> None:
|
||||||
|
raise OSError(errno.EPERM, "жёсткие ссылки не поддерживаются")
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.gateway.hotfolder.os.link", no_links)
|
||||||
|
with pytest.raises(OSError, match="жёсткие ссылки"):
|
||||||
|
hf.put("job.pdf", b"second")
|
||||||
|
assert (hf.input_dir / "job.pdf").read_bytes() == b"first"
|
||||||
|
|
||||||
|
def test_replace_failure_after_link_fallback_propagates(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
hf = LocalHotFolder(tmp_path, cleanup=True)
|
||||||
|
|
||||||
|
def no_links(src: Any, dst: Any) -> None:
|
||||||
|
raise OSError(errno.EPERM, "жёсткие ссылки не поддерживаются")
|
||||||
|
|
||||||
|
def broken_replace(src: Any, dst: Any) -> None:
|
||||||
|
raise OSError(errno.EIO, "замена не удалась")
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.gateway.hotfolder.os.link", no_links)
|
||||||
|
monkeypatch.setattr("src.gateway.hotfolder.os.replace", broken_replace)
|
||||||
|
with pytest.raises(OSError, match="замена не удалась"):
|
||||||
|
hf.put("job.pdf", b"data")
|
||||||
|
assert list(hf.tmp_dir.iterdir()) == [] # при очистке `.part` снят
|
||||||
|
|
||||||
|
|
||||||
class TestPoll:
|
class TestPoll:
|
||||||
"""`poll` — все `*.json` каталога `<output>/<job_id>/` по имени, когда набор настоялся `settle_seconds`."""
|
"""`poll` — все `*.json` каталога `<output>/<job_id>/` по имени, когда набор настоялся `settle_seconds`."""
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user