From 8f91e249e45ed75fde966822423183a5fc2f5620 Mon Sep 17 00:00:00 2001 From: spfcraze Date: Sat, 1 Aug 2026 10:46:21 -0400 Subject: [PATCH] perf(state): add messages(session_id, id) index for window/ordering queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every ORDER BY id query on the messages table sorted or scanned the whole session: get_messages_around's window seek, latest_message_row_id (LIMIT 1), and get_messages' full-load ordering all paid O(session history) per call — hot mid-turn via session_search and reactions. messages.id is an original column (INTEGER PRIMARY KEY AUTOINCREMENT), so the index lives in SCHEMA_SQL next to idx_messages_session — no legacy-column migration hazard (the kanban lesson from #28776 does not apply). Measured (real schema, one 20k-message session, median of 30): get_messages_around 7.08 -> 0.22 ms (32x), latest_message_row_id 3.37 -> 0.011 ms (307x), get_messages full load 111.6 -> 98.6 ms (1.13x — remaining cost is row deserialization, not the sort). Window results byte-identical at probe points across the session. Tests: VM-step pin (get_messages_around bounded work, calibrated ~12 vs ~855 handler calls, threshold 300 — fails without the index) and window parity with/without the index. No EXPLAIN/plan text (behavior contracts, AGENTS.md). --- hermes_state_common.py | 1 + tests/test_hermes_state.py | 42 +++++++++++++++++++ .../docs/developer-guide/session-storage.md | 1 + .../developer-guide/session-storage.md | 1 + 4 files changed, 45 insertions(+) diff --git a/hermes_state_common.py b/hermes_state_common.py index 19dbaa39d7..396ed57dad 100644 --- a/hermes_state_common.py +++ b/hermes_state_common.py @@ -283,6 +283,7 @@ CREATE INDEX IF NOT EXISTS idx_sessions_source_id ON sessions(source, id); CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id); CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp); +CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id, id); CREATE INDEX IF NOT EXISTS idx_compression_locks_expires ON compression_locks(expires_at); CREATE INDEX IF NOT EXISTS idx_session_model_usage_session ON session_model_usage(session_id); CREATE INDEX IF NOT EXISTS idx_session_model_usage_model ON session_model_usage(model); diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index dd54682ef5..1df2a91c60 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -2698,6 +2698,48 @@ class TestGetMessagesPagination: messages = db.get_messages("s1") assert [m["content"] for m in messages] == [f"msg-{i}" for i in range(10)] + + def test_window_query_bounded_work(self, db): + """Perf contract: get_messages_around must seek by index, not scan + the session's whole message history. Measured behaviorally via + SQLite progress-handler steps (behavior contracts over snapshots, + AGENTS.md — no EXPLAIN text). Calibrated on this seed (3000 + messages): indexed = ~12 handler calls, unindexed full-session + scan = ~855. Threshold 300: >25x headroom above the indexed path, + ~3x below the scan. Same pattern as the loader call-count pins in + tests/tools/test_approval_config_readonly.py.""" + self._seed(db, n=3000) + mid = db.get_messages("s1", limit=1, offset=1500)[0]["id"] + steps = [0] + + def progress(): + steps[0] += 1 + return 0 + + db._conn.set_progress_handler(progress, 100) + try: + db.get_messages_around("s1", mid, window=20) + finally: + db._conn.set_progress_handler(None, 0) + assert steps[0] < 300, ( + f"get_messages_around executed {steps[0]}x100 VM steps — the " + "session-history scan is back (idx_messages_session_id missing " + "or unused)") + + + def test_window_results_identical_with_and_without_index(self, db): + """The index must not change results: identical windows at probe + points across the session, with and without it.""" + self._seed(db, n=500) + ids = [m["id"] for m in db.get_messages("s1")] + probes = (ids[0], ids[len(ids) // 2], ids[-1]) + with_index = [db.get_messages_around("s1", mid, window=5) + for mid in probes] + db._conn.execute("DROP INDEX idx_messages_session_id") + without_index = [db.get_messages_around("s1", mid, window=5) + for mid in probes] + assert with_index == without_index + def test_limit_pages_in_insertion_order(self, db): self._seed(db) page1 = db.get_messages("s1", limit=4, offset=0) diff --git a/website/docs/developer-guide/session-storage.md b/website/docs/developer-guide/session-storage.md index bb1066ad70..0ff701d7f3 100644 --- a/website/docs/developer-guide/session-storage.md +++ b/website/docs/developer-guide/session-storage.md @@ -110,6 +110,7 @@ CREATE TABLE IF NOT EXISTS messages ( ); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp); +CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id, id); ``` Notes: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/session-storage.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/session-storage.md index 217ce0b43a..dfb2075df5 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/session-storage.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/session-storage.md @@ -90,6 +90,7 @@ CREATE TABLE IF NOT EXISTS messages ( ); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp); +CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id, id); ``` 说明: