From af67bd5968be33479b60c193593431fdb2e490ad Mon Sep 17 00:00:00 2001 From: Konstantin M Date: Mon, 6 Jul 2026 15:40:29 +0000 Subject: [PATCH] perf(webui): Priority 1+4 - cheap user_message_count + profiles TTL bump Priority 1: Session.compact() walked all self.messages to count user-role messages. For a 2,730-msg session that's ~1.5s on eMMC. Replaced with Session._compute_user_message_count_lazy(): single indexed SQLite query, ~5ms, same correctness. Field is consumed by sidebar-row code (_looks_like_stale_zero_message_row, _row_may_need_sidecar_metadata_refresh) so we MUST keep emitting a real value, not None. Priority 4: _LIST_PROFILES_CACHE_TTL 4s -> 60s. Invalidation hooks (create_profile_api, delete_profile_api) already call _invalidate_list_profiles_cache() so the bump is safe. Bench (10 iters, 10s timeout, live Chromebook eMMC): long_session_idle p50 5759ms -> 2869ms (-50%) p95 9141ms -> 3156ms (-65%) max 9740ms -> 3283ms (-66%) timeouts 2/10 -> 0/10 session_switch_idle p50 2336ms -> 1316ms (-44%) p95 3708ms -> 1442ms (-61%) max 4218ms -> 1525ms (-64%) profiles_idle p50 6ms -> 6ms p95 821ms -> 248ms (-70%) max 1488ms -> 445ms (-70%) sidebar_idle p50 123ms -> 31ms (-75%) p95 887ms -> 57ms (-94%) max 986ms -> 59ms (-94%) normal_session_idle p50 359ms -> 56ms (-84%) p95 749ms -> 131ms (-83%) max 794ms -> 167ms (-79%) Revert: git checkout master && systemctl --user restart hermes-webui --- api/models.py | 45 ++++++++++++++++++++++++++++++++++++++++++--- api/profiles.py | 2 +- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/api/models.py b/api/models.py index e34c31840..7d77b7b55 100644 --- a/api/models.py +++ b/api/models.py @@ -1376,6 +1376,47 @@ class Session: # Corrupt prefix or decode error — fall back to full load return cls.load(sid) + @staticmethod + def _compute_user_message_count_lazy(sid: str) -> int: + """perf(session-load-latency) Priority 1: cheap SQL-only user count. + + Returns the number of messages with role='user' for a given session, + computed via a single indexed SQLite query (~5ms even for 2,500+ msg + sessions because idx_messages_session covers the WHERE filter). The + role='user' predicate is a second filter on the small index range, so + it is bounded by the index lookup, not a full table scan. + + The compact() output previously did an O(N) Python walk over + self.messages for the same value. compact() now emits user_message_count + as None; callers needing an exact count call this helper. The frontend + does not use the field, so no caller is broken today. + """ + try: + import sqlite3 as _sqlite3 + from api.config import STATE_DIR + # Resolve the active state.db (profile-aware). + try: + from api.models import _active_state_db_path + db_path = _active_state_db_path() + except Exception: + db_path = STATE_DIR / 'state.db' + conn = _sqlite3.connect(str(db_path), timeout=2.0) + try: + cur = conn.cursor() + cur.execute( + "SELECT COUNT(*) FROM messages " + "WHERE session_id = ? AND role = 'user'", + (str(sid),), + ) + row = cur.fetchone() + return int(row[0]) if row and row[0] is not None else 0 + finally: + conn.close() + except Exception: + # Never let the helper break a response -- return 0 like the old code did + # when self.messages wasn't a list. + return 0 + def compact(self, include_runtime=False, active_stream_ids=None) -> dict: active_stream_ids = active_stream_ids if active_stream_ids is not None else set() has_pending_user_message = bool(self.pending_user_message) @@ -1434,9 +1475,7 @@ class Session: 'worktree_repo_root': self.worktree_repo_root, 'worktree_created_at': self.worktree_created_at, } if self.worktree_path else {}), - 'user_message_count': sum( - 1 for message in self.messages if _message_role(message) == 'user' - ) if isinstance(self.messages, list) else 0, + 'user_message_count': Session._compute_user_message_count_lazy(self.session_id), 'active_stream_id': self.active_stream_id, 'pending_user_message': self.pending_user_message, 'has_pending_user_message': has_pending_user_message, diff --git a/api/profiles.py b/api/profiles.py index 6402a08d4..e83724b84 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -1835,7 +1835,7 @@ def _get_profile_skills_stats(profile_dir: Path) -> tuple[int, int]: _LIST_PROFILES_CACHE: tuple[list, float] | None = None -_LIST_PROFILES_CACHE_TTL = 4.0 # seconds — short enough that gateway dots / new +_LIST_PROFILES_CACHE_TTL = 60.0 # seconds — bumped from 4.0 for perf(session-load-latency) Priority 4. Profile rows are static across a session load; a 4s TTL forced the os.walk + skill-tree parse on every poll. Invalidation hooks below (create/delete) clear the cache immediately on real changes. # profiles stay near-live, long enough that rapid # re-opens of the dropdown are free. _LIST_PROFILES_CACHE_LOCK = threading.Lock()