Срез 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:
parent
a0ab13d3d9
commit
83e55af941
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user