pythonasyncioeventletgeventlong-pollinglow-latencysocket-iosocketiosocketio-serverweb-serverwebsocket
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
298 lines
12 KiB
298 lines
12 KiB
import copy
|
|
import time
|
|
import threading
|
|
from collections import deque
|
|
from typing import Any, Dict, List, Optional, Set, Union
|
|
|
|
|
|
class RoomHistory:
|
|
"""Manages message history for a single (namespace, room) pair."""
|
|
|
|
def __init__(self, max_entries: int = 100, retention_seconds: Optional[float] = None,
|
|
payload_size_cap: Optional[int] = None):
|
|
# Validate inputs
|
|
if max_entries <= 0:
|
|
raise ValueError("max_entries must be positive")
|
|
if retention_seconds is not None and retention_seconds <= 0:
|
|
raise ValueError("retention_seconds must be positive")
|
|
if payload_size_cap is not None and payload_size_cap <= 0:
|
|
raise ValueError("payload_size_cap must be positive")
|
|
|
|
self.max_entries = max_entries
|
|
self.retention_seconds = retention_seconds
|
|
self.payload_size_cap = payload_size_cap
|
|
self.enabled = True
|
|
self.buffer: deque = deque(maxlen=max_entries)
|
|
self.lock = threading.Lock()
|
|
|
|
# Statistics
|
|
self.evictions_size = 0
|
|
self.evictions_time = 0
|
|
|
|
def add_entry(self, event: str, data: Any, timestamp: float):
|
|
"""Add a new entry to the history buffer."""
|
|
if not self.enabled:
|
|
return
|
|
|
|
with self.lock:
|
|
# Apply enable-time payload cap if configured
|
|
if self.payload_size_cap is not None:
|
|
data = self._truncate_payload(data, self.payload_size_cap)
|
|
|
|
entry = {
|
|
"event": event,
|
|
"data": data,
|
|
"timestamp": timestamp
|
|
}
|
|
|
|
# Check if we're at capacity before adding
|
|
if len(self.buffer) == self.max_entries:
|
|
self.evictions_size += 1
|
|
|
|
self.buffer.append(entry)
|
|
|
|
# Prune old entries based on retention time
|
|
if self.retention_seconds is not None:
|
|
self._prune_old_entries(timestamp)
|
|
|
|
def _prune_old_entries(self, current_time: float):
|
|
"""Remove entries older than retention_seconds."""
|
|
cutoff_time = current_time - self.retention_seconds
|
|
|
|
while self.buffer and self.buffer[0]["timestamp"] < cutoff_time:
|
|
self.buffer.popleft()
|
|
self.evictions_time += 1
|
|
|
|
def _truncate_payload(self, data: Any, size_cap: int) -> Any:
|
|
"""Truncate string or bytes payloads to the specified size."""
|
|
if isinstance(data, str):
|
|
return data[:size_cap]
|
|
elif isinstance(data, bytes):
|
|
return data[:size_cap]
|
|
elif isinstance(data, dict):
|
|
# Recursively truncate dict values
|
|
return {k: self._truncate_payload(v, size_cap) for k, v in data.items()}
|
|
elif isinstance(data, (list, tuple)):
|
|
# Recursively truncate list/tuple items
|
|
result = [self._truncate_payload(item, size_cap) for item in data]
|
|
return type(data)(result) if isinstance(data, tuple) else result
|
|
return data
|
|
|
|
def get_history(self, limit: int, include_events: Optional[Set[str]] = None,
|
|
exclude_events: Optional[Set[str]] = None,
|
|
payload_size_cap: Optional[int] = None) -> List[Dict[str, Any]]:
|
|
"""Retrieve history with filters applied."""
|
|
if not self.enabled:
|
|
return []
|
|
|
|
with self.lock:
|
|
# Prune old entries if retention is configured
|
|
if self.retention_seconds is not None:
|
|
self._prune_old_entries(time.time())
|
|
|
|
# Convert buffer to list for filtering
|
|
entries = list(self.buffer)
|
|
|
|
# Apply include filter first
|
|
if include_events is not None:
|
|
entries = [e for e in entries if e["event"] in include_events]
|
|
|
|
# Apply exclude filter second
|
|
if exclude_events is not None:
|
|
entries = [e for e in entries if e["event"] not in exclude_events]
|
|
|
|
# Select the most recent N entries (where N = limit)
|
|
if len(entries) > limit:
|
|
entries = entries[-limit:]
|
|
|
|
# Apply fetch-time payload cap if provided (overrides enable-time cap)
|
|
if payload_size_cap is not None:
|
|
entries = [
|
|
{
|
|
"event": e["event"],
|
|
"data": self._truncate_payload(e["data"], payload_size_cap),
|
|
"timestamp": e["timestamp"]
|
|
}
|
|
for e in entries
|
|
]
|
|
else:
|
|
# Deep copy to avoid external modifications
|
|
entries = [
|
|
{
|
|
"event": e["event"],
|
|
"data": copy.deepcopy(e["data"]),
|
|
"timestamp": e["timestamp"]
|
|
}
|
|
for e in entries
|
|
]
|
|
|
|
return entries
|
|
|
|
def get_stats(self) -> Dict[str, int]:
|
|
"""Get statistics for this room's history."""
|
|
with self.lock:
|
|
return {
|
|
"entries": len(self.buffer),
|
|
"evictions_size": self.evictions_size,
|
|
"evictions_time": self.evictions_time
|
|
}
|
|
|
|
def disable(self):
|
|
"""Disable history recording for this room."""
|
|
with self.lock:
|
|
self.enabled = False
|
|
|
|
def enable(self):
|
|
"""Enable history recording for this room."""
|
|
with self.lock:
|
|
self.enabled = True
|
|
|
|
def clear(self):
|
|
"""Clear all entries from the buffer."""
|
|
with self.lock:
|
|
self.buffer.clear()
|
|
|
|
def configure(self, enabled: Optional[bool] = None, max_entries: Optional[int] = None,
|
|
retention_seconds: Optional[float] = None,
|
|
payload_size_cap: Optional[int] = None):
|
|
"""Update configuration for this room's history."""
|
|
with self.lock:
|
|
if enabled is not None:
|
|
self.enabled = enabled
|
|
if max_entries is not None:
|
|
if max_entries <= 0:
|
|
raise ValueError("max_entries must be positive")
|
|
self.max_entries = max_entries
|
|
# Resize the buffer
|
|
new_buffer = deque(self.buffer, maxlen=max_entries)
|
|
evicted = len(self.buffer) - len(new_buffer)
|
|
if evicted > 0:
|
|
self.evictions_size += evicted
|
|
self.buffer = new_buffer
|
|
if retention_seconds is not None:
|
|
if retention_seconds <= 0:
|
|
raise ValueError("retention_seconds must be positive")
|
|
self.retention_seconds = retention_seconds
|
|
if payload_size_cap is not None:
|
|
if payload_size_cap <= 0:
|
|
raise ValueError("payload_size_cap must be positive")
|
|
self.payload_size_cap = payload_size_cap
|
|
|
|
|
|
class MessageHistory:
|
|
"""Manages message history for all rooms across all namespaces."""
|
|
|
|
def __init__(self):
|
|
self.histories: Dict[tuple, RoomHistory] = {}
|
|
self.lock = threading.Lock()
|
|
|
|
def _get_key(self, namespace: str, room: str) -> tuple:
|
|
"""Get the key for a (namespace, room) pair."""
|
|
return (namespace, room)
|
|
|
|
def record_message(self, event: str, data: Any, namespace: str, room: str):
|
|
"""Record a message to the history if enabled for this room."""
|
|
key = self._get_key(namespace, room)
|
|
|
|
# Fast path: check without holding lock for too long
|
|
history = self.histories.get(key)
|
|
if history is None:
|
|
return
|
|
|
|
# Record outside the main lock to minimize contention
|
|
timestamp = time.time()
|
|
try:
|
|
history.add_entry(event, data, timestamp)
|
|
except Exception:
|
|
# Best-effort: if recording fails, don't break the emit
|
|
pass
|
|
|
|
def enable_history(self, room: str, namespace: str = "/", max_entries: int = 100,
|
|
retention_seconds: Optional[float] = None,
|
|
payload_size_cap: Optional[int] = None):
|
|
"""Enable history for a specific room."""
|
|
key = self._get_key(namespace, room)
|
|
|
|
with self.lock:
|
|
if key not in self.histories:
|
|
self.histories[key] = RoomHistory(
|
|
max_entries=max_entries,
|
|
retention_seconds=retention_seconds,
|
|
payload_size_cap=payload_size_cap
|
|
)
|
|
else:
|
|
# Re-enabling: clear buffer and enable
|
|
self.histories[key].clear()
|
|
self.histories[key].enable()
|
|
|
|
def disable_history(self, room: str, namespace: str = "/"):
|
|
"""Disable history for a specific room."""
|
|
key = self._get_key(namespace, room)
|
|
|
|
with self.lock:
|
|
if key in self.histories:
|
|
self.histories[key].disable()
|
|
|
|
def configure_history(self, room: str, namespace: str = "/", enabled: Optional[bool] = None,
|
|
max_entries: Optional[int] = None,
|
|
retention_seconds: Optional[float] = None,
|
|
payload_size_cap: Optional[int] = None):
|
|
"""Configure history settings for a specific room."""
|
|
key = self._get_key(namespace, room)
|
|
|
|
with self.lock:
|
|
if key not in self.histories:
|
|
# Create new history with provided settings
|
|
self.histories[key] = RoomHistory(
|
|
max_entries=max_entries or 100,
|
|
retention_seconds=retention_seconds,
|
|
payload_size_cap=payload_size_cap
|
|
)
|
|
if enabled is not None:
|
|
self.histories[key].enabled = enabled
|
|
else:
|
|
# Check if re-enabling (was disabled, now enabled)
|
|
was_enabled = self.histories[key].enabled
|
|
if enabled is True and not was_enabled:
|
|
# Re-enabling: clear buffer
|
|
self.histories[key].clear()
|
|
|
|
self.histories[key].configure(
|
|
enabled=enabled,
|
|
max_entries=max_entries,
|
|
retention_seconds=retention_seconds,
|
|
payload_size_cap=payload_size_cap
|
|
)
|
|
|
|
def get_history(self, room: str, limit: int = 50, namespace: str = "/",
|
|
include_events: Optional[Union[List[str], tuple, Set[str]]] = None,
|
|
exclude_events: Optional[Union[List[str], tuple, Set[str]]] = None,
|
|
payload_size_cap: Optional[int] = None) -> List[Dict[str, Any]]:
|
|
"""Get history for a specific room with optional filters."""
|
|
key = self._get_key(namespace, room)
|
|
|
|
with self.lock:
|
|
if key not in self.histories:
|
|
return []
|
|
history = self.histories[key]
|
|
|
|
# Convert iterables to sets for efficient lookup
|
|
include_set = set(include_events) if include_events is not None else None
|
|
exclude_set = set(exclude_events) if exclude_events is not None else None
|
|
|
|
return history.get_history(limit, include_set, exclude_set, payload_size_cap)
|
|
|
|
def get_stats(self, room: str, namespace: str = "/") -> Dict[str, int]:
|
|
"""Get statistics for a specific room's history."""
|
|
key = self._get_key(namespace, room)
|
|
|
|
with self.lock:
|
|
if key not in self.histories:
|
|
return {
|
|
"entries": 0,
|
|
"evictions_size": 0,
|
|
"evictions_time": 0
|
|
}
|
|
history = self.histories[key]
|
|
|
|
return history.get_stats()
|