fix: speed up session index metadata refresh

This commit is contained in:
ai-ag2026
2026-05-29 23:34:07 +02:00
committed by nesquena-hermes
parent 95cee09c31
commit 017179b189
2 changed files with 262 additions and 18 deletions

View File

@@ -99,6 +99,39 @@ def _cleanup_stale_tmp_files() -> None:
pass # SESSION_DIR may not exist yet; that's fine
_PERSISTED_SESSION_IDS_CACHE: tuple[Path | None, int | None, frozenset[str]] = (None, None, frozenset())
def _persisted_session_ids_snapshot() -> frozenset[str]:
"""Return persisted session ids, caching the directory snapshot by mtime.
`/api/sessions` and incremental index writes may run every few seconds. A
full `SESSION_DIR.glob('*.json')` on a large session directory is expensive,
and doing that scan while request threads contend on LOCK makes the sidebar
look like it was designed by a committee of glaciers. Cache the listing until
the directory mtime changes, and let callers take the snapshot before
entering critical sections.
"""
global _PERSISTED_SESSION_IDS_CACHE
try:
dir_mtime_ns = SESSION_DIR.stat().st_mtime_ns
except Exception:
dir_mtime_ns = None
cached_dir, cached_mtime_ns, cached_ids = _PERSISTED_SESSION_IDS_CACHE
if cached_dir == SESSION_DIR and cached_mtime_ns == dir_mtime_ns:
return cached_ids
try:
ids = frozenset(
p.stem
for p in SESSION_DIR.glob('*.json')
if not p.name.startswith('_')
)
except Exception:
ids = frozenset()
_PERSISTED_SESSION_IDS_CACHE = (SESSION_DIR, dir_mtime_ns, ids)
return ids
def _rebuild_session_index_background() -> None:
try:
_write_session_index(updates=None)
@@ -210,18 +243,13 @@ def _write_session_index(updates=None):
# This avoids loading every session file on every single save().
_fallback = False
try:
# Avoid N filesystem exists() checks under LOCK by collecting
# on-disk IDs once before entering the critical section.
on_disk_ids = _persisted_session_ids_snapshot()
with LOCK:
existing = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
in_memory_ids = set(SESSIONS.keys())
# Avoid N filesystem exists() checks under LOCK by collecting
# on-disk IDs once.
on_disk_ids = {
p.stem
for p in SESSION_DIR.glob('*.json')
if not p.name.startswith('_')
}
existing = [
e for e in existing
if (e.get('session_id') in in_memory_ids or e.get('session_id') in on_disk_ids)
@@ -786,8 +814,10 @@ class Session:
parsed['messages'] = []
parsed['tool_calls'] = []
session = cls(**parsed)
index_message_count = _lookup_index_message_count(sid)
sidecar_message_count = _parse_nonnegative_int(parsed.get('message_count'))
index_message_count = None
if sidecar_message_count is None:
index_message_count = _lookup_index_message_count(sid)
# The sidebar index is a cache and can lag behind external sidecar
# appends/backfills. Prefer the largest known count so metadata-only
# active-session polls do not miss real remote updates.
@@ -2530,6 +2560,77 @@ def _strip_sidebar_internal_flags(sessions: list[dict]) -> None:
session.pop('_show_pre_compression_snapshot', None)
def _row_may_need_sidecar_metadata_refresh(session: dict) -> bool:
"""Return True when a row needs canonical sidecar runtime/snapshot metadata.
Compression lineage fields are enriched from state.db in one batched query
later in all_sessions(). Loading hundreds of lineage sidecars on every
/api/sessions poll turns the sidebar into molasses, so keep this refresh
limited to the few rows whose transient runtime or snapshot state is not
cheaply available from state.db.
"""
is_runtime_row = bool(
session.get('active_stream_id')
or session.get('has_pending_user_message')
or session.get('pending_user_message')
)
snapshot_missing_sidebar_metadata = bool(
session.get('pre_compression_snapshot')
and (
session.get('message_count') is None
or session.get('last_message_at') is None
)
)
return is_runtime_row or snapshot_missing_sidebar_metadata
def _refresh_index_rows_from_sidecar_metadata(sessions: list[dict]) -> list[dict]:
"""Overlay fuller sidecar metadata onto stale sidebar index rows.
``_index.json`` is a cache and can lag behind the canonical session sidecar
during compression/continuation writes. Keep this read-only and limited to
lineage/runtime-shaped rows so ordinary sidebar refreshes do not scan every
historical transcript.
"""
out: list[dict] = []
for session in sessions:
if not _row_may_need_sidecar_metadata_refresh(session):
out.append(session)
continue
sid = session.get('session_id')
if not sid:
out.append(session)
continue
sidecar = Session.load_metadata_only(sid)
if not sidecar:
out.append(session)
continue
compact = sidecar.compact(include_runtime=True)
refreshed = dict(session)
for key in (
'message_count', 'updated_at', 'last_message_at', 'title', 'workspace',
'model', 'model_provider', 'created_at', 'pinned', 'archived', 'project_id',
'profile', 'pre_compression_snapshot', 'parent_session_id', 'source_tag',
'raw_source', 'session_source', 'source_label', 'active_stream_id',
'has_pending_user_message', 'pending_user_message', 'pending_started_at',
):
value = compact.get(key)
if value is not None:
refreshed[key] = value
try:
refreshed['message_count'] = max(
int(session.get('message_count') or 0),
int(compact.get('message_count') or 0),
)
except (TypeError, ValueError):
pass
if _session_sort_timestamp(compact) > _session_sort_timestamp(session):
refreshed['updated_at'] = compact.get('updated_at', refreshed.get('updated_at'))
refreshed['last_message_at'] = compact.get('last_message_at', refreshed.get('last_message_at'))
out.append(refreshed)
return out
def _active_state_db_path() -> Path:
"""Return state.db for the active Hermes profile, degrading to HERMES_HOME."""
try:
@@ -2607,14 +2708,7 @@ def all_sessions(diag=None):
_diag_stage(diag, "all_sessions.prune_index")
with LOCK:
in_memory_ids = set(SESSIONS.keys())
try:
persisted_ids = {
p.stem
for p in SESSION_DIR.glob('*.json')
if not p.name.startswith('_')
}
except Exception:
persisted_ids = None
persisted_ids = _persisted_session_ids_snapshot()
index = [
s for s in index
if (
@@ -2658,6 +2752,13 @@ def all_sessions(diag=None):
include_runtime=True,
active_stream_ids=active_stream_ids,
)
_diag_stage(diag, "all_sessions.refresh_sidecar_metadata")
refreshed_index_rows = _refresh_index_rows_from_sidecar_metadata(list(index_map.values()))
index_map = {
row['session_id']: row
for row in refreshed_index_rows
if row.get('session_id')
}
_diag_stage(diag, "all_sessions.sort_filter")
result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), _session_sort_timestamp(s)), reverse=True)
# Hide empty Untitled sessions from the UI entirely — they are ephemeral