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