@@ -0,0 +1,141 @@
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from threading import Lock, Semaphore
|
||||
from typing import Any, Deque, Dict, Optional, Tuple
|
||||
|
||||
from config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SyncRuntime:
|
||||
"""Потокобезопасные runtime-примитивы для sync API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._semaphore = Semaphore(Config.SYNC_MAX_CONCURRENT)
|
||||
self._rate_limits: Dict[str, Deque[float]] = defaultdict(deque)
|
||||
self._pull_client_mutex = Lock()
|
||||
self._pull_client_locks: Dict[str, Lock] = {}
|
||||
# Дескрипторы flock (POSIX) — освобождать в release_pull_client.
|
||||
self._ipc_pull_fps: Dict[str, Any] = {}
|
||||
self._pull_durations_sec: Deque[float] = deque(maxlen=256)
|
||||
|
||||
def acquire_slot(self) -> bool:
|
||||
return self._semaphore.acquire(blocking=False)
|
||||
|
||||
def release_slot(self) -> None:
|
||||
self._semaphore.release()
|
||||
|
||||
def acquire_pull_client(self, client_id: str, *, wait_sec: Optional[float] = None) -> bool:
|
||||
"""
|
||||
Не более одного параллельного /pull на один node_id.
|
||||
|
||||
На POSIX — flock (видно всем процессам: reloader / несколько воркеров).
|
||||
На Windows — только threading.Lock внутри процесса.
|
||||
"""
|
||||
cid = (client_id or "").strip()
|
||||
if not cid:
|
||||
return False
|
||||
if wait_sec is None:
|
||||
wait_sec = float(getattr(Config, "SYNC_PULL_CLIENT_LOCK_WAIT_SEC", 180.0))
|
||||
|
||||
from app.services.sync_pull_ipc import ipc_pull_acquire, ipc_pull_supported
|
||||
|
||||
if ipc_pull_supported():
|
||||
fp = ipc_pull_acquire(cid, wait_sec)
|
||||
if fp is None:
|
||||
return False
|
||||
self._ipc_pull_fps[cid] = fp
|
||||
logger.debug("[SYNC-IPC] pull lock node %s…", cid[:12])
|
||||
return True
|
||||
|
||||
with self._pull_client_mutex:
|
||||
lk = self._pull_client_locks.get(cid)
|
||||
if lk is None:
|
||||
lk = Lock()
|
||||
self._pull_client_locks[cid] = lk
|
||||
if wait_sec <= 0:
|
||||
return lk.acquire(blocking=False)
|
||||
return lk.acquire(blocking=True, timeout=wait_sec)
|
||||
|
||||
def release_pull_client(self, client_id: str) -> None:
|
||||
cid = (client_id or "").strip()
|
||||
if not cid:
|
||||
return
|
||||
from app.services.sync_pull_ipc import ipc_pull_release, ipc_pull_supported
|
||||
|
||||
if ipc_pull_supported():
|
||||
fp = self._ipc_pull_fps.pop(cid, None)
|
||||
ipc_pull_release(fp)
|
||||
return
|
||||
lk = self._pull_client_locks.get(cid)
|
||||
if not lk:
|
||||
return
|
||||
try:
|
||||
lk.release()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def concurrency_status(self) -> Tuple[int, int]:
|
||||
available = self._semaphore._value # type: ignore[attr-defined]
|
||||
used = Config.SYNC_MAX_CONCURRENT - available
|
||||
logger.debug(
|
||||
"[SYNC-CONCURRENCY] Используется: %s/%s слотов синхронизации",
|
||||
used,
|
||||
Config.SYNC_MAX_CONCURRENT,
|
||||
)
|
||||
return used, available
|
||||
|
||||
def check_rate_limit(self, client_id: str) -> bool:
|
||||
now = time.time()
|
||||
window = 60
|
||||
dq = self._rate_limits[client_id]
|
||||
|
||||
while dq and dq[0] <= now - window:
|
||||
dq.popleft()
|
||||
|
||||
if len(dq) >= Config.SYNC_MAX_CONCURRENT * 10:
|
||||
logger.warning("[SYNC-RATE-LIMIT] Клиент %s превысил лимит", client_id[:8])
|
||||
return False
|
||||
|
||||
dq.append(now)
|
||||
return True
|
||||
|
||||
def record_pull_duration(self, seconds: float) -> None:
|
||||
self._pull_durations_sec.append(max(0.0, float(seconds)))
|
||||
|
||||
def pull_duration_p95(self) -> float:
|
||||
items = sorted(self._pull_durations_sec)
|
||||
if not items:
|
||||
return 0.0
|
||||
idx = int(0.95 * (len(items) - 1))
|
||||
return float(items[idx])
|
||||
|
||||
def diagnostics_snapshot(self) -> Dict[str, Any]:
|
||||
"""Метрики только текущего процесса (воркера), не кластера целиком."""
|
||||
from app.services.sync_pull_ipc import ipc_pull_supported
|
||||
|
||||
used, available = self.concurrency_status()
|
||||
tracked = sum(1 for dq in self._rate_limits.values() if dq)
|
||||
ipc_clients = len(self._ipc_pull_fps)
|
||||
thread_lock_clients = len(self._pull_client_locks)
|
||||
return {
|
||||
"scope": "process_local",
|
||||
"note": (
|
||||
"Счётчики слотов, p95 длительности pull и окна rate-limit относятся к этому "
|
||||
"процессу Python. При нескольких воркерах uWSGI/gunicorn значения различаются."
|
||||
),
|
||||
"concurrency_used": used,
|
||||
"concurrency_available": available,
|
||||
"concurrency_max": int(Config.SYNC_MAX_CONCURRENT),
|
||||
"pull_duration_p95_sec": self.pull_duration_p95(),
|
||||
"pull_duration_samples": len(self._pull_durations_sec),
|
||||
"pull_lock_ipc_supported": bool(ipc_pull_supported()),
|
||||
"pull_lock_ipc_held_clients": ipc_clients,
|
||||
"pull_lock_thread_clients": thread_lock_clients,
|
||||
"rate_limit_windows_with_activity": tracked,
|
||||
}
|
||||
|
||||
|
||||
sync_runtime = SyncRuntime()
|
||||
Reference in New Issue
Block a user