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
160 changes: 160 additions & 0 deletions Darling/Darling.Tests/IndexUsageBudgetLiveTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/*
* 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.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Npgsql;
using PerformanceMonitor.Collectors;
using PerformanceMonitor.Common;
using PerformanceMonitor.Darling.Service.Mcp;
using PerformanceMonitor.Darling.Storage;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// #4198 (per-tool lane): <c>get_index_usage</c> measured 69,290 bytes at default arguments on a busy
/// production store (200 rows, <c>IndexUsageTop</c>'s old default, #2636's <c>limit</c> parameter left at its
/// hardcoded-era value) -- more than double <see cref="McpResponseBudget.DefaultBytes"/>. #4224's fixture
/// excludes this tool from its own budget pin (it does not seed <c>index_object_stats</c>), so this file seeds
/// it directly and measures the same call.
///
/// <para>200 rows spread over 10 databases, with index/table names sized to reproduce the field's per-row
/// width (roughly 346 B/row the field measurement implies: 69,290 B / 200 rows). Unlike
/// <c>get_object_locking</c>, <c>IndexUsageSql</c> has no nonzero-activity filter -- every seeded row is a
/// candidate at the latest snapshot -- so all 200 compete for the default page and the truncated-page assertion
/// below is exact.</para>
/// </summary>
[Collection("live-postgres")]
public sealed class IndexUsageBudgetLiveTests
{
private const string ServerName = "darling-index-usage-budget-e2e";
private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName);
private const int SeededRowCount = 200;
private const int DatabaseCount = 10;

private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG");

/// <summary>
/// The regression pin: a default call (no <c>limit</c> passed) stays under
/// <see cref="McpResponseBudget.DefaultBytes"/>, is marked <c>truncated</c>, and an explicit
/// <c>limit</c> covering the whole seeded set still gets every row -- the ruling's "an explicit argument
/// still gets what it asks for" half. Reverting the tool's default-arguments fix reproduces the original
/// report: this fails on the old 200-row default the same way #4198's field measurement did.
/// </summary>
[Fact]
public async Task DefaultCallStaysUnderBudget_AndAnExplicitLimitStillGetsEveryRow()
{
var cs = ConnectionString;
Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live get_index_usage budget test.");

var ct = TestContext.Current.CancellationToken;
await using var connection = new NpgsqlConnection(cs);
await connection.OpenAsync(ct);
await PgMigrations.MigrateAsync(connection, ct);
await DeleteRowsAsync(connection, ct);
await using var postgres = NpgsqlDataSource.Create(cs!);

var bodySucceeded = false;
try
{
await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct);
var capture = DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow).AddMinutes(-1);
await SeedAsync(connection, ct, capture);

var defaultJson = await DarlingMcpObjectStatsTools.GetIndexUsage(postgres, ServerName);
Assert.False(McpHelpers.IsErrorEnvelope(defaultJson), $"tool returned an error: {defaultJson}");
var defaultBytes = Encoding.UTF8.GetByteCount(defaultJson);

TestContext.Current.TestOutputHelper?.WriteLine(
$"get_index_usage, default args, {SeededRowCount} rows seeded: {defaultBytes:N0} bytes, budget={McpResponseBudget.DefaultBytes:N0} bytes.");

Assert.True(defaultBytes <= McpResponseBudget.DefaultBytes,
$"get_index_usage default call was {defaultBytes:N0} bytes, over the {McpResponseBudget.DefaultBytes:N0}-byte budget.");

using (var defaultDoc = JsonDocument.Parse(defaultJson))
{
var root = defaultDoc.RootElement;
var returned = root.GetProperty("returned_index_count").GetInt32();
Assert.True(root.GetProperty("truncated").GetBoolean(),
$"expected the default page to be truncated against {SeededRowCount} seeded rows, but only {returned} were returned and truncated was false.");
Assert.True(returned < SeededRowCount,
$"expected the default page to be narrower than the seeded {SeededRowCount} rows; got {returned}.");
Assert.Equal(SeededRowCount, root.GetProperty("matching_index_count").GetInt32());
Assert.Equal(returned, root.GetProperty("indexes").GetArrayLength());
}

/* The ruling's other half: raising limit past the seeded count returns every row, unbounded by
whatever default the fix landed on. */
var fullJson = await DarlingMcpObjectStatsTools.GetIndexUsage(postgres, ServerName, limit: SeededRowCount);
Assert.False(McpHelpers.IsErrorEnvelope(fullJson), $"tool returned an error: {fullJson}");
using (var fullDoc = JsonDocument.Parse(fullJson))
{
var root = fullDoc.RootElement;
Assert.Equal(SeededRowCount, root.GetProperty("returned_index_count").GetInt32());
Assert.False(root.GetProperty("truncated").GetBoolean(),
"an explicit limit covering every seeded row should not come back truncated.");
Assert.Equal(SeededRowCount, root.GetProperty("indexes").GetArrayLength());
}

