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
98 changes: 98 additions & 0 deletions Darling/Darling.Tests/McpToolGuideHeads.PgB.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,101 @@ public void IoStatsLimitParameter_FitsD2Cap_AndKeepsItsGuardrailWords()
Assert.True(limit.Length <= 200, $"get_pg_io_stats.limit: {limit.Length} > 200");
}
}

/// <summary>
/// #3898 D3 head pins for the rest of the "pgB" family: <c>get_pg_blocking</c> (<c>DarlingMcpPgBlockingTools</c>),
/// <c>get_pg_wait_stats</c> (<c>DarlingMcpPgWaitTools</c>) and <c>get_pg_index_usage</c>
/// (<c>DarlingMcpPgIndexUsageTools</c>). Darling only - none of the three has a Lite twin (see
/// <c>CrossAppMcpToolInventoryPinTests.KnownLiteMissingMcpTools</c>). A separate class from
/// <see cref="McpToolGuideHeadsPgBTests"/> and <see cref="McpToolGuideHeadsPgBAuditIoWaitTests"/> so parallel
/// lanes converting different files in the same family never conflict on one class body.
/// </summary>
public sealed class McpToolGuideHeadsPgBBlockingWaitIndexTests
{
private static readonly string[] ConvertedTools =
[
"get_pg_blocking",
"get_pg_wait_stats",
"get_pg_index_usage",
];

/// <summary>The per-tool guardrail phrase each head must state.</summary>
private static readonly (string Tool, string Fact)[] HeadFacts =
[
("get_pg_blocking", "a periodic SAMPLE, not an event log"),
("get_pg_blocking", "empty means none was SAMPLED, not none happened"),
("get_pg_blocking", "Cycles (deadlocks) are reported separately, never folded into a chain"),
("get_pg_blocking", "root_backend_id is a STRING; compare as text, never as a number"),
("get_pg_blocking", "idle in transaction: an application defect; active: a query to tune"),
("get_pg_wait_stats", "Aurora only"),
("get_pg_wait_stats", "stock targets are served by get_pg_wait_sampling instead"),
("get_pg_wait_stats", "Values are milliseconds"),
("get_pg_wait_stats", "THE PAGE IS BOUNDED BY limit"),
("get_pg_wait_stats", "SHARES ARE OF THE WINDOW, NOT OF THE PAGE"),
("get_pg_index_usage", "scans_in_window, not lifetime total_scans_since_stats_reset, is the disuse figure"),
("get_pg_index_usage", "needs 2+ samples"),
("get_pg_index_usage", "Droppability is GATED on primary key/unique/exclusion/replica-identity/partial/expression/invalid facts"),
("get_pg_index_usage", "unscanned_without_a_structural_blocker is a candidate, never a conclusion"),
("get_pg_index_usage", "WRITERS ONLY"),
("get_pg_index_usage", "Floors at 64 kB"),
];

[Fact]
public void EveryConvertedHead_CarriesItsGuardrailFact_AndThePointer()
{
foreach (var tool in ConvertedTools)
{
var served = McpToolGuideTests.Served(tool);
Assert.NotNull(served.Tail);
Assert.EndsWith(McpToolGuide.GuidePointer, served.Served, StringComparison.Ordinal);
Assert.True(served.Served.Length <= 620, $"{tool}: served head {served.Served.Length} is over the 620 target");
Assert.All(served.ParameterDescriptionLengths, p => Assert.True(p.Length <= 200, $"{tool}.{p.Parameter}: {p.Length} > 200"));
}

foreach (var (tool, fact) in HeadFacts)
{
Assert.Contains(fact, McpToolGuideTests.Served(tool).Served, StringComparison.Ordinal);
}
}

/// <summary>Nothing dropped: every original sentence rides in the tail, including the ones the terse head
/// left out (the pid/state/application/query field list, the SQL Server blocked-process report contrast,
/// the 64-bit backend-id composite explanation, and the full Aurora wait-taxonomy/units narrative).</summary>
[Fact]
public void D3HeadCompression_LandsEveryDroppedSentenceInTheTail()
{
var blocking = McpToolGuideTests.Served("get_pg_blocking");
Assert.Contains("the root blocker's pid, state, application, username and query text", blocking.Tail!, StringComparison.Ordinal);
Assert.Contains("Unlike SQL Server's blocked-process report", blocking.Tail!, StringComparison.Ordinal);
Assert.Contains("a 64-bit composite of the backend's start time and its pid", blocking.Tail!, StringComparison.Ordinal);
Assert.Contains("Works on any PostgreSQL target including standbys.", blocking.Tail!, StringComparison.Ordinal);

var wait = McpToolGuideTests.Served("get_pg_wait_stats");
Assert.Contains("IO events point at storage or cache misses, Lock events at blocking between sessions", wait.Tail!, StringComparison.Ordinal);
Assert.Contains("Note this is a separate tool from get_wait_stats", wait.Tail!, StringComparison.Ordinal);
Assert.Contains("reports in microseconds, so the two cannot share one result shape", wait.Tail!, StringComparison.Ordinal);

var usage = McpToolGuideTests.Served("get_pg_index_usage");
Assert.Contains("an index with millions of lifetime scans and none in ninety days is dead weight today", usage.Tail!, StringComparison.Ordinal);
Assert.Contains("Ranked by the bytes of an index nothing scanned, largest first, with invalid indexes on top", usage.Tail!, StringComparison.Ordinal);
Assert.Contains("1,517", usage.Tail!, StringComparison.Ordinal);
}

/// <summary>D2 parameter overflow: <c>get_pg_wait_stats</c>' and <c>get_pg_index_usage</c>'s <c>limit</c>
/// descriptions were 214 and 257 chars; the trailing clause each cut to fit 200 lands verbatim in its
/// tool's tail rather than disappearing (#3898 lesson: never reword a cut parameter clause).</summary>
[Fact]
public void D2ParameterOverflow_LandsInTheTail_Verbatim()
{
var wait = McpToolGuideTests.Served("get_pg_wait_stats");
var waitLimit = Assert.Single(wait.ParameterDescriptionLengths, x => x.Parameter == "limit");
Assert.True(waitLimit.Length <= 200, $"get_pg_wait_stats.limit: {waitLimit.Length} > 200");
Assert.Contains("whatever this is set to.", wait.Tail!, StringComparison.Ordinal);

var usage = McpToolGuideTests.Served("get_pg_index_usage");
var usageLimit = Assert.Single(usage.ParameterDescriptionLengths, x => x.Parameter == "limit");
Assert.True(usageLimit.Length <= 200, $"get_pg_index_usage.limit: {usageLimit.Length} > 200");
Assert.Contains("This is what bounds the page", usage.Tail!, StringComparison.Ordinal);
Assert.Contains("it is observed by fetching one row past this cap, never inferred from a full page", usage.Tail!, StringComparison.Ordinal);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# DarlingMcpPgBlockingTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks.
tool get_pg_blocking 1606
tool get_pg_blocking 588
param get_pg_blocking.as_of 167
param get_pg_blocking.hours_back 40
param get_pg_blocking.limit 66
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# DarlingMcpPgIndexUsageTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks.
tool get_pg_index_usage 1471
tool get_pg_index_usage 599
param get_pg_index_usage.as_of 167
param get_pg_index_usage.hours_back 147
param get_pg_index_usage.limit 257
param get_pg_index_usage.limit 93
param get_pg_index_usage.server_name 28
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# DarlingMcpPgWaitTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks.
tool get_pg_wait_stats 1483
tool get_pg_wait_stats 586
param get_pg_wait_stats.as_of 167
param get_pg_wait_stats.hours_back 40
param get_pg_wait_stats.limit 214
param get_pg_wait_stats.limit 190
param get_pg_wait_stats.server_name 28
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ internal static string RemedyFor(string? rootState, bool idleInTransaction, long
+ "client that stopped reading.";
}

[McpServerTool(Name = "get_pg_blocking"), Description("Gets PostgreSQL blocking chains that were captured for a server, assembled from the stored edge list into one entry per chain with its ROOT blocker identified and attributed. Use this when sessions are timing out, waiting, or piling up on a PostgreSQL target, or to check whether a past slowdown involved lock contention. Reports for each captured chain: the root blocker's pid, state, application, username and query text, how many sessions were behind it in total and directly, how deep the chain went, the longest-waiting victim, and how many separate captures that same backend has been the root of - which distinguishes one stuck session from a recurring pattern. Also returns a specific remedy per root state, because an 'idle in transaction' root is an application defect while an 'active' root is a query-tuning problem and the two need opposite responses. IMPORTANT: this is a periodic SAMPLE, not an event log. Unlike SQL Server's blocked-process report, PostgreSQL records nothing on its own, so blocking shorter than the collection interval is never seen and an empty result means 'none was sampled', not 'none happened' - the capture counts in the response say how many samples the window actually contains. root_backend_id is returned as a STRING, not a number, and it is the value to compare a root blocker across captures with: it is a 64-bit composite of the backend's start time and its pid, always well past what a JSON number survives, so a numeric wire form would round DIFFERENT backends onto the same id rather than merely lose one. Works on any PostgreSQL target including standbys.")]
[McpServerTool(Name = "get_pg_blocking"), Description("PostgreSQL blocking chains, one entry per chain with its ROOT blocker and a remedy keyed to the root's state (idle in transaction: an application defect; active: a query to tune). IMPORTANT: a periodic SAMPLE, not an event log. PostgreSQL records nothing on its own, so blocking shorter than the interval is never seen; empty means none was SAMPLED, not none happened. Capture counts say how many samples the window held. Cycles (deadlocks) are reported separately, never folded into a chain. root_backend_id is a STRING; compare as text, never as a number. <<GUIDE>> Gets PostgreSQL blocking chains that were captured for a server, assembled from the stored edge list into one entry per chain with its ROOT blocker identified and attributed. Use this when sessions are timing out, waiting, or piling up on a PostgreSQL target, or to check whether a past slowdown involved lock contention. Reports for each captured chain: the root blocker's pid, state, application, username and query text, how many sessions were behind it in total and directly, how deep the chain went, the longest-waiting victim, and how many separate captures that same backend has been the root of - which distinguishes one stuck session from a recurring pattern. Also returns a specific remedy per root state, because an 'idle in transaction' root is an application defect while an 'active' root is a query-tuning problem and the two need opposite responses. IMPORTANT: this is a periodic SAMPLE, not an event log. Unlike SQL Server's blocked-process report, PostgreSQL records nothing on its own, so blocking shorter than the collection interval is never seen and an empty result means 'none was sampled', not 'none happened' - the capture counts in the response say how many samples the window actually contains. root_backend_id is returned as a STRING, not a number, and it is the value to compare a root blocker across captures with: it is a 64-bit composite of the backend's start time and its pid, always well past what a JSON number survives, so a numeric wire form would round DIFFERENT backends onto the same id rather than merely lose one. Works on any PostgreSQL target including standbys.")]
public static async Task<string> GetPgBlocking(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,13 @@ internal static string DescribeBytes(long bytes)

[McpServerTool(Name = "get_pg_index_usage")]
[Description(
"PostgreSQL per-index usage: how often each index was scanned, what it costs, and - the part a raw "
"Per-index usage and droppability advice, beyond raw pg_stat_user_indexes. scans_in_window, not "
+ "lifetime total_scans_since_stats_reset, is the disuse figure; needs 2+ samples (no index-creation "
+ "timestamp exists). Droppability is GATED on primary key/unique/exclusion/replica-identity/partial/"
+ "expression/invalid facts: unscanned_without_a_structural_blocker is a candidate, never a "
+ "conclusion. WRITERS ONLY - a replica's scan counts are its own, not the writer's. Floors at 64 kB "
+ "(plus any INVALID index), so counts run lower than get_pg_index_bloat's floor-free census. "
+ "<<GUIDE>> PostgreSQL per-index usage: how often each index was scanned, what it costs, and - the part a raw "
+ "pg_stat_user_indexes query cannot give you - whether it is actually safe to drop. Reports BOTH the "
+ "server's lifetime scan count since its statistics were last reset AND the scans observed across "
+ "the stored window, which is the more useful figure: an index with millions of lifetime scans and "
Expand All @@ -214,12 +220,14 @@ internal static string DescribeBytes(long bytes)
+ "indexes than get_pg_index_bloat, which is the complete btree census with no size floor - 1,517 "
+ "against 2,500 on one measured target, a difference that is entirely that floor. Neither is "
+ "missing objects, and for indexes too large for get_pg_index_bloat to measure this is the only "
+ "collector carrying their size over time.")]
+ "collector carrying their size over time. This is what bounds the page - read truncated to know "
+ "whether the server held more indexes than were returned; it is observed by fetching one row past "
+ "this cap, never inferred from a full page.")]
public static async Task<string> GetPgIndexUsage(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
[Description("Hours of history to analyze. Default 168 (seven days). Widen this to the longest interval any scheduled job runs on before calling an index unused.")] int hours_back = 168,
[Description("Maximum indexes to return, biggest unscanned first. Default 25. This is what bounds the page - read truncated to know whether the server held more indexes than were returned; it is observed by fetching one row past this cap, never inferred from a full page.")] int limit = 25,
[Description("Maximum indexes to return, biggest unscanned first. Default 25. See the tool's reading guide.")] int limit = 25,
[Description(McpHelpers.AsOfDescription)] string? as_of = null)
{
var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ namespace PerformanceMonitor.Darling.Service.Mcp;
[McpServerToolType]
public sealed class DarlingMcpPgWaitTools
{
[McpServerTool(Name = "get_pg_wait_stats"), Description("Gets the top PostgreSQL wait events aggregated over a time period, for Amazon Aurora PostgreSQL targets - the engine_cumulative tier, the finest of the three PostgreSQL wait instruments (Aurora native > pg_wait_sampling extension > service sampler; stock targets take one of the other two and get_pg_wait_sampling serves them, disclosing which). Waits reveal what the database spends time waiting on: IO events point at storage or cache misses, Lock events at blocking between sessions, LWLock at internal contention, and LSN is Aurora's storage-durability wait. Background-worker and client-idle waits are already excluded by the collector, so every row here is real work. Note this is a separate tool from get_wait_stats, which covers SQL Server: PostgreSQL has a two-level type/event taxonomy, no signal-wait concept, and reports in microseconds, so the two cannot share one result shape. THE PAGE IS BOUNDED BY limit: wait_events_returned is how many events you got, truncated says the window held more, and the rows are the heaviest so the ones past the cap are lighter. SHARES ARE OF THE WINDOW, NOT OF THE PAGE: pct_of_total_wait's denominator is total_wait_time_ms, the WHOLE window's wait time across every event, computed in the same statement as the rows - so a three-row page does not sum to 100%, and the gap between returned_wait_time_ms (what the page adds up to) and total_wait_time_ms is the waiting the cap left out; returned_pct_of_total is that ratio stated once.")]
[McpServerTool(Name = "get_pg_wait_stats"), Description("Top PostgreSQL wait events aggregated over a period, Aurora only - the engine_cumulative tier, the finest of three wait instruments (stock targets are served by get_pg_wait_sampling instead). Background-worker and idle waits are already excluded, so every row is real work. Values are milliseconds. THE PAGE IS BOUNDED BY limit: wait_events_returned/truncated. SHARES ARE OF THE WINDOW, NOT OF THE PAGE: pct_of_total_wait divides by total_wait_time_ms, not the page sum. Empty can mean no Aurora wait data yet, or a non-Aurora target - check list_servers. <<GUIDE>> Gets the top PostgreSQL wait events aggregated over a time period, for Amazon Aurora PostgreSQL targets - the engine_cumulative tier, the finest of the three PostgreSQL wait instruments (Aurora native > pg_wait_sampling extension > service sampler; stock targets take one of the other two and get_pg_wait_sampling serves them, disclosing which). Waits reveal what the database spends time waiting on: IO events point at storage or cache misses, Lock events at blocking between sessions, LWLock at internal contention, and LSN is Aurora's storage-durability wait. Background-worker and client-idle waits are already excluded by the collector, so every row here is real work. Note this is a separate tool from get_wait_stats, which covers SQL Server: PostgreSQL has a two-level type/event taxonomy, no signal-wait concept, and reports in microseconds, so the two cannot share one result shape. THE PAGE IS BOUNDED BY limit: wait_events_returned is how many events you got, truncated says the window held more, and the rows are the heaviest so the ones past the cap are lighter. SHARES ARE OF THE WINDOW, NOT OF THE PAGE: pct_of_total_wait's denominator is total_wait_time_ms, the WHOLE window's wait time across every event, computed in the same statement as the rows - so a three-row page does not sum to 100%, and the gap between returned_wait_time_ms (what the page adds up to) and total_wait_time_ms is the waiting the cap left out; returned_pct_of_total is that ratio stated once. Raising or lowering limit does not change that math - the shares stay of the whole window whatever this is set to.")]
public static async Task<string> GetPgWaitStats(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
[Description("Hours of history to analyze. Default 24.")] int hours_back = 24,
[Description("Maximum wait events to return, heaviest total wait first. Default 20. This is what bounds the page - read truncated to know whether the window held more; the shares stay of the whole window whatever this is set to.")] int limit = 20,
[Description("Maximum wait events to return, heaviest total wait first. Default 20. This is what bounds the page - read truncated to know whether the window held more; the shares stay of the whole window.")] int limit = 20,
[Description(McpHelpers.AsOfDescription)] string? as_of = null)
{
var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name);
Expand Down
Loading