diff --git a/infrastructure/persistence/json/atomic_json_file.py b/infrastructure/persistence/json/atomic_json_file.py index 9f2a516..d4727a0 100644 --- a/infrastructure/persistence/json/atomic_json_file.py +++ b/infrastructure/persistence/json/atomic_json_file.py @@ -24,6 +24,7 @@ from __future__ import annotations import json import os import tempfile +import time from datetime import datetime from pathlib import Path from typing import Any @@ -71,6 +72,34 @@ class AtomicJsonFile: return None # ---- ghi ------------------------------------------------------------ + #: Số lần thử lại ``os.replace`` và khoảng nghỉ giữa các lần (giây). + _REPLACE_TRIES = 6 + _REPLACE_BACKOFF = 0.02 + + @classmethod + def _replace_ben_bi(cls, src: Path, dst: Path) -> None: + """``os.replace`` có thử lại — bắt buộc trên Windows. + + MoveFileEx trả ERROR_ACCESS_DENIED khi có tiến trình khác đang giữ + handle lên nguồn hoặc đích. Trên Windows thật thì gần như luôn là + Defender hoặc Search Indexer quét file vừa tạo, giữ handle vài chục + mili-giây rồi nhả. Không phải lỗi quyền thật, thử lại là hết. + + Đo trên máy dev 25/08: hỏng 1 trong 7 lượt chạy 20 lần ghi, tức + khoảng 1 trên 140 lần lưu. Không có vòng này thì người dùng thỉnh + thoảng bấm Lưu là văng lỗi mà không tài nào tái hiện. + + POSIX không có kiểu hỏng này nên vòng lặp chạy đúng một lượt. + """ + for lan in range(cls._REPLACE_TRIES): + try: + os.replace(src, dst) + return + except PermissionError: + if lan == cls._REPLACE_TRIES - 1: + raise + time.sleep(cls._REPLACE_BACKOFF * (2 ** lan)) + def write(self, data: Any) -> None: """Ghi ``data``. Hoặc thành công trọn vẹn, hoặc file cũ còn nguyên.""" self.path.parent.mkdir(parents=True, exist_ok=True) @@ -87,7 +116,7 @@ class AtomicJsonFile: f.write(text) f.flush() os.fsync(f.fileno()) # xuống đĩa thật, không chỉ vào bộ đệm - os.replace(tmp, self.path) # nguyên tử + self._replace_ben_bi(tmp, self.path) # nguyên tử, có thử lại except BaseException: # Kể cả KeyboardInterrupt/SystemExit cũng phải dọn file tạm, đừng # để rác .tmp nằm lại cạnh file cấu hình. diff --git a/tests/test_atomic_json.py b/tests/test_atomic_json.py index faaa2c9..9315b86 100644 --- a/tests/test_atomic_json.py +++ b/tests/test_atomic_json.py @@ -103,3 +103,40 @@ def test_json_ghi_ra_doc_duoc_bang_thu_vien_chuan(tmp_path): path = tmp_path / "c.json" AtomicJsonFile(path).write({"n": [1, 2, {"m": None}]}) assert json.loads(path.read_text(encoding="utf-8")) == {"n": [1, 2, {"m": None}]} + + +# ---- Windows: os.replace bị Defender/Indexer chặn tạm thời ----------------- + +def test_thu_lai_khi_windows_chan_tam_thoi(tmp_path, monkeypatch): + """Hỏng 2 lần đầu rồi thành công — phải ghi được, không ném lỗi. + + Đây là lỗi thật bắt được ngày 25/08: chạy vòng 20 lần ghi thì cứ 7 lượt + lại có 1 lượt văng ``PermissionError: [WinError 5]`` ở ``os.replace``. + """ + that = os.replace + con_hong = [2] + + def replace_do_dong(src, dst): + if con_hong[0]: + con_hong[0] -= 1 + raise PermissionError(5, "Access is denied") + return that(src, dst) + + monkeypatch.setattr(os, "replace", replace_do_dong) + AtomicJsonFile(tmp_path / "a.json").write({"x": 1}) + + assert con_hong[0] == 0, "phải thật sự có thử lại, không phải may mà qua" + assert json.loads((tmp_path / "a.json").read_text(encoding="utf-8")) == {"x": 1} + assert list(tmp_path.glob("*.tmp")) == [] + + +def test_hong_that_thi_van_nem_loi_va_khong_de_lai_rac(tmp_path, monkeypatch): + """Thử lại không được phép nuốt lỗi quyền thật — hết lượt là ném.""" + def luon_hong(src, dst): + raise PermissionError(5, "Access is denied") + + monkeypatch.setattr(os, "replace", luon_hong) + with pytest.raises(PermissionError): + AtomicJsonFile(tmp_path / "b.json").write({"x": 1}) + + assert list(tmp_path.glob("*.tmp")) == [], "phải dọn file tạm"