fix(stats): 添加文件锁(fcntl.flock)防止并发竞态
- 添加 _acquire_lock() 和 _release_lock() 辅助函数 - _load_stats_data() 和 _atomic_write() 使用文件锁保护 - 使用独立的 .lock 文件作为锁 - 防止并发读写导致数据损坏
This commit is contained in:
@@ -8,6 +8,7 @@ Cross 模式统计报告模块
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import fcntl
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Any, Optional
|
from typing import Dict, List, Any, Optional
|
||||||
@@ -26,12 +27,32 @@ def _ensure_stats_dir():
|
|||||||
STATS_DIR.mkdir(parents=True, exist_ok=True)
|
STATS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# 锁文件路径
|
||||||
|
LOCK_FILE = STATS_DIR / "stats_data.lock"
|
||||||
|
|
||||||
|
|
||||||
|
def _acquire_lock(lock_file_path: Path):
|
||||||
|
"""获取文件锁"""
|
||||||
|
lock_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
lock_fd = open(lock_file_path, 'w')
|
||||||
|
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX)
|
||||||
|
return lock_fd
|
||||||
|
|
||||||
|
|
||||||
|
def _release_lock(lock_fd):
|
||||||
|
"""释放文件锁"""
|
||||||
|
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
|
||||||
|
lock_fd.close()
|
||||||
|
|
||||||
|
|
||||||
def _load_stats_data() -> Dict[str, Any]:
|
def _load_stats_data() -> Dict[str, Any]:
|
||||||
"""加载统计数据"""
|
"""加载统计数据(带锁保护)"""
|
||||||
if not STATS_FILE.exists():
|
if not STATS_FILE.exists():
|
||||||
return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}}
|
return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}}
|
||||||
|
|
||||||
|
lock_fd = None
|
||||||
try:
|
try:
|
||||||
|
lock_fd = _acquire_lock(LOCK_FILE)
|
||||||
with open(STATS_FILE, 'r', encoding='utf-8') as f:
|
with open(STATS_FILE, 'r', encoding='utf-8') as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
@@ -44,25 +65,40 @@ def _load_stats_data() -> Dict[str, Any]:
|
|||||||
return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}}
|
return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}}
|
||||||
except IOError:
|
except IOError:
|
||||||
return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}}
|
return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}}
|
||||||
|
finally:
|
||||||
|
if lock_fd:
|
||||||
|
_release_lock(lock_fd)
|
||||||
|
|
||||||
|
|
||||||
def _atomic_write(data: Dict[str, Any]):
|
def _atomic_write(data: Dict[str, Any]):
|
||||||
"""原子写入统计数据"""
|
"""原子写入统计数据(带锁保护)"""
|
||||||
_ensure_stats_dir()
|
_ensure_stats_dir()
|
||||||
|
|
||||||
# 写入临时文件
|
lock_fd = None
|
||||||
fd, temp_path = tempfile.mkstemp(dir=STATS_DIR, suffix='.tmp')
|
temp_path = None
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
lock_fd = _acquire_lock(LOCK_FILE)
|
||||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
||||||
|
# 写入临时文件
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode='w',
|
||||||
|
encoding='utf-8',
|
||||||
|
dir=STATS_DIR,
|
||||||
|
suffix='.tmp',
|
||||||
|
delete=False
|
||||||
|
) as temp_file:
|
||||||
|
json.dump(data, temp_file, ensure_ascii=False, indent=2)
|
||||||
|
temp_path = temp_file.name
|
||||||
|
|
||||||
# 原子重命名
|
# 原子重命名
|
||||||
os.replace(temp_path, STATS_FILE)
|
os.replace(temp_path, STATS_FILE)
|
||||||
except Exception:
|
temp_path = None # 防止在 finally 中再次删除
|
||||||
|
finally:
|
||||||
|
if lock_fd:
|
||||||
|
_release_lock(lock_fd)
|
||||||
# 清理临时文件
|
# 清理临时文件
|
||||||
if os.path.exists(temp_path):
|
if temp_path and os.path.exists(temp_path):
|
||||||
os.unlink(temp_path)
|
os.unlink(temp_path)
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_sensitive_data(data: Dict[str, Any]) -> Dict[str, Any]:
|
def _filter_sensitive_data(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
|||||||
Reference in New Issue
Block a user