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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **Lite UI no longer freezes during archival** ([#979]) — archival held DuckDB's exclusive write lock across the entire export-to-Parquet step, blocking every UI query (tab switches showed the spinning wheel, worse with more monitored servers). Export-to-Parquet only reads the database, so it now runs under a shared read lock concurrently with the UI; only the brief `DELETE` takes the exclusive write lock
- **Lite FinOps no longer recommends an edition downgrade on an Availability Group secondary** ([#980]) — the licensing recommendations suggested "downgrade to Standard to save $X/mo" for any Enterprise instance, with no AG awareness. On a secondary replica that advice is misleading — every replica in an AG must run the same edition. FinOps now detects the AG replica role and, on a secondary, shows an informational note instead of the downgrade/savings estimate
- **Lite alert emails no longer re-fire after an app restart** ([#981]) — the per-metric email cooldown lived only in memory, so restarting Lite cleared it and an alert sent minutes earlier could be sent again immediately. The cooldown is now seeded from `config_alert_log` (the most recent successful send for that server/metric) the first time each alert is evaluated, so it survives restarts
- **Data Retention job no longer fails with `xp_delete_file` error 22049** ([#972]) — the trace-file cleanup added in v2.11.0 passed a wildcard path to `xp_delete_file`, raising an uncatchable `Msg 22049` that failed the entire `PerformanceMonitor - Data Retention` Agent job on every run once any `Monitor_LongQueries_*.trc` files existed. `xp_delete_file` also cannot delete `.trc` files at all — it only accepts SQL Server backup files and Maintenance Plan report files — so that cleanup step has been removed from `config.data_retention`

### Changed
Expand All @@ -24,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#972]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/972
[#979]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/979
[#980]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/980
[#981]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/981

## [2.11.0] - 2026-05-19

Expand Down
64 changes: 64 additions & 0 deletions Lite/Services/EmailAlertService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ public async Task TrySendAlertEmailAsync(
!string.IsNullOrWhiteSpace(App.SmtpRecipients))
{
var cooldownKey = $"{serverId}:{metricName}";

/* Seed the in-memory cooldown from config_alert_log the first
time this key is seen, so an alert email sent shortly before
an app restart is not immediately re-sent afterward (#981).
The in-memory dictionary is authoritative once seeded. */
if (!_cooldowns.ContainsKey(cooldownKey))
{
var lastPersistedSend = await GetLastEmailSentUtcAsync(serverId, metricName);
if (lastPersistedSend.HasValue)
{
_cooldowns.TryAdd(cooldownKey, lastPersistedSend.Value);
}
}

var withinCooldown = _cooldowns.TryGetValue(cooldownKey, out var lastSent) &&
DateTime.UtcNow - lastSent < TimeSpan.FromMinutes(App.EmailCooldownMinutes);

Expand Down Expand Up @@ -140,6 +154,56 @@ await LogAlertAsync(serverId, serverName, metricName,
}
}

/// <summary>
/// Returns the UTC time the most recent alert email was successfully sent
/// for this server/metric, read from config_alert_log — or null if none.
/// Used to seed the in-memory cooldown after an app restart (#981).
/// </summary>
private async Task<DateTime?> GetLastEmailSentUtcAsync(int serverId, string metricName)
{
try
{
/* Use injected initializer, fall back to creating one from App.DatabasePath */
var duckDb = _duckDb;
if (duckDb == null)
{
var dbPath = App.DatabasePath;
if (string.IsNullOrEmpty(dbPath)) return null;
duckDb = new DuckDbInitializer(dbPath);
}

using var readLock = duckDb.AcquireReadLock();
using var connection = duckDb.CreateConnection();
await connection.OpenAsync();

using var command = connection.CreateCommand();
/* A successful email send is logged with a notification_type of
'email' / 'email+webhook' and a null send_error — that mirrors
exactly when _cooldowns is updated after SendEmailAsync. */
command.CommandText = @"
SELECT MAX(alert_time)
FROM config_alert_log
WHERE server_id = $1
AND metric_name = $2
AND notification_type IN ('email', 'email+webhook')
AND send_error IS NULL";
command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = serverId });
command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = metricName });

var result = await command.ExecuteScalarAsync();
if (result == null || result == DBNull.Value) return null;

/* alert_time is written as DateTime.UtcNow; tag it UTC so the kind
is explicit (the cooldown subtraction is tick math regardless). */
return DateTime.SpecifyKind(Convert.ToDateTime(result), DateTimeKind.Utc);
}
catch (Exception ex)
{
AppLogger.Error("EmailAlert", $"Could not read persisted alert cooldown: {ex.Message}");
return null;
}
}

/// <summary>
/// Sends a test email to verify SMTP configuration.
/// Returns null on success, or the error message on failure.
Expand Down
Loading