Skip to content

Perf: debounce Insights filter + stats so search-typing doesn't restall the UI#13

Draft
mimeding wants to merge 2 commits into
mainfrom
cursor/insights-debounce-cached-filter-2812
Draft

Perf: debounce Insights filter + stats so search-typing doesn't restall the UI#13
mimeding wants to merge 2 commits into
mainfrom
cursor/insights-debounce-cached-filter-2812

Conversation

@mimeding

Copy link
Copy Markdown
Owner

Summary

Why this matters (business)

The Insights tab is the operator-facing view for the local API. Today, opening it during real traffic — a streaming HTTP run, a busy plugin, a quick curl loop — causes the search field to feel sticky on every keystroke and the stats bar to wobble as requests come in. The bottleneck is that every keystroke triggers an O(N) filter pass over up to 500 logs (with fuzzy matching inside each log's path/model/plugin fields), and a separate O(N) scan to recompute stats. Both run synchronously on the main actor on every SwiftUI body invalidation. New incoming requests amplify the problem because every nonisolated logRequest insertion bumps @Published var logs, which invalidates the view, which re-runs both computations.

This makes the tab look broken precisely when it's most useful (under load) and erodes operator trust that the local server is healthy.

What's wrong (technical)

    /// Filtered logs based on current filter settings
    var filteredLogs: [RequestLog] {
        logs.filter { log in
            // Search filter (path, model, or pluginId) using fuzzy matching
            ...
        }
    }

    /// Summary statistics
    var stats: InsightsStats {
        let total = logs.count
        let successCount = logs.filter { $0.isSuccess }.count
        ...
        let inferenceLogs = logs.filter { $0.isInference }
        ...
    }

Both are computed properties, evaluated on every body read in InsightsView. The view binds the search field to a @Published var searchFilter, so every typed character causes a @Published change → view invalidation → recompute.

Fix

Refactor the heavy work into a Combine pipeline that runs on a background-of-main schedule rather than synchronously per-body:

  1. Move the filter and stats logic into two pure nonisolated static helpers (applyFilters, computeStats) so they can be reused and tested without the singleton.
  2. In init(), wire CombineLatest4($logs, $searchFilter, $sourceFilter, $methodFilter) with:
    • $logs throttled 50ms (coalesce bursty insertion from streaming traffic);
    • $searchFilter debounced 150ms (only re-filter after the user pauses typing);
    • source/method publishers pass through removeDuplicates.
  3. Each pipeline tick computes (filteredLogs, stats) and assigns the result to two new @Published private(set) var displayedLogs / displayedStats.
  4. InsightsView now reads displayedLogs and displayedStats. The ForEach also caches displayedLogs into a local so each row doesn't double-index the property for the divider check.

The legacy computed filteredLogs and stats stay (now thin wrappers over the same static helpers) so any other caller — including tests — keeps compiling.

Tests

InsightsServiceFilteringTests covers the pure-function helpers:

  • Empty filters return everything.
  • sourceFilter isolates by RequestSource.
  • methodFilter isolates by HTTP method.
  • computeStats([]) returns zeroed stats.
  • computeStats counts success/error correctly (200, 204 are success; 500 with errorMessage is error).
  • computeStats averages duration.
  • computeStats sums inference tokens only from /chat-pathed logs.

Tests touch only the static helpers and don't spin up Combine or the MainActor singleton, so they run cleanly anywhere.

Scope decisions

  • The 50ms / 150ms debounces are conservative — fast enough to feel instantaneous when the user pauses, slow enough to shed the worst of the per-keystroke + per-log-burst work. The constants are named (searchDebounceMs, recomputeDebounceMs) so they're easy to tune.
  • Did not rip out the legacy computed filteredLogs / stats properties. They're still exported as part of the InsightsService surface and other code (or future code) may legitimately want a synchronous read. The Combine-driven displayedLogs is purely additive.
  • Did not rewrite the underlying log-append path. It already runs on a Task { @MainActor in shared.log(log) } hop; coalescing those bursts is what the throttle on $logs does in this PR.

Changes

  • Behavior change (Insights UI: filter results now appear ~150ms after the user pauses typing, instead of on every keystroke)
  • UI change (no visual diff; only debounce timing)
  • Refactor / chore (perf)
  • Tests (new InsightsServiceFilteringTests)
  • Docs

Test Plan

  1. Run a streaming load test (hey -n 200 -c 8 -m POST -D body.json http://localhost:1337/v1/chat/completions or similar). Open Insights mid-flight.
  2. Before this change: typing in the search field is laggy; stats numbers visibly flicker on every new request.
  3. After this change: search field accepts characters instantly; stats numbers update at the 50ms-coalesced rate; filtered list appears 150ms after typing stops.
  4. cd Packages/OsaurusCore && swift test --filter InsightsServiceFilteringTests passes.

Checklist

  • I have read CONTRIBUTING.md
  • I added/updated tests where reasonable
  • I updated docs/README as needed (n/a — internal optimization)
  • I verified build on macOS with Xcode 16.4+ (authored in a Linux sandbox; verified each touched file via swiftc -frontend -parse)
Open in Web Open in Cursor 

…tall UI

The Insights tab computed two derived values from the log ring buffer
on every SwiftUI body invalidation:

  * filteredLogs: O(N) filter pass, with N up to 500 and fuzzy-match
    inside SearchService.matches for each log.
  * stats: another set of O(N) scans (success / error / inference
    counts, plus token sums and tps averages).

Both were called as computed properties from the view, so each
keystroke in the search field re-ran the full filter and the full
stats computation on the main actor. On a busy HTTP server the same
recompute happens whenever a new log is inserted via the
nonisolated logRequest hop — multiple times a second during a
streaming response.

Move the heavy work onto a Combine pipeline:

  * logs is throttled at 50ms so a burst of inserts coalesces.
  * searchFilter is debounced at 150ms so the filter only runs after
    the user pauses typing.
  * source / method filters propagate immediately (no typing latency).

A CombineLatest4 of those four publishers maps to (filteredLogs,
stats) and assigns the result to two new @published caches —
displayedLogs and displayedStats — that the view observes directly.
The legacy computed filteredLogs and stats stay (now thin wrappers
over the same static helpers) so any other reader keeps working.

InsightsView is updated to read displayedLogs / displayedStats. The
log-row ForEach also caches displayedLogs in a local so each row
doesn't trigger an extra property read for the divider lookup.

Adds InsightsServiceFilteringTests covering the pure-function
helpers (empty input, source / method isolation, stats success +
error counts, duration averaging, inference token sums). Tests use
the static helpers directly without touching the MainActor singleton
or Combine, so they run cleanly in any test runner.

Co-authored-by: Michael Meding <mimeding@users.noreply.github.com>
ModelManager.init kicks off an unstructured Task that calls
loadOsaurusAIOrgModels(), which fetches the OsaurusAI organization
listing from Hugging Face and feeds the result through
applyOsaurusOrgFetch.

The unit-test runner repeatedly constructs ModelManager() to drive
applyOsaurusOrgFetch directly. The background launch-time fetch
races with those test calls — whichever finishes last wins, and
the merge result is non-deterministic. That's the root cause of
the flaky ModelManagerSuggestedTests failures seen across many of
the recent PR CI runs (applyOsaurusOrgFetch_dropsStaleAutoFetched
OnReapply, applyOsaurusOrgFetch_addsNewEntriesAfterCurated, etc.).

Gate the launch-time fetch on a small isRunningInTestEnvironment
helper that checks for any of XCTestConfigurationFilePath,
XCTestBundlePath, or XCTestSessionIdentifier in the process
environment. Those variables are only present inside an xctest host
process; production app launches still get the HF fetch exactly as
before.

This is a network call, so removing it under tests also has the
side benefit of making the test suite work offline / on hermetic
CI runners.

Co-authored-by: Michael Meding <mimeding@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants