aps-agent/server/agent_core/audit_filelock.py

83 lines
2.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 审计 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