Feature/delta team/epic r04 #7

Merged
gitea-admin merged 67 commits from feature/delta-team/epic-R04 into main 2026-08-31 05:15:15 +00:00
2 changed files with 67 additions and 1 deletions
Showing only changes of commit 9d6a7be31b - Show all commits
@@ -24,6 +24,7 @@ from __future__ import annotations
import json import json
import os import os
import tempfile import tempfile
import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -71,6 +72,34 @@ class AtomicJsonFile:
return None return None
# ---- ghi ------------------------------------------------------------ # ---- 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: 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.""" """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) self.path.parent.mkdir(parents=True, exist_ok=True)
@@ -87,7 +116,7 @@ class AtomicJsonFile:
f.write(text) f.write(text)
f.flush() f.flush()
os.fsync(f.fileno()) # xuống đĩa thật, không chỉ vào bộ đệm 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: except BaseException:
# Kể cả KeyboardInterrupt/SystemExit cũng phải dọn file tạm, đừng # 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. # để rác .tmp nằm lại cạnh file cấu hình.
+37
View File
@@ -103,3 +103,40 @@ def test_json_ghi_ra_doc_duoc_bang_thu_vien_chuan(tmp_path):
path = tmp_path / "c.json" path = tmp_path / "c.json"
AtomicJsonFile(path).write({"n": [1, 2, {"m": None}]}) AtomicJsonFile(path).write({"n": [1, 2, {"m": None}]})
assert json.loads(path.read_text(encoding="utf-8")) == {"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"