mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user