diff --git a/Lite.Tests/DismissReliabilityTests.cs b/Lite.Tests/DismissReliabilityTests.cs new file mode 100644 index 00000000..9ee1bcde --- /dev/null +++ b/Lite.Tests/DismissReliabilityTests.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Tests.Helpers; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// Tests that dismiss operations use batched UPDATEs within transactions +/// and that the write lock prevents race conditions. +/// +public class DismissReliabilityTests : IDisposable +{ + private readonly string _tempDir; + private readonly string _dbPath; + private readonly TestAlertDataHelper _helper; + + public DismissReliabilityTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "LiteTests_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + _dbPath = Path.Combine(_tempDir, "test.duckdb"); + _helper = new TestAlertDataHelper(_dbPath); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + catch + { + /* Best-effort cleanup */ + } + } + + private async Task InitializeDatabaseAsync() + { + var initializer = new DuckDbInitializer(_dbPath); + await initializer.InitializeAsync(); + + var connection = new DuckDBConnection($"Data Source={_dbPath}"); + await connection.OpenAsync(TestContext.Current.CancellationToken); + return connection; + } + + [Fact] + public async Task BatchedUpdate_DismissesMultipleAlertsInSingleStatement() + { + using var connection = await InitializeDatabaseAsync(); + + // Capture fixed timestamps to ensure insert and update match exactly + var timestamps = new DateTime[5]; + for (int i = 0; i < 5; i++) + timestamps[i] = DateTime.UtcNow.AddHours(-(i + 1)); + + // Insert 5 live alerts with the captured timestamps + for (int i = 0; i < 5; i++) + { + await _helper.InsertLiveAlertAsync( + connection, + timestamps[i], + 1, "Server1", $"Alert_{i}"); + } + + // Build a batched UPDATE matching the pattern used in DismissAlertsAsync + var valuesClauses = new System.Text.StringBuilder(); + var parameters = new List(); + for (int i = 0; i < 5; i++) + { + if (i > 0) valuesClauses.Append(", "); + var p1 = $"${i * 3 + 1}"; + var p2 = $"${i * 3 + 2}"; + var p3 = $"${i * 3 + 3}"; + valuesClauses.Append($"({p1}, {p2}, {p3})"); + parameters.Add(new DuckDBParameter { Value = timestamps[i] }); + parameters.Add(new DuckDBParameter { Value = 1 }); + parameters.Add(new DuckDBParameter { Value = $"Alert_{i}" }); + } + + using var cmd = connection.CreateCommand(); + cmd.CommandText = $@" +UPDATE config_alert_log +SET dismissed = TRUE +WHERE dismissed = FALSE +AND (alert_time, server_id, metric_name) IN (VALUES {valuesClauses})"; + foreach (var p in parameters) + cmd.Parameters.Add(p); + + var affected = await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + Assert.Equal(5, affected); + + // Verify all are dismissed + using var checkCmd = connection.CreateCommand(); + checkCmd.CommandText = "SELECT COUNT(1) FROM config_alert_log WHERE dismissed = FALSE"; + var remaining = Convert.ToInt64(await checkCmd.ExecuteScalarAsync(TestContext.Current.CancellationToken)); + Assert.Equal(0, remaining); + } + + [Fact] + public async Task BatchedUpdate_ReturnsCorrectCount_WhenSomeAlreadyDismissed() + { + using var connection = await InitializeDatabaseAsync(); + + // Insert 3 alerts, dismiss 1 beforehand + var time1 = DateTime.UtcNow.AddHours(-1); + var time2 = DateTime.UtcNow.AddHours(-2); + var time3 = DateTime.UtcNow.AddHours(-3); + + await _helper.InsertLiveAlertAsync(connection, time1, 1, "Server1", "Alert_A"); + await _helper.InsertLiveAlertAsync(connection, time2, 1, "Server1", "Alert_B"); + await _helper.InsertLiveAlertAsync(connection, time3, 1, "Server1", "Alert_C", dismissed: true); + + // Batch dismiss all 3 — only 2 should be affected (Alert_C already dismissed) + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +UPDATE config_alert_log +SET dismissed = TRUE +WHERE dismissed = FALSE +AND (alert_time, server_id, metric_name) IN (VALUES ($1, $2, $3), ($4, $5, $6), ($7, $8, $9))"; + cmd.Parameters.Add(new DuckDBParameter { Value = time1 }); + cmd.Parameters.Add(new DuckDBParameter { Value = 1 }); + cmd.Parameters.Add(new DuckDBParameter { Value = "Alert_A" }); + cmd.Parameters.Add(new DuckDBParameter { Value = time2 }); + cmd.Parameters.Add(new DuckDBParameter { Value = 1 }); + cmd.Parameters.Add(new DuckDBParameter { Value = "Alert_B" }); + cmd.Parameters.Add(new DuckDBParameter { Value = time3 }); + cmd.Parameters.Add(new DuckDBParameter { Value = 1 }); + cmd.Parameters.Add(new DuckDBParameter { Value = "Alert_C" }); + + var affected = await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, affected); + } + + [Fact] + public async Task Transaction_RollbackRestoresState() + { + using var connection = await InitializeDatabaseAsync(); + + await _helper.InsertLiveAlertAsync( + connection, DateTime.UtcNow.AddHours(-1), 1, "Server1", "High CPU"); + + // Begin transaction, dismiss, then rollback + using var beginCmd = connection.CreateCommand(); + beginCmd.CommandText = "BEGIN TRANSACTION"; + await beginCmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + + using var updateCmd = connection.CreateCommand(); + updateCmd.CommandText = @" +UPDATE config_alert_log +SET dismissed = TRUE +WHERE metric_name = 'High CPU' +AND dismissed = FALSE"; + var affected = await updateCmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + Assert.Equal(1, affected); + + using var rollbackCmd = connection.CreateCommand(); + rollbackCmd.CommandText = "ROLLBACK"; + await rollbackCmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + + // Alert should still be undismissed after rollback + using var checkCmd = connection.CreateCommand(); + checkCmd.CommandText = "SELECT COUNT(1) FROM config_alert_log WHERE dismissed = FALSE AND metric_name = 'High CPU'"; + var undismissed = Convert.ToInt64(await checkCmd.ExecuteScalarAsync(TestContext.Current.CancellationToken)); + Assert.Equal(1, undismissed); + } + + [Fact] + public async Task Transaction_CommitPersistsState() + { + using var connection = await InitializeDatabaseAsync(); + + await _helper.InsertLiveAlertAsync( + connection, DateTime.UtcNow.AddHours(-1), 1, "Server1", "High CPU"); + + // Begin transaction, dismiss, then commit + using var beginCmd = connection.CreateCommand(); + beginCmd.CommandText = "BEGIN TRANSACTION"; + await beginCmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + + using var updateCmd = connection.CreateCommand(); + updateCmd.CommandText = @" +UPDATE config_alert_log +SET dismissed = TRUE +WHERE metric_name = 'High CPU' +AND dismissed = FALSE"; + await updateCmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + + using var commitCmd = connection.CreateCommand(); + commitCmd.CommandText = "COMMIT"; + await commitCmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + + // Alert should be dismissed after commit + using var checkCmd = connection.CreateCommand(); + checkCmd.CommandText = "SELECT COUNT(1) FROM config_alert_log WHERE dismissed = TRUE AND metric_name = 'High CPU'"; + var dismissed = Convert.ToInt64(await checkCmd.ExecuteScalarAsync(TestContext.Current.CancellationToken)); + Assert.Equal(1, dismissed); + } + + [Fact] + public async Task WriteLock_BlocksReadersDuringDismiss() + { + var initializer = new DuckDbInitializer(_dbPath); + await initializer.InitializeAsync(); + + // Acquire write lock on this thread + using var writeLock = initializer.AcquireWriteLock(); + + // A second write lock with timeout from a different thread should throw TimeoutException + Exception? caughtException = null; + var thread = new System.Threading.Thread(() => + { + try + { + using var secondLock = initializer.AcquireWriteLock(timeout: TimeSpan.FromMilliseconds(50)); + } + catch (Exception ex) + { + caughtException = ex; + } + }); + thread.Start(); + thread.Join(2000); + + Assert.NotNull(caughtException); + Assert.IsType(caughtException); + Assert.Contains("could not acquire", caughtException.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task WriteLock_Timeout_ThrowsTimeoutException() + { + var initializer = new DuckDbInitializer(_dbPath); + await initializer.InitializeAsync(); + + // Simulate archival holding the write lock on a background thread + using var archivalLock = initializer.AcquireWriteLock(); + + // A concurrent dismiss attempt with timeout should throw TimeoutException + var lockAcquired = false; + var thread = new System.Threading.Thread(() => + { + try + { + using var dismissLock = initializer.AcquireWriteLock(timeout: TimeSpan.FromMilliseconds(100)); + lockAcquired = true; + } + catch (TimeoutException) + { + lockAcquired = false; + } + }); + thread.Start(); + thread.Join(2000); + + Assert.False(lockAcquired, "Dismiss should not acquire write lock while archival holds it"); + } + + [Fact] + public async Task DismissAll_UsesWriteLock() + { + using var connection = await InitializeDatabaseAsync(); + + // Insert alerts + await _helper.InsertLiveAlertAsync( + connection, DateTime.UtcNow.AddHours(-1), 1, "Server1", "High CPU"); + await _helper.InsertLiveAlertAsync( + connection, DateTime.UtcNow.AddHours(-2), 1, "Server1", "Blocking"); + + // DismissAll targets the live table — should work with write lock + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +UPDATE config_alert_log +SET dismissed = TRUE +WHERE dismissed = FALSE"; + var affected = await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, affected); + + // Verify all dismissed + using var checkCmd = connection.CreateCommand(); + checkCmd.CommandText = "SELECT COUNT(1) FROM config_alert_log WHERE dismissed = FALSE"; + var remaining = Convert.ToInt64(await checkCmd.ExecuteScalarAsync(TestContext.Current.CancellationToken)); + Assert.Equal(0, remaining); + } +} diff --git a/Lite/Controls/AlertsHistoryTab.xaml.cs b/Lite/Controls/AlertsHistoryTab.xaml.cs index e0cf06e0..e6f37f34 100644 --- a/Lite/Controls/AlertsHistoryTab.xaml.cs +++ b/Lite/Controls/AlertsHistoryTab.xaml.cs @@ -308,6 +308,14 @@ private async void DismissSelected_Click(object sender, RoutedEventArgs e) } await LoadAlertsAsync(); } + catch (TimeoutException) + { + MessageBox.Show( + "The database is currently busy (archival or maintenance in progress).\n\nPlease try again in a few moments.", + "Dismiss Unavailable", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } catch (Exception ex) { AppLogger.Error("AlertsHistory", $"Failed to dismiss selected alerts: {ex.Message}"); @@ -358,6 +366,14 @@ private async void DismissAll_Click(object sender, RoutedEventArgs e) } await LoadAlertsAsync(); } + catch (TimeoutException) + { + MessageBox.Show( + "The database is currently busy (archival or maintenance in progress).\n\nPlease try again in a few moments.", + "Dismiss Unavailable", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } catch (Exception ex) { AppLogger.Error("AlertsHistory", $"Failed to dismiss all alerts: {ex.Message}"); diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs index 6a6e62fa..93b93053 100644 --- a/Lite/Database/DuckDbInitializer.cs +++ b/Lite/Database/DuckDbInitializer.cs @@ -49,10 +49,21 @@ exception that prevented Dispose(). Since we're already protected by a read lock /// /// Acquires an exclusive write lock on the database. Blocks until all readers finish. /// Dispose the returned object to release the lock. + /// When a timeout is specified, throws if the lock + /// cannot be acquired within the given duration (e.g., archival is in progress). /// - public IDisposable AcquireWriteLock() + public IDisposable AcquireWriteLock(TimeSpan? timeout = null) { - s_dbLock.EnterWriteLock(); + if (timeout.HasValue) + { + if (!s_dbLock.TryEnterWriteLock(timeout.Value)) + throw new TimeoutException( + "Could not acquire database write lock — another operation (archival or maintenance) may be in progress. Please try again in a few moments."); + } + else + { + s_dbLock.EnterWriteLock(); + } return new LockReleaser(s_dbLock, write: true); } diff --git a/Lite/Database/LockedConnection.cs b/Lite/Database/LockedConnection.cs index 44e67769..46e8586c 100644 --- a/Lite/Database/LockedConnection.cs +++ b/Lite/Database/LockedConnection.cs @@ -13,20 +13,20 @@ namespace PerformanceMonitorLite.Database; /// -/// Wraps a DuckDBConnection with a read lock that is released when the connection is disposed. -/// Ensures UI reads hold the lock for their entire duration, preventing CHECKPOINT or compaction -/// from reorganizing the database file while a reader has stale file offsets. +/// Wraps a DuckDBConnection with a lock (read or write) that is released when the connection is disposed. +/// Ensures operations hold the lock for their entire duration, preventing CHECKPOINT or compaction +/// from reorganizing the database file while the connection is active. /// public sealed class LockedConnection : IDisposable, IAsyncDisposable { private readonly DuckDBConnection _connection; - private readonly IDisposable _readLock; + private readonly IDisposable _lock; private bool _disposed; - public LockedConnection(DuckDBConnection connection, IDisposable readLock) + public LockedConnection(DuckDBConnection connection, IDisposable @lock) { _connection = connection; - _readLock = readLock; + _lock = @lock; } /// @@ -40,7 +40,7 @@ public void Dispose() if (_disposed) return; _disposed = true; _connection.Dispose(); - _readLock.Dispose(); + _lock.Dispose(); } public async ValueTask DisposeAsync() @@ -48,6 +48,6 @@ public async ValueTask DisposeAsync() if (_disposed) return; _disposed = true; await _connection.DisposeAsync(); - _readLock.Dispose(); + _lock.Dispose(); } } diff --git a/Lite/Services/LocalDataService.AlertHistory.cs b/Lite/Services/LocalDataService.AlertHistory.cs index 764e77f8..bbc9f6e7 100644 --- a/Lite/Services/LocalDataService.AlertHistory.cs +++ b/Lite/Services/LocalDataService.AlertHistory.cs @@ -103,8 +103,9 @@ ORDER BY alert_time DESC /// /// Dismisses specific alerts by marking them as dismissed in DuckDB. - /// Identifies rows by (alert_time, server_id, metric_name) composite key. - /// If an alert only exists in archived parquet, inserts into dismissed_archive_alerts instead. + /// Uses a single batched UPDATE with an exclusive write lock and transaction + /// to prevent race conditions with archival and ensure all-or-nothing semantics. + /// If alerts only exist in archived parquet, inserts into dismissed_archive_alerts instead. /// Logs structured telemetry and verifies dismissal success. /// public async Task DismissAlertsAsync(List alerts) @@ -116,65 +117,101 @@ public async Task DismissAlertsAsync(List alerts) if (App.LogAlertDismissals) AppLogger.Info("AlertDismiss", $"Action=DismissSelected, Requested={alerts.Count}"); - using var connection = await OpenConnectionAsync(); - int totalAffected = 0; + using var connection = await OpenWriteConnectionAsync(); int archivedDismissed = 0; - foreach (var alert in alerts) + using var beginCmd = connection.CreateCommand(); + beginCmd.CommandText = "BEGIN TRANSACTION"; + await beginCmd.ExecuteNonQueryAsync(); + + try { + // Build a single batched UPDATE using VALUES list + var valuesClauses = new System.Text.StringBuilder(); + var parameters = new List(); + for (int i = 0; i < alerts.Count; i++) + { + if (i > 0) valuesClauses.Append(", "); + var p1 = $"${i * 3 + 1}"; + var p2 = $"${i * 3 + 2}"; + var p3 = $"${i * 3 + 3}"; + valuesClauses.Append($"({p1}, {p2}, {p3})"); + parameters.Add(new DuckDBParameter { Value = alerts[i].AlertTime }); + parameters.Add(new DuckDBParameter { Value = alerts[i].ServerId }); + parameters.Add(new DuckDBParameter { Value = alerts[i].MetricName }); + } + using var command = connection.CreateCommand(); - command.CommandText = @" + command.CommandText = $@" UPDATE config_alert_log SET dismissed = TRUE -WHERE alert_time = $1 -AND server_id = $2 -AND metric_name = $3 -AND dismissed = FALSE"; - command.Parameters.Add(new DuckDBParameter { Value = alert.AlertTime }); - command.Parameters.Add(new DuckDBParameter { Value = alert.ServerId }); - command.Parameters.Add(new DuckDBParameter { Value = alert.MetricName }); - var affected = await command.ExecuteNonQueryAsync(); - totalAffected += affected; +WHERE dismissed = FALSE +AND (alert_time, server_id, metric_name) IN (VALUES {valuesClauses})"; + foreach (var p in parameters) + command.Parameters.Add(p); - if (affected == 0) + var totalAffected = await command.ExecuteNonQueryAsync(); + + // Sidecar fallback for alerts that weren't in the live table (archived to parquet) + if (totalAffected < alerts.Count) { - using var sidecarCmd = connection.CreateCommand(); - sidecarCmd.CommandText = @" + foreach (var alert in alerts) + { + using var sidecarCmd = connection.CreateCommand(); + sidecarCmd.CommandText = @" INSERT INTO dismissed_archive_alerts (alert_time, server_id, metric_name) SELECT $1, $2, $3 WHERE NOT EXISTS ( + SELECT 1 FROM config_alert_log + WHERE alert_time = $1 AND server_id = $2 AND metric_name = $3 +) +AND NOT EXISTS ( SELECT 1 FROM dismissed_archive_alerts - WHERE alert_time = $1 - AND server_id = $2 - AND metric_name = $3 + WHERE alert_time = $1 AND server_id = $2 AND metric_name = $3 )"; - sidecarCmd.Parameters.Add(new DuckDBParameter { Value = alert.AlertTime }); - sidecarCmd.Parameters.Add(new DuckDBParameter { Value = alert.ServerId }); - sidecarCmd.Parameters.Add(new DuckDBParameter { Value = alert.MetricName }); - var sidecarAffected = await sidecarCmd.ExecuteNonQueryAsync(); - archivedDismissed += sidecarAffected; - - if (App.LogAlertDismissals) - AppLogger.Info("AlertDismiss", $"Action=DismissSelected, Result=SidecarInsert, AlertTime={alert.AlertTime:O}, ServerId={alert.ServerId}, Metric={alert.MetricName}"); + sidecarCmd.Parameters.Add(new DuckDBParameter { Value = alert.AlertTime }); + sidecarCmd.Parameters.Add(new DuckDBParameter { Value = alert.ServerId }); + sidecarCmd.Parameters.Add(new DuckDBParameter { Value = alert.MetricName }); + archivedDismissed += await sidecarCmd.ExecuteNonQueryAsync(); + } } - } - sw.Stop(); + using var commitCmd = connection.CreateCommand(); + commitCmd.CommandText = "COMMIT"; + await commitCmd.ExecuteNonQueryAsync(); - if (App.LogAlertDismissals) - AppLogger.Info("AlertDismiss", $"Action=DismissSelected, Result=Complete, Requested={alerts.Count}, LiveUpdated={totalAffected}, ArchivedDismissed={archivedDismissed}, Duration={sw.ElapsedMilliseconds}ms"); + sw.Stop(); - // Post-dismiss verification: confirm the dismissed rows are no longer visible - if (totalAffected > 0) + if (App.LogAlertDismissals) + AppLogger.Info("AlertDismiss", $"Action=DismissSelected, Result=Complete, Requested={alerts.Count}, LiveUpdated={totalAffected}, ArchivedDismissed={archivedDismissed}, Duration={sw.ElapsedMilliseconds}ms"); + + // Post-dismiss verification: confirm the dismissed rows are no longer visible + if (totalAffected > 0) + { + await VerifyDismissAsync(connection, alerts, totalAffected); + } + + return totalAffected + archivedDismissed; + } + catch { - await VerifyDismissAsync(connection, alerts, totalAffected); + try + { + using var rollbackCmd = connection.CreateCommand(); + rollbackCmd.CommandText = "ROLLBACK"; + await rollbackCmd.ExecuteNonQueryAsync(); + } + catch (Exception rbEx) + { + AppLogger.Error("AlertDismiss", $"Rollback failed: {rbEx.Message}"); + } + throw; } - - return totalAffected + archivedDismissed; } /// /// Dismisses all visible (non-dismissed) alerts matching the current filter criteria. + /// Uses an exclusive write lock to prevent race conditions with archival. /// Updates the live table, then inserts any remaining archived alerts into the sidecar table. /// Logs structured telemetry and verifies dismissal success. /// @@ -185,7 +222,7 @@ public async Task DismissAllVisibleAlertsAsync(int hoursBack, int? serverId if (App.LogAlertDismissals) AppLogger.Info("AlertDismiss", $"Action=DismissAll, HoursBack={hoursBack}, ServerId={serverId?.ToString() ?? "all"}"); - using var connection = await OpenConnectionAsync(); + using var connection = await OpenWriteConnectionAsync(); using var command = connection.CreateCommand(); var cutoff = DateTime.UtcNow.AddHours(-hoursBack); diff --git a/Lite/Services/LocalDataService.cs b/Lite/Services/LocalDataService.cs index d0aae7dc..a51394c9 100644 --- a/Lite/Services/LocalDataService.cs +++ b/Lite/Services/LocalDataService.cs @@ -1,119 +1,140 @@ -/* - * Copyright (c) 2026 Erik Darling, Darling Data LLC - * - * This file is part of the SQL Server Performance Monitor Lite. - * - * Licensed under the MIT License. See LICENSE file in the project root for full license information. - */ - -using System; -using System.Collections.Generic; -using System.Data; -using System.IO; -using System.Linq; -using System.Numerics; -using System.Threading.Tasks; -using DuckDB.NET.Data; -using PerformanceMonitorLite.Database; - -namespace PerformanceMonitorLite.Services; - -/// -/// Service for reading collected data from DuckDB. -/// Partial class - individual data type readers are in separate files. -/// -public partial class LocalDataService -{ - private readonly DuckDbInitializer _duckDb; - - public LocalDataService(DuckDbInitializer duckDb) - { - _duckDb = duckDb; - } - - /// - /// Creates and opens a DuckDB connection wrapped in a read lock. - /// The lock prevents CHECKPOINT and compaction from reorganizing the database file - /// while this connection is reading from it. - /// - internal async Task OpenConnectionAsync() - { - var readLock = _duckDb.AcquireReadLock(); - try - { - var connection = _duckDb.CreateConnection(); - await connection.OpenAsync(); - return new LockedConnection(connection, readLock); - } - catch - { - readLock.Dispose(); - throw; - } - } - - /// - /// Safely converts a DuckDB value to double, handling BigInteger from SUM aggregations. - /// - protected static double ToDouble(object value) - { - if (value is BigInteger bi) - return (double)bi; - return Convert.ToDouble(value); - } - - /// - /// Safely converts a DuckDB value to long, handling BigInteger from SUM/COUNT aggregations. - /// - protected static long ToInt64(object value) - { - if (value is BigInteger bi) - return (long)bi; - return Convert.ToInt64(value); - } - - /// - /// Gets the time range for queries based on hoursBack or explicit date range. - /// Returns UTC time for collection_time queries (most tables store collection_time in UTC). - /// When fromDate/toDate are provided, they should already be in UTC. - /// - protected static (DateTime startTime, DateTime endTime) GetTimeRange(int hoursBack, DateTime? fromDate, DateTime? toDate) - { - if (fromDate.HasValue && toDate.HasValue) - { - /* Custom date range - convert from server time back to UTC for storage lookup */ - var startUtc = fromDate.Value.AddMinutes(-ServerTimeHelper.UtcOffsetMinutes); - var endUtc = toDate.Value.AddMinutes(-ServerTimeHelper.UtcOffsetMinutes); - return (startUtc, endUtc); - } - - /* Use UTC directly since collection_time is stored in UTC */ - return (DateTime.UtcNow.AddHours(-hoursBack), DateTime.UtcNow); - } - - /// - /// Gets the time range in server local time (for tables like cpu_utilization_stats.sample_time). - /// - protected static (DateTime startTime, DateTime endTime) GetTimeRangeServerLocal(int hoursBack, DateTime? fromDate, DateTime? toDate) - { - var serverNow = DateTime.UtcNow.AddMinutes(ServerTimeHelper.UtcOffsetMinutes); - - if (fromDate.HasValue && toDate.HasValue) - { - /* fromDate/toDate are already in server time from the caller */ - return (fromDate.Value, toDate.Value); - } - - return (serverNow.AddHours(-hoursBack), serverNow); - } - - /// - /// Starts query timing for performance logging. Use with 'using' statement. - /// Only logs queries that exceed the slow query threshold (default 500ms). - /// - protected static Helpers.QueryExecutionContext TimeQuery(string context, string sql) - { - return Helpers.QueryLogger.StartQuery(context, sql, source: "DuckDB"); - } - -} +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using System.Linq; +using System.Numerics; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitorLite.Database; + +namespace PerformanceMonitorLite.Services; + +/// +/// Service for reading collected data from DuckDB. +/// Partial class - individual data type readers are in separate files. +/// +public partial class LocalDataService +{ + private readonly DuckDbInitializer _duckDb; + + public LocalDataService(DuckDbInitializer duckDb) + { + _duckDb = duckDb; + } + + /// + /// Creates and opens a DuckDB connection wrapped in a read lock. + /// The lock prevents CHECKPOINT and compaction from reorganizing the database file + /// while this connection is reading from it. + /// + internal async Task OpenConnectionAsync() + { + var readLock = _duckDb.AcquireReadLock(); + try + { + var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + return new LockedConnection(connection, readLock); + } + catch + { + readLock.Dispose(); + throw; + } + } + + /// + /// Creates and opens a DuckDB connection wrapped in an exclusive write lock. + /// Use for UPDATE/DELETE/INSERT operations that must not race with archival or compaction. + /// A 5-second timeout prevents UI freeze if archival currently holds the lock. + /// + internal async Task OpenWriteConnectionAsync() + { + var writeLock = _duckDb.AcquireWriteLock(timeout: TimeSpan.FromSeconds(5)); + try + { + var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + return new LockedConnection(connection, writeLock); + } + catch + { + writeLock.Dispose(); + throw; + } + } + + /// + /// Safely converts a DuckDB value to double, handling BigInteger from SUM aggregations. + /// + protected static double ToDouble(object value) + { + if (value is BigInteger bi) + return (double)bi; + return Convert.ToDouble(value); + } + + /// + /// Safely converts a DuckDB value to long, handling BigInteger from SUM/COUNT aggregations. + /// + protected static long ToInt64(object value) + { + if (value is BigInteger bi) + return (long)bi; + return Convert.ToInt64(value); + } + + /// + /// Gets the time range for queries based on hoursBack or explicit date range. + /// Returns UTC time for collection_time queries (most tables store collection_time in UTC). + /// When fromDate/toDate are provided, they should already be in UTC. + /// + protected static (DateTime startTime, DateTime endTime) GetTimeRange(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + if (fromDate.HasValue && toDate.HasValue) + { + /* Custom date range - convert from server time back to UTC for storage lookup */ + var startUtc = fromDate.Value.AddMinutes(-ServerTimeHelper.UtcOffsetMinutes); + var endUtc = toDate.Value.AddMinutes(-ServerTimeHelper.UtcOffsetMinutes); + return (startUtc, endUtc); + } + + /* Use UTC directly since collection_time is stored in UTC */ + return (DateTime.UtcNow.AddHours(-hoursBack), DateTime.UtcNow); + } + + /// + /// Gets the time range in server local time (for tables like cpu_utilization_stats.sample_time). + /// + protected static (DateTime startTime, DateTime endTime) GetTimeRangeServerLocal(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + var serverNow = DateTime.UtcNow.AddMinutes(ServerTimeHelper.UtcOffsetMinutes); + + if (fromDate.HasValue && toDate.HasValue) + { + /* fromDate/toDate are already in server time from the caller */ + return (fromDate.Value, toDate.Value); + } + + return (serverNow.AddHours(-hoursBack), serverNow); + } + + /// + /// Starts query timing for performance logging. Use with 'using' statement. + /// Only logs queries that exceed the slow query threshold (default 500ms). + /// + protected static Helpers.QueryExecutionContext TimeQuery(string context, string sql) + { + return Helpers.QueryLogger.StartQuery(context, sql, source: "DuckDB"); + } + +}