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
167 changes: 167 additions & 0 deletions Darling/Darling.Tests/CustomAlertLastFiredLiveTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using System.Threading;
using System.Threading.Tasks;
using Npgsql;
using NpgsqlTypes;
using PerformanceMonitor.Darling.Service;
using PerformanceMonitor.Darling.Storage;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// #3360 gated live round-trips (DARLING_TEST_PG) for the "last fired" correlation the rule LIST read
/// (<see cref="CustomAlertRuleStore.ListSql"/>) adds. The correlation is pure SQL — a LEFT JOIN of the rule set to
/// a grouped <c>config_alert_log</c> subquery on the immutable <c>Custom:&lt;id&gt;</c> fire key — so it can only be
/// exercised against a real Postgres: a fire row counts, a resolve row for the SAME rule does NOT (it carries a
/// different <c>metric_name</c>), one rule's fires never bleed into another's, and a never-fired rule reports null,
/// all resolved in ONE list read. All test rows are tagged with a per-run prefix / server name and deleted in
/// cleanup, so the shared store is left as it was found.
/// </summary>
[Collection("live-postgres")]
public sealed class CustomAlertLastFiredLiveTests
{
private const string SampleDefinition =
"{\"metric\":{\"source\":\"wait_stats\",\"measure\":\"wait_time_ms\",\"aggregate\":\"sum\",\"hours\":0.25},\"predicate\":{\"op\":\"ge\",\"warnThreshold\":1}}";

private static string RequireLivePostgres()
{
var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
Assert.SkipWhen(string.IsNullOrEmpty(connectionString),
"Set DARLING_TEST_PG to a Postgres connection string (owner/superuser) to run the custom-alert last-fired live tests.");
return connectionString!;
}

private static async Task<NpgsqlDataSource> MigrateAndOpenAsync(string connectionString, CancellationToken ct)
{
await using (var migrate = new NpgsqlConnection(connectionString))
{
await migrate.OpenAsync(ct);
await PgMigrations.MigrateAsync(migrate, ct);
}

var dataSourceConnectionString = new NpgsqlConnectionStringBuilder(connectionString)
{
SearchPath = "collect,config,public",
}.ConnectionString;

return NpgsqlDataSource.Create(dataSourceConnectionString);
}

/// <summary>Inserts ONE <c>config_alert_log</c> row with the given metric_name + naive-UTC alert_time, tagged
/// with <paramref name="serverName"/> so cleanup can delete exactly this run's rows. The other columns are the
/// minimal valid no-channel shape (the correlation reads only metric_name + alert_time).</summary>
private static async Task InsertAlertLogAsync(
NpgsqlDataSource dataSource, string serverName, string metricName, DateTime alertTimeUtc, CancellationToken ct)
{
await using var command = dataSource.CreateCommand(@"
INSERT INTO config_alert_log
(alert_time, server_id, server_name, metric_name, current_value, threshold_value,
alert_sent, notification_type, send_error, muted, detail_text, context_json)
VALUES ($1, 1, $2, $3, 0, 0, false, 'none', NULL, false, NULL, NULL)");
command.Parameters.Add(new NpgsqlParameter
{
NpgsqlDbType = NpgsqlDbType.Timestamp,
Value = DateTime.SpecifyKind(alertTimeUtc, DateTimeKind.Unspecified),
});
command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = serverName });
command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = metricName });
await command.ExecuteNonQueryAsync(ct);
}

private static async Task<CustomAlertRule> CreateRuleAsync(
CustomAlertRuleStore store, string name, CancellationToken ct)
{
var result = Assert.IsType<CustomAlertRuleResult.Ok>(
await store.CreateAsync(name, description: null, SampleDefinition, enabled: true, "lastfiredtest", ct));
return result.Rule!;
}