bodySucceeded = true;
}
finally
{
await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) =>
await DeleteRowsAsync(cleanup, cleanupCt));
}
}

/// <summary>
/// 200 rows across 10 databases, table/index names sized like a real composite-index schema. Not shared
/// with any other #4198 lane's fixture -- several run tonight.
/// </summary>
private static async Task SeedAsync(NpgsqlConnection connection, CancellationToken ct, DateTime capture)
{
var collectionId = CollectionIdGenerator.Next();

for (var i = 0; i < SeededRowCount; i++)
{
var dbIndex = i % DatabaseCount;
var databaseName = $"TenantDb{dbIndex:D2}";
var tableName = $"OrderLineItems{(i % 30):D2}";
var indexName = $"IX_OrderLineItems{(i % 30):D2}_TenantId_OrderId";
var indexType = i % 5 == 0 ? "CLUSTERED" : "NONCLUSTERED";
var reservedMb = 100m + i * 7.31m;
var totalRows = 10_000L + i * 997L;
/* A mix of Unused / Write-only / Active, the same three-way split the read classifies -- byte
size does not depend on the split, but a fixture that is all one classification would not
exercise the unused-first ORDER BY the way a real server's mix does. */
var seeks = i % 3 == 0 ? 0L : 500L + i * 11L;
var scans = i % 3 == 0 ? 0L : 10L + i % 40L;
var lookups = i % 3 == 0 ? 0L : 5L + i % 20L;
var updates = i % 3 == 1 ? 0L : 50L + i % 90L;

await DarlingMcpTestData.ExecAsync(
connection,
ct,
@"INSERT INTO index_object_stats (collection_id, collection_time, server_id, server_name, database_name, schema_name, object_id, table_name, index_id, index_name, index_type_desc, reserved_mb, used_mb, total_rows, user_seeks, user_scans, user_lookups, user_updates)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)",
collectionId, capture, ServerId, ServerName, databaseName, "dbo", 1000 + i, tableName,
1 + i % 3, indexName, indexType, reservedMb, reservedMb, totalRows,
seeks, scans, lookups, updates);
}
}

