Files
hermes-webui/tests/test_issue5854_anchor_scene_split.py
Rodrigo Gomes da Silvaandrodrigogs 66423ad2dc fix(sessions): stop one oversized metadata field from re-parsing the whole sidecar (#7573)
Read the metadata prefix in staged geometric reads so one oversized metadata field no longer forces a full re-parse of the whole sidecar.

Co-authored-by: rodrigogs <rodrigogs@users.noreply.github.com>
2026-09-15 21:25:27 -07:00

354 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Regression coverage for #5854: move anchor_activity_scenes out of the metadata prefix.
Root of #4633 (server RSS climb → OOM) and #5839 (browser freeze): reasoning-heavy
sessions store 250–480 KB of `anchor_activity_scenes`, and `save()` serialized them
BEFORE `messages`, so the cheap metadata-prefix read (`_read_metadata_json_prefix`,
64 KB cap) overflowed and fell back to a full multi-MB `json.loads` on every
sidebar poll — the allocation churn that drove the RSS high-water crash.
The fix persists a compact `anchor_scene_index` fingerprint ({scene_key: updated_at})
in the metadata prefix (after `message_count`, before `messages`) and moves the full
scene bodies AFTER `messages`. The sidebar-poll freshness check reads the fingerprint;
the full-session GET still reads full bodies. These tests lock:
* the new serialization order (message_count < scene_index < messages < scenes),
* the cheap prefix stays small for a large-scene session (the #4633 fix),
* metadata-only stubs carry the fingerprint and a truthful message_count,
* full load round-trips scene bodies unchanged,
* the freshness comparison stays behavior-identical (keys + max updated_at),
* legacy-layout back-compat (scenes-before-messages) + the message_count recovery.
"""
import json
import pytest
import api.models as M
@pytest.fixture
def session_store(tmp_path, monkeypatch):
sdir = tmp_path / "sessions"
monkeypatch.setattr(M, "SESSION_DIR", sdir)
sdir.mkdir(parents=True, exist_ok=True)
return sdir
def _big_scenes(n_scenes=3, n_rows=80, base_ts=1000.0):
rows = [{"row_id": f"r{i}", "kind": "tool", "text": "X" * 3000,
"thinking": {"text": ""}, "tool": {"name": "read_file"}} for i in range(n_rows)]
return {f"scene{j}": {"version": 1, "updated_at": base_ts + j,
"scene": {"activity_rows": rows}} for j in range(n_scenes)}
def _make(session_store, sid="s1", scenes=None, msgs=None):
s = M.Session(session_id=sid, title="T", workspace=str(session_store.parent),
model="glm", messages=msgs if msgs is not None else [
{"role": "user", "content": "hi"}, {"role": "assistant", "content": "yo"}])
if scenes is not None:
s.anchor_activity_scenes = scenes
s.save()
return s
# ── Serialization order + cheap prefix (the #4633 fix) ──────────────────────
def test_scene_bodies_serialize_after_messages_with_fingerprint_before(session_store):
_make(session_store, "s1", scenes=_big_scenes())
raw = (session_store / "s1.json").read_text(encoding="utf-8")
ci = raw.find('"message_count"')
xi = raw.find('"anchor_scene_index"')
mi = raw.find('"messages"')
si = raw.find('"anchor_activity_scenes"')
assert -1 < ci < xi < mi < si, "order must be message_count < scene_index < messages < scenes"
def test_large_scene_session_keeps_cheap_prefix_small(session_store):
"""The #4633 root: a 250KB+ scene session must NOT force a full parse."""
_make(session_store, "s1", scenes=_big_scenes())
prefix = M._read_metadata_json_prefix(session_store / "s1.json")
assert prefix is not None, "cheap prefix must succeed (not fall back to full load)"
assert len(prefix.encode("utf-8")) < 65536
parsed = json.loads(prefix)
assert parsed["message_count"] == 2
assert set(parsed["anchor_scene_index"]) == {"scene0", "scene1", "scene2"}
assert "anchor_activity_scenes" not in parsed, "full scenes must not be in the cheap prefix"
def test_metadata_only_stub_is_cheap_and_correct(session_store):
_make(session_store, "s1", scenes=_big_scenes())
stub = M.Session.load_metadata_only("s1")
assert stub is not None
assert stub.messages == []
assert stub._metadata_message_count == 2
assert stub._loaded_metadata_only is True
# fingerprint available on the stub; full scenes not materialized
assert M._session_scene_keys(stub) == {"scene0", "scene1", "scene2"}
assert M._session_scene_updated_at(stub) == 1002.0
def test_full_load_round_trips_scene_bodies(session_store):
scenes = _big_scenes(n_scenes=3, n_rows=80)
_make(session_store, "s1", scenes=scenes)
full = M.Session.load("s1")
assert set(full.anchor_activity_scenes) == {"scene0", "scene1", "scene2"}
assert len(full.anchor_activity_scenes["scene0"]["scene"]["activity_rows"]) == 80
# bodies unchanged
assert full.anchor_activity_scenes["scene0"]["scene"]["activity_rows"][0]["text"] == "X" * 3000
def test_save_reload_save_is_idempotent_on_scene_index(session_store):
_make(session_store, "s1", scenes=_big_scenes())
full = M.Session.load("s1")
full.save(touch_updated_at=False)
prefix = json.loads(M._read_metadata_json_prefix(session_store / "s1.json"))
assert set(prefix["anchor_scene_index"]) == {"scene0", "scene1", "scene2"}
assert prefix["message_count"] == 2
# ── Freshness comparison stays behavior-identical ───────────────────────────
def test_disk_scene_fingerprint_modern_and_legacy_equivalent():
# modern: fingerprint present
modern = {"anchor_scene_index": {"a": 5.0, "b": 9.0}}
keys, latest = M._disk_scene_fingerprint(modern)
assert keys == {"a", "b"} and latest == 9.0
# legacy: full bodies inline (scenes-before-messages layout)
legacy = {"anchor_activity_scenes": {"a": {"updated_at": 5.0}, "b": {"updated_at": 9.0}}}
keys2, latest2 = M._disk_scene_fingerprint(legacy)
assert keys2 == {"a", "b"} and latest2 == 9.0
# neither present → None (must fall through, not assume "no scenes")
assert M._disk_scene_fingerprint({"title": "x"}) is None
# empty modern index present → ("no scenes", 0.0), NOT None
assert M._disk_scene_fingerprint({"anchor_scene_index": {}}) == (set(), 0.0)
def test_cached_lags_disk_detects_new_scene_and_newer_timestamp(session_store):
# persist a session with scene0@1000
_make(session_store, "s1", scenes={"scene0": {"updated_at": 1000.0, "scene": {}}})
# cached copy that is BEHIND (no scenes) → must lag
cached_behind = M.Session(session_id="s1", title="T", workspace=str(session_store.parent),
model="glm", messages=[{"role": "user", "content": "hi"},
{"role": "assistant", "content": "yo"}])
assert M._cached_session_lags_disk(cached_behind) is True
# cached copy AT PARITY → must NOT lag
cached_parity = M.Session(session_id="s1", title="T", workspace=str(session_store.parent),
model="glm", messages=[{"role": "user", "content": "hi"},
{"role": "assistant", "content": "yo"}])
cached_parity.anchor_activity_scenes = {"scene0": {"updated_at": 1000.0, "scene": {}}}
assert M._cached_session_lags_disk(cached_parity) is False
# cached copy AHEAD of disk (extra un-persisted scene) → must NOT force reload
cached_ahead = M.Session(session_id="s1", title="T", workspace=str(session_store.parent),
model="glm", messages=[{"role": "user", "content": "hi"},
{"role": "assistant", "content": "yo"}])
cached_ahead.anchor_activity_scenes = {"scene0": {"updated_at": 1000.0, "scene": {}},
"scene1": {"updated_at": 2000.0, "scene": {}}}
assert M._cached_session_lags_disk(cached_ahead) is False
def test_cached_lags_disk_detects_newer_scene_updated_at(session_store):
_make(session_store, "s1", scenes={"scene0": {"updated_at": 2000.0, "scene": {}}})
cached_stale = M.Session(session_id="s1", title="T", workspace=str(session_store.parent),
model="glm", messages=[{"role": "user", "content": "hi"},
{"role": "assistant", "content": "yo"}])
cached_stale.anchor_activity_scenes = {"scene0": {"updated_at": 1000.0, "scene": {}}}
assert M._cached_session_lags_disk(cached_stale) is True
def test_parity_cache_not_forced_reload_when_prefix_read_fails(session_store):
"""MUST-FIX regression (Opus round-1): when the cheap prefix read fails
(oversized metadata field > 64KB), the slow path must compare the CACHED
side against its REAL records, not a stale load-time fingerprint. A cache at
exact parity with disk must NOT be judged as lagging (which would force a
spurious full reload of the heavy session this PR optimizes).
Simulate an oversized metadata prefix by giving the session a large
`compression_anchor_summary` so `_read_metadata_json_prefix` overflows and
the slow path (Session.load_metadata_only) is taken.
Sized off `_METADATA_PREFIX_MAX_BYTES` rather than a literal: this test needs
the prefix read to FAIL, so hardcoding a number that happens to exceed
today's budget makes it silently stop exercising the slow path the next time
the budget moves. (It did: the budget went 64 KB -> 1 MiB and the old
`"Z" * 80000` no longer overflowed.)
"""
oversized = "Z" * (M._METADATA_PREFIX_MAX_BYTES + 100_000)
scenes = {"scene0": {"updated_at": 1000.0, "scene": {}}}
s = M.Session(session_id="p1", title="T", workspace=str(session_store.parent),
model="glm", messages=[{"role": "user", "content": "hi"},
{"role": "assistant", "content": "yo"}])
s.anchor_activity_scenes = scenes
s.compression_anchor_summary = oversized
s.save()
# Sanity: the cheap prefix genuinely fails for this file (slow path taken).
assert M._read_metadata_json_prefix(session_store / "p1.json") is None
# A cached full session at exact parity (same scene key + updated_at) whose
# load-time fingerprint is stale/empty must NOT be judged as lagging.
cached_parity = M.Session(session_id="p1", title="T", workspace=str(session_store.parent),
model="glm", messages=[{"role": "user", "content": "hi"},
{"role": "assistant", "content": "yo"}])
cached_parity.anchor_activity_scenes = {"scene0": {"updated_at": 1000.0, "scene": {}}}
cached_parity._anchor_scene_index = {} # stale/empty load-time fingerprint
cached_parity.compression_anchor_summary = oversized
assert M._cached_session_lags_disk(cached_parity) is False
# But a genuinely-behind cache (disk has a newer scene) still reloads.
cached_behind = M.Session(session_id="p1", title="T", workspace=str(session_store.parent),
model="glm", messages=[{"role": "user", "content": "hi"},
{"role": "assistant", "content": "yo"}])
cached_behind.anchor_activity_scenes = {}
assert M._cached_session_lags_disk(cached_behind) is True
# ── Legacy back-compat ──────────────────────────────────────────────────────
def _write_legacy(session_store, sid, scenes, message_count, with_msgs=True):
"""Emulate a pre-#5854 sidecar: scenes BEFORE message_count/messages, no fingerprint."""
doc = {"session_id": sid, "title": "Legacy", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"anchor_activity_scenes": scenes,
"message_count": message_count,
"messages": [{"role": "user", "content": "hi"}] if with_msgs else []}
(session_store / f"{sid}.json").write_text(json.dumps(doc, indent=2), encoding="utf-8")
def test_legacy_layout_metadata_only_reads_count_and_scene_keys(session_store):
_write_legacy(session_store, "leg1", {"sc0": {"updated_at": 5.0}}, message_count=7)
stub = M.Session.load_metadata_only("leg1")
assert stub is not None
# legacy message_count survives (it's before scenes in this legacy doc, but even
# when after, the recovery path covers it — see next test)
assert stub._metadata_message_count == 7
def test_legacy_large_scene_missing_count_recovers_via_full_load(session_store):
"""The Codex CORE hazard: legacy scenes-before-count + large scenes. The cheap
prefix stops at scenes and misses message_count; with no index count, a naive
stub would report 0 msgs and the session could vanish. Must recover via full load."""
big = _big_scenes(n_scenes=3, n_rows=80) # >64KB so prefix stops at scenes
# legacy order: scenes FIRST, then message_count AFTER (pre-#5854 shape)
doc = {"session_id": "leg2", "title": "Legacy", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"anchor_activity_scenes": big,
"message_count": 5,
"messages": [{"role": "user", "content": f"m{i}"} for i in range(5)]}
(session_store / "leg2.json").write_text(json.dumps(doc, indent=2), encoding="utf-8")
# No _index.json entry → must fall back to full load and get the real count.
stub = M.Session.load_metadata_only("leg2")
assert stub is not None
assert (stub._metadata_message_count or len(stub.messages)) == 5, "must not report 0 messages"
def test_legacy_missing_count_uses_authoritative_not_stale_index(session_store):
"""Codex round-2/3 CORE: on a legacy sidecar whose prefix lacks message_count,
_persisted_message_count must NOT trust a (possibly lagging) _index.json. It
full-parses once to get the AUTHORITATIVE count (and caches it), rather than
under-reporting from a stale index or returning None (which would pin the
session in memory as non-evictable and can 404 the first send)."""
big = _big_scenes(n_scenes=3, n_rows=80)
doc = {"session_id": "leg3", "title": "Legacy", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"anchor_activity_scenes": big, "message_count": 4,
"messages": [{"role": "user", "content": f"m{i}"} for i in range(4)]}
(session_store / "leg3.json").write_text(json.dumps(doc, indent=2), encoding="utf-8")
M._LEGACY_SIDECAR_FACTS.clear()
# Prefix stops at scenes (no count, no fingerprint) → must return the real
# authoritative count (4), never None and never a stale index value.
assert M._persisted_message_count("leg3") == 4
def test_legacy_disk_ahead_scene_detected_via_full_load(session_store):
"""Codex round-2 SILENT: a legacy stub carries no scene signal (no fingerprint,
scenes after the prefix). The slow path must full-load to detect a genuine
disk-ahead scene change instead of serving a stale cached worklog."""
# legacy file WITH a prefix-captured count (small enough) but scenes present;
# emulate the stub-with-no-scene-signal by writing legacy layout.
doc = {"session_id": "leg4", "title": "Legacy", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"message_count": 2,
"anchor_activity_scenes": {"sceneNEW": {"updated_at": 5000.0, "scene": {}}},
"messages": [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "yo"}]}
(session_store / "leg4.json").write_text(json.dumps(doc, indent=2), encoding="utf-8")
# cached copy is BEHIND: it has an older/different scene set.
cached_behind = M.Session(session_id="leg4", title="Legacy", workspace=str(session_store.parent),
model="glm", messages=[{"role": "user", "content": "hi"},
{"role": "assistant", "content": "yo"}])
cached_behind.anchor_activity_scenes = {}
assert M._cached_session_lags_disk(cached_behind) is True
def test_fully_loaded_session_never_uses_stale_fingerprint(session_store):
"""Codex round-2 SILENT: the scene helpers must read REAL records for a
fully-loaded session, never its (possibly stale) load-time _anchor_scene_index."""
full = M.Session(session_id="f1", title="T", workspace=str(session_store.parent),
model="glm", messages=[{"role": "user", "content": "hi"}])
full.anchor_activity_scenes = {"sceneA": {"updated_at": 30.0}}
full._anchor_scene_index = {"sceneOLD": 10.0} # stale load-time fingerprint
# _loaded_metadata_only is falsy → must read the REAL records, not the index.
assert M._session_scene_keys(full) == {"sceneA"}
assert M._session_scene_updated_at(full) == 30.0
# A metadata-only stub, by contrast, DOES use its fingerprint.
stub = M.Session(session_id="f2", title="T", workspace=str(session_store.parent),
model="glm", messages=[])
stub._loaded_metadata_only = True
stub.anchor_activity_scenes = {}
stub._anchor_scene_index = {"sceneB": 42.0}
assert M._session_scene_keys(stub) == {"sceneB"}
assert M._session_scene_updated_at(stub) == 42.0
def _write_legacy_large(session_store, sid, n_msgs=5):
"""Write a legacy large-scene sidecar (scenes before count, >64KB prefix)."""
big = _big_scenes(n_scenes=3, n_rows=80)
doc = {"session_id": sid, "title": "Legacy", "workspace": str(session_store.parent),
"model": "glm", "created_at": 1.0, "updated_at": 2.0,
"anchor_activity_scenes": big,
"message_count": n_msgs,
"messages": [{"role": "user", "content": f"m{i}"} for i in range(n_msgs)]}
(session_store / f"{sid}.json").write_text(json.dumps(doc, indent=2), encoding="utf-8")
def test_legacy_large_scene_not_reparsed_on_every_read(session_store, monkeypatch):
"""Codex round-3 SILENT: an unchanged legacy large-scene sidecar must be
full-parsed at most once (cached by stat signature), not on every poll —
otherwise the #4633 churn returns for legacy files that are never re-saved."""
M._LEGACY_SIDECAR_FACTS.clear()
_write_legacy_large(session_store, "legcache", n_msgs=5)
# Count real full-loads via a spy on the raw file read inside load().
calls = {"n": 0}
real_load = M.Session.load.__func__
def _counting_load(cls, sid, *a, **k):
if sid == "legcache":
calls["n"] += 1
return real_load(cls, sid, *a, **k)
monkeypatch.setattr(M.Session, "load", classmethod(_counting_load))
s1 = M.Session.load_metadata_only("legcache")
s2 = M.Session.load_metadata_only("legcache")
s3 = M.Session.load_metadata_only("legcache")
assert s1._metadata_message_count == 5
assert s2._metadata_message_count == 5
assert s3._metadata_message_count == 5
assert calls["n"] == 1, f"legacy file full-loaded {calls['n']}x; must be cached after the first"
def test_legacy_session_stays_evictable(session_store):
"""Codex round-3 SILENT: a clean legacy session must remain LRU-evictable —
_persisted_message_count must yield its authoritative count (via the facts
cache) rather than None (which would pin it in memory forever)."""
M._LEGACY_SIDECAR_FACTS.clear()
_write_legacy_large(session_store, "legevict", n_msgs=3)
# Prime the facts cache via a full load (mirrors get_session materializing it).
M.Session.load("legevict")
assert M._persisted_message_count("legevict") == 3
def test_legacy_count_recovers_on_facts_cache_miss(session_store):
"""Codex round-3 CORE: even with the legacy-facts cache EMPTY (never loaded,
or LRU-evicted), _persisted_message_count must not return None — it full-parses
once and returns the authoritative count, so the session stays evictable and a
fresh new_session() can't evict its own unsaved session and 404 the first send."""
_write_legacy_large(session_store, "legmiss", n_msgs=6)
M._LEGACY_SIDECAR_FACTS.clear() # simulate cold / evicted facts cache
assert M._persisted_message_count("legmiss") == 6
# and the miss re-populated the cache for the next call
assert M._legacy_sidecar_facts_get("legmiss") is not None