[Fact]
public async Task ListAsync_ReportsLastFired_PerRule_ExcludingResolveRows_InOneRead()
{
var connectionString = RequireLivePostgres();
var ct = TestContext.Current.CancellationToken;
await using var dataSource = await MigrateAndOpenAsync(connectionString, ct);

var store = new CustomAlertRuleStore(dataSource);
var prefix = "carlf_" + Guid.NewGuid().ToString("N") + "_";
var bodySucceeded = false;
try
{
// Three rules: alpha (fires twice, then a resolve), bravo (fires once), charlie (never fires).
var alpha = await CreateRuleAsync(store, prefix + "alpha", ct);
var bravo = await CreateRuleAsync(store, prefix + "bravo", ct);
var charlie = await CreateRuleAsync(store, prefix + "charlie", ct);

var t0 = new DateTime(2026, 1, 2, 3, 0, 0);
var alphaFire1 = t0; // alpha first fire
var bravoFire = t0.AddMinutes(5); // bravo's only fire
var alphaFire2 = t0.AddMinutes(10); // alpha's LATER fire -> MAX for alpha
var alphaResolveByName = t0.AddMinutes(20); // resolve row for alpha (different metric_name) -> excluded
var alphaResolveLikeKey = t0.AddMinutes(30); // 'Custom:<id> Resolved' -> matches LIKE but NOT the join key

// alpha's fires (on its immutable fire key) — the later one is the answer.
await InsertAlertLogAsync(dataSource, prefix, CustomAlertEvaluator.MetricNameFor(alpha.Id), alphaFire1, ct);
await InsertAlertLogAsync(dataSource, prefix, CustomAlertEvaluator.MetricNameFor(alpha.Id), alphaFire2, ct);

// A NATURAL resolve for alpha: metric_name '<name> Resolved' (DeliverResolveAsync). It is LATER than the
// last fire, so if it were mistaken for a fire alpha's last_fired would be wrong. It must be excluded —
// it does not even match LIKE 'Custom:%'.
await InsertAlertLogAsync(dataSource, prefix, alpha.Name + " Resolved", alphaResolveByName, ct);

// An ADVERSARIAL row 'Custom:<id> Resolved': it DOES match the LIKE 'Custom:%' pre-filter, and is the
// latest row of all, but is not EXACTLY 'Custom:<id>', so the join key must reject it. This proves the
// exact join (not the LIKE) is what correlates a fire.
await InsertAlertLogAsync(
dataSource, prefix, CustomAlertEvaluator.MetricNameFor(alpha.Id) + " Resolved", alphaResolveLikeKey, ct);

// bravo fires once — its own key only.
await InsertAlertLogAsync(dataSource, prefix, CustomAlertEvaluator.MetricNameFor(bravo.Id), bravoFire, ct);

// charlie: no rows at all.

// ONE read resolves last_fired for every rule (never a per-rule query).
var summaries = await store.ListAsync(ct);
var alphaSummary = Assert.Single(summaries, s => s.Id == alpha.Id);
var bravoSummary = Assert.Single(summaries, s => s.Id == bravo.Id);
var charlieSummary = Assert.Single(summaries, s => s.Id == charlie.Id);

// alpha: MAX over its FIRE rows only — the second fire, NOT the (later) resolve rows.
Assert.NotNull(alphaSummary.LastFired);
Assert.Equal(alphaFire2, alphaSummary.LastFired!.Value);

// bravo: its own single fire — alpha's fires do not bleed in.
Assert.NotNull(bravoSummary.LastFired);
Assert.Equal(bravoFire, bravoSummary.LastFired!.Value);

// charlie: never fired -> null.
Assert.Null(charlieSummary.LastFired);

bodySucceeded = true;
}
finally
{
await LiveStoreCleanup.RunAsync(connectionString, bodySucceeded, async (cleanup, cleanupCt) =>
{
using (var deleteLog = new NpgsqlCommand("DELETE FROM config.config_alert_log WHERE server_name = $1", cleanup))
{
deleteLog.Parameters.Add(new NpgsqlParameter<string> { TypedValue = prefix });
await deleteLog.ExecuteNonQueryAsync(cleanupCt);
}

using var deleteRules = new NpgsqlCommand("DELETE FROM config.custom_alert_rules WHERE name LIKE $1", cleanup);
deleteRules.Parameters.Add(new NpgsqlParameter<string> { TypedValue = prefix + "%" });
await deleteRules.ExecuteNonQueryAsync(cleanupCt);
});
}
}
}
26 changes: 25 additions & 1 deletion Darling/Darling.Tests/DarlingWebEndpointsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ public void BuildRuleSummariesNode_IsABareArray_WithEnabled_AndNoDefinitionBody(
var summaries = new List<CustomAlertRuleSummary>
{
new(Id: 11, Name: "blocking", Description: null, Enabled: true, Version: 2,
UpdatedAt: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), UpdatedBy: "mcp"),
UpdatedAt: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), UpdatedBy: "mcp", LastFired: null),
};

