Release YB (v0.51.672): pre-aggregate cron sidebar ordering for missing-index scans (#4962)

Release YB (v0.51.672): pre-aggregate cron sidebar ordering for missing-index scans (#4962)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-06-25 20:20:04 -07:00
commit 66d0278659
3 files changed
+297 -41

No files matched your search

+6
View File
@@ -3,6 +3,12 @@
## [Unreleased] ## [Unreleased]
## [v0.51.672] — 2026-06-26 — Release YB (faster cron sidebar rebuild on stores missing the message index)
### Fixed
- **The cron/CLI sidebar rebuild is much faster on a `state.db` that lacks the message-session index.** When `idx_messages_session` is missing (an unmigrated/older store), the cron-only capped rescue scan ran a correlated per-row latest-message subquery that could take multiple seconds cold. That pass now uses a single pre-aggregated latest-message ordering (one grouped scan) for the missing-index case only; the common indexed path is unchanged (the correlated ordering is still the faster plan on a healthy migrated store), and the session set/order is identical to before. Follow-up to the #4842 chain, independent of #4952. Thanks @rodboev. (#4962)
## [v0.51.671] — 2026-06-26 — Release YA (sidebar polls no longer stall behind a slow session-list rebuild) ## [v0.51.671] — 2026-06-26 — Release YA (sidebar polls no longer stall behind a slow session-list rebuild)
### Fixed ### Fixed
+78 -36
View File
@@ -492,25 +492,26 @@ def read_importable_agent_session_rows(
use_messages_join = messages_has_session_id use_messages_join = messages_has_session_id
count_col = 'id' if 'id' in message_cols else 'session_id' count_col = 'id' if 'id' in message_cols else 'session_id'
# Defensive index prime (#3887). The candidate-ordering query below sorts # Defensive index prime (#3887). The normal candidate-ordering shape uses
# sessions by a correlated ``MAX(mx.timestamp)`` subquery over ``messages``. # the agent's standard ``idx_messages_session ON messages(session_id,
# That is fast only when the agent's standard # timestamp)`` index; without it, large cron-only scans degrade badly.
# ``idx_messages_session ON messages(session_id, timestamp)`` index exists. # Writable dbs self-heal by recreating the index. Read-only or locked dbs
# A normally-migrated hermes-agent state.db has it, but a db that lost its # fall back to the pre-aggregated cron-only path below instead of failing.
# migrations (older hermes-agent, or a hand-rebuilt/reimported db) does messages_index_present = False
# not — and the subquery then degrades to a full ``messages`` scan per
# candidate session, stalling ``/api/sessions`` for seconds on every
# refresh (the 5s-TTL cache never settles). Priming the index is a no-op
# (~free) when it already exists, and self-heals an affected db in
# milliseconds. Best-effort: degrade silently on a read-only db or any
# error so the listing never fails because of the prime.
if messages_has_session_id and messages_has_timestamp: if messages_has_session_id and messages_has_timestamp:
try: try:
cur.execute( cur.execute("PRAGMA index_list(messages)")
"CREATE INDEX IF NOT EXISTS idx_messages_session " messages_index_present = any(str(row[1]) == "idx_messages_session" for row in cur.fetchall())
"ON messages(session_id, timestamp)" except sqlite3.Error:
) messages_index_present = False
conn.commit() try:
if not messages_index_present:
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_messages_session "
"ON messages(session_id, timestamp)"
)
conn.commit()
messages_index_present = True
except sqlite3.Error: except sqlite3.Error:
pass # read-only db / locked / older schema — degrade gracefully pass # read-only db / locked / older schema — degrade gracefully
@@ -532,21 +533,13 @@ def read_importable_agent_session_rows(
join_clause = "" join_clause = ""
group_by_clause = "" group_by_clause = ""
if use_messages_join and messages_has_timestamp: order_by_clause = "ORDER BY s.started_at DESC"
order_by_clause = "ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC" latest_messages_cte = None
candidate_order_clause = ( candidate_order_clause = "ORDER BY s.started_at DESC"
"ORDER BY COALESCE(\n"
" (SELECT MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id),\n"
" s.started_at\n"
" ) DESC,\n"
" s.started_at DESC"
)
else:
order_by_clause = "ORDER BY s.started_at DESC"
candidate_order_clause = "ORDER BY s.started_at DESC"
where_clauses = ["s.source IS NOT NULL"] where_clauses = ["s.source IS NOT NULL"]
params: list[object] = [] params: list[object] = []
included = ()
if include_sources: if include_sources:
included = tuple(str(source) for source in include_sources if source) included = tuple(str(source) for source in include_sources if source)
if included: if included:
@@ -560,6 +553,32 @@ def read_importable_agent_session_rows(
where_clauses.append(f"s.source NOT IN ({placeholders})") where_clauses.append(f"s.source NOT IN ({placeholders})")
params.extend(excluded) params.extend(excluded)
use_preaggregated_candidate_order = (
use_messages_join
and messages_has_timestamp
and included == ("cron",)
and not messages_index_present
)
if use_preaggregated_candidate_order:
order_by_clause = "ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC"
latest_messages_cte = (
"latest_messages AS (\n"
" SELECT mx.session_id AS session_id, MAX(mx.timestamp) AS last_message_at\n"
" FROM messages mx\n"
" GROUP BY mx.session_id\n"
" )"
)
candidate_order_clause = "ORDER BY COALESCE(lm.last_message_at, s.started_at) DESC, s.started_at DESC"
elif use_messages_join and messages_has_timestamp:
order_by_clause = "ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC"
candidate_order_clause = (
"ORDER BY COALESCE(\n"
" (SELECT MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id),\n"
" s.started_at\n"
" ) DESC,\n"
" s.started_at DESC"
)
select_sql = f""" select_sql = f"""
SELECT s.id, s.title, s.model, s.message_count, SELECT s.id, s.title, s.model, s.message_count,
s.started_at, s.source, s.started_at, s.source,
@@ -592,15 +611,38 @@ def read_importable_agent_session_rows(
# Oversampling preserves room for hidden compression segments or # Oversampling preserves room for hidden compression segments or
# other rows filtered after projection. # other rows filtered after projection.
candidate_limit = max(result_limit * 8, result_limit) candidate_limit = max(result_limit * 8, result_limit)
if latest_messages_cte:
candidate_cte = (
"WITH {latest_messages_cte}, candidates AS (\n"
" SELECT s.id\n"
" FROM sessions s\n"
" LEFT JOIN latest_messages lm ON lm.session_id = s.id\n"
" WHERE {where_clause}\n"
" {candidate_order_clause}\n"
" LIMIT ?\n"
" )"
).format(
latest_messages_cte=latest_messages_cte,
where_clause=" AND ".join(where_clauses),
candidate_order_clause=candidate_order_clause,
)
else:
candidate_cte = (
"WITH candidates AS (\n"
" SELECT s.id\n"
" FROM sessions s\n"
" WHERE {where_clause}\n"
" {candidate_order_clause}\n"
" LIMIT ?\n"
" )"
).format(
where_clause=" AND ".join(where_clauses),
candidate_order_clause=candidate_order_clause,
)
cur.execute( cur.execute(
f""" f"""
WITH candidates AS ( {candidate_cte}
SELECT s.id
FROM sessions s
WHERE {' AND '.join(where_clauses)}
{candidate_order_clause}
LIMIT ?
)
{select_sql} {select_sql}
FROM sessions s FROM sessions s
JOIN candidates c ON c.id = s.id JOIN candidates c ON c.id = s.id
+213 -5
View File
@@ -4,12 +4,15 @@ import pathlib
import sqlite3 import sqlite3
import time import time
import pytest
import api.agent_sessions as agent_sessions import api.agent_sessions as agent_sessions
_REAL_SQLITE_CONNECT = sqlite3.connect
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
def _make_state_db(path, *, sessions=80, messages_per_session=3): def _make_state_db(path, *, sessions=80, messages_per_session=3, create_messages_index=True, source="cli", session_source="cli"):
conn = sqlite3.connect(str(path)) conn = sqlite3.connect(str(path))
conn.executescript( conn.executescript(
""" """
@@ -33,7 +36,6 @@ def _make_state_db(path, *, sessions=80, messages_per_session=3):
content TEXT, content TEXT,
timestamp REAL timestamp REAL
); );
CREATE INDEX idx_messages_session ON messages(session_id, timestamp);
""" """
) )
base = time.time() - sessions base = time.time() - sessions
@@ -44,19 +46,153 @@ def _make_state_db(path, *, sessions=80, messages_per_session=3):
""" """
INSERT INTO sessions INSERT INTO sessions
(id, source, session_source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) (id, source, session_source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason)
VALUES (?, 'cli', 'cli', ?, 'openai/gpt-5', ?, ?, NULL, NULL, NULL) VALUES (?, ?, ?, ?, 'openai/gpt-5', ?, ?, NULL, NULL, NULL)
""", """,
(sid, sid, started, messages_per_session), (sid, source, session_source, sid, started, messages_per_session),
) )
for j in range(messages_per_session): for j in range(messages_per_session):
conn.execute( conn.execute(
"INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, ?, 'hello', ?)", "INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, ?, 'hello', ?)",
(f"msg_{i:04d}_{j:02d}", sid, "user" if j == 0 else "assistant", started + j / 10), (f"msg_{i:04d}_{j:02d}", sid, "user" if j == 0 else "assistant", started + j / 10),
) )
if create_messages_index:
conn.execute("CREATE INDEX idx_messages_session ON messages(session_id, timestamp)")
conn.commit() conn.commit()
conn.close() conn.close()
def _newest_first_reference_ids(db_path, *, include_sources=None, exclude_sources=("webui",)):
where_clauses = ["s.source IS NOT NULL"]
params = []
if include_sources:
placeholders = ", ".join("?" for _ in include_sources)
where_clauses.append(f"s.source IN ({placeholders})")
params.extend(include_sources)
if exclude_sources:
placeholders = ", ".join("?" for _ in exclude_sources)
where_clauses.append(f"s.source NOT IN ({placeholders})")
params.extend(exclude_sources)
conn = sqlite3.connect(f"file:{db_path}?mode=ro&immutable=1", uri=True)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
f"""
SELECT s.id
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
WHERE {' AND '.join(where_clauses)}
GROUP BY s.id
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
""",
params,
).fetchall()
return [row["id"] for row in rows]
finally:
conn.close()
def _execute_candidate_ordering_baseline_sql(
db_path,
candidate_limit,
*,
budget_ops,
interval=1,
include_sources=None,
exclude_sources=("webui",),
):
where_clauses = ["s.source IS NOT NULL"]
params = []
if include_sources:
placeholders = ", ".join("?" for _ in include_sources)
where_clauses.append(f"s.source IN ({placeholders})")
params.extend(include_sources)
if exclude_sources:
placeholders = ", ".join("?" for _ in exclude_sources)
where_clauses.append(f"s.source NOT IN ({placeholders})")
params.extend(exclude_sources)
def _on_progress():
nonlocal steps
steps += 1
return 1 if steps > budget_ops else 0
steps = 0
conn = sqlite3.connect(f"file:{db_path}?mode=ro&immutable=1", uri=True)
conn.set_progress_handler(_on_progress, interval)
try:
return conn.execute(
f"""
WITH candidates AS (
SELECT s.id
FROM sessions s
WHERE {' AND '.join(where_clauses)}
ORDER BY COALESCE(
(SELECT MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id),
s.started_at
) DESC,
s.started_at DESC
LIMIT ?
)
SELECT s.id
FROM sessions s
JOIN candidates c ON c.id = s.id
LEFT JOIN messages m ON m.session_id = s.id
GROUP BY s.id
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
""",
[*params, candidate_limit],
).fetchall()
finally:
conn.close()
def _make_connect_with_progress_budget(*, budget_ops, interval=1):
def _connect(database, *_, **__):
steps = {"count": 0}
database_uri = str(database)
if database_uri.startswith("file:"):
target_uri = database_uri
else:
target_uri = f"file:{database_uri}?mode=ro&immutable=1"
def _on_progress():
steps["count"] += 1
return 1 if steps["count"] > budget_ops else 0
conn = _REAL_SQLITE_CONNECT(
target_uri,
uri=True,
)
conn.set_progress_handler(_on_progress, interval)
return conn
return _connect
def _make_connect_with_progress_counter(*, interval=1):
steps = {"count": 0}
def _connect(database, *_, **__):
database_uri = str(database)
if database_uri.startswith("file:"):
target_uri = database_uri
else:
target_uri = f"file:{database_uri}?mode=ro&immutable=1"
def _on_progress():
steps["count"] += 1
return 0
conn = _REAL_SQLITE_CONNECT(
target_uri,
uri=True,
)
conn.set_progress_handler(_on_progress, interval)
return conn
return _connect, steps
def test_importable_agent_rows_push_sidebar_limit_into_sql(tmp_path): def test_importable_agent_rows_push_sidebar_limit_into_sql(tmp_path):
"""A capped sidebar scan should not aggregate the entire state.db first.""" """A capped sidebar scan should not aggregate the entire state.db first."""
db = tmp_path / "state.db" db = tmp_path / "state.db"
@@ -71,10 +207,82 @@ def test_importable_agent_rows_push_sidebar_limit_into_sql(tmp_path):
src = (REPO_ROOT / "api" / "agent_sessions.py").read_text() src = (REPO_ROOT / "api" / "agent_sessions.py").read_text()
assert "WITH candidates AS" in src assert "WITH candidates AS" in src
assert "JOIN candidates c ON c.id = s.id" in src assert "JOIN candidates c ON c.id = s.id" in src
assert "SELECT MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id" in src assert "latest_messages AS" in src
assert "LEFT JOIN latest_messages lm ON lm.session_id = s.id" in src
assert 'included == ("cron",)' in src
assert "not messages_index_present" in src
assert "PRAGMA index_list(messages)" in src
assert "CREATE INDEX IF NOT EXISTS idx_messages_session" in src
assert "_CRON_PREAGGREGATE_CANDIDATE_ORDER_MIN_MESSAGES" not in src
assert "MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id" in src
assert "candidate_limit = max(result_limit * 8, result_limit)" in src assert "candidate_limit = max(result_limit * 8, result_limit)" in src
def test_importable_agent_rows_candidate_ordering_stays_under_progress_budget(tmp_path, monkeypatch):
"""Cron-only missing-index scans should fail under the old shape budget, then pass after pre-aggregation."""
db = tmp_path / "state.db"
_make_state_db(
db,
sessions=120,
messages_per_session=900,
create_messages_index=False,
source="cron",
session_source="cron",
)
reference_ids = _newest_first_reference_ids(db, include_sources=("cron",), exclude_sources=None)
candidate_limit = max(20 * 8, 20)
progress_interval = 100
original_connect = agent_sessions.sqlite3.connect
connect_with_progress_counter, progress_counter = _make_connect_with_progress_counter(
interval=progress_interval
)
monkeypatch.setattr(agent_sessions.sqlite3, "connect", connect_with_progress_counter)
try:
measured_rows = agent_sessions.read_importable_agent_session_rows(
db,
limit=20,
exclude_sources=None,
include_sources=("cron",),
)
assert [row["id"] for row in measured_rows] == reference_ids[:20]
finally:
# Keep this helper isolated; the baseline must still run without the
# counting handler to validate raw cost differences.
monkeypatch.setattr(agent_sessions.sqlite3, "connect", original_connect)
# Give the head path a small deterministic margin, then require the old
# correlated query to exceed the same budget on the missing-index branch.
progress_budget_ops = max(progress_counter["count"] + 200, 1)
with pytest.raises(sqlite3.OperationalError, match="interrupted"):
_execute_candidate_ordering_baseline_sql(
db,
candidate_limit,
budget_ops=progress_budget_ops,
interval=progress_interval,
include_sources=("cron",),
exclude_sources=None,
)
monkeypatch.setattr(
agent_sessions.sqlite3,
"connect",
_make_connect_with_progress_budget(
budget_ops=progress_budget_ops,
interval=progress_interval,
),
)
rows = agent_sessions.read_importable_agent_session_rows(
db,
limit=20,
exclude_sources=None,
include_sources=("cron",),
)
assert [row["id"] for row in rows] == reference_ids[:20]
def test_importable_agent_rows_limit_includes_resumed_old_session(tmp_path): def test_importable_agent_rows_limit_includes_resumed_old_session(tmp_path):
"""The capped candidate window must not hide old sessions resumed recently.""" """The capped candidate window must not hide old sessions resumed recently."""
db = tmp_path / "state.db" db = tmp_path / "state.db"