Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions Lite.Tests/MuteRulesSurviveResetTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
using System;
using System.IO;
using System.Threading.Tasks;
using DuckDB.NET.Data;
using PerformanceMonitorLite.Database;
using PerformanceMonitorLite.Services;
using Xunit;

namespace PerformanceMonitorLite.Tests;

/// <summary>
/// Issue #938 — mute rules (especially expires_at_utc = NULL "permanent" rules)
/// were silently lost when ArchiveAllAndResetAsync fired due to the 512 MB size threshold.
/// The reset deletes monitor.duckdb outright, and config_mute_rules was not preserved.
/// </summary>
public class MuteRulesSurviveResetTests : IDisposable
{
private readonly string _tempDir;
private readonly string _dbPath;
private readonly string _archiveDir;

public MuteRulesSurviveResetTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "LiteTests_" + Guid.NewGuid().ToString("N")[..8]);
Directory.CreateDirectory(_tempDir);
_dbPath = Path.Combine(_tempDir, "test.duckdb");
_archiveDir = Path.Combine(_tempDir, "archive");
Directory.CreateDirectory(_archiveDir);
}

public void Dispose()
{
try
{
if (Directory.Exists(_tempDir))
Directory.Delete(_tempDir, recursive: true);
}
catch
{
/* Best-effort cleanup */
}
}

[Fact]
public async Task PermanentMuteRule_SurvivesArchiveAllAndReset()
{
var initializer = new DuckDbInitializer(_dbPath);
await initializer.InitializeAsync();

var ruleId = Guid.NewGuid().ToString();
var createdAt = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);

await InsertMuteRuleAsync(ruleId, createdAt, expiresAtUtc: null,
serverName: "ProdSql01", metricName: "Blocking Detected");

var archiveService = new ArchiveService(initializer, _archiveDir);
await archiveService.ArchiveAllAndResetAsync();

var (count, expiresIsNull, serverName) = await ReadMuteRuleAsync(ruleId);

Assert.Equal(1, count);
Assert.True(expiresIsNull);
Assert.Equal("ProdSql01", serverName);
}

[Fact]
public async Task ExpiringMuteRule_SurvivesArchiveAllAndReset()
{
var initializer = new DuckDbInitializer(_dbPath);
await initializer.InitializeAsync();

var ruleId = Guid.NewGuid().ToString();
var createdAt = DateTime.UtcNow;
var expiresAt = createdAt.AddDays(7);

await InsertMuteRuleAsync(ruleId, createdAt, expiresAt,
serverName: "ProdSql02", metricName: "Long-Running Job");

var archiveService = new ArchiveService(initializer, _archiveDir);
await archiveService.ArchiveAllAndResetAsync();

var (count, expiresIsNull, serverName) = await ReadMuteRuleAsync(ruleId);

Assert.Equal(1, count);
Assert.False(expiresIsNull);
Assert.Equal("ProdSql02", serverName);
}

[Fact]
public async Task EmptyMuteRulesTable_DoesNotBreakReset()
{
var initializer = new DuckDbInitializer(_dbPath);
await initializer.InitializeAsync();

var archiveService = new ArchiveService(initializer, _archiveDir);
await archiveService.ArchiveAllAndResetAsync();

using var connection = new DuckDBConnection($"Data Source={_dbPath}");
await connection.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM config_mute_rules";
var count = Convert.ToInt64(await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken));

Assert.Equal(0, count);
}

private async Task InsertMuteRuleAsync(
string id,
DateTime createdAt,
DateTime? expiresAtUtc,
string serverName,
string metricName)
{
using var connection = new DuckDBConnection($"Data Source={_dbPath}");
await connection.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
INSERT INTO config_mute_rules
(id, enabled, created_at_utc, expires_at_utc, reason,
server_name, metric_name, database_pattern,
query_text_pattern, wait_type_pattern, job_name_pattern)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)";
cmd.Parameters.Add(new DuckDBParameter { Value = id });
cmd.Parameters.Add(new DuckDBParameter { Value = true });
cmd.Parameters.Add(new DuckDBParameter { Value = createdAt });
cmd.Parameters.Add(new DuckDBParameter { Value = (object?)expiresAtUtc ?? DBNull.Value });
cmd.Parameters.Add(new DuckDBParameter { Value = "test rule" });
cmd.Parameters.Add(new DuckDBParameter { Value = serverName });
cmd.Parameters.Add(new DuckDBParameter { Value = metricName });
cmd.Parameters.Add(new DuckDBParameter { Value = DBNull.Value });
cmd.Parameters.Add(new DuckDBParameter { Value = DBNull.Value });
cmd.Parameters.Add(new DuckDBParameter { Value = DBNull.Value });
cmd.Parameters.Add(new DuckDBParameter { Value = DBNull.Value });
await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
}

private async Task<(int Count, bool ExpiresIsNull, string ServerName)> ReadMuteRuleAsync(string id)
{
using var connection = new DuckDBConnection($"Data Source={_dbPath}");
await connection.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT expires_at_utc, server_name FROM config_mute_rules WHERE id = $1";
cmd.Parameters.Add(new DuckDBParameter { Value = id });

using var reader = await cmd.ExecuteReaderAsync(TestContext.Current.CancellationToken);
if (!await reader.ReadAsync(TestContext.Current.CancellationToken))
return (0, false, "");

var expiresIsNull = reader.IsDBNull(0);
var serverName = reader.GetString(1);
return (1, expiresIsNull, serverName);
}
}
87 changes: 86 additions & 1 deletion Lite/Services/ArchiveService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ public static bool IsArchiving
private set => s_isArchiving = value;
}

/* Config tables that must be preserved through ArchiveAllAndResetAsync.
These hold user configuration (not time-series) and must survive when the
size threshold trips a database reset. Issue #938 — permanent mute rules
were silently lost because ResetDatabaseAsync deletes monitor.duckdb. */
private static readonly string[] PreservedConfigTables =
[
"config_mute_rules",
"dismissed_archive_alerts"
];

/* Tables eligible for archival with their time column.
IMPORTANT: Every table with time-series data must be listed here,
or it will grow unbounded and push the DB past the 512 MB reset threshold. */
Expand Down Expand Up @@ -502,12 +512,16 @@ public async Task ArchiveAllAndResetAsync()
}

IsArchiving = true;
var preserveDir = Path.Combine(Path.GetTempPath(), $"pm_preserve_{Guid.NewGuid():N}");
var preservedFiles = new Dictionary<string, string>();
try
{
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmm");

_logger?.LogInformation("Archiving ALL data to Parquet (prefix: {Timestamp}) and resetting database", timestamp);

Directory.CreateDirectory(preserveDir);

/* Export everything under write lock */
using (_duckDb.AcquireWriteLock())
{
Expand Down Expand Up @@ -541,6 +555,32 @@ Archive views use glob (*_table.parquet) to pick up all files. */
_logger?.LogError(ex, "Failed to archive table {Table}", table);
}
}

/* Preserve config tables that must survive the reset (issue #938).
Written to a temp dir, not the archive dir — these are restored
into the new database, not exposed via archive views. */
foreach (var table in PreservedConfigTables)
{
try
{
using var countCmd = connection.CreateCommand();
countCmd.CommandText = $"SELECT COUNT(*) FROM {table}";
var rowCount = Convert.ToInt64(await countCmd.ExecuteScalarAsync());
if (rowCount == 0) continue;

var preservePath = Path.Combine(preserveDir, $"{table}.parquet").Replace("\\", "/");
using var exportCmd = connection.CreateCommand();
exportCmd.CommandText = $"COPY (SELECT * FROM {table}) TO '{EscapeSqlPath(preservePath)}' (FORMAT PARQUET)";
await exportCmd.ExecuteNonQueryAsync();
preservedFiles[table] = preservePath;

_logger?.LogInformation("Preserved {Count} rows from {Table} for restoration after reset", rowCount, table);
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to preserve {Table} before reset — rows will be lost", table);
}
}
}

/* Compact per-cycle files into monthly parquet files before reset.
Expand All @@ -553,11 +593,56 @@ and only touches filesystem files — no contention with collectors. */
_logger?.LogInformation("Deleting and reinitializing database");
await _duckDb.ResetDatabaseAsync();

/* Restore preserved config rows into the freshly initialized tables. */
var allRestoresSucceeded = true;
if (preservedFiles.Count > 0)
{
using (_duckDb.AcquireWriteLock())
{
using var connection = _duckDb.CreateConnection();
await connection.OpenAsync();
foreach (var (table, path) in preservedFiles)
{
try
{
using var insertCmd = connection.CreateCommand();
insertCmd.CommandText = $"INSERT INTO {table} SELECT * FROM read_parquet('{EscapeSqlPath(path)}')";
await insertCmd.ExecuteNonQueryAsync();
_logger?.LogInformation("Restored rows to {Table} after database reset", table);
}
catch (Exception ex)
{
allRestoresSucceeded = false;
_logger?.LogError(ex, "Failed to restore {Table} from {Path} — preservation files retained for manual recovery", table, path);
}
}
}
}

_logger?.LogInformation("Database reset complete — archive views now serve all historical data from Parquet");

/* Clean up temp preservation dir only if every restore succeeded.
On failure, leave the parquet files so the user can recover manually. */
if (allRestoresSucceeded)
{
try
{
if (Directory.Exists(preserveDir))
Directory.Delete(preserveDir, recursive: true);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Could not clean up preservation temp dir {Dir}", preserveDir);
}
}
else
{
_logger?.LogWarning("Preservation files retained at {Dir} for manual recovery", preserveDir);
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "Archive-all-and-reset failed");
_logger?.LogError(ex, "Archive-all-and-reset failed — preservation files (if any) retained at {Dir}", preserveDir);
}
finally
{
Expand Down
Loading