aps-agent/server/agent_core/audit_filelock.py

83 lines
2.7 KiB
Python
Raw Permalink Normal View History

# ============================================================
# 审计 append-only 文件锁辅助 v1(moduleId: core-audit-filelock, 可重生 ✅)
# plan.md §3.6 / 矩阵「审计 append-only」剩余项:跨进程锁强化
# JSONL 追加写使用跨进程独占锁(Windows msvcrt;其他平台退化为进程内锁),
# 防止多进程/多线程并发 append 行交错导致审计证据损坏;失败重试可配。
# ============================================================
from __future__ import annotations
import os
import threading
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any
# 锁重试(秒):拿不到锁时轮询等待,避免瞬时竞争直接失败
_LOCK_RETRY_SECONDS = float(os.environ.get("APS_AUDIT_LOCK_RETRY_SECONDS") or "10.0")
_LOCK_STEP = 0.02
try: # Windows:msvcrt 文件区域锁
import msvcrt
_HAS_MSVCRT = True
except ImportError: # 其他平台:仅进程内锁
_HAS_MSVCRT = False
_lock_registry: dict[str, threading.Lock] = {}
_lock_registry_guard = threading.Lock()
def _process_lock_for(path: Path) -> threading.Lock:
key = str(path)
with _lock_registry_guard:
lock = _lock_registry.get(key)
if lock is None:
lock = threading.Lock()
_lock_registry[key] = lock
return lock
@contextmanager
def locked_append(path: Path) -> Any:
"""跨进程安全的 JSONL 追加写上下文。
先取进程内锁(串行化同进程线程),再取跨进程文件锁(msvcrt 区域锁),
最后以追加模式打开文件并 yield file handle;退出时 flush+fsync。
"""
proc = _process_lock_for(path)
with proc:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "a", encoding="utf-8") as fh:
if _HAS_MSVCRT:
_acquire_msvcrt(fh)
try:
yield fh
finally:
_release_msvcrt(fh)
else:
yield fh
fh.flush()
os.fsync(fh.fileno())
def _acquire_msvcrt(fh: Any) -> None:
"""对文件首字节区域加独占锁(重试至超时)。"""
deadline = time.monotonic() + _LOCK_RETRY_SECONDS
while True:
try:
fh.seek(0)
msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK, 1)
return
except OSError:
if time.monotonic() >= deadline:
raise
time.sleep(_LOCK_STEP)
def _release_msvcrt(fh: Any) -> None:
try:
fh.seek(0)
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
except OSError:
pass