mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-12 10:40:33 +00:00
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:
+42
-3
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user