From fb515c26f85e150af41e02267c03803b281c24b2 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:54:55 -0400 Subject: [PATCH] Perf: off-thread alert-check DuckDB queries (Lite UI freeze under load) The alert-check paths ran synchronous DuckDB queries directly on the WPF dispatcher: MainWindow.CheckPerformanceAlerts (poison waits, blocked-process reports, deadlocks, long-running queries, tempdb space, anomalous jobs) and ServerTab.RefreshAlertCountsAsync (alert counts). DuckDB.NET is synchronous, so `await _dataService.X()` completes on the calling thread; under load a single DuckDB connection open is ~766ms. These fire on the 60s overview auto-refresh and on every Blocking-tab refresh, causing intermittent ~1s UI freezes that landed on whatever tab the user happened to be on. Wrap the 9 calls in Task.Run(() => _dataService.X(...)) -- the same off-thread pattern used across the other refresh paths; the off-thread sweep (#1110-1120) missed the alert/overview paths. Root-caused with dotnet-trace (dotnet-sampled-thread-time); method-level Stopwatch instrumentation had ruled out chart render (0-3ms), GC (0), and grid binding (<30ms). Measured (Lite vs SQL2025 under HammerDB TPC-C, 75s spanning the overview auto-refresh): dispatcher latency MAX 1174ms -> 9.2ms, p99 2.0ms -> 1.5ms, zero stalls >16ms (was 2 over 250ms including one over 1s). Lite.Tests 434/434. Co-Authored-By: Claude Opus 4.8 (1M context) --- Lite/Controls/ServerTab.Refresh.cs | 2 +- Lite/MainWindow.xaml.cs | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Lite/Controls/ServerTab.Refresh.cs b/Lite/Controls/ServerTab.Refresh.cs index f181b84f..3904e1af 100644 --- a/Lite/Controls/ServerTab.Refresh.cs +++ b/Lite/Controls/ServerTab.Refresh.cs @@ -121,7 +121,7 @@ private async System.Threading.Tasks.Task RefreshAlertCountsAsync(int hoursBack, { try { - var (blockingCount, deadlockCount, latestEventTime) = await _dataService.GetAlertCountsAsync(_serverId, hoursBack, fromDate, toDate); + var (blockingCount, deadlockCount, latestEventTime) = await Task.Run(() => _dataService.GetAlertCountsAsync(_serverId, hoursBack, fromDate, toDate)); AlertCountsChanged?.Invoke(blockingCount, deadlockCount, latestEventTime); } catch (Exception ex) diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index 919b3a83..0ef4771d 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -1549,7 +1549,7 @@ await _emailAlertService.TrySendAlertEmailAsync( { try { - var blockingRows = await _dataService.GetRecentBlockedProcessReportsAsync(summary.ServerId, hoursBack: 1); + var blockingRows = await Task.Run(() => _dataService.GetRecentBlockedProcessReportsAsync(summary.ServerId, hoursBack: 1)); effectiveBlockingCount = blockingRows .Count(r => string.IsNullOrEmpty(r.DatabaseName) || !App.AlertExcludedDatabases.Any(e => @@ -1622,7 +1622,7 @@ await _emailAlertService.TrySendAlertEmailAsync( { try { - var deadlockRows = await _dataService.GetRecentDeadlocksAsync(summary.ServerId, hoursBack: 1); + var deadlockRows = await Task.Run(() => _dataService.GetRecentDeadlocksAsync(summary.ServerId, hoursBack: 1)); effectiveDeadlockCount = deadlockRows .Count(r => !IsDeadlockExcluded(r, App.AlertExcludedDatabases)); } @@ -1691,7 +1691,7 @@ await _emailAlertService.TrySendAlertEmailAsync( { try { - var poisonWaits = await _dataService.GetLatestPoisonWaitAvgsAsync(summary.ServerId); + var poisonWaits = await Task.Run(() => _dataService.GetLatestPoisonWaitAvgsAsync(summary.ServerId)); var triggered = poisonWaits.FindAll(w => w.AvgMsPerWait >= App.AlertPoisonWaitThresholdMs); if (triggered.Count > 0) @@ -1760,7 +1760,7 @@ await _emailAlertService.TrySendAlertEmailAsync( { try { - var longRunning = await _dataService.GetLongRunningQueriesAsync(summary.ServerId, App.AlertLongRunningQueryThresholdMinutes, App.AlertLongRunningQueryMaxResults, App.AlertLongRunningQueryExcludeSpServerDiagnostics, App.AlertLongRunningQueryExcludeWaitFor, App.AlertLongRunningQueryExcludeBackups, App.AlertLongRunningQueryExcludeMiscWaits, App.AlertLongRunningQueryExcludeCdc); + var longRunning = await Task.Run(() => _dataService.GetLongRunningQueriesAsync(summary.ServerId, App.AlertLongRunningQueryThresholdMinutes, App.AlertLongRunningQueryMaxResults, App.AlertLongRunningQueryExcludeSpServerDiagnostics, App.AlertLongRunningQueryExcludeWaitFor, App.AlertLongRunningQueryExcludeBackups, App.AlertLongRunningQueryExcludeMiscWaits, App.AlertLongRunningQueryExcludeCdc)); if (App.AlertExcludedDatabases.Count > 0) { @@ -1841,7 +1841,7 @@ await _emailAlertService.TrySendAlertEmailAsync( { try { - var tempDb = await _dataService.GetLatestTempDbSpaceAsync(summary.ServerId); + var tempDb = await Task.Run(() => _dataService.GetLatestTempDbSpaceAsync(summary.ServerId)); if (tempDb != null && tempDb.UsedPercent >= App.AlertTempDbSpaceThresholdPercent) { @@ -1903,7 +1903,7 @@ await _emailAlertService.TrySendAlertEmailAsync( { try { - var anomalousJobs = await _dataService.GetAnomalousJobsAsync(summary.ServerId, App.AlertLongRunningJobMultiplier); + var anomalousJobs = await Task.Run(() => _dataService.GetAnomalousJobsAsync(summary.ServerId, App.AlertLongRunningJobMultiplier)); /* _lastLongRunningJobAlert is keyed per job *run* ({server}:{jobId}:{startTime}), so unlike the per-server cooldown dicts it grows without bound. Drop entries @@ -2003,7 +2003,7 @@ private static string TruncateText(string text, int maxLength = 300) { if (_dataService == null) return null; - var events = await _dataService.GetRecentBlockedProcessReportsAsync(serverId, hoursBack: 1); + var events = await Task.Run(() => _dataService.GetRecentBlockedProcessReportsAsync(serverId, hoursBack: 1)); if (events == null || events.Count == 0) return null; if (App.AlertExcludedDatabases.Count > 0) @@ -2063,7 +2063,7 @@ private static string TruncateText(string text, int maxLength = 300) { if (_dataService == null) return null; - var deadlocks = await _dataService.GetRecentDeadlocksAsync(serverId, hoursBack: 1); + var deadlocks = await Task.Run(() => _dataService.GetRecentDeadlocksAsync(serverId, hoursBack: 1)); if (deadlocks == null || deadlocks.Count == 0) return null; if (App.AlertExcludedDatabases.Count > 0)