forked from retoor/devplacepy
Phase 3 of plan.md. Device registration with one-time secrets, scoped single-use socket tickets, the lock-owner WS /nodes/ws with handshake and heartbeat eviction, a single batch ConsentGate, and NodesService which disconnects nodes within one tick when consent, device or the nodes_enabled kill switch changes. A reconcile of 2000 sessions measured about 110 ms after replacing per-session queries with batches. Devii may withdraw but never grant bandwidth_sharing. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
85 lines
2.6 KiB
Python
85 lines
2.6 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import Counter
|
|
|
|
from devplacepy.database import get_int_setting
|
|
from devplacepy.services.nodes.session import NodeSession
|
|
from devplacepy.services.nodes.spec import SPEC
|
|
|
|
LIMITS = SPEC.limits
|
|
|
|
COUNTER_NAMES: tuple[str, ...] = (
|
|
"connected_total",
|
|
"rejected_capacity",
|
|
"rejected_ticket",
|
|
"rejected_consent",
|
|
"rejected_device",
|
|
"protocol_errors",
|
|
"evicted_heartbeat",
|
|
"kicked",
|
|
"replaced",
|
|
)
|
|
|
|
|
|
class NodeCounters:
|
|
def __init__(self) -> None:
|
|
self._counts: Counter[str] = Counter({name: 0 for name in COUNTER_NAMES})
|
|
|
|
def inc(self, name: str) -> None:
|
|
if name not in COUNTER_NAMES:
|
|
raise KeyError(name)
|
|
self._counts[name] += 1
|
|
|
|
def snapshot(self) -> dict[str, int]:
|
|
return {name: self._counts[name] for name in COUNTER_NAMES}
|
|
|
|
|
|
class NodeRegistry:
|
|
def __init__(self, counters: NodeCounters) -> None:
|
|
self._sessions: dict[str, NodeSession] = {}
|
|
self.counters = counters
|
|
|
|
def capacity(self) -> int:
|
|
configured = get_int_setting("nodes_max_total", LIMITS.max_connected_nodes)
|
|
return max(0, min(configured, LIMITS.max_connected_nodes))
|
|
|
|
def count(self) -> int:
|
|
return len(self._sessions)
|
|
|
|
def sessions(self) -> list[NodeSession]:
|
|
return list(self._sessions.values())
|
|
|
|
def get(self, device_uid: str) -> NodeSession | None:
|
|
return self._sessions.get(device_uid)
|
|
|
|
def by_user(self, user_uid: str) -> list[NodeSession]:
|
|
return [item for item in self._sessions.values() if item.user_uid == user_uid]
|
|
|
|
def admit(self, session: NodeSession) -> tuple[bool, NodeSession | None]:
|
|
previous = self._sessions.get(session.device_uid)
|
|
if previous is None and self.count() >= self.capacity():
|
|
self.counters.inc("rejected_capacity")
|
|
return False, None
|
|
self._sessions[session.device_uid] = session
|
|
self.counters.inc("connected_total")
|
|
if previous is not None:
|
|
self.counters.inc("replaced")
|
|
return True, previous
|
|
|
|
def release(self, session: NodeSession) -> None:
|
|
if self._sessions.get(session.device_uid) is session:
|
|
del self._sessions[session.device_uid]
|
|
|
|
async def kick(self, session: NodeSession, reason: str) -> None:
|
|
self.release(session)
|
|
self.counters.inc("kicked")
|
|
await session.kick(reason)
|
|
|
|
async def kick_all(self, reason: str) -> int:
|
|
sessions = self.sessions()
|
|
for session in sessions:
|
|
await self.kick(session, reason)
|
|
return len(sessions)
|