From 20cc3a2985b5c68fa2218e3380e35d728c071409 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 5 May 2026 12:07:31 +0200 Subject: [PATCH] perf(sessions): fold sessions+previews into one batched SSH round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Services/HermesDataService.swift | 54 +++++++++++++++++++ .../Chat/ViewModels/ChatViewModel.swift | 12 ++++- .../ViewModels/SessionsViewModel.swift | 9 +++- 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift index fd27b85..0ebc523 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift @@ -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 { diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 41dc8a8..7379d9d 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -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. diff --git a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift index 1a521e4..9473af5 100644 --- a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift +++ b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift @@ -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