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");
+ }
+
+}