var array = DarlingWebEndpoints.BuildRuleSummariesNode(summaries);
Expand All @@ -270,4 +270,28 @@ public void BuildRuleSummariesNode_IsABareArray_WithEnabled_AndNoDefinitionBody(
Assert.True((bool)only["enabled"]!);
Assert.False(only.ContainsKey("definition")); // the list projection never carries the definition body
}

[Fact]
public void BuildRuleSummariesNode_CarriesLastFired_OrNullWhenNeverFired()
{
var firedAt = new DateTime(2026, 3, 4, 5, 6, 7, DateTimeKind.Utc);
var summaries = new List<CustomAlertRuleSummary>
{
new(Id: 1, Name: "fired", Description: null, Enabled: true, Version: 1,
UpdatedAt: firedAt, UpdatedBy: null, LastFired: firedAt),
new(Id: 2, Name: "never", Description: null, Enabled: true, Version: 1,
UpdatedAt: firedAt, UpdatedBy: null, LastFired: null),
};

var array = DarlingWebEndpoints.BuildRuleSummariesNode(summaries);

var fired = Assert.IsType<JsonObject>(array[0]);
Assert.True(fired.ContainsKey("last_fired"));
Assert.Equal(firedAt, fired["last_fired"]!.GetValue<DateTime>());

// The key is ALWAYS present (a stable wire shape) — a never-fired rule carries JSON null, not an absent key.
var never = Assert.IsType<JsonObject>(array[1]);
Assert.True(never.ContainsKey("last_fired"));
Assert.Null(never["last_fired"]);
}
}
59 changes: 52 additions & 7 deletions Darling/PerformanceMonitor.Darling.Service/CustomAlertRuleStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,50 @@ public CustomAlertRuleStore(NpgsqlDataSource dataSource)
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}

/// <summary>The lightweight list read: the (potentially large) <c>definition</c> body is never selected.</summary>
/// <summary>
/// The lightweight list read: the (potentially large) <c>definition</c> body is never selected. LEFT JOINs
/// each rule to when it LAST FIRED (#3360) — <c>MAX(alert_time)</c> over the <c>config_alert_log</c> rows keyed
/// on the rule's IMMUTABLE fire key <c>'Custom:' || id</c> (<see cref="CustomAlertEvaluator.MetricNameFor"/>).
///
/// <para>The correlation is that EXACT fire key only. A resolve / teardown row is written under a DIFFERENT
/// <c>metric_name</c> — <c>'&lt;display name&gt; Resolved'</c> (<c>CustomAlertEvaluator.DeliverResolveAsync</c> /
/// <c>WriteTeardownResolutionAsync</c>) — which can never equal <c>'Custom:&lt;numeric id&gt;'</c>, so a resolve
/// is never counted as a fire and one rule's fires never bleed into another's. The <c>LIKE 'Custom:%'</c> is only
/// a pre-filter that shrinks the grouped set; the <c>= 'Custom:' || id</c> join key is what actually correlates,
/// so even a (contrived) row like <c>'Custom:5 Resolved'</c> — which passes the LIKE — does NOT join to rule 5.</para>
///
/// <para>Every fire DELIVERY writes a <c>Custom:&lt;id&gt;</c> row regardless of mute / channel (the deliver path
/// records the incident even when muted or channel-less), so <c>MAX(alert_time)</c> over those rows means "the
/// last time the rule's condition fired (the incident was recorded)" — the intended meaning of "last fired" —
/// and the read is deliberately NOT filtered on <c>alert_sent</c> / <c>notification_type</c>. It is ONE
/// round-trip: the grouped subquery scans the fire rows once and joins to the whole rule set, never a per-rule
/// query. The <c>idx_config_alert_log_time (server_id, metric_name, alert_time)</c> index leads with
/// <c>server_id</c>, so this deliberately cross-server aggregate scans rather than seeks — acceptable on this
/// cold, operator-triggered list render over the retention-bounded history table (the per-(server, metric)
/// cooldown seeds still seek that index). A rule that has never fired LEFT-JOINs to a NULL <c>last_fired</c>.</para>
/// </summary>
public const string ListSql = @"
SELECT id, name, description, enabled, version, updated_at, updated_by
FROM custom_alert_rules
ORDER BY name";
SELECT
r.id,
r.name,
r.description,
r.enabled,
r.version,
r.updated_at,
r.updated_by,
f.last_fired
FROM custom_alert_rules AS r
LEFT JOIN
(
SELECT
metric_name,
MAX(alert_time) AS last_fired
FROM config_alert_log
WHERE metric_name LIKE 'Custom:%'
GROUP BY metric_name
) AS f
ON f.metric_name = 'Custom:' || r.id
ORDER BY r.name";

