Срез 0 [7.2, fix T2.1 T2.2 T2.4]: горячие папки — повторный листинг после чтения, ENOTDIR в SMB

- T2.1: после чтения набора каталог ответа листается заново (Local и SMB); если снимок (имена и
  размеры) отличается от настоявшегося — None, набор настаивается заново.
- T2.2: SmbHotFolder — ENOTDIR из листинга Output/<job_id> (на месте каталога файл) — None,
  как у LocalHotFolder.
- T2.4: тест SMB — ENOENT / SHARING_VIOLATION / ENOTDIR во время настаивания сбрасывают
  накопленное состояние settle (pending_jobs() == 0).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-04 14:18:29 +04:00
parent a0ab13d3d9
commit 83e55af941
4 changed files with 125 additions and 7 deletions

View File

@ -250,7 +250,8 @@ class LocalHotFolder:
def poll(self, job_id: str) -> list[AnswerFile] | None:
"""Готовый набор `*.json` каталога ответа или `None` (нет корня/каталога/json-файлов, набор ещё
настаивается, файл исчез или изменился между листингом и чтением). Другие ошибки ФС поднимаются."""
настаивается, файл исчез или изменился между листингом и чтением, набор изменился за время чтения).
Другие ошибки ФС поднимаются."""
folder = self.answer_dir(job_id)
snapshot = self._snapshot(folder)
if not self._settle.ready(job_id, snapshot):
@ -264,6 +265,10 @@ class LocalHotFolder:
if len(data) != size: # файл изменился после листинга — набор настаивается заново
return None
files.append(AnswerFile(name, data))
# T2.1: за время чтения мог появиться или пропасть файл — такой набор не отдаётся, он настаивается заново
# (состояние settle уже сброшено в момент готовности)
if self._snapshot(folder) != snapshot:
return None
return files
@property

View File

@ -39,6 +39,11 @@ def _has_errno(err: OSError, code: int, exc_type: type[OSError]) -> bool:
return isinstance(err, exc_type) or err.errno == code
def _is_no_answer(err: OSError) -> bool:
"""Ответа нет: каталога/файла нет (ENOENT) или на месте каталога `<job_id>` — файл (ENOTDIR, T2.2)."""
return _has_errno(err, errno.ENOENT, FileNotFoundError) or _has_errno(err, errno.ENOTDIR, NotADirectoryError)
def build_unc_root(host: str, path: str) -> str:
r"""UNC-корень горячей папки.
@ -112,8 +117,9 @@ class SmbHotFolder:
def poll(self, job_id: str) -> list[AnswerFile] | None:
r"""Готовый набор `*.json` каталога `<output>\<job_id>\` по возрастанию имени или `None`: каталога/`Output`/
json-файлов нет, набор ещё настаивается, файл исчез или изменился между листингом и чтением, ИС ещё держит
файл (`STATUS_SHARING_VIOLATION`, удаляется — `STATUS_DELETE_PENDING`). Остальные ошибки SMB поднимаются."""
json-файлов нет, на месте каталога — файл, набор ещё настаивается, файл исчез или изменился между листингом и
чтением, набор изменился за время чтения, ИС ещё держит файл (`STATUS_SHARING_VIOLATION`, удаляется —
`STATUS_DELETE_PENDING`). Остальные ошибки SMB поднимаются."""
folder = self.answer_dir(job_id)
try:
snapshot = self._snapshot(folder)
@ -128,8 +134,12 @@ class SmbHotFolder:
if len(data) != size: # файл изменился после листинга — набор настаивается заново
return None
files.append(AnswerFile(name, data))
# T2.1: за время чтения мог появиться или пропасть файл — такой набор не отдаётся, он настаивается
# заново (состояние settle уже сброшено в момент готовности)
if self._snapshot(folder) != snapshot:
return None
except OSError as err:
if _has_errno(err, errno.ENOENT, FileNotFoundError) or getattr(err, "ntstatus", None) in _NOT_READY:
if _is_no_answer(err) or getattr(err, "ntstatus", None) in _NOT_READY:
self._settle.forget(job_id)
return None
raise
@ -154,7 +164,7 @@ class SmbHotFolder:
def _snapshot(self, folder: str) -> Snapshot:
"""(имя, размер) `*.json`-файлов каталога по возрастанию имени; размер — из листинга (`end_of_file`), без
отдельного stat. Нет каталога — ENOENT-ошибка `scandir` (её обрабатывает `poll`)."""
отдельного stat. Нет каталога — ENOENT, на его месте файл — ENOTDIR из `scandir` (их обрабатывает `poll`)."""
found = [
(entry.name, int(entry.smb_info.end_of_file))
for entry in smbclient.scandir(folder, **self._credentials)

View File

@ -525,6 +525,50 @@ class TestPoll:
monkeypatch.setattr(Path, "read_bytes", vanished)
assert hf.poll(job_id) is None
def test_new_file_appeared_while_reading_is_none_and_set_resettles(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""T2.1: после чтения каталог листается заново; набор изменился (появился файл) — `None`, набор настаивается
заново и отдаётся целиком."""
hf, clock = _settled(tmp_path)
job_id = _job_id()
_write_answer(hf, job_id, b"{}")
assert hf.poll(job_id) is None
clock.now += 2
real_read = Path.read_bytes
def read_then_add(self: Path) -> bytes:
data = real_read(self)
_write_answer(hf, job_id, b'{"n": 2}', f"{job_id}_ofr.json")
return data
monkeypatch.setattr(Path, "read_bytes", read_then_add)
assert hf.poll(job_id) is None
monkeypatch.setattr(Path, "read_bytes", real_read)
assert hf.poll(job_id) is None # состояние сброшено: новый набор настаивается с нуля
clock.now += 2
assert hf.poll(job_id) == [
AnswerFile(f"{job_id}.json", b"{}"),
AnswerFile(f"{job_id}_ofr.json", b'{"n": 2}'),
]
def test_file_removed_after_read_is_none(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""T2.1: файл прочитан, но пропал до повторного листинга — набор изменился, `None`."""
hf = LocalHotFolder(tmp_path, settle_seconds=0)
job_id = _job_id()
_write_answer(hf, job_id, b"{}")
second = _write_answer(hf, job_id, b"[]", f"{job_id}_ofr.json")
real_read = Path.read_bytes
def read_then_remove(self: Path) -> bytes:
data = real_read(self)
if self == second:
second.unlink()
return data
monkeypatch.setattr(Path, "read_bytes", read_then_remove)
assert hf.poll(job_id) is None
class TestSettleTracker:
def test_snapshot_must_be_stable_for_settle_seconds(self) -> None:

View File

@ -397,6 +397,7 @@ class TestPoll:
("scandir", (folder,), {}),
("open_file", (f"{folder}\\{job_id}.json", "rb"), {}),
("open_file", (f"{folder}\\{job_id}_ofr.json", "rb"), {}),
("scandir", (folder,), {}), # T2.1: повторный листинг — набор не изменился за время чтения
]
def test_one_json_after_settle(self, fake: FakeSmbClient, share_root: Path) -> None:
@ -409,7 +410,8 @@ class TestPoll:
assert hf.poll(job_id) is None
clock.now += 0.1
assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}")]
assert fake.funcs() == ["scandir", "scandir", "scandir", "open_file"] # до готовности файлы не читаются
# до готовности файлы не читаются; после чтения — повторный листинг (T2.1)
assert fake.funcs() == ["scandir", "scandir", "scandir", "open_file", "scandir"]
def test_growing_file_is_not_ready_until_stable(self, fake: FakeSmbClient, share_root: Path) -> None:
clock = FakeClock()
@ -468,6 +470,63 @@ class TestPoll:
fake.fail["open_file"] = SMBOSError(status, OUTPUT)
assert _hotfolder().poll(job_id) is None
def test_answer_dir_is_a_file_is_none(self, fake: FakeSmbClient, share_root: Path) -> None:
"""T2.2: на месте `Output/<job_id>` — файл (ENOTDIR из листинга) — ответа нет, как у `LocalHotFolder`."""
job_id = _job_id()
(share_root / "Output").mkdir()
(share_root / "Output" / job_id).write_bytes(b"{}")
assert _hotfolder().poll(job_id) is None
def test_new_file_appeared_while_reading_is_none_and_set_resettles(
self, fake: FakeSmbClient, share_root: Path
) -> None:
"""T2.1: после чтения каталог листается заново; набор изменился — `None`, набор настаивается заново."""
clock = FakeClock()
hf = _hotfolder(settle_seconds=2, clock=clock)
job_id = _job_id()
_write_answer(share_root, job_id, b"{}")
assert hf.poll(job_id) is None
clock.now += 2
real_open = fake.open_file
def open_then_add(path: str, mode: str = "r", **kwargs: Any) -> IO[Any]:
fd = real_open(path, mode, **kwargs)
_write_answer(share_root, job_id, b"[]", f"{job_id}_ofr.json")
return fd
fake.open_file = open_then_add # type: ignore[method-assign]
assert hf.poll(job_id) is None
fake.open_file = real_open # type: ignore[method-assign]
assert hf.poll(job_id) is None # состояние сброшено: новый набор настаивается с нуля
clock.now += 2
assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}"), AnswerFile(f"{job_id}_ofr.json", b"[]")]
@pytest.mark.parametrize(
"error",
[
SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, OUTPUT),
SMBOSError(NtStatus.STATUS_SHARING_VIOLATION, OUTPUT),
SMBOSError(NtStatus.STATUS_NOT_A_DIRECTORY, OUTPUT),
],
ids=["ENOENT", "SHARING_VIOLATION", "ENOTDIR"],
)
def test_listing_error_resets_settle_state(self, fake: FakeSmbClient, share_root: Path, error: SMBOSError) -> None:
"""T2.4: «нет ответа»/«ещё занят» во время настаивания сбрасывает накопленное состояние settle."""
clock = FakeClock()
hf = _hotfolder(settle_seconds=2, clock=clock)
job_id = _job_id()
_write_answer(share_root, job_id, b"{}")
assert hf.poll(job_id) is None
assert hf.pending_jobs() == 1
fake.fail["scandir"] = error
clock.now += 2
assert hf.poll(job_id) is None
assert hf.pending_jobs() == 0
del fake.fail["scandir"]
assert hf.poll(job_id) is None # набор настаивается заново
clock.now += 2
assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}")]
@pytest.mark.parametrize("func", ["scandir", "open_file"])
def test_other_errors_propagate(self, fake: FakeSmbClient, share_root: Path, func: str) -> None:
job_id = _job_id()
@ -485,7 +544,7 @@ class TestPoll:
job_id = _job_id()
_write_answer(share_root, job_id, b"{}")
_hotfolder(login="user", password="secret").poll(job_id)
assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 2
assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 3
class TestHealthy: