From d421b0048288d737b8f499eb788f1f6696eb99e6 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:51:41 -0500 Subject: [PATCH 01/32] Fix mutex crash on app exit (fixes #89) ReleaseMutex() throws ApplicationException if called by a thread that doesn't own the mutex. This happens when the app exits after detecting a second instance, or when shutdown is triggered from a non-owning thread. Track ownership and only release if we own it. Fixed in both Dashboard and Lite. Co-Authored-By: Claude Opus 4.6 --- Dashboard/App.xaml.cs | 11 +++++++---- Lite/App.xaml.cs | 11 +++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Dashboard/App.xaml.cs b/Dashboard/App.xaml.cs index 1a17661f..54af8047 100644 --- a/Dashboard/App.xaml.cs +++ b/Dashboard/App.xaml.cs @@ -20,13 +20,14 @@ public partial class App : Application { private const string MutexName = "PerformanceMonitorDashboard_SingleInstance"; private Mutex? _singleInstanceMutex; + private bool _ownsMutex; protected override void OnStartup(StartupEventArgs e) { // Check for existing instance - _singleInstanceMutex = new Mutex(true, MutexName, out bool isNewInstance); + _singleInstanceMutex = new Mutex(true, MutexName, out _ownsMutex); - if (!isNewInstance) + if (!_ownsMutex) { // Another instance is already running - activate it and exit NativeMethods.BroadcastShowMessage(); @@ -58,8 +59,10 @@ protected override void OnExit(ExitEventArgs e) mainWin.ExitApplication(); } - // Release the mutex - _singleInstanceMutex?.ReleaseMutex(); + if (_ownsMutex) + { + _singleInstanceMutex?.ReleaseMutex(); + } _singleInstanceMutex?.Dispose(); base.OnExit(e); diff --git a/Lite/App.xaml.cs b/Lite/App.xaml.cs index 47d46db6..950855c7 100644 --- a/Lite/App.xaml.cs +++ b/Lite/App.xaml.cs @@ -20,6 +20,7 @@ public partial class App : Application { private const string MutexName = "PerformanceMonitorLite_SingleInstance"; private Mutex? _singleInstanceMutex; + private bool _ownsMutex; /// /// Gets the application data directory where config and data files are stored. @@ -119,9 +120,9 @@ protected override void OnStartup(StartupEventArgs e) { // Check for existing instance - _singleInstanceMutex = new Mutex(true, MutexName, out bool isNewInstance); + _singleInstanceMutex = new Mutex(true, MutexName, out _ownsMutex); - if (!isNewInstance) + if (!_ownsMutex) { MessageBox.Show( "Performance Monitor Lite is already running.", @@ -168,8 +169,10 @@ protected override void OnExit(ExitEventArgs e) AppLogger.Info("App", "Shutting down"); AppLogger.Shutdown(); - // Release the mutex - _singleInstanceMutex?.ReleaseMutex(); + if (_ownsMutex) + { + _singleInstanceMutex?.ReleaseMutex(); + } _singleInstanceMutex?.Dispose(); base.OnExit(e); From 209f2e37565c489d662a461241b53e87c16ade70 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:22:48 -0500 Subject: [PATCH 02/32] DuckDB checkpoint optimization and timing accuracy fix - Add checkpoint_threshold=1GB to connection string to prevent auto-checkpoint stalls - Add manual CHECKPOINT after each collection cycle during idle time - Change collector execution from parallel Task.WhenAll to sequential per-server - Fix using var timing bug across all 16 collector files: change to explicit using blocks so appender Dispose (flush + connection close) is captured inside the DuckDB stopwatch, giving accurate timing in collection_log Co-Authored-By: Claude Opus 4.6 --- Lite/Database/DuckDbInitializer.cs | 24 ++- Lite/Services/CollectionBackgroundService.cs | 4 + ...teCollectorService.BlockedProcessReport.cs | 123 ++++++++------- Lite/Services/RemoteCollectorService.Cpu.cs | 48 +++--- .../RemoteCollectorService.Deadlocks.cs | 39 ++--- .../Services/RemoteCollectorService.FileIo.cs | 80 +++++----- .../Services/RemoteCollectorService.Memory.cs | 79 +++++----- .../RemoteCollectorService.MemoryGrants.cs | 53 ++++--- .../RemoteCollectorService.Perfmon.cs | 60 ++++---- .../RemoteCollectorService.ProcedureStats.cs | 124 +++++++-------- .../RemoteCollectorService.QuerySnapshots.cs | 68 +++++---- .../RemoteCollectorService.QueryStats.cs | 144 +++++++++--------- .../RemoteCollectorService.QueryStore.cs | 102 +++++++------ .../RemoteCollectorService.RunningJobs.cs | 45 +++--- .../Services/RemoteCollectorService.TempDb.cs | 41 ++--- .../RemoteCollectorService.WaitStats.cs | 54 ++++--- .../RemoteCollectorService.WaitingTasks.cs | 54 ++++--- Lite/Services/RemoteCollectorService.cs | 27 +++- 18 files changed, 640 insertions(+), 529 deletions(-) diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs index 13e7874b..77a441fd 100644 --- a/Lite/Database/DuckDbInitializer.cs +++ b/Lite/Database/DuckDbInitializer.cs @@ -29,8 +29,10 @@ public DuckDbInitializer(string databasePath, ILogger? logger /// /// Gets the connection string for the DuckDB database. + /// Disables automatic WAL checkpoints to prevent 2-3s stop-the-world stalls + /// during collector writes. Manual CHECKPOINT runs between collection cycles instead. /// - public string ConnectionString => $"Data Source={_databasePath}"; + public string ConnectionString => $"Data Source={_databasePath};checkpoint_threshold=1GB"; /// /// Ensures the database exists and all tables are created. @@ -408,6 +410,26 @@ public DuckDBConnection CreateConnection() return new DuckDBConnection(ConnectionString); } + /// + /// Runs a manual WAL checkpoint. Call this between collection cycles + /// to flush the WAL during idle time instead of during collector writes. + /// + public async Task CheckpointAsync() + { + try + { + using var connection = CreateConnection(); + await connection.OpenAsync(); + using var command = connection.CreateCommand(); + command.CommandText = "CHECKPOINT"; + await command.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + _logger?.LogDebug(ex, "Manual checkpoint failed (non-critical)"); + } + } + /// /// Executes a non-query SQL statement. /// diff --git a/Lite/Services/CollectionBackgroundService.cs b/Lite/Services/CollectionBackgroundService.cs index dd15d340..d52e1429 100644 --- a/Lite/Services/CollectionBackgroundService.cs +++ b/Lite/Services/CollectionBackgroundService.cs @@ -85,6 +85,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) IsCollecting = true; await _collectorService.RunDueCollectorsAsync(stoppingToken); LastCollectionTime = DateTime.UtcNow; + + /* Flush WAL during idle time instead of letting auto-checkpoint + stall collectors mid-write with 2-3s stop-the-world pauses */ + await _collectorService.CheckpointAsync(); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { diff --git a/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs b/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs index 7c6da3d3..f81df834 100644 --- a/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs +++ b/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs @@ -390,71 +390,76 @@ as it lingers in the ring buffer across collection cycles. */ _lastSqlMs = sqlSw.ElapsedMilliseconds; var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - using var appender = duckConnection.CreateAppender("blocked_process_reports"); - - while (await reader.ReadAsync(cancellationToken)) + using (var duckConnection = _duckDb.CreateConnection()) { - var eventTime = reader.IsDBNull(0) ? (DateTime?)null : reader.GetDateTime(0); - var reportXml = reader.IsDBNull(1) ? null : reader.GetString(1); - - if (string.IsNullOrEmpty(reportXml)) - { - continue; - } + await duckConnection.OpenAsync(cancellationToken); - /* Parse the blocked process report XML in C# */ - var parsed = ParseBlockedProcessReportXml(reportXml, eventTime); - if (parsed == null) + using (var appender = duckConnection.CreateAppender("blocked_process_reports")) { - continue; + while (await reader.ReadAsync(cancellationToken)) + { + var eventTime = reader.IsDBNull(0) ? (DateTime?)null : reader.GetDateTime(0); + var reportXml = reader.IsDBNull(1) ? null : reader.GetString(1); + + if (string.IsNullOrEmpty(reportXml)) + { + continue; + } + + /* Parse the blocked process report XML in C# */ + var parsed = ParseBlockedProcessReportXml(reportXml, eventTime); + if (parsed == null) + { + continue; + } + + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(parsed.EventTime) + .AppendValue(parsed.DatabaseName) + .AppendValue(parsed.BlockedSpid) + .AppendValue(parsed.BlockedEcid) + .AppendValue(parsed.BlockingSpid) + .AppendValue(parsed.BlockingEcid) + .AppendValue(parsed.WaitTimeMs) + .AppendValue(parsed.WaitResource) + .AppendValue(parsed.LockMode) + .AppendValue(parsed.BlockedStatus) + .AppendValue(parsed.BlockedIsolationLevel) + .AppendValue(parsed.BlockedLogUsed) + .AppendValue(parsed.BlockedTransactionCount) + .AppendValue(parsed.BlockedClientApp) + .AppendValue(parsed.BlockedHostName) + .AppendValue(parsed.BlockedLoginName) + .AppendValue(parsed.BlockedSqlText) + .AppendValue(parsed.BlockingStatus) + .AppendValue(parsed.BlockingIsolationLevel) + .AppendValue(parsed.BlockingClientApp) + .AppendValue(parsed.BlockingHostName) + .AppendValue(parsed.BlockingLoginName) + .AppendValue(parsed.BlockingSqlText) + .AppendValue(parsed.BlockedTransactionName) + .AppendValue(parsed.BlockingTransactionName) + .AppendValue(parsed.BlockedLastTranStarted) + .AppendValue(parsed.BlockingLastTranStarted) + .AppendValue(parsed.BlockedLastBatchStarted) + .AppendValue(parsed.BlockingLastBatchStarted) + .AppendValue(parsed.BlockedLastBatchCompleted) + .AppendValue(parsed.BlockingLastBatchCompleted) + .AppendValue(parsed.BlockedPriority) + .AppendValue(parsed.BlockingPriority) + .AppendValue(reportXml) + .EndRow(); + + rowsCollected++; + } } - - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(parsed.EventTime) - .AppendValue(parsed.DatabaseName) - .AppendValue(parsed.BlockedSpid) - .AppendValue(parsed.BlockedEcid) - .AppendValue(parsed.BlockingSpid) - .AppendValue(parsed.BlockingEcid) - .AppendValue(parsed.WaitTimeMs) - .AppendValue(parsed.WaitResource) - .AppendValue(parsed.LockMode) - .AppendValue(parsed.BlockedStatus) - .AppendValue(parsed.BlockedIsolationLevel) - .AppendValue(parsed.BlockedLogUsed) - .AppendValue(parsed.BlockedTransactionCount) - .AppendValue(parsed.BlockedClientApp) - .AppendValue(parsed.BlockedHostName) - .AppendValue(parsed.BlockedLoginName) - .AppendValue(parsed.BlockedSqlText) - .AppendValue(parsed.BlockingStatus) - .AppendValue(parsed.BlockingIsolationLevel) - .AppendValue(parsed.BlockingClientApp) - .AppendValue(parsed.BlockingHostName) - .AppendValue(parsed.BlockingLoginName) - .AppendValue(parsed.BlockingSqlText) - .AppendValue(parsed.BlockedTransactionName) - .AppendValue(parsed.BlockingTransactionName) - .AppendValue(parsed.BlockedLastTranStarted) - .AppendValue(parsed.BlockingLastTranStarted) - .AppendValue(parsed.BlockedLastBatchStarted) - .AppendValue(parsed.BlockingLastBatchStarted) - .AppendValue(parsed.BlockedLastBatchCompleted) - .AppendValue(parsed.BlockingLastBatchCompleted) - .AppendValue(parsed.BlockedPriority) - .AppendValue(parsed.BlockingPriority) - .AppendValue(reportXml) - .EndRow(); - - rowsCollected++; } + duckSw.Stop(); _lastDuckDbMs = duckSw.ElapsedMilliseconds; } diff --git a/Lite/Services/RemoteCollectorService.Cpu.cs b/Lite/Services/RemoteCollectorService.Cpu.cs index 09fd6df5..800fb01c 100644 --- a/Lite/Services/RemoteCollectorService.Cpu.cs +++ b/Lite/Services/RemoteCollectorService.Cpu.cs @@ -128,30 +128,34 @@ drs.end_time DESC /* Insert into DuckDB using Appender for bulk performance */ var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - using var appender = duckConnection.CreateAppender("cpu_utilization_stats"); - - while (await reader.ReadAsync(cancellationToken)) + using (var duckConnection = _duckDb.CreateConnection()) { - var sampleTime = reader.GetDateTime(0); - - /* Client-side dedup for ring buffer (computed sample_time can't be filtered in SQL) */ - if (!isAzureSqlDb && lastSampleTime.HasValue && sampleTime <= lastSampleTime.Value) - continue; - - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(sampleTime) - .AppendValue(reader.IsDBNull(1) ? 0 : reader.GetInt32(1)) - .AppendValue(reader.IsDBNull(2) ? 0 : reader.GetInt32(2)) - .EndRow(); - - rowsCollected++; + await duckConnection.OpenAsync(cancellationToken); + + using (var appender = duckConnection.CreateAppender("cpu_utilization_stats")) + { + while (await reader.ReadAsync(cancellationToken)) + { + var sampleTime = reader.GetDateTime(0); + + /* Client-side dedup for ring buffer (computed sample_time can't be filtered in SQL) */ + if (!isAzureSqlDb && lastSampleTime.HasValue && sampleTime <= lastSampleTime.Value) + continue; + + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(sampleTime) + .AppendValue(reader.IsDBNull(1) ? 0 : reader.GetInt32(1)) + .AppendValue(reader.IsDBNull(2) ? 0 : reader.GetInt32(2)) + .EndRow(); + + rowsCollected++; + } + } } duckSw.Stop(); diff --git a/Lite/Services/RemoteCollectorService.Deadlocks.cs b/Lite/Services/RemoteCollectorService.Deadlocks.cs index 2ff5e9f9..5fc79330 100644 --- a/Lite/Services/RemoteCollectorService.Deadlocks.cs +++ b/Lite/Services/RemoteCollectorService.Deadlocks.cs @@ -388,26 +388,31 @@ and was previously misattributed as DuckDB time. */ } var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - using var appender = duckConnection.CreateAppender("deadlocks"); - - foreach (var (deadlockTime, victimProcessId, victimSqlText, graphXml) in deadlockRows) + using (var duckConnection = _duckDb.CreateConnection()) { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(deadlockTime) - .AppendValue(victimProcessId) - .AppendValue(victimSqlText) - .AppendValue(graphXml) - .EndRow(); - - rowsCollected++; + await duckConnection.OpenAsync(cancellationToken); + + using (var appender = duckConnection.CreateAppender("deadlocks")) + { + foreach (var (deadlockTime, victimProcessId, victimSqlText, graphXml) in deadlockRows) + { + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(deadlockTime) + .AppendValue(victimProcessId) + .AppendValue(victimSqlText) + .AppendValue(graphXml) + .EndRow(); + + rowsCollected++; + } + } } + duckSw.Stop(); _lastDuckDbMs = duckSw.ElapsedMilliseconds; } diff --git a/Lite/Services/RemoteCollectorService.FileIo.cs b/Lite/Services/RemoteCollectorService.FileIo.cs index e829e143..1681624a 100644 --- a/Lite/Services/RemoteCollectorService.FileIo.cs +++ b/Lite/Services/RemoteCollectorService.FileIo.cs @@ -120,46 +120,50 @@ AND vfs.database_id < 32761 /* Insert into DuckDB with delta calculations */ var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - using var appender = duckConnection.CreateAppender("file_io_stats"); - - foreach (var stat in fileStats) + using (var duckConnection = _duckDb.CreateConnection()) { - var deltaKey = $"{stat.DatabaseId}_{stat.FileId}"; - var deltaReads = _deltaCalculator.CalculateDelta(serverId, "file_io_reads", deltaKey, stat.NumOfReads); - var deltaWrites = _deltaCalculator.CalculateDelta(serverId, "file_io_writes", deltaKey, stat.NumOfWrites); - var deltaReadBytes = _deltaCalculator.CalculateDelta(serverId, "file_io_read_bytes", deltaKey, stat.ReadBytes); - var deltaWriteBytes = _deltaCalculator.CalculateDelta(serverId, "file_io_write_bytes", deltaKey, stat.WriteBytes); - var deltaStallReadMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_read", deltaKey, stat.IoStallReadMs); - var deltaStallWriteMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_write", deltaKey, stat.IoStallWriteMs); - - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(stat.DatabaseName) - .AppendValue(stat.FileName) - .AppendValue(stat.FileType) - .AppendValue(stat.PhysicalName) - .AppendValue(stat.SizeMb) - .AppendValue(stat.NumOfReads) - .AppendValue(stat.NumOfWrites) - .AppendValue(stat.ReadBytes) - .AppendValue(stat.WriteBytes) - .AppendValue(stat.IoStallReadMs) - .AppendValue(stat.IoStallWriteMs) - .AppendValue(deltaReads) - .AppendValue(deltaWrites) - .AppendValue(deltaReadBytes) - .AppendValue(deltaWriteBytes) - .AppendValue(deltaStallReadMs) - .AppendValue(deltaStallWriteMs) - .EndRow(); - - rowsCollected++; + await duckConnection.OpenAsync(cancellationToken); + + using (var appender = duckConnection.CreateAppender("file_io_stats")) + { + foreach (var stat in fileStats) + { + var deltaKey = $"{stat.DatabaseId}_{stat.FileId}"; + var deltaReads = _deltaCalculator.CalculateDelta(serverId, "file_io_reads", deltaKey, stat.NumOfReads); + var deltaWrites = _deltaCalculator.CalculateDelta(serverId, "file_io_writes", deltaKey, stat.NumOfWrites); + var deltaReadBytes = _deltaCalculator.CalculateDelta(serverId, "file_io_read_bytes", deltaKey, stat.ReadBytes); + var deltaWriteBytes = _deltaCalculator.CalculateDelta(serverId, "file_io_write_bytes", deltaKey, stat.WriteBytes); + var deltaStallReadMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_read", deltaKey, stat.IoStallReadMs); + var deltaStallWriteMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_write", deltaKey, stat.IoStallWriteMs); + + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(stat.DatabaseName) + .AppendValue(stat.FileName) + .AppendValue(stat.FileType) + .AppendValue(stat.PhysicalName) + .AppendValue(stat.SizeMb) + .AppendValue(stat.NumOfReads) + .AppendValue(stat.NumOfWrites) + .AppendValue(stat.ReadBytes) + .AppendValue(stat.WriteBytes) + .AppendValue(stat.IoStallReadMs) + .AppendValue(stat.IoStallWriteMs) + .AppendValue(deltaReads) + .AppendValue(deltaWrites) + .AppendValue(deltaReadBytes) + .AppendValue(deltaWriteBytes) + .AppendValue(deltaStallReadMs) + .AppendValue(deltaStallWriteMs) + .EndRow(); + + rowsCollected++; + } + } } duckSw.Stop(); diff --git a/Lite/Services/RemoteCollectorService.Memory.cs b/Lite/Services/RemoteCollectorService.Memory.cs index 4531413b..e9ac7e90 100644 --- a/Lite/Services/RemoteCollectorService.Memory.cs +++ b/Lite/Services/RemoteCollectorService.Memory.cs @@ -146,26 +146,31 @@ FROM sys.dm_os_performance_counters /* Insert into DuckDB using Appender */ var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - - using var appender = duckConnection.CreateAppender("memory_stats"); - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(totalPhysicalMb) - .AppendValue(availablePhysicalMb) - .AppendValue(totalPageFileMb) - .AppendValue(availablePageFileMb) - .AppendValue(systemMemoryState) - .AppendValue(sqlMemoryModel) - .AppendValue(targetServerMemoryMb) - .AppendValue(totalServerMemoryMb) - .AppendValue(bufferPoolMb) - .AppendValue(planCacheMb) - .EndRow(); + + using (var duckConnection = _duckDb.CreateConnection()) + { + await duckConnection.OpenAsync(cancellationToken); + + using (var appender = duckConnection.CreateAppender("memory_stats")) + { + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(totalPhysicalMb) + .AppendValue(availablePhysicalMb) + .AppendValue(totalPageFileMb) + .AppendValue(availablePageFileMb) + .AppendValue(systemMemoryState) + .AppendValue(sqlMemoryModel) + .AppendValue(targetServerMemoryMb) + .AppendValue(totalServerMemoryMb) + .AppendValue(bufferPoolMb) + .AppendValue(planCacheMb) + .EndRow(); + } + } duckSw.Stop(); _lastSqlMs = sqlSw.ElapsedMilliseconds; @@ -211,23 +216,27 @@ ORDER BY /* Insert into DuckDB */ var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - - using var appender = duckConnection.CreateAppender("memory_clerks"); - while (await reader.ReadAsync(cancellationToken)) + using (var duckConnection = _duckDb.CreateConnection()) { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(reader.GetString(0)) - .AppendValue(reader.GetDecimal(1)) - .EndRow(); - - rowsCollected++; + await duckConnection.OpenAsync(cancellationToken); + + using (var appender = duckConnection.CreateAppender("memory_clerks")) + { + while (await reader.ReadAsync(cancellationToken)) + { + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(reader.GetString(0)) + .AppendValue(reader.GetDecimal(1)) + .EndRow(); + + rowsCollected++; + } + } } duckSw.Stop(); diff --git a/Lite/Services/RemoteCollectorService.MemoryGrants.cs b/Lite/Services/RemoteCollectorService.MemoryGrants.cs index fd455f18..dfaeab5e 100644 --- a/Lite/Services/RemoteCollectorService.MemoryGrants.cs +++ b/Lite/Services/RemoteCollectorService.MemoryGrants.cs @@ -84,32 +84,37 @@ WHERE mg.session_id <> @@SPID sqlSw.Stop(); var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - using var appender = duckConnection.CreateAppender("memory_grant_stats"); - foreach (var r in rows) + using (var duckConnection = _duckDb.CreateConnection()) { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(r.SessionId) - .AppendValue(r.DatabaseName) - .AppendValue(r.QueryText) - .AppendValue(r.RequestedMb) - .AppendValue(r.GrantedMb) - .AppendValue(r.UsedMb) - .AppendValue(r.MaxUsedMb) - .AppendValue(r.IdealMb) - .AppendValue(r.RequiredMb) - .AppendValue(r.WaitTimeMs) - .AppendValue(r.IsSmall) - .AppendValue(r.Dop) - .AppendValue(r.QueryCost) - .EndRow(); - rowsCollected++; + await duckConnection.OpenAsync(cancellationToken); + + using (var appender = duckConnection.CreateAppender("memory_grant_stats")) + { + foreach (var r in rows) + { + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(r.SessionId) + .AppendValue(r.DatabaseName) + .AppendValue(r.QueryText) + .AppendValue(r.RequestedMb) + .AppendValue(r.GrantedMb) + .AppendValue(r.UsedMb) + .AppendValue(r.MaxUsedMb) + .AppendValue(r.IdealMb) + .AppendValue(r.RequiredMb) + .AppendValue(r.WaitTimeMs) + .AppendValue(r.IsSmall) + .AppendValue(r.Dop) + .AppendValue(r.QueryCost) + .EndRow(); + rowsCollected++; + } + } } duckSw.Stop(); diff --git a/Lite/Services/RemoteCollectorService.Perfmon.cs b/Lite/Services/RemoteCollectorService.Perfmon.cs index 9d48c48a..867f878a 100644 --- a/Lite/Services/RemoteCollectorService.Perfmon.cs +++ b/Lite/Services/RemoteCollectorService.Perfmon.cs @@ -149,36 +149,40 @@ WHERE pc.counter_name IN ( _lastSqlMs = sqlSw.ElapsedMilliseconds; var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - using var appender = duckConnection.CreateAppender("perfmon_stats"); - - while (await reader.ReadAsync(cancellationToken)) + using (var duckConnection = _duckDb.CreateConnection()) { - var objectName = reader.IsDBNull(0) ? "" : reader.GetString(0); - var counterName = reader.IsDBNull(1) ? "" : reader.GetString(1); - var instanceName = reader.IsDBNull(2) ? "" : reader.GetString(2); - var cntrValue = reader.GetInt64(3); - - /* Delta for per-second counters */ - var deltaKey = $"{objectName}|{counterName}|{instanceName}"; - var deltaCntrValue = _deltaCalculator.CalculateDelta(serverId, "perfmon", deltaKey, cntrValue); - - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(objectName) - .AppendValue(counterName) - .AppendValue(instanceName) - .AppendValue(cntrValue) - .AppendValue(deltaCntrValue) - .AppendValue(600) /* 10-minute interval */ - .EndRow(); - - rowsCollected++; + await duckConnection.OpenAsync(cancellationToken); + + using (var appender = duckConnection.CreateAppender("perfmon_stats")) + { + while (await reader.ReadAsync(cancellationToken)) + { + var objectName = reader.IsDBNull(0) ? "" : reader.GetString(0); + var counterName = reader.IsDBNull(1) ? "" : reader.GetString(1); + var instanceName = reader.IsDBNull(2) ? "" : reader.GetString(2); + var cntrValue = reader.GetInt64(3); + + /* Delta for per-second counters */ + var deltaKey = $"{objectName}|{counterName}|{instanceName}"; + var deltaCntrValue = _deltaCalculator.CalculateDelta(serverId, "perfmon", deltaKey, cntrValue); + + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(objectName) + .AppendValue(counterName) + .AppendValue(instanceName) + .AppendValue(cntrValue) + .AppendValue(deltaCntrValue) + .AppendValue(600) /* 10-minute interval */ + .EndRow(); + + rowsCollected++; + } + } } duckSw.Stop(); diff --git a/Lite/Services/RemoteCollectorService.ProcedureStats.cs b/Lite/Services/RemoteCollectorService.ProcedureStats.cs index a0c1eb52..acc6d653 100644 --- a/Lite/Services/RemoteCollectorService.ProcedureStats.cs +++ b/Lite/Services/RemoteCollectorService.ProcedureStats.cs @@ -199,71 +199,75 @@ ORDER BY s.total_elapsed_time DESC sqlSw.Stop(); var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - using var appender = duckConnection.CreateAppender("procedure_stats"); - - while (await reader.ReadAsync(cancellationToken)) + using (var duckConnection = _duckDb.CreateConnection()) { - var dbName = reader.IsDBNull(0) ? "" : reader.GetString(0); - var schemaName = reader.IsDBNull(1) ? "" : reader.GetString(1); - var objectName = reader.IsDBNull(2) ? "" : reader.GetString(2); - var objectType = reader.IsDBNull(3) ? "" : reader.GetString(3); - var execCount = reader.GetInt64(4); - var workerTime = reader.GetInt64(5); - var elapsedTime = reader.GetInt64(6); - var logicalReads = reader.GetInt64(7); - var physicalReads = reader.GetInt64(8); - var logicalWrites = reader.GetInt64(9); - var minWorkerTime = reader.GetInt64(10); - var maxWorkerTime = reader.GetInt64(11); - var minElapsedTime = reader.GetInt64(12); - var maxElapsedTime = reader.GetInt64(13); - var totalSpills = reader.GetInt64(14); - var sqlHandle = reader.IsDBNull(15) ? (string?)null : reader.GetString(15); - var planHandle = reader.IsDBNull(16) ? (string?)null : reader.GetString(16); + await duckConnection.OpenAsync(cancellationToken); + + using (var appender = duckConnection.CreateAppender("procedure_stats")) + { + while (await reader.ReadAsync(cancellationToken)) + { + var dbName = reader.IsDBNull(0) ? "" : reader.GetString(0); + var schemaName = reader.IsDBNull(1) ? "" : reader.GetString(1); + var objectName = reader.IsDBNull(2) ? "" : reader.GetString(2); + var objectType = reader.IsDBNull(3) ? "" : reader.GetString(3); + var execCount = reader.GetInt64(4); + var workerTime = reader.GetInt64(5); + var elapsedTime = reader.GetInt64(6); + var logicalReads = reader.GetInt64(7); + var physicalReads = reader.GetInt64(8); + var logicalWrites = reader.GetInt64(9); + var minWorkerTime = reader.GetInt64(10); + var maxWorkerTime = reader.GetInt64(11); + var minElapsedTime = reader.GetInt64(12); + var maxElapsedTime = reader.GetInt64(13); + var totalSpills = reader.GetInt64(14); + var sqlHandle = reader.IsDBNull(15) ? (string?)null : reader.GetString(15); + var planHandle = reader.IsDBNull(16) ? (string?)null : reader.GetString(16); - /* Delta key: database.schema.object */ - var deltaKey = $"{dbName}.{schemaName}.{objectName}"; - var deltaExec = _deltaCalculator.CalculateDelta(serverId, "proc_stats_exec", deltaKey, execCount); - var deltaWorker = _deltaCalculator.CalculateDelta(serverId, "proc_stats_worker", deltaKey, workerTime); - var deltaElapsed = _deltaCalculator.CalculateDelta(serverId, "proc_stats_elapsed", deltaKey, elapsedTime); - var deltaReads = _deltaCalculator.CalculateDelta(serverId, "proc_stats_reads", deltaKey, logicalReads); - var deltaWrites = _deltaCalculator.CalculateDelta(serverId, "proc_stats_writes", deltaKey, logicalWrites); - var deltaPhysReads = _deltaCalculator.CalculateDelta(serverId, "proc_stats_phys_reads", deltaKey, physicalReads); + /* Delta key: database.schema.object */ + var deltaKey = $"{dbName}.{schemaName}.{objectName}"; + var deltaExec = _deltaCalculator.CalculateDelta(serverId, "proc_stats_exec", deltaKey, execCount); + var deltaWorker = _deltaCalculator.CalculateDelta(serverId, "proc_stats_worker", deltaKey, workerTime); + var deltaElapsed = _deltaCalculator.CalculateDelta(serverId, "proc_stats_elapsed", deltaKey, elapsedTime); + var deltaReads = _deltaCalculator.CalculateDelta(serverId, "proc_stats_reads", deltaKey, logicalReads); + var deltaWrites = _deltaCalculator.CalculateDelta(serverId, "proc_stats_writes", deltaKey, logicalWrites); + var deltaPhysReads = _deltaCalculator.CalculateDelta(serverId, "proc_stats_phys_reads", deltaKey, physicalReads); - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(dbName) - .AppendValue(schemaName) - .AppendValue(objectName) - .AppendValue(objectType) - .AppendValue(execCount) - .AppendValue(workerTime) - .AppendValue(elapsedTime) - .AppendValue(logicalReads) - .AppendValue(physicalReads) - .AppendValue(logicalWrites) - .AppendValue(minWorkerTime) - .AppendValue(maxWorkerTime) - .AppendValue(minElapsedTime) - .AppendValue(maxElapsedTime) - .AppendValue(totalSpills) - .AppendValue(sqlHandle) - .AppendValue(planHandle) - .AppendValue(deltaExec) - .AppendValue(deltaWorker) - .AppendValue(deltaElapsed) - .AppendValue(deltaReads) - .AppendValue(deltaWrites) - .AppendValue(deltaPhysReads) - .EndRow(); + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(dbName) + .AppendValue(schemaName) + .AppendValue(objectName) + .AppendValue(objectType) + .AppendValue(execCount) + .AppendValue(workerTime) + .AppendValue(elapsedTime) + .AppendValue(logicalReads) + .AppendValue(physicalReads) + .AppendValue(logicalWrites) + .AppendValue(minWorkerTime) + .AppendValue(maxWorkerTime) + .AppendValue(minElapsedTime) + .AppendValue(maxElapsedTime) + .AppendValue(totalSpills) + .AppendValue(sqlHandle) + .AppendValue(planHandle) + .AppendValue(deltaExec) + .AppendValue(deltaWorker) + .AppendValue(deltaElapsed) + .AppendValue(deltaReads) + .AppendValue(deltaWrites) + .AppendValue(deltaPhysReads) + .EndRow(); - rowsCollected++; + rowsCollected++; + } + } } duckSw.Stop(); diff --git a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs index ee791fc9..894a8221 100644 --- a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs +++ b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs @@ -103,41 +103,45 @@ AND dest.text IS NOT NULL _lastSqlMs = sqlSw.ElapsedMilliseconds; var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - using var appender = duckConnection.CreateAppender("query_snapshots"); - - while (await reader.ReadAsync(cancellationToken)) + using (var duckConnection = _duckDb.CreateConnection()) { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(Convert.ToInt32(reader.GetValue(0))) /* session_id */ - .AppendValue(reader.IsDBNull(1) ? (string?)null : reader.GetString(1)) /* database_name */ - .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) /* elapsed_time_formatted */ - .AppendValue(reader.IsDBNull(3) ? (string?)null : reader.GetString(3)) /* query_text */ - .AppendValue(reader.IsDBNull(4) ? (string?)null : reader.GetString(4)) /* query_plan */ - .AppendValue(reader.IsDBNull(5) ? (string?)null : reader.GetValue(5)?.ToString()) /* live_query_plan (xml) */ - .AppendValue(reader.IsDBNull(6) ? (string?)null : reader.GetString(6)) /* status */ - .AppendValue(reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7))) /* blocking_session_id */ - .AppendValue(reader.IsDBNull(8) ? (string?)null : reader.GetString(8)) /* wait_type */ - .AppendValue(reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9))) /* wait_time_ms */ - .AppendValue(reader.IsDBNull(10) ? (string?)null : reader.GetString(10)) /* wait_resource */ - .AppendValue(reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11))) /* cpu_time_ms */ - .AppendValue(reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12))) /* total_elapsed_time_ms */ - .AppendValue(reader.IsDBNull(13) ? 0L : Convert.ToInt64(reader.GetValue(13))) /* reads */ - .AppendValue(reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14))) /* writes */ - .AppendValue(reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15))) /* logical_reads */ - .AppendValue(reader.IsDBNull(16) ? 0m : reader.GetDecimal(16)) /* granted_query_memory_gb */ - .AppendValue(reader.IsDBNull(17) ? (string?)null : reader.GetString(17)) /* transaction_isolation_level */ - .AppendValue(reader.IsDBNull(18) ? 0 : Convert.ToInt32(reader.GetValue(18))) /* dop */ - .AppendValue(reader.IsDBNull(19) ? 0 : Convert.ToInt32(reader.GetValue(19))) /* parallel_worker_count */ - .EndRow(); + await duckConnection.OpenAsync(cancellationToken); + + using (var appender = duckConnection.CreateAppender("query_snapshots")) + { + while (await reader.ReadAsync(cancellationToken)) + { + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(Convert.ToInt32(reader.GetValue(0))) /* session_id */ + .AppendValue(reader.IsDBNull(1) ? (string?)null : reader.GetString(1)) /* database_name */ + .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) /* elapsed_time_formatted */ + .AppendValue(reader.IsDBNull(3) ? (string?)null : reader.GetString(3)) /* query_text */ + .AppendValue(reader.IsDBNull(4) ? (string?)null : reader.GetString(4)) /* query_plan */ + .AppendValue(reader.IsDBNull(5) ? (string?)null : reader.GetValue(5)?.ToString()) /* live_query_plan (xml) */ + .AppendValue(reader.IsDBNull(6) ? (string?)null : reader.GetString(6)) /* status */ + .AppendValue(reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7))) /* blocking_session_id */ + .AppendValue(reader.IsDBNull(8) ? (string?)null : reader.GetString(8)) /* wait_type */ + .AppendValue(reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9))) /* wait_time_ms */ + .AppendValue(reader.IsDBNull(10) ? (string?)null : reader.GetString(10)) /* wait_resource */ + .AppendValue(reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11))) /* cpu_time_ms */ + .AppendValue(reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12))) /* total_elapsed_time_ms */ + .AppendValue(reader.IsDBNull(13) ? 0L : Convert.ToInt64(reader.GetValue(13))) /* reads */ + .AppendValue(reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14))) /* writes */ + .AppendValue(reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15))) /* logical_reads */ + .AppendValue(reader.IsDBNull(16) ? 0m : reader.GetDecimal(16)) /* granted_query_memory_gb */ + .AppendValue(reader.IsDBNull(17) ? (string?)null : reader.GetString(17)) /* transaction_isolation_level */ + .AppendValue(reader.IsDBNull(18) ? 0 : Convert.ToInt32(reader.GetValue(18))) /* dop */ + .AppendValue(reader.IsDBNull(19) ? 0 : Convert.ToInt32(reader.GetValue(19))) /* parallel_worker_count */ + .EndRow(); - rowsCollected++; + rowsCollected++; + } + } } duckSw.Stop(); diff --git a/Lite/Services/RemoteCollectorService.QueryStats.cs b/Lite/Services/RemoteCollectorService.QueryStats.cs index 497c4e7f..16774801 100644 --- a/Lite/Services/RemoteCollectorService.QueryStats.cs +++ b/Lite/Services/RemoteCollectorService.QueryStats.cs @@ -156,78 +156,82 @@ qs.total_elapsed_time DESC sqlSw.Stop(); var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - using var appender = duckConnection.CreateAppender("query_stats"); - - while (await reader.ReadAsync(cancellationToken)) + using (var duckConnection = _duckDb.CreateConnection()) { - var queryHash = reader.IsDBNull(1) ? "" : reader.GetString(1); - var executionCount = reader.IsDBNull(3) ? 0L : reader.GetInt64(3); - var totalWorkerTime = reader.IsDBNull(4) ? 0L : reader.GetInt64(4); - var totalElapsedTime = reader.IsDBNull(5) ? 0L : reader.GetInt64(5); - var totalLogicalReads = reader.IsDBNull(6) ? 0L : reader.GetInt64(6); - var totalLogicalWrites = reader.IsDBNull(7) ? 0L : reader.GetInt64(7); - var totalPhysicalReads = reader.IsDBNull(8) ? 0L : reader.GetInt64(8); - var totalRows = reader.IsDBNull(9) ? 0L : reader.GetInt64(9); - var totalSpills = reader.IsDBNull(10) ? 0L : reader.GetInt64(10); - var minWorkerTime = reader.IsDBNull(11) ? 0L : reader.GetInt64(11); - var maxWorkerTime = reader.IsDBNull(12) ? 0L : reader.GetInt64(12); - var minElapsedTime = reader.IsDBNull(13) ? 0L : reader.GetInt64(13); - var maxElapsedTime = reader.IsDBNull(14) ? 0L : reader.GetInt64(14); - var minDop = reader.IsDBNull(15) ? 0 : Convert.ToInt32(reader.GetValue(15)); - var maxDop = reader.IsDBNull(16) ? 0 : Convert.ToInt32(reader.GetValue(16)); - var sqlHandle = reader.IsDBNull(17) ? (string?)null : reader.GetString(17); - var planHandle = reader.IsDBNull(18) ? (string?)null : reader.GetString(18); - - /* Delta calculations based on query_hash */ - var deltaExecCount = _deltaCalculator.CalculateDelta(serverId, "query_stats_exec", queryHash, executionCount); - var deltaWorkerTime = _deltaCalculator.CalculateDelta(serverId, "query_stats_worker", queryHash, totalWorkerTime); - var deltaElapsedTime = _deltaCalculator.CalculateDelta(serverId, "query_stats_elapsed", queryHash, totalElapsedTime); - var deltaLogicalReads = _deltaCalculator.CalculateDelta(serverId, "query_stats_reads", queryHash, totalLogicalReads); - var deltaLogicalWrites = _deltaCalculator.CalculateDelta(serverId, "query_stats_writes", queryHash, totalLogicalWrites); - var deltaPhysicalReads = _deltaCalculator.CalculateDelta(serverId, "query_stats_phys_reads", queryHash, totalPhysicalReads); - var deltaRows = _deltaCalculator.CalculateDelta(serverId, "query_stats_rows", queryHash, totalRows); - var deltaSpills = _deltaCalculator.CalculateDelta(serverId, "query_stats_spills", queryHash, totalSpills); - - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(reader.IsDBNull(0) ? (string?)null : reader.GetString(0)) - .AppendValue(queryHash) - .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) - .AppendValue(executionCount) - .AppendValue(totalWorkerTime) - .AppendValue(totalElapsedTime) - .AppendValue(totalLogicalReads) - .AppendValue(totalLogicalWrites) - .AppendValue(totalPhysicalReads) - .AppendValue(totalRows) - .AppendValue(totalSpills) - .AppendValue(minWorkerTime) - .AppendValue(maxWorkerTime) - .AppendValue(minElapsedTime) - .AppendValue(maxElapsedTime) - .AppendValue(minDop) - .AppendValue(maxDop) - .AppendValue(reader.IsDBNull(19) ? (string?)null : reader.GetString(19)) - .AppendValue((string?)null) /* query plans retrieved on-demand */ - .AppendValue(sqlHandle) - .AppendValue(planHandle) - .AppendValue(deltaExecCount) - .AppendValue(deltaWorkerTime) - .AppendValue(deltaElapsedTime) - .AppendValue(deltaLogicalReads) - .AppendValue(deltaLogicalWrites) - .AppendValue(deltaPhysicalReads) - .AppendValue(deltaRows) - .AppendValue(deltaSpills) - .EndRow(); - - rowsCollected++; + await duckConnection.OpenAsync(cancellationToken); + + using (var appender = duckConnection.CreateAppender("query_stats")) + { + while (await reader.ReadAsync(cancellationToken)) + { + var queryHash = reader.IsDBNull(1) ? "" : reader.GetString(1); + var executionCount = reader.IsDBNull(3) ? 0L : reader.GetInt64(3); + var totalWorkerTime = reader.IsDBNull(4) ? 0L : reader.GetInt64(4); + var totalElapsedTime = reader.IsDBNull(5) ? 0L : reader.GetInt64(5); + var totalLogicalReads = reader.IsDBNull(6) ? 0L : reader.GetInt64(6); + var totalLogicalWrites = reader.IsDBNull(7) ? 0L : reader.GetInt64(7); + var totalPhysicalReads = reader.IsDBNull(8) ? 0L : reader.GetInt64(8); + var totalRows = reader.IsDBNull(9) ? 0L : reader.GetInt64(9); + var totalSpills = reader.IsDBNull(10) ? 0L : reader.GetInt64(10); + var minWorkerTime = reader.IsDBNull(11) ? 0L : reader.GetInt64(11); + var maxWorkerTime = reader.IsDBNull(12) ? 0L : reader.GetInt64(12); + var minElapsedTime = reader.IsDBNull(13) ? 0L : reader.GetInt64(13); + var maxElapsedTime = reader.IsDBNull(14) ? 0L : reader.GetInt64(14); + var minDop = reader.IsDBNull(15) ? 0 : Convert.ToInt32(reader.GetValue(15)); + var maxDop = reader.IsDBNull(16) ? 0 : Convert.ToInt32(reader.GetValue(16)); + var sqlHandle = reader.IsDBNull(17) ? (string?)null : reader.GetString(17); + var planHandle = reader.IsDBNull(18) ? (string?)null : reader.GetString(18); + + /* Delta calculations based on query_hash */ + var deltaExecCount = _deltaCalculator.CalculateDelta(serverId, "query_stats_exec", queryHash, executionCount); + var deltaWorkerTime = _deltaCalculator.CalculateDelta(serverId, "query_stats_worker", queryHash, totalWorkerTime); + var deltaElapsedTime = _deltaCalculator.CalculateDelta(serverId, "query_stats_elapsed", queryHash, totalElapsedTime); + var deltaLogicalReads = _deltaCalculator.CalculateDelta(serverId, "query_stats_reads", queryHash, totalLogicalReads); + var deltaLogicalWrites = _deltaCalculator.CalculateDelta(serverId, "query_stats_writes", queryHash, totalLogicalWrites); + var deltaPhysicalReads = _deltaCalculator.CalculateDelta(serverId, "query_stats_phys_reads", queryHash, totalPhysicalReads); + var deltaRows = _deltaCalculator.CalculateDelta(serverId, "query_stats_rows", queryHash, totalRows); + var deltaSpills = _deltaCalculator.CalculateDelta(serverId, "query_stats_spills", queryHash, totalSpills); + + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(reader.IsDBNull(0) ? (string?)null : reader.GetString(0)) + .AppendValue(queryHash) + .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) + .AppendValue(executionCount) + .AppendValue(totalWorkerTime) + .AppendValue(totalElapsedTime) + .AppendValue(totalLogicalReads) + .AppendValue(totalLogicalWrites) + .AppendValue(totalPhysicalReads) + .AppendValue(totalRows) + .AppendValue(totalSpills) + .AppendValue(minWorkerTime) + .AppendValue(maxWorkerTime) + .AppendValue(minElapsedTime) + .AppendValue(maxElapsedTime) + .AppendValue(minDop) + .AppendValue(maxDop) + .AppendValue(reader.IsDBNull(19) ? (string?)null : reader.GetString(19)) + .AppendValue((string?)null) /* query plans retrieved on-demand */ + .AppendValue(sqlHandle) + .AppendValue(planHandle) + .AppendValue(deltaExecCount) + .AppendValue(deltaWorkerTime) + .AppendValue(deltaElapsedTime) + .AppendValue(deltaLogicalReads) + .AppendValue(deltaLogicalWrites) + .AppendValue(deltaPhysicalReads) + .AppendValue(deltaRows) + .AppendValue(deltaSpills) + .EndRow(); + + rowsCollected++; + } + } } duckSw.Stop(); diff --git a/Lite/Services/RemoteCollectorService.QueryStore.cs b/Lite/Services/RemoteCollectorService.QueryStore.cs index 127a3878..6f69db5e 100644 --- a/Lite/Services/RemoteCollectorService.QueryStore.cs +++ b/Lite/Services/RemoteCollectorService.QueryStore.cs @@ -74,15 +74,17 @@ ORDER BY d.name } var duckSw = new Stopwatch(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - /* For each database, collect new query store intervals since last collection */ - foreach (var dbName in databases) + using (var duckConnection = _duckDb.CreateConnection()) { - try + await duckConnection.OpenAsync(cancellationToken); + + /* For each database, collect new query store intervals since last collection */ + foreach (var dbName in databases) { - var qsQuery = $@" + try + { + var qsQuery = $@" EXECUTE [{dbName.Replace("]", "]]")}].sys.sp_executesql N'SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; @@ -112,50 +114,54 @@ WHERE qsrs.last_execution_time > @cutoff_time N'@cutoff_time datetime2(7)', @cutoff_time;"; - sqlSw.Start(); - using var qsCommand = new SqlCommand(qsQuery, sqlConnection); - qsCommand.CommandTimeout = CommandTimeoutSeconds; - qsCommand.Parameters.Add(new SqlParameter("@cutoff_time", System.Data.SqlDbType.DateTime2) { Value = cutoffTime }); - - using var reader = await qsCommand.ExecuteReaderAsync(cancellationToken); - sqlSw.Stop(); - - duckSw.Start(); - using var appender = duckConnection.CreateAppender("query_store_stats"); - - while (await reader.ReadAsync(cancellationToken)) + sqlSw.Start(); + using var qsCommand = new SqlCommand(qsQuery, sqlConnection); + qsCommand.CommandTimeout = CommandTimeoutSeconds; + qsCommand.Parameters.Add(new SqlParameter("@cutoff_time", System.Data.SqlDbType.DateTime2) { Value = cutoffTime }); + + using var reader = await qsCommand.ExecuteReaderAsync(cancellationToken); + sqlSw.Stop(); + + duckSw.Start(); + + using (var appender = duckConnection.CreateAppender("query_store_stats")) + { + while (await reader.ReadAsync(cancellationToken)) + { + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(dbName) + .AppendValue(reader.GetInt64(0)) /* query_id */ + .AppendValue(reader.GetInt64(1)) /* plan_id */ + .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) /* query_text */ + .AppendValue(reader.IsDBNull(3) ? (string?)null : reader.GetString(3)) /* query_hash */ + .AppendValue(reader.GetInt64(4)) /* execution_count */ + .AppendValue(reader.IsDBNull(5) ? 0m : reader.GetDecimal(5)) /* avg_duration_ms */ + .AppendValue(reader.IsDBNull(6) ? 0m : reader.GetDecimal(6)) /* avg_cpu_time_ms */ + .AppendValue(reader.IsDBNull(7) ? 0m : reader.GetDecimal(7)) /* avg_logical_reads */ + .AppendValue(reader.IsDBNull(8) ? 0m : reader.GetDecimal(8)) /* avg_logical_writes */ + .AppendValue(reader.IsDBNull(9) ? 0m : reader.GetDecimal(9)) /* avg_physical_reads */ + .AppendValue(reader.IsDBNull(10) ? 0m : reader.GetDecimal(10)) /* avg_rowcount */ + .AppendValue(reader.IsDBNull(11) ? (DateTime?)null : ((DateTimeOffset)reader.GetValue(11)).UtcDateTime) + .AppendValue(reader.IsDBNull(12) ? (string?)null : reader.GetString(12)) + .EndRow(); + + totalRows++; + } + } + + duckSw.Stop(); + } + catch (SqlException ex) { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(dbName) - .AppendValue(reader.GetInt64(0)) /* query_id */ - .AppendValue(reader.GetInt64(1)) /* plan_id */ - .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) /* query_text */ - .AppendValue(reader.IsDBNull(3) ? (string?)null : reader.GetString(3)) /* query_hash */ - .AppendValue(reader.GetInt64(4)) /* execution_count */ - .AppendValue(reader.IsDBNull(5) ? 0m : reader.GetDecimal(5)) /* avg_duration_ms */ - .AppendValue(reader.IsDBNull(6) ? 0m : reader.GetDecimal(6)) /* avg_cpu_time_ms */ - .AppendValue(reader.IsDBNull(7) ? 0m : reader.GetDecimal(7)) /* avg_logical_reads */ - .AppendValue(reader.IsDBNull(8) ? 0m : reader.GetDecimal(8)) /* avg_logical_writes */ - .AppendValue(reader.IsDBNull(9) ? 0m : reader.GetDecimal(9)) /* avg_physical_reads */ - .AppendValue(reader.IsDBNull(10) ? 0m : reader.GetDecimal(10)) /* avg_rowcount */ - .AppendValue(reader.IsDBNull(11) ? (DateTime?)null : ((DateTimeOffset)reader.GetValue(11)).UtcDateTime) - .AppendValue(reader.IsDBNull(12) ? (string?)null : reader.GetString(12)) - .EndRow(); - - totalRows++; + sqlSw.Stop(); + duckSw.Stop(); + _logger?.LogWarning("Failed to collect Query Store data from [{Database}] on '{Server}': {Message}", + dbName, server.DisplayName, ex.Message); } - duckSw.Stop(); - } - catch (SqlException ex) - { - sqlSw.Stop(); - duckSw.Stop(); - _logger?.LogWarning("Failed to collect Query Store data from [{Database}] on '{Server}': {Message}", - dbName, server.DisplayName, ex.Message); } } diff --git a/Lite/Services/RemoteCollectorService.RunningJobs.cs b/Lite/Services/RemoteCollectorService.RunningJobs.cs index 63c29de2..00d26b87 100644 --- a/Lite/Services/RemoteCollectorService.RunningJobs.cs +++ b/Lite/Services/RemoteCollectorService.RunningJobs.cs @@ -153,28 +153,33 @@ rj.current_duration_seconds DESC sqlSw.Stop(); var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - using var appender = duckConnection.CreateAppender("running_jobs"); - foreach (var r in rows) + using (var duckConnection = _duckDb.CreateConnection()) { - var row = appender.CreateRow(); - row.AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(r.JobName) - .AppendValue(r.JobId) - .AppendValue(r.JobEnabled) - .AppendValue(r.StartTime) - .AppendValue(r.CurrentDuration) - .AppendValue(r.AvgDuration) - .AppendValue(r.P95Duration) - .AppendValue(r.SuccessfulRunCount) - .AppendValue(r.IsRunningLong) - .AppendValue(r.PercentOfAverage) - .EndRow(); - rowsCollected++; + await duckConnection.OpenAsync(cancellationToken); + + using (var appender = duckConnection.CreateAppender("running_jobs")) + { + foreach (var r in rows) + { + var row = appender.CreateRow(); + row.AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(r.JobName) + .AppendValue(r.JobId) + .AppendValue(r.JobEnabled) + .AppendValue(r.StartTime) + .AppendValue(r.CurrentDuration) + .AppendValue(r.AvgDuration) + .AppendValue(r.P95Duration) + .AppendValue(r.SuccessfulRunCount) + .AppendValue(r.IsRunningLong) + .AppendValue(r.PercentOfAverage) + .EndRow(); + rowsCollected++; + } + } } duckSw.Stop(); diff --git a/Lite/Services/RemoteCollectorService.TempDb.cs b/Lite/Services/RemoteCollectorService.TempDb.cs index 4cafdcef..ef0a300d 100644 --- a/Lite/Services/RemoteCollectorService.TempDb.cs +++ b/Lite/Services/RemoteCollectorService.TempDb.cs @@ -77,24 +77,29 @@ FROM sys.dm_db_session_space_usage AS ssu /* Insert into DuckDB using Appender */ var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - - using var appender = duckConnection.CreateAppender("tempdb_stats"); - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue(userObjMb) - .AppendValue(internalObjMb) - .AppendValue(versionStoreMb) - .AppendValue(totalReservedMb) - .AppendValue(unallocatedMb) - .AppendValue(totalSessions) - .AppendValue(topSessionId) - .AppendValue(topSessionMb) - .EndRow(); + + using (var duckConnection = _duckDb.CreateConnection()) + { + await duckConnection.OpenAsync(cancellationToken); + + using (var appender = duckConnection.CreateAppender("tempdb_stats")) + { + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue(userObjMb) + .AppendValue(internalObjMb) + .AppendValue(versionStoreMb) + .AppendValue(totalReservedMb) + .AppendValue(unallocatedMb) + .AppendValue(totalSessions) + .AppendValue(topSessionId) + .AppendValue(topSessionMb) + .EndRow(); + } + } duckSw.Stop(); _lastSqlMs = sqlSw.ElapsedMilliseconds; diff --git a/Lite/Services/RemoteCollectorService.WaitStats.cs b/Lite/Services/RemoteCollectorService.WaitStats.cs index 26ad9cb1..52767e7f 100644 --- a/Lite/Services/RemoteCollectorService.WaitStats.cs +++ b/Lite/Services/RemoteCollectorService.WaitStats.cs @@ -121,33 +121,37 @@ WHERE ws.wait_time_ms > 0 /* Insert into DuckDB with delta calculations using Appender for bulk performance */ var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - using var appender = duckConnection.CreateAppender("wait_stats"); - - foreach (var stat in waitStats) + using (var duckConnection = _duckDb.CreateConnection()) { - var deltaKey = stat.WaitType; - var deltaWaitingTasks = _deltaCalculator.CalculateDelta(serverId, "wait_stats_tasks", deltaKey, stat.WaitingTasks); - var deltaWaitTimeMs = _deltaCalculator.CalculateDelta(serverId, "wait_stats_time", deltaKey, stat.WaitTimeMs); - var deltaSignalWaitTimeMs = _deltaCalculator.CalculateDelta(serverId, "wait_stats_signal", deltaKey, stat.SignalWaitTimeMs); - - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) /* collection_id BIGINT */ - .AppendValue(collectionTime) /* collection_time TIMESTAMP */ - .AppendValue(serverId) /* server_id INTEGER */ - .AppendValue(server.ServerName) /* server_name VARCHAR */ - .AppendValue(stat.WaitType) /* wait_type VARCHAR */ - .AppendValue(stat.WaitingTasks) /* waiting_tasks_count BIGINT */ - .AppendValue(stat.WaitTimeMs) /* wait_time_ms BIGINT */ - .AppendValue(stat.SignalWaitTimeMs) /* signal_wait_time_ms BIGINT */ - .AppendValue(deltaWaitingTasks) /* delta_waiting_tasks BIGINT */ - .AppendValue(deltaWaitTimeMs) /* delta_wait_time_ms BIGINT */ - .AppendValue(deltaSignalWaitTimeMs) /* delta_signal_wait_time_ms BIGINT */ - .EndRow(); - - rowsCollected++; + await duckConnection.OpenAsync(cancellationToken); + + using (var appender = duckConnection.CreateAppender("wait_stats")) + { + foreach (var stat in waitStats) + { + var deltaKey = stat.WaitType; + var deltaWaitingTasks = _deltaCalculator.CalculateDelta(serverId, "wait_stats_tasks", deltaKey, stat.WaitingTasks); + var deltaWaitTimeMs = _deltaCalculator.CalculateDelta(serverId, "wait_stats_time", deltaKey, stat.WaitTimeMs); + var deltaSignalWaitTimeMs = _deltaCalculator.CalculateDelta(serverId, "wait_stats_signal", deltaKey, stat.SignalWaitTimeMs); + + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) /* collection_id BIGINT */ + .AppendValue(collectionTime) /* collection_time TIMESTAMP */ + .AppendValue(serverId) /* server_id INTEGER */ + .AppendValue(server.ServerName) /* server_name VARCHAR */ + .AppendValue(stat.WaitType) /* wait_type VARCHAR */ + .AppendValue(stat.WaitingTasks) /* waiting_tasks_count BIGINT */ + .AppendValue(stat.WaitTimeMs) /* wait_time_ms BIGINT */ + .AppendValue(stat.SignalWaitTimeMs) /* signal_wait_time_ms BIGINT */ + .AppendValue(deltaWaitingTasks) /* delta_waiting_tasks BIGINT */ + .AppendValue(deltaWaitTimeMs) /* delta_wait_time_ms BIGINT */ + .AppendValue(deltaSignalWaitTimeMs) /* delta_signal_wait_time_ms BIGINT */ + .EndRow(); + + rowsCollected++; + } + } } duckSw.Stop(); diff --git a/Lite/Services/RemoteCollectorService.WaitingTasks.cs b/Lite/Services/RemoteCollectorService.WaitingTasks.cs index 99a21022..888811e0 100644 --- a/Lite/Services/RemoteCollectorService.WaitingTasks.cs +++ b/Lite/Services/RemoteCollectorService.WaitingTasks.cs @@ -59,35 +59,39 @@ AND wt.wait_type IS NOT NULL _lastSqlMs = sqlSw.ElapsedMilliseconds; var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - using var appender = duckConnection.CreateAppender("waiting_tasks"); - - while (await reader.ReadAsync(cancellationToken)) + using (var duckConnection = _duckDb.CreateConnection()) { - /* session_id and blocking_session_id are smallint in sys.dm_os_waiting_tasks */ - var sessionId = reader.IsDBNull(0) ? 0 : reader.GetInt16(0); - var waitType = reader.IsDBNull(1) ? null : reader.GetString(1); - var waitDurationMs = reader.IsDBNull(2) ? 0L : reader.GetInt64(2); - var blockingSessionId = reader.IsDBNull(3) ? (short?)null : reader.GetInt16(3); - var resourceDescription = reader.IsDBNull(4) ? null : reader.GetString(4); - var databaseName = reader.IsDBNull(5) ? null : reader.GetString(5); + await duckConnection.OpenAsync(cancellationToken); + + using (var appender = duckConnection.CreateAppender("waiting_tasks")) + { + while (await reader.ReadAsync(cancellationToken)) + { + /* session_id and blocking_session_id are smallint in sys.dm_os_waiting_tasks */ + var sessionId = reader.IsDBNull(0) ? 0 : reader.GetInt16(0); + var waitType = reader.IsDBNull(1) ? null : reader.GetString(1); + var waitDurationMs = reader.IsDBNull(2) ? 0L : reader.GetInt64(2); + var blockingSessionId = reader.IsDBNull(3) ? (short?)null : reader.GetInt16(3); + var resourceDescription = reader.IsDBNull(4) ? null : reader.GetString(4); + var databaseName = reader.IsDBNull(5) ? null : reader.GetString(5); - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(server.ServerName) - .AppendValue((int)sessionId) - .AppendValue(waitType) - .AppendValue(waitDurationMs) - .AppendValue(blockingSessionId.HasValue ? (int?)blockingSessionId.Value : null) - .AppendValue(resourceDescription) - .AppendValue(databaseName) - .EndRow(); + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(server.ServerName) + .AppendValue((int)sessionId) + .AppendValue(waitType) + .AppendValue(waitDurationMs) + .AppendValue(blockingSessionId.HasValue ? (int?)blockingSessionId.Value : null) + .AppendValue(resourceDescription) + .AppendValue(databaseName) + .EndRow(); - rowsCollected++; + rowsCollected++; + } + } } duckSw.Stop(); diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs index bda30b09..9b2e1b3e 100644 --- a/Lite/Services/RemoteCollectorService.cs +++ b/Lite/Services/RemoteCollectorService.cs @@ -120,6 +120,11 @@ public RemoteCollectorService( /// public Task SeedDeltaCacheAsync() => _deltaCalculator.SeedFromDatabaseAsync(_duckDb); + /// + /// Runs a manual DuckDB WAL checkpoint during idle time between collection cycles. + /// + public Task CheckpointAsync() => _duckDb.CheckpointAsync(); + /// /// Gets a summary of collector health. When serverId is provided, filters to that server only. /// @@ -201,8 +206,8 @@ public async Task RunDueCollectorsAsync(CancellationToken cancellationToken = de return; } - var tasks = new List(); int skippedOffline = 0; + var onlineServers = new List(); foreach (var server in enabledServers) { @@ -213,17 +218,25 @@ public async Task RunDueCollectorsAsync(CancellationToken cancellationToken = de _logger?.LogDebug("Skipping offline server '{Server}'", server.DisplayName); continue; } + onlineServers.Add(server); + } + + _logger?.LogInformation("Running {CollectorCount} collectors for {OnlineCount}/{TotalCount} servers ({SkippedCount} offline, skipped)", + dueCollectors.Count, onlineServers.Count, enabledServers.Count, skippedOffline); + /* Run servers in parallel, but collectors within each server sequentially. + DuckDB is single-writer; running all collectors in parallel causes spin-wait + contention (50%+ CPU, multi-second stalls). Sequential per-server eliminates + this while still allowing multi-server parallelism. */ + var serverTasks = onlineServers.Select(server => Task.Run(async () => + { foreach (var collector in dueCollectors) { - tasks.Add(RunCollectorAsync(server, collector.Name, cancellationToken)); + await RunCollectorAsync(server, collector.Name, cancellationToken); } - } - - _logger?.LogInformation("Running {CollectorCount} collectors for {OnlineCount}/{TotalCount} servers ({SkippedCount} offline, skipped)", - dueCollectors.Count, enabledServers.Count - skippedOffline, enabledServers.Count, skippedOffline); + }, cancellationToken)); - await Task.WhenAll(tasks); + await Task.WhenAll(serverTasks); } /// From eb3c06d04bff886d1a1ae51e740c398f992ea57f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:03:05 -0500 Subject: [PATCH 03/32] Parquet archive visibility and scheduled database compaction (#160, #161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display queries now read from views that UNION hot DuckDB tables with archived parquet files, so users can see the full 90-day retention window instead of only the 7-day hot data window. Views are created at startup and refreshed after each archive cycle. Adds daily database compaction to prevent file bloat from DuckDB's append-only storage. Compaction exports all tables to a fresh database via ATTACH/CREATE TABLE AS, swaps files, and recreates indexes/views. Includes a 1GB size watchdog that logs warnings between compaction cycles. Tested: 24/24 — archive round-trip (export to parquet, delete hot rows, verify view still returns all rows) and compaction (19MB → 5MB, all 1055 rows preserved across 25 tables). Co-Authored-By: Claude Opus 4.6 --- Lite/Database/DuckDbInitializer.cs | 189 ++++++++++++++++++ Lite/MainWindow.xaml.cs | 2 +- Lite/Services/ArchiveService.cs | 3 + Lite/Services/CollectionBackgroundService.cs | 46 ++++- Lite/Services/LocalDataService.Blocking.cs | 10 +- .../LocalDataService.CollectionHealth.cs | 4 +- Lite/Services/LocalDataService.Cpu.cs | 2 +- Lite/Services/LocalDataService.FileIo.cs | 8 +- Lite/Services/LocalDataService.Memory.cs | 8 +- Lite/Services/LocalDataService.Overview.cs | 10 +- Lite/Services/LocalDataService.Perfmon.cs | 8 +- Lite/Services/LocalDataService.QueryStats.cs | 14 +- Lite/Services/LocalDataService.QueryStore.cs | 8 +- Lite/Services/LocalDataService.TempDb.cs | 4 +- Lite/Services/LocalDataService.WaitStats.cs | 12 +- 15 files changed, 282 insertions(+), 46 deletions(-) diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs index 77a441fd..dfaa4f75 100644 --- a/Lite/Database/DuckDbInitializer.cs +++ b/Lite/Database/DuckDbInitializer.cs @@ -21,12 +21,24 @@ public class DuckDbInitializer /// internal const int CurrentSchemaVersion = 10; + private readonly string _archivePath; + public DuckDbInitializer(string databasePath, ILogger? logger = null) { _databasePath = databasePath; _logger = logger; + _archivePath = Path.Combine(Path.GetDirectoryName(databasePath) ?? ".", "archive"); } + /* Tables that have parquet archives — views are created to UNION hot data with archived parquet files */ + private static readonly string[] ArchivableTables = + [ + "wait_stats", "query_stats", "procedure_stats", "query_store_stats", + "query_snapshots", "cpu_utilization_stats", "file_io_stats", "memory_stats", + "memory_clerks", "tempdb_stats", "perfmon_stats", "deadlocks", + "blocked_process_reports", "collection_log" + ]; + /// /// Gets the connection string for the DuckDB database. /// Disables automatic WAL checkpoints to prevent 2-3s stop-the-world stalls @@ -99,6 +111,8 @@ await ExecuteNonQueryAsync(connection, _logger?.LogInformation("Database initialization complete. Schema version: {Version}", CurrentSchemaVersion); } + + await CreateArchiveViewsAsync(); } /// @@ -410,6 +424,58 @@ public DuckDBConnection CreateConnection() return new DuckDBConnection(ConnectionString); } + /// + /// Creates or refreshes views that UNION hot DuckDB tables with archived parquet files. + /// Call at startup and after each archive cycle so newly archived data is queryable. + /// + public async Task CreateArchiveViewsAsync() + { + using var connection = CreateConnection(); + await connection.OpenAsync(); + + foreach (var table in ArchivableTables) + { + try + { + var parquetGlob = Path.Combine(_archivePath, $"*_{table}.parquet"); + var hasParquetFiles = Directory.Exists(_archivePath) + && Directory.GetFiles(_archivePath, $"*_{table}.parquet").Length > 0; + + string viewSql; + if (hasParquetFiles) + { + var globPath = parquetGlob.Replace("\\", "/"); + viewSql = $"CREATE OR REPLACE VIEW v_{table} AS SELECT * FROM {table} UNION ALL SELECT * FROM read_parquet('{globPath}', union_by_name=true)"; + } + else + { + viewSql = $"CREATE OR REPLACE VIEW v_{table} AS SELECT * FROM {table}"; + } + + using var cmd = connection.CreateCommand(); + cmd.CommandText = viewSql; + await cmd.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + /* Schema mismatch between hot table and old parquet — fall back to table-only view */ + _logger?.LogWarning(ex, "Failed to create archive view for {Table}, using table-only view", table); + try + { + using var fallbackCmd = connection.CreateCommand(); + fallbackCmd.CommandText = $"CREATE OR REPLACE VIEW v_{table} AS SELECT * FROM {table}"; + await fallbackCmd.ExecuteNonQueryAsync(); + } + catch (Exception fallbackEx) + { + _logger?.LogError(fallbackEx, "Failed to create fallback view for {Table}", table); + } + } + } + + _logger?.LogDebug("Archive views created/refreshed for {Count} tables", ArchivableTables.Length); + } + /// /// Runs a manual WAL checkpoint. Call this between collection cycles /// to flush the WAL during idle time instead of during collector writes. @@ -470,4 +536,127 @@ public double GetDatabaseSizeMb() return fileInfo.Length / (1024.0 * 1024.0); } + /// + /// Compacts the database by exporting all tables to a fresh file and swapping. + /// DuckDB VACUUM does not reclaim space from append-fragmented files — only + /// export/reimport eliminates bloat. Typically takes 2-5 seconds for a 300MB database. + /// + /// True if compaction was performed, false if skipped or failed. + public async Task CompactAsync() + { + if (!DatabaseExists()) + { + return false; + } + + var sizeBefore = GetDatabaseSizeMb(); + var tempPath = _databasePath + ".compact"; + var backupPath = _databasePath + ".precompact"; + + _logger?.LogInformation("Starting database compaction ({SizeMb:F0} MB)", sizeBefore); + + try + { + /* Export all data to a fresh database via ATTACH + CREATE TABLE AS */ + if (File.Exists(tempPath)) File.Delete(tempPath); + + using (var connection = CreateConnection()) + { + await connection.OpenAsync(); + + /* Checkpoint first to flush WAL */ + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = "CHECKPOINT"; + await cmd.ExecuteNonQueryAsync(); + } + + /* Attach the new database and copy all tables */ + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = $"ATTACH '{tempPath.Replace("\\", "/")}' AS compact_db"; + await cmd.ExecuteNonQueryAsync(); + } + + /* Get all table names (exclude views) */ + var tableNames = new List(); + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = "SELECT table_name FROM information_schema.tables WHERE table_schema = 'main' AND table_type = 'BASE TABLE'"; + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + tableNames.Add(reader.GetString(0)); + } + } + + foreach (var table in tableNames) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"CREATE TABLE compact_db.{table} AS SELECT * FROM main.{table}"; + await cmd.ExecuteNonQueryAsync(); + } + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = "DETACH compact_db"; + await cmd.ExecuteNonQueryAsync(); + } + } + + /* Swap files: old → backup, compact → primary */ + if (File.Exists(backupPath)) File.Delete(backupPath); + File.Move(_databasePath, backupPath); + + var walPath = _databasePath + ".wal"; + if (File.Exists(walPath)) File.Delete(walPath); + + File.Move(tempPath, _databasePath); + + /* Recreate indexes and views on the fresh database */ + using (var connection = CreateConnection()) + { + await connection.OpenAsync(); + + foreach (var indexStatement in Schema.GetAllIndexStatements()) + { + try + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = indexStatement; + await cmd.ExecuteNonQueryAsync(); + } + catch { /* Index may already exist from CREATE TABLE AS */ } + } + } + + await CreateArchiveViewsAsync(); + + /* Clean up backup */ + File.Delete(backupPath); + + var sizeAfter = GetDatabaseSizeMb(); + _logger?.LogInformation("Compaction complete: {Before:F0} MB -> {After:F0} MB ({Saved:F0} MB reclaimed)", + sizeBefore, sizeAfter, sizeBefore - sizeAfter); + + return true; + } + catch (Exception ex) + { + _logger?.LogError(ex, "Database compaction failed"); + + /* Restore from backup if the primary file was moved */ + if (!File.Exists(_databasePath) && File.Exists(backupPath)) + { + File.Move(backupPath, _databasePath); + _logger?.LogInformation("Restored database from pre-compaction backup"); + } + + /* Clean up temp file */ + if (File.Exists(tempPath)) File.Delete(tempPath); + + return false; + } + } + } diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index a29d0600..e1aed234 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -105,7 +105,7 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e) var archiveService = new ArchiveService(_databaseInitializer, App.ArchiveDirectory); var retentionService = new RetentionService(App.ArchiveDirectory); - _backgroundService = new CollectionBackgroundService(_collectorService, archiveService, retentionService, _serverManager); + _backgroundService = new CollectionBackgroundService(_collectorService, _databaseInitializer, archiveService, retentionService, _serverManager); // Start background collection _backgroundCts = new CancellationTokenSource(); diff --git a/Lite/Services/ArchiveService.cs b/Lite/Services/ArchiveService.cs index bfcf995b..5b71fac1 100644 --- a/Lite/Services/ArchiveService.cs +++ b/Lite/Services/ArchiveService.cs @@ -128,6 +128,9 @@ UNION ALL _logger?.LogError(ex, "Failed to archive table {Table}", table); } } + + /* Refresh archive views so newly archived parquet files are queryable */ + await _duckDb.CreateArchiveViewsAsync(); } finally { diff --git a/Lite/Services/CollectionBackgroundService.cs b/Lite/Services/CollectionBackgroundService.cs index d52e1429..cbc9ec1e 100644 --- a/Lite/Services/CollectionBackgroundService.cs +++ b/Lite/Services/CollectionBackgroundService.cs @@ -22,6 +22,7 @@ namespace PerformanceMonitorLite.Services; public class CollectionBackgroundService : BackgroundService { private readonly RemoteCollectorService _collectorService; + private readonly DuckDbInitializer? _duckDb; private readonly ServerManager? _serverManager; private readonly ArchiveService? _archiveService; private readonly RetentionService? _retentionService; @@ -30,10 +31,15 @@ public class CollectionBackgroundService : BackgroundService private static readonly TimeSpan CollectionInterval = TimeSpan.FromMinutes(1); private DateTime _lastArchiveTime = DateTime.MinValue; private DateTime _lastRetentionTime = DateTime.MinValue; + private DateTime _lastCompactionTime = DateTime.MinValue; - /* Archive every hour, retention cleanup once per day */ + /* Archive every hour, retention + compaction once per day */ private static readonly TimeSpan ArchiveInterval = TimeSpan.FromHours(1); private static readonly TimeSpan RetentionInterval = TimeSpan.FromHours(24); + private static readonly TimeSpan CompactionInterval = TimeSpan.FromHours(24); + + /* Warn if database exceeds this size between compaction cycles */ + private const double SizeWarningThresholdMb = 1024; public bool IsPaused { get; set; } public DateTime? LastCollectionTime { get; private set; } @@ -41,12 +47,14 @@ public class CollectionBackgroundService : BackgroundService public CollectionBackgroundService( RemoteCollectorService collectorService, + DuckDbInitializer? duckDb = null, ArchiveService? archiveService = null, RetentionService? retentionService = null, ServerManager? serverManager = null, ILogger? logger = null) { _collectorService = collectorService; + _duckDb = duckDb; _serverManager = serverManager; _archiveService = archiveService; _retentionService = retentionService; @@ -108,6 +116,9 @@ stall collectors mid-write with 2-3s stop-the-world pauses */ /* Periodic retention cleanup */ RunRetentionIfDue(); + + /* Periodic database compaction to prevent bloat */ + await RunCompactionIfDueAsync(); } try @@ -158,4 +169,37 @@ private void RunRetentionIfDue() _logger?.LogError(ex, "Retention cleanup failed"); } } + + private async Task RunCompactionIfDueAsync() + { + if (_duckDb == null || DateTime.UtcNow - _lastCompactionTime < CompactionInterval) + { + /* Size watchdog: warn if database is large even between compaction cycles */ + if (_duckDb != null) + { + var sizeMb = _duckDb.GetDatabaseSizeMb(); + if (sizeMb > SizeWarningThresholdMb) + { + _logger?.LogWarning("Database size is {SizeMb:F0} MB (threshold: {Threshold} MB) — compaction will run at next scheduled interval", + sizeMb, SizeWarningThresholdMb); + } + } + return; + } + + try + { + IsPaused = true; + await _duckDb.CompactAsync(); + _lastCompactionTime = DateTime.UtcNow; + } + catch (Exception ex) + { + _logger?.LogError(ex, "Database compaction failed"); + } + finally + { + IsPaused = false; + } + } } diff --git a/Lite/Services/LocalDataService.Blocking.cs b/Lite/Services/LocalDataService.Blocking.cs index 11616d79..e82f781d 100644 --- a/Lite/Services/LocalDataService.Blocking.cs +++ b/Lite/Services/LocalDataService.Blocking.cs @@ -78,7 +78,7 @@ public async Task> GetRecentDeadlocksAsync(int serverId, int h victim_process_id, victim_sql_text, deadlock_graph_xml -FROM deadlocks +FROM v_deadlocks WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -139,7 +139,7 @@ public async Task> GetLatestQuerySnapshotsAsync(int serve query_plan, live_query_plan, collection_time -FROM query_snapshots +FROM v_query_snapshots WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -230,7 +230,7 @@ public async Task> GetRecentBlockedProcessReportsA blocking_last_batch_completed, blocked_priority, blocking_priority -FROM blocked_process_reports +FROM v_blocked_process_reports WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -310,7 +310,7 @@ Group by event_time (when blocking actually occurred) rather than collection_tim SELECT DATE_TRUNC('minute', event_time) AS bucket, COUNT(*) AS incident_count - FROM blocked_process_reports + FROM v_blocked_process_reports WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3 @@ -353,7 +353,7 @@ public async Task> GetDeadlockTrendAsync(int serverId, int hour SELECT DATE_TRUNC('hour', deadlock_time) AS bucket, COUNT(*) AS deadlock_count - FROM deadlocks + FROM v_deadlocks WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 diff --git a/Lite/Services/LocalDataService.CollectionHealth.cs b/Lite/Services/LocalDataService.CollectionHealth.cs index 226a3177..b93bfb76 100644 --- a/Lite/Services/LocalDataService.CollectionHealth.cs +++ b/Lite/Services/LocalDataService.CollectionHealth.cs @@ -34,7 +34,7 @@ public async Task> GetCollectionHealthAsync(int serverI MAX(CASE WHEN status IN ('ERROR', 'PERMISSIONS') THEN error_message END) AS last_error, MAX(CASE WHEN status IN ('ERROR', 'PERMISSIONS') THEN collection_time END) AS last_error_time, SUM(CASE WHEN status = 'PERMISSIONS' THEN 1 ELSE 0 END) AS permission_denied_count -FROM collection_log +FROM v_collection_log WHERE server_id = $1 AND collection_time >= $2 GROUP BY collector_name @@ -83,7 +83,7 @@ public async Task> GetRecentCollectionLogAsync(int server status, error_message, server_name -FROM collection_log +FROM v_collection_log WHERE server_id = $1 AND collection_time >= $2 ORDER BY collection_time DESC diff --git a/Lite/Services/LocalDataService.Cpu.cs b/Lite/Services/LocalDataService.Cpu.cs index e9d6aa61..50e7be8a 100644 --- a/Lite/Services/LocalDataService.Cpu.cs +++ b/Lite/Services/LocalDataService.Cpu.cs @@ -32,7 +32,7 @@ public async Task> GetCpuUtilizationAsync(int serverId, sample_time, sqlserver_cpu_utilization, other_process_cpu_utilization -FROM cpu_utilization_stats +FROM v_cpu_utilization_stats WHERE server_id = $1 AND sample_time >= $2 AND sample_time <= $3 diff --git a/Lite/Services/LocalDataService.FileIo.cs b/Lite/Services/LocalDataService.FileIo.cs index 47185c91..3b1dfbe0 100644 --- a/Lite/Services/LocalDataService.FileIo.cs +++ b/Lite/Services/LocalDataService.FileIo.cs @@ -35,9 +35,9 @@ public async Task> GetLatestFileIoStatsAsync(int serverId) delta_write_bytes, delta_stall_read_ms, delta_stall_write_ms -FROM file_io_stats +FROM v_file_io_stats WHERE server_id = $1 -AND collection_time = (SELECT MAX(collection_time) FROM file_io_stats WHERE server_id = $1) +AND collection_time = (SELECT MAX(collection_time) FROM v_file_io_stats WHERE server_id = $1) ORDER BY (delta_stall_read_ms + delta_stall_write_ms) DESC"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); @@ -81,7 +81,7 @@ public async Task> GetFileIoLatencyTrendAsync(int serverI database_name, CASE WHEN SUM(delta_reads) > 0 THEN SUM(CAST(delta_stall_read_ms AS DOUBLE)) / SUM(delta_reads) ELSE 0 END AS avg_read_latency_ms, CASE WHEN SUM(delta_writes) > 0 THEN SUM(CAST(delta_stall_write_ms AS DOUBLE)) / SUM(delta_writes) ELSE 0 END AS avg_write_latency_ms -FROM file_io_stats +FROM v_file_io_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -124,7 +124,7 @@ public async Task> GetTempDbFileIoTrendAsync(int serverId file_name, CASE WHEN SUM(delta_reads) > 0 THEN SUM(CAST(delta_stall_read_ms AS DOUBLE)) / SUM(delta_reads) ELSE 0 END AS avg_read_latency_ms, CASE WHEN SUM(delta_writes) > 0 THEN SUM(CAST(delta_stall_write_ms AS DOUBLE)) / SUM(delta_writes) ELSE 0 END AS avg_write_latency_ms -FROM file_io_stats +FROM v_file_io_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 diff --git a/Lite/Services/LocalDataService.Memory.cs b/Lite/Services/LocalDataService.Memory.cs index 07612ee7..55869c47 100644 --- a/Lite/Services/LocalDataService.Memory.cs +++ b/Lite/Services/LocalDataService.Memory.cs @@ -35,7 +35,7 @@ public partial class LocalDataService total_server_memory_mb, buffer_pool_mb, plan_cache_mb -FROM memory_stats +FROM v_memory_stats WHERE server_id = $1 ORDER BY collection_time DESC LIMIT 1"; @@ -81,7 +81,7 @@ public async Task> GetMemoryTrendAsync(int serverId, int target_server_memory_mb, buffer_pool_mb, plan_cache_mb -FROM memory_stats +FROM v_memory_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -117,9 +117,9 @@ public async Task> GetLatestMemoryClerksAsync(int serverId) using var command = connection.CreateCommand(); command.CommandText = @" SELECT clerk_type, memory_mb -FROM memory_clerks +FROM v_memory_clerks WHERE server_id = $1 -AND collection_time = (SELECT MAX(collection_time) FROM memory_clerks WHERE server_id = $1) +AND collection_time = (SELECT MAX(collection_time) FROM v_memory_clerks WHERE server_id = $1) ORDER BY memory_mb DESC"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); diff --git a/Lite/Services/LocalDataService.Overview.cs b/Lite/Services/LocalDataService.Overview.cs index 2c9789be..b86f6c4a 100644 --- a/Lite/Services/LocalDataService.Overview.cs +++ b/Lite/Services/LocalDataService.Overview.cs @@ -34,7 +34,7 @@ public partial class LocalDataService { cmd.CommandText = @" SELECT sqlserver_cpu_utilization, sample_time -FROM cpu_utilization_stats +FROM v_cpu_utilization_stats WHERE server_id = $1 ORDER BY sample_time DESC LIMIT 1"; @@ -52,7 +52,7 @@ ORDER BY sample_time DESC { cmd.CommandText = @" SELECT total_server_memory_mb -FROM memory_stats +FROM v_memory_stats WHERE server_id = $1 ORDER BY collection_time DESC LIMIT 1"; @@ -69,7 +69,7 @@ ORDER BY collection_time DESC { cmd.CommandText = @" SELECT COUNT(*) -FROM blocked_process_reports +FROM v_blocked_process_reports WHERE server_id = $1 AND event_time >= $2"; cmd.Parameters.Add(new DuckDBParameter { Value = serverId }); @@ -83,7 +83,7 @@ FROM blocked_process_reports { cmd.CommandText = @" SELECT COUNT(*) -FROM deadlocks +FROM v_deadlocks WHERE server_id = $1 AND deadlock_time >= $2"; cmd.Parameters.Add(new DuckDBParameter { Value = serverId }); @@ -97,7 +97,7 @@ FROM deadlocks { cmd.CommandText = @" SELECT MAX(collection_time) -FROM collection_log +FROM v_collection_log WHERE server_id = $1"; cmd.Parameters.Add(new DuckDBParameter { Value = serverId }); var result = await cmd.ExecuteScalarAsync(); diff --git a/Lite/Services/LocalDataService.Perfmon.cs b/Lite/Services/LocalDataService.Perfmon.cs index 74a07e47..f748846c 100644 --- a/Lite/Services/LocalDataService.Perfmon.cs +++ b/Lite/Services/LocalDataService.Perfmon.cs @@ -28,9 +28,9 @@ public async Task> GetLatestPerfmonStatsAsync(int serverId) instance_name, cntr_value, delta_cntr_value -FROM perfmon_stats +FROM v_perfmon_stats WHERE server_id = $1 -AND collection_time = (SELECT MAX(collection_time) FROM perfmon_stats WHERE server_id = $1) +AND collection_time = (SELECT MAX(collection_time) FROM v_perfmon_stats WHERE server_id = $1) ORDER BY counter_name"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); @@ -63,7 +63,7 @@ public async Task> GetDistinctPerfmonCountersAsync(int serverId, in command.CommandText = @" SELECT DISTINCT counter_name -FROM perfmon_stats +FROM v_perfmon_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -97,7 +97,7 @@ public async Task> GetPerfmonTrendAsync(int serverId, st collection_time, cntr_value, delta_cntr_value -FROM perfmon_stats +FROM v_perfmon_stats WHERE server_id = $1 AND counter_name = $2 AND collection_time >= $3 diff --git a/Lite/Services/LocalDataService.QueryStats.cs b/Lite/Services/LocalDataService.QueryStats.cs index fd4bafaf..0ed8a34c 100644 --- a/Lite/Services/LocalDataService.QueryStats.cs +++ b/Lite/Services/LocalDataService.QueryStats.cs @@ -66,7 +66,7 @@ public async Task> GetTopQueriesByCpuAsync(int serverId, int MAX(plan_handle) AS plan_handle, MAX(query_text) AS query_text, MAX(query_plan_xml) AS query_plan -FROM query_stats +FROM v_query_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -139,7 +139,7 @@ public async Task> GetQueryStatsHistoryAsync(int serv max_elapsed_time, query_plan_xml, query_plan_hash -FROM query_stats +FROM v_query_stats WHERE server_id = $1 AND database_name = $2 AND query_hash = $3 @@ -196,7 +196,7 @@ public async Task> GetProcedureStatsHistoryAsync( delta_logical_reads, delta_logical_writes, delta_physical_reads -FROM procedure_stats +FROM v_procedure_stats WHERE server_id = $1 AND database_name = $2 AND schema_name = $3 @@ -330,7 +330,7 @@ public async Task> GetTopProceduresByCpuAsync(int server SUM(total_spills) AS total_spills, MAX(sql_handle) AS sql_handle, MAX(plan_handle) AS plan_handle -FROM procedure_stats +FROM v_procedure_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -389,7 +389,7 @@ WITH raw AS SUM(delta_elapsed_time) / 1000.0 AS total_elapsed_ms, SUM(delta_execution_count) AS total_executions, date_diff('second', LAG(collection_time) OVER (ORDER BY collection_time), collection_time) AS interval_seconds - FROM query_stats + FROM v_query_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -438,7 +438,7 @@ WITH raw AS SUM(delta_elapsed_time) / 1000.0 AS total_elapsed_ms, SUM(delta_execution_count) AS total_executions, date_diff('second', LAG(collection_time) OVER (ORDER BY collection_time), collection_time) AS interval_seconds - FROM procedure_stats + FROM v_procedure_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -486,7 +486,7 @@ WITH raw AS collection_time, SUM(delta_execution_count) AS total_executions, date_diff('second', LAG(collection_time) OVER (ORDER BY collection_time), collection_time) AS interval_seconds - FROM query_stats + FROM v_query_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 diff --git a/Lite/Services/LocalDataService.QueryStore.cs b/Lite/Services/LocalDataService.QueryStore.cs index b492ae72..d68ae69c 100644 --- a/Lite/Services/LocalDataService.QueryStore.cs +++ b/Lite/Services/LocalDataService.QueryStore.cs @@ -44,7 +44,7 @@ public async Task> GetQueryStoreTopQueriesAsync(int serverId AVG(avg_rowcount) AS avg_rowcount, MAX(last_execution_time) AS last_execution_time, MAX(query_plan_hash) AS query_plan_hash -FROM query_store_stats +FROM v_query_store_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -102,7 +102,7 @@ public async Task> GetQueryStoreHistoryAsync(int serv avg_physical_reads, avg_rowcount, last_execution_time -FROM query_store_stats +FROM v_query_store_stats WHERE server_id = $1 AND database_name = $2 AND query_id = $3 @@ -146,7 +146,7 @@ public async Task> GetQueryStoreDatabasesAsync(int serverId, int ho using var command = connection.CreateCommand(); command.CommandText = @" SELECT DISTINCT database_name -FROM query_store_stats +FROM v_query_store_stats WHERE server_id = $1 AND collection_time >= $2 ORDER BY database_name"; @@ -221,7 +221,7 @@ WITH raw AS SUM(execution_count * avg_duration_ms) AS total_duration_ms, SUM(execution_count) AS total_executions, date_diff('second', LAG(collection_time) OVER (ORDER BY collection_time), collection_time) AS interval_seconds - FROM query_store_stats + FROM v_query_store_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 diff --git a/Lite/Services/LocalDataService.TempDb.cs b/Lite/Services/LocalDataService.TempDb.cs index 0b4e1bba..9b9ea7fe 100644 --- a/Lite/Services/LocalDataService.TempDb.cs +++ b/Lite/Services/LocalDataService.TempDb.cs @@ -36,7 +36,7 @@ public async Task> GetTempDbTrendAsync(int serverId, int hoursBa total_sessions_using_tempdb, top_session_id, top_session_tempdb_mb -FROM tempdb_stats +FROM v_tempdb_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -84,7 +84,7 @@ FROM tempdb_stats version_store_reserved_mb, top_session_tempdb_mb, top_session_id -FROM tempdb_stats +FROM v_tempdb_stats WHERE server_id = $1 ORDER BY collection_time DESC LIMIT 1"; diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs index 406bdd57..46994844 100644 --- a/Lite/Services/LocalDataService.WaitStats.cs +++ b/Lite/Services/LocalDataService.WaitStats.cs @@ -32,7 +32,7 @@ public async Task> GetWaitStatsAsync(int serverId, int hoursB SUM(delta_wait_time_ms) AS total_wait_time_ms, SUM(delta_signal_wait_time_ms) AS total_signal_wait_time_ms, COUNT(*) AS sample_count -FROM wait_stats +FROM v_wait_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -75,7 +75,7 @@ public async Task> GetDistinctWaitTypesAsync(int serverId, int hour SELECT wait_type, SUM(delta_wait_time_ms) AS total_delta -FROM wait_stats +FROM v_wait_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -114,7 +114,7 @@ WITH raw AS delta_signal_wait_time_ms, delta_waiting_tasks, date_diff('second', LAG(collection_time) OVER (ORDER BY collection_time), collection_time) AS interval_seconds - FROM wait_stats + FROM v_wait_stats WHERE server_id = $1 AND wait_type = $2 AND collection_time >= $3 @@ -166,7 +166,7 @@ public async Task> GetLatestPoisonWaitAvgsAsync(int server CASE WHEN delta_waiting_tasks > 0 THEN CAST(delta_wait_time_ms AS DOUBLE) / delta_waiting_tasks ELSE 0 END AS avg_ms_per_wait -FROM wait_stats +FROM v_wait_stats WHERE server_id = $1 AND wait_type IN ('THREADPOOL', 'RESOURCE_SEMAPHORE', 'RESOURCE_SEMAPHORE_QUERY_COMPILE') AND delta_waiting_tasks > 0 @@ -213,9 +213,9 @@ public async Task> GetLongRunningQueriesAsync(int ser writes, wait_type, blocking_session_id -FROM query_snapshots +FROM v_query_snapshots WHERE server_id = $1 -AND collection_time = (SELECT MAX(collection_time) FROM query_snapshots WHERE server_id = $1) +AND collection_time = (SELECT MAX(collection_time) FROM v_query_snapshots WHERE server_id = $1) AND session_id > 50 AND total_elapsed_time_ms >= $2 ORDER BY total_elapsed_time_ms DESC From e567b763af7a68db1b6065386fbb50a8b4996dbe Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:54:33 -0500 Subject: [PATCH 04/32] Expand Lite database config collector to 28 sys.databases columns (#142) (#163) Collector now reports state, collation, RCSI, snapshot isolation, stats settings, encryption, security, CDC, broker, and three version-gated columns (ADR 2019+, memory optimized 2019+, optimized locking 2025+). Dynamic SQL builds the SELECT list based on SQL Server version; Azure SQL DB always gets 2019+ columns. Schema version bumped to v11 (drop/recreate database_config table). Co-authored-by: Claude Opus 4.6 --- Lite/Controls/ServerTab.xaml | 63 ++++++- Lite/Database/DuckDbInitializer.cs | 11 +- Lite/Database/Schema.cs | 21 ++- Lite/Services/LocalDataService.Config.cs | 85 +++++++-- .../RemoteCollectorService.ServerConfig.cs | 173 ++++++++++++++++-- 5 files changed, 319 insertions(+), 34 deletions(-) diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml index c99a7afe..342dd44b 100644 --- a/Lite/Controls/ServerTab.xaml +++ b/Lite/Controls/ServerTab.xaml @@ -911,21 +911,69 @@ public static class TabHelpers { + /// + /// Returns true if a double-click originated from a DataGridRow (not a header). + /// Use at the top of MouseDoubleClick handlers to prevent header clicks from + /// triggering row actions. + /// + public static bool IsDoubleClickOnRow(DependencyObject originalSource) + { + var dep = originalSource; + while (dep != null) + { + if (dep is DataGridRow) return true; + if (dep is DataGridColumnHeader) return false; + dep = VisualTreeHelper.GetParent(dep); + } + return false; + } + /// /// Material Design 300-level color palette for chart data series. /// Soft pastels optimized for dark backgrounds, ordered to map 1:1 diff --git a/Dashboard/ManageServersWindow.xaml.cs b/Dashboard/ManageServersWindow.xaml.cs index d6b81558..4ce1eaf1 100644 --- a/Dashboard/ManageServersWindow.xaml.cs +++ b/Dashboard/ManageServersWindow.xaml.cs @@ -34,6 +34,7 @@ private void LoadServers() private void ServersDataGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e) { + if (!Helpers.TabHelpers.IsDoubleClickOnRow((DependencyObject)e.OriginalSource)) return; EditSelectedServer(); } diff --git a/Dashboard/ServerTab.xaml.cs b/Dashboard/ServerTab.xaml.cs index 9ee1de2d..055fb452 100644 --- a/Dashboard/ServerTab.xaml.cs +++ b/Dashboard/ServerTab.xaml.cs @@ -1356,6 +1356,7 @@ await Task.WhenAll( private void HealthDataGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) { + if (!Helpers.TabHelpers.IsDoubleClickOnRow((DependencyObject)e.OriginalSource)) return; if (HealthDataGrid.SelectedItem is CollectionHealthItem item) { var logWindow = new CollectionLogWindow(item.CollectorName, _databaseService); From d529ae3ef933619d2c9167b79e0474a39259cbd9 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 20 Feb 2026 07:49:56 -0500 Subject: [PATCH 24/32] Fix DBNull cast error in Lite database_config collector on SQL 2016 Express (fixes #192) All GetBoolean() and Convert.ToInt32() calls now check IsDBNull first, defaulting to false/0. Prevents "Object cannot be cast from DBNull to other types" on editions where certain sys.databases columns return NULL. Co-Authored-By: Claude Opus 4.6 --- .../RemoteCollectorService.ServerConfig.cs | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/Lite/Services/RemoteCollectorService.ServerConfig.cs b/Lite/Services/RemoteCollectorService.ServerConfig.cs index 1e6d6e70..9adad481 100644 --- a/Lite/Services/RemoteCollectorService.ServerConfig.cs +++ b/Lite/Services/RemoteCollectorService.ServerConfig.cs @@ -182,40 +182,40 @@ ORDER BY d.name { DbName = reader.GetString(ordinal++), StateDesc = reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal), - CompatLevel = Convert.ToInt32(reader.GetValue(++ordinal)), + CompatLevel = reader.IsDBNull(++ordinal) ? 0 : Convert.ToInt32(reader.GetValue(ordinal)), CollationName = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), RecoveryModel = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), - IsReadOnly = reader.GetBoolean(++ordinal), - AutoClose = reader.GetBoolean(++ordinal), - AutoShrink = reader.GetBoolean(++ordinal), - AutoCreateStats = reader.GetBoolean(++ordinal), - AutoUpdateStats = reader.GetBoolean(++ordinal), - AutoUpdateStatsAsync = reader.GetBoolean(++ordinal), - Rcsi = reader.GetBoolean(++ordinal), + IsReadOnly = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + AutoClose = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + AutoShrink = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + AutoCreateStats = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + AutoUpdateStats = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + AutoUpdateStatsAsync = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + Rcsi = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), SnapshotIsolation = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), - ParameterizationForced = reader.GetBoolean(++ordinal), - QueryStore = reader.GetBoolean(++ordinal), - Encrypted = reader.GetBoolean(++ordinal), - Trustworthy = reader.GetBoolean(++ordinal), - DbChaining = reader.GetBoolean(++ordinal), - BrokerEnabled = reader.GetBoolean(++ordinal), - CdcEnabled = reader.GetBoolean(++ordinal), - MixedPageAllocation = reader.GetBoolean(++ordinal), + ParameterizationForced = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + QueryStore = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + Encrypted = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + Trustworthy = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + DbChaining = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + BrokerEnabled = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + CdcEnabled = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + MixedPageAllocation = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), LogReuseWait = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), PageVerify = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), - TargetRecovery = Convert.ToInt32(reader.GetValue(++ordinal)), + TargetRecovery = reader.IsDBNull(++ordinal) ? 0 : Convert.ToInt32(reader.GetValue(ordinal)), DelayedDurability = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), }; if (has2019Columns) { - r.AcceleratedDatabaseRecovery = reader.GetBoolean(++ordinal); - r.MemoryOptimized = reader.GetBoolean(++ordinal); + r.AcceleratedDatabaseRecovery = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal); + r.MemoryOptimized = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal); } if (has2025Columns) { - r.OptimizedLocking = reader.GetBoolean(++ordinal); + r.OptimizedLocking = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal); } rows.Add(r); From 8bf4af3dc12aaaf7b90c4e7e3db2b3e7f213ea7f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 20 Feb 2026 07:59:51 -0500 Subject: [PATCH 25/32] Rename misleading "Total Executions" label in drill-down summaries (fixes #194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan cache execution_count is a cumulative counter — MAX is the correct aggregation, matching the main grid. The "Total Executions" label implied a sum, confusing users. Renamed to "Executions" in QueryStatsHistory and ProcedureHistory windows. Query Store window keeps "Total Executions" since it correctly sums interval deltas. Co-Authored-By: Claude Opus 4.6 --- Dashboard/ProcedureHistoryWindow.xaml.cs | 2 +- Dashboard/QueryStatsHistoryWindow.xaml.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dashboard/ProcedureHistoryWindow.xaml.cs b/Dashboard/ProcedureHistoryWindow.xaml.cs index ad1a1176..a8356ac0 100644 --- a/Dashboard/ProcedureHistoryWindow.xaml.cs +++ b/Dashboard/ProcedureHistoryWindow.xaml.cs @@ -97,7 +97,7 @@ private async Task LoadHistoryAsync() var lastSample = _historyData.Max(h => h.CollectionTime); SummaryText.Text = string.Format(CultureInfo.CurrentCulture, - "Samples: {0} | First: {1:yyyy-MM-dd HH:mm} | Last: {2:yyyy-MM-dd HH:mm} | Total Executions: {3:N0} | Avg CPU: {4:N2} ms | Avg Duration: {5:N2} ms", + "Samples: {0} | First: {1:yyyy-MM-dd HH:mm} | Last: {2:yyyy-MM-dd HH:mm} | Executions: {3:N0} | Avg CPU: {4:N2} ms | Avg Duration: {5:N2} ms", _historyData.Count, firstSample, lastSample, totalExecutions, avgCpu, avgDuration); UpdateChart(); diff --git a/Dashboard/QueryStatsHistoryWindow.xaml.cs b/Dashboard/QueryStatsHistoryWindow.xaml.cs index c480e039..c1520d28 100644 --- a/Dashboard/QueryStatsHistoryWindow.xaml.cs +++ b/Dashboard/QueryStatsHistoryWindow.xaml.cs @@ -94,7 +94,7 @@ private async Task LoadHistoryAsync() var lastSample = _historyData.Max(h => h.CollectionTime); SummaryText.Text = string.Format(CultureInfo.CurrentCulture, - "Samples: {0} | First: {1:yyyy-MM-dd HH:mm} | Last: {2:yyyy-MM-dd HH:mm} | Total Executions: {3:N0} | Avg CPU: {4:N2} ms | Avg Duration: {5:N2} ms", + "Samples: {0} | First: {1:yyyy-MM-dd HH:mm} | Last: {2:yyyy-MM-dd HH:mm} | Executions: {3:N0} | Avg CPU: {4:N2} ms | Avg Duration: {5:N2} ms", _historyData.Count, firstSample, lastSample, totalExecutions, avgCpu, avgDuration); UpdateChart(); From 6d7a5f7be1f41380ad1831e055698b210e062229 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 20 Feb 2026 08:29:01 -0500 Subject: [PATCH 26/32] Fix numeric filter negative range parsing and MCP shutdown deadlock risk NumericFilterHelper: Replace naive Split('-') range parsing with TryParseRange that finds the separator dash by looking for a '-' preceded by a digit. Correctly handles -100-200, -100--50, and .. syntax. Previously, negative-start ranges were silently ignored due to the StartsWith('-') guard. MainWindow: Wrap MCP StopAsync in Task.Run to avoid sync-over-async deadlock on the WPF UI thread during app close. Addresses items from #113 and #112. Co-Authored-By: Claude Opus 4.6 --- Dashboard/Helpers/NumericFilterHelper.cs | 43 ++++++++++++------------ Dashboard/MainWindow.xaml.cs | 2 +- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/Dashboard/Helpers/NumericFilterHelper.cs b/Dashboard/Helpers/NumericFilterHelper.cs index 77cefcce..a5f78a0e 100644 --- a/Dashboard/Helpers/NumericFilterHelper.cs +++ b/Dashboard/Helpers/NumericFilterHelper.cs @@ -23,14 +23,10 @@ public static bool MatchesFilter(object? value, string? filterText) if (!TryConvertToDecimal(value, out decimal numericValue)) return true; // If can't convert, don't filter out - // Check for range: "100-200" or "100..200" - if (filterText.Contains('-', StringComparison.Ordinal) && !filterText.StartsWith('-')) + // Check for range: "100-200", "-100-200", "-100--50", or "100..200" + if (TryParseRange(filterText, out decimal rangeMin, out decimal rangeMax)) { - return EvaluateRange(numericValue, filterText); - } - else if (filterText.Contains("..", StringComparison.Ordinal)) - { - return EvaluateRange(numericValue, filterText.Replace("..", "-", StringComparison.Ordinal)); + return numericValue >= rangeMin && numericValue <= rangeMax; } // Check for >= else if (filterText.StartsWith(">=", StringComparison.Ordinal)) @@ -84,28 +80,33 @@ private static bool TryConvertToDecimal(object value, out decimal result) } } - private static bool EvaluateRange(decimal value, string rangeText) + private static bool TryParseRange(string text, out decimal min, out decimal max) { - var parts = rangeText.Split('-'); - if (parts.Length == 2) + min = max = 0; + + // Try ".." separator first (unambiguous) + int dotIdx = text.IndexOf("..", StringComparison.Ordinal); + if (dotIdx >= 0) { - if (decimal.TryParse(parts[0].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out decimal min) && - decimal.TryParse(parts[1].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out decimal max)) - { - return value >= min && value <= max; - } + return decimal.TryParse(text.Substring(0, dotIdx).Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out min) && + decimal.TryParse(text.Substring(dotIdx + 2).Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out max); } - // Handle negative numbers in range: e.g., "-100-200" means -100 to 200 - else if (parts.Length == 3 && string.IsNullOrEmpty(parts[0])) + + // For "-" separator, find the dash that separates two values (not a negative sign). + // A separator dash has a digit before it: "100-200", "-100-200", "-100--50" + for (int i = 1; i < text.Length; i++) { - if (decimal.TryParse("-" + parts[1].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out decimal min) && - decimal.TryParse(parts[2].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out decimal max)) + if (text[i] == '-' && char.IsDigit(text[i - 1])) { - return value >= min && value <= max; + if (decimal.TryParse(text.Substring(0, i).Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out min) && + decimal.TryParse(text.Substring(i + 1).Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out max)) + { + return true; + } } } - return true; // Invalid range, don't filter + return false; } } } diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index e6f76277..9ff95486 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -234,7 +234,7 @@ private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEven try { _mcpCts?.Cancel(); - _mcpHostService.StopAsync(CancellationToken.None).Wait(TimeSpan.FromSeconds(5)); + Task.Run(() => _mcpHostService.StopAsync(CancellationToken.None)).Wait(TimeSpan.FromSeconds(5)); } catch (Exception ex) { From 2a4eb014294bf70ff06479a443fc2c2aa077b68b Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 20 Feb 2026 08:37:07 -0500 Subject: [PATCH 27/32] Exclude WAITFOR sessions from long-running query alerts CDC capture agents use `waitfor delay @waittime` and run indefinitely by design. Service Broker listeners use `waitfor receive`. Neither should trigger long-running query alerts. Adds NOT LIKE filters for both patterns in Dashboard (live DMV query) and Lite (DuckDB alert query). Fixes #151. Co-Authored-By: Claude Opus 4.6 --- Dashboard/Services/DatabaseService.NocHealth.cs | 2 ++ Lite/Services/LocalDataService.WaitStats.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Dashboard/Services/DatabaseService.NocHealth.cs b/Dashboard/Services/DatabaseService.NocHealth.cs index e5125e3d..c1f59887 100644 --- a/Dashboard/Services/DatabaseService.NocHealth.cs +++ b/Dashboard/Services/DatabaseService.NocHealth.cs @@ -623,6 +623,8 @@ CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS t JOIN sys.dm_exec_sessions AS s ON s.session_id = r.session_id WHERE r.session_id > 50 AND r.total_elapsed_time >= @thresholdMs + AND t.text NOT LIKE N'%waitfor delay%' + AND t.text NOT LIKE N'%waitfor receive%' ORDER BY r.total_elapsed_time DESC OPTION(MAXDOP 1, RECOMPILE);"; diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs index 46994844..f7f1a313 100644 --- a/Lite/Services/LocalDataService.WaitStats.cs +++ b/Lite/Services/LocalDataService.WaitStats.cs @@ -218,6 +218,8 @@ FROM v_query_snapshots AND collection_time = (SELECT MAX(collection_time) FROM v_query_snapshots WHERE server_id = $1) AND session_id > 50 AND total_elapsed_time_ms >= $2 +AND query_text NOT LIKE '%waitfor delay%' +AND query_text NOT LIKE '%waitfor receive%' ORDER BY total_elapsed_time_ms DESC LIMIT 5"; From 527d009d5272e2f831f6a963482f128cd9d8cf56 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:12:38 -0500 Subject: [PATCH 28/32] Replace TextBox-in-header filters with popup filter buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates 5 DataGrids from the old TextBox-in-header filter style to the standard popup filter pattern used by the rest of the Dashboard: - DefaultTraceContent: DefaultTraceEventsDataGrid (7 cols), TraceAnalysisDataGrid (9 cols) - CurrentConfigContent: ServerConfigDataGrid (9 cols), DatabaseConfigDataGrid (5 cols), TraceFlagsDataGrid (5 cols — previously had no filters at all) All columns now have consistent popup filter buttons with operator support and gold active-filter indicators. Fixes #200. Co-Authored-By: Claude Opus 4.6 --- Dashboard/Controls/CurrentConfigContent.xaml | 150 +++++++++++---- .../Controls/CurrentConfigContent.xaml.cs | 180 +++++++++++++++++- Dashboard/Controls/DefaultTraceContent.xaml | 96 +++++----- .../Controls/DefaultTraceContent.xaml.cs | 156 +++++++++++++-- 4 files changed, 477 insertions(+), 105 deletions(-) diff --git a/Dashboard/Controls/CurrentConfigContent.xaml b/Dashboard/Controls/CurrentConfigContent.xaml index 831b44c2..1a42ce54 100644 --- a/Dashboard/Controls/CurrentConfigContent.xaml +++ b/Dashboard/Controls/CurrentConfigContent.xaml @@ -16,34 +16,76 @@ - - - + +