fix: speed up session index metadata refresh
This commit is contained in:
committed by
nesquena-hermes
parent
95cee09c31
commit
017179b189
135
api/models.py
135
api/models.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user