diff --git a/CHANGELOG.md b/CHANGELOG.md index a9b60fa..abc74ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/Lite/Services/EmailAlertService.cs b/Lite/Services/EmailAlertService.cs index c895b9f..cba4403 100644 --- a/Lite/Services/EmailAlertService.cs +++ b/Lite/Services/EmailAlertService.cs @@ -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); @@ -140,6 +154,56 @@ await LogAlertAsync(serverId, serverName, metricName, } } + /// + /// 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). + /// + private async Task 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; + } + } + /// /// Sends a test email to verify SMTP configuration. /// Returns null on success, or the error message on failure.