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
70 changes: 65 additions & 5 deletions Darling/Darling.Tests/DarlingMcpAlertToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ private static (string Name, bool Optional)[] McpParams(string toolName)
}

[Theory]
[InlineData("get_alert_history", "server_name,hours_back,limit,as_of")]
[InlineData("get_alert_history", "server_name,hours_back,limit,as_of,include_dismissed")]
[InlineData("get_mute_rules", "enabled_only")]
[InlineData("update_alert_settings", "settings_json")]
[InlineData("create_mute_rule", "server_name,metric_name,database_pattern,query_text_pattern,wait_type_pattern,job_name_pattern,reason,expires_at")]
Expand Down Expand Up @@ -137,11 +137,15 @@ public void ParamContract_WriteTools_RequireTheirTarget(string toolName, string
/* ---------------- read SQL pins ---------------- */

[Fact]
public void AlertHistorySql_ReadsLog_ExcludesDismissed_ServerScoped()
public void AlertHistorySql_ReadsLog_ExcludesDismissedByDefault_ServerScoped()
{
var sql = Reader.AlertHistorySql;
Assert.Contains("FROM config_alert_log", sql, StringComparison.Ordinal);
Assert.Contains("dismissed = FALSE", sql, StringComparison.Ordinal);
/* #3541 A3: the exclusion is the DEFAULT arm of a caller's choice, not a hidden constant — $5 lifts
it. The literal stays so the grid's read and this one keep saying the same words. */
Assert.Contains("(dismissed = FALSE OR $5)", sql, StringComparison.Ordinal);
/* And the row SAYS which population it belongs to, so an include-dismissed page can label each row. */
Assert.Contains("dismissed\n", sql.Replace("\r\n", "\n"), StringComparison.Ordinal);
/* #2495: BOTH window edges are bound, so server_id and the cap moved up one ordinal each. */
Assert.Contains("alert_time >= $1", sql, StringComparison.Ordinal);
Assert.Contains("alert_time <= $2", sql, StringComparison.Ordinal);
Expand All @@ -151,16 +155,61 @@ public void AlertHistorySql_ReadsLog_ExcludesDismissed_ServerScoped()
}

[Fact]
public void AlertHistoryAllServersSql_ReadsLog_ExcludesDismissed_NoServerFilter()
public void AlertHistoryAllServersSql_ReadsLog_ExcludesDismissedByDefault_NoServerFilter()
{
var sql = Reader.AlertHistoryAllServersSql;
Assert.Contains("FROM config_alert_log", sql, StringComparison.Ordinal);
Assert.Contains("dismissed = FALSE", sql, StringComparison.Ordinal);
Assert.Contains("(dismissed = FALSE OR $4)", sql, StringComparison.Ordinal);
Assert.DoesNotContain("server_id =", sql, StringComparison.Ordinal); /* fleet-wide */
Assert.Contains("alert_time <= $2", sql, StringComparison.Ordinal); /* #2495 upper edge */
Assert.Contains("LIMIT $3", sql, StringComparison.Ordinal);
}

/// <summary>
/// #3541 A3: the hidden filter is MEASURED, not just disclosed. The count reads the same table over the
/// same window and scope as the history read, with the predicate inverted, so it is exactly the rows the
/// default read removed — and it must NOT carry a LIMIT, or a busy window would under-count what it hid.
/// </summary>
[Theory]
[InlineData(nameof(Reader.DismissedAlertCountSql), true)]
[InlineData(nameof(Reader.DismissedAlertCountAllServersSql), false)]
public void DismissedAlertCountSql_CountsTheRowsTheDefaultReadHides_Unbounded(string sqlName, bool serverScoped)
{
var sql = sqlName == nameof(Reader.DismissedAlertCountSql) ? Reader.DismissedAlertCountSql : Reader.DismissedAlertCountAllServersSql;

Assert.Contains("SELECT COUNT(*)", sql, StringComparison.Ordinal);
Assert.Contains("FROM config_alert_log", sql, StringComparison.Ordinal);
Assert.Contains("alert_time >= $1", sql, StringComparison.Ordinal);
Assert.Contains("alert_time <= $2", sql, StringComparison.Ordinal);
Assert.Contains("dismissed = TRUE", sql, StringComparison.Ordinal);
Assert.DoesNotContain("LIMIT", sql, StringComparison.Ordinal);
Assert.Equal(serverScoped, sql.Contains("server_id = $3", StringComparison.Ordinal));
}

/// <summary>
/// <c>include_dismissed</c> is an APPENDED optional, after <c>as_of</c>, defaulting to the grid's own read
/// — a caller who never sends it reads what they always read, the /api/read dispatch (which passes
/// <c>as_of</c> by name) is untouched, and no positional C# caller is re-bound. The description must name
/// the filter in both directions: what it hides and how to lift it.
/// </summary>
[Fact]
public void GetAlertHistory_IncludeDismissed_IsAnAppendedOptional_AndTheDescriptionNamesTheFilter()
{
var method = typeof(DarlingMcpAlertTools).GetMethods(BindingFlags.Public | BindingFlags.Static)
.Single(m => m.GetCustomAttribute<McpServerToolAttribute>()?.Name == "get_alert_history");
var names = McpParams("get_alert_history").Select(p => p.Name).ToArray();

var flag = method.GetParameters().Single(p => p.Name == "include_dismissed");
Assert.True(flag.HasDefaultValue);
Assert.False((bool)flag.DefaultValue!);
Assert.True(Array.IndexOf(names, "include_dismissed") > Array.IndexOf(names, "as_of"));

var description = method.GetCustomAttribute<DescriptionAttribute>()!.Description;
Assert.Contains("EXCLUDES DISMISSED ALERTS", description, StringComparison.Ordinal);
Assert.Contains("dismissed_excluded_count", description, StringComparison.Ordinal);
Assert.Contains("include_dismissed", description, StringComparison.Ordinal);
}

/// <summary>
/// #2391: #2349's four knobs are readable and writable through the MCP. They reached 3.5.0 with the
/// store plane only — clamped on read in <c>DarlingAlertSettings</c>, columns in V79 — but with no group
Expand Down Expand Up @@ -768,13 +817,17 @@ public void AlertSettingsSql_ReadsSingleGlobalRow()
[Theory]
[InlineData(nameof(Reader.AlertHistorySql))]
[InlineData(nameof(Reader.AlertHistoryAllServersSql))]
[InlineData(nameof(Reader.DismissedAlertCountSql))]
[InlineData(nameof(Reader.DismissedAlertCountAllServersSql))]
[InlineData(nameof(Reader.AlertSettingsSelectSql))]
public void Reads_ArePostgresDialect_NoTsqlIsms(string sqlName)
{
var sql = sqlName switch
{
nameof(Reader.AlertHistorySql) => Reader.AlertHistorySql,
nameof(Reader.AlertHistoryAllServersSql) => Reader.AlertHistoryAllServersSql,
nameof(Reader.DismissedAlertCountSql) => Reader.DismissedAlertCountSql,
nameof(Reader.DismissedAlertCountAllServersSql) => Reader.DismissedAlertCountAllServersSql,
_ => Reader.AlertSettingsSelectSql,
};
var lower = sql.ToLowerInvariant();
Expand Down Expand Up @@ -1856,6 +1909,13 @@ await DarlingMcpTestData.ExecAsync(connection, ct,
var scoped = await DarlingMcpAlertTools.GetAlertHistory(postgres, ServerName);
DarlingMcpTestData.AssertEnvelope(scoped, ServerName, "alerts");
Assert.Contains("High CPU", scoped, StringComparison.Ordinal);
/* #3541 A3: one planted, undismissed row — the page says so, says the filter applied and hid
nothing, and carries no `total_` key. */
JsonAssert.Contains("\"alerts_returned\": 1", scoped);
JsonAssert.Contains("\"truncated\": false", scoped);
JsonAssert.Contains("\"dismissed_excluded\": true", scoped);
JsonAssert.Contains("\"dismissed_excluded_count\": 0", scoped);
Assert.DoesNotContain("total_alerts", scoped, StringComparison.Ordinal);

var fleet = await DarlingMcpAlertTools.GetAlertHistory(postgres);
Assert.False(fleet.StartsWith("Error during", StringComparison.Ordinal), fleet);
Expand Down
72 changes: 72 additions & 0 deletions Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,33 @@ public void BlockedProcessReportsSql_ReadsBaseTable_XmlAndPairColumns_WindowsOnC
Assert.Contains("blocking_spid", sql, StringComparison.Ordinal);
Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal);
Assert.Contains("ORDER BY event_time DESC", sql, StringComparison.Ordinal);
/* #3541 A3: the cap is the CALLER'S ($4), not the 200 the reader used to hide under a tool that
advertised `limit`. */
Assert.Contains("LIMIT $4", sql, StringComparison.Ordinal);
Assert.DoesNotContain("LIMIT 200", sql, StringComparison.Ordinal);
}

/// <summary>
/// #3541 A3: <c>get_blocked_process_xml</c> pages over rows that CARRY a report, and the predicate is in
/// the SQL — filtering for XML in C# after a capped fetch was the defect (a run of graph-less rows at the
/// newest end read as "no XML in the window"). Pinned as the SAME projection as the unfiltered read plus
/// exactly the predicate, so the two consts cannot drift a column apart.
/// </summary>
[Fact]
public void BlockedProcessReportsWithXmlSql_IsTheUnfilteredRead_PlusTheXmlPredicate_InSql()
{
var plain = DarlingBlockingReader.BlockedProcessReportsSql;
var withXml = DarlingBlockingReader.BlockedProcessReportsWithXmlSql;

Assert.Contains("AND blocked_process_report_xml IS NOT NULL", withXml, StringComparison.Ordinal);
Assert.Contains("AND blocked_process_report_xml <> ''", withXml, StringComparison.Ordinal);
Assert.DoesNotContain("blocked_process_report_xml IS NOT NULL", plain, StringComparison.Ordinal);

/* Everything up to the window predicate is byte-identical. */
const string Cut = "AND collection_time <= $3";
Assert.Equal(plain[..(plain.IndexOf(Cut, StringComparison.Ordinal) + Cut.Length)],
withXml[..(withXml.IndexOf(Cut, StringComparison.Ordinal) + Cut.Length)]);
Assert.EndsWith("ORDER BY event_time DESC\nLIMIT $4", withXml.Replace("\r\n", "\n"), StringComparison.Ordinal);
}

[Fact]
Expand All @@ -199,6 +226,8 @@ public void DmvBlockingSnapshotsSql_ReadsView_NoXmlColumn()
Assert.DoesNotContain("blocked_process_report_xml", sql, StringComparison.Ordinal); /* the DMV snapshot has no report XML */
Assert.Contains("contentious_object", sql, StringComparison.Ordinal);
Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal);
Assert.Contains("LIMIT $4", sql, StringComparison.Ordinal);
Assert.DoesNotContain("LIMIT 200", sql, StringComparison.Ordinal);
}

[Fact]
Expand All @@ -210,6 +239,40 @@ public void RecentDeadlocksSql_ReadsBaseTable_GraphXml_OrdersByDeadlockTime()
Assert.Contains("deadlock_graph_xml", sql, StringComparison.Ordinal);
Assert.Contains("victim_process_id", sql, StringComparison.Ordinal);
Assert.Contains("ORDER BY deadlock_time DESC", sql, StringComparison.Ordinal);
/* #3541 A3: the cap is the caller's, not the 50 a caller asking for 100 deadlocks never saw. */
Assert.Contains("LIMIT $4", sql, StringComparison.Ordinal);
Assert.DoesNotContain("LIMIT 50", sql, StringComparison.Ordinal);
}

/// <summary>Same shape as the blocked-process pair: <c>get_deadlock_detail</c>'s <c>limit</c> counts graphs
/// because the graph predicate is in the SQL, and the two consts share one body.</summary>
[Fact]
public void RecentDeadlocksWithGraphSql_IsTheUnfilteredRead_PlusTheGraphPredicate_InSql()
{
var plain = DarlingBlockingReader.RecentDeadlocksSql;
var withGraph = DarlingBlockingReader.RecentDeadlocksWithGraphSql;

Assert.Contains("AND deadlock_graph_xml IS NOT NULL", withGraph, StringComparison.Ordinal);
Assert.Contains("AND deadlock_graph_xml <> ''", withGraph, StringComparison.Ordinal);
Assert.DoesNotContain("deadlock_graph_xml IS NOT NULL", plain, StringComparison.Ordinal);

const string Cut = "AND collection_time <= $3";
Assert.Equal(plain[..(plain.IndexOf(Cut, StringComparison.Ordinal) + Cut.Length)],
withGraph[..(withGraph.IndexOf(Cut, StringComparison.Ordinal) + Cut.Length)]);
Assert.EndsWith("ORDER BY deadlock_time DESC\nLIMIT $4", withGraph.Replace("\r\n", "\n"), StringComparison.Ordinal);
}

