Browse Source

Add message history feature

- 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 documentation
pull/1550/head
Karan Singh Bisht 4 months ago
parent
commit
de6f405172
  1. 950
      new_tests/test_message_history.py
  2. 119
      problem_description.md
  3. 413
      solution.patch
  4. 298
      src/socketio/message_history.py
  5. 83
      src/socketio/server.py
  6. 1012
      test.patch
  7. 49
      test.sh

950
new_tests/test_message_history.py

@ -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

119
problem_description.md

@ -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. 🎯

413
solution.patch

@ -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):

298
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()

83
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):

1012
test.patch

File diff suppressed because it is too large

49
test.sh

@ -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…
Cancel
Save