/// <summary>The full single-rule read (includes <c>definition</c>). $1 id.</summary>
public const string GetSql = @"
Expand Down Expand Up @@ -195,7 +234,9 @@ public async Task<IReadOnlyList<CustomAlertRuleSummary>> ListAsync(CancellationT
reader.GetBoolean(3),
reader.GetInt32(4),
reader.GetDateTime(5),
reader.IsDBNull(6) ? null : reader.GetString(6)));
reader.IsDBNull(6) ? null : reader.GetString(6),
// last_fired: naive-UTC like updated_at (timestamp column -> Kind=Unspecified), NULL when never fired.
reader.IsDBNull(7) ? (DateTime?)null : reader.GetDateTime(7)));
}

return results;
Expand Down Expand Up @@ -613,15 +654,19 @@ public sealed record CustomAlertRule(
DateTime UpdatedAt,
string? UpdatedBy);

/// <summary>The lightweight list projection — every field except the <c>definition</c> body.</summary>
/// <summary>The lightweight list projection — every field except the <c>definition</c> body. <see cref="LastFired"/>
/// (#3360) is the UTC instant this rule most recently FIRED, or null when it never has — see
/// <see cref="CustomAlertRuleStore.ListSql"/> for how it is correlated on the immutable <c>Custom:&lt;id&gt;</c>
/// fire key (resolve rows excluded).</summary>
public sealed record CustomAlertRuleSummary(
long Id,
string Name,
string? Description,
bool Enabled,
int Version,
DateTime UpdatedAt,
string? UpdatedBy);
string? UpdatedBy,
DateTime? LastFired);

/// <summary>
/// The discriminated outcome of a store operation. Closed (the private base constructor blocks external
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2260,7 +2260,10 @@ enabled state is an enabled rule (the real full-document editor always sends the
};

/// <summary>The bare-array list wire shape (no definition body), carrying <c>enabled</c> so the list can
/// badge a paused rule without fetching each full definition — mirrors the MCP list surface.</summary>
/// badge a paused rule without fetching each full definition — plus <c>last_fired</c> (#3360), the naive-UTC
/// ISO-8601 instant the rule most recently fired (JSON <c>null</c> when it never has) so a card can show
/// "last fired …" / "never fired" without a per-rule read. Mirrors the MCP list surface (they share this
/// builder AND <see cref="CustomAlertRuleSummary"/>, so the field reaches both surfaces identically).</summary>
internal static JsonArray BuildRuleSummariesNode(IReadOnlyList<CustomAlertRuleSummary> rules)
{
var array = new JsonArray();
Expand All @@ -2275,6 +2278,7 @@ internal static JsonArray BuildRuleSummariesNode(IReadOnlyList<CustomAlertRuleSu
["version"] = rule.Version,
["updated_at"] = rule.UpdatedAt,
["updated_by"] = rule.UpdatedBy,
["last_fired"] = rule.LastFired,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ public sealed class DarlingMcpCustomAlertTools
{
[McpServerTool(Name = "list_custom_alert_rules"), Description(
"Lists every saved custom alert rule as a lightweight summary - id, name, description, whether it is " +
"enabled, version, and who/when it was last updated. No definition body. Use the id with " +
"enabled, version, who/when it was last updated, and when it last fired (last_fired, an ISO-8601 UTC " +
"instant, or null if the rule has never fired). No definition body. Use the id with " +
"get_custom_alert_rule to fetch a rule's full spec, or with update_custom_alert_rule / " +
"delete_custom_alert_rule.")]
public static async Task<string> ListCustomAlertRules(NpgsqlDataSource postgres)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@
-webkit-box-orient: vertical;
}
.view-card .vc-meta { font-size: 0.72rem; color: var(--muted); }
/* #3360: "last fired …" — a fired rule reads in --fg to draw the eye to active rules; "never fired" is dimmed. */
.view-card .vc-fired { font-size: 0.72rem; color: var(--fg); margin-bottom: 0.15rem; }
.view-card .vc-fired.never { color: var(--muted); font-style: italic; }

/* enriched card: panel count + distinct viz-type chips (fetched per view — the list summary omits the definition) */
.view-card .vc-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.45rem; }
Expand Down
Loading
Loading