/// <summary>
/// The fingerprint scan ceiling (#3541 A3): #2159 promised the dedup_key filter runs over the window
/// BEFORE limit, and a hidden 200-row cap was quietly breaking it. The ceiling has to be materially
/// wider than that cap or the promise is still hollow, and bounded because a scan row carries the graph
/// or both SQL texts; 5,000 is what the analysis pair-row readers fetch WITHOUT the XML.
/// </summary>
[Fact]
public void FingerprintScanCeiling_IsWiderThanTheOldHiddenCap_AndBounded()
{
Assert.True(DarlingBlockingReader.FingerprintScanCeiling >= 1000, "the scan ceiling is not materially wider than the 200-row cap #2159's promise was hollow under");
Assert.True(DarlingBlockingReader.FingerprintScanCeiling <= 5000, "the scan carries XML per row; the analysis readers fetch 5,000 WITHOUT it");
}

[Fact]
Expand Down Expand Up @@ -280,14 +343,18 @@ public void LockWaitTrend_AnchorCarriesTheSharedDescription()

[Theory]
[InlineData(nameof(DarlingBlockingReader.BlockedProcessReportsSql))]
[InlineData(nameof(DarlingBlockingReader.BlockedProcessReportsWithXmlSql))]
[InlineData(nameof(DarlingBlockingReader.DmvBlockingSnapshotsSql))]
[InlineData(nameof(DarlingBlockingReader.RecentDeadlocksSql))]
[InlineData(nameof(DarlingBlockingReader.RecentDeadlocksWithGraphSql))]
public void Reads_ArePostgresDialect_NoTsqlIsms(string sqlName)
{
var sql = sqlName switch
{
nameof(DarlingBlockingReader.BlockedProcessReportsSql) => DarlingBlockingReader.BlockedProcessReportsSql,
nameof(DarlingBlockingReader.BlockedProcessReportsWithXmlSql) => DarlingBlockingReader.BlockedProcessReportsWithXmlSql,
nameof(DarlingBlockingReader.DmvBlockingSnapshotsSql) => DarlingBlockingReader.DmvBlockingSnapshotsSql,
nameof(DarlingBlockingReader.RecentDeadlocksWithGraphSql) => DarlingBlockingReader.RecentDeadlocksWithGraphSql,
_ => DarlingBlockingReader.RecentDeadlocksSql,
};
var lower = sql.ToLowerInvariant();
Expand Down Expand Up @@ -397,6 +464,11 @@ await DarlingMcpTestData.ExecAsync(connection, ct,
var blocking = await DarlingMcpBlockingTools.GetBlocking(postgres, ServerName);
DarlingMcpTestData.AssertEnvelope(blocking, ServerName, "events");
Assert.Contains("dbo.Posts", blocking, StringComparison.Ordinal);
/* #3541 A3: two planted rows (one XE, one DMV on a different pair) merge to a two-row page well
under the default limit, so the page says so — and no `total_` key is on it. */
JsonAssert.Contains("\"events_returned\": 2", blocking);
JsonAssert.Contains("\"truncated\": false", blocking);
Assert.DoesNotContain("total_events", blocking, StringComparison.Ordinal);

DarlingMcpTestData.AssertEnvelope(await DarlingMcpBlockingTools.GetDeadlocks(postgres, ServerName), ServerName, "deadlocks");
var detail = await DarlingMcpBlockingTools.GetDeadlockDetail(postgres, ServerName);
Expand Down
4 changes: 4 additions & 0 deletions Darling/Darling.Tests/DarlingMcpDataToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,10 @@ public void WaitStatsSql_AggregatesDeltas_HeaviestFirst()
Assert.Contains("SUM(delta_waiting_tasks)", sql, StringComparison.Ordinal);
Assert.Contains("GROUP BY wait_type", sql, StringComparison.Ordinal);
Assert.Contains("ORDER BY SUM(delta_wait_time_ms) DESC", sql, StringComparison.Ordinal);
/* #3541 A3: the cap is the caller's ($4), not the 50 that sat under a limit the tool accepts up to
1,000 — the shape DarlingPgWaitReader already fixed for the PostgreSQL twin. */
Assert.Contains("LIMIT $4", sql, StringComparison.Ordinal);
Assert.DoesNotContain("LIMIT 50", sql, StringComparison.Ordinal);
}

[Fact]
Expand Down
7 changes: 5 additions & 2 deletions Darling/Darling.Tests/DarlingMcpPlanCorrectionToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,11 @@ such database shows up as a phantom "recommendation" in the tool output. */
SqlTextPin.AssertExpresses("collection_time <= $3", sql, "the window's end no longer bounds the read");
SqlTextPin.AssertExpresses("ORDER BY collection_time DESC", sql, "the newest recommendations no longer come first");

/* Mirrors the Viewer grid read's bound. */
Assert.Contains("LIMIT 200", sql, StringComparison.Ordinal);
/* #3541 A3: the cap is the CALLER'S, bound as $4, not the Viewer grid's literal 200. The literal gave
every window the same ~16-hour reach over per-cycle re-captures and the tool published that page as
the window's count. */
SqlTextPin.AssertExpresses("LIMIT $4", sql, "the row cap is no longer the caller's limit");
Assert.DoesNotContain("LIMIT 200", sql, StringComparison.Ordinal);
}

[Fact]
Expand Down
4 changes: 4 additions & 0 deletions Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ public void WaitingTasksSql_ReadsBaseTable_NoView_Windowed()
Assert.Contains("wait_duration_ms", sql, StringComparison.Ordinal);
Assert.Contains("resource_description", sql, StringComparison.Ordinal);
Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal);
/* #3541 A3: the cap is the caller's ($4), not the 500 the reader hid under a tool that advertised
`limit` and then published a bare envelope. */
Assert.Contains("LIMIT $4", sql, StringComparison.Ordinal);
Assert.DoesNotContain("LIMIT 500", sql, StringComparison.Ordinal);
}

[Theory]
Expand Down
Loading
Loading