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); ``` 说明: