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
61 changes: 61 additions & 0 deletions Darling/Darling.Tests/DarlingMcpObjectStatsToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,48 @@ public void IndexLockingSql_ServerLatestCapture_NonzeroWaits_ContendedFirst()
Assert.DoesNotContain("GROUP BY database_name", sql, StringComparison.Ordinal);
}

/// <summary>
/// #3880, Erik's ruling on the call #3878/#3879 recorded: the read PROJECTS its anchor column, so
/// <c>get_object_locking</c> can stamp the snapshot it answered from. The always-runs half of the live
/// assertion in <c>DarlingIndexLockingRenamedDatabaseLivePostgresTests</c>: the column is on the row
/// statement's select list (the general rule
/// <see cref="McpLatestSnapshotStampTests.EveryStampedRead_SelectsItsStampColumn_OnTheRowStatement"/>
/// holds per read, and this names the tool) and the record carries it in the position the tool reads.
/// The negative half matters as much: a stamp fetched by a SECOND <c>MAX(collection_time)</c> read can
/// resolve to the next capture landing between the two queries, so there is exactly one such subquery in
/// this constant — the anchor itself.
///
/// <para>That the PAYLOAD publishes <c>captured_at</c> is asserted where every other stamped read's is:
/// <c>get_object_locking</c> is a <c>Shape.Stamped</c> roster row in <see cref="McpLatestSnapshotStampTests"/>
/// on both SKUs since #3880, and that roster sweeps both tool bodies for the key and both descriptions
/// for the words. Repeating the body scan here would be a second spelling of one contract, and the one
/// that drifts is always the copy.</para>
/// </summary>
[Fact]
public void IndexLockingSql_ProjectsItsAnchorColumn_SoTheToolCanStampTheSnapshot()
{
var sql = DarlingObjectStatsReader.IndexLockingSql;
Assert.Contains("ios.collection_time,", sql, StringComparison.Ordinal);

/* The projection is on the ROW statement's select list, not only inside the anchor subquery: from the
statement's SELECT to its FROM. */
var from = sql.IndexOf("\nFROM ", StringComparison.Ordinal);
Assert.True(from > 0, "no column-zero FROM — the read's shape has changed; re-anchor this pin");
var select = sql.LastIndexOf("\nSELECT", from, StringComparison.Ordinal);
Assert.Contains("collection_time", sql[(select < 0 ? 0 : select)..from], StringComparison.Ordinal);

/* Exactly one MAX(collection_time): the anchor. A second one would be a stamp read that can see a
capture the returned rows did not come from. */
Assert.Single(System.Text.RegularExpressions.Regex.Matches(sql, @"MAX\(collection_time\)"));

/* The stamp reaches the row type at ordinal 0, which is the mapping the reader's GetDateTime(0) and
the tool's rows[0].CollectionTime both depend on: a member inserted ahead of it would shift every
positional read silently. */
Assert.Equal(
nameof(DarlingObjectStatsReader.IndexLockingRow.CollectionTime),
typeof(DarlingObjectStatsReader.IndexLockingRow).GetConstructors().Single().GetParameters()[0].Name);
}

[Fact]
public void DatabaseSizeLatestSql_LatestSnapshot_CarriesSizeAndVolume()
{
Expand Down Expand Up @@ -371,6 +413,12 @@ public async Task AfterADatabaseRename_AllFourLockingReads_ShowOnlyTheCurrentNam
Assert.Contains(mcpRows, r => r.DatabaseName == NewName && r.RowLockWaitInMs == 70_000);
Assert.DoesNotContain(mcpRows, r => r.DatabaseName == OldName);

/* #3880: every row the reader returns carries the NEWEST capture's stamp, because the anchor and
the stamp are now the same column of the same statement. The pre-rename capture is 30 days back
in this fixture, so a stamp taken from the wrong rows or re-read with a second MAX() over a
different predicate would be a MONTH off, not a rounding difference. */
Assert.All(mcpRows, r => Assert.Equal(newest, r.CollectionTime));

/* ...and through the tool itself, so the envelope an agent reads is checked too. Parsed, never
substring-matched on quoted text: the serializer escapes apostrophes. */
var payload = await DarlingMcpObjectStatsTools.GetObjectLocking(postgres, ServerName);
Expand All @@ -379,6 +427,19 @@ public async Task AfterADatabaseRename_AllFourLockingReads_ShowOnlyTheCurrentNam
Assert.Contains(NewName, names);
Assert.DoesNotContain(OldName, names);

/* The published stamp, read as JSON: present, round-trippable, and the newest capture's instant
rather than the rename-era one. #3541 A10's point is that a latest read says WHEN, and #3880's
is that a read anchored on ONE instant can say WHICH — this is that claim, executed. */
using (var doc = System.Text.Json.JsonDocument.Parse(payload))
{
var stamp = doc.RootElement.GetProperty("captured_at").GetString();
Assert.False(string.IsNullOrWhiteSpace(stamp));
var parsed = DateTime.Parse(stamp!, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind);
Assert.Equal(newest, parsed);
Assert.NotEqual(beforeRename, parsed);
}

/* 2. The Viewer's all-databases grid. */
var gridRows = await viewer.GetIndexLockingAsync(ServerId, 200, null, ct);
Assert.Contains(gridRows, r => r.DatabaseName == NewName && r.RowLockWaitInMs == 70_000);
Expand Down
39 changes: 29 additions & 10 deletions Darling/Darling.Tests/McpLatestSnapshotStampTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ dialect like every other latest read. */
(typeof(DarlingMcpJobTools), "get_running_jobs", "Lite/Mcp/McpJobTools.cs", Shape.Stamped),
(typeof(DarlingMcpDataTools), "get_server_properties", "Lite/Mcp/McpServerInfoTools.cs", Shape.Stamped),
(typeof(DarlingMcpSessionTools), "get_session_stats", "Lite/Mcp/McpSessionTools.cs", Shape.Stamped),
/* #3880: the first of the three object-stats residuals below to leave that list. Erik's ruling on the
call #3878/#3879 recorded: #3879 replaced this read's per-database_name MAX groups with the
server's newest capture, which made it one honest instant for the first time, and a read that
resolves one instant can say which instant — so it is stamped rather than rostered as debt on
McpPayloadContractCensusTests' shrink-only list. Stamped on BOTH SKUs in one lane (the twin is
#3877's), so this is a pair row like every other, not an asymmetry. */
(typeof(DarlingMcpObjectStatsTools), "get_object_locking", "Lite/Mcp/McpObjectStatsTools.cs", Shape.Stamped),
];

/// <summary>
Expand All @@ -104,19 +111,25 @@ dialect like every other latest read. */
/// <c>captured_at</c> without leaving this list, and so the gap is on the record rather than invisible.
/// Reported to #3541 as the A10 residual.
///
/// <para><b>The parenthetical this comment used to carry is now false, and its correction is a reason the
/// residual is easier to close than it was.</b> It said
/// <c>DarlingObjectStatsReader.IndexLockingSql</c> "takes MAX per database, not one instant" — true when
/// written, and the defect #3878 removed: a per-<c>database_name</c> MAX group is not one instant, which
/// is precisely why it kept returning databases renamed away a month earlier (#3876). All four Darling
/// locking reads now anchor on <c>(SELECT MAX(collection_time) … WHERE server_id = $1)</c>, so this read
/// IS one instant, like the rest of the family — exactly the property a <c>captured_at</c> stamp needs to
/// be truthful about. It stays on this list because it is still UNSTAMPED, which is the only thing the
/// list claims; nothing about the anchor exempts it from the sweep below.</para>
/// <para><b>#3880 took the first of the three</b>, and the route it took is the route the other two have.
/// This comment used to carry a parenthetical — <c>DarlingObjectStatsReader.IndexLockingSql</c> "takes MAX
/// per database, not one instant" — that was true when written and was itself the defect #3878 removed: a
/// per-<c>database_name</c> MAX group is not one instant, which is precisely why the read kept returning
/// databases renamed away a month earlier (#3876). All four Darling locking reads now anchor on
/// <c>(SELECT MAX(collection_time) … WHERE server_id = $1)</c>, making the read one instant like the rest
/// of the family — exactly the property a <c>captured_at</c> stamp needs to be truthful about. #3879 left
/// it here anyway, unstamped, which is all this list ever claimed; Erik ruled the other way in #3880, the
/// SQL projects its anchor column, <c>get_object_locking</c> publishes <c>captured_at</c> on both SKUs,
/// and the tool is a <see cref="LatestTools"/> row above.</para>
///
/// <para>The two that remain are an ordinary A10 residual each, not a different rule:
/// <c>get_index_usage</c> and <c>get_table_index_sizes</c> read the same daily snapshots through anchors
/// of the same shape, and the same one-line projection would stamp them. They stay because nobody has
/// measured what their callers read, not because the pattern does not reach them.</para>
/// </summary>
public static readonly string[] UnstampedLatestReadsPendingA10 =
[
"get_index_usage", "get_object_locking", "get_table_index_sizes",
"get_index_usage", "get_table_index_sizes",
];

/// <summary>
Expand Down Expand Up @@ -534,6 +547,11 @@ widened exemption. 26 latest-reading tool bodies at the time of writing. */
[InlineData(nameof(DarlingJobReader.RunningJobsSql), "collection_time")]
[InlineData(nameof(DarlingDataReader.LatestServerPropertiesSql), "collection_time")]
[InlineData(nameof(DarlingSessionReader.LatestSessionStatsSql), "collection_time")]
/* #3880: the object-locking read, stamped by Erik's ruling rather than rostered as unstamped debt. It is
held to the SAME rule as every other stamped read, and the rule is the whole reason the stamp is worth
having here: the anchor column rides the row statement, so the instant published is the instant the
returned rows came from, never a fresher capture a second MAX() read happened to see. */
[InlineData(nameof(DarlingObjectStatsReader.IndexLockingSql), "collection_time")]
public void EveryStampedRead_SelectsItsStampColumn_OnTheRowStatement(string sqlName, string column)
{
var sql = ReaderSql(sqlName);
Expand Down Expand Up @@ -690,6 +708,7 @@ projection carries collection_time at the SAME sixteen-space indent the old disc
nameof(DarlingPgLoggingAuditReader.NewestSnapshotSql) => DarlingPgLoggingAuditReader.NewestSnapshotSql,
nameof(DarlingPgServerConfigReader.CurrentConfigSql) => DarlingPgServerConfigReader.CurrentConfigSql,
nameof(DarlingObjectStatsReader.DatabaseSizeLatestSql) => DarlingObjectStatsReader.DatabaseSizeLatestSql,
nameof(DarlingObjectStatsReader.IndexLockingSql) => DarlingObjectStatsReader.IndexLockingSql,
nameof(DarlingJobReader.RunningJobsSql) => DarlingJobReader.RunningJobsSql,
nameof(DarlingDataReader.LatestServerPropertiesSql) => DarlingDataReader.LatestServerPropertiesSql,
nameof(DarlingSessionReader.LatestSessionStatsSql) => DarlingSessionReader.LatestSessionStatsSql,
Expand Down
39 changes: 23 additions & 16 deletions Darling/Darling.Tests/McpPayloadContractCensusTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -931,28 +931,35 @@ public void TheTotalDiscriminator_FlagsACountUnderATotalName_AndPassesACompariso
/// <summary>
/// The Darling reader SQL constants whose outer statement anchors on <c>= (SELECT MAX(collection_time |
/// capture_time) …)</c> — a latest-snapshot read — and do NOT project the anchor column, so the tool above
/// them cannot stamp the snapshot's time (#3541 A10, "latest is a time"). Three, all known:
/// them cannot stamp the snapshot's time (#3541 A10, "latest is a time"). Two, both known:
/// <c>IndexUsageSql</c> is the object-stats residual <see cref="McpLatestSnapshotStampTests.UnstampedLatestReadsPendingA10"/>
/// already holds; <c>IndexUsageMatchCountSql</c> is a <c>COUNT(*)</c> over the same anchor with no row to
/// stamp; and <c>IndexLockingSql</c> joined them in #3878.
/// already holds, and <c>IndexUsageMatchCountSql</c> is a <c>COUNT(*)</c> over the same anchor with no row
/// to stamp.
///
/// <para><b>Why this roster GREW, and why that is not the drift it looks like.</b> It was written
/// <para><b>This roster GREW once, in #3879, and #3880 shrank it back by ruling.</b> It is written
/// shrink-only, on the reasoning that a read either projects its anchor or is waiting for the A10 lane to
/// stamp it. <c>IndexLockingSql</c> was invisible to this census before #3878 for a reason that is
/// itself the defect: its anchor was a per-<c>database_name</c> <c>MAX(collection_time)</c> group inside a
/// CTE, and this scan deliberately ignores an anchor below depth zero because such an anchor picks a SET
/// rather than a snapshot. That was exactly true of the old shape — it picked one row per name the store
/// had ever seen, which is why a database renamed away was returned forever (#3876). Anchoring it on the
/// server's newest capture makes it a snapshot read for the first time, so the census sees it and asks the
/// standing question. Its answer is the same as its two neighbours': the row statement does not project
/// <c>collection_time</c> and <c>get_object_locking</c> publishes no <c>captured_at</c>, which is the A10
/// residual all three sit in. A roster entry gained by a read becoming MORE honest is the census working;
/// the shrink-only rule still governs departures, and all three leave together when A10 stamps the
/// family.</para>
/// stamp it. <c>IndexLockingSql</c> was invisible to this census before #3878 for a reason that was itself
/// the defect: its anchor was a per-<c>database_name</c> <c>MAX(collection_time)</c> group inside a CTE,
/// and this scan deliberately ignores an anchor below depth zero because such an anchor picks a SET rather
/// than a snapshot. That was exactly true of the old shape — it picked one row per name the store had ever
/// seen, which is why a database renamed away was returned forever (#3876). Anchoring it on the server's
/// newest capture made it a snapshot read for the first time, so the census saw it and asked the standing
/// question, and #3879's lane answered with a roster row on the pre-existing-debt argument: its two
/// neighbours are unstamped too, and all three would leave together when A10 stamped the family.
/// <b>Erik ruled the other way</b> (#3880): a read that has just become one honest instant can say WHICH
/// instant, so stamp it rather than roster it. <c>IndexLockingSql</c> projects <c>ios.collection_time</c>,
/// <c>get_object_locking</c> publishes <c>captured_at</c> on both SKUs, and this list is back to the two
/// entries it held before #3879 — the shrink-only direction restored, with the growth episode kept on the
/// record here rather than quietly erased.</para>
///
/// <para>The two survivors are not the same kind of gap. <c>IndexUsageSql</c> is a genuine A10 residual:
/// projecting its anchor and stamping <c>get_index_usage</c> is the same small edit #3880 made next door,
/// and it is available whenever the family is taken. <c>IndexUsageMatchCountSql</c> is structural — a
/// scalar <c>COUNT(*)</c> has no row for a stamp to ride on, so it leaves this list only if it ever
/// returns the anchor beside its count.</para>
/// </summary>
public static readonly string[] LatestAnchoredReadsWithoutTheirStamp =
[
"DarlingObjectStatsReader.cs IndexLockingSql",
"DarlingObjectStatsReader.cs IndexUsageMatchCountSql",
"DarlingObjectStatsReader.cs IndexUsageSql",
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ public static string Build(DarlingPeerDirectory.Snapshot peers)
| `get_trace_flags` | CURRENT active trace flags (latest snapshot) — flag number, enabled, global/session, as of `captured_at` (captured on connect) | `server_name` |
| `get_table_index_sizes` | The 100 largest tables with size + growth from the latest daily snapshot. Growth spans only history the store holds: `history` says how many days exist and whether the 7d/30d baselines are reachable; `growth_7d_mb` / `growth_30d_mb` / `growth_pct_30d` are null (reason in `growth_note`) when their baseline does not exist — never re-measured over a shorter span under the same name — and `growth_over_available_history_*` spans exactly `growth_window_days` | `server_name` |
| `get_index_usage` | Per-index usage classified Unused / Write-only / Active | `server_name` |
| `get_object_locking` | Per-index lock/latch contention, most contended first | `server_name` |
| `get_object_locking` | Per-index lock/latch contention, most contended first, at the newest index/object snapshot — `captured_at` is the instant it was collected, and the collection is DAILY, so on a healthy server the stamp is routinely hours old | `server_name` |
| `get_database_sizes` | Per-file database sizes, space usage, and volume free space | `server_name` |

### Resource-contention + jobs data-read tools
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ capability check still runs first — a wrong-engine target has no index_object_
}
}

[McpServerTool(Name = "get_object_locking"), Description("Gets per-index locking and latch contention (row/page lock waits in ms, lock escalations, page-latch and page-IO-latch waits) from the latest daily snapshot, top contended objects first. Use to find tables/indexes driving blocking and contention. Counters are cumulative since the last instance restart.")]
[McpServerTool(Name = "get_object_locking"), Description("Gets per-index locking and latch contention (row/page lock waits in ms, lock escalations, page-latch and page-IO-latch waits) from the latest daily snapshot, top contended objects first. Use to find tables/indexes driving blocking and contention. Counters are cumulative since the last instance restart. LATEST IS A TIME: this reads the newest index/object snapshot for the server, not a window, and captured_at is the instant it was collected - these are the databases and indexes that existed AT that stamp, and because object stats are collected DAILY the stamp can be most of a day old on a healthy server and older still on one whose collector has stalled.")]
public static async Task<string> GetObjectLocking(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null)
Expand Down Expand Up @@ -267,6 +267,15 @@ public static async Task<string> GetObjectLocking(
return JsonSerializer.Serialize(new
{
server = resolved.ServerName,
/* #3880 (Erik's ruling on the judgment call #3878/#3879 recorded): captured_at, the #3637
census's one spelling for a latest read's stamp - see GetDatabaseSizes below, and
DarlingMcpDataTools.GetServerProperties for why it is a cut-over and not an alias. Every
row of this read comes from ONE capture (the anchor #3879 substituted for the immortal
per-name MAX groups of #3876), so rows[0] IS the snapshot's stamp for all of them; the
#3879 lane rostered the read as unstamped debt instead, and the ruling was to stamp it so
the shrink-only roster shrinks. A daily-collected read most needs this: without it an
agent reads a 23-hour-old contention picture as "now". */
captured_at = rows[0].CollectionTime.ToString("o"),
objects = result
}, McpHelpers.JsonOptions);
}
Expand Down
Loading
Loading