perf(sessions): fold sessions+previews into one batched SSH round-trip

Audit Finding 1 — ChatViewModel.loadRecentSessions and
SessionsViewModel.load each fired two sequential `await
dataService.fetch*` calls (sessions + previews), paying the 420 ms
SSH RTT twice on every reload. Visible in ScarfMon traces as
back-to-back `ssh.run` intervals, totaling ~840 ms minimum
overhead per sidebar refresh.

Adds HermesDataService.sessionListSnapshot(limit:) — same shape
as the existing dashboardSnapshot, folds both queries into a
single backend.queryBatch() call. Both call sites switched.

Halves the SSH round-trips for every sidebar load. With Finding 5's
coalescing, redundant parallel reloads also become free. Together,
the 9× redundant queries-per-minute observed in baseline captures
should drop substantially.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-05 12:07:31 +02:00
parent 432d5b0b52
commit 20cc3a2985
3 changed files with 71 additions and 4 deletions
@@ -605,6 +605,60 @@ public actor HermesDataService {
}
}
/// Bundle for the chat sidebar / Sessions tab loaders. Folds
/// `fetchSessions(limit:)` + `fetchSessionPreviews(limit:)` into
/// one `queryBatch()` round-trip same shape as
/// `dashboardSnapshot`. Pre-fix `ChatViewModel.loadRecentSessions`
/// + `SessionsViewModel.load` each fired the two `await
/// dataService.fetch*` calls in serial, paying the SSH RTT
/// twice (~840 ms minimum on a 420 ms-RTT remote, observed in
/// ScarfMon `mac.loadRecentSessions` traces). Halves the
/// round-trips for every sidebar load. Each tick still pays
/// for `dashboard.loadRegistry` separately because that's a
/// projects.json read (not SQL) and goes through a different
/// transport call.
public struct SessionListSnapshot: Sendable {
public let sessions: [HermesSession]
public let previews: [String: String]
}
public func sessionListSnapshot(limit: Int = QueryDefaults.sessionLimit) async -> SessionListSnapshot {
let previewLimit = limit
let statements: [(sql: String, params: [SQLValue])] = [
(
"SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL ORDER BY started_at DESC LIMIT ?",
[.integer(Int64(limit))]
),
(
"""
SELECT m.session_id, substr(m.content, 1, \(QueryDefaults.previewContentLength))
FROM messages m
INNER JOIN (
SELECT session_id, MIN(id) as min_id
FROM messages
WHERE role = 'user' AND content <> ''
GROUP BY session_id
) first ON m.id = first.min_id
ORDER BY m.timestamp DESC
LIMIT ?
""",
[.integer(Int64(previewLimit))]
)
]
do {
let resultSets = try await backend.queryBatch(statements)
let sessions = (resultSets.first ?? []).map { sessionFromRow($0) }
var previews: [String: String] = [:]
for row in (resultSets.count > 1 ? resultSets[1] : []) {
previews[row.string(at: 0)] = row.string(at: 1)
}
return SessionListSnapshot(sessions: sessions, previews: previews)
} catch {
Self.logger.warning("sessionListSnapshot failed: \(error.localizedDescription, privacy: .public)")
return SessionListSnapshot(sessions: [], previews: [:])
}
}
/// Bundle the queries Insights fires on every load into one
/// backend round-trip same rationale as `dashboardSnapshot`.
public struct InsightsSnapshot: Sendable {
@@ -947,8 +947,16 @@ final class ChatViewModel {
// getting truncated out of the original limit). Sessions feature
// loads 500; the chat sidebar doesn't need that, but 50 keeps
// the project filter useful without measurable cost.
let fetchedSessions = await dataService.fetchSessions(limit: 50)
let fetchedPreviews = await dataService.fetchSessionPreviews(limit: 50)
//
// v2.7: folded sessions + previews into one queryBatch round
// trip via sessionListSnapshot. Pre-fix the two awaits below
// were serialized SSH calls, paying the 420 ms RTT twice
// every time the file watcher fired (~2.2 s baseline reload).
// sessionListSnapshot halves the round-trips for every
// sidebar refresh.
let snapshot = await dataService.sessionListSnapshot(limit: 50)
let fetchedSessions = snapshot.sessions
let fetchedPreviews = snapshot.previews
await dataService.close()
// Project attribution + registry single batched off-main read.
@@ -77,8 +77,13 @@ final class SessionsViewModel {
// re-opening cleanup() closes on disappear.
let opened = await dataService.refresh()
guard opened else { return }
sessions = await dataService.fetchSessions(limit: 500)
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
// v2.7: folded the two serial fetches into one batched round
// trip via sessionListSnapshot. Pre-fix this paid the 420 ms
// SSH RTT twice on every Sessions tab open (~840 ms minimum
// for the two queries alone over remote).
let snapshot = await dataService.sessionListSnapshot(limit: 500)
sessions = snapshot.sessions
sessionPreviews = snapshot.previews
// Load attribution + registry off the main actor in one batch so
// 500 rows don't trigger 500 SFTP reads. Failure is silent the