Browse Source
- Implement message history functionality in server.py - Add message_history.py module for history management - Add comprehensive tests for message history - Include problem description and solution documentationpull/1550/head
7 changed files with 2924 additions and 0 deletions
@ -0,0 +1,950 @@ |
|||||
|
import time |
||||
|
import inspect |
||||
|
import functools |
||||
|
import socketio |
||||
|
import pytest |
||||
|
|
||||
|
|
||||
|
def _mk_server(): |
||||
|
"""Basic in-memory server suitable for unit tests.""" |
||||
|
return socketio.Server(async_mode="threading") |
||||
|
|
||||
|
|
||||
|
# ---------- Discovery and adaptation helpers ---------- |
||||
|
|
||||
|
def _iter_callables(obj, max_depth=3): |
||||
|
"""Yield (owner, name, callable) by recursively scanning attributes up to max_depth.""" |
||||
|
seen = set() |
||||
|
|
||||
|
def walk(target, depth): |
||||
|
if depth > max_depth: |
||||
|
return |
||||
|
for name in dir(target): |
||||
|
if name.startswith("_"): |
||||
|
continue |
||||
|
try: |
||||
|
cand = getattr(target, name, None) |
||||
|
except Exception: |
||||
|
continue |
||||
|
if callable(cand): |
||||
|
key = (id(target), name) |
||||
|
if key not in seen: |
||||
|
seen.add(key) |
||||
|
yield (target, name, cand) |
||||
|
if cand is None or callable(cand): |
||||
|
continue |
||||
|
try: |
||||
|
yield from walk(cand, depth + 1) |
||||
|
except Exception: |
||||
|
continue |
||||
|
|
||||
|
yield from walk(obj, 0) |
||||
|
|
||||
|
|
||||
|
def _find_callable(obj, predicate): |
||||
|
"""Find first callable matching predicate.""" |
||||
|
for owner, name, fn in _iter_callables(obj): |
||||
|
if predicate(name, fn): |
||||
|
return name, fn |
||||
|
return None, None |
||||
|
|
||||
|
|
||||
|
def _has_roomish_param(fn): |
||||
|
"""Check if function has a room-like parameter.""" |
||||
|
try: |
||||
|
sig = inspect.signature(fn) |
||||
|
except (TypeError, ValueError): |
||||
|
return False |
||||
|
pnames = set(sig.parameters.keys()) |
||||
|
# Only use aliases from the spec |
||||
|
return _pick_alias(pnames, ["room", "room_id", "room_name"]) is not None |
||||
|
|
||||
|
|
||||
|
def _pick_alias(param_names, aliases): |
||||
|
"""Return the first alias that exists in param_names, else None.""" |
||||
|
for a in aliases: |
||||
|
if a in param_names: |
||||
|
return a |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
def _wrap_kwargs(fn, semantic_kwargs=True): |
||||
|
"""Build a wrapper for fn that accepts semantic kwargs using spec-defined aliases.""" |
||||
|
sig = inspect.signature(fn) |
||||
|
params = set(sig.parameters.keys()) |
||||
|
# Only aliases from the spec |
||||
|
alias_map = { |
||||
|
"room": ["room", "room_id", "room_name"], |
||||
|
"limit": ["limit", "n", "count", "max_items"], |
||||
|
"namespace": ["namespace", "ns"], |
||||
|
"include_events": ["include_events", "include", "events_include"], |
||||
|
"exclude_events": ["exclude_events", "exclude", "events_exclude"], |
||||
|
"payload_size_cap": ["payload_size_cap", "cap", "size_cap"], |
||||
|
"enabled": ["enabled", "enable", "on"], |
||||
|
"max_entries": ["max_entries", "max", "limit"], |
||||
|
"retention_seconds": ["retention_seconds", "retention", "ttl"], |
||||
|
} |
||||
|
|
||||
|
def map_kwargs(kwargs): |
||||
|
mapped = {} |
||||
|
for sem_key, value in kwargs.items(): |
||||
|
aliases = alias_map.get(sem_key, [sem_key]) |
||||
|
chosen = _pick_alias(params, aliases) |
||||
|
if chosen is not None: |
||||
|
mapped[chosen] = value |
||||
|
return mapped |
||||
|
|
||||
|
@functools.wraps(fn) |
||||
|
def wrapper(**kwargs): |
||||
|
return fn(**map_kwargs(kwargs)) |
||||
|
|
||||
|
return wrapper |
||||
|
|
||||
|
|
||||
|
def discover_get_history(sio): |
||||
|
"""Find the best matching fetch method by scoring candidates.""" |
||||
|
candidates = [] |
||||
|
for owner, name, fn in _iter_callables(sio): |
||||
|
lname = name.lower() |
||||
|
try: |
||||
|
sig = inspect.signature(fn) |
||||
|
pnames = set(sig.parameters.keys()) |
||||
|
except (TypeError, ValueError): |
||||
|
continue |
||||
|
|
||||
|
# Only use aliases from the spec |
||||
|
room_ok = _pick_alias(pnames, ["room", "room_id", "room_name"]) is not None |
||||
|
limit_ok = _pick_alias(pnames, ["limit", "n", "count", "max_items"]) is not None |
||||
|
any_opt = any(_pick_alias(pnames, alist) for alist in [ |
||||
|
["namespace", "ns"], |
||||
|
["include_events", "include", "events_include"], |
||||
|
["exclude_events", "exclude", "events_exclude"], |
||||
|
["payload_size_cap", "cap", "size_cap"], |
||||
|
]) |
||||
|
|
||||
|
has_fetch_get = any(k in lname for k in ["get", "fetch"]) or lname.startswith("list") |
||||
|
is_setter_like = any(k in lname for k in ["set", "configure", "update", "assign"]) and not ("get" in lname or "fetch" in lname) |
||||
|
has_history_hint = ("history" in lname) or ("recent" in lname) or ("buffer" in lname) |
||||
|
|
||||
|
score = 0 |
||||
|
if room_ok: |
||||
|
score += 2 |
||||
|
if limit_ok: |
||||
|
score += 2 |
||||
|
if any_opt: |
||||
|
score += 1 |
||||
|
if has_fetch_get: |
||||
|
score += 1 |
||||
|
if is_setter_like: |
||||
|
score -= 2 |
||||
|
if has_history_hint: |
||||
|
score += 1 |
||||
|
|
||||
|
if room_ok and limit_ok: |
||||
|
candidates.append((score, name, fn)) |
||||
|
|
||||
|
assert candidates, "Missing a room history getter per spec." |
||||
|
candidates.sort(key=lambda t: (-t[0], t[1])) |
||||
|
_, _, best = candidates[0] |
||||
|
return _wrap_kwargs(best, semantic_kwargs=True) |
||||
|
|
||||
|
|
||||
|
def discover_enable_disable_configure(sio): |
||||
|
"""Return a tuple of wrappers: (enable_fn, disable_fn, configure_fn, enable_params_set).""" |
||||
|
def pred_enable(name, fn): |
||||
|
lname = name.lower() |
||||
|
return any(k in lname for k in ["enable", "on", "start", "activate", "set"]) and _has_roomish_param(fn) |
||||
|
|
||||
|
def pred_disable(name, fn): |
||||
|
lname = name.lower() |
||||
|
return any(k in lname for k in ["disable", "off", "stop", "deactivate", "unset"]) and _has_roomish_param(fn) |
||||
|
|
||||
|
def pred_config(name, fn): |
||||
|
lname = name.lower() |
||||
|
return any(k in lname for k in ["config", "configure", "set"]) and _has_roomish_param(fn) |
||||
|
|
||||
|
_, e_fn = _find_callable(sio, pred_enable) |
||||
|
_, d_fn = _find_callable(sio, pred_disable) |
||||
|
_, c_fn = _find_callable(sio, pred_config) |
||||
|
|
||||
|
e_wrap = _wrap_kwargs(e_fn, semantic_kwargs=True) if e_fn else None |
||||
|
d_wrap = _wrap_kwargs(d_fn, semantic_kwargs=True) if d_fn else None |
||||
|
c_wrap = _wrap_kwargs(c_fn, semantic_kwargs=True) if c_fn else None |
||||
|
|
||||
|
enable_params_set = set() |
||||
|
if e_fn: |
||||
|
p = set(inspect.signature(e_fn).parameters.keys()) |
||||
|
for sem, aliases in { |
||||
|
"namespace": ["namespace", "ns"], |
||||
|
"max_entries": ["max_entries", "max", "limit"], |
||||
|
"retention_seconds": ["retention_seconds", "retention", "ttl"], |
||||
|
"payload_size_cap": ["payload_size_cap", "cap", "size_cap"], |
||||
|
"enabled": ["enabled", "enable", "on"], |
||||
|
}.items(): |
||||
|
if _pick_alias(p, aliases): |
||||
|
enable_params_set.add(sem) |
||||
|
|
||||
|
return e_wrap, d_wrap, c_wrap, enable_params_set |
||||
|
|
||||
|
|
||||
|
def discover_stats_getter(sio): |
||||
|
"""Return (stats_fn_wrapper, fields_alias) or (None, None).""" |
||||
|
def pred(name, fn): |
||||
|
lname = name.lower() |
||||
|
return any(k in lname for k in ["stats", "metrics", "observability", "counters", "counts"]) |
||||
|
|
||||
|
_, fn = _find_callable(sio, pred) |
||||
|
if not fn: |
||||
|
return None, None |
||||
|
|
||||
|
stats_wrap = _wrap_kwargs(fn, semantic_kwargs=True) |
||||
|
# Only aliases from the spec |
||||
|
fields_alias = { |
||||
|
"entries": ["entries_count", "entries", "count"], |
||||
|
"evict_size": ["evictions_size", "size_evictions", "evictions_by_size"], |
||||
|
"evict_time": ["evictions_time", "time_evictions", "evictions_by_time"], |
||||
|
} |
||||
|
return stats_wrap, fields_alias |
||||
|
|
||||
|
|
||||
|
def pick_stat_keys(stats_dict, fields_alias): |
||||
|
"""Pick actual keys from stats dict based on allowed aliases.""" |
||||
|
picked = {} |
||||
|
for need, aliases in fields_alias.items(): |
||||
|
for key in aliases: |
||||
|
if key in stats_dict: |
||||
|
picked[need] = key |
||||
|
break |
||||
|
assert need in picked, f"stats missing required semantic field: {need}" |
||||
|
return picked |
||||
|
|
||||
|
|
||||
|
# ---------- Tests ---------- |
||||
|
|
||||
|
def test_default_disabled_is_required_with_enable_path(): |
||||
|
"""History must be disabled by default and require explicit enablement.""" |
||||
|
sio = _mk_server() |
||||
|
get_history = discover_get_history(sio) |
||||
|
enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio) |
||||
|
|
||||
|
assert enable_fn or configure_fn, ( |
||||
|
"Spec requires per-room enablement." |
||||
|
) |
||||
|
|
||||
|
hist = get_history(room="__t:default_disabled__", limit=10, namespace="/") |
||||
|
assert isinstance(hist, list) |
||||
|
assert hist == [], "History must be empty before enabling." |
||||
|
|
||||
|
sio.emit("x_disabled", 1, room="__t:default_disabled__", namespace="/") |
||||
|
hist2 = get_history(room="__t:default_disabled__", limit=10, namespace="/") |
||||
|
assert hist2 == [] |
||||
|
|
||||
|
|
||||
|
def test_ordering_and_schema_after_enable(): |
||||
|
"""After enabling, messages should be recorded with correct schema and ordering.""" |
||||
|
sio = _mk_server() |
||||
|
get_history = discover_get_history(sio) |
||||
|
enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio) |
||||
|
|
||||
|
room = "__t:order__" |
||||
|
|
||||
|
if enable_fn: |
||||
|
kwargs = {"room": room} |
||||
|
if "namespace" in enable_params: |
||||
|
kwargs["namespace"] = "/" |
||||
|
if "max_entries" in enable_params: |
||||
|
kwargs["max_entries"] = 50 |
||||
|
enable_fn(**kwargs) |
||||
|
else: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
kwargs = {"room": room, "enabled": True} |
||||
|
if "namespace" in cfg_sig: |
||||
|
kwargs["namespace"] = "/" |
||||
|
max_key = _pick_alias(cfg_sig, ["max_entries", "max", "limit"]) |
||||
|
if max_key: |
||||
|
kwargs[max_key] = 50 |
||||
|
configure_fn(**kwargs) |
||||
|
|
||||
|
sio.emit("message", {"text": "hello"}, room=room) |
||||
|
time.sleep(0.01) |
||||
|
sio.emit("message", {"text": "world"}, room=room) |
||||
|
time.sleep(0.01) |
||||
|
sio.emit("message", {"text": "!"}, room=room) |
||||
|
|
||||
|
hist = get_history(room=room, limit=2, namespace="/") |
||||
|
assert isinstance(hist, list) |
||||
|
assert len(hist) == 2 |
||||
|
assert [e["data"]["text"] for e in hist] == ["world", "!"] |
||||
|
|
||||
|
now = time.time() |
||||
|
for e in hist: |
||||
|
assert "event" in e and "data" in e and "timestamp" in e |
||||
|
assert isinstance(e["timestamp"], float) |
||||
|
assert now - 24 * 3600 <= e["timestamp"] <= now + 5 |
||||
|
|
||||
|
|
||||
|
def test_include_only_and_exclude_only_filters_required(): |
||||
|
"""Include and exclude filters must work correctly.""" |
||||
|
sio = _mk_server() |
||||
|
get_history = discover_get_history(sio) |
||||
|
enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio) |
||||
|
|
||||
|
room = "__t:filters__" |
||||
|
|
||||
|
if enable_fn: |
||||
|
kwargs = {"room": room} |
||||
|
if "namespace" in enable_params: |
||||
|
kwargs["namespace"] = "/" |
||||
|
enable_fn(**kwargs) |
||||
|
else: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
kwargs = {"room": room, "enabled": True} |
||||
|
if "namespace" in cfg_sig: |
||||
|
kwargs["namespace"] = "/" |
||||
|
configure_fn(**kwargs) |
||||
|
|
||||
|
sio.emit("state", {"v": 1}, room=room) |
||||
|
sio.emit("info", {"v": 2}, room=room) |
||||
|
sio.emit("other", {"v": 3}, room=room) |
||||
|
|
||||
|
inc = get_history( |
||||
|
room=room, limit=10, namespace="/", |
||||
|
include_events=["state", "info"], exclude_events=None, payload_size_cap=None |
||||
|
) |
||||
|
assert all(h["event"] in {"state", "info"} for h in inc) |
||||
|
|
||||
|
exc = get_history( |
||||
|
room=room, limit=10, namespace="/", |
||||
|
include_events=None, exclude_events=["info"], payload_size_cap=None |
||||
|
) |
||||
|
assert all(h["event"] != "info" for h in exc) |
||||
|
|
||||
|
both = get_history( |
||||
|
room=room, limit=10, namespace="/", |
||||
|
include_events=("state", "info"), exclude_events={"info"}, payload_size_cap=None |
||||
|
) |
||||
|
assert all(h["event"] in {"state", "info"} for h in both) |
||||
|
assert all(h["event"] != "info" for h in both) |
||||
|
|
||||
|
def test_stats_getter_is_per_room(): |
||||
|
"""Stats getter must accept room parameter and return room-specific stats.""" |
||||
|
sio = _mk_server() |
||||
|
get_history = discover_get_history(sio) |
||||
|
stats_fn, fields_alias = discover_stats_getter(sio) |
||||
|
assert stats_fn is not None, "Spec requires stats getter" |
||||
|
|
||||
|
enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio) |
||||
|
|
||||
|
r1, r2 = "__t:stats_room:1__", "__t:stats_room:2__" |
||||
|
|
||||
|
# Enable both rooms |
||||
|
if enable_fn: |
||||
|
kwargs = {} |
||||
|
if "namespace" in enable_params: |
||||
|
kwargs["namespace"] = "/" |
||||
|
if "max_entries" in enable_params: |
||||
|
kwargs["max_entries"] = 10 |
||||
|
enable_fn(room=r1, **kwargs) |
||||
|
enable_fn(room=r2, **kwargs) |
||||
|
else: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
base = {"enabled": True} |
||||
|
if "namespace" in cfg_sig: |
||||
|
base["namespace"] = "/" |
||||
|
max_key = _pick_alias(cfg_sig, ["max_entries", "max", "limit"]) |
||||
|
if max_key: |
||||
|
base[max_key] = 10 |
||||
|
configure_fn(room=r1, **base) |
||||
|
configure_fn(room=r2, **base) |
||||
|
|
||||
|
# Emit different counts to each room |
||||
|
sio.emit("a", 1, room=r1) |
||||
|
sio.emit("a", 2, room=r1) |
||||
|
sio.emit("a", 3, room=r1) |
||||
|
sio.emit("b", 1, room=r2) |
||||
|
|
||||
|
# Stats should be per-room, not global |
||||
|
s1 = stats_fn(room=r1, namespace="/") |
||||
|
s2 = stats_fn(room=r2, namespace="/") |
||||
|
|
||||
|
keys1 = pick_stat_keys(s1, fields_alias) |
||||
|
keys2 = pick_stat_keys(s2, fields_alias) |
||||
|
|
||||
|
# Room 1 has 3 entries, Room 2 has 1 |
||||
|
assert s1[keys1["entries"]] == 3, "Stats must be per-room" |
||||
|
assert s2[keys2["entries"]] == 1, "Stats must be per-room" |
||||
|
|
||||
|
def test_size_eviction_required(): |
||||
|
"""Size-based eviction must work correctly (required feature).""" |
||||
|
sio = _mk_server() |
||||
|
get_history = discover_get_history(sio) |
||||
|
enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio) |
||||
|
|
||||
|
room1 = "__t:evict:size__" |
||||
|
|
||||
|
if enable_fn: |
||||
|
kwargs = {"room": room1} |
||||
|
if "namespace" in enable_params: |
||||
|
kwargs["namespace"] = "/" |
||||
|
if "max_entries" in enable_params: |
||||
|
kwargs["max_entries"] = 2 |
||||
|
enable_fn(**kwargs) |
||||
|
else: |
||||
|
enable_fn(**kwargs) |
||||
|
if configure_fn: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
max_key = _pick_alias(cfg_sig, ["max_entries", "max", "limit"]) |
||||
|
if max_key: |
||||
|
cfg_kwargs = {"room": room1, "enabled": True, max_key: 2} |
||||
|
if "namespace" in cfg_sig: |
||||
|
cfg_kwargs["namespace"] = "/" |
||||
|
configure_fn(**cfg_kwargs) |
||||
|
else: |
||||
|
pytest.fail("Spec requires a size cap knob; none discovered") |
||||
|
else: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
max_key = _pick_alias(cfg_sig, ["max_entries", "max", "limit"]) |
||||
|
assert max_key is not None, "Spec requires a size cap knob" |
||||
|
cfg_kwargs = {"room": room1, "enabled": True, max_key: 2} |
||||
|
if "namespace" in cfg_sig: |
||||
|
cfg_kwargs["namespace"] = "/" |
||||
|
configure_fn(**cfg_kwargs) |
||||
|
|
||||
|
for i in range(5): |
||||
|
sio.emit("message", {"i": i}, room=room1) |
||||
|
|
||||
|
h1 = get_history(room=room1, limit=10, namespace="/") |
||||
|
assert [e["data"]["i"] for e in h1] == [3, 4] |
||||
|
|
||||
|
def test_api_requirements_accessible_on_server(): |
||||
|
"""Required methods must be accessible directly on socketio.Server.""" |
||||
|
sio = _mk_server() |
||||
|
|
||||
|
assert hasattr(sio, 'enable_history') and callable(getattr(sio, 'enable_history')) |
||||
|
assert hasattr(sio, 'disable_history') and callable(getattr(sio, 'disable_history')) |
||||
|
assert hasattr(sio, 'get_history') and callable(getattr(sio, 'get_history')) |
||||
|
assert hasattr(sio, 'get_history_stats') and callable(getattr(sio, 'get_history_stats')) |
||||
|
|
||||
|
sig_enable = inspect.signature(sio.enable_history) |
||||
|
assert 'room' in sig_enable.parameters |
||||
|
|
||||
|
sig_disable = inspect.signature(sio.disable_history) |
||||
|
assert 'room' in sig_disable.parameters |
||||
|
|
||||
|
sig_get = inspect.signature(sio.get_history) |
||||
|
assert 'room' in sig_get.parameters |
||||
|
assert 'limit' in sig_get.parameters |
||||
|
|
||||
|
sig_stats = inspect.signature(sio.get_history_stats) |
||||
|
assert 'room' in sig_stats.parameters |
||||
|
|
||||
|
def test_filter_ordering_include_then_exclude(): |
||||
|
"""When both filters provided, include must be applied first, then exclude.""" |
||||
|
sio = _mk_server() |
||||
|
|
||||
|
room = "__t:filter_order__" |
||||
|
sio.enable_history(room=room) |
||||
|
|
||||
|
sio.emit("A", {"val": 1}, room=room) |
||||
|
sio.emit("B", {"val": 2}, room=room) |
||||
|
sio.emit("C", {"val": 3}, room=room) |
||||
|
sio.emit("D", {"val": 4}, room=room) |
||||
|
|
||||
|
result = sio.get_history( |
||||
|
room=room, limit=10, |
||||
|
include_events=["A", "B"], |
||||
|
exclude_events=["B", "D"] |
||||
|
) |
||||
|
|
||||
|
events = [e["event"] for e in result] |
||||
|
assert "A" in events, "A should be included" |
||||
|
assert "B" not in events, "B should be excluded (after being included)" |
||||
|
assert "C" not in events, "C should not be included" |
||||
|
assert "D" not in events, "D should be excluded" |
||||
|
assert len(events) == 1, "Only event A should remain" |
||||
|
|
||||
|
result2 = sio.get_history( |
||||
|
room=room, limit=10, |
||||
|
include_events=["B"], |
||||
|
exclude_events=["B"] |
||||
|
) |
||||
|
assert len(result2) == 0, "Include must be applied first, then exclude" |
||||
|
|
||||
|
def test_time_retention_optional(): |
||||
|
"""Time-based eviction (optional feature per spec).""" |
||||
|
sio = _mk_server() |
||||
|
get_history = discover_get_history(sio) |
||||
|
enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio) |
||||
|
|
||||
|
room2 = "__t:evict:time__" |
||||
|
|
||||
|
# Check if TTL is supported |
||||
|
ttl_supported = False |
||||
|
ret_key = None |
||||
|
|
||||
|
if enable_fn and "retention_seconds" in enable_params: |
||||
|
ttl_supported = True |
||||
|
elif configure_fn: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
ret_key = _pick_alias(cfg_sig, ["retention_seconds", "retention", "ttl"]) |
||||
|
if ret_key: |
||||
|
ttl_supported = True |
||||
|
|
||||
|
if not ttl_supported: |
||||
|
pytest.skip("Time-based expiry is optional per spec; not supported by this implementation") |
||||
|
|
||||
|
if enable_fn and "retention_seconds" in enable_params: |
||||
|
kwargs = {"room": room2, "retention_seconds": 0.5} |
||||
|
if "namespace" in enable_params: |
||||
|
kwargs["namespace"] = "/" |
||||
|
enable_fn(**kwargs) |
||||
|
else: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
ret_key = _pick_alias(cfg_sig, ["retention_seconds", "retention", "ttl"]) |
||||
|
cfg_kwargs = {"room": room2, "enabled": True, ret_key: 0.5} |
||||
|
if "namespace" in cfg_sig: |
||||
|
cfg_kwargs["namespace"] = "/" |
||||
|
configure_fn(**cfg_kwargs) |
||||
|
|
||||
|
sio.emit("m", {"x": 1}, room=room2) |
||||
|
time.sleep(0.7) |
||||
|
h2 = get_history(room=room2, limit=10, namespace="/") |
||||
|
assert h2 == [] |
||||
|
|
||||
|
|
||||
|
def test_namespace_isolation_using_fetch_namespace(): |
||||
|
"""History must be isolated by namespace.""" |
||||
|
sio = _mk_server() |
||||
|
get_history = discover_get_history(sio) |
||||
|
enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio) |
||||
|
|
||||
|
room = "__t:ns__" |
||||
|
ns1 = "/" |
||||
|
ns2 = "/admin" |
||||
|
|
||||
|
if enable_fn: |
||||
|
if "namespace" in enable_params: |
||||
|
enable_fn(room=room, namespace=ns1) |
||||
|
enable_fn(room=room, namespace=ns2) |
||||
|
else: |
||||
|
enable_fn(room=room) |
||||
|
elif configure_fn: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
if "namespace" in cfg_sig: |
||||
|
configure_fn(room=room, enabled=True, namespace=ns1) |
||||
|
configure_fn(room=room, enabled=True, namespace=ns2) |
||||
|
else: |
||||
|
configure_fn(room=room, enabled=True) |
||||
|
|
||||
|
sio.emit("e1", {"n": 1}, room=room, namespace=ns1) |
||||
|
sio.emit("e2", {"n": 2}, room=room, namespace=ns2) |
||||
|
|
||||
|
h1 = get_history(room=room, limit=10, namespace=ns1) |
||||
|
h2 = get_history(room=room, limit=10, namespace=ns2) |
||||
|
|
||||
|
assert any(e["event"] == "e1" for e in h1) |
||||
|
assert not any(e["event"] == "e2" for e in h1) |
||||
|
assert any(e["event"] == "e2" for e in h2) |
||||
|
assert not any(e["event"] == "e1" for e in h2) |
||||
|
|
||||
|
|
||||
|
def test_limit_applies_after_filters_oldest_to_newest(): |
||||
|
"""Limit should apply after filtering, returning oldest-to-newest.""" |
||||
|
sio = _mk_server() |
||||
|
get_history = discover_get_history(sio) |
||||
|
enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio) |
||||
|
|
||||
|
room = "__t:filters_limit__" |
||||
|
|
||||
|
if enable_fn: |
||||
|
kwargs = {"room": room} |
||||
|
if "namespace" in enable_params: |
||||
|
kwargs["namespace"] = "/" |
||||
|
enable_fn(**kwargs) |
||||
|
else: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
kwargs = {"room": room, "enabled": True} |
||||
|
if "namespace" in cfg_sig: |
||||
|
kwargs["namespace"] = "/" |
||||
|
configure_fn(**kwargs) |
||||
|
|
||||
|
for i in range(6): |
||||
|
sio.emit("state" if i % 2 == 0 else "info", {"i": i}, room=room) |
||||
|
|
||||
|
hist = get_history( |
||||
|
room=room, limit=2, namespace="/", |
||||
|
include_events=["state"], exclude_events=None, payload_size_cap=None |
||||
|
) |
||||
|
assert len(hist) == 2 |
||||
|
assert [e["data"]["i"] for e in hist] == [2, 4] |
||||
|
|
||||
|
|
||||
|
def test_default_namespace_fallback_behaves_like_root(): |
||||
|
"""Omitting namespace should default to root namespace.""" |
||||
|
sio = _mk_server() |
||||
|
get_history = discover_get_history(sio) |
||||
|
enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio) |
||||
|
|
||||
|
room = "__t:ns_default__" |
||||
|
|
||||
|
if enable_fn: |
||||
|
kwargs = {"room": room} |
||||
|
if "namespace" in enable_params: |
||||
|
kwargs["namespace"] = "/" |
||||
|
enable_fn(**kwargs) |
||||
|
else: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
kwargs = {"room": room, "enabled": True} |
||||
|
if "namespace" in cfg_sig: |
||||
|
kwargs["namespace"] = "/" |
||||
|
configure_fn(**kwargs) |
||||
|
|
||||
|
sio.emit("x", {"v": 1}, room=room) |
||||
|
h_no_ns = get_history(room=room, limit=10) |
||||
|
h_root = get_history(room=room, limit=10, namespace="/") |
||||
|
assert [e["event"] for e in h_no_ns] == [e["event"] for e in h_root] |
||||
|
|
||||
|
|
||||
|
def test_enable_time_payload_cap_if_supported(): |
||||
|
"""Enable-time payload cap should apply when fetch-time cap is omitted (if supported).""" |
||||
|
sio = _mk_server() |
||||
|
get_history = discover_get_history(sio) |
||||
|
enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio) |
||||
|
|
||||
|
room = "__t:cap_enable_only__" |
||||
|
long_txt = "abcdefghijklmnopqrstuvwxyz" |
||||
|
|
||||
|
# Check if enable-time cap is supported |
||||
|
applied = False |
||||
|
if enable_fn and "payload_size_cap" in enable_params: |
||||
|
kwargs = {"room": room, "payload_size_cap": 5} |
||||
|
if "namespace" in enable_params: |
||||
|
kwargs["namespace"] = "/" |
||||
|
enable_fn(**kwargs) |
||||
|
applied = True |
||||
|
elif configure_fn: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
cap_key = _pick_alias(cfg_sig, ["payload_size_cap", "cap", "size_cap"]) |
||||
|
enabled_key = _pick_alias(cfg_sig, ["enabled", "enable", "on"]) |
||||
|
if enabled_key and cap_key: |
||||
|
kwargs = {"room": room, enabled_key: True, cap_key: 5} |
||||
|
if "namespace" in cfg_sig: |
||||
|
kwargs["namespace"] = "/" |
||||
|
configure_fn(**kwargs) |
||||
|
applied = True |
||||
|
|
||||
|
if not applied: |
||||
|
pytest.skip("Payload truncation is optional; not supported by this implementation") |
||||
|
|
||||
|
sio.emit("text", long_txt, room=room) |
||||
|
hist = get_history(room=room, limit=10, namespace="/") |
||||
|
txt = next(e for e in hist if e["event"] == "text")["data"] |
||||
|
assert isinstance(txt, str) and len(txt) <= 5 |
||||
|
|
||||
|
def test_payload_size_cap_at_fetch_time_if_supported(): |
||||
|
"""Payload size cap should truncate strings and bytes at fetch time (if supported).""" |
||||
|
sio = _mk_server() |
||||
|
get_history = discover_get_history(sio) |
||||
|
enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio) |
||||
|
|
||||
|
# Check if fetch-time payload cap is supported |
||||
|
try: |
||||
|
sig = inspect.signature(get_history.__wrapped__) |
||||
|
except AttributeError: |
||||
|
sig = inspect.signature(get_history) |
||||
|
|
||||
|
params = set(sig.parameters.keys()) |
||||
|
cap_param = _pick_alias(params, ["payload_size_cap", "cap", "size_cap"]) |
||||
|
|
||||
|
if cap_param is None: |
||||
|
pytest.skip("Fetch-time payload truncation is optional; not supported by this implementation") |
||||
|
|
||||
|
room = "__t:cap__" |
||||
|
|
||||
|
if enable_fn: |
||||
|
kwargs = {"room": room} |
||||
|
if "namespace" in enable_params: |
||||
|
kwargs["namespace"] = "/" |
||||
|
enable_fn(**kwargs) |
||||
|
else: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
kwargs = {"room": room, "enabled": True} |
||||
|
if "namespace" in cfg_sig: |
||||
|
kwargs["namespace"] = "/" |
||||
|
configure_fn(**kwargs) |
||||
|
|
||||
|
sio.emit("text", "abcdefghij", room=room) |
||||
|
sio.emit("blob", b"ABCDEFGHIJ", room=room) |
||||
|
|
||||
|
hist = get_history( |
||||
|
room=room, limit=10, namespace="/", |
||||
|
include_events=None, exclude_events=None, payload_size_cap=5 |
||||
|
) |
||||
|
txt = next(e for e in hist if e["event"] == "text")["data"] |
||||
|
blob = next(e for e in hist if e["event"] == "blob")["data"] |
||||
|
assert txt == "abcde" |
||||
|
assert blob == b"ABCDE" |
||||
|
|
||||
|
|
||||
|
def test_fetch_time_cap_precedence_if_both_supported(): |
||||
|
"""Fetch-time payload cap takes precedence over enable-time cap (if both supported).""" |
||||
|
sio = _mk_server() |
||||
|
get_history = discover_get_history(sio) |
||||
|
enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio) |
||||
|
|
||||
|
# Check if fetch-time cap is supported |
||||
|
try: |
||||
|
sig = inspect.signature(get_history.__wrapped__) |
||||
|
except AttributeError: |
||||
|
sig = inspect.signature(get_history) |
||||
|
|
||||
|
params = set(sig.parameters.keys()) |
||||
|
fetch_cap_param = _pick_alias(params, ["payload_size_cap", "cap", "size_cap"]) |
||||
|
|
||||
|
if fetch_cap_param is None: |
||||
|
pytest.skip("Fetch-time payload truncation is optional; not supported") |
||||
|
|
||||
|
# Check if enable-time cap is supported |
||||
|
enable_cap_supported = False |
||||
|
if enable_fn and "payload_size_cap" in enable_params: |
||||
|
enable_cap_supported = True |
||||
|
elif configure_fn: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
if _pick_alias(cfg_sig, ["payload_size_cap", "cap", "size_cap"]): |
||||
|
enable_cap_supported = True |
||||
|
|
||||
|
if not enable_cap_supported: |
||||
|
pytest.skip("Enable-time payload cap not supported; cannot test precedence") |
||||
|
|
||||
|
room = "__t:cap-precedence__" |
||||
|
|
||||
|
# Set enable-time cap to 10 |
||||
|
if enable_fn and "payload_size_cap" in enable_params: |
||||
|
kwargs = {"room": room, "payload_size_cap": 10} |
||||
|
if "namespace" in enable_params: |
||||
|
kwargs["namespace"] = "/" |
||||
|
enable_fn(**kwargs) |
||||
|
else: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
cap_key = _pick_alias(cfg_sig, ["payload_size_cap", "cap", "size_cap"]) |
||||
|
kwargs = {"room": room, "enabled": True, cap_key: 10} |
||||
|
if "namespace" in cfg_sig: |
||||
|
kwargs["namespace"] = "/" |
||||
|
configure_fn(**kwargs) |
||||
|
|
||||
|
sio.emit("text", "abcdefghij", room=room) |
||||
|
|
||||
|
# Fetch with smaller cap (5); should override enable-time cap (10) |
||||
|
hist = get_history(room=room, limit=10, namespace="/", payload_size_cap=5) |
||||
|
txt = next(e for e in hist if e["event"] == "text")["data"] |
||||
|
assert txt == "abcde", "Fetch-time cap should take precedence" |
||||
|
|
||||
|
def test_cross_room_isolation_same_namespace(): |
||||
|
"""Different rooms in same namespace must have isolated history.""" |
||||
|
sio = _mk_server() |
||||
|
get_history = discover_get_history(sio) |
||||
|
enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio) |
||||
|
|
||||
|
r1, r2 = "__t:rooms:1__", "__t:rooms:2__" |
||||
|
|
||||
|
if enable_fn: |
||||
|
kwargs = {} |
||||
|
if "namespace" in enable_params: |
||||
|
kwargs["namespace"] = "/" |
||||
|
enable_fn(room=r1, **kwargs) |
||||
|
enable_fn(room=r2, **kwargs) |
||||
|
else: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
base = {"enabled": True} |
||||
|
if "namespace" in cfg_sig: |
||||
|
base["namespace"] = "/" |
||||
|
configure_fn(room=r1, **base) |
||||
|
configure_fn(room=r2, **base) |
||||
|
|
||||
|
sio.emit("a", 1, room=r1) |
||||
|
sio.emit("b", 2, room=r2) |
||||
|
|
||||
|
h1 = get_history(room=r1, limit=10, namespace="/") |
||||
|
h2 = get_history(room=r2, limit=10, namespace="/") |
||||
|
|
||||
|
assert any(e["event"] == "a" for e in h1) |
||||
|
assert not any(e["event"] == "b" for e in h1) |
||||
|
assert any(e["event"] == "b" for e in h2) |
||||
|
assert not any(e["event"] == "a" for e in h2) |
||||
|
|
||||
|
|
||||
|
def test_stats_required_with_size_eviction(): |
||||
|
"""Stats getter must return required fields with size eviction tracking.""" |
||||
|
sio = _mk_server() |
||||
|
get_history = discover_get_history(sio) |
||||
|
stats_fn, fields_alias = discover_stats_getter(sio) |
||||
|
assert stats_fn is not None, "Spec requires basic stats for observability" |
||||
|
|
||||
|
enable_fn, _, configure_fn, enable_params = discover_enable_disable_configure(sio) |
||||
|
|
||||
|
room = "__t:stats__" |
||||
|
|
||||
|
configured = False |
||||
|
if enable_fn: |
||||
|
kwargs = {"room": room} |
||||
|
if "namespace" in enable_params: |
||||
|
kwargs["namespace"] = "/" |
||||
|
if "max_entries" in enable_params: |
||||
|
kwargs["max_entries"] = 3 |
||||
|
enable_fn(**kwargs) |
||||
|
configured = True |
||||
|
|
||||
|
# Also try to configure via configure_fn if max_entries not in enable |
||||
|
if "max_entries" not in enable_params and configure_fn: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
max_key = _pick_alias(cfg_sig, ["max_entries", "max", "limit"]) |
||||
|
if max_key: |
||||
|
cfg_kwargs = {"room": room, "enabled": True, max_key: 3} |
||||
|
if "namespace" in cfg_sig: |
||||
|
cfg_kwargs["namespace"] = "/" |
||||
|
try: |
||||
|
configure_fn(**cfg_kwargs) |
||||
|
except TypeError: |
||||
|
pass |
||||
|
elif configure_fn: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
cfg_kwargs = {"room": room, "enabled": True} |
||||
|
if "namespace" in cfg_sig: |
||||
|
cfg_kwargs["namespace"] = "/" |
||||
|
max_key = _pick_alias(cfg_sig, ["max_entries", "max", "limit"]) |
||||
|
assert max_key, "Spec requires size limit parameter" |
||||
|
cfg_kwargs[max_key] = 3 |
||||
|
configure_fn(**cfg_kwargs) |
||||
|
configured = True |
||||
|
|
||||
|
assert configured, "Spec requires per-room enable/configuration" |
||||
|
|
||||
|
s0 = stats_fn(room=room, namespace="/") |
||||
|
assert isinstance(s0, dict) |
||||
|
keys = pick_stat_keys(s0, fields_alias) |
||||
|
|
||||
|
sio.emit("a", 1, room=room) |
||||
|
sio.emit("b", 2, room=room) |
||||
|
sio.emit("c", 3, room=room) |
||||
|
sio.emit("d", 4, room=room) |
||||
|
|
||||
|
s1 = stats_fn(room=room, namespace="/") |
||||
|
assert s1[keys["entries"]] >= 0 |
||||
|
assert s1[keys["evict_size"]] >= s0[keys["evict_size"]] |
||||
|
|
||||
|
for k in keys.values(): |
||||
|
assert s1[k] >= 0 |
||||
|
|
||||
|
|
||||
|
def test_disable_stops_new_recordings_and_getter_returns_empty(): |
||||
|
"""Disabling history must stop recording and return empty list.""" |
||||
|
sio = _mk_server() |
||||
|
get_history = discover_get_history(sio) |
||||
|
enable_fn, disable_fn, configure_fn, enable_params = discover_enable_disable_configure(sio) |
||||
|
|
||||
|
assert enable_fn or configure_fn, "Spec requires a per-room enable/configure mechanism." |
||||
|
assert disable_fn or configure_fn, "Spec requires a way to disable." |
||||
|
|
||||
|
room = "__t:disable:getter__" |
||||
|
|
||||
|
if enable_fn: |
||||
|
kwargs = {"room": room} |
||||
|
if "namespace" in enable_params: |
||||
|
kwargs["namespace"] = "/" |
||||
|
if "max_entries" in enable_params: |
||||
|
kwargs["max_entries"] = 5 |
||||
|
enable_fn(**kwargs) |
||||
|
else: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
kwargs = {"room": room, "enabled": True} |
||||
|
if "namespace" in cfg_sig: |
||||
|
kwargs["namespace"] = "/" |
||||
|
max_key = _pick_alias(cfg_sig, ["max_entries", "max", "limit"]) |
||||
|
if max_key: |
||||
|
kwargs[max_key] = 5 |
||||
|
configure_fn(**kwargs) |
||||
|
|
||||
|
sio.emit("before", {"v": 1}, room=room) |
||||
|
assert len(get_history(room=room, limit=10, namespace="/")) >= 1 |
||||
|
|
||||
|
if disable_fn: |
||||
|
disable_fn(room=room, namespace="/") |
||||
|
else: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
kwargs = {"room": room, "enabled": False} |
||||
|
if "namespace" in cfg_sig: |
||||
|
kwargs["namespace"] = "/" |
||||
|
configure_fn(**kwargs) |
||||
|
|
||||
|
sio.emit("after_disable", {"v": 2}, room=room) |
||||
|
assert get_history(room=room, limit=10, namespace="/") == [] |
||||
|
|
||||
|
if enable_fn: |
||||
|
kwargs = {"room": room} |
||||
|
if "namespace" in enable_params: |
||||
|
kwargs["namespace"] = "/" |
||||
|
enable_fn(**kwargs) |
||||
|
else: |
||||
|
cfg_sig = set(inspect.signature(configure_fn).parameters.keys()) |
||||
|
kwargs = {"room": room, "enabled": True} |
||||
|
if "namespace" in cfg_sig: |
||||
|
kwargs["namespace"] = "/" |
||||
|
configure_fn(**kwargs) |
||||
|
|
||||
|
sio.emit("after_reenable", {"v": 3}, room=room) |
||||
|
reh = get_history(room=room, limit=10, namespace="/") |
||||
|
assert any(e["event"] == "after_reenable" for e in reh) |
||||
|
assert not any(e["event"] == "after_disable" for e in reh), \ |
||||
|
"Events emitted while disabled MUST NOT surface after re-enabling" |
||||
|
|
||||
|
|
||||
|
def test_re_enable_starts_fresh(): |
||||
|
"""Re-enabling a room must start with empty history (no retained entries).""" |
||||
|
sio = _mk_server() |
||||
|
get_history = discover_get_history(sio) |
||||
|
enable_fn, disable_fn, configure_fn, enable_params = discover_enable_disable_configure(sio) |
||||
|
|
||||
|
room = "__t:re_enable_fresh__" |
||||
|
|
||||
|
# Enable and emit |
||||
|
if enable_fn: |
||||
|
enable_fn(room=room, namespace="/") |
||||
|
else: |
||||
|
configure_fn(room=room, enabled=True, namespace="/") |
||||
|
|
||||
|
sio.emit("old_event", {"data": "old"}, room=room) |
||||
|
assert len(get_history(room=room, limit=10, namespace="/")) == 1 |
||||
|
|
||||
|
# Disable |
||||
|
if disable_fn: |
||||
|
disable_fn(room=room, namespace="/") |
||||
|
else: |
||||
|
configure_fn(room=room, enabled=False, namespace="/") |
||||
|
|
||||
|
# Re-enable |
||||
|
if enable_fn: |
||||
|
enable_fn(room=room, namespace="/") |
||||
|
else: |
||||
|
configure_fn(room=room, enabled=True, namespace="/") |
||||
|
|
||||
|
# History must be empty after re-enable |
||||
|
hist = get_history(room=room, limit=10, namespace="/") |
||||
|
assert hist == [], "Re-enabling must start with fresh history" |
||||
|
|
||||
|
# New events should be recorded |
||||
|
sio.emit("new_event", {"data": "new"}, room=room) |
||||
|
hist = get_history(room=room, limit=10, namespace="/") |
||||
|
assert len(hist) == 1 |
||||
|
assert hist[0]["event"] == "new_event" |
||||
|
|
||||
|
|
||||
|
@pytest.mark.skip(reason="Non-functional: best-effort/non-blocking not reliably testable") |
||||
|
def test_recording_is_best_effort_and_non_blocking(): |
||||
|
"""Placeholder for non-functional requirement.""" |
||||
|
pass |
||||
|
|
||||
|
|
||||
|
@pytest.mark.skip(reason="Non-functional: single-process/in-memory not verifiable in unit tests") |
||||
|
def test_in_memory_single_process_only(): |
||||
|
"""Placeholder for non-functional requirement.""" |
||||
|
pass |
||||
@ -0,0 +1,119 @@ |
|||||
|
|
||||
|
# Feature Request: In-Memory Message History for Socket.IO Rooms |
||||
|
|
||||
|
## Problem |
||||
|
|
||||
|
Clients that reconnect or join a room late cannot fetch recent context, degrading user experience. |
||||
|
|
||||
|
## Requirements |
||||
|
|
||||
|
### Core Features |
||||
|
- **Per-room history** - Store and retrieve recent messages for specific rooms |
||||
|
- **Disabled by default** - Opt-in per room to avoid unnecessary memory usage |
||||
|
- **Namespace isolation** - History isolated per `(namespace, room)` pair |
||||
|
- **Default namespace** - Omitting namespace defaults to "/" |
||||
|
|
||||
|
### Retention Controls |
||||
|
- **Size limit (required)** - Configurable max entries (oldest evicted first) |
||||
|
- **Time-based expiry (optional)** - TTL to remove old messages |
||||
|
- **Fetch limit** - Retrieve up to N most recent messages |
||||
|
|
||||
|
### Query Capabilities |
||||
|
- **Event filtering** - Include/exclude specific event types |
||||
|
- When both filters provided, apply inclusion first, then exclusion |
||||
|
- **Payload truncation** - Optionally limit payload size |
||||
|
- Fetch-time truncation takes precedence over any enable-time setting |
||||
|
- **Chronological order** - Return messages oldest-to-newest among selected entries |
||||
|
|
||||
|
## Behavior Specifications |
||||
|
|
||||
|
1. **When disabled**: Fetching returns empty list; emitted events are not recorded |
||||
|
2. **Re-enabling**: Previously disabled rooms start fresh (no retained entries required) |
||||
|
|
||||
|
## API Requirements |
||||
|
|
||||
|
The following methods must be accessible from `socketio.Server` instance: |
||||
|
|
||||
|
### Required Methods |
||||
|
|
||||
|
1. **Enable/configure history** - Enable or configure history for a room |
||||
|
- Must have explicit `room` parameter |
||||
|
|
||||
|
2. **Disable history** - Disable history for a room |
||||
|
- Must have explicit `room` parameter |
||||
|
|
||||
|
3. **Fetch history** - Get history for a room |
||||
|
- **Must have explicit parameters**: `room` AND `limit` (not in **kwargs) |
||||
|
- **Returns**: List of entry dictionaries |
||||
|
|
||||
|
4. **Get statistics** - Get stats for a room's history |
||||
|
- Must have explicit `room` parameter |
||||
|
- **Returns**: Dictionary with statistics counters |
||||
|
|
||||
|
### Important: Parameter Implementation |
||||
|
|
||||
|
**Critical**: The `room` and `limit` parameters MUST be explicit in the fetch method signature, not captured via `**kwargs`. For example: |
||||
|
|
||||
|
✅ CORRECT: |
||||
|
```python |
||||
|
def get_history(room, limit, namespace='/', include_events=None, ...): |
||||
|
``` |
||||
|
|
||||
|
❌ INCORRECT: |
||||
|
```python |
||||
|
def get_history(room, **kwargs): # limit hidden in kwargs |
||||
|
``` |
||||
|
|
||||
|
### Accepted Parameter Names |
||||
|
|
||||
|
Parameters must use one of these accepted names: |
||||
|
|
||||
|
| Concept | Accepted Names | |
||||
|
|---------|----------------| |
||||
|
| Room identifier | `room`, `room_id`, `room_name` | |
||||
|
| Namespace | `namespace`, `ns` | |
||||
|
| Fetch limit | `limit`, `n`, `count`, `max_items` | |
||||
|
| Max buffer size | `max_entries`, `max`, `limit` | |
||||
|
| Time-to-live | `retention_seconds`, `retention`, `ttl` | |
||||
|
| Payload cap | `payload_size_cap`, `cap`, `size_cap` | |
||||
|
| Include filter | `include_events`, `include`, `events_include` | |
||||
|
| Exclude filter | `exclude_events`, `exclude`, `events_exclude` | |
||||
|
| Enabled toggle | `enabled`, `enable`, `on` | |
||||
|
|
||||
|
### Return Formats |
||||
|
|
||||
|
**History entries** (each dict in the returned list contains): |
||||
|
- `event`: Event name (string) |
||||
|
- `data`: Event payload |
||||
|
- `timestamp`: Unix timestamp in seconds (float) |
||||
|
|
||||
|
**Statistics dict** (must use one of these keys for each metric): |
||||
|
|
||||
|
| Metric | Accepted Keys | |
||||
|
|--------|--------------| |
||||
|
| Entry count | `entries_count`, `entries`, `count` | |
||||
|
| Size evictions | `evictions_size`, `size_evictions`, `evictions_by_size` | |
||||
|
| Time evictions | `evictions_time`, `time_evictions`, `evictions_by_time` | |
||||
|
|
||||
|
## Integration |
||||
|
|
||||
|
The feature should integrate with Socket.IO's existing emit mechanism to automatically record messages when history is enabled for a room. |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Key Addition |
||||
|
|
||||
|
The critical addition is the **"Important: Parameter Implementation"** section that explicitly states: |
||||
|
|
||||
|
1. `room` and `limit` MUST be explicit parameters |
||||
|
2. They cannot be hidden in `**kwargs` |
||||
|
3. Shows correct vs incorrect examples |
||||
|
|
||||
|
This should eliminate the TEST_MISMATCH failures where agents use `**kwargs` and the discovery mechanism can't find the parameters. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Result |
||||
|
|
||||
|
With this change, agents will know to use explicit parameters, and the discovery mechanism will be able to find them. This should convert the 3 TEST_MISMATCH failures into either passes or legitimate logic failures. 🎯 |
||||
@ -0,0 +1,413 @@ |
|||||
|
diff --git a/src/socketio/message_history.py b/src/socketio/message_history.py
|
||||
|
new file mode 100644 |
||||
|
index 0000000..c299e3d
|
||||
|
--- /dev/null
|
||||
|
+++ b/src/socketio/message_history.py
|
||||
|
@@ -0,0 +1,298 @@
|
||||
|
+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()
|
||||
|
\ No newline at end of file |
||||
|
diff --git a/src/socketio/server.py b/src/socketio/server.py
|
||||
|
index f325708..51f44ef 100644
|
||||
|
--- a/src/socketio/server.py
|
||||
|
+++ b/src/socketio/server.py
|
||||
|
@@ -5,6 +5,7 @@ import engineio
|
||||
|
from . import base_server |
||||
|
from . import exceptions |
||||
|
from . import packet |
||||
|
+from . import message_history
|
||||
|
|
||||
|
default_logger = logging.getLogger('socketio.server') |
||||
|
|
||||
|
@@ -163,6 +164,16 @@ class Server(base_server.BaseServer):
|
||||
|
room = to or room |
||||
|
self.logger.info('emitting event "%s" to %s [%s]', event, |
||||
|
room or 'all', namespace) |
||||
|
+
|
||||
|
+ # Record message to history if room is specified
|
||||
|
+ if room is not None and hasattr(self, '_message_history'):
|
||||
|
+ try:
|
||||
|
+ rooms = room if isinstance(room, (list, tuple)) else [room]
|
||||
|
+ for r in rooms:
|
||||
|
+ self._message_history.record_message(event, data, namespace, r)
|
||||
|
+ except Exception:
|
||||
|
+ pass # Never fail emit due to history recording
|
||||
|
+
|
||||
|
self.manager.emit(event, data, namespace, room=room, |
||||
|
skip_sid=skip_sid, callback=callback, |
||||
|
ignore_queue=ignore_queue) |
||||
|
@@ -460,6 +471,78 @@ class Server(base_server.BaseServer):
|
||||
|
""" |
||||
|
return self.eio.sleep(seconds) |
||||
|
|
||||
|
+ def enable_history(self, room, namespace='/', max_entries=100,
|
||||
|
+ retention_seconds=None, payload_size_cap=None):
|
||||
|
+ """Enable message history for a specific room.
|
||||
|
+
|
||||
|
+ :param room: Room name to enable history for.
|
||||
|
+ :param namespace: The Socket.IO namespace. Defaults to '/'.
|
||||
|
+ :param max_entries: Maximum number of messages to retain (ring buffer).
|
||||
|
+ :param retention_seconds: Time-based retention in seconds (optional).
|
||||
|
+ :param payload_size_cap: Size cap for payloads at record time (optional).
|
||||
|
+ """
|
||||
|
+ if not hasattr(self, '_message_history'):
|
||||
|
+ self._message_history = message_history.MessageHistory()
|
||||
|
+ self._message_history.enable_history(room, namespace, max_entries,
|
||||
|
+ retention_seconds, payload_size_cap)
|
||||
|
+
|
||||
|
+ def disable_history(self, room, namespace='/'):
|
||||
|
+ """Disable message history for a specific room.
|
||||
|
+
|
||||
|
+ :param room: Room name to disable history for.
|
||||
|
+ :param namespace: The Socket.IO namespace. Defaults to '/'.
|
||||
|
+ """
|
||||
|
+ if hasattr(self, '_message_history'):
|
||||
|
+ self._message_history.disable_history(room, namespace)
|
||||
|
+
|
||||
|
+ def set_history_config(self, room, namespace='/', enabled=None,
|
||||
|
+ max_entries=None, retention_seconds=None,
|
||||
|
+ payload_size_cap=None):
|
||||
|
+ """Configure message history settings for a specific room.
|
||||
|
+
|
||||
|
+ :param room: Room name to configure.
|
||||
|
+ :param namespace: The Socket.IO namespace. Defaults to '/'.
|
||||
|
+ :param enabled: Enable or disable history (optional).
|
||||
|
+ :param max_entries: Maximum number of messages to retain (optional).
|
||||
|
+ :param retention_seconds: Time-based retention in seconds (optional).
|
||||
|
+ :param payload_size_cap: Size cap for payloads at record time (optional).
|
||||
|
+ """
|
||||
|
+ if not hasattr(self, '_message_history'):
|
||||
|
+ self._message_history = message_history.MessageHistory()
|
||||
|
+ self._message_history.configure_history(room, namespace, enabled,
|
||||
|
+ max_entries, retention_seconds,
|
||||
|
+ payload_size_cap)
|
||||
|
+
|
||||
|
+ def get_history(self, room, limit=50, namespace='/',
|
||||
|
+ include_events=None, exclude_events=None,
|
||||
|
+ payload_size_cap=None):
|
||||
|
+ """Get message history for a specific room.
|
||||
|
+
|
||||
|
+ :param room: Room name to get history for.
|
||||
|
+ :param limit: Maximum number of messages to return.
|
||||
|
+ :param namespace: The Socket.IO namespace. Defaults to '/'.
|
||||
|
+ :param include_events: List/set of event names to include (optional).
|
||||
|
+ :param exclude_events: List/set of event names to exclude (optional).
|
||||
|
+ :param payload_size_cap: Size cap for payloads at fetch time (optional).
|
||||
|
+ :return: List of message dictionaries with 'event', 'data', 'timestamp'.
|
||||
|
+ """
|
||||
|
+ if not hasattr(self, '_message_history'):
|
||||
|
+ return []
|
||||
|
+ return self._message_history.get_history(room, limit, namespace,
|
||||
|
+ include_events, exclude_events,
|
||||
|
+ payload_size_cap)
|
||||
|
+
|
||||
|
+ def get_history_stats(self, room, namespace='/'):
|
||||
|
+ """Get statistics for a room's message history.
|
||||
|
+
|
||||
|
+ :param room: Room name to get stats for.
|
||||
|
+ :param namespace: The Socket.IO namespace. Defaults to '/'.
|
||||
|
+ :return: Dictionary with 'entries', 'evictions_size', 'evictions_time'.
|
||||
|
+ """
|
||||
|
+ if not hasattr(self, '_message_history'):
|
||||
|
+ return {'entries': 0, 'evictions_size': 0, 'evictions_time': 0}
|
||||
|
+ return self._message_history.get_stats(room, namespace)
|
||||
|
+
|
||||
|
def instrument(self, auth=None, mode='development', read_only=False, |
||||
|
server_id=None, namespace='/admin', |
||||
|
server_stats_interval=2): |
||||
@ -0,0 +1,298 @@ |
|||||
|
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() |
||||
File diff suppressed because it is too large
@ -0,0 +1,49 @@ |
|||||
|
#!/usr/bin/env bash |
||||
|
set -euo pipefail |
||||
|
|
||||
|
# Test runner for python-socketio repository |
||||
|
# Usage: |
||||
|
# ./test.sh base # run existing tests (should pass at base commit) |
||||
|
# ./test.sh new # run only the newly added tests |
||||
|
|
||||
|
cmd_exists() { |
||||
|
command -v "$1" >/dev/null 2>&1 |
||||
|
} |
||||
|
|
||||
|
run_pytest() { |
||||
|
if cmd_exists pytest; then |
||||
|
pytest -q "$@" |
||||
|
else |
||||
|
python -m pytest -q "$@" |
||||
|
fi |
||||
|
} |
||||
|
|
||||
|
# Ensure an argument is provided before entering case |
||||
|
if [ $# -lt 1 ]; then |
||||
|
echo "Usage: ./test.sh {base|new}" >&2 |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
case "$1" in |
||||
|
base) |
||||
|
# Run the repository's existing test suite only |
||||
|
if [ -d tests ]; then |
||||
|
run_pytest tests/ |
||||
|
else |
||||
|
echo "ERROR: Base suite not found at ./tests/. Provide baseline tests or adjust runner." >&2 |
||||
|
exit 2 |
||||
|
fi |
||||
|
;; |
||||
|
new) |
||||
|
if [ -f new_tests/test_message_history.py ]; then |
||||
|
run_pytest new_tests/test_message_history.py |
||||
|
else |
||||
|
echo "ERROR: New test file not found: new_tests/test_message_history.py" >&2 |
||||
|
exit 2 |
||||
|
fi |
||||
|
;; |
||||
|
*) |
||||
|
echo "Usage: ./test.sh {base|new}" >&2 |
||||
|
exit 1 |
||||
|
;; |
||||
|
esac |
||||
Loading…
Reference in new issue