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
This commit is contained in:
Konstantin M
2026-07-06 15:40:29 +00:00
parent ce1aaf9d7b
commit af67bd5968
2 changed files with 43 additions and 4 deletions
+42 -3
View File
@@ -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,
+1 -1
View File
@@ -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()