From 83e55af941bcd69fd2022e6d092960ee5ff3e288 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 14:18:29 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=80=D0=B5=D0=B7=200=20[7.2,=20fix=20T2?= =?UTF-8?q?.1=20T2.2=20T2.4]:=20=D0=B3=D0=BE=D1=80=D1=8F=D1=87=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8=20=E2=80=94=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=D0=BD=D1=8B=D0=B9=20=D0=BB=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BD=D0=B3=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20?= =?UTF-8?q?=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D1=8F,=20ENOTDIR=20=D0=B2=20SMB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T2.1: после чтения набора каталог ответа листается заново (Local и SMB); если снимок (имена и размеры) отличается от настоявшегося — None, набор настаивается заново. - T2.2: SmbHotFolder — ENOTDIR из листинга Output/ (на месте каталога файл) — None, как у LocalHotFolder. - T2.4: тест SMB — ENOENT / SHARING_VIOLATION / ENOTDIR во время настаивания сбрасывают накопленное состояние settle (pending_jobs() == 0). Co-Authored-By: Claude Sonnet 5.5 --- api/src/gateway/hotfolder.py | 7 ++- api/src/gateway/smb.py | 18 ++++++-- api/tests/unit/test_local_hotfolder.py | 44 ++++++++++++++++++ api/tests/unit/test_smb_hotfolder.py | 63 +++++++++++++++++++++++++- 4 files changed, 125 insertions(+), 7 deletions(-) diff --git a/api/src/gateway/hotfolder.py b/api/src/gateway/hotfolder.py index 144b6b6..f16e64d 100644 --- a/api/src/gateway/hotfolder.py +++ b/api/src/gateway/hotfolder.py @@ -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 diff --git a/api/src/gateway/smb.py b/api/src/gateway/smb.py index 4bad421..1db65c9 100644 --- a/api/src/gateway/smb.py +++ b/api/src/gateway/smb.py @@ -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) или на месте каталога `` — файл (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` каталога `\\` по возрастанию имени или `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) diff --git a/api/tests/unit/test_local_hotfolder.py b/api/tests/unit/test_local_hotfolder.py index aa89ac0..9c319c3 100644 --- a/api/tests/unit/test_local_hotfolder.py +++ b/api/tests/unit/test_local_hotfolder.py @@ -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: diff --git a/api/tests/unit/test_smb_hotfolder.py b/api/tests/unit/test_smb_hotfolder.py index e814f0f..2ed90d1 100644 --- a/api/tests/unit/test_smb_hotfolder.py +++ b/api/tests/unit/test_smb_hotfolder.py @@ -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/` — файл (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: