Feature/delta team/epic r04 #7
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user