private static async Task DeleteRowsAsync(NpgsqlConnection connection, CancellationToken ct)
{
using var cleanup = new NpgsqlCommand(
$"DELETE FROM index_object_stats WHERE server_id = {ServerId}; DELETE FROM servers WHERE server_id = {ServerId};",
connection);
await cleanup.ExecuteNonQueryAsync(ct);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ param get_database_sizes.server_name 28

tool get_index_usage 488
param get_index_usage.database_name 123
param get_index_usage.limit 36
param get_index_usage.limit 35
param get_index_usage.server_name 28

tool get_object_locking 661
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,18 @@ namespace PerformanceMonitor.Darling.Service.Mcp;
[McpServerToolType]
public sealed class DarlingMcpObjectStatsTools
{
/// <summary>Lite's default result caps (the tools take no top parameter).</summary>
/// <summary>Lite's default result caps (get_table_index_sizes takes no top parameter).</summary>
private const int TableSizesTop = 100;
private const int IndexUsageTop = 200;

/// <summary>
/// #4198: <c>get_index_usage</c>'s caller-optional <c>limit</c> DEFAULT (#2636 made it optional; this lane
/// sizes what it falls back to). 200 rows measured 69,290 bytes at default arguments on a busy production
/// store -- more than double <see cref="McpResponseBudget.DefaultBytes"/>, at roughly 346 bytes/row. 75
/// rows leaves headroom under the budget even for wider index/table names than the measuring store's. An
/// explicit <c>limit</c> still gets what it asks for, up to <see cref="McpHelpers.MaxTop"/>.
/// </summary>
private const int IndexUsageTop = 75;

private const int ObjectLockingTop = 200;

[McpServerTool(Name = "get_table_index_sizes"), Description("Gets the 100 largest tables with per-table size, growth (7d/30d/daily rate), and row counts from the latest daily snapshot. Indexes are rolled up per table. Use to find storage hot-spots and fast-growing tables for capacity planning. Growth is measured only over history the store actually holds: the history block says how many days of snapshots exist and whether the 7-day and 30-day baselines are reachable; growth_7d_mb / growth_30d_mb / growth_pct_30d are null (with the reason in growth_note) when their baseline does not exist, never re-labelled from a nearer one, and growth_over_available_history_* always spans exactly growth_window_days. A table absent from a baseline snapshot (created since) reports null growth for that window, not 0. tables_returned and truncated bound the page.")]
Expand Down Expand Up @@ -146,7 +155,7 @@ public static async Task<string> GetIndexUsage(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
[Description("Limit to one database. Strongly recommended: without it, unused-first ordering can fill the whole result from one database.")] string? database_name = null,
[Description("Maximum rows to return. Default 200.")] int limit = IndexUsageTop)
[Description("Maximum rows to return. Default 75.")] int limit = IndexUsageTop)
{
var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name);
if (error != null) return error;
Expand Down
151 changes: 151 additions & 0 deletions Lite.Tests/IndexUsageBudgetTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor Lite.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using DuckDB.NET.Data;
using PerformanceMonitor.Common;
using PerformanceMonitorLite.Database;
using PerformanceMonitorLite.Mcp;
using PerformanceMonitorLite.Models;
using PerformanceMonitorLite.Services;
using Xunit;

namespace PerformanceMonitorLite.Tests;

/// <summary>
/// #4198, Lite parity for <c>Darling.Tests/IndexUsageBudgetLiveTests</c>: <c>get_index_usage</c> measured
/// 69,290 bytes at default arguments on a busy production store (200 rows, <c>IndexUsageTop</c>'s old default,
/// #2636's <c>limit</c> parameter left at its hardcoded-era value) -- more than double
/// <see cref="McpResponseBudget.DefaultBytes"/>. Not on #4224's <c>McpReadToolBudgetLiveTests</c> roster and
/// that fixture doesn't seed <c>index_object_stats</c>, so this file seeds it directly on Lite's shared DuckDB
/// fixture (no rig needed) and measures the same call.
/// </summary>
public sealed class IndexUsageBudgetTests : IClassFixture<SharedDuckDbFixture>, IDisposable
{
private const string ServerName = "IndexUsageBudgetSrv";
private const int SeededRowCount = 200;
private const int DatabaseCount = 10;

private readonly int _serverId;
private readonly DuckDbInitializer _duckDb;
private readonly LocalDataService _dataService;
private readonly string _configDir;
private readonly ServerManager _serverManager;
private readonly DateTime _startTime = DateTime.UtcNow.AddDays(-10);
private DuckDBConnection? _seedConn;
private long _nextId = -1;

public IndexUsageBudgetTests(SharedDuckDbFixture fixture)
{
fixture.ResetData();
_duckDb = fixture.DuckDb;
_dataService = new LocalDataService(_duckDb);

_configDir = Path.Combine(Path.GetTempPath(), "pmlite-indexusagebudget-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_configDir);
_serverManager = new ServerManager(_configDir);

var server = new ServerConnection
{
Id = Guid.NewGuid().ToString(),
ServerName = ServerName,
IsEnabled = true,
};
_serverManager.AddServer(server);
_serverId = RemoteCollectorService.GetDeterministicHashCode(
RemoteCollectorService.GetServerNameForStorage(server));
}

public void Dispose()
{
_seedConn?.Dispose();
try { Directory.Delete(_configDir, recursive: true); } catch (IOException) { /* temp dir */ }
}

/// <summary>
/// The regression pin: a default call (no <c>limit</c> passed) stays under
/// <see cref="McpResponseBudget.DefaultBytes"/>, is marked <c>truncated</c>, and an explicit <c>limit</c>
/// covering the whole seeded set still gets every row.
/// </summary>
[Fact]
public async Task DefaultCallStaysUnderBudget_AndAnExplicitLimitStillGetsEveryRow()
{
await SeedAsync();

var defaultJson = await McpObjectStatsTools.GetIndexUsage(_dataService, _serverManager, ServerName);
var defaultBytes = Encoding.UTF8.GetByteCount(defaultJson);
TestContext.Current.TestOutputHelper?.WriteLine(
$"get_index_usage, default args, {SeededRowCount} rows seeded: {defaultBytes:N0} bytes, budget={McpResponseBudget.DefaultBytes:N0} bytes.");
Assert.True(defaultBytes <= McpResponseBudget.DefaultBytes,
$"get_index_usage default call was {defaultBytes:N0} bytes, over the {McpResponseBudget.DefaultBytes:N0}-byte budget.");

var defaultRoot = JsonDocument.Parse(defaultJson).RootElement;
var returned = defaultRoot.GetProperty("returned_index_count").GetInt32();
Assert.True(returned < SeededRowCount,
$"expected the default page to be narrower than the seeded {SeededRowCount} rows; got {returned}.");
Assert.True(defaultRoot.GetProperty("truncated").GetBoolean());
Assert.Equal(SeededRowCount, defaultRoot.GetProperty("matching_index_count").GetInt32());
Assert.Equal(returned, defaultRoot.GetProperty("indexes").GetArrayLength());

var fullJson = await McpObjectStatsTools.GetIndexUsage(_dataService, _serverManager, ServerName, limit: SeededRowCount);
var fullRoot = JsonDocument.Parse(fullJson).RootElement;
Assert.Equal(SeededRowCount, fullRoot.GetProperty("returned_index_count").GetInt32());
Assert.False(fullRoot.GetProperty("truncated").GetBoolean());
Assert.Equal(SeededRowCount, fullRoot.GetProperty("indexes").GetArrayLength());
}

/// <summary>200 rows across 10 databases, table/index names sized like a real composite-index schema, all
/// sharing one capture so "latest snapshot" keeps every row.</summary>
private async Task SeedAsync()
{
var capture = DateTime.UtcNow;
using var readLock = _duckDb.AcquireReadLock();
var conn = await SeedConnectionAsync();

for (var i = 0; i < SeededRowCount; i++)
{
var dbIndex = i % DatabaseCount;
var databaseName = $"TenantDb{dbIndex:D2}";
var tableName = $"OrderLineItems{(i % 30):D2}";
var indexName = $"IX_OrderLineItems{(i % 30):D2}_TenantId_OrderId";
var indexType = i % 5 == 0 ? "CLUSTERED" : "NONCLUSTERED";
var reservedMb = 100m + i * 7.31m;
var totalRows = 10_000L + i * 997L;
var seeks = i % 3 == 0 ? 0L : 500L + i * 11L;
var scans = i % 3 == 0 ? 0L : 10L + i % 40L;
var lookups = i % 3 == 0 ? 0L : 5L + i % 20L;
var updates = i % 3 == 1 ? 0L : 50L + i % 90L;

using var cmd = conn.CreateCommand();
cmd.CommandText = @"INSERT INTO index_object_stats
(collection_id, collection_time, server_id, server_name, sqlserver_start_time, database_name, database_id,
schema_name, object_id, table_name, index_id, index_name, index_type_desc, reserved_mb, used_mb, total_rows,
user_seeks, user_scans, user_lookups, user_updates)
VALUES ($1,$2,$3,$4,$5,$6,7,'dbo',$7,$8,$9,$10,$11,$12,$12,$13,$14,$15,$16,$17)";
void P(object v) => cmd.Parameters.Add(new DuckDBParameter { Value = v });
P(_nextId--); P(capture); P(_serverId); P(ServerName); P(_startTime); P(databaseName);
P(1000 + i); P(tableName); P(1 + i % 3); P(indexName); P(indexType); P(reservedMb); P(totalRows);
P(seeks); P(scans); P(lookups); P(updates);
await cmd.ExecuteNonQueryAsync();
}
}

private async Task<DuckDBConnection> SeedConnectionAsync()
{
if (_seedConn is null)
{
_seedConn = _duckDb.CreateConnection();
await _seedConn.OpenAsync();
}
return _seedConn;
}
}
2 changes: 1 addition & 1 deletion Lite.Tests/McpToolsListBudget/McpObjectStatsTools.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# McpObjectStatsTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks.
tool get_index_usage 488
param get_index_usage.database_name 123
param get_index_usage.limit 36
param get_index_usage.limit 35
param get_index_usage.server_name 28

tool get_object_locking 661
Expand Down
Loading
Loading