From fa542873cfc0ec9bd43ee969ea26f1756846cb09 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:30:46 +0100 Subject: [PATCH 01/10] Anchor a windowed read at a past incident, not only at now (#2495) Every MCP read that takes hours_back measured it backwards from now, so no caller could ask what a server was doing during a past incident. Widening hours_back until the incident falls inside is not the same question: for an aggregate read a wider window is a DIFFERENT answer, not the same answer with more rows. as_of is an optional ISO-8601 UTC anchor that moves the window's END off now while hours_back stays its LENGTH. 52 Darling reads and 45 Lite reads carry it with one shared name, description and validator; the web catalog and dispatch carry it too, so /api/read/{name} reaches it as well, and a test pins the two surfaces together in both directions. Backward compatible by construction: the anchor defaults to now, and ValidateWindow returns exactly what ValidateHoursBack returned when no anchor is sent. An anchor we cannot use is REFUSED (unparseable, or in the future past a clock-skew allowance) rather than quietly answered as of now. An anchor older than anything the store holds is NOT refused -- retention is per-deployment, so a floor would be a guess -- and comes back as the read's own empty/unavailable. Three reads that bounded only their lower edge now bound both; for alert history that is a correctness fix, since its cap is applied by the database. Left unanchored, and said so in both instructions files: the analysis family windows inside the analysis engine, and get_pvs_stats / get_fleet_overview mix a latest-snapshot measurement with a windowed one. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 + .../Darling.Tests/AsOfWindowAnchorTests.cs | 405 ++++++++++++++++++ .../DarlingMcpAlertToolsTests.cs | 12 +- .../DarlingMcpBlockingToolsTests.cs | 12 +- .../DarlingMcpConfigHistoryToolsTests.cs | 6 +- .../Darling.Tests/DarlingMcpDataToolsTests.cs | 16 +- .../DarlingMcpDefaultTraceToolsTests.cs | 2 +- .../DarlingMcpHealthParserToolsTests.cs | 2 +- .../DarlingMcpLatchSpinlockToolsTests.cs | 2 +- .../DarlingMcpMemoryGrantToolsTests.cs | 2 +- .../DarlingMcpPlanCacheSchedulerToolsTests.cs | 2 +- .../DarlingMcpPlanCorrectionToolsTests.cs | 2 +- .../DarlingMcpSessionToolsTests.cs | 4 +- .../DarlingWebEndpoints.cs | 246 ++++++----- .../Mcp/DarlingAlertReader.cs | 21 +- .../Mcp/DarlingDataReader.cs | 12 +- .../Mcp/DarlingMcpAlertTools.cs | 9 +- .../Mcp/DarlingMcpBlockingTools.cs | 38 +- .../Mcp/DarlingMcpConfigHistoryTools.cs | 42 +- .../Mcp/DarlingMcpDataTools.cs | 80 ++-- .../Mcp/DarlingMcpDefaultTraceTools.cs | 7 +- .../Mcp/DarlingMcpHealthParserTools.cs | 57 +-- .../Mcp/DarlingMcpInstructions.cs | 108 ++--- .../Mcp/DarlingMcpLatchSpinlockTools.cs | 14 +- .../Mcp/DarlingMcpLongQueryTools.cs | 7 +- .../Mcp/DarlingMcpMemoryGrantTools.cs | 21 +- .../Mcp/DarlingMcpPgAutovacuumTools.cs | 7 +- .../Mcp/DarlingMcpPgBlockingTools.cs | 7 +- .../Mcp/DarlingMcpPgIoTools.cs | 7 +- .../Mcp/DarlingMcpPgSlotTools.cs | 7 +- .../Mcp/DarlingMcpPgStatementTools.cs | 7 +- .../Mcp/DarlingMcpPgWaitTools.cs | 7 +- .../Mcp/DarlingMcpPgWraparoundTools.cs | 7 +- .../Mcp/DarlingMcpPgXminTools.cs | 7 +- .../Mcp/DarlingMcpPlanCacheSchedulerTools.cs | 7 +- .../Mcp/DarlingMcpPlanCorrectionTools.cs | 7 +- .../DarlingMcpQueryStoreRegressionTools.cs | 7 +- .../Mcp/DarlingMcpSessionTools.cs | 14 +- .../Mcp/DarlingMcpTrendTools.cs | 49 ++- Lite.Tests/AsOfWindowAnchorTests.cs | 276 ++++++++++++ Lite/Mcp/McpAlertTools.cs | 7 +- Lite/Mcp/McpBlockingTools.cs | 43 +- Lite/Mcp/McpConfigHistoryTools.cs | 21 +- Lite/Mcp/McpCpuTools.cs | 7 +- Lite/Mcp/McpDefaultTraceTools.cs | 7 +- Lite/Mcp/McpHealthParserTools.cs | 63 +-- Lite/Mcp/McpHealthTools.cs | 28 +- Lite/Mcp/McpInstructions.cs | 110 ++--- Lite/Mcp/McpIoTools.cs | 5 +- Lite/Mcp/McpLatchSpinlockTools.cs | 14 +- Lite/Mcp/McpLongQueryTools.cs | 7 +- Lite/Mcp/McpMemoryTools.cs | 26 +- Lite/Mcp/McpPerfmonTools.cs | 9 +- Lite/Mcp/McpPlanCacheSchedulerTools.cs | 16 +- Lite/Mcp/McpPlanCorrectionTools.cs | 7 +- Lite/Mcp/McpQueryTools.cs | 66 +-- Lite/Mcp/McpSessionTools.cs | 7 +- Lite/Mcp/McpTempDbTools.cs | 7 +- Lite/Mcp/McpWaitTools.cs | 28 +- .../Services/LocalDataService.AlertHistory.cs | 17 +- Lite/Services/LocalDataService.Blocking.cs | 20 +- .../LocalDataService.BlockingStats.cs | 8 +- .../LocalDataService.CollectionHealth.cs | 4 +- .../LocalDataService.ConfigChanges.cs | 12 +- Lite/Services/LocalDataService.Cpu.cs | 4 +- .../Services/LocalDataService.CpuScheduler.cs | 4 +- Lite/Services/LocalDataService.FileIo.cs | 4 +- .../LocalDataService.LatchSpinlock.cs | 8 +- Lite/Services/LocalDataService.LongQueries.cs | 4 +- Lite/Services/LocalDataService.Memory.cs | 8 +- .../Services/LocalDataService.MemoryGrants.cs | 8 +- Lite/Services/LocalDataService.Perfmon.cs | 8 +- Lite/Services/LocalDataService.PlanCache.cs | 8 +- .../LocalDataService.PlanCorrection.cs | 4 +- Lite/Services/LocalDataService.QueryStats.cs | 20 +- Lite/Services/LocalDataService.QueryStore.cs | 8 +- .../LocalDataService.QueryStoreRegressions.cs | 8 +- .../Services/LocalDataService.SystemEvents.cs | 40 +- Lite/Services/LocalDataService.TempDb.cs | 4 +- Lite/Services/LocalDataService.WaitStats.cs | 12 +- .../Services/LocalDataService.WaitingTasks.cs | 22 +- Lite/Services/LocalDataService.cs | 18 +- PerformanceMonitor.Common/Mcp/McpHelpers.cs | 96 +++++ 83 files changed, 1686 insertions(+), 688 deletions(-) create mode 100644 Darling/Darling.Tests/AsOfWindowAnchorTests.cs create mode 100644 Lite.Tests/AsOfWindowAnchorTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f47c9c901..9c08168662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Every windowed read can be anchored at a past incident, not just at now** ([#2495]) - every MCP read that takes `hours_back` measured it backwards from *now*, so no caller could ask what a server was doing during last Tuesday's incident. Widening `hours_back` until Tuesday falls inside is not the same question: for an aggregate read a wider window is a **different answer**, not the same answer with more rows - it changes what a top-N returns, what an average is taken over, and how much a capped read truncates. **`as_of`** is now an optional ISO-8601 UTC anchor on **52 Darling reads and 45 Lite reads** (identical name, semantics and description on both SKUs, from one shared constant), moving the window's END off now while `hours_back` stays its LENGTH - so "the four hours around 03:00 last Tuesday" is one call instead of a 170-hour window filtered by hand. It reaches the web surface too: `/api/read/{name}` is machine-derived from the MCP catalog, so each read's descriptor carries `as_of` and a test pins the two surfaces together in both directions. **Backward compatible by construction** - the anchor defaults to now, so a caller sending only `hours_back` gets byte-for-byte the window it always got, and `ValidateWindow` returns exactly what `ValidateHoursBack` returned when no anchor is sent. Following the `ValidateTop` convention, an anchor we cannot use is **refused** rather than quietly replaced: an unparseable value, or one in the future (past a five-minute client-clock allowance), comes back as a message, because a read that silently reverts to now is indistinguishable from a correct one. An anchor older than anything the store still holds is **not** refused - retention is per-deployment, per-server and per-collector, so a hardcoded floor would be a guess; it returns the read's own `empty` / `unavailable` status, which is unambiguous because the caller chose the window. Three reads that bounded only the LOWER edge (`get_cpu_utilization`, `get_tempdb_trend`, `get_alert_history`) now bound both, which for alert history is a correctness fix as well: its cap is applied by the database, so trimming after the read would have spent the whole `LIMIT` on rows newer than the anchor. Left unanchored on purpose, and stated in the instructions: the analysis family (`analyze_server`, `get_analysis_facts`, `compare_analysis`, `get_analysis_findings`) windows inside the analysis engine rather than at the read, and `get_pvs_stats` / `get_fleet_overview` mix a latest-snapshot measurement with a windowed one, where anchoring only the windowed half would return a result whose two halves describe different instants. - **Five ready-made dashboards, so a new user's Custom Views page is not an empty one** ([#2480]) - no custom view ships seeded, so the first thing a new user meets at `#/views` is a blank page and a blank canvas over an 82-read catalog. The first-run hero already knew what would help and rendered it as three **inert** chips - "Top waits by server", "CPU trend over time", "Slowest procedures by database", no handler, no href - which is the [#2437] defect shape (a promise rendered as a caption) on the one page nobody arrives at with context. Those chips are now the real templates: **Server health at a glance**, **CPU investigation**, **Blocking and deadlocks**, **Memory pressure** and **Configuration review**, each created in one click against a server picked beside them, and they sit in the existing "New from template" menu alongside the notebook seeds ([#1563] D7) rather than in a second affordance. **Templates, not seeded rows**: seeding would need a migration rung, a `StorageVersion` bump, four pinned test files and a Viewer probe sentinel for content that is not schema - and a seeded row RESURRECTS itself on the next upgrade after the user deletes it, with no reset path if an editing seat breaks one. A template is created only when asked for, and what lands is an ordinary view the user owns, edits and can delete for good. The two halves of the menu behave differently on purpose: a notebook template links to the composer pre-filled because its composed panels re-scope live from the notebook's own controls, while a dashboard template is v1 READ panels whose `server` param is **static** (`renderView` threads variables and range into composed panels only), so it is created against the server chosen in the menu - and the server name goes into the view NAME, so two servers' copies of one template do not collide on the unique-name constraint. **Honest on day one**, which for a starter dashboard is the whole game - it is the first screen a UAT tester opens, on the store with the least data it will ever have. Every table and chart panel carries its own empty-state sentence (a count pin, not a spot-check), and the two reads that CANNOT be honest on a fresh install are deliberately absent rather than merely unused: analysis findings (the pass writes nothing for 24 hours) and Query Store (a target with it off has nothing, ever). The chart case is the one that bites without looking like it - `get_blocking_trend` and `get_deadlock_trend` answer an IDLE server with `trend: []` and no `{status,message}` envelope, so those panels say an empty trend means none happened rather than inheriting a sentence about collection. Every empty and failure state is stated rather than blank: a fleet with no servers says why the dashboards are unavailable instead of rendering an empty picker, and a refused create surfaces the backend's own message verbatim (with a 409 getting its own sentence, since a second click of the same template is the likely failure) because flattening it to "could not create" would hide the one line that says which panel is wrong. Pinned by the same invariant the built-in pages carry - every read exists in the shipped dispatch, every parameter key is one its read binds, every viz is in the shipped vocabulary - and each of the five definitions was fed to the service's own `ValidateDefinition`, the authority that would refuse the POST. - **The web dashboard's server page gets the viewer's tabs: twelve sections, 61 of the 82 served reads, and a time range** ([#2475]) - the service dispatched **82** reads at `GET /api/read/{name}` and the built-in pages reached **23** of them; `#/server/{name}` was one scroll of seven panels against the desktop viewer's **65** `TabItem`s. The gap was never backend work - `panels.js` has been a generic renderer over those reads since #1562 - so this is descriptors over the unchanged `renderPanel` seam: twelve sub-tabs (Overview, Wait Stats, CPU, Memory, Blocking, File I/O, Queries, Configuration, Config Changes, Activity, System Events, Collection Health) carrying ~70 panels, reaching **61** reads. The header carries the WHY beneath the band badge - `Warning` has three unrelated causes (a real metric breach, a server awaiting its first collection, a collector error), so a badge reading "Warning" with no way to ask why is [#2422] rebuilt on a new surface; the fleet's own reason string and the fleet card's severity chips are rendered there, the chips through `fleet.js`'s own `metricBands` so there is one implementation rather than two, and neither is re-derived in the browser (R1). Every web grid that renders query text now puts it immediately right of its time/identity anchor ([#1949]) and the pin that enforced that on one array enforces it on all seven. The tab id rides in the hash (`#/server/{name}/{tab}`) so a section is deep-linkable and survives the 60s refresh, and an unknown or absent id resolves to Overview, which is what keeps every existing `#/server/{name}` link working. A page-level range picker (1h/4h/12h/24h/7d/30d) is the twin of the viewer's toolbar presets - **not** persisted, because a page that reopens on a 30-day window is slow for a reason the reader cannot see; panels whose read takes no window at all say "latest snapshot" rather than inheriting a label that would misdescribe them. Two reads were previously **unreachable from a browser** because they require a parameter no UI collected - `get_wait_trend` needs a `wait_type` and `get_perfmon_trend` a `counter_name` - and both now have a picker, the wait one seeded from the rows of the table directly above it so the picker and the table cannot disagree. **No fifth viz kind was added**: the property-grid shape that tempted one is served by `stat` (the reads returning a flat object) and `table` (the ones already returning rows), and a fifth kind that lived only in `panels.js` would be a page-only special case, while doing it properly means `KnownVizList`, `derive.js` and an editor config arm - composer surface this change does not need. What the browser genuinely cannot do is **stated in the tab where a reader goes looking for it** rather than left as a page that quietly lacks a feature: plan analysis, the query heatmap, cached-plan retrieval and actual-plan re-execution need a plan renderer and a command back to the monitored server, and the block-chain view and interactive deadlock graph need a graph viewer - the Blocking tab hands over the captured blocked-process-report and deadlock-graph XML verbatim instead of pretending. Every data panel supplies its own empty-state sentence and both helpers THROW without one. vizTable's generic "No rows in this window" reads as a fault on a collector that is off, opt-in, or daily; and the chart case was worse - `get_blocking_trend` and `get_deadlock_trend` answer an IDLE server with `trend: []` and no `{status,message}` envelope at all, so a perfectly healthy server was told its blocking chart did not have "enough data points to chart yet". `vizLine` now renders a descriptor's `emptyText` at exactly ZERO rows and still falls through at one (where the chart's own sentence is the true one) and when no `emptyText` was authored, so every stored view predating this is unchanged. A read feeding several panels on one tab is fetched ONCE (`fanout`) rather than per descriptor - `readTool` has no cache, so `get_collection_health`, which rolls up seven days of collector logs and computes sweep pressure, was running three times to open its own tab; six such duplicates existed across five tabs and a pin now refuses a seventh. Guarded by an invariant rather than by spot-checks: every read name the module mentions must exist in the shipped dispatch, every parameter key must be one its read actually binds (an unknown query key is silently ignored, so `limit` sent to a read binding `top` quietly returns the default), every viz must be in the shipped vocabulary, and no `get_pg_*` read may appear until the fleet payload can tell a PostgreSQL target from a SQL Server one - it carries `engine_edition`, not a `CollectorTargetEngine`, so a PostgreSQL panel today would render on all 42 SQL Servers, permanently empty. @@ -2852,6 +2853,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#2312]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2312 [#2306]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2306 [#2302]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2302 +[#2495]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2495 [#2480]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2480 [#2489]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2489 [#2481]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2481 diff --git a/Darling/Darling.Tests/AsOfWindowAnchorTests.cs b/Darling/Darling.Tests/AsOfWindowAnchorTests.cs new file mode 100644 index 0000000000..14038d62ed --- /dev/null +++ b/Darling/Darling.Tests/AsOfWindowAnchorTests.cs @@ -0,0 +1,405 @@ +/* + * 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.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// #2495: the window ANCHOR. Every windowed read took hours_back measured from now, so nothing +/// could ask about a past incident — and widening hours_back until the incident falls inside is +/// NOT the same question, because for an aggregate read a wider window is a different answer rather than +/// the same answer with more rows. +/// +/// These are the ungated halves: the shared resolver's contract, and the two surfaces agreeing about +/// which reads carry the parameter. The live half — seed a past window, read it anchored, read it +/// unanchored, prove the two disagree — is . +/// +public sealed class AsOfWindowAnchorTests +{ + /* ── the resolver's contract ── */ + + [Fact] + public void NoAnchor_ResolvesToNow_WhichIsThePreChangeBehaviour() + { + var before = DateTime.UtcNow; + Assert.Null(McpHelpers.ResolveAsOf(null, out var end)); + var after = DateTime.UtcNow; + + Assert.InRange(end, before, after); + Assert.Equal(DateTimeKind.Utc, end.Kind); + } + + [Fact] + public void BlankAnchor_IsTreatedAsAbsent_NotAsAParseFailure() + { + var before = DateTime.UtcNow; + Assert.Null(McpHelpers.ResolveAsOf(" ", out var end)); + Assert.True(end >= before); + } + + /// + /// The four accepted spellings all land on ONE instant. The offset-less form matters most: it is read + /// as UTC, never as the SERVICE HOST's local time — the store is UTC throughout and the caller is an + /// agent on some other machine, so a local-time reading would silently shift the window by the host's + /// offset with nothing in the result to show for it. + /// + [Theory] + [InlineData("2026-08-18T14:30:00Z")] + [InlineData("2026-08-18T14:30:00")] + [InlineData("2026-08-18T16:30:00+02:00")] + [InlineData("2026-08-18T09:30:00-05:00")] + public void EveryAcceptedSpelling_ResolvesToTheSameUtcInstant(string asOf) + { + Assert.Null(McpHelpers.ResolveAsOf(asOf, out var end)); + Assert.Equal(new DateTime(2026, 8, 18, 14, 30, 0, DateTimeKind.Utc), end); + Assert.Equal(DateTimeKind.Utc, end.Kind); + } + + [Fact] + public void DateOnly_IsMidnightUtc() + { + Assert.Null(McpHelpers.ResolveAsOf("2026-08-18", out var end)); + Assert.Equal(new DateTime(2026, 8, 18, 0, 0, 0, DateTimeKind.Utc), end); + } + + /// + /// An anchor we cannot use is REFUSED, following . Silently falling + /// back to now is the one outcome this parameter exists to prevent: a read answering "the last 4 hours" + /// when it was asked for "the 4 hours ending Tuesday 03:00" is indistinguishable from a correct answer. + /// + [Theory] + [InlineData("last tuesday")] + [InlineData("2026-13-45")] + [InlineData("4 hours ago")] + public void AnUnusableAnchor_IsRefused_NotSilentlyTreatedAsNow(string asOf) + { + var error = McpHelpers.ResolveAsOf(asOf, out _); + Assert.NotNull(error); + Assert.Contains("Invalid as_of", error, StringComparison.Ordinal); + Assert.Contains(asOf, error, StringComparison.Ordinal); + } + + [Fact] + public void AFutureAnchor_IsRefused_BecauseTheStoreCannotHoldDataNotYetCollected() + { + var error = McpHelpers.ResolveAsOf(DateTime.UtcNow.AddHours(2).ToString("o"), out _); + Assert.NotNull(error); + Assert.Contains("future", error, StringComparison.Ordinal); + } + + /// + /// The future refusal has a clock-skew allowance, and it is not a grace period for asking about the + /// future: an agent that computes "now" from its own clock and sends it must not be refused because + /// that clock runs a minute fast, on a stored read whose newest row is minutes old anyway. + /// + [Fact] + public void AClientClockRunningSlightlyFast_IsStillAccepted() + { + var justInside = DateTime.UtcNow + McpHelpers.AsOfFutureTolerance - TimeSpan.FromMinutes(1); + Assert.Null(McpHelpers.ResolveAsOf(justInside.ToString("o"), out _)); + } + + /// + /// There is deliberately NO lower bound. An anchor older than anything the store holds is a legitimate + /// question whose honest answer is the read's own empty / unavailable status — and the + /// caller knows the anchor they sent, so that status is unambiguous. A hardcoded floor would have to + /// guess at retention, which is per-deployment, per-server and per-collector. + /// + [Fact] + public void AnAnchorOlderThanAnyRetention_IsAccepted_AndLeftToTheReadsOwnMissVocabulary() + { + Assert.Null(McpHelpers.ResolveAsOf("1999-01-01T00:00:00Z", out var end)); + Assert.Equal(1999, end.Year); + } + + /* ── the two knobs together ── */ + + [Fact] + public void ValidateWindow_ReportsTheSpanBeforeTheAnchor_SoTheOldMessageIsUnchanged() + { + /* A caller who sent both wrong is told about hours_back first, exactly as before as_of existed. */ + var zeroHours = McpHelpers.ValidateWindow(0, "not a date", out _); + Assert.NotNull(zeroHours); + Assert.Contains("hours_back", zeroHours, StringComparison.Ordinal); + + var tooManyHours = McpHelpers.ValidateWindow(McpHelpers.MaxHoursBack + 1, "not a date", out _); + Assert.NotNull(tooManyHours); + Assert.Contains("exceeds maximum", tooManyHours, StringComparison.Ordinal); + } + + [Fact] + public void ValidateWindow_WithNoAnchor_IsExactlyTheOldValidateHoursBack() + { + foreach (var hours in new[] { -1, 0, 1, 24, McpHelpers.MaxHoursBack, McpHelpers.MaxHoursBack + 1 }) + { + Assert.Equal(McpHelpers.ValidateHoursBack(hours), McpHelpers.ValidateWindow(hours, null, out _)); + } + } + + [Fact] + public void TheWindow_IsHoursBackEndingAtTheAnchor() + { + Assert.Null(McpHelpers.ValidateWindow(3, "2026-08-18T15:30:00Z", out var end)); + + Assert.Equal(new DateTime(2026, 8, 18, 15, 30, 0, DateTimeKind.Utc), end); + Assert.Equal(new DateTime(2026, 8, 18, 12, 30, 0, DateTimeKind.Utc), end.AddHours(-3)); + } + + /* ── the two surfaces carry the same convention ── */ + + /// + /// A read reachable over MCP but not over /api/read/{name} is the failure mode the catalog exists + /// to prevent: the descriptor is the ONLY input truth for the web surface, so a parameter missing from it + /// is simply unreachable there, and an unknown query key is ignored rather than rejected — the panel + /// quietly gets the read's default window and nothing anywhere says so. + /// + [Fact] + public void EveryDispatchedReadWhoseToolTakesAnAnchor_AdvertisesItInTheCatalog() + { + var missing = DarlingWebEndpoints.BuildReadDispatch().Keys + .Where(ToolTakesAnAnchor) + .Where(name => !DarlingWebEndpoints.CatalogDescriptors[name].Params.Any(p => p.Name == "as_of")) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + + Assert.True( + missing.Length == 0, + "these reads take as_of over MCP but do not advertise it in the catalog, so it cannot be sent " + + "over /api/read: " + string.Join(", ", missing)); + } + + /// The other direction: nothing advertises a parameter its tool would ignore. + [Fact] + public void NoCatalogEntry_AdvertisesAnAnchorItsToolDoesNotTake() + { + var extra = DarlingWebEndpoints.BuildReadDispatch().Keys + .Where(name => DarlingWebEndpoints.CatalogDescriptors[name].Params.Any(p => p.Name == "as_of")) + .Where(name => !ToolTakesAnAnchor(name)) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + + Assert.True(extra.Length == 0, "catalog advertises as_of for reads whose tool ignores it: " + string.Join(", ", extra)); + } + + /// + /// The convention itself: a read that windows takes the anchor. The exclusions are named rather than + /// discovered, so removing one from the list is a deliberate act — and adding a new windowed read + /// without an anchor fails here rather than shipping half the convention. + /// + [Fact] + public void EveryWindowedRead_TakesTheAnchor_ExceptTheNamedExclusions() + { + /* analyze_server / get_analysis_* / compare_analysis window INSIDE the analysis engine (they hand it + a bare hours_back), so anchoring them is an engine change rather than a read-surface one, and + analyze_server also PERSISTS what it finds. get_pvs_stats and get_fleet_overview mix a + latest-snapshot measurement with a windowed one: anchoring only the windowed half would return a + result whose two halves describe different instants, which is worse than not offering it. + get_store_metrics windows in days over the store's own growth series. */ + var excluded = new[] + { + "analyze_server", "get_analysis_facts", "get_analysis_findings", "compare_analysis", + "get_pvs_stats", "get_fleet_overview", "get_store_metrics", + }; + + var unanchored = DarlingWebEndpoints.BuildReadDispatch().Keys + .Where(name => DarlingWebEndpoints.CatalogDescriptors[name].Params.Any(p => p.Name == "hours")) + .Where(name => !DarlingWebEndpoints.CatalogDescriptors[name].Params.Any(p => p.Name == "as_of")) + .Where(name => !excluded.Contains(name, StringComparer.Ordinal)) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + + Assert.True( + unanchored.Length == 0, + "these reads window but cannot be anchored, and are not on the named exclusion list: " + + string.Join(", ", unanchored)); + } + + private static bool ToolTakesAnAnchor(string readName) + { + var method = typeof(DarlingMcpDataTools).Assembly.GetTypes() + .SelectMany(t => t.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)) + .FirstOrDefault(m => m.GetCustomAttributes(typeof(ModelContextProtocol.Server.McpServerToolAttribute), false) + .Cast() + .Any(a => a.Name == readName)); + + return method is not null && method.GetParameters().Any(p => p.Name == "as_of"); + } +} + +/// +/// Gated (DARLING_TEST_PG) proof of the whole point: rows seeded in a PAST window come back when the read +/// is anchored there and do NOT come back on the default anchor — and widening hours_back instead is +/// a demonstrably different answer, not the same one with more rows. +/// +[Collection("live-postgres")] +public sealed class AsOfWindowAnchorLivePostgresTests +{ + private const string ServerName = "darling-asof-anchor-e2e"; + private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName); + + /* The incident: 30 hours ago, i.e. OUTSIDE every default window on the surface (the widest default is + 24). "Now" carries a different wait type so the two windows are told apart by content, not by count. */ + private const string IncidentWait = "PAGEIOLATCH_SH"; + private const string RecentWait = "CXPACKET"; + + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + [Fact] + public async Task AnAnchoredRead_SeesThePastWindow_AndTheDefaultAnchorDoesNot() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live as_of test."); + + var ct = TestContext.Current.CancellationToken; + 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 RegisterServerAsync(connection, ct); + + var now = Truncate(DateTime.UtcNow); + var incident = now.AddHours(-30); + var anchor = incident.AddMinutes(30).ToString("o"); /* the incident sits inside a 4-hour window ending here */ + + await PlantWaitAsync(connection, incident, IncidentWait, 900_000L, ct); + await PlantWaitAsync(connection, incident.AddMinutes(5), IncidentWait, 800_000L, ct); + await PlantWaitAsync(connection, now.AddMinutes(-10), RecentWait, 1_000L, ct); + + /* 1. The default anchor is unchanged: the last 24 hours, which the incident is not in. */ + var live = await DarlingMcpDataTools.GetWaitStats(postgres, ServerName); + Assert.Contains(RecentWait, live, StringComparison.Ordinal); + Assert.DoesNotContain(IncidentWait, live, StringComparison.Ordinal); + + /* 2. Anchored at the incident, the SAME read returns the incident and nothing since. This is + the capability the issue says was unreachable. */ + var anchored = await DarlingMcpDataTools.GetWaitStats(postgres, ServerName, 4, 20, anchor); + Assert.Contains(IncidentWait, anchored, StringComparison.Ordinal); + Assert.DoesNotContain(RecentWait, anchored, StringComparison.Ordinal); + + /* 3. And widening hours_back is NOT the same question. A 48-hour window reaches the incident, + but it reaches everything since too — the aggregate now describes both, so the top-N and + the totals are answers to a question nobody asked. */ + var widened = await DarlingMcpDataTools.GetWaitStats(postgres, ServerName, 48); + Assert.Contains(IncidentWait, widened, StringComparison.Ordinal); + Assert.Contains(RecentWait, widened, StringComparison.Ordinal); + Assert.NotEqual(WaitTypesIn(anchored).Count, WaitTypesIn(widened).Count); + + /* 4. The collection log — the read the gap was noticed on — moves with the anchor too, and its + two-branch miss still tells a quiet window from a server that never collected. */ + await PlantCollectionLogAsync(connection, incident, ct); + var logAnchored = await DarlingMcpDataTools.GetCollectionLog(postgres, ServerName, 4, 200, anchor); + Assert.Contains("wait_stats", logAnchored, StringComparison.Ordinal); + Assert.Equal("empty", StatusOf(await DarlingMcpDataTools.GetCollectionLog(postgres, ServerName))); + + /* 5. Refusals reach the caller as the tool's own message, not as a silently-different answer. */ + var future = await DarlingMcpDataTools.GetWaitStats(postgres, ServerName, 4, 20, DateTime.UtcNow.AddDays(1).ToString("o")); + Assert.Contains("future", future, StringComparison.Ordinal); + Assert.StartsWith("Invalid as_of", await DarlingMcpDataTools.GetWaitStats(postgres, ServerName, 4, 20, "last tuesday"), StringComparison.Ordinal); + + /* 6. An anchor older than anything the store holds is the read's honest empty, not a refusal. */ + Assert.Equal( + "unavailable", + StatusOf(await DarlingMcpDataTools.GetWaitStats(postgres, ServerName, 4, 20, "1999-01-01T00:00:00Z"))); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) => + await DeleteRowsAsync(cleanup, cleanupCt)); + } + } + + private static System.Collections.Generic.List WaitTypesIn(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("waits").EnumerateArray() + .Select(w => w.GetProperty("wait_type").GetString()!) + .ToList(); + } + + private static string StatusOf(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("status").GetString()!; + } + + private static DateTime Truncate(DateTime value) => + new(value.Year, value.Month, value.Day, value.Hour, value.Minute, value.Second, DateTimeKind.Utc); + + private static DateTime Naive(DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Unspecified); + + private static async Task RegisterServerAsync(NpgsqlConnection connection, CancellationToken ct) + { + using var command = new NpgsqlCommand(@" +INSERT INTO servers (server_id, server_name, display_name, is_enabled, sql_major_version, created_date, modified_date) +VALUES ($1, $2, $3, TRUE, 15, $4, $4) +ON CONFLICT (server_id) DO UPDATE SET is_enabled = TRUE, sql_major_version = 15;", connection); + command.Parameters.AddWithValue(ServerId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(Naive(DateTime.UtcNow)); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task PlantWaitAsync(NpgsqlConnection connection, DateTime at, string waitType, long deltaMs, CancellationToken ct) + { + using var command = new NpgsqlCommand(@" +INSERT INTO wait_stats (collection_id, collection_time, server_id, server_name, wait_type, delta_wait_time_ms, delta_signal_wait_time_ms, delta_waiting_tasks) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8)", connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(Naive(at)); + command.Parameters.AddWithValue(ServerId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(waitType); + command.Parameters.AddWithValue(deltaMs); + command.Parameters.AddWithValue(deltaMs / 10); + command.Parameters.AddWithValue(50L); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task PlantCollectionLogAsync(NpgsqlConnection connection, DateTime at, CancellationToken ct) + { + using var command = new NpgsqlCommand(@" +INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, duration_ms, status, error_message, rows_collected, sql_duration_ms, duckdb_duration_ms) +VALUES ($1, $2, $3, $4, $5, 120, 'SUCCESS', NULL, 7, 90, 30)", connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(ServerId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue("wait_stats"); + command.Parameters.AddWithValue(Naive(at)); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, CancellationToken ct) + { + var sql = string.Join(" ", new[] { "wait_stats", "collection_log" } + .Select(table => $"DELETE FROM {table} WHERE server_id = {ServerId};")); + sql += $" DELETE FROM servers WHERE server_id = {ServerId};"; + using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(ct); + } +} diff --git a/Darling/Darling.Tests/DarlingMcpAlertToolsTests.cs b/Darling/Darling.Tests/DarlingMcpAlertToolsTests.cs index 1196f74266..27c7756bfb 100644 --- a/Darling/Darling.Tests/DarlingMcpAlertToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpAlertToolsTests.cs @@ -84,7 +84,7 @@ private static (string Name, bool Optional)[] McpParams(string toolName) } [Theory] - [InlineData("get_alert_history", "server_name,hours_back,limit")] + [InlineData("get_alert_history", "server_name,hours_back,limit,as_of")] [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")] @@ -128,9 +128,12 @@ public void AlertHistorySql_ReadsLog_ExcludesDismissed_ServerScoped() var sql = Reader.AlertHistorySql; Assert.Contains("FROM config_alert_log", sql, StringComparison.Ordinal); Assert.Contains("dismissed = FALSE", sql, StringComparison.Ordinal); - Assert.Contains("server_id = $2", sql, 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); + Assert.Contains("server_id = $3", sql, StringComparison.Ordinal); Assert.Contains("ORDER BY alert_time DESC", sql, StringComparison.Ordinal); - Assert.Contains("LIMIT $3", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT $4", sql, StringComparison.Ordinal); } [Fact] @@ -140,7 +143,8 @@ public void AlertHistoryAllServersSql_ReadsLog_ExcludesDismissed_NoServerFilter( Assert.Contains("FROM config_alert_log", sql, StringComparison.Ordinal); Assert.Contains("dismissed = FALSE", sql, StringComparison.Ordinal); Assert.DoesNotContain("server_id =", sql, StringComparison.Ordinal); /* fleet-wide */ - Assert.Contains("LIMIT $2", sql, StringComparison.Ordinal); + Assert.Contains("alert_time <= $2", sql, StringComparison.Ordinal); /* #2495 upper edge */ + Assert.Contains("LIMIT $3", sql, StringComparison.Ordinal); } /// diff --git a/Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs b/Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs index 8ff14210bc..5137ffee49 100644 --- a/Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs @@ -88,12 +88,12 @@ private static (string Name, bool Optional)[] McpParams(string toolName) /// REORDERING that keeps the same names still fails. /// [Theory] - [InlineData("get_blocking", "server_name,hours_back,limit")] - [InlineData("get_deadlocks", "server_name,hours_back,limit")] - [InlineData("get_deadlock_detail", "server_name,hours_back,limit")] - [InlineData("get_blocked_process_xml", "server_name,hours_back,limit")] - [InlineData("get_blocking_trend", "server_name,hours_back")] - [InlineData("get_deadlock_trend", "server_name,hours_back")] + [InlineData("get_blocking", "server_name,hours_back,limit,as_of")] + [InlineData("get_deadlocks", "server_name,hours_back,limit,as_of")] + [InlineData("get_deadlock_detail", "server_name,hours_back,limit,as_of")] + [InlineData("get_blocked_process_xml", "server_name,hours_back,limit,as_of")] + [InlineData("get_blocking_trend", "server_name,hours_back,as_of")] + [InlineData("get_deadlock_trend", "server_name,hours_back,as_of")] public void ParamContract_MatchesLite(string toolName, string expectedCsv) { var expected = expectedCsv.Split(','); diff --git a/Darling/Darling.Tests/DarlingMcpConfigHistoryToolsTests.cs b/Darling/Darling.Tests/DarlingMcpConfigHistoryToolsTests.cs index d7011b338d..603b9308ac 100644 --- a/Darling/Darling.Tests/DarlingMcpConfigHistoryToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpConfigHistoryToolsTests.cs @@ -75,9 +75,9 @@ private static (string Name, bool Optional)[] McpParams(string toolName) } [Theory] - [InlineData("get_server_config_changes", "server_name,hours_back")] - [InlineData("get_database_config_changes", "server_name,hours_back")] - [InlineData("get_trace_flag_changes", "server_name,hours_back")] + [InlineData("get_server_config_changes", "server_name,hours_back,as_of")] + [InlineData("get_database_config_changes", "server_name,hours_back,as_of")] + [InlineData("get_trace_flag_changes", "server_name,hours_back,as_of")] [InlineData("get_database_scoped_config", "server_name,database_name")] [InlineData("get_query_store_health", "server_name,database_name")] public void ParamContract_MatchesContract(string toolName, string expectedCsv) diff --git a/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs b/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs index da9622c2ea..3889dc5bb9 100644 --- a/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs @@ -113,18 +113,18 @@ private static (string Name, bool Optional)[] McpParams(string toolName) /// fails, because the prefix is compared element-wise rather than as a joined substring. /// [Theory] - [InlineData("get_cpu_utilization", "server_name,hours_back")] - [InlineData("get_wait_stats", "server_name,hours_back,limit")] - [InlineData("get_wait_trend", "wait_type,server_name,hours_back")] - [InlineData("get_wait_types", "server_name,hours_back")] + [InlineData("get_cpu_utilization", "server_name,hours_back,as_of")] + [InlineData("get_wait_stats", "server_name,hours_back,limit,as_of")] + [InlineData("get_wait_trend", "wait_type,server_name,hours_back,as_of")] + [InlineData("get_wait_types", "server_name,hours_back,as_of")] [InlineData("get_memory_stats", "server_name")] [InlineData("get_memory_clerks", "server_name")] [InlineData("get_file_io_stats", "server_name")] - [InlineData("get_tempdb_trend", "server_name,hours_back")] + [InlineData("get_tempdb_trend", "server_name,hours_back,as_of")] [InlineData("get_perfmon_stats", "server_name,counter_name,instance_name")] - [InlineData("get_top_queries_by_cpu", "server_name,hours_back,top,database_name,parallel_only,min_dop")] - [InlineData("get_top_procedures_by_cpu", "server_name,hours_back,top,database_name")] - [InlineData("get_query_store_top", "server_name,hours_back,top,database_name")] + [InlineData("get_top_queries_by_cpu", "server_name,hours_back,top,database_name,parallel_only,min_dop,as_of")] + [InlineData("get_top_procedures_by_cpu", "server_name,hours_back,top,database_name,as_of")] + [InlineData("get_query_store_top", "server_name,hours_back,top,database_name,as_of")] [InlineData("get_collection_health", "server_name")] [InlineData("get_server_properties", "server_name")] public void ParamContract_MatchesLite(string toolName, string expectedCsv) diff --git a/Darling/Darling.Tests/DarlingMcpDefaultTraceToolsTests.cs b/Darling/Darling.Tests/DarlingMcpDefaultTraceToolsTests.cs index 314b015aa7..a575e2c8c7 100644 --- a/Darling/Darling.Tests/DarlingMcpDefaultTraceToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpDefaultTraceToolsTests.cs @@ -58,7 +58,7 @@ public void ParamContract_ServerNameHoursBackLimit_AllOptional() .Where(x => x.GetCustomAttribute() is not null) .Select(x => (x.Name!, x.HasDefaultValue)) .ToArray(); - Assert.Equal(new[] { "server_name", "hours_back", "limit" }, p.Select(x => x.Item1).ToArray()); + Assert.Equal(new[] { "server_name", "hours_back", "limit", "as_of" }, p.Select(x => x.Item1).ToArray()); Assert.All(p, x => Assert.True(x.Item2, $"{x.Item1} must be optional")); } diff --git a/Darling/Darling.Tests/DarlingMcpHealthParserToolsTests.cs b/Darling/Darling.Tests/DarlingMcpHealthParserToolsTests.cs index a7bbf2c0a7..aa5fc62165 100644 --- a/Darling/Darling.Tests/DarlingMcpHealthParserToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpHealthParserToolsTests.cs @@ -82,7 +82,7 @@ public void ParamContract_EveryTool_ServerNameHoursBackLimit_AllOptional() .Where(x => x.GetCustomAttribute() is not null) .Select(x => (x.Name!, x.HasDefaultValue)) .ToArray(); - Assert.Equal(new[] { "server_name", "hours_back", "limit" }, p.Select(x => x.Item1).ToArray()); + Assert.Equal(new[] { "server_name", "hours_back", "limit", "as_of" }, p.Select(x => x.Item1).ToArray()); Assert.All(p, x => Assert.True(x.Item2, $"{tool}.{x.Item1} must be optional")); } } diff --git a/Darling/Darling.Tests/DarlingMcpLatchSpinlockToolsTests.cs b/Darling/Darling.Tests/DarlingMcpLatchSpinlockToolsTests.cs index 5027c7e2b5..0296d9ea3d 100644 --- a/Darling/Darling.Tests/DarlingMcpLatchSpinlockToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpLatchSpinlockToolsTests.cs @@ -72,7 +72,7 @@ private static (string Name, bool Optional)[] McpParams(string toolName) public void ParamContract_MatchesDashboard_ServerHoursTop_AllOptional(string toolName) { var p = McpParams(toolName); - Assert.Equal(new[] { "server_name", "hours_back", "top" }, p.Select(x => x.Name).ToArray()); + Assert.Equal(new[] { "server_name", "hours_back", "top", "as_of" }, p.Select(x => x.Name).ToArray()); Assert.All(p, x => Assert.True(x.Optional, $"{toolName}.{x.Name} must be optional")); } diff --git a/Darling/Darling.Tests/DarlingMcpMemoryGrantToolsTests.cs b/Darling/Darling.Tests/DarlingMcpMemoryGrantToolsTests.cs index 8647296648..a377a9995f 100644 --- a/Darling/Darling.Tests/DarlingMcpMemoryGrantToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpMemoryGrantToolsTests.cs @@ -73,7 +73,7 @@ private static (string Name, bool Optional)[] McpParams(string toolName) public void ParamContract_ServerHours_AllOptional(string toolName) { var p = McpParams(toolName); - Assert.Equal(new[] { "server_name", "hours_back" }, p.Select(x => x.Name).ToArray()); + Assert.Equal(new[] { "server_name", "hours_back", "as_of" }, p.Select(x => x.Name).ToArray()); Assert.All(p, x => Assert.True(x.Optional, $"{toolName}.{x.Name} must be optional")); } diff --git a/Darling/Darling.Tests/DarlingMcpPlanCacheSchedulerToolsTests.cs b/Darling/Darling.Tests/DarlingMcpPlanCacheSchedulerToolsTests.cs index 192e820136..d660da5e02 100644 --- a/Darling/Darling.Tests/DarlingMcpPlanCacheSchedulerToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpPlanCacheSchedulerToolsTests.cs @@ -69,7 +69,7 @@ private static (string Name, bool Optional)[] McpParams(string toolName) public void ParamContract_PlanCacheBloat_ServerHours() { var p = McpParams("get_plan_cache_bloat"); - Assert.Equal(new[] { "server_name", "hours_back" }, p.Select(x => x.Name).ToArray()); + Assert.Equal(new[] { "server_name", "hours_back", "as_of" }, p.Select(x => x.Name).ToArray()); Assert.All(p, x => Assert.True(x.Optional)); } diff --git a/Darling/Darling.Tests/DarlingMcpPlanCorrectionToolsTests.cs b/Darling/Darling.Tests/DarlingMcpPlanCorrectionToolsTests.cs index b8835a7764..8df8541e15 100644 --- a/Darling/Darling.Tests/DarlingMcpPlanCorrectionToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpPlanCorrectionToolsTests.cs @@ -58,7 +58,7 @@ public void ParamContract_StandardWindowedShape_ServerNameOptional() .Select(p => (p.Name, p.HasDefaultValue)) .ToArray(); - Assert.Equal(new[] { "server_name", "hours_back", "limit" }, mcpParams.Select(p => p.Name).ToArray()); + Assert.Equal(new[] { "server_name", "hours_back", "limit", "as_of" }, mcpParams.Select(p => p.Name).ToArray()); Assert.True(mcpParams.Single(p => p.Name == "server_name").HasDefaultValue, "server_name must be optional"); } diff --git a/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs b/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs index 60af8eb9a8..9f48501190 100644 --- a/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs @@ -69,8 +69,8 @@ private static (string Name, bool Optional)[] McpParams(string toolName) [Theory] [InlineData("get_session_stats", "server_name")] - [InlineData("get_active_queries", "server_name,hours_back,database_name,blocking_only,limit")] - [InlineData("get_waiting_tasks", "server_name,hours_back,limit")] + [InlineData("get_active_queries", "server_name,hours_back,database_name,blocking_only,limit,as_of")] + [InlineData("get_waiting_tasks", "server_name,hours_back,limit,as_of")] public void ParamContract_MatchesLite(string toolName, string expectedCsv) { Assert.Equal(expectedCsv.Split(','), McpParams(toolName).Select(p => p.Name).ToArray()); diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs index 426a83cda9..c8e57ef4f1 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs @@ -32,7 +32,7 @@ namespace PerformanceMonitor.Darling.Service; /// each endpoint calls the SAME public static tool method the MCP server exposes (the [McpServerTool] /// attributes are inert — the tool bodies are plain statics), so there is ZERO SQL / projection drift between the /// two surfaces. Query-string values bind to the tool's parameters (server/server_name, -/// hours/hours_back, top, limit, ...); missing optional parameters fall back to the +/// hours/hours_back, as_of, top, limit, ...); missing optional parameters fall back to the /// method's own defaults. The excluded tools are the ones that are not read-only-over-the-store: /// analyze_server (a live monitored-server touch), mute_analysis_finding (a write), and the /// analyze_*_plan compute family (phase 2). A reflection test pins the endpoint set == the tool catalog @@ -1043,6 +1043,14 @@ internal sealed record CatalogRead(string Category, string Description, IReadOnl private static CatalogParam PServer() => new("server", TypeServer, false, null); private static CatalogParam PHours(int def) => new("hours", TypeInt, false, def); + + /// + /// The window ANCHOR (#2495): ?as_of= moves the END of a window off "now" so a + /// caller can ask about a past incident. Typed text rather than a new catalog type because it IS a + /// string on the wire — an ISO-8601 instant the tool parses and REFUSES if it cannot, which is where the + /// error message belongs; a new type would only teach the picker to render a box it already renders. + /// + private static CatalogParam PAsOf() => new("as_of", TypeText, false, null); private static CatalogParam PLimit(int def) => new("limit", TypeInt, false, def); private static CatalogParam PTop(int def) => new("top", TypeInt, false, def); private static CatalogParam PText(string name) => new(name, TypeText, false, null); @@ -1072,74 +1080,74 @@ private static CatalogRead R(string category, string description, params Catalog ["get_analysis_findings"] = R(CatAnalysis, "Persisted analysis findings for a server.", PServer(), PHours(24)), /* ── sessions (DarlingMcpSessionTools) ── */ - ["get_active_queries"] = R(CatSessions, "Currently-active queries, optionally blocking-only.", PServer(), PHours(1), PText("database_name"), PBool("blocking_only", false), PLimit(50)), + ["get_active_queries"] = R(CatSessions, "Currently-active queries, optionally blocking-only.", PServer(), PHours(1), PText("database_name"), PBool("blocking_only", false), PLimit(50), PAsOf()), ["get_session_stats"] = R(CatSessions, "Session-level summary counters for a server.", PServer()), - ["get_waiting_tasks"] = R(CatSessions, "Tasks currently waiting, with wait type and duration.", PServer(), PHours(1), PLimit(30)), + ["get_waiting_tasks"] = R(CatSessions, "Tasks currently waiting, with wait type and duration.", PServer(), PHours(1), PLimit(30), PAsOf()), /* ── alerts / mute rules (DarlingMcpAlertTools) ── */ - ["get_alert_history"] = R(CatAlerts, "Recent fired-alert history for a server.", PServer(), PHours(24), PLimit(50)), + ["get_alert_history"] = R(CatAlerts, "Recent fired-alert history for a server.", PServer(), PHours(24), PLimit(50), PAsOf()), ["get_alert_settings"] = R(CatAlerts, "The current alert-settings configuration."), ["get_mute_rules"] = R(CatAlerts, "The alert mute rules (enabled-only by default).", PBool("enabled_only", true)), /* ── blocking / deadlocks (DarlingMcpBlockingTools) ── */ - ["get_blocked_process_xml"] = R(CatBlocking, "Blocked-process-report XML captures.", PServer(), PHours(24), PLimit(5)), - ["get_blocking"] = R(CatBlocking, "Blocking chains observed in the window.", PServer(), PHours(24), PLimit(30)), - ["get_blocking_trend"] = R(CatBlocking, "Blocking-event counts over time.", PServer(), PHours(24)), - ["get_deadlock_detail"] = R(CatBlocking, "Deadlock graph detail for recent deadlocks.", PServer(), PHours(24), PLimit(5)), - ["get_deadlock_trend"] = R(CatBlocking, "Deadlock counts over time.", PServer(), PHours(24)), - ["get_deadlocks"] = R(CatBlocking, "Recent deadlocks with victim/resource summary.", PServer(), PHours(24), PLimit(20)), + ["get_blocked_process_xml"] = R(CatBlocking, "Blocked-process-report XML captures.", PServer(), PHours(24), PLimit(5), PAsOf()), + ["get_blocking"] = R(CatBlocking, "Blocking chains observed in the window.", PServer(), PHours(24), PLimit(30), PAsOf()), + ["get_blocking_trend"] = R(CatBlocking, "Blocking-event counts over time.", PServer(), PHours(24), PAsOf()), + ["get_deadlock_detail"] = R(CatBlocking, "Deadlock graph detail for recent deadlocks.", PServer(), PHours(24), PLimit(5), PAsOf()), + ["get_deadlock_trend"] = R(CatBlocking, "Deadlock counts over time.", PServer(), PHours(24), PAsOf()), + ["get_deadlocks"] = R(CatBlocking, "Recent deadlocks with victim/resource summary.", PServer(), PHours(24), PLimit(20), PAsOf()), /* ── automatic plan correction (DarlingMcpPlanCorrectionTools, #2028) ── */ - ["get_plan_corrections"] = R(CatAnalysis, "Automatic plan correction activity + per-database FORCE_LAST_GOOD_PLAN state.", PServer(), PHours(24), PLimit(50)), + ["get_plan_corrections"] = R(CatAnalysis, "Automatic plan correction activity + per-database FORCE_LAST_GOOD_PLAN state.", PServer(), PHours(24), PLimit(50), PAsOf()), /* ── config: current + history (DarlingMcpConfigTools / DarlingMcpConfigHistoryTools) ── */ ["get_database_config"] = R(CatConfig, "Database-level configuration for a server.", PServer(), PText("database_name")), ["get_server_config"] = R(CatConfig, "Server-level configuration (sp_configure) for a server.", PServer()), ["get_trace_flags"] = R(CatConfig, "Active trace flags for a server.", PServer()), - ["get_database_config_changes"] = R(CatConfig, "Database-configuration changes over time.", PServer(), PHours(168)), + ["get_database_config_changes"] = R(CatConfig, "Database-configuration changes over time.", PServer(), PHours(168), PAsOf()), ["get_database_scoped_config"] = R(CatConfig, "Database-scoped configuration for a database.", PServer(), PText("database_name")), ["get_query_store_health"] = R(CatConfig, "Per-database Query Store health: actual vs desired state, readonly_reason, storage vs cap.", PServer(), PText("database_name")), - ["get_server_config_changes"] = R(CatConfig, "Server-configuration changes over time.", PServer(), PHours(168)), - ["get_trace_flag_changes"] = R(CatConfig, "Trace-flag changes over time.", PServer(), PHours(168)), + ["get_server_config_changes"] = R(CatConfig, "Server-configuration changes over time.", PServer(), PHours(168), PAsOf()), + ["get_trace_flag_changes"] = R(CatConfig, "Trace-flag changes over time.", PServer(), PHours(168), PAsOf()), /* ── core data reads (DarlingMcpDataTools + long-query / fleet tools) ── */ ["get_collection_health"] = R(CatData, "Per-collector collection health for a server.", PServer()), - ["get_collection_log"] = R(CatData, "Raw per-run collector log for a server, newest first.", PServer(), PHours(24), PLimit(200)), - ["get_current_waits_trend"] = R(CatData, "Waiting-task and blocked-session series over time.", PServer(), PHours(4), PText("database_name")), - ["get_blocking_stats"] = R(CatData, "Blocking duration and deadlock severity per minute.", PServer(), PHours(24)), - ["get_cpu_utilization"] = R(CatData, "CPU utilization over time.", PServer(), PHours(4)), + ["get_collection_log"] = R(CatData, "Raw per-run collector log for a server, newest first.", PServer(), PHours(24), PLimit(200), PAsOf()), + ["get_current_waits_trend"] = R(CatData, "Waiting-task and blocked-session series over time.", PServer(), PHours(4), PText("database_name"), PAsOf()), + ["get_blocking_stats"] = R(CatData, "Blocking duration and deadlock severity per minute.", PServer(), PHours(24), PAsOf()), + ["get_cpu_utilization"] = R(CatData, "CPU utilization over time.", PServer(), PHours(4), PAsOf()), ["get_file_io_stats"] = R(CatData, "Per-file IO stall/throughput stats.", PServer()), ["get_memory_clerks"] = R(CatData, "Top memory clerks by allocation.", PServer()), ["get_memory_stats"] = R(CatData, "Server memory summary counters.", PServer()), ["get_perfmon_stats"] = R(CatData, "Perfmon counter values, filtered by counter/instance.", PServer(), PText("counter_name"), PText("instance_name")), - ["get_query_store_regressions"] = R(CatData, "Queries whose Query Store performance got WORSE vs their baseline.", PServer(), PHours(24), PText("database_name"), PLimit(50)), - ["get_query_store_top"] = R(CatData, "Top Query Store queries in the window.", PServer(), PHours(24), PTop(20), PText("database_name")), - ["get_long_query_completions"] = R(CatData, "Completed long-running queries captured by the XE trace.", PServer(), PHours(24), PLimit(30)), + ["get_query_store_regressions"] = R(CatData, "Queries whose Query Store performance got WORSE vs their baseline.", PServer(), PHours(24), PText("database_name"), PLimit(50), PAsOf()), + ["get_query_store_top"] = R(CatData, "Top Query Store queries in the window.", PServer(), PHours(24), PTop(20), PText("database_name"), PAsOf()), + ["get_long_query_completions"] = R(CatData, "Completed long-running queries captured by the XE trace.", PServer(), PHours(24), PLimit(30), PAsOf()), ["get_server_properties"] = R(CatData, "Server properties/inventory for a server.", PServer()), - ["get_tempdb_trend"] = R(CatData, "tempdb space usage over time.", PServer(), PHours(24)), - ["get_top_procedures_by_cpu"] = R(CatData, "Top stored procedures by CPU.", PServer(), PHours(24), PTop(20), PText("database_name")), - ["get_top_queries_by_cpu"] = R(CatData, "Top queries by CPU, optionally parallel-only / min-DOP.", PServer(), PHours(24), PTop(20), PText("database_name"), PBool("parallel_only", false), PInt("min_dop", 0)), - ["get_pg_top_queries"] = R(CatData, "Top PostgreSQL query shapes by total execution time (Aurora targets).", PServer(), PHours(24), PLimit(20)), - ["get_pg_wraparound_risk"] = R(CatData, "PostgreSQL XID/MultiXact freeze headroom per database.", PServer(), PHours(24)), - ["get_pg_xmin_horizon"] = R(CatData, "What is holding back the PostgreSQL xmin horizon, by cause.", PServer(), PHours(24)), - ["get_pg_replication_slots"] = R(CatData, "PostgreSQL replication slot health, including whether retained WAL is still growing.", PServer(), PHours(24)), - ["get_pg_autovacuum_health"] = R(CatData, "PostgreSQL tables behind on vacuum or analyze, ranked by how far past each table's own threshold.", PServer(), PHours(24), PLimit(20)), - ["get_pg_io_stats"] = R(CatData, "PostgreSQL I/O by backend type, object and context, differenced across the window.", PServer(), PHours(24), PLimit(20)), - ["get_pg_wait_stats"] = R(CatData, "Top PostgreSQL wait events in the window (Aurora targets).", PServer(), PHours(24), PLimit(20)), - ["get_pg_blocking"] = R(CatData, "PostgreSQL blocking chains that were sampled, with the root blocker attributed. A sample, not an event log.", PServer(), PHours(24), PLimit(50)), - ["get_wait_stats"] = R(CatData, "Top wait statistics in the window.", PServer(), PHours(24), PLimit(20)), - ["get_wait_trend"] = R(CatData, "One wait type's totals over time (requires wait_type).", PReqText("wait_type"), PServer(), PHours(24)), - ["get_wait_types"] = R(CatData, "The wait types observed in the window.", PServer(), PHours(24)), + ["get_tempdb_trend"] = R(CatData, "tempdb space usage over time.", PServer(), PHours(24), PAsOf()), + ["get_top_procedures_by_cpu"] = R(CatData, "Top stored procedures by CPU.", PServer(), PHours(24), PTop(20), PText("database_name"), PAsOf()), + ["get_top_queries_by_cpu"] = R(CatData, "Top queries by CPU, optionally parallel-only / min-DOP.", PServer(), PHours(24), PTop(20), PText("database_name"), PBool("parallel_only", false), PInt("min_dop", 0), PAsOf()), + ["get_pg_top_queries"] = R(CatData, "Top PostgreSQL query shapes by total execution time (Aurora targets).", PServer(), PHours(24), PLimit(20), PAsOf()), + ["get_pg_wraparound_risk"] = R(CatData, "PostgreSQL XID/MultiXact freeze headroom per database.", PServer(), PHours(24), PAsOf()), + ["get_pg_xmin_horizon"] = R(CatData, "What is holding back the PostgreSQL xmin horizon, by cause.", PServer(), PHours(24), PAsOf()), + ["get_pg_replication_slots"] = R(CatData, "PostgreSQL replication slot health, including whether retained WAL is still growing.", PServer(), PHours(24), PAsOf()), + ["get_pg_autovacuum_health"] = R(CatData, "PostgreSQL tables behind on vacuum or analyze, ranked by how far past each table's own threshold.", PServer(), PHours(24), PLimit(20), PAsOf()), + ["get_pg_io_stats"] = R(CatData, "PostgreSQL I/O by backend type, object and context, differenced across the window.", PServer(), PHours(24), PLimit(20), PAsOf()), + ["get_pg_wait_stats"] = R(CatData, "Top PostgreSQL wait events in the window (Aurora targets).", PServer(), PHours(24), PLimit(20), PAsOf()), + ["get_pg_blocking"] = R(CatData, "PostgreSQL blocking chains that were sampled, with the root blocker attributed. A sample, not an event log.", PServer(), PHours(24), PLimit(50), PAsOf()), + ["get_wait_stats"] = R(CatData, "Top wait statistics in the window.", PServer(), PHours(24), PLimit(20), PAsOf()), + ["get_wait_trend"] = R(CatData, "One wait type's totals over time (requires wait_type).", PReqText("wait_type"), PServer(), PHours(24), PAsOf()), + ["get_wait_types"] = R(CatData, "The wait types observed in the window.", PServer(), PHours(24), PAsOf()), ["list_servers"] = R(CatData, "The monitored servers known to the store."), /* ── trends (DarlingMcpTrendTools) ── */ - ["get_file_io_trend"] = R(CatTrends, "File-IO throughput over time.", PServer(), PHours(24)), - ["get_memory_trend"] = R(CatTrends, "Memory usage over time.", PServer(), PHours(24)), - ["get_perfmon_trend"] = R(CatTrends, "One perfmon counter over time (requires counter_name).", PReqText("counter_name"), PServer(), PHours(24)), - ["get_procedure_duration_trend"] = R(CatTrends, "Stored-procedure elapsed ms/sec + executions/sec over time.", PServer(), PHours(24)), - ["get_query_duration_trend"] = R(CatTrends, "Query-duration percentiles over time.", PServer(), PHours(24)), - ["get_query_store_duration_trend"] = R(CatTrends, "Query Store duration ms/sec + executions/sec over time.", PServer(), PHours(24)), - ["get_query_trend"] = R(CatTrends, "One query's metrics over time (requires query_hash + database_name).", PReqText("query_hash"), PReqText("database_name"), PServer(), PHours(24)), + ["get_file_io_trend"] = R(CatTrends, "File-IO throughput over time.", PServer(), PHours(24), PAsOf()), + ["get_memory_trend"] = R(CatTrends, "Memory usage over time.", PServer(), PHours(24), PAsOf()), + ["get_perfmon_trend"] = R(CatTrends, "One perfmon counter over time (requires counter_name).", PReqText("counter_name"), PServer(), PHours(24), PAsOf()), + ["get_procedure_duration_trend"] = R(CatTrends, "Stored-procedure elapsed ms/sec + executions/sec over time.", PServer(), PHours(24), PAsOf()), + ["get_query_duration_trend"] = R(CatTrends, "Query-duration percentiles over time.", PServer(), PHours(24), PAsOf()), + ["get_query_store_duration_trend"] = R(CatTrends, "Query Store duration ms/sec + executions/sec over time.", PServer(), PHours(24), PAsOf()), + ["get_query_trend"] = R(CatTrends, "One query's metrics over time (requires query_hash + database_name).", PReqText("query_hash"), PReqText("database_name"), PServer(), PHours(24), PAsOf()), /* ── health / overview (DarlingMcpHealthTools / DarlingMcpFleetTools) ── */ ["get_server_summary"] = R(CatOverview, "A one-shot health summary for a server.", PServer()), @@ -1149,13 +1157,13 @@ private static CatalogRead R(string category, string description, params Catalog ["get_store_metrics"] = R(CatOverview, "The monitoring store's own size/compression/growth series (self-metrics).", PInt("days_back", 30)), /* ── latch / spinlock (DarlingMcpLatchSpinlockTools) ── */ - ["get_latch_stats"] = R(CatLatch, "Top latch waits in the window.", PServer(), PHours(24), PTop(10)), - ["get_spinlock_stats"] = R(CatLatch, "Top spinlock activity in the window.", PServer(), PHours(24), PTop(10)), + ["get_latch_stats"] = R(CatLatch, "Top latch waits in the window.", PServer(), PHours(24), PTop(10), PAsOf()), + ["get_spinlock_stats"] = R(CatLatch, "Top spinlock activity in the window.", PServer(), PHours(24), PTop(10), PAsOf()), /* ── memory grants (DarlingMcpMemoryGrantTools) ── */ - ["get_memory_grants"] = R(CatMemoryGrants, "Active/recent memory grants.", PServer(), PHours(1)), - ["get_memory_pressure_events"] = R(CatMemoryGrants, "Memory-pressure events in the window.", PServer(), PHours(24)), - ["get_resource_semaphore"] = R(CatMemoryGrants, "Resource-semaphore state over time.", PServer(), PHours(24)), + ["get_memory_grants"] = R(CatMemoryGrants, "Active/recent memory grants.", PServer(), PHours(1), PAsOf()), + ["get_memory_pressure_events"] = R(CatMemoryGrants, "Memory-pressure events in the window.", PServer(), PHours(24), PAsOf()), + ["get_resource_semaphore"] = R(CatMemoryGrants, "Resource-semaphore state over time.", PServer(), PHours(24), PAsOf()), /* ── object / index stats (DarlingMcpObjectStatsTools) ── */ ["get_database_sizes"] = R(CatObjects, "Per-database size breakdown.", PServer()), @@ -1166,7 +1174,7 @@ private static CatalogRead R(string category, string description, params Catalog /* ── plan cache / scheduler (DarlingMcpPlanCacheSchedulerTools) ── */ ["get_cpu_scheduler_pressure"] = R(CatPlanCache, "CPU scheduler pressure indicators.", PServer()), - ["get_plan_cache_bloat"] = R(CatPlanCache, "Plan-cache bloat / single-use plan indicators.", PServer(), PHours(24)), + ["get_plan_cache_bloat"] = R(CatPlanCache, "Plan-cache bloat / single-use plan indicators.", PServer(), PHours(24), PAsOf()), /* ── jobs (DarlingMcpJobTools) ── */ ["get_running_jobs"] = R(CatJobs, "Currently-running SQL Agent jobs.", PServer()), @@ -1175,18 +1183,18 @@ private static CatalogRead R(string category, string description, params Catalog ["get_plan_xml"] = R(CatPlans, "The stored execution-plan XML for a query (requires query_hash).", PReqText("query_hash"), PServer(), PText("database_name")), /* ── default trace (DarlingMcpDefaultTraceTools) ── */ - ["get_default_trace_events"] = R(CatDefaultTrace, "Default-trace events (file growth, DDL, security).", PServer(), PHours(24), PLimit(100)), + ["get_default_trace_events"] = R(CatDefaultTrace, "Default-trace events (file growth, DDL, security).", PServer(), PHours(24), PLimit(100), PAsOf()), /* ── system_health parse-on-read family (DarlingMcpHealthParserTools) ── */ - ["get_health_parser_cpu_tasks"] = R(CatSystemHealth, "system_health: CPU-bound task snapshots.", PServer(), PHours(24), PLimit(50)), - ["get_health_parser_io_issues"] = R(CatSystemHealth, "system_health: IO-latency issues.", PServer(), PHours(24), PLimit(50)), - ["get_health_parser_memory_broker"] = R(CatSystemHealth, "system_health: memory-broker ring-buffer entries.", PServer(), PHours(24), PLimit(50)), - ["get_health_parser_memory_conditions"] = R(CatSystemHealth, "system_health: resource-monitor memory conditions.", PServer(), PHours(24), PLimit(50)), - ["get_health_parser_memory_node_oom"] = R(CatSystemHealth, "system_health: per-node out-of-memory events.", PServer(), PHours(24), PLimit(50)), - ["get_health_parser_scheduler_issues"] = R(CatSystemHealth, "system_health: non-yielding scheduler issues.", PServer(), PHours(24), PLimit(50)), - ["get_health_parser_severe_errors"] = R(CatSystemHealth, "system_health: severe (sev >= 17) errors.", PServer(), PHours(24), PLimit(50)), - ["get_health_parser_significant_waits"] = R(CatSystemHealth, "system_health: individual 500 ms+ waits with their statement.", PServer(), PHours(24), PLimit(50)), - ["get_health_parser_system_health"] = R(CatSystemHealth, "system_health: the raw parsed session records.", PServer(), PHours(24), PLimit(50)), + ["get_health_parser_cpu_tasks"] = R(CatSystemHealth, "system_health: CPU-bound task snapshots.", PServer(), PHours(24), PLimit(50), PAsOf()), + ["get_health_parser_io_issues"] = R(CatSystemHealth, "system_health: IO-latency issues.", PServer(), PHours(24), PLimit(50), PAsOf()), + ["get_health_parser_memory_broker"] = R(CatSystemHealth, "system_health: memory-broker ring-buffer entries.", PServer(), PHours(24), PLimit(50), PAsOf()), + ["get_health_parser_memory_conditions"] = R(CatSystemHealth, "system_health: resource-monitor memory conditions.", PServer(), PHours(24), PLimit(50), PAsOf()), + ["get_health_parser_memory_node_oom"] = R(CatSystemHealth, "system_health: per-node out-of-memory events.", PServer(), PHours(24), PLimit(50), PAsOf()), + ["get_health_parser_scheduler_issues"] = R(CatSystemHealth, "system_health: non-yielding scheduler issues.", PServer(), PHours(24), PLimit(50), PAsOf()), + ["get_health_parser_severe_errors"] = R(CatSystemHealth, "system_health: severe (sev >= 17) errors.", PServer(), PHours(24), PLimit(50), PAsOf()), + ["get_health_parser_significant_waits"] = R(CatSystemHealth, "system_health: individual 500 ms+ waits with their statement.", PServer(), PHours(24), PLimit(50), PAsOf()), + ["get_health_parser_system_health"] = R(CatSystemHealth, "system_health: the raw parsed session records.", PServer(), PHours(24), PLimit(50), PAsOf()), }; /// Builds the /api/catalog body: the reads (names taken from , @@ -1515,80 +1523,80 @@ internal static IReadOnlyDictionary BuildReadDispatch() ["get_analysis_findings"] = (c, pg, an) => DarlingMcpTools.GetAnalysisFindings(an, pg, Server(c), Hours(c, 24), QueryBool(c, "include_drilldown", false)), /* ── sessions ── */ - ["get_active_queries"] = (c, pg, an) => DarlingMcpSessionTools.GetActiveQueries(pg, Server(c), Hours(c, 1), Str(c, "database_name"), QueryBool(c, "blocking_only", false), Rows(c, "limit", 50)), + ["get_active_queries"] = (c, pg, an) => DarlingMcpSessionTools.GetActiveQueries(pg, Server(c), Hours(c, 1), Str(c, "database_name"), QueryBool(c, "blocking_only", false), Rows(c, "limit", 50), as_of: AsOf(c)), ["get_session_stats"] = (c, pg, an) => DarlingMcpSessionTools.GetSessionStats(pg, Server(c)), - ["get_waiting_tasks"] = (c, pg, an) => DarlingMcpSessionTools.GetWaitingTasks(pg, Server(c), Hours(c, 1), Rows(c, "limit", 30)), + ["get_waiting_tasks"] = (c, pg, an) => DarlingMcpSessionTools.GetWaitingTasks(pg, Server(c), Hours(c, 1), Rows(c, "limit", 30), as_of: AsOf(c)), /* ── alerts / mute rules ── */ - ["get_alert_history"] = (c, pg, an) => DarlingMcpAlertTools.GetAlertHistory(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), + ["get_alert_history"] = (c, pg, an) => DarlingMcpAlertTools.GetAlertHistory(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50), as_of: AsOf(c)), ["get_alert_settings"] = (c, pg, an) => DarlingMcpAlertTools.GetAlertSettings(pg), ["get_mute_rules"] = (c, pg, an) => DarlingMcpAlertTools.GetMuteRules(pg, QueryBool(c, "enabled_only", true)), /* ── blocking / deadlocks ── */ - ["get_blocked_process_xml"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlockedProcessXml(pg, Server(c), Hours(c, 24), Rows(c, "limit", 5)), - ["get_blocking"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlocking(pg, Server(c), Hours(c, 24), Rows(c, "limit", 30)), - ["get_blocking_trend"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlockingTrend(pg, Server(c), Hours(c, 24)), - ["get_deadlock_detail"] = (c, pg, an) => DarlingMcpBlockingTools.GetDeadlockDetail(pg, Server(c), Hours(c, 24), Rows(c, "limit", 5)), - ["get_deadlock_trend"] = (c, pg, an) => DarlingMcpBlockingTools.GetDeadlockTrend(pg, Server(c), Hours(c, 24)), - ["get_deadlocks"] = (c, pg, an) => DarlingMcpBlockingTools.GetDeadlocks(pg, Server(c), Hours(c, 24), Rows(c, "limit", 20)), + ["get_blocked_process_xml"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlockedProcessXml(pg, Server(c), Hours(c, 24), Rows(c, "limit", 5), as_of: AsOf(c)), + ["get_blocking"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlocking(pg, Server(c), Hours(c, 24), Rows(c, "limit", 30), as_of: AsOf(c)), + ["get_blocking_trend"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlockingTrend(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), + ["get_deadlock_detail"] = (c, pg, an) => DarlingMcpBlockingTools.GetDeadlockDetail(pg, Server(c), Hours(c, 24), Rows(c, "limit", 5), as_of: AsOf(c)), + ["get_deadlock_trend"] = (c, pg, an) => DarlingMcpBlockingTools.GetDeadlockTrend(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), + ["get_deadlocks"] = (c, pg, an) => DarlingMcpBlockingTools.GetDeadlocks(pg, Server(c), Hours(c, 24), Rows(c, "limit", 20), as_of: AsOf(c)), /* ── automatic plan correction (#2028) ── */ - ["get_plan_corrections"] = (c, pg, an) => DarlingMcpPlanCorrectionTools.GetPlanCorrections(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), + ["get_plan_corrections"] = (c, pg, an) => DarlingMcpPlanCorrectionTools.GetPlanCorrections(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50), as_of: AsOf(c)), /* ── config (current + history) ── */ ["get_database_config"] = (c, pg, an) => DarlingMcpConfigTools.GetDatabaseConfig(pg, Server(c), Str(c, "database_name")), ["get_server_config"] = (c, pg, an) => DarlingMcpConfigTools.GetServerConfig(pg, Server(c)), ["get_trace_flags"] = (c, pg, an) => DarlingMcpConfigTools.GetTraceFlags(pg, Server(c)), - ["get_database_config_changes"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetDatabaseConfigChanges(pg, Server(c), Hours(c, 168)), + ["get_database_config_changes"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetDatabaseConfigChanges(pg, Server(c), Hours(c, 168), as_of: AsOf(c)), ["get_database_scoped_config"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetDatabaseScopedConfig(pg, Server(c), Str(c, "database_name")), ["get_query_store_health"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetQueryStoreHealth(pg, Server(c), Str(c, "database_name")), - ["get_server_config_changes"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetServerConfigChanges(pg, Server(c), Hours(c, 168)), - ["get_trace_flag_changes"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetTraceFlagChanges(pg, Server(c), Hours(c, 168)), + ["get_server_config_changes"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetServerConfigChanges(pg, Server(c), Hours(c, 168), as_of: AsOf(c)), + ["get_trace_flag_changes"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetTraceFlagChanges(pg, Server(c), Hours(c, 168), as_of: AsOf(c)), /* ── core data reads ── */ ["get_collection_health"] = (c, pg, an) => DarlingMcpDataTools.GetCollectionHealth(pg, Server(c)), - ["get_collection_log"] = (c, pg, an) => DarlingMcpDataTools.GetCollectionLog(pg, Server(c), Hours(c, 24), Rows(c, "limit", 200)), - ["get_current_waits_trend"] = (c, pg, an) => DarlingMcpDataTools.GetCurrentWaitsTrend(pg, Server(c), Hours(c, 4), Str(c, "database_name")), - ["get_blocking_stats"] = (c, pg, an) => DarlingMcpDataTools.GetBlockingStats(pg, Server(c), Hours(c, 24)), - ["get_cpu_utilization"] = (c, pg, an) => DarlingMcpDataTools.GetCpuUtilization(pg, Server(c), Hours(c, 4)), + ["get_collection_log"] = (c, pg, an) => DarlingMcpDataTools.GetCollectionLog(pg, Server(c), Hours(c, 24), Rows(c, "limit", 200), as_of: AsOf(c)), + ["get_current_waits_trend"] = (c, pg, an) => DarlingMcpDataTools.GetCurrentWaitsTrend(pg, Server(c), Hours(c, 4), Str(c, "database_name"), as_of: AsOf(c)), + ["get_blocking_stats"] = (c, pg, an) => DarlingMcpDataTools.GetBlockingStats(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), + ["get_cpu_utilization"] = (c, pg, an) => DarlingMcpDataTools.GetCpuUtilization(pg, Server(c), Hours(c, 4), as_of: AsOf(c)), ["get_file_io_stats"] = (c, pg, an) => DarlingMcpDataTools.GetFileIoStats(pg, Server(c)), ["get_memory_clerks"] = (c, pg, an) => DarlingMcpDataTools.GetMemoryClerks(pg, Server(c)), ["get_memory_stats"] = (c, pg, an) => DarlingMcpDataTools.GetMemoryStats(pg, Server(c)), ["get_perfmon_stats"] = (c, pg, an) => DarlingMcpDataTools.GetPerfmonStats(pg, Server(c), Str(c, "counter_name"), Str(c, "instance_name")), - ["get_query_store_regressions"] = (c, pg, an) => DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(pg, Server(c), Hours(c, 24), Str(c, "database_name"), Rows(c, "limit", 50)), - ["get_query_store_top"] = (c, pg, an) => DarlingMcpDataTools.GetQueryStoreTop(pg, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name")), - ["get_long_query_completions"] = (c, pg, an) => DarlingMcpLongQueryTools.GetLongQueryCompletions(pg, Server(c), Hours(c, 24), Rows(c, "limit", 30)), + ["get_query_store_regressions"] = (c, pg, an) => DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(pg, Server(c), Hours(c, 24), Str(c, "database_name"), Rows(c, "limit", 50), as_of: AsOf(c)), + ["get_query_store_top"] = (c, pg, an) => DarlingMcpDataTools.GetQueryStoreTop(pg, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name"), as_of: AsOf(c)), + ["get_long_query_completions"] = (c, pg, an) => DarlingMcpLongQueryTools.GetLongQueryCompletions(pg, Server(c), Hours(c, 24), Rows(c, "limit", 30), as_of: AsOf(c)), ["get_server_properties"] = (c, pg, an) => DarlingMcpDataTools.GetServerProperties(pg, Server(c)), - ["get_tempdb_trend"] = (c, pg, an) => DarlingMcpDataTools.GetTempDbTrend(pg, Server(c), Hours(c, 24)), - ["get_top_procedures_by_cpu"] = (c, pg, an) => DarlingMcpDataTools.GetTopProceduresByCpu(pg, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name")), - ["get_top_queries_by_cpu"] = (c, pg, an) => DarlingMcpDataTools.GetTopQueriesByCpu(pg, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name"), QueryBool(c, "parallel_only", false), QueryInt(c, "min_dop", null, 0)), - ["get_pg_top_queries"] = (c, pg, an) => DarlingMcpPgStatementTools.GetPgTopQueries(pg, Server(c), Hours(c, 24), Rows(c, "limit", 20)), - ["get_pg_wraparound_risk"] = (c, pg, an) => DarlingMcpPgWraparoundTools.GetPgWraparoundRisk(pg, Server(c), Hours(c, 24)), - ["get_pg_xmin_horizon"] = (c, pg, an) => DarlingMcpPgXminTools.GetPgXminHorizon(pg, Server(c), Hours(c, 24)), - ["get_pg_replication_slots"] = (c, pg, an) => DarlingMcpPgSlotTools.GetPgReplicationSlots(pg, Server(c), Hours(c, 24)), - ["get_pg_autovacuum_health"] = (c, pg, an) => DarlingMcpPgAutovacuumTools.GetPgAutovacuumHealth(pg, Server(c), Hours(c, 24), Rows(c, "limit", 20)), - ["get_pg_io_stats"] = (c, pg, an) => DarlingMcpPgIoTools.GetPgIoStats(pg, Server(c), Hours(c, 24), Rows(c, "limit", 20)), - ["get_pg_wait_stats"] = (c, pg, an) => DarlingMcpPgWaitTools.GetPgWaitStats(pg, Server(c), Hours(c, 24), Rows(c, "limit", 20)), - ["get_pg_blocking"] = (c, pg, an) => DarlingMcpPgBlockingTools.GetPgBlocking(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), - ["get_wait_stats"] = (c, pg, an) => DarlingMcpDataTools.GetWaitStats(pg, Server(c), Hours(c, 24), Rows(c, "limit", 20)), + ["get_tempdb_trend"] = (c, pg, an) => DarlingMcpDataTools.GetTempDbTrend(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), + ["get_top_procedures_by_cpu"] = (c, pg, an) => DarlingMcpDataTools.GetTopProceduresByCpu(pg, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name"), as_of: AsOf(c)), + ["get_top_queries_by_cpu"] = (c, pg, an) => DarlingMcpDataTools.GetTopQueriesByCpu(pg, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name"), QueryBool(c, "parallel_only", false), QueryInt(c, "min_dop", null, 0), as_of: AsOf(c)), + ["get_pg_top_queries"] = (c, pg, an) => DarlingMcpPgStatementTools.GetPgTopQueries(pg, Server(c), Hours(c, 24), Rows(c, "limit", 20), as_of: AsOf(c)), + ["get_pg_wraparound_risk"] = (c, pg, an) => DarlingMcpPgWraparoundTools.GetPgWraparoundRisk(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), + ["get_pg_xmin_horizon"] = (c, pg, an) => DarlingMcpPgXminTools.GetPgXminHorizon(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), + ["get_pg_replication_slots"] = (c, pg, an) => DarlingMcpPgSlotTools.GetPgReplicationSlots(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), + ["get_pg_autovacuum_health"] = (c, pg, an) => DarlingMcpPgAutovacuumTools.GetPgAutovacuumHealth(pg, Server(c), Hours(c, 24), Rows(c, "limit", 20), as_of: AsOf(c)), + ["get_pg_io_stats"] = (c, pg, an) => DarlingMcpPgIoTools.GetPgIoStats(pg, Server(c), Hours(c, 24), Rows(c, "limit", 20), as_of: AsOf(c)), + ["get_pg_wait_stats"] = (c, pg, an) => DarlingMcpPgWaitTools.GetPgWaitStats(pg, Server(c), Hours(c, 24), Rows(c, "limit", 20), as_of: AsOf(c)), + ["get_pg_blocking"] = (c, pg, an) => DarlingMcpPgBlockingTools.GetPgBlocking(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50), as_of: AsOf(c)), + ["get_wait_stats"] = (c, pg, an) => DarlingMcpDataTools.GetWaitStats(pg, Server(c), Hours(c, 24), Rows(c, "limit", 20), as_of: AsOf(c)), ["get_wait_trend"] = (c, pg, an) => RequireText(c, "wait_type", out var waitType) - ? DarlingMcpDataTools.GetWaitTrend(pg, waitType, Server(c), Hours(c, 24)) + ? DarlingMcpDataTools.GetWaitTrend(pg, waitType, Server(c), Hours(c, 24), as_of: AsOf(c)) : MissingParam("wait_type"), - ["get_wait_types"] = (c, pg, an) => DarlingMcpDataTools.GetWaitTypes(pg, Server(c), Hours(c, 24)), + ["get_wait_types"] = (c, pg, an) => DarlingMcpDataTools.GetWaitTypes(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), ["list_servers"] = (c, pg, an) => DarlingMcpDataTools.ListServers(pg), /* ── trends ── */ - ["get_file_io_trend"] = (c, pg, an) => DarlingMcpTrendTools.GetFileIoTrend(pg, Server(c), Hours(c, 24)), - ["get_memory_trend"] = (c, pg, an) => DarlingMcpTrendTools.GetMemoryTrend(pg, Server(c), Hours(c, 24)), + ["get_file_io_trend"] = (c, pg, an) => DarlingMcpTrendTools.GetFileIoTrend(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), + ["get_memory_trend"] = (c, pg, an) => DarlingMcpTrendTools.GetMemoryTrend(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), ["get_perfmon_trend"] = (c, pg, an) => RequireText(c, "counter_name", out var counter) - ? DarlingMcpTrendTools.GetPerfmonTrend(pg, counter, Server(c), Hours(c, 24)) + ? DarlingMcpTrendTools.GetPerfmonTrend(pg, counter, Server(c), Hours(c, 24), as_of: AsOf(c)) : MissingParam("counter_name"), - ["get_procedure_duration_trend"] = (c, pg, an) => DarlingMcpTrendTools.GetProcedureDurationTrend(pg, Server(c), Hours(c, 24)), - ["get_query_duration_trend"] = (c, pg, an) => DarlingMcpTrendTools.GetQueryDurationTrend(pg, Server(c), Hours(c, 24)), - ["get_query_store_duration_trend"] = (c, pg, an) => DarlingMcpTrendTools.GetQueryStoreDurationTrend(pg, Server(c), Hours(c, 24)), + ["get_procedure_duration_trend"] = (c, pg, an) => DarlingMcpTrendTools.GetProcedureDurationTrend(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), + ["get_query_duration_trend"] = (c, pg, an) => DarlingMcpTrendTools.GetQueryDurationTrend(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), + ["get_query_store_duration_trend"] = (c, pg, an) => DarlingMcpTrendTools.GetQueryStoreDurationTrend(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), ["get_query_trend"] = (c, pg, an) => RequireText(c, "query_hash", out var queryHash) ? (RequireText(c, "database_name", out var db) - ? DarlingMcpTrendTools.GetQueryTrend(pg, queryHash, db, Server(c), Hours(c, 24)) + ? DarlingMcpTrendTools.GetQueryTrend(pg, queryHash, db, Server(c), Hours(c, 24), as_of: AsOf(c)) : MissingParam("database_name")) : MissingParam("query_hash"), @@ -1600,13 +1608,13 @@ internal static IReadOnlyDictionary BuildReadDispatch() ["get_store_metrics"] = (c, pg, an) => DarlingMcpStoreMetricsTools.GetStoreMetrics(pg, QueryInt(c, "days_back", null, 30)), /* ── latch / spinlock ── */ - ["get_latch_stats"] = (c, pg, an) => DarlingMcpLatchSpinlockTools.GetLatchStats(pg, Server(c), Hours(c, 24), Rows(c, "top", 10)), - ["get_spinlock_stats"] = (c, pg, an) => DarlingMcpLatchSpinlockTools.GetSpinlockStats(pg, Server(c), Hours(c, 24), Rows(c, "top", 10)), + ["get_latch_stats"] = (c, pg, an) => DarlingMcpLatchSpinlockTools.GetLatchStats(pg, Server(c), Hours(c, 24), Rows(c, "top", 10), as_of: AsOf(c)), + ["get_spinlock_stats"] = (c, pg, an) => DarlingMcpLatchSpinlockTools.GetSpinlockStats(pg, Server(c), Hours(c, 24), Rows(c, "top", 10), as_of: AsOf(c)), /* ── memory grants ── */ - ["get_memory_grants"] = (c, pg, an) => DarlingMcpMemoryGrantTools.GetMemoryGrants(pg, Server(c), Hours(c, 1)), - ["get_memory_pressure_events"] = (c, pg, an) => DarlingMcpMemoryGrantTools.GetMemoryPressureEvents(pg, Server(c), Hours(c, 24)), - ["get_resource_semaphore"] = (c, pg, an) => DarlingMcpMemoryGrantTools.GetResourceSemaphore(pg, Server(c), Hours(c, 24)), + ["get_memory_grants"] = (c, pg, an) => DarlingMcpMemoryGrantTools.GetMemoryGrants(pg, Server(c), Hours(c, 1), as_of: AsOf(c)), + ["get_memory_pressure_events"] = (c, pg, an) => DarlingMcpMemoryGrantTools.GetMemoryPressureEvents(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), + ["get_resource_semaphore"] = (c, pg, an) => DarlingMcpMemoryGrantTools.GetResourceSemaphore(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), /* ── object / index stats ── */ ["get_database_sizes"] = (c, pg, an) => DarlingMcpObjectStatsTools.GetDatabaseSizes(pg, Server(c)), @@ -1617,7 +1625,7 @@ internal static IReadOnlyDictionary BuildReadDispatch() /* ── plan cache / scheduler ── */ ["get_cpu_scheduler_pressure"] = (c, pg, an) => DarlingMcpPlanCacheSchedulerTools.GetCpuSchedulerPressure(pg, Server(c)), - ["get_plan_cache_bloat"] = (c, pg, an) => DarlingMcpPlanCacheSchedulerTools.GetPlanCacheBloat(pg, Server(c), Hours(c, 24)), + ["get_plan_cache_bloat"] = (c, pg, an) => DarlingMcpPlanCacheSchedulerTools.GetPlanCacheBloat(pg, Server(c), Hours(c, 24), as_of: AsOf(c)), /* ── jobs ── */ ["get_running_jobs"] = (c, pg, an) => DarlingMcpJobTools.GetRunningJobs(pg, Server(c)), @@ -1628,18 +1636,18 @@ internal static IReadOnlyDictionary BuildReadDispatch() : MissingParam("query_hash"), /* ── default trace ── */ - ["get_default_trace_events"] = (c, pg, an) => DarlingMcpDefaultTraceTools.GetDefaultTraceEvents(pg, Server(c), Hours(c, 24), Rows(c, "limit", 100)), + ["get_default_trace_events"] = (c, pg, an) => DarlingMcpDefaultTraceTools.GetDefaultTraceEvents(pg, Server(c), Hours(c, 24), Rows(c, "limit", 100), as_of: AsOf(c)), /* ── system_health parse-on-read family ── */ - ["get_health_parser_cpu_tasks"] = (c, pg, an) => DarlingMcpHealthParserTools.GetCPUTasks(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), - ["get_health_parser_io_issues"] = (c, pg, an) => DarlingMcpHealthParserTools.GetIOIssues(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), - ["get_health_parser_memory_broker"] = (c, pg, an) => DarlingMcpHealthParserTools.GetMemoryBroker(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), - ["get_health_parser_memory_conditions"] = (c, pg, an) => DarlingMcpHealthParserTools.GetMemoryConditions(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), - ["get_health_parser_memory_node_oom"] = (c, pg, an) => DarlingMcpHealthParserTools.GetMemoryNodeOOM(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), - ["get_health_parser_scheduler_issues"] = (c, pg, an) => DarlingMcpHealthParserTools.GetSchedulerIssues(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), - ["get_health_parser_severe_errors"] = (c, pg, an) => DarlingMcpHealthParserTools.GetSevereErrors(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), - ["get_health_parser_significant_waits"] = (c, pg, an) => DarlingMcpHealthParserTools.GetSignificantWaits(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), - ["get_health_parser_system_health"] = (c, pg, an) => DarlingMcpHealthParserTools.GetSystemHealth(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), + ["get_health_parser_cpu_tasks"] = (c, pg, an) => DarlingMcpHealthParserTools.GetCPUTasks(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50), as_of: AsOf(c)), + ["get_health_parser_io_issues"] = (c, pg, an) => DarlingMcpHealthParserTools.GetIOIssues(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50), as_of: AsOf(c)), + ["get_health_parser_memory_broker"] = (c, pg, an) => DarlingMcpHealthParserTools.GetMemoryBroker(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50), as_of: AsOf(c)), + ["get_health_parser_memory_conditions"] = (c, pg, an) => DarlingMcpHealthParserTools.GetMemoryConditions(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50), as_of: AsOf(c)), + ["get_health_parser_memory_node_oom"] = (c, pg, an) => DarlingMcpHealthParserTools.GetMemoryNodeOOM(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50), as_of: AsOf(c)), + ["get_health_parser_scheduler_issues"] = (c, pg, an) => DarlingMcpHealthParserTools.GetSchedulerIssues(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50), as_of: AsOf(c)), + ["get_health_parser_severe_errors"] = (c, pg, an) => DarlingMcpHealthParserTools.GetSevereErrors(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50), as_of: AsOf(c)), + ["get_health_parser_significant_waits"] = (c, pg, an) => DarlingMcpHealthParserTools.GetSignificantWaits(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50), as_of: AsOf(c)), + ["get_health_parser_system_health"] = (c, pg, an) => DarlingMcpHealthParserTools.GetSystemHealth(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50), as_of: AsOf(c)), }; } @@ -1696,6 +1704,14 @@ internal static ToolResponseKind ClassifyToolResponse(string result) /// The hours-back window from ?hours= (or the tool's own ?hours_back=), else the tool's default. private static int Hours(HttpContext context, int def) => QueryInt(context, "hours", "hours_back", def); + /// + /// The window ANCHOR from ?as_of=; null when absent, which is what makes the window end at now. + /// Passed through UNVALIDATED on purpose — the tool owns the parse and the refusal message, so the web and + /// MCP surfaces cannot disagree about what a bad anchor means, and the bare string reaches the same + /// 400 mapping every other client-correctable message does. + /// + private static string? AsOf(HttpContext context) => First(context, "as_of"); + /// An optional text parameter; null when absent or empty (so the tool sees its own default). private static string? Str(HttpContext context, string key) => First(context, key); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAlertReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAlertReader.cs index a2afd8f950..845ed3f4b2 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAlertReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAlertReader.cs @@ -52,26 +52,32 @@ public sealed record AlertHistoryReadRow( muted, detail_text"; - /// Per-server alert history — the viewer's AlertHistorySql. $1 window start, $2 server_id, - /// $3 limit (naive UTC / int / int). + /// Per-server alert history — the viewer's AlertHistorySql. $1 window start, $2 window + /// end, $3 server_id, $4 limit (naive UTC / naive UTC / int / int). + /// + /// The upper edge is bounded rather than open (#2495): the row cap is applied by the database, so + /// trimming after the read would spend the whole LIMIT on rows newer than the anchor and hand back an + /// empty window that looks like a quiet one. public const string AlertHistorySql = @" SELECT" + AlertHistorySelectColumns + @" FROM config_alert_log WHERE alert_time >= $1 -AND server_id = $2 +AND alert_time <= $2 +AND server_id = $3 AND dismissed = FALSE ORDER BY alert_time DESC -LIMIT $3"; +LIMIT $4"; /// All-servers alert history (the fleet default) — the viewer's AlertHistoryAllServersSql. - /// $1 window start, $2 limit (naive UTC / int). + /// $1 window start, $2 window end, $3 limit (naive UTC / naive UTC / int). public const string AlertHistoryAllServersSql = @" SELECT" + AlertHistorySelectColumns + @" FROM config_alert_log WHERE alert_time >= $1 +AND alert_time <= $2 AND dismissed = FALSE ORDER BY alert_time DESC -LIMIT $2"; +LIMIT $3"; /// /// Recent alerts newest first, excluding dismissed rows — the Alert History read. With no @@ -79,12 +85,13 @@ ORDER BY alert_time DESC /// server. Mirrors the viewer's optional-serverId GetAlertHistoryAsync. /// public static async Task> GetAlertHistoryAsync( - NpgsqlDataSource postgres, DateTime sinceUtc, int? serverId, int limit, CancellationToken cancellationToken = default) + NpgsqlDataSource postgres, DateTime sinceUtc, DateTime untilUtc, int? serverId, int limit, CancellationToken cancellationToken = default) { var rows = new List(); await using var command = postgres.CreateCommand(serverId.HasValue ? AlertHistorySql : AlertHistoryAllServersSql); DarlingMcpReadParameters.AddTimestamp(command, sinceUtc); + DarlingMcpReadParameters.AddTimestamp(command, untilUtc); if (serverId.HasValue) { DarlingMcpReadParameters.AddInt(command, serverId.Value); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs index a4ab554c66..b6fc77cfa3 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs @@ -164,7 +164,7 @@ public sealed record ServerPropertiesReadRow( /// naive UTC by subtracting the per-batch UTC offset (#1262). Windows on collection_time (the /// reliable naive-UTC clock, not the server-local sample_time). Reads the base /// cpu_utilization_stats table (the de-skew window function needs collection_time alongside - /// sample_time). $1 server_id, $2 window start (naive UTC). + /// sample_time). $1 server_id, $2 window start, $3 window end (naive UTC). /// public const string CpuUtilizationSql = """ SELECT @@ -178,16 +178,18 @@ public sealed record ServerPropertiesReadRow( FROM cpu_utilization_stats WHERE server_id = $1 AND collection_time >= $2 + AND collection_time <= $3 ORDER BY sample_time """; public static async Task> GetCpuUtilizationAsync( - NpgsqlDataSource postgres, int serverId, DateTime startUtc, CancellationToken cancellationToken = default) + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) { var samples = new List(); await using var command = postgres.CreateCommand(CpuUtilizationSql); AddInt(command, serverId); AddTimestamp(command, startUtc); + AddTimestamp(command, endUtc); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { @@ -521,7 +523,7 @@ public static async Task> GetLatestFileIoStatsAsync( /// /// tempdb space-usage samples over the window — the viewer's TempDbTrendSql. MB columns are /// numeric(18,2) → double precision; total_sessions_using_tempdb is bigint, top_session_id is - /// integer. $1 server_id, $2 window start (naive UTC). + /// integer. $1 server_id, $2 window start, $3 window end (naive UTC). /// public const string TempDbTrendSql = """ SELECT @@ -537,16 +539,18 @@ public static async Task> GetLatestFileIoStatsAsync( FROM v_tempdb_stats WHERE server_id = $1 AND collection_time >= $2 + AND collection_time <= $3 ORDER BY collection_time """; public static async Task> GetTempDbTrendAsync( - NpgsqlDataSource postgres, int serverId, DateTime startUtc, CancellationToken cancellationToken = default) + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) { var samples = new List(); await using var command = postgres.CreateCommand(TempDbTrendSql); AddInt(command, serverId); AddTimestamp(command, startUtc); + AddTimestamp(command, endUtc); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs index 826947a3b5..0175259de2 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs @@ -68,9 +68,10 @@ public static async Task GetAlertHistory( NpgsqlDataSource postgres, [Description("Server name or display name. Omit to return alerts across all servers (the fleet default).")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, - [Description("Maximum rows. Default 50.")] int limit = 50) + [Description("Maximum rows. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var limitError = McpHelpers.ValidateTop(limit); if (limitError != null) return limitError; @@ -89,8 +90,8 @@ public static async Task GetAlertHistory( try { - var since = DateTime.UtcNow.AddHours(-hours_back); - var rows = await DarlingAlertReader.GetAlertHistoryAsync(postgres, since, serverId, limit); + var since = windowEnd.AddHours(-hours_back); + var rows = await DarlingAlertReader.GetAlertHistoryAsync(postgres, since, windowEnd, serverId, limit); if (rows.Count == 0) return McpHelpers.Status("empty", "No alerts found in the specified time range."); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs index cf6a9db734..c6781fc180 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs @@ -50,19 +50,20 @@ public static async Task GetBlocking( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum rows. Default 30.")] int limit = 30, - [Description("Optional #1140 alert fingerprint (the alert's Dedup Key). When supplied, returns only the incident with that key — paste it straight from an alert or ticket instead of scanning the window. The key is scoped to the server's display name and the incident's involved objects.")] string? dedup_key = null) + [Description("Optional #1140 alert fingerprint (the alert's Dedup Key). When supplied, returns only the incident with that key — paste it straight from an alert or ticket instead of scanning the window. The key is scoped to the server's display name and the incident's involved objects.")] string? dedup_key = null, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveWithFingerprintNameAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(limit); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingBlockingReader.GetRecentBlockedProcessReportsAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) @@ -152,19 +153,20 @@ public static async Task GetDeadlocks( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum rows. Default 20.")] int limit = 20, - [Description("Optional #1140 alert fingerprint (the alert's Dedup Key). When supplied, returns only the incident with that key — paste it straight from an alert or ticket instead of scanning the window. The key is scoped to the server's display name and the incident's involved objects.")] string? dedup_key = null) + [Description("Optional #1140 alert fingerprint (the alert's Dedup Key). When supplied, returns only the incident with that key — paste it straight from an alert or ticket instead of scanning the window. The key is scoped to the server's display name and the incident's involved objects.")] string? dedup_key = null, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveWithFingerprintNameAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(limit); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingBlockingReader.GetRecentDeadlocksAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) @@ -219,19 +221,20 @@ public static async Task GetDeadlockDetail( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum deadlocks to return. Default 5.")] int limit = 5, - [Description("Optional #1140 alert fingerprint (the alert's Dedup Key). When supplied, returns only the incident with that key — paste it straight from an alert or ticket instead of scanning the window. The key is scoped to the server's display name and the incident's involved objects.")] string? dedup_key = null) + [Description("Optional #1140 alert fingerprint (the alert's Dedup Key). When supplied, returns only the incident with that key — paste it straight from an alert or ticket instead of scanning the window. The key is scoped to the server's display name and the incident's involved objects.")] string? dedup_key = null, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveWithFingerprintNameAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(limit); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingBlockingReader.GetRecentDeadlocksAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); @@ -288,19 +291,20 @@ public static async Task GetBlockedProcessXml( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, - [Description("Maximum reports to return. Default 5.")] int limit = 5) + [Description("Maximum reports to return. Default 5.")] int limit = 5, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(limit); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingBlockingReader.GetRecentBlockedProcessReportsAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); var withXml = rows.Where(r => r.HasReportXml).Take(limit).ToList(); @@ -334,12 +338,13 @@ public static async Task GetBlockedProcessXml( public static async Task GetBlockingTrend( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try @@ -383,12 +388,13 @@ means nothing at all is known about the window. The stored tables cannot tell th public static async Task GetDeadlockTrend( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs index f9a620cdb4..b3fa72ea34 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs @@ -44,21 +44,23 @@ public sealed class DarlingMcpConfigHistoryTools public static async Task GetServerConfigChanges( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) + [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var windowStart = NaiveUtcNow().AddHours(-hours_back); + var windowEndNaive = NaiveUtc(windowEnd); + var windowStart = windowEndNaive.AddHours(-hours_back); var snapshots = await DarlingConfigHistoryReader.GetServerConfigSnapshotsAsync(postgres, resolved.ServerId); /* The tool reads the full unbounded history and only lower-bounds, so pass DateTime.MaxValue as the (no-op) upper edge — the shared both-edges diff then reproduces the prior behavior exactly. */ - var changes = ConfigChangeDiff.DiffServerConfigChanges(snapshots, windowStart, DateTime.MaxValue); + var changes = ConfigChangeDiff.DiffServerConfigChanges(snapshots, windowStart, UpperEdge(as_of, windowEndNaive)); if (changes.Count == 0) return NoChanges(resolved.ServerName, hours_back, DistinctCaptures(snapshots.Select(s => s.CaptureTime))); @@ -92,19 +94,21 @@ public static async Task GetServerConfigChanges( public static async Task GetDatabaseConfigChanges( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) + [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var windowStart = NaiveUtcNow().AddHours(-hours_back); + var windowEndNaive = NaiveUtc(windowEnd); + var windowStart = windowEndNaive.AddHours(-hours_back); var snapshots = await DarlingConfigHistoryReader.GetDatabaseConfigSnapshotsAsync(postgres, resolved.ServerId); - var changes = ConfigChangeDiff.DiffDatabaseConfigChanges(snapshots, windowStart, DateTime.MaxValue); + var changes = ConfigChangeDiff.DiffDatabaseConfigChanges(snapshots, windowStart, UpperEdge(as_of, windowEndNaive)); if (changes.Count == 0) return NoChanges(resolved.ServerName, hours_back, DistinctCaptures(snapshots.Select(s => s.CaptureTime))); @@ -135,19 +139,21 @@ public static async Task GetDatabaseConfigChanges( public static async Task GetTraceFlagChanges( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) + [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var windowStart = NaiveUtcNow().AddHours(-hours_back); + var windowEndNaive = NaiveUtc(windowEnd); + var windowStart = windowEndNaive.AddHours(-hours_back); var snapshots = await DarlingConfigHistoryReader.GetTraceFlagSnapshotsAsync(postgres, resolved.ServerId); - var changes = ConfigChangeDiff.DiffTraceFlagChanges(snapshots, windowStart, DateTime.MaxValue); + var changes = ConfigChangeDiff.DiffTraceFlagChanges(snapshots, windowStart, UpperEdge(as_of, windowEndNaive)); if (changes.Count == 0) return NoChanges(resolved.ServerName, hours_back, DistinctCaptures(snapshots.Select(s => s.CaptureTime))); @@ -302,5 +308,15 @@ private static string Scope(bool? isGlobal, bool? isSession) => _ => "", }; - private static DateTime NaiveUtcNow() => DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); + private static DateTime NaiveUtc(DateTime utc) => DateTime.SpecifyKind(utc, DateTimeKind.Unspecified); + + /// + /// The diff's upper edge. With no as_of the read is still open-ended + /// () — byte-for-byte the pre-#2495 behaviour, and deliberately not + /// "now": these snapshots are stamped by the collector, so a capture whose clock ran a little ahead of + /// the service's would drop out of a window it belongs in. An anchored read bounds at the anchor, + /// because that is the whole point of sending one. + /// + private static DateTime UpperEdge(string? asOf, DateTime anchorNaiveUtc) => + string.IsNullOrWhiteSpace(asOf) ? DateTime.MaxValue : anchorNaiveUtc; } diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index 2a210d72cb..907e59637c 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -52,17 +52,18 @@ public sealed class DarlingMcpDataTools public static async Task GetCpuUtilization( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 4.")] int hours_back = 4) + [Description("Hours of history. Default 4.")] int hours_back = 4, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var rows = await DarlingDataReader.GetCpuUtilizationAsync(postgres, resolved.ServerId, DateTime.UtcNow.AddHours(-hours_back)); + var rows = await DarlingDataReader.GetCpuUtilizationAsync(postgres, resolved.ServerId, windowEnd.AddHours(-hours_back), windowEnd); if (rows.Count == 0) return McpHelpers.Status("unavailable", "No CPU utilization data available."); @@ -100,19 +101,20 @@ public static async Task GetWaitStats( 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 rows to return. Default 20.")] int limit = 20) + [Description("Maximum rows to return. Default 20.")] int limit = 20, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(limit); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingDataReader.GetWaitStatsAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) return McpHelpers.Status("unavailable", "No wait stats data available for the specified time range."); @@ -148,17 +150,18 @@ public static async Task GetWaitStats( public static async Task GetWaitTypes( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var types = await DarlingDataReader.GetDistinctWaitTypesAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); @@ -197,17 +200,18 @@ public static async Task GetWaitTrend( NpgsqlDataSource postgres, [Description("The exact wait type name, e.g. CXPACKET, PAGEIOLATCH_SH.")] string wait_type, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var start = now.AddHours(-hours_back); var points = await DarlingDataReader.GetWaitTrendAsync(postgres, resolved.ServerId, wait_type, start, now); if (points.Count == 0) @@ -377,17 +381,18 @@ public static async Task GetFileIoStats( public static async Task GetTempDbTrend( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var rows = await DarlingDataReader.GetTempDbTrendAsync(postgres, resolved.ServerId, DateTime.UtcNow.AddHours(-hours_back)); + var rows = await DarlingDataReader.GetTempDbTrendAsync(postgres, resolved.ServerId, windowEnd.AddHours(-hours_back), windowEnd); if (rows.Count == 0) return McpHelpers.Status("unavailable", "No TempDB data available."); @@ -470,7 +475,8 @@ public static async Task GetTopQueriesByCpu( [Description("Filter to a specific database.")] string? database_name = null, [Description("If true, only return queries whose cached plan has EVER run at DOP > 1. Note: max_dop comes from sys.dm_exec_query_stats and is a lifetime-max for the plan's time in cache, so a plan compiled before MAXDOP was lowered keeps reporting the old higher value until it is evicted or recompiled. Confirm current parallelism with analyze_query_plan, which reads the actual plan.")] bool parallel_only = false, [Description("Minimum DOP to filter on. Implies parallel filtering. Filters the same lifetime-max value as parallel_only, not current parallelism.")] int min_dop = 0, - [Description("Grouping. 'query_hash' (default) is one row per (database, query_hash, host_object). 'host_object' rolls every statement of a hosting procedure/function into ONE row — use it when dynamic SQL built with per-value literals fragments one logical statement across many query_hash values, which makes top-N-by-hash structurally unable to surface it (measured at 21 fragments for one statement, whose combined CPU was the largest on the instance while no single fragment ranked). Ad-hoc statements have no host object and stay grouped per hash in both modes. distinct_query_hashes reports how many hashes a row rolled up.")] string group_by = "query_hash") + [Description("Grouping. 'query_hash' (default) is one row per (database, query_hash, host_object). 'host_object' rolls every statement of a hosting procedure/function into ONE row — use it when dynamic SQL built with per-value literals fragments one logical statement across many query_hash values, which makes top-N-by-hash structurally unable to surface it (measured at 21 fragments for one statement, whose combined CPU was the largest on the instance while no single fragment ranked). Ad-hoc statements have no host object and stay grouped per hash in both modes. distinct_query_hashes reports how many hashes a row rolled up.")] string group_by = "query_hash", + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; @@ -485,14 +491,14 @@ the exact wrong conclusion this option exists to prevent. */ $"group_by must be 'query_hash' or 'host_object' (got '{group_by}')."); } - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(top, "top"); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingDataReader.GetTopQueriesByCpuAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now, top, database_name, rollUpByHostObject: rollUp); if (rows.Count == 0) @@ -597,19 +603,20 @@ public static async Task GetTopProceduresByCpu( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Number of top procedures. Default 20.")] int top = 20, - [Description("Filter to a specific database.")] string? database_name = null) + [Description("Filter to a specific database.")] string? database_name = null, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(top, "top"); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingDataReader.GetTopProceduresByCpuAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now, top, database_name); if (rows.Count == 0) return McpHelpers.Status( @@ -681,19 +688,20 @@ public static async Task GetQueryStoreTop( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Number of top queries. Default 20.")] int top = 20, - [Description("Filter to a specific database.")] string? database_name = null) + [Description("Filter to a specific database.")] string? database_name = null, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(top, "top"); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var requestedStart = now.AddHours(-hours_back); var rows = await DarlingDataReader.GetQueryStoreTopAsync(postgres, resolved.ServerId, requestedStart, now, top, database_name); @@ -1082,7 +1090,8 @@ public static async Task GetCollectionLog( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, - [Description("Maximum rows to return, newest first. Default 200.")] int limit = 200) + [Description("Maximum rows to return, newest first. Default 200.")] int limit = 200, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; @@ -1092,9 +1101,12 @@ public static async Task GetCollectionLog( var invalidLimit = McpHelpers.ValidateTop(limit); if (invalidLimit != null) return invalidLimit; + var anchorError = McpHelpers.ResolveAsOf(as_of, out var windowEnd); + if (anchorError != null) return anchorError; + try { - var end = DateTime.UtcNow; + var end = windowEnd; var start = end.AddHours(-Math.Abs(hours_back)); /* Over-fetch by one so truncation is OBSERVED rather than inferred. Comparing count to the @@ -1165,14 +1177,18 @@ public static async Task GetCurrentWaitsTrend( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 4.")] int hours_back = 4, - [Description("Limit the blocked-session series to one database. Omit for all databases.")] string? database_name = null) + [Description("Limit the blocked-session series to one database. Omit for all databases.")] string? database_name = null, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; + var anchorError = McpHelpers.ResolveAsOf(as_of, out var windowEnd); + if (anchorError != null) return anchorError; + try { - var end = DateTime.UtcNow; + var end = windowEnd; var start = end.AddHours(-Math.Abs(hours_back)); var waits = await DarlingDataReader.GetWaitingTaskTrendAsync(postgres, resolved.ServerId, start, end); @@ -1230,14 +1246,18 @@ across two tools a caller can fetch one and draw the wrong conclusion. public static async Task GetBlockingStats( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; + var anchorError = McpHelpers.ResolveAsOf(as_of, out var windowEnd); + if (anchorError != null) return anchorError; + try { - var end = DateTime.UtcNow; + var end = windowEnd; var start = end.AddHours(-Math.Abs(hours_back)); var blocking = await DarlingDataReader.GetBlockingDurationStatsAsync(postgres, resolved.ServerId, start, end); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDefaultTraceTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDefaultTraceTools.cs index fda50e00c8..edb2576644 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDefaultTraceTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDefaultTraceTools.cs @@ -42,17 +42,18 @@ public static async Task GetDefaultTraceEvents( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of events to return. Default 100.")] int limit = 100) + [Description("Maximum number of events to return. Default 100.")] int limit = 100, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd) ?? McpHelpers.ValidateTop(limit); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var all = await DarlingDefaultTraceReader.ReadEventsAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthParserTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthParserTools.cs index 20f46a7983..74f9c46698 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthParserTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthParserTools.cs @@ -51,13 +51,14 @@ public static async Task GetSystemHealth( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { try { /* The corruption + contention counter series is UNGATED (no warnings-only filter) — the viewer's GetSystemHealthAsync keeps every SYSTEM snapshot that has a timestamp. */ - var c = await CollectAsync(postgres, server_name, hours_back, limit, + var c = await CollectAsync(postgres, server_name, hours_back, limit, as_of, SystemHealthParser.SpServerDiagnosticsEvent, xml => One(SystemHealthParser.ParseSystemHealth(xml)), r => r.EventTime.HasValue); @@ -99,17 +100,18 @@ public static async Task GetSevereErrors( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd) ?? McpHelpers.ValidateTop(limit); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; /* database_id → name resolution needs the collected size-stats mapping (the shred left it null). */ var mapTask = DarlingSystemHealthReader.GetDatabaseNameMapAsync(postgres, resolved.ServerId); var xmls = await DarlingSystemHealthReader.ReadEventXmlAsync( @@ -149,12 +151,13 @@ public static async Task GetIOIssues( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { try { /* IO_SUBSYSTEM fans one event out to one row per pending-request file — a many-per-event shred. */ - var c = await CollectAsync(postgres, server_name, hours_back, limit, + var c = await CollectAsync(postgres, server_name, hours_back, limit, as_of, SystemHealthParser.SpServerDiagnosticsEvent, SystemHealthParser.ParseIoIssues, SystemHealthSignificance.IsSignificant); @@ -187,11 +190,12 @@ public static async Task GetSchedulerIssues( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { try { - var c = await CollectAsync(postgres, server_name, hours_back, limit, + var c = await CollectAsync(postgres, server_name, hours_back, limit, as_of, SystemHealthParser.SchedulerMonitorEvent, xml => One(SystemHealthParser.ParseSchedulerIssue(xml)), SystemHealthSignificance.IsSignificant); @@ -226,11 +230,12 @@ public static async Task GetMemoryConditions( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { try { - var c = await CollectAsync(postgres, server_name, hours_back, limit, + var c = await CollectAsync(postgres, server_name, hours_back, limit, as_of, SystemHealthParser.SpServerDiagnosticsEvent, xml => One(SystemHealthParser.ParseMemoryConditions(xml)), SystemHealthSignificance.IsSignificant); @@ -288,11 +293,12 @@ public static async Task GetCPUTasks( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { try { - var c = await CollectAsync(postgres, server_name, hours_back, limit, + var c = await CollectAsync(postgres, server_name, hours_back, limit, as_of, SystemHealthParser.SpServerDiagnosticsEvent, xml => One(SystemHealthParser.ParseCpuTasks(xml)), SystemHealthSignificance.IsSignificant); @@ -329,11 +335,12 @@ public static async Task GetMemoryBroker( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { try { - var c = await CollectAsync(postgres, server_name, hours_back, limit, + var c = await CollectAsync(postgres, server_name, hours_back, limit, as_of, SystemHealthParser.MemoryBrokerEvent, xml => One(SystemHealthParser.ParseMemoryBroker(xml)), SystemHealthSignificance.IsSignificant); @@ -372,13 +379,14 @@ public static async Task GetMemoryNodeOOM( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { try { /* Memory-node OOM is never gated — every recorded OOM is significant (sp_HealthParser applies no WHERE filter to this category). */ - var c = await CollectAsync(postgres, server_name, hours_back, limit, + var c = await CollectAsync(postgres, server_name, hours_back, limit, as_of, SystemHealthParser.MemoryNodeOomEvent, xml => One(SystemHealthParser.ParseMemoryNodeOom(xml)), SystemHealthSignificance.IsSignificant); @@ -432,12 +440,13 @@ public static async Task GetSignificantWaits( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd) ?? McpHelpers.ValidateTop(limit); if (validation != null) return validation; try @@ -448,7 +457,7 @@ Written out rather than routed through CollectAsync because the raw event count server, and zero out of zero is a blind one. CollectAsync returns only the surviving rows, so the two would arrive indistinguishable. */ - var now = DateTime.UtcNow; + var now = windowEnd; var xmls = await DarlingSystemHealthReader.ReadEventXmlAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now, SystemHealthParser.WaitInfoEvent); @@ -516,7 +525,7 @@ signal close to the total is CPU pressure wearing a wait type's name. */ private readonly record struct Collected(string? EarlyReturn, string ServerName, List Rows); /// - /// Resolves the server, validates hours_back + limit, reads the raw event_xml for + /// Resolves the server, validates hours_back + as_of + limit, reads the raw event_xml for /// over the window, shreds each blob with (the /// reused — 0..n records per event), and keeps only the rows /// accepts. The seven gated categories pass their @@ -524,16 +533,16 @@ signal close to the total is CPU pressure wearing a wait type's name. */ /// predicate (ungated, matching the viewer's chart read). /// private static async Task> CollectAsync( - NpgsqlDataSource postgres, string? serverName, int hoursBack, int limit, string eventType, + NpgsqlDataSource postgres, string? serverName, int hoursBack, int limit, string? asOf, string eventType, Func> shred, Func significant) where T : class { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, serverName); if (error != null) return new Collected(error, "", new List()); - var validation = McpHelpers.ValidateHoursBack(hoursBack) ?? McpHelpers.ValidateTop(limit); + var validation = McpHelpers.ValidateWindow(hoursBack, asOf, out var windowEnd) ?? McpHelpers.ValidateTop(limit); if (validation != null) return new Collected(validation, "", new List()); - var now = DateTime.UtcNow; + var now = windowEnd; var xmls = await DarlingSystemHealthReader.ReadEventXmlAsync( postgres, resolved.ServerId, now.AddHours(-hoursBack), now, eventType); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs index 08bd11d2db..0d3f63f9dc 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs @@ -64,6 +64,18 @@ public static string Build(DarlingPeerDirectory.Snapshot peers) This server exposes 108 tools. 83 are the same names Performance Monitor Lite exposes, spanning diagnostic analysis, plan analysis, data reads at core and diagnostic depth, resource contention + jobs, trends, system-health parse-on-read, alerts + health overview, and the Default Trace. The remaining 25 are unique to Darling: eight are the PostgreSQL reads (Aurora/PostgreSQL targets only Darling's central store can hold), eight are the Custom Views tools (seven manage the saved views — the one view-authoring write surface — and `describe_custom_view_catalog` returns the read-only compose vocabulary those authoring tools draw from), three are alert-tuning write tools (`update_alert_settings` tunes the alert engine's thresholds; `create_mute_rule` / `delete_mute_rule` manage the mute rules) that write only the shared alert configuration in the monitoring store, two are server-onboarding write tools (`add_servers` bulk-adds monitored servers; `remove_server` removes one) that add or remove rows in the monitoring store's monitored-server registry, `get_fleet_overview` and `get_ag_health` are the two cross-server reads only a central store can answer, `get_store_metrics` reads the monitoring store's OWN hourly size/compression/growth series for capacity forecasting, and `get_blocking` is Darling's name for the blocked-process-report read that Lite exposes as `get_blocked_process_reports` — a naming difference, not a capability gap. Every data-read tool reads the data the collectors already captured into the store — a stored read, never a live query against the monitored server. + ### Asking about a PAST window + + Every tool below that takes `hours_back` also takes `as_of`: an optional ISO-8601 UTC instant that moves the END of the window off "now". `hours_back` stays the window's LENGTH. So the four hours around last Tuesday 03:00 is `as_of=2026-08-19T05:00:00Z, hours_back=4` — not `hours_back=170`. + + Reach for it whenever the question is about a time rather than about the present, and do NOT substitute a wider `hours_back`: for an aggregate read a wider window is a DIFFERENT answer, not the same answer with more rows. It changes what a top-N returns, what an average is taken over, and how much a capped read truncates. + + - Accepted forms: `2026-08-19T05:00:00Z`, `2026-08-19T05:00:00` (read as UTC), `2026-08-19T07:00:00+02:00`, `2026-08-19` (midnight UTC). + - An unparseable `as_of`, or one in the future, is REFUSED with a message rather than quietly answered as "now" — a read that silently reverts to now is indistinguishable from a correct one. + - An `as_of` older than anything the store still holds is NOT refused. It returns the read's normal `empty` / `unavailable` status, which means exactly what it says: we looked in the window you named and there was nothing in it. + - Tools that take no window at all (latest-snapshot reads like `get_memory_stats`, `get_file_io_stats`, `get_index_usage`, and the configuration reads) do not take `as_of` — they read the newest row, and there is no window to move. + - `analyze_server`, `get_analysis_facts`, `compare_analysis`, `get_analysis_findings`, `get_pvs_stats` and `get_fleet_overview` do not take it either. The first four window inside the analysis engine rather than at the read; the last two mix a latest-snapshot measurement with a windowed one, so anchoring only the windowed half would return a result whose two halves describe different instants. + ### Diagnostic-analysis tools | Tool | Purpose | Key Parameters | @@ -93,24 +105,24 @@ public static string Build(DarlingPeerDirectory.Snapshot peers) | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_cpu_utilization` | CPU % over time (SQL / other-process / total / idle), 1-minute averages | `server_name`, `hours_back` (default 4) | - | `get_wait_stats` | Top wait types aggregated over the window (wait/signal/resource ms, signal %) | `server_name`, `hours_back` (default 24), `limit` (default 20) | - | `get_wait_trend` | A single wait type's per-second trend over time | `wait_type` (required), `server_name`, `hours_back` (default 24) | - | `get_wait_types` | The distinct wait types observed on the server (heaviest first) — pick a `wait_type` for get_wait_trend. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server no wait stats have ever been stored for (`unavailable`) | `server_name`, `hours_back` (default 24) | + | `get_cpu_utilization` | CPU % over time (SQL / other-process / total / idle), 1-minute averages | `server_name`, `hours_back` (default 4), `as_of` | + | `get_wait_stats` | Top wait types aggregated over the window (wait/signal/resource ms, signal %) | `server_name`, `hours_back` (default 24), `limit` (default 20), `as_of` | + | `get_wait_trend` | A single wait type's per-second trend over time | `wait_type` (required), `server_name`, `hours_back` (default 24), `as_of` | + | `get_wait_types` | The distinct wait types observed on the server (heaviest first) — pick a `wait_type` for get_wait_trend. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server no wait stats have ever been stored for (`unavailable`) | `server_name`, `hours_back` (default 24), `as_of` | | `get_memory_stats` | Latest memory snapshot: physical / buffer pool / plan cache / utilization %, memory model | `server_name` | | `get_memory_clerks` | Latest top memory consumers by clerk type. An empty result is `unavailable`, never a quiet period — a live SQL Server always has clerks, so nothing retained means the collector has not run or its rows aged out | `server_name` | | `get_file_io_stats` | Latest per-file I/O: reads/writes/bytes/stall and computed read/write latency | `server_name` | - | `get_tempdb_trend` | TempDB space over time (user / internal / version store / unallocated) + top consumer | `server_name`, `hours_back` (default 24) | + | `get_tempdb_trend` | TempDB space over time (user / internal / version store / unallocated) + top consumer | `server_name`, `hours_back` (default 24), `as_of` | | `get_perfmon_stats` | Latest perfmon counters (value + delta); filter by counter / instance | `server_name`, `counter_name`, `instance_name` | - | `get_top_queries_by_cpu` | Expensive queries from query stats (plan cache) with query_hash / sql_handle; `cpu_attribution.attributed_cpu_ratio` says how much of the box's measured CPU the returned rows explain | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name`, `parallel_only`, `min_dop` | - | `get_top_procedures_by_cpu` | Most expensive stored procedures by total CPU, with the same `cpu_attribution` disclosure | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name` | - | `get_query_store_top` | Expensive queries from Query Store with query_id / plan_id (survives restarts) | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name` | - | `get_query_store_regressions` | Queries whose Query Store performance got WORSE: each (database, query_id) group's averages inside the recent window vs its BASELINE — every capture collected BEFORE that window. Baseline vs recent duration / CPU / logical reads with a regression percent each, the execution-count-weighted `additional_duration_ms` (the ranking key: a 5 ms regression run a million times outranks a 5-second one run twice), the plan counts on both sides, and a severity band. `get_query_store_top` answers what is EXPENSIVE and the costliest query is usually the one that always was; this answers what CHANGED. Kept only where average CPU regressed > 25%. Zero rows is FOUR states and the read says which: never collected (`unavailable`), no BASELINE because all history falls inside the window (`unavailable`, and NOT a clean bill of health — shorten hours_back), nothing collected in the window (`empty`, widen it), or a genuine all-clear (`empty`) | `server_name`, `hours_back` (default 24), `database_name`, `limit` (default 50) | + | `get_top_queries_by_cpu` | Expensive queries from query stats (plan cache) with query_hash / sql_handle; `cpu_attribution.attributed_cpu_ratio` says how much of the box's measured CPU the returned rows explain | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name`, `parallel_only`, `min_dop`, `as_of` | + | `get_top_procedures_by_cpu` | Most expensive stored procedures by total CPU, with the same `cpu_attribution` disclosure | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name`, `as_of` | + | `get_query_store_top` | Expensive queries from Query Store with query_id / plan_id (survives restarts) | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name`, `as_of` | + | `get_query_store_regressions` | Queries whose Query Store performance got WORSE: each (database, query_id) group's averages inside the recent window vs its BASELINE — every capture collected BEFORE that window. Baseline vs recent duration / CPU / logical reads with a regression percent each, the execution-count-weighted `additional_duration_ms` (the ranking key: a 5 ms regression run a million times outranks a 5-second one run twice), the plan counts on both sides, and a severity band. `get_query_store_top` answers what is EXPENSIVE and the costliest query is usually the one that always was; this answers what CHANGED. Kept only where average CPU regressed > 25%. Zero rows is FOUR states and the read says which: never collected (`unavailable`), no BASELINE because all history falls inside the window (`unavailable`, and NOT a clean bill of health — shorten hours_back), nothing collected in the window (`empty`, widen it), or a genuine all-clear (`empty`) | `server_name`, `hours_back` (default 24), `database_name`, `limit` (default 50), `as_of` | | `list_servers` | All monitored servers with collection-freshness status and last collection time, plus `peer_fleets` — the declared SIBLING Darling stores and what each covers (disclosure only; this server cannot read them) and `peer_note`, which says what an EMPTY `peer_fleets` does and does not prove | none | | `get_collection_health` | Per-collector health (running / failing / stale) over the last 7 days, plus the server's sweep_pressure block: a `verdict` for SUSTAINED demand (a SATURATED body collects at a multiple of its configured cadence with every collector healthy) and a separate `peak_cycle_risk` for a SINGLE sweep (BODY_OVERRUN means one scheduled body cannot fit the budget even when the verdict reads OK, the signature of one infrequent heavy collector; `peak_collector` names it). Per-collector rows carry `avg_duration_ms`, `p95_duration_ms` and `max_duration_ms`: a mean far below the p95 means the collector's runs come in two sizes and the mean describes neither | `server_name` | - | `get_collection_log` | The RAW per-run collection log behind that rollup: one row per collector run with total duration split into time on the monitored server and time on the store, rows collected, status and any error. Reach for it when the rollup reads HEALTHY and collection still looks wrong, or to see what a collector was doing during a specific window. An empty result distinguishes a quiet window (`empty`, widen it) from a server that has never collected (`unavailable`, collection is not running) | `server_name`, `hours_back`, `limit` | - | `get_current_waits_trend` | The two Current Waits series over time: waiting-task total wait per wait type per collection, and blocked-session counts per database per collection. `get_waiting_tasks` gives the snapshot and can never say whether now is worse than an hour ago; this is that question. Read the two series together — a wait-type spike with no blocked sessions is a resource wait, the same spike with them is contention. An empty result distinguishes a genuine all-clear (`empty`) from a server the collector has never sampled (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back`, `database_name` | - | `get_blocking_stats` | Blocking SEVERITY per minute: blocking duration (event count, total, max, avg wait) and deadlock severity (victim count plus total/max/avg wait across EVERY process in the graphs, not just victims). `get_blocking_trend` and `get_deadlock_trend` say how OFTEN; this says how BAD — ten one-second blocks and one ten-minute block are the same count and a different problem. An empty result distinguishes a genuinely clear window (`empty`) from a server where neither capture path has ever produced a row (`unavailable`), which is NOT a clean bill of health | `server_name`, `hours_back` | + | `get_collection_log` | The RAW per-run collection log behind that rollup: one row per collector run with total duration split into time on the monitored server and time on the store, rows collected, status and any error. Reach for it when the rollup reads HEALTHY and collection still looks wrong, or to see what a collector was doing during a specific window. An empty result distinguishes a quiet window (`empty`, widen it) from a server that has never collected (`unavailable`, collection is not running) | `server_name`, `hours_back`, `limit`, `as_of` | + | `get_current_waits_trend` | The two Current Waits series over time: waiting-task total wait per wait type per collection, and blocked-session counts per database per collection. `get_waiting_tasks` gives the snapshot and can never say whether now is worse than an hour ago; this is that question. Read the two series together — a wait-type spike with no blocked sessions is a resource wait, the same spike with them is contention. An empty result distinguishes a genuine all-clear (`empty`) from a server the collector has never sampled (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back`, `database_name`, `as_of` | + | `get_blocking_stats` | Blocking SEVERITY per minute: blocking duration (event count, total, max, avg wait) and deadlock severity (victim count plus total/max/avg wait across EVERY process in the graphs, not just victims). `get_blocking_trend` and `get_deadlock_trend` say how OFTEN; this says how BAD — ten one-second blocks and one ten-minute block are the same count and a different problem. An empty result distinguishes a genuinely clear window (`empty`) from a server where neither capture path has ever produced a row (`unavailable`), which is NOT a clean bill of health | `server_name`, `hours_back`, `as_of` | | `get_server_properties` | Instance properties: edition, version, CPU count, memory, socket/core topology, HADR | `server_name` | ### Diagnostic-depth data-read tools @@ -119,19 +131,19 @@ public static string Build(DarlingPeerDirectory.Snapshot peers) | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_blocking` | Recent blocked/blocking pairs from the blocked-process-report XE + the always-on DMV fallback | `server_name`, `hours_back` (default 24), `limit` (default 30), `dedup_key` (optional) | - | `get_deadlocks` | Recent deadlocks: victim process/SQL + a process summary | `server_name`, `hours_back` (default 24), `limit` (default 20), `dedup_key` (optional) | - | `get_deadlock_detail` | The raw deadlock graph XML for the recent deadlocks | `server_name`, `hours_back` (default 24), `limit` (default 5), `dedup_key` (optional) | - | `get_blocked_process_xml` | The raw blocked-process-report XML | `server_name`, `hours_back` (default 24), `limit` (default 5) | - | `get_long_query_completions` | Longest completed queries (rpc/batch over the trace threshold) + attentions/cancels from the opt-in long-query trace, duration DESC (empty until the collector is enabled) | `server_name`, `hours_back` (default 24), `limit` (default 30) | - | `get_blocking_trend` | Per-minute blocking-incident counts over time (XE, DMV-snapshot fallback). An empty result distinguishes a genuine all-clear (`empty`, with the collector run counts in `hints` so you can see how many captures the window actually holds) from a window no collector covered (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back` (default 24) | - | `get_deadlock_trend` | Per-minute deadlock counts over time. An empty result distinguishes a genuine all-clear (`empty`, with the collector run counts in `hints` so you can see how many captures the window actually holds) from a window no collector covered (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back` (default 24) | + | `get_blocking` | Recent blocked/blocking pairs from the blocked-process-report XE + the always-on DMV fallback | `server_name`, `hours_back` (default 24), `limit` (default 30), `dedup_key` (optional), `as_of` | + | `get_deadlocks` | Recent deadlocks: victim process/SQL + a process summary | `server_name`, `hours_back` (default 24), `limit` (default 20), `dedup_key` (optional), `as_of` | + | `get_deadlock_detail` | The raw deadlock graph XML for the recent deadlocks | `server_name`, `hours_back` (default 24), `limit` (default 5), `dedup_key` (optional), `as_of` | + | `get_blocked_process_xml` | The raw blocked-process-report XML | `server_name`, `hours_back` (default 24), `limit` (default 5), `as_of` | + | `get_long_query_completions` | Longest completed queries (rpc/batch over the trace threshold) + attentions/cancels from the opt-in long-query trace, duration DESC (empty until the collector is enabled) | `server_name`, `hours_back` (default 24), `limit` (default 30), `as_of` | + | `get_blocking_trend` | Per-minute blocking-incident counts over time (XE, DMV-snapshot fallback). An empty result distinguishes a genuine all-clear (`empty`, with the collector run counts in `hints` so you can see how many captures the window actually holds) from a window no collector covered (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back` (default 24), `as_of` | + | `get_deadlock_trend` | Per-minute deadlock counts over time. An empty result distinguishes a genuine all-clear (`empty`, with the collector run counts in `hints` so you can see how many captures the window actually holds) from a window no collector covered (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back` (default 24), `as_of` | | `get_session_stats` | Latest per-application connection/session counts (running/sleeping/dormant) + resource totals | `server_name` | - | `get_active_queries` | Captured running-query snapshots over the window (waits, CPU, blocking, grants) | `server_name`, `hours_back` (default 1), `database_name`, `blocking_only`, `limit` (default 50) | - | `get_waiting_tasks` | Individual waiting tasks captured at collection time | `server_name`, `hours_back` (default 1), `limit` (default 30) | - | `get_server_config_changes` | sp_configure changes, diffed from config snapshots | `server_name`, `hours_back` (default 168) | - | `get_database_config_changes` | sys.databases setting changes, diffed from config snapshots | `server_name`, `hours_back` (default 168) | - | `get_trace_flag_changes` | Trace flags enabled/disabled/modified, diffed from config snapshots | `server_name`, `hours_back` (default 168) | + | `get_active_queries` | Captured running-query snapshots over the window (waits, CPU, blocking, grants) | `server_name`, `hours_back` (default 1), `database_name`, `blocking_only`, `limit` (default 50), `as_of` | + | `get_waiting_tasks` | Individual waiting tasks captured at collection time | `server_name`, `hours_back` (default 1), `limit` (default 30), `as_of` | + | `get_server_config_changes` | sp_configure changes, diffed from config snapshots | `server_name`, `hours_back` (default 168), `as_of` | + | `get_database_config_changes` | sys.databases setting changes, diffed from config snapshots | `server_name`, `hours_back` (default 168), `as_of` | + | `get_trace_flag_changes` | Trace flags enabled/disabled/modified, diffed from config snapshots | `server_name`, `hours_back` (default 168), `as_of` | | `get_database_scoped_config` | Latest database-scoped configuration (MAXDOP, legacy CE, ...) | `server_name`, `database_name` | | `get_query_store_health` | Per-database Query Store health (latest hourly snapshot) — actual vs desired state, readonly_reason decoded, storage vs cap, cleanup thresholds | `server_name`, `database_name` | | `get_server_config` | CURRENT sys.configurations (latest snapshot) — what CTFP / MAXDOP / max memory are set to now | `server_name` | @@ -148,12 +160,12 @@ public static string Build(DarlingPeerDirectory.Snapshot peers) | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_latch_stats` | Top latch classes by wait time, with per-second rates + severity / description / recommendation | `server_name`, `hours_back` (default 24), `top` (default 10) | - | `get_spinlock_stats` | Top spinlocks by collisions, with per-second rates + description | `server_name`, `hours_back` (default 24), `top` (default 10) | - | `get_resource_semaphore` | Latest workspace-memory semaphores: target / max-target ceiling vs granted / used, waiter / timeout / forced | `server_name`, `hours_back` (default 24) | - | `get_memory_grants` | Latest per-pool grant detail: available / granted / used + waiter / timeout / forced deltas | `server_name`, `hours_back` (default 1) | - | `get_memory_pressure_events` | RING_BUFFER_RESOURCE_MONITOR memory-pressure notifications (process/system indicator scale 0-3+); not on Azure SQL DB | `server_name`, `hours_back` (default 24) | - | `get_plan_cache_bloat` | Plan cache single-use vs multi-use composition + bloat_level classification | `server_name`, `hours_back` (default 24) | + | `get_latch_stats` | Top latch classes by wait time, with per-second rates + severity / description / recommendation | `server_name`, `hours_back` (default 24), `top` (default 10), `as_of` | + | `get_spinlock_stats` | Top spinlocks by collisions, with per-second rates + description | `server_name`, `hours_back` (default 24), `top` (default 10), `as_of` | + | `get_resource_semaphore` | Latest workspace-memory semaphores: target / max-target ceiling vs granted / used, waiter / timeout / forced | `server_name`, `hours_back` (default 24), `as_of` | + | `get_memory_grants` | Latest per-pool grant detail: available / granted / used + waiter / timeout / forced deltas | `server_name`, `hours_back` (default 1), `as_of` | + | `get_memory_pressure_events` | RING_BUFFER_RESOURCE_MONITOR memory-pressure notifications (process/system indicator scale 0-3+); not on Azure SQL DB | `server_name`, `hours_back` (default 24), `as_of` | + | `get_plan_cache_bloat` | Plan cache single-use vs multi-use composition + bloat_level classification | `server_name`, `hours_back` (default 24), `as_of` | | `get_cpu_scheduler_pressure` | Latest scheduler snapshot: runnable queue, worker utilization, pressure_level + warnings | `server_name` | | `get_running_jobs` | Currently running SQL Agent jobs with duration vs historical average / p95 | `server_name` | @@ -163,13 +175,13 @@ public static string Build(DarlingPeerDirectory.Snapshot peers) | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_memory_trend` | Memory usage over time: total / target server memory, buffer pool, plan cache. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) | `server_name`, `hours_back` (default 24) | - | `get_perfmon_trend` | A single performance counter's value + delta over time (summed across instances) | `counter_name` (required), `server_name`, `hours_back` (default 24) | - | `get_file_io_trend` | Per-database file I/O read/write latency over time (top-10 busiest files). An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) | `server_name`, `hours_back` (default 24) | - | `get_query_trend` | One query's per-collection history (deltas, avg cpu/elapsed, DOP) by query_hash | `query_hash` (required), `database_name` (required), `server_name`, `hours_back` (default 24) | - | `get_query_duration_trend` | Overall query elapsed-ms/sec + executions/sec across all queries over time, from the PLAN CACHE. Each point carries `value` (ms/sec), `execution_count` and `executions_per_second` — the last two are the same quantity, and `execution_count` is truncated to an integer, so read `executions_per_second` on a quiet server where the rate is below 1. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) | `server_name`, `hours_back` (default 24) | - | `get_procedure_duration_trend` | The same series over `procedure_stats`. NOT a duplicate of the above: query_stats attributes a procedure's work to the individual statements inside it, so a procedure that got slower is smeared across however many statements it runs — this charges the whole call to the procedure. Read the two together to tell an ad-hoc regression from a procedure regression | `server_name`, `hours_back` (default 24) | - | `get_query_store_duration_trend` | The same series over Query Store. The plan-cache trends lose everything an eviction or a restart takes with them; Query Store persists per interval, so this is the series that survives a failover and the one to reach for when a regression is older than the cache. Each interval is counted once, at the hour the work RAN. Its `unavailable` names the cause the other two do not have: Query Store may simply be OFF on every database | `server_name`, `hours_back` (default 24) | + | `get_memory_trend` | Memory usage over time: total / target server memory, buffer pool, plan cache. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) | `server_name`, `hours_back` (default 24), `as_of` | + | `get_perfmon_trend` | A single performance counter's value + delta over time (summed across instances) | `counter_name` (required), `server_name`, `hours_back` (default 24), `as_of` | + | `get_file_io_trend` | Per-database file I/O read/write latency over time (top-10 busiest files). An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) | `server_name`, `hours_back` (default 24), `as_of` | + | `get_query_trend` | One query's per-collection history (deltas, avg cpu/elapsed, DOP) by query_hash | `query_hash` (required), `database_name` (required), `server_name`, `hours_back` (default 24), `as_of` | + | `get_query_duration_trend` | Overall query elapsed-ms/sec + executions/sec across all queries over time, from the PLAN CACHE. Each point carries `value` (ms/sec), `execution_count` and `executions_per_second` — the last two are the same quantity, and `execution_count` is truncated to an integer, so read `executions_per_second` on a quiet server where the rate is below 1. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) | `server_name`, `hours_back` (default 24), `as_of` | + | `get_procedure_duration_trend` | The same series over `procedure_stats`. NOT a duplicate of the above: query_stats attributes a procedure's work to the individual statements inside it, so a procedure that got slower is smeared across however many statements it runs — this charges the whole call to the procedure. Read the two together to tell an ad-hoc regression from a procedure regression | `server_name`, `hours_back` (default 24), `as_of` | + | `get_query_store_duration_trend` | The same series over Query Store. The plan-cache trends lose everything an eviction or a restart takes with them; Query Store persists per interval, so this is the series that survives a failover and the one to reach for when a regression is older than the cache. Each interval is counted once, at the hour the work RAN. Its `unavailable` names the cause the other two do not have: Query Store may simply be OFF on every database | `server_name`, `hours_back` (default 24), `as_of` | ### System-health parse-on-read tools @@ -177,15 +189,15 @@ public static string Build(DarlingPeerDirectory.Snapshot peers) | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_health_parser_system_health` | SYSTEM-component snapshots: corruption (bad pages / dumps / access violations) + contention (non-yielding / latch / sick spinlock / CPU) counters | `server_name`, `hours_back` (default 24), `limit` (default 50) | - | `get_health_parser_severe_errors` | error_reported severity >= 19 (excl. 17830 / 18056), with database_id resolved to a name | `server_name`, `hours_back` (default 24), `limit` (default 50) | - | `get_health_parser_scheduler_issues` | Non-yielding / offline scheduler WARNING rows | `server_name`, `hours_back` (default 24), `limit` (default 50) | - | `get_health_parser_memory_conditions` | RESOURCE_MEMPHYSICAL_LOW memory-pressure snapshots | `server_name`, `hours_back` (default 24), `limit` (default 50) | - | `get_health_parser_memory_broker` | RESOURCE_MEMPHYSICAL_LOW memory-broker ratio changes | `server_name`, `hours_back` (default 24), `limit` (default 50) | - | `get_health_parser_memory_node_oom` | Every recorded per-NUMA-node out-of-memory event (never gated) | `server_name`, `hours_back` (default 24), `limit` (default 50) | - | `get_health_parser_cpu_tasks` | QUERY_PROCESSING WARNING rows with pendingTasks >= 10 (worker-thread exhaustion) | `server_name`, `hours_back` (default 24), `limit` (default 50) | - | `get_health_parser_io_issues` | IO_SUBSYSTEM WARNING rows (15-second I/O warnings), one per pending-request file | `server_name`, `hours_back` (default 24), `limit` (default 50) | - | `get_health_parser_significant_waits` | Individual wait_info events: a real session's non-BACKUP statement waited 500 ms+ on a non-idle wait type. Returns wait type, duration and signal duration, wait resource, session id and the waiting SQL text — `get_wait_stats` gives the instance-wide totals and can never name the statement that paid them. An empty result says WHICH nothing it is: events captured but none significant (the healthy answer), a quiet window (`empty`, widen it), or a server whose wait_info has never been captured (`unavailable`, NOT an all-clear) | `server_name`, `hours_back` (default 24), `limit` (default 50) | + | `get_health_parser_system_health` | SYSTEM-component snapshots: corruption (bad pages / dumps / access violations) + contention (non-yielding / latch / sick spinlock / CPU) counters | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | + | `get_health_parser_severe_errors` | error_reported severity >= 19 (excl. 17830 / 18056), with database_id resolved to a name | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | + | `get_health_parser_scheduler_issues` | Non-yielding / offline scheduler WARNING rows | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | + | `get_health_parser_memory_conditions` | RESOURCE_MEMPHYSICAL_LOW memory-pressure snapshots | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | + | `get_health_parser_memory_broker` | RESOURCE_MEMPHYSICAL_LOW memory-broker ratio changes | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | + | `get_health_parser_memory_node_oom` | Every recorded per-NUMA-node out-of-memory event (never gated) | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | + | `get_health_parser_cpu_tasks` | QUERY_PROCESSING WARNING rows with pendingTasks >= 10 (worker-thread exhaustion) | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | + | `get_health_parser_io_issues` | IO_SUBSYSTEM WARNING rows (15-second I/O warnings), one per pending-request file | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | + | `get_health_parser_significant_waits` | Individual wait_info events: a real session's non-BACKUP statement waited 500 ms+ on a non-idle wait type. Returns wait type, duration and signal duration, wait resource, session id and the waiting SQL text — `get_wait_stats` gives the instance-wide totals and can never name the statement that paid them. An empty result says WHICH nothing it is: events captured but none significant (the healthy answer), a quiet window (`empty`, widen it), or a server whose wait_info has never been captured (`unavailable`, NOT an all-clear) | `server_name`, `hours_back` (default 24), `limit` (default 50), `as_of` | ### Alert + health-overview tools @@ -193,7 +205,7 @@ public static string Build(DarlingPeerDirectory.Snapshot peers) | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_alert_history` | Alerts that fired (metric, value vs threshold, delivery success/failure, muted); omit server_name for the whole fleet, each row names its server | `server_name` (optional — all servers if omitted), `hours_back` (default 24), `limit` (default 50) | + | `get_alert_history` | Alerts that fired (metric, value vs threshold, delivery success/failure, muted); omit server_name for the whole fleet, each row names its server | `server_name` (optional — all servers if omitted), `hours_back` (default 24), `limit` (default 50), `as_of` | | `get_alert_settings` | The current alert config the service uses: per-alert enable + thresholds, cooldown, excluded databases, delivery mode, and the scheduled-analysis cadence | none | | `get_mute_rules` | The alert mute rules in force, so a suppressed server is distinguishable from a healthy-quiet one. An empty result distinguishes no rule ever written from rules that exist but have all lapsed, with the configured count in `hints` | `enabled_only` (default true) | | `get_server_summary` | One-shot per-server health: current CPU %, memory, recent blocking count, recent deadlock count | `server_name` | @@ -236,7 +248,7 @@ public static string Build(DarlingPeerDirectory.Snapshot peers) | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_default_trace_events` | Significant Default Trace events — auto-grow/shrink stalls, severe ErrorLog, schema DDL, security audits, memory change — each categorized (config-change events excluded) | `server_name`, `hours_back` (default 24), `limit` (default 100) | + | `get_default_trace_events` | Significant Default Trace events — auto-grow/shrink stalls, severe ErrorLog, schema DDL, security audits, memory change — each categorized (config-change events excluded) | `server_name`, `hours_back` (default 24), `limit` (default 100), `as_of` | ### Custom Views (create & manage) diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs index 74384728a4..e7b6a547b0 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs @@ -42,19 +42,20 @@ public static async Task GetLatchStats( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of data to analyze. Default 24.")] int hours_back = 24, - [Description("Number of top latch classes to return. Default 10.")] int top = 10) + [Description("Number of top latch classes to return. Default 10.")] int top = 10, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(top); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingLatchSpinlockReader.GetLatchStatsTopNAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now, top); if (rows.Count == 0) @@ -95,19 +96,20 @@ public static async Task GetSpinlockStats( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of data to analyze. Default 24.")] int hours_back = 24, - [Description("Number of top spinlocks to return. Default 10.")] int top = 10) + [Description("Number of top spinlocks to return. Default 10.")] int top = 10, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(top); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingLatchSpinlockReader.GetSpinlockStatsTopNAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now, top); if (rows.Count == 0) diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs index 7354054499..223a4187a1 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs @@ -34,19 +34,20 @@ public static async Task GetLongQueryCompletions( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, - [Description("Maximum rows. Default 30.")] int limit = 30) + [Description("Maximum rows. Default 30.")] int limit = 30, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(limit); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingLongQueryReader.GetRecentLongQueryCompletionsAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs index fcadf76f09..4fd55d2a05 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs @@ -34,17 +34,18 @@ public sealed class DarlingMcpMemoryGrantTools public static async Task GetResourceSemaphore( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingMemoryGrantReader.GetResourceSemaphoreLatestAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) @@ -85,17 +86,18 @@ public static async Task GetResourceSemaphore( public static async Task GetMemoryGrants( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 1.")] int hours_back = 1) + [Description("Hours of history. Default 1.")] int hours_back = 1, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingMemoryGrantReader.GetMemoryGrantsLatestAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) @@ -140,17 +142,18 @@ Not available on Azure SQL DB (ring buffer not exposed).")] public static async Task GetMemoryPressureEvents( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingMemoryGrantReader.GetMemoryPressureEventsAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgAutovacuumTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgAutovacuumTools.cs index ea6d85e860..d91be6e31a 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgAutovacuumTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgAutovacuumTools.cs @@ -61,19 +61,20 @@ public static async Task GetPgAutovacuumHealth( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to analyze, used for the dead-tuple growth comparison. Default 24.")] int hours_back = 24, - [Description("Maximum tables to return, worst first. Default 20.")] int limit = 20) + [Description("Maximum tables to return, worst first. Default 20.")] int limit = 20, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(limit); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingPgAutovacuumReader.GetPgAutovacuumAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now, limit); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgBlockingTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgBlockingTools.cs index fc22c91e97..a8d79b2029 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgBlockingTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgBlockingTools.cs @@ -78,12 +78,13 @@ public static async Task GetPgBlocking( 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 chains to return, worst-first by victim count. Default 50.")] int limit = 50) + [Description("Maximum chains to return, worst-first by victim count. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; var limitValidation = McpHelpers.ValidateTop(limit); @@ -91,7 +92,7 @@ public static async Task GetPgBlocking( try { - var now = DateTime.UtcNow; + var now = windowEnd; var startUtc = now.AddHours(-hours_back); var chains = await DarlingPgBlockingReader.GetPgBlockingChainsAsync( diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgIoTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgIoTools.cs index ff9e13f60f..f1798f7be2 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgIoTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgIoTools.cs @@ -50,19 +50,20 @@ public static async Task GetPgIoStats( 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 (backend_type, object, context) combinations to return, busiest first. Default 20.")] int limit = 20) + [Description("Maximum (backend_type, object, context) combinations to return, busiest first. Default 20.")] int limit = 20, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(limit); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingPgIoReader.GetPgIoAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now, limit); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgSlotTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgSlotTools.cs index 25b5b509de..5a901d2560 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgSlotTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgSlotTools.cs @@ -47,17 +47,18 @@ internal static string Classify(string? walStatus, bool isActive, bool retainedW public static async Task GetPgReplicationSlots( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history to analyze, used for the WAL growth comparison. Default 24.")] int hours_back = 24) + [Description("Hours of history to analyze, used for the WAL growth comparison. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingPgSlotReader.GetPgSlotsAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgStatementTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgStatementTools.cs index e5367f298b..bd309e3d28 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgStatementTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgStatementTools.cs @@ -29,19 +29,20 @@ public static async Task GetPgTopQueries( 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 rows to return. Default 20.")] int limit = 20) + [Description("Maximum rows to return. Default 20.")] int limit = 20, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(limit); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingPgStatementReader.GetPgTopQueriesAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgWaitTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgWaitTools.cs index e0d9ea017b..6ec76e2f3b 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgWaitTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgWaitTools.cs @@ -28,19 +28,20 @@ public static async Task 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 rows to return. Default 20.")] int limit = 20) + [Description("Maximum rows to return. Default 20.")] int limit = 20, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(limit); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingPgWaitReader.GetPgWaitStatsAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now, limit); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgWraparoundTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgWraparoundTools.cs index 1c22cae697..a044b08ada 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgWraparoundTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgWraparoundTools.cs @@ -78,17 +78,18 @@ grade calmer than the engine. */ public static async Task GetPgWraparoundRisk( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history to analyze, used for the peak comparison. Default 24.")] int hours_back = 24) + [Description("Hours of history to analyze, used for the peak comparison. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingPgWraparoundReader.GetPgWraparoundAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgXminTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgXminTools.cs index f33499358f..ef35c0ba27 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgXminTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgXminTools.cs @@ -59,17 +59,18 @@ public sealed class DarlingMcpPgXminTools public static async Task GetPgXminHorizon( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history to analyze, used for the persistence figures. Default 24.")] int hours_back = 24) + [Description("Hours of history to analyze, used for the persistence figures. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingPgXminReader.GetPgXminHorizonAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCacheSchedulerTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCacheSchedulerTools.cs index 4876466b37..57e65b12d0 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCacheSchedulerTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCacheSchedulerTools.cs @@ -34,17 +34,18 @@ public sealed class DarlingMcpPlanCacheSchedulerTools public static async Task GetPlanCacheBloat( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of data to analyze. Default 24.")] int hours_back = 24) + [Description("Hours of data to analyze. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingPlanCacheSchedulerReader.GetPlanCacheBloatAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs index 1e3e3b10e3..ab04a9a35c 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs @@ -35,19 +35,20 @@ public static async Task GetPlanCorrections( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, - [Description("Maximum recommendation rows. Default 50.")] int limit = 50) + [Description("Maximum recommendation rows. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(limit); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var tuning = await DarlingPlanCorrectionReader.GetLatestAutomaticTuningAsync(postgres, resolved.ServerId); var rows = await DarlingPlanCorrectionReader.GetPlanCorrectionsAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs index a885399167..ced50c3ea7 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs @@ -39,17 +39,18 @@ public static async Task GetQueryStoreRegressions( [Description("Server name or display name.")] string? server_name = null, [Description("Size of the RECENT window, in hours back from now. Everything collected before it is the baseline. Default 24.")] int hours_back = 24, [Description("Limit to one database. Omit for all databases.")] string? database_name = null, - [Description("Maximum rows to return, worst first. Default 50 (the number the desktop viewer shows).")] int limit = 50) + [Description("Maximum rows to return, worst first. Default 50 (the number the desktop viewer shows).")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd) ?? McpHelpers.ValidateTop(limit); if (validation != null) return validation; try { - var end = DateTime.UtcNow; + var end = windowEnd; var start = end.AddHours(-hours_back); /* diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs index e00d59fa85..2a40dbdcc7 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs @@ -93,19 +93,20 @@ public static async Task GetActiveQueries( [Description("Hours of data to retrieve. Default 1.")] int hours_back = 1, [Description("Filter to a specific database.")] string? database_name = null, [Description("Show only queries involved in blocking (blocking_session_id > 0 or is a head blocker).")] bool blocking_only = false, - [Description("Maximum number of rows to return. Default 50.")] int limit = 50) + [Description("Maximum number of rows to return. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(limit); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingSessionReader.GetActiveQueriesAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) @@ -166,19 +167,20 @@ public static async Task GetWaitingTasks( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 1.")] int hours_back = 1, - [Description("Maximum rows. Default 30.")] int limit = 30) + [Description("Maximum rows. Default 30.")] int limit = 30, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; validation = McpHelpers.ValidateTop(limit); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var rows = await DarlingSessionReader.GetWaitingTasksAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (rows.Count == 0) diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs index fa6d02c800..24a9d56488 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs @@ -49,17 +49,18 @@ public sealed class DarlingMcpTrendTools public static async Task GetMemoryTrend( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var points = await DarlingTrendReader.GetMemoryTrendAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (points.Count == 0) { @@ -110,17 +111,18 @@ public static async Task GetPerfmonTrend( NpgsqlDataSource postgres, [Description("The exact counter name, e.g. 'Batch Requests/sec'.")] string counter_name, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var start = now.AddHours(-hours_back); var points = await DarlingTrendReader.GetPerfmonTrendAsync(postgres, resolved.ServerId, counter_name, start, now); if (points.Count == 0) @@ -187,17 +189,18 @@ by the configured 60 s is wrong by whatever the jitter was (#2233, #2234). */ public static async Task GetFileIoTrend( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var points = await DarlingTrendReader.GetFileIoLatencyTrendAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (points.Count == 0) { @@ -241,17 +244,18 @@ public static async Task GetQueryTrend( [Description("The query_hash value from get_top_queries_by_cpu or get_query_store_top.")] string query_hash, [Description("The database name the query belongs to.")] string database_name, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var history = await DarlingTrendReader.GetQueryHistoryAsync(postgres, resolved.ServerId, database_name, query_hash, now.AddHours(-hours_back), now); var rows = history.Points; if (rows.Count == 0) @@ -324,17 +328,18 @@ concluding the query did not run. */ public static async Task GetQueryDurationTrend( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var points = await DarlingTrendReader.GetQueryDurationTrendAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now); if (points.Count == 0) { @@ -365,17 +370,18 @@ reporting the wrong branch. */ public static async Task GetProcedureDurationTrend( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var points = await DarlingTrendReader.GetProcedureDurationTrendAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); @@ -399,17 +405,18 @@ public static async Task GetProcedureDurationTrend( public static async Task GetQueryStoreDurationTrend( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var now = DateTime.UtcNow; + var now = windowEnd; var points = await DarlingTrendReader.GetQueryStoreDurationTrendAsync( postgres, resolved.ServerId, now.AddHours(-hours_back), now); diff --git a/Lite.Tests/AsOfWindowAnchorTests.cs b/Lite.Tests/AsOfWindowAnchorTests.cs new file mode 100644 index 0000000000..4555514d20 --- /dev/null +++ b/Lite.Tests/AsOfWindowAnchorTests.cs @@ -0,0 +1,276 @@ +/* + * 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.Linq; +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; + +/// +/// #2495, Lite half: as_of moves the END of a windowed read off "now" so a caller can ask about a +/// past incident. Darling's twin (AsOfWindowAnchorLivePostgresTests) pins the same behaviour against +/// live Postgres; this pins it against a real DuckDB through the real tool methods. +/// +/// These run through the SERVICE, not just the helper, because the anchor has to survive the whole +/// path — tool parameter, shared validator, LocalDataService's window helper, and the SQL's two +/// bounds. A helper-only test would pass with the plumbing missing, which is exactly the shape of bug the +/// #2213 review found four of. +/// +/* The server-local half below reads ServerTimeHelper.UtcOffsetMinutes, a process-wide mutable static that a + sibling class SETS for its duration. xUnit runs test classes in parallel, so this joins the collection the + other offset-reading classes already use rather than racing them into a window hours away from its rows. */ +[Collection("server-time-helper")] +public sealed class AsOfWindowAnchorTests : IClassFixture, IDisposable +{ + /* Outside every default window on the surface (the widest default is 24 hours). The two windows are + told apart by CONTENT rather than by row count, so a read returning the wrong one cannot look right. */ + private const string IncidentWait = "PAGEIOLATCH_SH"; + private const string RecentWait = "CXPACKET"; + + private readonly string _tempDir; + private readonly DuckDbInitializer _duckDb; + private readonly LocalDataService _dataService; + private readonly ServerManager _serverManager; + private readonly int _serverId; + private long _nextId = -1; + private DuckDBConnection? _seedConn; + + public AsOfWindowAnchorTests(SharedDuckDbFixture fixture) + { + fixture.ResetData(); + _duckDb = fixture.DuckDb; + + _tempDir = Path.Combine(Path.GetTempPath(), "AsOfAnchorTests_" + Guid.NewGuid().ToString("N")[..8]); + var configDir = Path.Combine(_tempDir, "config"); + Directory.CreateDirectory(configDir); + + _dataService = new LocalDataService(_duckDb); + _serverManager = new ServerManager(configDir); + + var server = new ServerConnection { ServerName = "TestServer", DisplayName = "TestServer" }; + _serverManager.AddServer(server); + + /* The derived id, not a literal: seeding under a hand-picked number makes the read return nothing + and the test pass for the wrong reason. */ + _serverId = RemoteCollectorService.GetDeterministicHashCode( + RemoteCollectorService.GetServerNameForStorage(server)); + } + + public void Dispose() + { + _seedConn?.Dispose(); + try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } + catch (IOException) { /* best-effort cleanup */ } + catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + } + + /// + /// The whole point of the issue: the same read, anchored at a past incident, returns the incident — + /// and on the default anchor it does not. + /// + [Fact] + public async Task AnAnchoredRead_SeesThePastWindow_AndTheDefaultAnchorDoesNot() + { + var now = DateTime.UtcNow; + var incident = now.AddHours(-30); + + await SeedWaitAsync(incident, IncidentWait, 900_000); + await SeedWaitAsync(incident.AddMinutes(5), IncidentWait, 800_000); + await SeedWaitAsync(now.AddMinutes(-10), RecentWait, 1_000); + + var anchor = incident.AddMinutes(30).ToString("o"); + + var live = await McpWaitTools.GetWaitStats(_dataService, _serverManager, "TestServer"); + Assert.Contains(RecentWait, WaitTypesIn(live)); + Assert.DoesNotContain(IncidentWait, WaitTypesIn(live)); + + var anchored = await McpWaitTools.GetWaitStats(_dataService, _serverManager, "TestServer", 4, 20, anchor); + Assert.Contains(IncidentWait, WaitTypesIn(anchored)); + Assert.DoesNotContain(RecentWait, WaitTypesIn(anchored)); + } + + /// + /// Widening hours_back is not the workaround it looks like. A window wide enough to reach the + /// incident reaches everything since it too, so the aggregate now describes both periods — a different + /// answer, not the same answer with more rows. + /// + [Fact] + public async Task WideningTheWindow_IsADifferentAnswer_NotTheSameOneWithMoreRows() + { + var now = DateTime.UtcNow; + var incident = now.AddHours(-30); + + await SeedWaitAsync(incident, IncidentWait, 900_000); + await SeedWaitAsync(now.AddMinutes(-10), RecentWait, 1_000); + + var anchored = await McpWaitTools.GetWaitStats(_dataService, _serverManager, "TestServer", 4, 20, incident.AddMinutes(30).ToString("o")); + var widened = await McpWaitTools.GetWaitStats(_dataService, _serverManager, "TestServer", 48); + + Assert.Equal(new[] { IncidentWait }, WaitTypesIn(anchored)); + Assert.Equal(2, WaitTypesIn(widened).Length); + Assert.Contains(IncidentWait, WaitTypesIn(widened)); + Assert.Contains(RecentWait, WaitTypesIn(widened)); + } + + /// + /// Backward compatibility, stated as a test rather than as a claim: a caller that sends only + /// hours_back gets the window it always got. Seeded either side of the boundary so a window that + /// silently moved would show up as content, not as a timing wobble. + /// + [Fact] + public async Task WithNoAnchor_TheWindowIsStillTheLastHoursBackHours() + { + var now = DateTime.UtcNow; + await SeedWaitAsync(now.AddHours(-2), RecentWait, 5_000); + await SeedWaitAsync(now.AddHours(-10), IncidentWait, 5_000); + + Assert.Equal(new[] { RecentWait }, WaitTypesIn(await McpWaitTools.GetWaitStats(_dataService, _serverManager, "TestServer", 4))); + + var both = WaitTypesIn(await McpWaitTools.GetWaitStats(_dataService, _serverManager, "TestServer", 24)); + Assert.Contains(RecentWait, both); + Assert.Contains(IncidentWait, both); + } + + /// + /// An anchor the read cannot use is refused with a message. It must NOT quietly answer as of now — that + /// answer is indistinguishable from a correct one, which is the failure this parameter exists to remove. + /// + [Fact] + public async Task AnUnusableAnchor_IsRefused_RatherThanAnsweredAsOfNow() + { + await SeedWaitAsync(DateTime.UtcNow.AddMinutes(-10), RecentWait, 1_000); + + var garbage = await McpWaitTools.GetWaitStats(_dataService, _serverManager, "TestServer", 4, 20, "last tuesday"); + Assert.StartsWith("Invalid as_of", garbage, StringComparison.Ordinal); + Assert.DoesNotContain(RecentWait, garbage, StringComparison.Ordinal); + + var future = await McpWaitTools.GetWaitStats( + _dataService, _serverManager, "TestServer", 4, 20, DateTime.UtcNow.AddDays(1).ToString("o")); + Assert.Contains("future", future, StringComparison.Ordinal); + Assert.DoesNotContain(RecentWait, future, StringComparison.Ordinal); + } + + /// + /// An anchor older than anything the store holds is NOT refused — the store's earliest row is + /// per-server and per-collector, so a floor would be a guess. It comes back as the read's own honest + /// miss, which is unambiguous because the caller chose the window. + /// + [Fact] + public async Task AnAnchorBeforeAnyData_ReturnsTheReadsOwnMiss_NotARefusal() + { + await SeedWaitAsync(DateTime.UtcNow.AddMinutes(-10), RecentWait, 1_000); + + var ancient = await McpWaitTools.GetWaitStats(_dataService, _serverManager, "TestServer", 4, 20, "1999-01-01T00:00:00Z"); + + using var doc = JsonDocument.Parse(ancient); + Assert.Equal("unavailable", doc.RootElement.GetProperty("status").GetString()); + } + + /// + /// The anchor reaches a read whose window is built by the SERVER-LOCAL helper, not just the UTC one. + /// Two families, two time bases, one instant — a bug here would shift the window by the monitored + /// server's offset and still look plausible. + /// + [Fact] + public async Task TheAnchorAlsoMoves_AServerLocalWindow() + { + var now = DateTime.UtcNow; + var incident = now.AddHours(-30); + + /* sample_time is the monitored server's LOCAL wall clock, so the fixture is seeded in that base — + the read's window is server-local and the anchor arrives in UTC, and the point of the test is + that the conversion between them happens exactly once. */ + var offset = ServerTimeHelper.UtcOffsetMinutes; + await SeedCpuAsync(incident.AddMinutes(offset), 91); + await SeedCpuAsync(now.AddMinutes(-10).AddMinutes(offset), 12); + + var anchored = await McpCpuTools.GetCpuUtilization( + _dataService, _serverManager, "TestServer", 4, incident.AddMinutes(30).ToString("o")); + Assert.Equal(new[] { 91 }, SqlCpuIn(anchored)); + + var live = await McpCpuTools.GetCpuUtilization(_dataService, _serverManager, "TestServer"); + Assert.Equal(new[] { 12 }, SqlCpuIn(live)); + } + + // ── helpers ── + + private static string[] WaitTypesIn(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("waits").EnumerateArray() + .Select(w => w.GetProperty("wait_type").GetString()!) + .ToArray(); + } + + private static int[] SqlCpuIn(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("samples").EnumerateArray() + .Select(s => s.GetProperty("sql_server_cpu").GetInt32()) + .ToArray(); + } + + private async Task SeedConnectionAsync() + { + if (_seedConn is null) + { + _seedConn = _duckDb.CreateConnection(); + await _seedConn.OpenAsync(); + } + return _seedConn; + } + + private async Task SeedWaitAsync(DateTime at, string waitType, long deltaMs) + { + using var readLock = _duckDb.AcquireReadLock(); + var conn = await SeedConnectionAsync(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"INSERT INTO wait_stats + (collection_id, collection_time, server_id, server_name, wait_type, + waiting_tasks_count, wait_time_ms, signal_wait_time_ms, + delta_waiting_tasks, delta_wait_time_ms, delta_signal_wait_time_ms) + VALUES ($1, $2, $3, 'TestServer', $4, 0, 0, 0, 50, $5, 0)"; + void P(object v) => cmd.Parameters.Add(new DuckDBParameter { Value = v }); + P(_nextId--); + P(at); + P(_serverId); + P(waitType); + P(deltaMs); + await cmd.ExecuteNonQueryAsync(); + } + + private async Task SeedCpuAsync(DateTime at, int sqlCpu) + { + using var readLock = _duckDb.AcquireReadLock(); + var conn = await SeedConnectionAsync(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"INSERT INTO cpu_utilization_stats + (collection_id, collection_time, server_id, server_name, sample_time, + sqlserver_cpu_utilization, other_process_cpu_utilization) + VALUES ($1, $2, $3, 'TestServer', $4, $5, 0)"; + void P(object v) => cmd.Parameters.Add(new DuckDBParameter { Value = v }); + P(_nextId--); + P(at); + P(_serverId); + P(at); + P(sqlCpu); + await cmd.ExecuteNonQueryAsync(); + } +} diff --git a/Lite/Mcp/McpAlertTools.cs b/Lite/Mcp/McpAlertTools.cs index 0c03872435..71f498e0ab 100644 --- a/Lite/Mcp/McpAlertTools.cs +++ b/Lite/Mcp/McpAlertTools.cs @@ -31,17 +31,18 @@ internal static string CpuModeFor(CpuAlertMode mode) => public static async Task GetAlertHistory( LocalDataService dataService, [Description("Hours of history. Default 24.")] int hours_back = 24, - [Description("Maximum rows. Default 50.")] int limit = 50) + [Description("Maximum rows. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var limitError = McpHelpers.ValidateTop(limit); if (limitError != null) return limitError; - var rows = await dataService.GetAlertHistoryAsync(hours_back, limit); + var rows = await dataService.GetAlertHistoryAsync(hours_back, limit, asOfUtc: windowEnd); if (rows.Count == 0) { diff --git a/Lite/Mcp/McpBlockingTools.cs b/Lite/Mcp/McpBlockingTools.cs index 07f26848fd..91a39cd02c 100644 --- a/Lite/Mcp/McpBlockingTools.cs +++ b/Lite/Mcp/McpBlockingTools.cs @@ -15,20 +15,21 @@ public static async Task GetDeadlocks( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, - [Description("Maximum rows. Default 20.")] int limit = 20) + [Description("Maximum rows. Default 20.")] int limit = 20, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var limitError = McpHelpers.ValidateTop(limit); if (limitError != null) return limitError; - var rows = await dataService.GetRecentDeadlocksAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetRecentDeadlocksAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { return McpHelpers.Status("empty", "No deadlocks found in the specified time range."); @@ -64,20 +65,21 @@ public static async Task GetDeadlockDetail( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, - [Description("Maximum deadlocks to return. Default 5.")] int limit = 5) + [Description("Maximum deadlocks to return. Default 5.")] int limit = 5, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var limitError = McpHelpers.ValidateTop(limit); if (limitError != null) return limitError; - var rows = await dataService.GetRecentDeadlocksAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetRecentDeadlocksAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); var withXml = rows.Where(r => r.HasDeadlockXml).Take(limit).ToList(); if (withXml.Count == 0) { @@ -111,20 +113,21 @@ public static async Task GetBlockedProcessReports( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, - [Description("Maximum rows. Default 30.")] int limit = 30) + [Description("Maximum rows. Default 30.")] int limit = 30, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var limitError = McpHelpers.ValidateTop(limit); if (limitError != null) return limitError; - var rows = await dataService.GetRecentBlockedProcessReportsAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetRecentBlockedProcessReportsAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { return McpHelpers.Status("empty", "No blocked process reports found."); @@ -186,20 +189,21 @@ public static async Task GetBlockedProcessXml( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, - [Description("Maximum reports to return. Default 5.")] int limit = 5) + [Description("Maximum reports to return. Default 5.")] int limit = 5, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var limitError = McpHelpers.ValidateTop(limit); if (limitError != null) return limitError; - var rows = await dataService.GetRecentBlockedProcessReportsAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetRecentBlockedProcessReportsAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); var withXml = rows.Where(r => r.HasReportXml).Take(limit).ToList(); if (withXml.Count == 0) { @@ -234,20 +238,22 @@ public static async Task GetBlockingTrend( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var anchorEnd); if (hoursError != null) return hoursError; /* One instant for BOTH reads. Resolving now separately in the trend and the capture count lets a row arrive between them, and the two answers exist to be compared -- Darling's - twin pins a single now for exactly this reason. */ - var windowEnd = DateTime.UtcNow; + twin pins a single now for exactly this reason. That instant is the as_of anchor when one + was sent, so both reads move together onto the past window rather than one of them. */ + var windowEnd = anchorEnd; var windowStart = windowEnd.AddHours(-Math.Abs(hours_back)); var points = await dataService.GetBlockingTrendAsync( @@ -290,14 +296,15 @@ public static async Task GetDeadlockTrend( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; /* Same single-instant discipline as the blocking trend above. */ diff --git a/Lite/Mcp/McpConfigHistoryTools.cs b/Lite/Mcp/McpConfigHistoryTools.cs index 432649d957..ff7c12a348 100644 --- a/Lite/Mcp/McpConfigHistoryTools.cs +++ b/Lite/Mcp/McpConfigHistoryTools.cs @@ -22,17 +22,18 @@ public static async Task GetServerConfigChanges( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) + [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var rows = await dataService.GetServerConfigChangesAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetServerConfigChangesAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) return McpHelpers.Status("empty", $"No server configuration changes detected in the last {hours_back}h. Config is captured on connect, so at least two snapshots are needed to detect a change."); @@ -68,17 +69,18 @@ public static async Task GetDatabaseConfigChanges( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) + [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var rows = await dataService.GetDatabaseConfigChangesAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetDatabaseConfigChangesAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) return McpHelpers.Status("empty", $"No database configuration changes detected in the last {hours_back}h. Config is captured on connect, so at least two snapshots are needed to detect a change."); @@ -110,17 +112,18 @@ public static async Task GetTraceFlagChanges( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) + [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var rows = await dataService.GetTraceFlagChangesAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetTraceFlagChangesAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) return McpHelpers.Status("empty", $"No trace flag changes detected in the last {hours_back}h. Config is captured on connect, so at least two snapshots are needed to detect a change."); diff --git a/Lite/Mcp/McpCpuTools.cs b/Lite/Mcp/McpCpuTools.cs index b3ee765c4e..1034b1ab69 100644 --- a/Lite/Mcp/McpCpuTools.cs +++ b/Lite/Mcp/McpCpuTools.cs @@ -14,17 +14,18 @@ public static async Task GetCpuUtilization( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 4.")] int hours_back = 4) + [Description("Hours of history. Default 4.")] int hours_back = 4, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var rows = await dataService.GetCpuUtilizationAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetCpuUtilizationAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { return McpHelpers.Status("unavailable", "No CPU utilization data available."); diff --git a/Lite/Mcp/McpDefaultTraceTools.cs b/Lite/Mcp/McpDefaultTraceTools.cs index 59d86d9337..1676d48614 100644 --- a/Lite/Mcp/McpDefaultTraceTools.cs +++ b/Lite/Mcp/McpDefaultTraceTools.cs @@ -23,17 +23,18 @@ public static async Task GetDefaultTraceEvents( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of events to return. Default 100.")] int limit = 100) + [Description("Maximum number of events to return. Default 100.")] int limit = 100, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd) ?? McpHelpers.ValidateTop(limit); if (validation != null) return validation; - var rows = await dataService.GetDefaultTraceEventsAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetDefaultTraceEventsAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) return McpHelpers.Status("empty", "No significant default trace events found in the requested time range."); diff --git a/Lite/Mcp/McpHealthParserTools.cs b/Lite/Mcp/McpHealthParserTools.cs index c813113c8d..35c39543de 100644 --- a/Lite/Mcp/McpHealthParserTools.cs +++ b/Lite/Mcp/McpHealthParserTools.cs @@ -25,17 +25,18 @@ public static async Task GetSystemHealth( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd) ?? McpHelpers.ValidateTop(limit); if (validation != null) return validation; - var rows = await dataService.GetSystemHealthAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetSystemHealthAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) return McpHelpers.Status("empty", "No system health data found in the requested time range."); @@ -75,17 +76,18 @@ public static async Task GetSevereErrors( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd) ?? McpHelpers.ValidateTop(limit); if (validation != null) return validation; - var rows = await dataService.GetSevereErrorsAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetSevereErrorsAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) return McpHelpers.Status("empty", "No severe errors found in the requested time range."); @@ -116,17 +118,18 @@ public static async Task GetIOIssues( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd) ?? McpHelpers.ValidateTop(limit); if (validation != null) return validation; - var rows = await dataService.GetIoIssuesAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetIoIssuesAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) return McpHelpers.Status("empty", "No I/O issues found in the requested time range."); @@ -157,17 +160,18 @@ public static async Task GetSchedulerIssues( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd) ?? McpHelpers.ValidateTop(limit); if (validation != null) return validation; - var rows = await dataService.GetSchedulerIssuesAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetSchedulerIssuesAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) return McpHelpers.Status("empty", "No scheduler issues found in the requested time range."); @@ -200,17 +204,18 @@ public static async Task GetMemoryConditions( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd) ?? McpHelpers.ValidateTop(limit); if (validation != null) return validation; - var rows = await dataService.GetMemoryConditionsAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetMemoryConditionsAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) return McpHelpers.Status("empty", "No memory condition events found in the requested time range."); @@ -266,17 +271,18 @@ public static async Task GetCPUTasks( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd) ?? McpHelpers.ValidateTop(limit); if (validation != null) return validation; - var rows = await dataService.GetCpuTasksAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetCpuTasksAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) return McpHelpers.Status("empty", "No CPU task events found in the requested time range."); @@ -311,17 +317,18 @@ public static async Task GetMemoryBroker( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd) ?? McpHelpers.ValidateTop(limit); if (validation != null) return validation; - var rows = await dataService.GetMemoryBrokerAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetMemoryBrokerAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) return McpHelpers.Status("empty", "No memory broker events found in the requested time range."); @@ -358,17 +365,18 @@ public static async Task GetMemoryNodeOOM( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd) ?? McpHelpers.ValidateTop(limit); if (validation != null) return validation; - var rows = await dataService.GetMemoryNodeOomAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetMemoryNodeOomAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) return McpHelpers.Status("empty", "No memory node OOM events found in the requested time range."); @@ -420,17 +428,18 @@ public static async Task GetSignificantWaits( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, - [Description("Maximum number of entries. Default 50.")] int limit = 50) + [Description("Maximum number of entries. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd) ?? McpHelpers.ValidateTop(limit); if (validation != null) return validation; - var (rows, captured) = await dataService.GetSignificantWaitsWithCaptureAsync(resolved.ServerId, hours_back); + var (rows, captured) = await dataService.GetSignificantWaitsWithCaptureAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { diff --git a/Lite/Mcp/McpHealthTools.cs b/Lite/Mcp/McpHealthTools.cs index b1e68b5c35..a2d27cfdb7 100644 --- a/Lite/Mcp/McpHealthTools.cs +++ b/Lite/Mcp/McpHealthTools.cs @@ -268,7 +268,8 @@ public static async Task GetCollectionLog( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, - [Description("Maximum rows to return, newest first. Default 200.")] int limit = 200) + [Description("Maximum rows to return, newest first. Default 200.")] int limit = 200, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; @@ -277,12 +278,15 @@ public static async Task GetCollectionLog( var invalidLimit = McpHelpers.ValidateTop(limit); if (invalidLimit != null) return invalidLimit; + var anchorError = McpHelpers.ResolveAsOf(as_of, out var windowEnd); + if (anchorError != null) return anchorError; + try { var hours = Math.Abs(hours_back); /* Over-fetch by one so truncation is observed, not inferred -- see Darling's twin. */ - var rows = await dataService.GetRecentCollectionLogAsync(resolved.ServerId, hours, maxRows: limit + 1); + var rows = await dataService.GetRecentCollectionLogAsync(resolved.ServerId, hours, maxRows: limit + 1, asOfUtc: windowEnd); var truncated = rows.Count > limit; if (truncated) rows = rows.Take(limit).ToList(); @@ -346,18 +350,22 @@ public static async Task GetCurrentWaitsTrend( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 4.")] int hours_back = 4, - [Description("Limit the blocked-session series to one database. Omit for all databases.")] string? database_name = null) + [Description("Limit the blocked-session series to one database. Omit for all databases.")] string? database_name = null, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; + var anchorError = McpHelpers.ResolveAsOf(as_of, out var windowEnd); + if (anchorError != null) return anchorError; + try { var hours = Math.Abs(hours_back); var filter = string.IsNullOrWhiteSpace(database_name) ? null : new[] { database_name }; - var waits = await dataService.GetWaitingTaskTrendAsync(resolved.ServerId, hours); - var blocked = await dataService.GetBlockedSessionTrendAsync(resolved.ServerId, hours, databaseNames: filter); + var waits = await dataService.GetWaitingTaskTrendAsync(resolved.ServerId, hours, asOfUtc: windowEnd); + var blocked = await dataService.GetBlockedSessionTrendAsync(resolved.ServerId, hours, databaseNames: filter, asOfUtc: windowEnd); if (waits.Count == 0 && blocked.Count == 0) { @@ -405,16 +413,20 @@ public static async Task GetBlockingStats( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; + var anchorError = McpHelpers.ResolveAsOf(as_of, out var windowEnd); + if (anchorError != null) return anchorError; + try { var hours = Math.Abs(hours_back); - var blocking = await dataService.GetBlockingDurationStatsAsync(resolved.ServerId, hours); - var deadlocks = await dataService.GetDeadlockSeverityStatsAsync(resolved.ServerId, hours); + var blocking = await dataService.GetBlockingDurationStatsAsync(resolved.ServerId, hours, asOfUtc: windowEnd); + var deadlocks = await dataService.GetDeadlockSeverityStatsAsync(resolved.ServerId, hours, asOfUtc: windowEnd); if (blocking.Count == 0 && deadlocks.Count == 0) { diff --git a/Lite/Mcp/McpInstructions.cs b/Lite/Mcp/McpInstructions.cs index 1d19108699..0124f59f39 100644 --- a/Lite/Mcp/McpInstructions.cs +++ b/Lite/Mcp/McpInstructions.cs @@ -32,85 +32,97 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo ## Tool Reference + ### Asking about a PAST window + + Every tool below that takes `hours_back` also takes `as_of`: an optional ISO-8601 UTC instant that moves the END of the window off "now". `hours_back` stays the window's LENGTH. So the four hours around last Tuesday 03:00 is `as_of=2026-08-19T05:00:00Z, hours_back=4` — not `hours_back=170`. + + Reach for it whenever the question is about a time rather than about the present, and do NOT substitute a wider `hours_back`: for an aggregate read a wider window is a DIFFERENT answer, not the same answer with more rows. It changes what a top-N returns, what an average is taken over, and how much a capped read truncates. + + - Accepted forms: `2026-08-19T05:00:00Z`, `2026-08-19T05:00:00` (read as UTC), `2026-08-19T07:00:00+02:00`, `2026-08-19` (midnight UTC). + - An unparseable `as_of`, or one in the future, is REFUSED with a message rather than quietly answered as "now" — a read that silently reverts to now is indistinguishable from a correct one. + - An `as_of` older than anything the store still holds is NOT refused. It returns the read's normal `empty` / `unavailable` status, which means exactly what it says: we looked in the window you named and there was nothing in it. + - Tools that take no window at all (latest-snapshot reads like `get_memory_stats`, `get_file_io_stats`, `get_index_usage`, and the configuration reads) do not take `as_of` — they read the newest row, and there is no window to move. + - `analyze_server`, `get_analysis_facts`, `compare_analysis`, `get_analysis_findings` and `get_pvs_stats` do not take it either. The first four window inside the analysis engine rather than at the read; `get_pvs_stats` mixes a latest-snapshot measurement with a windowed trend, so anchoring only the windowed half would return a result whose two halves describe different instants. + ### Discovery & Health Tools | Tool | Purpose | Key Parameters | |------|---------|----------------| | `list_servers` | Lists all monitored SQL Server instances with status and last collection time | none | | `get_collection_health` | Shows collector health: running, failing, or stale, plus the server's sweep_pressure block: a `verdict` for SUSTAINED demand (a SATURATED body collects at a multiple of its configured cadence with every collector healthy) and a separate `peak_cycle_risk` for a SINGLE sweep (BODY_OVERRUN means one scheduled body cannot fit the budget even when the verdict reads OK, the signature of one infrequent heavy collector; `peak_collector` names it). Per-collector rows carry `avg_duration_ms`, `p95_duration_ms` and `max_duration_ms`: a mean far below the p95 means the collector's runs come in two sizes and the mean describes neither | `server_name` | - | `get_collection_log` | The RAW per-run collection log behind that rollup: one row per collector run with total duration split into time on the monitored server and time on the store, rows collected, status and any error. Reach for it when the rollup reads HEALTHY and collection still looks wrong, or to see what a collector was doing during a specific window. An empty result distinguishes a quiet window (`empty`, widen it) from a server that has never collected (`unavailable`, collection is not running) | `server_name`, `hours_back`, `limit` | - | `get_current_waits_trend` | The two Current Waits series over time: waiting-task total wait per wait type per collection, and blocked-session counts per database per collection. `get_waiting_tasks` gives the snapshot and can never say whether now is worse than an hour ago; this is that question. Read the two series together — a wait-type spike with no blocked sessions is a resource wait, the same spike with them is contention. An empty result distinguishes a genuine all-clear (`empty`) from a server the collector has never sampled (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back`, `database_name` | - | `get_blocking_stats` | Blocking SEVERITY per minute: blocking duration (event count, total, max, avg wait) and deadlock severity (victim count plus total/max/avg wait across EVERY process in the graphs, not just victims). `get_blocking_trend` and `get_deadlock_trend` say how OFTEN; this says how BAD — ten one-second blocks and one ten-minute block are the same count and a different problem. An empty result distinguishes a genuinely clear window (`empty`) from a server where neither capture path has ever produced a row (`unavailable`), which is NOT a clean bill of health | `server_name`, `hours_back` | + | `get_collection_log` | The RAW per-run collection log behind that rollup: one row per collector run with total duration split into time on the monitored server and time on the store, rows collected, status and any error. Reach for it when the rollup reads HEALTHY and collection still looks wrong, or to see what a collector was doing during a specific window. An empty result distinguishes a quiet window (`empty`, widen it) from a server that has never collected (`unavailable`, collection is not running) | `server_name`, `hours_back`, `limit`, `as_of` | + | `get_current_waits_trend` | The two Current Waits series over time: waiting-task total wait per wait type per collection, and blocked-session counts per database per collection. `get_waiting_tasks` gives the snapshot and can never say whether now is worse than an hour ago; this is that question. Read the two series together — a wait-type spike with no blocked sessions is a resource wait, the same spike with them is contention. An empty result distinguishes a genuine all-clear (`empty`) from a server the collector has never sampled (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back`, `database_name`, `as_of` | + | `get_blocking_stats` | Blocking SEVERITY per minute: blocking duration (event count, total, max, avg wait) and deadlock severity (victim count plus total/max/avg wait across EVERY process in the graphs, not just victims). `get_blocking_trend` and `get_deadlock_trend` say how OFTEN; this says how BAD — ten one-second blocks and one ten-minute block are the same count and a different problem. An empty result distinguishes a genuinely clear window (`empty`) from a server where neither capture path has ever produced a row (`unavailable`), which is NOT a clean bill of health | `server_name`, `hours_back`, `as_of` | | `get_server_summary` | Quick health overview: CPU %, memory, blocking/deadlock counts | `server_name` | | `get_daily_summary` | Daily composite health band + wait/query/deadlock/blocking/CPU/memory/alert rollup for one day | `server_name`, `summary_date` (yyyy-MM-dd, default today) | ### Wait Statistics Tools | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_wait_stats` | Top wait types aggregated over time period | `server_name`, `hours_back` (default 24), `limit` (default 20) | - | `get_wait_types` | Lists distinct wait types observed (use before `get_wait_trend`). An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server no wait stats have ever been stored for (`unavailable`) | `server_name`, `hours_back` | - | `get_wait_trend` | Time-series for a specific wait type | `wait_type` (required), `server_name`, `hours_back` | - | `get_waiting_tasks` | Currently/recently waiting queries with details | `server_name`, `hours_back` (default 1), `limit` | + | `get_wait_stats` | Top wait types aggregated over time period | `server_name`, `hours_back` (default 24), `limit` (default 20), `as_of` | + | `get_wait_types` | Lists distinct wait types observed (use before `get_wait_trend`). An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server no wait stats have ever been stored for (`unavailable`) | `server_name`, `hours_back`, `as_of` | + | `get_wait_trend` | Time-series for a specific wait type | `wait_type` (required), `server_name`, `hours_back`, `as_of` | + | `get_waiting_tasks` | Currently/recently waiting queries with details | `server_name`, `hours_back` (default 1), `limit`, `as_of` | ### CPU Tools | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_cpu_utilization` | SQL Server CPU vs other process CPU over time | `server_name`, `hours_back` (default 4) | - | `get_cpu_scheduler_pressure` | Latest scheduler snapshot: runnable queue depth, worker-thread utilization, queued/blocked requests, pressure warnings | `server_name`, `hours_back` (default 24) | + | `get_cpu_utilization` | SQL Server CPU vs other process CPU over time | `server_name`, `hours_back` (default 4), `as_of` | + | `get_cpu_scheduler_pressure` | Latest scheduler snapshot: runnable queue depth, worker-thread utilization, queued/blocked requests, pressure warnings | `server_name`, `hours_back` (default 24), `as_of` | ### Contention Tools | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_latch_stats` | Latest latch-contention snapshot by class (waits + last-interval delta waits) | `server_name`, `hours_back` (default 24) | - | `get_spinlock_stats` | Latest spinlock-contention snapshot (collisions, spins, backoffs) | `server_name`, `hours_back` (default 24) | + | `get_latch_stats` | Latest latch-contention snapshot by class (waits + last-interval delta waits) | `server_name`, `hours_back` (default 24), `as_of` | + | `get_spinlock_stats` | Latest spinlock-contention snapshot (collisions, spins, backoffs) | `server_name`, `hours_back` (default 24), `as_of` | ### Plan Cache Tools | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_plan_cache_bloat` | Single-use vs multi-use plan composition per cache/object type, with bloat-level classification | `server_name`, `hours_back` (default 24) | + | `get_plan_cache_bloat` | Single-use vs multi-use plan composition per cache/object type, with bloat-level classification | `server_name`, `hours_back` (default 24), `as_of` | ### Query Performance Tools | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_top_queries_by_cpu` | Expensive queries from plan cache with DOP, spills, query_hash. `max_dop` is a lifetime-max for the cached plan, not current parallelism - confirm with `analyze_query_plan`. `cpu_attribution.attributed_cpu_ratio` says how much of the box's measured CPU the returned rows explain | `server_name`, `hours_back`, `top`, `database_name`, `parallel_only`, `min_dop` | - | `get_top_procedures_by_cpu` | Expensive stored procedures by CPU time, with the same `cpu_attribution` disclosure | `server_name`, `hours_back`, `top`, `database_name` | - | `get_query_store_top` | Expensive queries from Query Store (persistent) | `server_name`, `hours_back`, `top`, `database_name` | - | `get_query_store_regressions` | Queries whose Query Store performance got WORSE: the recent window's averages vs the BASELINE (every capture before it), with duration / CPU / reads regression percents, the execution-count-weighted extra duration (the ranking key), the plan counts on both sides and a severity band. `get_query_store_top` answers what is EXPENSIVE; this answers what CHANGED. Kept only where average CPU regressed > 25%. Zero rows is four states and the read says which — including "no baseline, so no regression is detectable", which is NOT a clean bill of health | `server_name`, `hours_back`, `database_name`, `limit` | - | `get_query_trend` | Time-series for a specific query by query_hash | `query_hash` (required), `database_name` (required), `server_name`, `hours_back` | - | `get_query_duration_trend` | Overall query elapsed-ms/sec + executions/sec over time, from the PLAN CACHE. `execution_count` and `executions_per_second` are the same quantity; the first is truncated to an integer, so read the second on a quiet server. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) | `server_name`, `hours_back` | - | `get_procedure_duration_trend` | The same series over procedure_stats. NOT a duplicate: query_stats smears a procedure's work across the statements inside it, this charges the whole call to the procedure. Read both to tell an ad-hoc regression from a procedure regression | `server_name`, `hours_back` | - | `get_query_store_duration_trend` | The same series over Query Store, which persists per interval and survives a plan-cache eviction or a restart. Each interval is counted once, at the hour the work RAN. Its `unavailable` names the cause the plan-cache trends do not have: Query Store may be OFF on every database | `server_name`, `hours_back` | + | `get_top_queries_by_cpu` | Expensive queries from plan cache with DOP, spills, query_hash. `max_dop` is a lifetime-max for the cached plan, not current parallelism - confirm with `analyze_query_plan`. `cpu_attribution.attributed_cpu_ratio` says how much of the box's measured CPU the returned rows explain | `server_name`, `hours_back`, `top`, `database_name`, `parallel_only`, `min_dop`, `as_of` | + | `get_top_procedures_by_cpu` | Expensive stored procedures by CPU time, with the same `cpu_attribution` disclosure | `server_name`, `hours_back`, `top`, `database_name`, `as_of` | + | `get_query_store_top` | Expensive queries from Query Store (persistent) | `server_name`, `hours_back`, `top`, `database_name`, `as_of` | + | `get_query_store_regressions` | Queries whose Query Store performance got WORSE: the recent window's averages vs the BASELINE (every capture before it), with duration / CPU / reads regression percents, the execution-count-weighted extra duration (the ranking key), the plan counts on both sides and a severity band. `get_query_store_top` answers what is EXPENSIVE; this answers what CHANGED. Kept only where average CPU regressed > 25%. Zero rows is four states and the read says which — including "no baseline, so no regression is detectable", which is NOT a clean bill of health | `server_name`, `hours_back`, `database_name`, `limit`, `as_of` | + | `get_query_trend` | Time-series for a specific query by query_hash | `query_hash` (required), `database_name` (required), `server_name`, `hours_back`, `as_of` | + | `get_query_duration_trend` | Overall query elapsed-ms/sec + executions/sec over time, from the PLAN CACHE. `execution_count` and `executions_per_second` are the same quantity; the first is truncated to an integer, so read the second on a quiet server. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) | `server_name`, `hours_back`, `as_of` | + | `get_procedure_duration_trend` | The same series over procedure_stats. NOT a duplicate: query_stats smears a procedure's work across the statements inside it, this charges the whole call to the procedure. Read both to tell an ad-hoc regression from a procedure regression | `server_name`, `hours_back`, `as_of` | + | `get_query_store_duration_trend` | The same series over Query Store, which persists per interval and survives a plan-cache eviction or a restart. Each interval is counted once, at the hour the work RAN. Its `unavailable` names the cause the plan-cache trends do not have: Query Store may be OFF on every database | `server_name`, `hours_back`, `as_of` | ### Blocking & Deadlock Tools | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_deadlocks` | Recent deadlock events with victim info | `server_name`, `hours_back`, `limit` | - | `get_deadlock_detail` | Full deadlock graph XML for deep analysis | `server_name`, `hours_back`, `limit` | - | `get_blocked_process_reports` | Parsed blocking from sp_HumanEventsBlockViewer (extended events) | `server_name`, `hours_back`, `limit` | - | `get_blocked_process_xml` | Raw blocked process report XML | `server_name`, `hours_back`, `limit` | - | `get_long_query_completions` | Longest completed queries (rpc/batch over the trace threshold) + attentions/cancels from the opt-in long-query trace, duration DESC (empty until the collector is enabled) | `server_name`, `hours_back`, `limit` | - | `get_blocking_trend` | Time-series of blocking event counts. An empty result distinguishes a genuine all-clear (`empty`, with the collector run counts in `hints` so you can see how many captures the window actually holds) from a window no collector covered (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back` | - | `get_deadlock_trend` | Time-series of deadlock event counts. An empty result distinguishes a genuine all-clear (`empty`, with the collector run counts in `hints` so you can see how many captures the window actually holds) from a window no collector covered (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back` | + | `get_deadlocks` | Recent deadlock events with victim info | `server_name`, `hours_back`, `limit`, `as_of` | + | `get_deadlock_detail` | Full deadlock graph XML for deep analysis | `server_name`, `hours_back`, `limit`, `as_of` | + | `get_blocked_process_reports` | Parsed blocking from sp_HumanEventsBlockViewer (extended events) | `server_name`, `hours_back`, `limit`, `as_of` | + | `get_blocked_process_xml` | Raw blocked process report XML | `server_name`, `hours_back`, `limit`, `as_of` | + | `get_long_query_completions` | Longest completed queries (rpc/batch over the trace threshold) + attentions/cancels from the opt-in long-query trace, duration DESC (empty until the collector is enabled) | `server_name`, `hours_back`, `limit`, `as_of` | + | `get_blocking_trend` | Time-series of blocking event counts. An empty result distinguishes a genuine all-clear (`empty`, with the collector run counts in `hints` so you can see how many captures the window actually holds) from a window no collector covered (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back`, `as_of` | + | `get_deadlock_trend` | Time-series of deadlock event counts. An empty result distinguishes a genuine all-clear (`empty`, with the collector run counts in `hints` so you can see how many captures the window actually holds) from a window no collector covered (`unavailable`), which is NOT an all-clear | `server_name`, `hours_back`, `as_of` | ### Memory Tools | Tool | Purpose | Key Parameters | |------|---------|----------------| | `get_memory_stats` | Latest memory snapshot: physical, buffer pool, plan cache | `server_name` | - | `get_memory_trend` | Memory usage over time. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) | `server_name`, `hours_back` | + | `get_memory_trend` | Memory usage over time. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) | `server_name`, `hours_back`, `as_of` | | `get_memory_clerks` | Top memory consumers by clerk type. An empty result is `unavailable`, never a quiet period — a live SQL Server always has clerks, so nothing retained means the collector has not run or its rows aged out | `server_name` | - | `get_memory_grants` | Active/recent memory grants (detect grant pressure) | `server_name`, `hours_back` (default 1), `limit` | - | `get_resource_semaphore` | Latest resource-semaphore snapshot: workspace memory vs target/max ceiling, waiter/timeout/forced-grant pressure | `server_name`, `hours_back` (default 24) | - | `get_memory_pressure_events` | Ring buffer memory pressure notifications (sp_pressuredetector source) | `server_name`, `hours_back` | + | `get_memory_grants` | Active/recent memory grants (detect grant pressure) | `server_name`, `hours_back` (default 1), `limit`, `as_of` | + | `get_resource_semaphore` | Latest resource-semaphore snapshot: workspace memory vs target/max ceiling, waiter/timeout/forced-grant pressure | `server_name`, `hours_back` (default 24), `as_of` | + | `get_memory_pressure_events` | Ring buffer memory pressure notifications (sp_pressuredetector source) | `server_name`, `hours_back`, `as_of` | ### I/O Tools | Tool | Purpose | Key Parameters | |------|---------|----------------| | `get_file_io_stats` | Latest file I/O stats per database file with latency | `server_name` | - | `get_file_io_trend` | I/O latency trend over time per database. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) | `server_name`, `hours_back` | + | `get_file_io_trend` | I/O latency trend over time per database. An empty result distinguishes a quiet window (`empty`, widen `hours_back`) from a server nothing has ever been collected for (`unavailable`, collection is not running) | `server_name`, `hours_back`, `as_of` | ### TempDB Tools | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_tempdb_trend` | TempDB space: user objects, internal objects, version store | `server_name`, `hours_back` | + | `get_tempdb_trend` | TempDB space: user objects, internal objects, version store | `server_name`, `hours_back`, `as_of` | ### Storage & Index Tools | Tool | Purpose | Key Parameters | @@ -123,12 +135,12 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo | Tool | Purpose | Key Parameters | |------|---------|----------------| | `get_perfmon_stats` | Latest perfmon counters (batch requests/sec, etc.) | `server_name`, `counter_name`, `instance_name` | - | `get_perfmon_trend` | Time-series for a specific perfmon counter | `counter_name` (required), `server_name`, `hours_back` | + | `get_perfmon_trend` | Time-series for a specific perfmon counter | `counter_name` (required), `server_name`, `hours_back`, `as_of` | ### Alert Tools | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_alert_history` | Recent alert history: what fired, when, email status | `hours_back` (default 24), `limit` (default 50) | + | `get_alert_history` | Recent alert history: what fired, when, email status | `hours_back` (default 24), `limit` (default 50), `as_of` | | `get_alert_settings` | Every alert group's enable flag and thresholds (CPU, blocking, deadlocks, poison waits, long-running queries/jobs, tempdb, low disk, PVS, file growth, failed jobs, database state), plus cooldown, excluded databases, delivery mode, analysis cadence and SMTP configuration | none | | `get_mute_rules` | Configured mute rules that suppress specific recurring alerts (still logged). An empty result distinguishes no rule ever written from rules that exist but have all lapsed, with the configured count in `hints` | `enabled_only` (default true) | @@ -145,23 +157,23 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo | `get_database_scoped_config` | Database-scoped configuration (MAXDOP, legacy CE, parameter sniffing) | `server_name`, `database_name` | | `get_query_store_health` | Per-database Query Store health (latest hourly snapshot) — actual vs desired state, readonly_reason decoded, storage vs cap, cleanup thresholds | `server_name`, `database_name` | | `get_trace_flags` | Active trace flags with global/session scope | `server_name` | - | `get_server_config_changes` | sp_configure change history (diff of on-connect snapshots) | `server_name`, `hours_back` (default 168) | - | `get_database_config_changes` | sys.databases change history (recovery model, RCSI, compat level, etc.) | `server_name`, `hours_back` (default 168) | - | `get_trace_flag_changes` | Trace flag enable/disable history (diff of on-connect snapshots) | `server_name`, `hours_back` (default 168) | + | `get_server_config_changes` | sp_configure change history (diff of on-connect snapshots) | `server_name`, `hours_back` (default 168), `as_of` | + | `get_database_config_changes` | sys.databases change history (recovery model, RCSI, compat level, etc.) | `server_name`, `hours_back` (default 168), `as_of` | + | `get_trace_flag_changes` | Trace flag enable/disable history (diff of on-connect snapshots) | `server_name`, `hours_back` (default 168), `as_of` | ### System Health & Default Trace Tools | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_default_trace_events` | Significant Default Trace events: file auto-grow/shrink stalls, severe ErrorLog writes, schema DDL, security audits | `server_name`, `hours_back` (default 24), `limit` (default 100) | - | `get_health_parser_system_health` | Parsed sp_server_diagnostics health counters (spinlocks, latch warnings, dumps, CPU, bad pages) | `server_name`, `hours_back`, `limit` | - | `get_health_parser_severe_errors` | Severe errors (severity >= 19) from system_health | `server_name`, `hours_back`, `limit` | - | `get_health_parser_io_issues` | I/O warnings from system_health (15-second I/O, long/pending I/O) | `server_name`, `hours_back`, `limit` | - | `get_health_parser_scheduler_issues` | Non-yielding schedulers and scheduler-monitor warnings | `server_name`, `hours_back`, `limit` | - | `get_health_parser_memory_conditions` | Low-memory snapshots (RESOURCE_MEMPHYSICAL_LOW) with the memory-manager report | `server_name`, `hours_back`, `limit` | - | `get_health_parser_cpu_tasks` | CPU task/worker-thread snapshots (QUERY_PROCESSING) with deadlock/blocking flags | `server_name`, `hours_back`, `limit` | - | `get_health_parser_memory_broker` | Memory broker ratio changes and target adjustments | `server_name`, `hours_back`, `limit` | - | `get_health_parser_memory_node_oom` | Per-NUMA-node out-of-memory events | `server_name`, `hours_back`, `limit` | - | `get_health_parser_significant_waits` | Individual wait_info events: a real session's non-BACKUP statement waited 500 ms+ on a non-idle wait type, with the wait type, duration and signal duration, resource, session id and the waiting SQL text. `get_wait_stats` gives the instance-wide totals and can never name the statement that paid them. An empty result says which nothing it is: events captured but none significant (the healthy answer), a quiet window (`empty`), or wait_info never captured (`unavailable`, NOT an all-clear) | `server_name`, `hours_back`, `limit` | + | `get_default_trace_events` | Significant Default Trace events: file auto-grow/shrink stalls, severe ErrorLog writes, schema DDL, security audits | `server_name`, `hours_back` (default 24), `limit` (default 100), `as_of` | + | `get_health_parser_system_health` | Parsed sp_server_diagnostics health counters (spinlocks, latch warnings, dumps, CPU, bad pages) | `server_name`, `hours_back`, `limit`, `as_of` | + | `get_health_parser_severe_errors` | Severe errors (severity >= 19) from system_health | `server_name`, `hours_back`, `limit`, `as_of` | + | `get_health_parser_io_issues` | I/O warnings from system_health (15-second I/O, long/pending I/O) | `server_name`, `hours_back`, `limit`, `as_of` | + | `get_health_parser_scheduler_issues` | Non-yielding schedulers and scheduler-monitor warnings | `server_name`, `hours_back`, `limit`, `as_of` | + | `get_health_parser_memory_conditions` | Low-memory snapshots (RESOURCE_MEMPHYSICAL_LOW) with the memory-manager report | `server_name`, `hours_back`, `limit`, `as_of` | + | `get_health_parser_cpu_tasks` | CPU task/worker-thread snapshots (QUERY_PROCESSING) with deadlock/blocking flags | `server_name`, `hours_back`, `limit`, `as_of` | + | `get_health_parser_memory_broker` | Memory broker ratio changes and target adjustments | `server_name`, `hours_back`, `limit`, `as_of` | + | `get_health_parser_memory_node_oom` | Per-NUMA-node out-of-memory events | `server_name`, `hours_back`, `limit`, `as_of` | + | `get_health_parser_significant_waits` | Individual wait_info events: a real session's non-BACKUP statement waited 500 ms+ on a non-idle wait type, with the wait type, duration and signal duration, resource, session id and the waiting SQL text. `get_wait_stats` gives the instance-wide totals and can never name the statement that paid them. An empty result says which nothing it is: events captured but none significant (the healthy answer), a quiet window (`empty`), or wait_info never captured (`unavailable`, NOT an all-clear) | `server_name`, `hours_back`, `limit`, `as_of` | ### Server Information Tools | Tool | Purpose | Key Parameters | @@ -172,7 +184,7 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo ### Session & Active Query Tools | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_active_queries` | Active query snapshots from sys.dm_exec_requests — what was running at each collection point | `server_name`, `hours_back` (default 1), `database_name`, `blocking_only`, `limit` | + | `get_active_queries` | Active query snapshots from sys.dm_exec_requests — what was running at each collection point | `server_name`, `hours_back` (default 1), `database_name`, `blocking_only`, `limit`, `as_of` | | `get_session_stats` | Connection counts and resource usage grouped by application | `server_name` | ### Execution Plan Analysis Tools diff --git a/Lite/Mcp/McpIoTools.cs b/Lite/Mcp/McpIoTools.cs index 29cab93e55..ea35700dc9 100644 --- a/Lite/Mcp/McpIoTools.cs +++ b/Lite/Mcp/McpIoTools.cs @@ -60,14 +60,15 @@ public static async Task GetFileIoTrend( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var points = await dataService.GetFileIoLatencyTrendAsync(resolved.ServerId, hours_back); diff --git a/Lite/Mcp/McpLatchSpinlockTools.cs b/Lite/Mcp/McpLatchSpinlockTools.cs index ad66a29751..6de3f5b4cd 100644 --- a/Lite/Mcp/McpLatchSpinlockTools.cs +++ b/Lite/Mcp/McpLatchSpinlockTools.cs @@ -20,17 +20,18 @@ public static async Task GetLatchStats( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history to search for the latest snapshot. Default 24.")] int hours_back = 24) + [Description("Hours of history to search for the latest snapshot. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var rows = await dataService.GetLatchStatsSnapshotAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetLatchStatsSnapshotAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) return McpHelpers.Status("unavailable", "No latch statistics available in the requested time range."); @@ -64,17 +65,18 @@ public static async Task GetSpinlockStats( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history to search for the latest snapshot. Default 24.")] int hours_back = 24) + [Description("Hours of history to search for the latest snapshot. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var rows = await dataService.GetSpinlockStatsSnapshotAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetSpinlockStatsSnapshotAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) return McpHelpers.Status("unavailable", "No spinlock statistics available in the requested time range."); diff --git a/Lite/Mcp/McpLongQueryTools.cs b/Lite/Mcp/McpLongQueryTools.cs index 9bf98567d3..38d93a25cc 100644 --- a/Lite/Mcp/McpLongQueryTools.cs +++ b/Lite/Mcp/McpLongQueryTools.cs @@ -22,20 +22,21 @@ public static async Task GetLongQueryCompletions( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, - [Description("Maximum rows. Default 30.")] int limit = 30) + [Description("Maximum rows. Default 30.")] int limit = 30, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var limitError = McpHelpers.ValidateTop(limit); if (limitError != null) return limitError; - var rows = await dataService.GetRecentLongQueryCompletionsAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetRecentLongQueryCompletionsAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { return McpHelpers.Status("empty", "No long-running query completions found in the specified time range. The long_query_completions collector is opt-in (default OFF) — enable it in the collector schedule to capture data."); diff --git a/Lite/Mcp/McpMemoryTools.cs b/Lite/Mcp/McpMemoryTools.cs index e45d73dbd4..10e7d05f61 100644 --- a/Lite/Mcp/McpMemoryTools.cs +++ b/Lite/Mcp/McpMemoryTools.cs @@ -52,14 +52,15 @@ public static async Task GetMemoryTrend( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var points = await dataService.GetMemoryTrendAsync(resolved.ServerId, hours_back); @@ -165,17 +166,18 @@ public static async Task GetMemoryPressureEvents( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var rows = await dataService.GetMemoryPressureEventsAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetMemoryPressureEventsAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { return McpHelpers.Status("empty", "No memory pressure events found in the requested time range."); @@ -205,17 +207,18 @@ public static async Task GetResourceSemaphore( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history to search for the latest snapshot. Default 24.")] int hours_back = 24) + [Description("Hours of history to search for the latest snapshot. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var rows = await dataService.GetResourceSemaphoreSnapshotAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetResourceSemaphoreSnapshotAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { return McpHelpers.Status("unavailable", "No memory grant data available."); @@ -257,17 +260,18 @@ public static async Task GetMemoryGrants( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 1.")] int hours_back = 1) + [Description("Hours of history. Default 1.")] int hours_back = 1, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var rows = await dataService.GetMemoryGrantChartDataAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetMemoryGrantChartDataAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { return McpHelpers.Status("unavailable", "No memory grant data available."); diff --git a/Lite/Mcp/McpPerfmonTools.cs b/Lite/Mcp/McpPerfmonTools.cs index 3588083324..dc96cdd752 100644 --- a/Lite/Mcp/McpPerfmonTools.cs +++ b/Lite/Mcp/McpPerfmonTools.cs @@ -60,22 +60,23 @@ public static async Task GetPerfmonTrend( ServerManager serverManager, [Description("The exact counter name, e.g. 'Batch Requests/sec'.")] string counter_name, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var points = await dataService.GetPerfmonTrendAsync(resolved.ServerId, counter_name, hours_back); + var points = await dataService.GetPerfmonTrendAsync(resolved.ServerId, counter_name, hours_back, asOfUtc: windowEnd); if (points.Count == 0) { /* No points can mean three different things to a caller. Distinguish them so an LLM doesn't read a bad counter name as "this metric looks fine." */ - var collected = await dataService.GetDistinctPerfmonCountersAsync(resolved.ServerId, hours_back); + var collected = await dataService.GetDistinctPerfmonCountersAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); /* Page Life Expectancy is the counter people reach for by habit; it is intentionally not collected, so an empty trend would otherwise be misread as "PLE looks fine." */ diff --git a/Lite/Mcp/McpPlanCacheSchedulerTools.cs b/Lite/Mcp/McpPlanCacheSchedulerTools.cs index 390f89a95d..1659d72ac8 100644 --- a/Lite/Mcp/McpPlanCacheSchedulerTools.cs +++ b/Lite/Mcp/McpPlanCacheSchedulerTools.cs @@ -22,18 +22,19 @@ public static async Task GetPlanCacheBloat( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history to search for the latest snapshot. Default 24.")] int hours_back = 24) + [Description("Hours of history to search for the latest snapshot. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var summary = await dataService.GetPlanCacheSummaryAsync(resolved.ServerId, hours_back); - var cacheTypes = await dataService.GetPlanCacheSnapshotAsync(resolved.ServerId, hours_back); + var summary = await dataService.GetPlanCacheSummaryAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); + var cacheTypes = await dataService.GetPlanCacheSnapshotAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (summary.TotalPlans == 0 && cacheTypes.Count == 0) return McpHelpers.Status("unavailable", "No plan cache statistics available in the requested time range."); @@ -80,17 +81,18 @@ public static async Task GetCpuSchedulerPressure( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history to search for the latest snapshot. Default 24.")] int hours_back = 24) + [Description("Hours of history to search for the latest snapshot. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var item = await dataService.GetCpuSchedulerSnapshotAsync(resolved.ServerId, hours_back); + var item = await dataService.GetCpuSchedulerSnapshotAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (item == null) return McpHelpers.Status("unavailable", "No CPU scheduler data available. The scheduler collector may not have run yet."); diff --git a/Lite/Mcp/McpPlanCorrectionTools.cs b/Lite/Mcp/McpPlanCorrectionTools.cs index 8c00b5484f..20aad054b6 100644 --- a/Lite/Mcp/McpPlanCorrectionTools.cs +++ b/Lite/Mcp/McpPlanCorrectionTools.cs @@ -31,21 +31,22 @@ public static async Task GetPlanCorrections( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, - [Description("Maximum recommendation rows. Default 50.")] int limit = 50) + [Description("Maximum recommendation rows. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var limitError = McpHelpers.ValidateTop(limit); if (limitError != null) return limitError; var tuning = await dataService.GetLatestAutomaticTuningAsync(resolved.ServerId); - var rows = await dataService.GetPlanCorrectionsAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetPlanCorrectionsAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (tuning.Count == 0 && rows.Count == 0) { diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index e323db99f0..e75f97c7e5 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -18,14 +18,15 @@ public static async Task GetTopQueriesByCpu( [Description("Number of top queries. Default 20.")] int top = 20, [Description("Filter to a specific database.")] string? database_name = null, [Description("If true, only return queries whose cached plan has EVER run at DOP > 1. Note: max_dop comes from sys.dm_exec_query_stats and is a lifetime-max for the plan's time in cache, so a plan compiled before MAXDOP was lowered keeps reporting the old higher value until it is evicted or recompiled. Confirm current parallelism with analyze_query_plan, which reads the actual plan.")] bool parallel_only = false, - [Description("Minimum DOP to filter on. Implies parallel filtering. Filters the same lifetime-max value as parallel_only, not current parallelism.")] int min_dop = 0) + [Description("Minimum DOP to filter on. Implies parallel filtering. Filters the same lifetime-max value as parallel_only, not current parallelism.")] int min_dop = 0, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var topError = McpHelpers.ValidateTop(top, "top"); @@ -36,8 +37,8 @@ shrinks the numerator/denominator window skew from the ranking query's full dura call-entry overhead (review catch; threading one instant INTO the shared ranking read's signature is the only way to zero it, and sub-microsecond against an hours window does not buy that churn). */ - var nowUtc = DateTime.UtcNow; - var rows = await dataService.GetTopQueriesByCpuAsync(resolved.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }); + var nowUtc = windowEnd; + var rows = await dataService.GetTopQueriesByCpuAsync(resolved.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }, asOfUtc: windowEnd); if (rows.Count == 0) { return McpHelpers.Status("unavailable", "No query stats available for the specified time range."); @@ -131,22 +132,23 @@ public static async Task GetTopProceduresByCpu( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Number of top procedures. Default 20.")] int top = 20, - [Description("Filter to a specific database.")] string? database_name = null) + [Description("Filter to a specific database.")] string? database_name = null, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var topError = McpHelpers.ValidateTop(top, "top"); if (topError != null) return topError; /* Same pre-read capture as the queries tool — the skew shrinks to call-entry overhead. */ - var nowUtc = DateTime.UtcNow; - var rows = await dataService.GetTopProceduresByCpuAsync(resolved.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }); + var nowUtc = windowEnd; + var rows = await dataService.GetTopProceduresByCpuAsync(resolved.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }, asOfUtc: windowEnd); if (rows.Count == 0) { return McpHelpers.Status( @@ -220,20 +222,21 @@ public static async Task GetQueryStoreTop( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Number of top queries. Default 20.")] int top = 20, - [Description("Filter to a specific database.")] string? database_name = null) + [Description("Filter to a specific database.")] string? database_name = null, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var topError = McpHelpers.ValidateTop(top, "top"); if (topError != null) return topError; - var rows = await dataService.GetQueryStoreTopQueriesAsync(resolved.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }); + var rows = await dataService.GetQueryStoreTopQueriesAsync(resolved.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }, asOfUtc: windowEnd); if (rows.Count == 0) { return McpHelpers.Status("unavailable", "No Query Store data available. Query Store may not be enabled on target databases."); @@ -277,14 +280,15 @@ public static async Task GetQueryStoreRegressions( [Description("Server name or display name.")] string? server_name = null, [Description("Size of the RECENT window, in hours back from now. Everything collected before it is the baseline. Default 24.")] int hours_back = 24, [Description("Limit to one database. Omit for all databases.")] string? database_name = null, - [Description("Maximum rows to return, worst first. Default 50 (the number the desktop viewer shows).")] int limit = 50) + [Description("Maximum rows to return, worst first. Default 50 (the number the desktop viewer shows).")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd) ?? McpHelpers.ValidateTop(limit); if (validation != null) return validation; /* @@ -294,10 +298,10 @@ public static async Task GetQueryStoreRegressions( */ var databases = string.IsNullOrWhiteSpace(database_name) ? null : new[] { database_name }; var rows = await dataService.GetQueryStoreRegressionsAsync( - resolved.ServerId, hours_back, limit + 1, databases); + resolved.ServerId, hours_back, limit + 1, databases, asOfUtc: windowEnd); if (rows.Count == 0) - return await EmptyRegressionsAsync(dataService, resolved.ServerId, resolved.ServerName, hours_back); + return await EmptyRegressionsAsync(dataService, resolved.ServerId, resolved.ServerName, hours_back, windowEnd); var truncated = rows.Count > limit; @@ -356,9 +360,11 @@ the recent window bigger AND the baseline shorter. /// regressions" there is a confident wrong answer rather than a missing one. /// private static async Task EmptyRegressionsAsync( - LocalDataService dataService, int serverId, string serverName, int hours_back) + LocalDataService dataService, int serverId, string serverName, int hours_back, DateTime windowEnd) { - var (hasBaseline, hasRecent) = await dataService.GetQueryStoreRegressionCoverageAsync(serverId, hours_back); + /* The anchor is threaded in rather than resolved again: the coverage probe answers "does a BEFORE + exist for this window", and a window it computed for itself would be a different one. */ + var (hasBaseline, hasRecent) = await dataService.GetQueryStoreRegressionCoverageAsync(serverId, hours_back, asOfUtc: windowEnd); if (!hasBaseline && !hasRecent) { @@ -391,14 +397,15 @@ public static async Task GetQueryDurationTrend( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var points = await dataService.GetQueryDurationTrendAsync(resolved.ServerId, hours_back); @@ -432,17 +439,18 @@ public static async Task GetProcedureDurationTrend( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var points = await dataService.GetProcedureDurationTrendAsync(resolved.ServerId, hours_back); + var points = await dataService.GetProcedureDurationTrendAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (points.Count == 0) { return await EmptyTrendAsync( @@ -464,17 +472,18 @@ public static async Task GetQueryStoreDurationTrend( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var points = await dataService.GetQueryStoreDurationTrendAsync(resolved.ServerId, hours_back); + var points = await dataService.GetQueryStoreDurationTrendAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (points.Count == 0) { /* @@ -544,17 +553,18 @@ public static async Task GetQueryTrend( [Description("The query_hash value from get_top_queries_by_cpu or get_query_store_top.")] string query_hash, [Description("The database name the query belongs to.")] string database_name, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var rows = await dataService.GetQueryStatsHistoryAsync(resolved.ServerId, database_name, query_hash, hours_back); + var rows = await dataService.GetQueryStatsHistoryAsync(resolved.ServerId, database_name, query_hash, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { return McpHelpers.Status("empty", $"No history found for query_hash '{query_hash}' in database '{database_name}' within the last {hours_back} hours."); diff --git a/Lite/Mcp/McpSessionTools.cs b/Lite/Mcp/McpSessionTools.cs index 114a48b304..64fc2d8744 100644 --- a/Lite/Mcp/McpSessionTools.cs +++ b/Lite/Mcp/McpSessionTools.cs @@ -17,17 +17,18 @@ public static async Task GetActiveQueries( [Description("Hours of data to retrieve. Default 1.")] int hours_back = 1, [Description("Filter to a specific database.")] string? database_name = null, [Description("Show only queries involved in blocking (blocking_session_id > 0 or is a head blocker).")] bool blocking_only = false, - [Description("Maximum number of rows to return. Default 50.")] int limit = 50) + [Description("Maximum number of rows to return. Default 50.")] int limit = 50, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; - var validation = McpHelpers.ValidateHoursBack(hours_back); + var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (validation != null) return validation; try { - var rows = await dataService.GetLatestQuerySnapshotsAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetLatestQuerySnapshotsAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) return McpHelpers.Status("empty", "No active query snapshots found in the requested time range."); diff --git a/Lite/Mcp/McpTempDbTools.cs b/Lite/Mcp/McpTempDbTools.cs index 4f947edfa7..48b766d808 100644 --- a/Lite/Mcp/McpTempDbTools.cs +++ b/Lite/Mcp/McpTempDbTools.cs @@ -14,17 +14,18 @@ public static async Task GetTempDbTrend( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var rows = await dataService.GetTempDbTrendAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetTempDbTrendAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { return McpHelpers.Status("unavailable", "No TempDB data available."); diff --git a/Lite/Mcp/McpWaitTools.cs b/Lite/Mcp/McpWaitTools.cs index c735100c1f..504a6157c6 100644 --- a/Lite/Mcp/McpWaitTools.cs +++ b/Lite/Mcp/McpWaitTools.cs @@ -15,20 +15,21 @@ public static async Task GetWaitStats( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to analyze. Default 24.")] int hours_back = 24, - [Description("Maximum rows to return. Default 20.")] int limit = 20) + [Description("Maximum rows to return. Default 20.")] int limit = 20, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var limitError = McpHelpers.ValidateTop(limit); if (limitError != null) return limitError; - var rows = await dataService.GetWaitStatsAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetWaitStatsAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { return McpHelpers.Status("unavailable", "No wait stats data available for the specified time range."); @@ -62,14 +63,15 @@ public static async Task GetWaitTypes( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var types = await dataService.GetDistinctWaitTypesAsync(resolved.ServerId, hours_back); @@ -110,22 +112,23 @@ public static async Task GetWaitTrend( ServerManager serverManager, [Description("The exact wait type name, e.g. CXPACKET, PAGEIOLATCH_SH.")] string wait_type, [Description("Server name or display name.")] string? server_name = null, - [Description("Hours of history. Default 24.")] int hours_back = 24) + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var points = await dataService.GetWaitStatsTrendAsync(resolved.ServerId, wait_type, hours_back); + var points = await dataService.GetWaitStatsTrendAsync(resolved.ServerId, wait_type, hours_back, asOfUtc: windowEnd); if (points.Count == 0) { /* Same shape as get_perfmon_trend: tell the caller whether the wait type is just unknown here vs. nothing collected at all, and hand back the ones that do have data. */ - var collected = await dataService.GetDistinctWaitTypesAsync(resolved.ServerId, hours_back); + var collected = await dataService.GetDistinctWaitTypesAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (collected.Count == 0) return McpHelpers.Status( "unavailable", @@ -165,20 +168,21 @@ public static async Task GetWaitingTasks( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 1.")] int hours_back = 1, - [Description("Maximum rows. Default 30.")] int limit = 30) + [Description("Maximum rows. Default 30.")] int limit = 30, + [Description(McpHelpers.AsOfDescription)] string? as_of = null) { var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; try { - var hoursError = McpHelpers.ValidateHoursBack(hours_back); + var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; var limitError = McpHelpers.ValidateTop(limit); if (limitError != null) return limitError; - var rows = await dataService.GetWaitingTasksAsync(resolved.ServerId, hours_back); + var rows = await dataService.GetWaitingTasksAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (rows.Count == 0) { return McpHelpers.Status("empty", "No waiting tasks found."); diff --git a/Lite/Services/LocalDataService.AlertHistory.cs b/Lite/Services/LocalDataService.AlertHistory.cs index fd7fe178fc..a325e33690 100644 --- a/Lite/Services/LocalDataService.AlertHistory.cs +++ b/Lite/Services/LocalDataService.AlertHistory.cs @@ -20,12 +20,15 @@ public partial class LocalDataService /// /// Gets alert history from the config_alert_log table (excludes dismissed alerts). /// - public async Task> GetAlertHistoryAsync(int hoursBack = 24, int limit = 500, int? serverId = null) + public async Task> GetAlertHistoryAsync(int hoursBack = 24, int limit = 500, int? serverId = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var cutoff = DateTime.UtcNow.AddHours(-hoursBack); + /* Both edges, not just the lower one: the row cap is applied by the database, so trimming + after the read would spend the whole LIMIT on rows newer than an as_of anchor and hand back an + empty window that looks exactly like a quiet one. */ + var (cutoff, until) = GetTimeRange(hoursBack, null, null, asOfUtc); if (serverId.HasValue) { @@ -46,11 +49,13 @@ public async Task> GetAlertHistoryAsync(int hoursBack = 24 context_json FROM v_config_alert_log WHERE alert_time >= $1 -AND server_id = $2 +AND alert_time <= $2 +AND server_id = $3 AND dismissed = FALSE ORDER BY alert_time DESC -LIMIT $3"; +LIMIT $4"; command.Parameters.Add(new DuckDBParameter { Value = cutoff }); + command.Parameters.Add(new DuckDBParameter { Value = until }); command.Parameters.Add(new DuckDBParameter { Value = serverId.Value }); command.Parameters.Add(new DuckDBParameter { Value = limit }); } @@ -73,10 +78,12 @@ ORDER BY alert_time DESC context_json FROM v_config_alert_log WHERE alert_time >= $1 +AND alert_time <= $2 AND dismissed = FALSE ORDER BY alert_time DESC -LIMIT $2"; +LIMIT $3"; command.Parameters.Add(new DuckDBParameter { Value = cutoff }); + command.Parameters.Add(new DuckDBParameter { Value = until }); command.Parameters.Add(new DuckDBParameter { Value = limit }); } diff --git a/Lite/Services/LocalDataService.Blocking.cs b/Lite/Services/LocalDataService.Blocking.cs index 3621afebf7..16a3362648 100644 --- a/Lite/Services/LocalDataService.Blocking.cs +++ b/Lite/Services/LocalDataService.Blocking.cs @@ -73,12 +73,12 @@ FROM v_dmv_blocking_snapshots /// /// Gets recent deadlock events for a server. /// - public async Task> GetRecentDeadlocksAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetRecentDeadlocksAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" SELECT @@ -173,13 +173,13 @@ GROUP BY date_trunc('hour', collection_time) /// /// Gets query snapshots (currently running queries) for a server. /// - public async Task> GetLatestQuerySnapshotsAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null) + public async Task> GetLatestQuerySnapshotsAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetLatestQuerySnapshotsAsync", "v_query_snapshots latest"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" @@ -313,12 +313,12 @@ SELECT MAX(deadlock_time) AS t FROM v_deadlocks /// /// Gets recent blocked process reports from the XE-based collector. /// - public async Task> GetRecentBlockedProcessReportsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null) + public async Task> GetRecentBlockedProcessReportsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" @@ -665,12 +665,12 @@ GROUP BY date_trunc('hour', collection_time) /// Uses blocked_process_reports from Extended Events for more reliable detection. /// Falls back to blocking_snapshots if no XE data available. /// - public async Task> GetBlockingTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null) + public async Task> GetBlockingTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); /* Use blocked_process_reports from XE session - more reliable than point-in-time snapshots @@ -717,12 +717,12 @@ UNION ALL /// /// Gets deadlock trend (count of deadlocks per minute bucket). /// - public async Task> GetDeadlockTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetDeadlockTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" SELECT diff --git a/Lite/Services/LocalDataService.BlockingStats.cs b/Lite/Services/LocalDataService.BlockingStats.cs index f2aff5a1a9..96c5668966 100644 --- a/Lite/Services/LocalDataService.BlockingStats.cs +++ b/Lite/Services/LocalDataService.BlockingStats.cs @@ -73,12 +73,12 @@ OR EXISTS (SELECT 1 FROM v_dmv_blocking_snapshots WHERE server_id = $2) /// count trend. /// public async Task> GetBlockingDurationStatsAsync( - int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null) + int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); /* BPR per-minute severity buckets, falling back to the always-on DMV snapshot only when BPR has none @@ -145,14 +145,14 @@ UNION ALL /// . /// public async Task> GetDeadlockSeverityStatsAsync( - int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { var rows = new List(); using (var connection = await OpenConnectionAsync()) using (var command = connection.CreateCommand()) { - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" SELECT diff --git a/Lite/Services/LocalDataService.CollectionHealth.cs b/Lite/Services/LocalDataService.CollectionHealth.cs index 307530173c..54cb0f30cd 100644 --- a/Lite/Services/LocalDataService.CollectionHealth.cs +++ b/Lite/Services/LocalDataService.CollectionHealth.cs @@ -261,12 +261,12 @@ FROM v_collection_log /// windows its read. The old single now-relative lower bound ignored /// the custom To, rounding a custom range to a hours-back-from-now span. /// - public async Task> GetRecentCollectionLogAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null, int maxRows = 500) + public async Task> GetRecentCollectionLogAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null, int maxRows = 500, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" SELECT diff --git a/Lite/Services/LocalDataService.ConfigChanges.cs b/Lite/Services/LocalDataService.ConfigChanges.cs index 5d15e273d8..13c39a169e 100644 --- a/Lite/Services/LocalDataService.ConfigChanges.cs +++ b/Lite/Services/LocalDataService.ConfigChanges.cs @@ -104,10 +104,10 @@ public partial class LocalDataService /// snapshot up to the window end (keeping the pre-window baseline), then diffs per configuration_name via /// the shared . public async Task> GetServerConfigChangesAsync( - int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetServerConfigChangesAsync", "v_server_config snapshot diff"); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var snapshots = await ReadServerConfigSnapshotsAsync(serverId, endTime); return ConfigChangeDiff.DiffServerConfigChanges(snapshots, startTime, endTime) .Select(c => new ServerConfigChangeRow(c)) @@ -118,10 +118,10 @@ public async Task> GetServerConfigChangesAsync( /// snapshot up to the window end then UNPIVOTs the wide row per (database, setting) whose value moved via /// the shared . #1319: the global database filter is pushed into SQL. public async Task> GetDatabaseConfigChangesAsync( - int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null) + int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetDatabaseConfigChangesAsync", "v_database_config snapshot diff"); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var snapshots = await ReadDatabaseConfigSnapshotsAsync(serverId, endTime, databaseNames); return ConfigChangeDiff.DiffDatabaseConfigChanges(snapshots, startTime, endTime) .Select(c => new DatabaseConfigChangeRow(c)) @@ -132,10 +132,10 @@ public async Task> GetDatabaseConfigChangesAsync( /// window end then set-diffs consecutive captures via the shared (flag /// appears = enabled, disappears = disabled, status/scope moves = modified). public async Task> GetTraceFlagChangesAsync( - int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetTraceFlagChangesAsync", "v_trace_flags snapshot diff"); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var snapshots = await ReadTraceFlagSnapshotsAsync(serverId, endTime); return ConfigChangeDiff.DiffTraceFlagChanges(snapshots, startTime, endTime) .Select(c => new TraceFlagChangeRow(c)) diff --git a/Lite/Services/LocalDataService.Cpu.cs b/Lite/Services/LocalDataService.Cpu.cs index 1e4fff3661..70dd495dd7 100644 --- a/Lite/Services/LocalDataService.Cpu.cs +++ b/Lite/Services/LocalDataService.Cpu.cs @@ -19,13 +19,13 @@ public partial class LocalDataService /// Gets CPU utilization data for charting. /// Note: sample_time is stored in server local time (from SYSDATETIME()), not UTC. /// - public async Task> GetCpuUtilizationAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetCpuUtilizationAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); /* sample_time is in server local time, not UTC */ - var (startTime, endTime) = GetTimeRangeServerLocal(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRangeServerLocal(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" SELECT diff --git a/Lite/Services/LocalDataService.CpuScheduler.cs b/Lite/Services/LocalDataService.CpuScheduler.cs index 6657a1f89d..5dfe31e1b5 100644 --- a/Lite/Services/LocalDataService.CpuScheduler.cs +++ b/Lite/Services/LocalDataService.CpuScheduler.cs @@ -72,13 +72,13 @@ FROM v_cpu_scheduler_stats /// (every scheduler / worker / NUMA / OS-memory pressure column + the collector's CASE-computed /// warning flags), feeding the metric grid. Returns null when the window holds no snapshot. /// - public async Task GetCpuSchedulerSnapshotAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task GetCpuSchedulerSnapshotAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetCpuSchedulerSnapshotAsync", "v_cpu_scheduler_stats latest snapshot"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" SELECT diff --git a/Lite/Services/LocalDataService.FileIo.cs b/Lite/Services/LocalDataService.FileIo.cs index 4c48cd783a..0d788e451b 100644 --- a/Lite/Services/LocalDataService.FileIo.cs +++ b/Lite/Services/LocalDataService.FileIo.cs @@ -68,12 +68,12 @@ FROM v_file_io_stats /// /// Gets file I/O latency trend data broken down by file for charting (top 10 files by I/O activity). /// - public async Task> GetFileIoLatencyTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetFileIoLatencyTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" WITH top_files AS ( diff --git a/Lite/Services/LocalDataService.LatchSpinlock.cs b/Lite/Services/LocalDataService.LatchSpinlock.cs index e5d4cfbb13..58ea630ced 100644 --- a/Lite/Services/LocalDataService.LatchSpinlock.cs +++ b/Lite/Services/LocalDataService.LatchSpinlock.cs @@ -89,13 +89,13 @@ FROM raw /// The Latch Stats latest-snapshot grid: every latch class captured at the most recent collection in /// the window, ordered by the last interval's delta wait time then cumulative wait time, capped at 20. /// - public async Task> GetLatchStatsSnapshotAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetLatchStatsSnapshotAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetLatchStatsSnapshotAsync", "v_latch_stats latest snapshot"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" WITH latest AS @@ -209,13 +209,13 @@ FROM raw /// The Spinlock Stats latest-snapshot grid: every spinlock captured at the most recent collection in /// the window, ordered by the last interval's delta collisions then cumulative collisions, capped at 20. /// - public async Task> GetSpinlockStatsSnapshotAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetSpinlockStatsSnapshotAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetSpinlockStatsSnapshotAsync", "v_spinlock_stats latest snapshot"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" WITH latest AS diff --git a/Lite/Services/LocalDataService.LongQueries.cs b/Lite/Services/LocalDataService.LongQueries.cs index 03acb28eae..94ca1e4a97 100644 --- a/Lite/Services/LocalDataService.LongQueries.cs +++ b/Lite/Services/LocalDataService.LongQueries.cs @@ -21,12 +21,12 @@ public partial class LocalDataService /// applies a view-only DESCENDING-by-duration sort, so the SQL keeps the chronological ORDER BY /// (mirrors the blocked-process reader). /// - public async Task> GetRecentLongQueryCompletionsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null) + public async Task> GetRecentLongQueryCompletionsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" diff --git a/Lite/Services/LocalDataService.Memory.cs b/Lite/Services/LocalDataService.Memory.cs index e5b31d2a48..07fdb1c083 100644 --- a/Lite/Services/LocalDataService.Memory.cs +++ b/Lite/Services/LocalDataService.Memory.cs @@ -67,12 +67,12 @@ ORDER BY collection_time DESC /// /// Gets memory stats trend for charting. /// - public async Task> GetMemoryTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetMemoryTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" SELECT @@ -222,12 +222,12 @@ AND clerk_type IN ({typeParams}) /// /// Gets memory pressure events (from RING_BUFFER_RESOURCE_MONITOR) for charting. /// - public async Task> GetMemoryPressureEventsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetMemoryPressureEventsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" SELECT diff --git a/Lite/Services/LocalDataService.MemoryGrants.cs b/Lite/Services/LocalDataService.MemoryGrants.cs index c4ad194238..0ac0d17e6b 100644 --- a/Lite/Services/LocalDataService.MemoryGrants.cs +++ b/Lite/Services/LocalDataService.MemoryGrants.cs @@ -60,12 +60,12 @@ GROUP BY collection_time /// Gets memory grant chart data aggregated by collection_time and pool_id /// for the Memory Grants sub-tab charts. /// - public async Task> GetMemoryGrantChartDataAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetMemoryGrantChartDataAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" SELECT @@ -117,12 +117,12 @@ FROM v_memory_grant_stats /// , which aggregates a per-pool grant subset for the charts; /// this returns one row per (pool_id, resource_semaphore_id) with the ceiling metrics intact. /// - public async Task> GetResourceSemaphoreSnapshotAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetResourceSemaphoreSnapshotAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" WITH latest AS diff --git a/Lite/Services/LocalDataService.Perfmon.cs b/Lite/Services/LocalDataService.Perfmon.cs index 8da8a29e09..243b5ae433 100644 --- a/Lite/Services/LocalDataService.Perfmon.cs +++ b/Lite/Services/LocalDataService.Perfmon.cs @@ -54,12 +54,12 @@ FROM v_perfmon_stats /// /// Gets the distinct perfmon counter names for a server. /// - public async Task> GetDistinctPerfmonCountersAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetDistinctPerfmonCountersAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" SELECT DISTINCT counter_name @@ -85,12 +85,12 @@ FROM v_perfmon_stats /// /// Gets perfmon counter trend data for charting. /// - public async Task> GetPerfmonTrendAsync(int serverId, string counterName, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetPerfmonTrendAsync(int serverId, string counterName, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" SELECT diff --git a/Lite/Services/LocalDataService.PlanCache.cs b/Lite/Services/LocalDataService.PlanCache.cs index 8235b4a70a..a23a2e32d2 100644 --- a/Lite/Services/LocalDataService.PlanCache.cs +++ b/Lite/Services/LocalDataService.PlanCache.cs @@ -70,13 +70,13 @@ GROUP BY collection_time /// The Plan Cache latest-snapshot composition grid: every (cacheobjtype, objtype) group captured at /// the most recent collection in the window, ordered by total cache size then plan count, capped at 30. /// - public async Task> GetPlanCacheSnapshotAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetPlanCacheSnapshotAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetPlanCacheSnapshotAsync", "v_plan_cache_stats latest composition"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" WITH latest AS @@ -138,13 +138,13 @@ FROM v_plan_cache_stats /// the window. Computed independently of the top-30 grid so "Total Plans" is exact even when the grid /// is capped. An aggregate with no GROUP BY always returns one row, so an empty window yields (0,0,null). /// - public async Task GetPlanCacheSummaryAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task GetPlanCacheSummaryAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetPlanCacheSummaryAsync", "v_plan_cache_stats summary totals"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" WITH latest AS diff --git a/Lite/Services/LocalDataService.PlanCorrection.cs b/Lite/Services/LocalDataService.PlanCorrection.cs index d4b99c4360..0c7512597e 100644 --- a/Lite/Services/LocalDataService.PlanCorrection.cs +++ b/Lite/Services/LocalDataService.PlanCorrection.cs @@ -23,12 +23,12 @@ public partial class LocalDataService /// a view-only DESCENDING-by-score sort, so the SQL keeps the chronological ORDER BY (mirrors the /// long-query reader). /// - public async Task> GetPlanCorrectionsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null) + public async Task> GetPlanCorrectionsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" diff --git a/Lite/Services/LocalDataService.QueryStats.cs b/Lite/Services/LocalDataService.QueryStats.cs index 22c78698f3..b28256dfdf 100644 --- a/Lite/Services/LocalDataService.QueryStats.cs +++ b/Lite/Services/LocalDataService.QueryStats.cs @@ -89,13 +89,13 @@ GROUP BY date_trunc('hour', collection_time) return items; } - public async Task> GetTopQueriesByCpuAsync(int serverId, int hoursBack = 24, int top = 50, DateTime? fromDate = null, DateTime? toDate = null, int utcOffsetMinutes = 0, IReadOnlyList? databaseNames = null) + public async Task> GetTopQueriesByCpuAsync(int serverId, int hoursBack = 24, int top = 50, DateTime? fromDate = null, DateTime? toDate = null, int utcOffsetMinutes = 0, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetTopQueriesByCpuAsync", "v_query_stats top N by CPU"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var dbClause = BuildDbInClause(databaseNames, "database_name", 6, out var dbValues); command.CommandText = @" @@ -403,11 +403,11 @@ ON c.database_name IS NOT DISTINCT FROM b.database_name /// /// Gets collection-level history for a specific query hash (for drilldown). /// - public async Task> GetQueryStatsHistoryAsync(int serverId, string databaseName, string queryHash, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetQueryStatsHistoryAsync(int serverId, string databaseName, string queryHash, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" SELECT collection_time, @@ -870,13 +870,13 @@ GROUP BY date_trunc('hour', collection_time) return items; } - public async Task> GetTopProceduresByCpuAsync(int serverId, int hoursBack = 24, int top = 50, DateTime? fromDate = null, DateTime? toDate = null, int utcOffsetMinutes = 0, IReadOnlyList? databaseNames = null) + public async Task> GetTopProceduresByCpuAsync(int serverId, int hoursBack = 24, int top = 50, DateTime? fromDate = null, DateTime? toDate = null, int utcOffsetMinutes = 0, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetTopProceduresByCpuAsync", "v_procedure_stats top N by CPU"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var dbClause = BuildDbInClause(databaseNames, "database_name", 6, out var dbValues); command.CommandText = @" @@ -1108,12 +1108,12 @@ LIMIT 1 /// /// Gets query duration trend — total elapsed time per collection snapshot. /// - public async Task> GetQueryDurationTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null) + public async Task> GetQueryDurationTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" @@ -1185,12 +1185,12 @@ FROM v_query_stats /// /// Gets procedure duration trend — elapsed time per second per collection snapshot. /// - public async Task> GetProcedureDurationTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null) + public async Task> GetProcedureDurationTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" diff --git a/Lite/Services/LocalDataService.QueryStore.cs b/Lite/Services/LocalDataService.QueryStore.cs index 949ef9e513..dce642bbf7 100644 --- a/Lite/Services/LocalDataService.QueryStore.cs +++ b/Lite/Services/LocalDataService.QueryStore.cs @@ -155,13 +155,13 @@ GROUP BY date_trunc('hour', COALESCE(interval_start_time_utc, collection_time)) return items; } - public async Task> GetQueryStoreTopQueriesAsync(int serverId, int hoursBack = 24, int top = 50, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null) + public async Task> GetQueryStoreTopQueriesAsync(int serverId, int hoursBack = 24, int top = 50, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetQueryStoreTopQueriesAsync", "v_query_store_stats top N"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var dbClause = BuildDbInClause(databaseNames, "database_name", 5, out var dbValues); command.CommandText = @" @@ -890,12 +890,12 @@ AND qsp.query_plan IS NOT NULL /// corrected recent section and an un-corrected older one, each behaving as its own generation /// always did, and the mixture resolves itself as the pre-upgrade rows age out of retention. /// - public async Task> GetQueryStoreDurationTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null) + public async Task> GetQueryStoreDurationTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" diff --git a/Lite/Services/LocalDataService.QueryStoreRegressions.cs b/Lite/Services/LocalDataService.QueryStoreRegressions.cs index f3d293f12e..f74377100c 100644 --- a/Lite/Services/LocalDataService.QueryStoreRegressions.cs +++ b/Lite/Services/LocalDataService.QueryStoreRegressions.cs @@ -64,12 +64,12 @@ public partial class LocalDataService /// compared with everything collected before it, ranked by execution-count-weighted extra duration. /// public async Task> GetQueryStoreRegressionsAsync( - int serverId, int hoursBack = 24, int maxRows = 50, IReadOnlyList? databaseNames = null) + int serverId, int hoursBack = 24, int maxRows = 50, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, null, null); + var (startTime, endTime) = GetTimeRange(hoursBack, null, null, asOfUtc); var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); var limitIndex = 4 + dbValues.Count; @@ -224,12 +224,12 @@ ORDER BY additional_duration_ms DESC /// DarlingQueryStoreRegressionReader.RegressionCoverageSql. /// public async Task<(bool HasBaseline, bool HasRecent)> GetQueryStoreRegressionCoverageAsync( - int serverId, int hoursBack = 24) + int serverId, int hoursBack = 24, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, null, null); + var (startTime, endTime) = GetTimeRange(hoursBack, null, null, asOfUtc); command.CommandText = @" SELECT diff --git a/Lite/Services/LocalDataService.SystemEvents.cs b/Lite/Services/LocalDataService.SystemEvents.cs index 83281ee0e1..5484d1a22e 100644 --- a/Lite/Services/LocalDataService.SystemEvents.cs +++ b/Lite/Services/LocalDataService.SystemEvents.cs @@ -329,10 +329,10 @@ AND event_xml IS NOT NULL // ── Scheduler Issues ── /// Significant scheduler-monitor warnings (WARNING status) for the window, newest first. - public async Task> GetSchedulerIssuesAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetSchedulerIssuesAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetSchedulerIssuesAsync", "v_system_health_events scheduler_monitor shred"); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.SchedulerMonitorEvent); var rows = new List(); @@ -353,10 +353,10 @@ public async Task> GetSchedulerIssuesAsync(int serverId, /// errors have no database_name column (the DB is resolved in C# from the event's database_id via the /// collected id -> name map), so the global database filter is applied client-side on the resolved name. /// - public async Task> GetSevereErrorsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null) + public async Task> GetSevereErrorsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetSevereErrorsAsync", "v_system_health_events error_reported shred"); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var map = await GetDatabaseNameMapAsync(serverId); var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.ErrorReportedEvent); @@ -379,10 +379,10 @@ public async Task> GetSevereErrorsAsync(int serverId, int h // ── Memory Conditions ── /// Significant memory-conditions snapshots (RESOURCE_MEMPHYSICAL_LOW) for the window, newest first. - public async Task> GetMemoryConditionsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetMemoryConditionsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetMemoryConditionsAsync", "v_system_health_events sp_server_diagnostics RESOURCE shred"); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.SpServerDiagnosticsEvent); var rows = new List(); @@ -399,10 +399,10 @@ public async Task> GetMemoryConditionsAsync(int server // ── Memory Broker ── /// Significant memory-broker ratio changes (RESOURCE_MEMPHYSICAL_LOW) for the window, newest first. - public async Task> GetMemoryBrokerAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetMemoryBrokerAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetMemoryBrokerAsync", "v_system_health_events memory_broker shred"); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.MemoryBrokerEvent); var rows = new List(); @@ -418,10 +418,10 @@ public async Task> GetMemoryBrokerAsync(int serverId, int // ── Memory Node OOM ── /// Every memory-node OOM for the window, newest first (this category is never filtered). - public async Task> GetMemoryNodeOomAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetMemoryNodeOomAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetMemoryNodeOomAsync", "v_system_health_events memory_node_oom shred"); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.MemoryNodeOomEvent); var rows = new List(); @@ -452,10 +452,10 @@ public async Task> GetSignificantWaitsAsync(int serverI /// the two numbers can never describe different windows. Darling gets the same count for free from its /// own reader. /// - public async Task<(List Rows, int CapturedCount)> GetSignificantWaitsWithCaptureAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task<(List Rows, int CapturedCount)> GetSignificantWaitsWithCaptureAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetSignificantWaitsAsync", "v_system_health_events wait_info shred"); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.WaitInfoEvent); var rows = new List(); @@ -503,10 +503,10 @@ AND event_xml IS NOT NULL /// window, newest first. Reads the same sp_server_diagnostics feed as Memory Conditions / I/O Issues; /// only the QUERY_PROCESSING component yields a record. /// - public async Task> GetCpuTasksAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetCpuTasksAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetCpuTasksAsync", "v_system_health_events sp_server_diagnostics QUERY_PROCESSING shred"); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.SpServerDiagnosticsEvent); var rows = new List(); @@ -527,10 +527,10 @@ public async Task> GetCpuTasksAsync(int serverId, int hoursBac /// can carry several pending-request files, so the shred fans out to one row per file (durations summed); /// only the IO_SUBSYSTEM component yields records. /// - public async Task> GetIoIssuesAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetIoIssuesAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetIoIssuesAsync", "v_system_health_events sp_server_diagnostics IO_SUBSYSTEM shred"); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.SpServerDiagnosticsEvent); var rows = new List(); @@ -556,10 +556,10 @@ public async Task> GetIoIssuesAsync(int serverId, int hoursBac /// time, so this returns them all (records with no event time can't sit on a time axis and are dropped). /// Both chart sub-tabs read this one list and select their own columns. /// - public async Task> GetSystemHealthAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetSystemHealthAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetSystemHealthAsync", "v_system_health_events sp_server_diagnostics SYSTEM shred"); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.SpServerDiagnosticsEvent); var records = new List(); @@ -636,7 +636,7 @@ public static string ResolveDatabaseName(int? databaseId, IReadOnlyDictionarydatabase_name. The ErrorLog severity gate is /// applied on read via the shared . /// - public async Task> GetDefaultTraceEventsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null) + public async Task> GetDefaultTraceEventsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetDefaultTraceEventsAsync", "v_default_trace_events significant-set read"); using var connection = await OpenConnectionAsync(); @@ -644,7 +644,7 @@ public async Task> GetDefaultTraceEventsAsync(int ser /* Default Trace event_time is server-LOCAL, so window on server-local bounds (the same helper the CPU/sample_time reads use). Bind db filter params immediately after the 3 fixed params ($4+). */ - var (startTime, endTime) = GetTimeRangeServerLocal(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRangeServerLocal(hoursBack, fromDate, toDate, asOfUtc); var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" diff --git a/Lite/Services/LocalDataService.TempDb.cs b/Lite/Services/LocalDataService.TempDb.cs index 4e785220bf..6881b73e88 100644 --- a/Lite/Services/LocalDataService.TempDb.cs +++ b/Lite/Services/LocalDataService.TempDb.cs @@ -18,12 +18,12 @@ public partial class LocalDataService /// /// Gets TempDB stats trend for charting. /// - public async Task> GetTempDbTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetTempDbTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" SELECT diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs index c15e81f294..3246021a90 100644 --- a/Lite/Services/LocalDataService.WaitStats.cs +++ b/Lite/Services/LocalDataService.WaitStats.cs @@ -25,13 +25,13 @@ public partial class LocalDataService /// /// Gets aggregated wait stats for a server over a time period, sorted by delta wait time. /// - public async Task> GetWaitStatsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetWaitStatsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var _q = TimeQuery("GetWaitStatsAsync", "v_wait_stats top by delta"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var exclude = IgnoredWaitTypes.BuildExclusionClause(_ignoredWaitTypes.Value); command.CommandText = $@" @@ -100,12 +100,12 @@ FROM v_wait_stats /// /// Gets the distinct wait types that have been collected for a server. /// - public async Task> GetDistinctWaitTypesAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetDistinctWaitTypesAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var exclude = IgnoredWaitTypes.BuildExclusionClause(_ignoredWaitTypes.Value); command.CommandText = $@" @@ -136,12 +136,12 @@ GROUP BY wait_type /// /// Gets wait stats trend data for charting. /// - public async Task> GetWaitStatsTrendAsync(int serverId, string waitType, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetWaitStatsTrendAsync(int serverId, string waitType, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" WITH raw AS diff --git a/Lite/Services/LocalDataService.WaitingTasks.cs b/Lite/Services/LocalDataService.WaitingTasks.cs index d23748dd51..693e9b2de0 100644 --- a/Lite/Services/LocalDataService.WaitingTasks.cs +++ b/Lite/Services/LocalDataService.WaitingTasks.cs @@ -18,7 +18,7 @@ public partial class LocalDataService /// /// Gets recent waiting task snapshots for a server. /// - public async Task> GetWaitingTasksAsync(int serverId, int hoursBack = 1, IReadOnlyList? databaseNames = null) + public async Task> GetWaitingTasksAsync(int serverId, int hoursBack = 1, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); @@ -27,7 +27,11 @@ public async Task> GetWaitingTasksAsync(int serverId, int h tasks already in the DuckDB (collected before a type was ignored) don't surface here — the same BuildExclusionClause the wait-stats reads use. */ var exclude = IgnoredWaitTypes.BuildExclusionClause(_ignoredWaitTypes.Value); - var dbClause = BuildDbInClause(databaseNames, "database_name", 3, out var dbValues); + + /* The window's upper edge is $3, so the optional database list starts at $4. Bounding both edges + (rather than only the lower one) is what lets an as_of anchor mean anything here. */ + var (startTime, endTime) = GetTimeRange(hoursBack, null, null, asOfUtc); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = $@" SELECT collection_time, @@ -39,12 +43,14 @@ BuildExclusionClause the wait-stats reads use. */ database_name FROM v_waiting_tasks WHERE server_id = $1 -AND collection_time >= $2{dbClause} +AND collection_time >= $2 +AND collection_time <= $3{dbClause} {exclude} ORDER BY collection_time DESC, wait_duration_ms DESC"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); - command.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow.AddHours(-hoursBack) }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); foreach (var db in dbValues) command.Parameters.Add(new DuckDBParameter { Value = db }); @@ -94,12 +100,12 @@ FROM v_waiting_tasks /// /// Gets waiting task duration trend grouped by wait type for charting. /// - public async Task> GetWaitingTaskTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task> GetWaitingTaskTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); /* #1240 parity: exclude the user's ignored (benign) wait types at DISPLAY time (mirrors the wait-stats reads) so the Current Waits duration chart matches the Wait Stats tab. */ @@ -143,12 +149,12 @@ ORDER BY /// /// Gets blocked session count trend grouped by database for charting. /// - public async Task> GetBlockedSessionTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null) + public async Task> GetBlockedSessionTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList? databaseNames = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" diff --git a/Lite/Services/LocalDataService.cs b/Lite/Services/LocalDataService.cs index 01c1dffe66..16c4659ea2 100644 --- a/Lite/Services/LocalDataService.cs +++ b/Lite/Services/LocalDataService.cs @@ -112,7 +112,7 @@ private static long ToInt64(object value) /// Returns UTC time for collection_time queries (most tables store collection_time in UTC). /// When fromDate/toDate are provided, they should already be in UTC. /// - private static (DateTime startTime, DateTime endTime) GetTimeRange(int hoursBack, DateTime? fromDate, DateTime? toDate) + private static (DateTime startTime, DateTime endTime) GetTimeRange(int hoursBack, DateTime? fromDate, DateTime? toDate, DateTime? asOfUtc = null) { if (fromDate.HasValue && toDate.HasValue) { @@ -122,16 +122,26 @@ private static (DateTime startTime, DateTime endTime) GetTimeRange(int hoursBack return (startUtc, endUtc); } + /* + #2495: asOfUtc moves the END of the hoursBack window off "now" so a caller can ask about a + past incident. It is deliberately NOT expressed as fromDate/toDate -- those are SERVER-LOCAL + (converted back to UTC just above), while the MCP anchor is UTC, and routing a UTC instant + through that branch would silently shift the window by the monitored server's offset. + */ + var anchor = asOfUtc ?? DateTime.UtcNow; + /* Use UTC directly since collection_time is stored in UTC */ - return (DateTime.UtcNow.AddHours(-hoursBack), DateTime.UtcNow); + return (anchor.AddHours(-hoursBack), anchor); } /// /// Gets the time range in server local time (for tables like cpu_utilization_stats.sample_time). /// - private static (DateTime startTime, DateTime endTime) GetTimeRangeServerLocal(int hoursBack, DateTime? fromDate, DateTime? toDate) + private static (DateTime startTime, DateTime endTime) GetTimeRangeServerLocal(int hoursBack, DateTime? fromDate, DateTime? toDate, DateTime? asOfUtc = null) { - var serverNow = DateTime.UtcNow.AddMinutes(ServerTimeHelper.UtcOffsetMinutes); + /* The anchor arrives in UTC (see GetTimeRange) and is carried into server-local here, so both + families answer the same instant even though they window on differently-based columns. */ + var serverNow = (asOfUtc ?? DateTime.UtcNow).AddMinutes(ServerTimeHelper.UtcOffsetMinutes); if (fromDate.HasValue && toDate.HasValue) { diff --git a/PerformanceMonitor.Common/Mcp/McpHelpers.cs b/PerformanceMonitor.Common/Mcp/McpHelpers.cs index 903032f822..5b4b88d730 100644 --- a/PerformanceMonitor.Common/Mcp/McpHelpers.cs +++ b/PerformanceMonitor.Common/Mcp/McpHelpers.cs @@ -7,6 +7,7 @@ */ using System; +using System.Globalization; using System.Text.Json; namespace PerformanceMonitor.Common; @@ -69,6 +70,101 @@ internal static class McpHelpers return null; } + /// + /// Validates a windowed read's two time knobs together and hands back the window's END — the single call + /// every windowed read makes in place of a bare . + /// + /// The span is checked BEFORE the anchor so a caller who sent both wrong is told about + /// hours_back first, exactly as they were before as_of existed. is + /// only meaningful when this returns null. + /// + public static string? ValidateWindow(int hoursBack, string? asOf, out DateTime endUtc) + { + endUtc = DateTime.UtcNow; + + var hoursError = ValidateHoursBack(hoursBack); + if (hoursError != null) + { + return hoursError; + } + + return ResolveAsOf(asOf, out endUtc); + } + + /// + /// The as_of parameter's description, shared VERBATIM by every windowed read on both SKUs. + /// + /// It is a constant rather than 100 hand-typed attribute strings for the reason the rest of the + /// two-SKU parity rules exist: the same parameter described two different ways on two servers is a + /// divergence no test would catch and every agent would notice. + /// + public const string AsOfDescription = + "Optional UTC anchor for the END of the window, ISO-8601 (2026-08-18T14:30:00Z, or 2026-08-18 for " + + "midnight UTC). Omit to anchor at now. The window is the hours_back hours ENDING here, so a past " + + "incident is 'as_of the end of it, hours_back its length' — widening hours_back is NOT the same " + + "question, because a wider window is a different aggregate, not the same one with more rows."; + + /// + /// How far past now an as_of anchor may sit and still be accepted. + /// + /// Not a grace period for asking about the future — it is the client-clock allowance. An agent that + /// computes "now" from its own clock and sends it as as_of must not be refused because that clock + /// runs a minute or two fast, and a stored read is answering out of a store whose newest row is minutes + /// old anyway. Anything beyond this is a real request for data that cannot exist yet. + /// + public static readonly TimeSpan AsOfFutureTolerance = TimeSpan.FromMinutes(5); + + /// + /// Resolves the END of a windowed read from the optional as_of anchor: + /// receives when the caller sent nothing (the pre-#2495 behaviour, exactly), + /// or the parsed instant when they did. Returns null when the anchor is usable, an error message when it + /// is not. + /// + /// REFUSES rather than substitutes, following : a caller who sent an anchor + /// we could not use asked a specific question, and quietly answering a different one — silently falling + /// back to now — is the failure mode this whole parameter exists to remove. A read that says "the last 4 + /// hours" when it was asked for "the 4 hours ending Tuesday 03:00" is indistinguishable from a correct + /// answer. + /// + /// There is deliberately NO lower bound. An as_of older than anything the store holds is a + /// legitimate question with an honest answer — the read's own empty / unavailable status — + /// and the caller knows the anchor they sent, so that status is unambiguous. A hardcoded floor would have + /// to guess at retention, which is per-deployment, per-server and per-collector, and would refuse real + /// reads on a long-retention store. The SPAN is still bounded by ; only the + /// anchor is free. + /// + public static string? ResolveAsOf(string? asOf, out DateTime endUtc) + { + var now = DateTime.UtcNow; + endUtc = now; + + if (string.IsNullOrWhiteSpace(asOf)) + { + return null; + } + + /* AssumeUniversal so a bare "2026-08-18T14:30:00" is read as UTC rather than as the SERVICE host's + local time — the store is UTC throughout, the caller is an agent on some other machine, and a + silent local-time reading would shift the window by the host's offset with nothing to show for it. + AdjustToUniversal then normalises an explicit offset ("...+02:00") into the same UTC instant. */ + if (!DateTime.TryParse( + asOf, + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out var parsed)) + { + return $"Invalid as_of value '{asOf}'. Expected an ISO-8601 UTC instant, e.g. '2026-08-18T14:30:00Z' or '2026-08-18'."; + } + + if (parsed > now + AsOfFutureTolerance) + { + return $"as_of value '{asOf}' is in the future. A stored read cannot cover data that has not been collected yet; anchor at or before now (UTC)."; + } + + endUtc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc); + return null; + } + /// /// Validates top/limit parameter. Returns null if valid, error message if invalid. /// From 188c3c3a093232f18eef2a53cb07a9e754677909 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:44:04 +0100 Subject: [PATCH 02/10] Refuse an ambiguous as_of, and fix the pins as_of moved (#2495) Seven parameter-contract pins failed for the right reason: `as_of` is now the last parameter on every windowed read, and three of them already carried a Darling-only trailing optional (`group_by`, `dedup_key`). Lite's list is therefore no longer a PREFIX of Darling's. The pins now assert what the prefix was standing in for -- every Lite name present in Lite's relative order, and every Darling-only extra optional -- which still fails on a drop, a reorder, or a required addition, and does not require relocating a shipped parameter to preserve a position no MCP caller can observe. Review catch: `DateTime.TryParse` accepted `01/02/2026` as `M/d/yyyy` under the invariant culture, so a caller who meant 1 February would have got a window around 2 January with nothing to say so -- the same defect one step removed from the silent fall back to "now" that this parameter exists to refuse. The parser is now `TryParseExact` against an ISO-8601 allowlist, and the refusal message names the forms that work. Also drops a doc claim the code does not support: the config-change diff's open upper edge was justified by collector clock skew, and `capture_time` is stamped by the same process that reads it, so it cannot happen. The real reason is that an unanchored read must not change, which is what the comment now says. Co-Authored-By: Claude Opus 5 --- .../Darling.Tests/AsOfWindowAnchorTests.cs | 14 +++++ .../Darling.Tests/DarlingCustomViewsTests.cs | 8 +-- .../DarlingMcpBlockingToolsTests.cs | 55 +++++++++++++------ .../Darling.Tests/DarlingMcpDataToolsTests.cs | 55 +++++++++++++------ .../Mcp/DarlingMcpConfigHistoryTools.cs | 19 ++++--- Lite.Tests/AsOfWindowAnchorTests.cs | 5 ++ PerformanceMonitor.Common/Mcp/McpHelpers.cs | 30 +++++++++- 7 files changed, 139 insertions(+), 47 deletions(-) diff --git a/Darling/Darling.Tests/AsOfWindowAnchorTests.cs b/Darling/Darling.Tests/AsOfWindowAnchorTests.cs index 14038d62ed..f74d1cb9df 100644 --- a/Darling/Darling.Tests/AsOfWindowAnchorTests.cs +++ b/Darling/Darling.Tests/AsOfWindowAnchorTests.cs @@ -65,6 +65,9 @@ public void BlankAnchor_IsTreatedAsAbsent_NotAsAParseFailure() [InlineData("2026-08-18T14:30:00")] [InlineData("2026-08-18T16:30:00+02:00")] [InlineData("2026-08-18T09:30:00-05:00")] + [InlineData("2026-08-18T14:30Z")] + [InlineData("2026-08-18T14:30:00.000Z")] + [InlineData(" 2026-08-18T14:30:00Z ")] public void EveryAcceptedSpelling_ResolvesToTheSameUtcInstant(string asOf) { Assert.Null(McpHelpers.ResolveAsOf(asOf, out var end)); @@ -83,11 +86,22 @@ public void DateOnly_IsMidnightUtc() /// An anchor we cannot use is REFUSED, following . Silently falling /// back to now is the one outcome this parameter exists to prevent: a read answering "the last 4 hours" /// when it was asked for "the 4 hours ending Tuesday 03:00" is indistinguishable from a correct answer. + /// + /// The slash forms are the interesting half, and the reason the parser is an ISO-8601 ALLOWLIST + /// rather than a general date parse (review catch): a plain DateTime.TryParse under the invariant + /// culture accepts 01/02/2026 as M/d/yyyy, so a caller who meant 1 February gets a window + /// around 2 January and nothing anywhere says so. That is the same defect one step removed — an answer to + /// a question nobody asked — so it is refused rather than guessed at. /// [Theory] [InlineData("last tuesday")] [InlineData("2026-13-45")] [InlineData("4 hours ago")] + [InlineData("08/18/2026")] + [InlineData("01/02/2026")] + [InlineData("2026/08/18")] + [InlineData("18 Aug 2026")] + [InlineData("2026-08-18 14:30:00")] public void AnUnusableAnchor_IsRefused_NotSilentlyTreatedAsNow(string asOf) { var error = McpHelpers.ResolveAsOf(asOf, out _); diff --git a/Darling/Darling.Tests/DarlingCustomViewsTests.cs b/Darling/Darling.Tests/DarlingCustomViewsTests.cs index e97dabea9b..78a267cf0f 100644 --- a/Darling/Darling.Tests/DarlingCustomViewsTests.cs +++ b/Darling/Darling.Tests/DarlingCustomViewsTests.cs @@ -188,11 +188,11 @@ public void Catalog_ParamsNameTheActualWireQueryKeys_NotCSharpParamNames() { /* Builder 2 binds these keys onto ?query=strings, so they MUST be the literal keys the dispatch lambdas read — the whole reason the metadata is hand-authored (reflection would emit the C# param names). */ - AssertParamKeys("get_query_store_top", "server", "hours", "top", "database_name"); - AssertParamKeys("get_wait_stats", "server", "hours", "limit"); - AssertParamKeys("get_wait_trend", "wait_type", "server", "hours"); + AssertParamKeys("get_query_store_top", "server", "hours", "top", "database_name", "as_of"); + AssertParamKeys("get_wait_stats", "server", "hours", "limit", "as_of"); + AssertParamKeys("get_wait_trend", "wait_type", "server", "hours", "as_of"); AssertParamKeys("get_plan_xml", "query_hash", "server", "database_name"); - AssertParamKeys("get_top_queries_by_cpu", "server", "hours", "top", "database_name", "parallel_only", "min_dop"); + AssertParamKeys("get_top_queries_by_cpu", "server", "hours", "top", "database_name", "parallel_only", "min_dop", "as_of"); AssertParamKeys("compare_analysis", "server", "hours", "baseline_hours_back"); AssertParamKeys("get_fleet_overview", "hours"); AssertParamKeys("list_servers"); diff --git a/Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs b/Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs index 5137ffee49..d288c55f38 100644 --- a/Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs @@ -75,17 +75,21 @@ private static (string Name, bool Optional)[] McpParams(string toolName) } /// - /// Lite's parameter contract, pinned as a PREFIX rather than as the whole list (#2159). + /// Lite's parameter contract, pinned as an ORDERED SUBSEQUENCE of Darling's (#2159, widened by #2495). /// /// This used to assert the full parameter list, which was the same thing until Darling's incident - /// readers gained a trailing optional dedup_key that Lite does not have. The guarantee that actually - /// matters was never "the lists are identical" — it is that a client written against LITE's contract still - /// calls Darling correctly. A trailing optional parameter preserves exactly that, positionally and by name, - /// so the prefix is the honest form of the assertion and the full-list form was over-tight. + /// readers gained a trailing optional dedup_key that Lite does not have; it then asserted a PREFIX, + /// which held only while every parameter Lite gained after that landed before dedup_key. #2495 + /// appended as_of to BOTH SKUs — last on each, the same convention dedup_key followed — so + /// Lite's list is no longer a prefix of Darling's: Darling reads …, limit, dedup_key, as_of while + /// Lite reads …, limit, as_of. /// - /// Anything appended must therefore stay optional AND stay at the end. StartsWith semantics are - /// spelled out by comparing the first N rather than by trusting a substring of a joined string, so a - /// REORDERING that keeps the same names still fails. + /// The guarantee that actually matters was never "the lists are identical", and it is not positional + /// either: MCP invokes by NAME, and the only C# call sites (the /api/read dispatch) do not pass + /// Darling's extra optionals at all. It is that a client written against Lite's contract still calls + /// Darling correctly — every Lite name present in Lite's relative order, and every Darling-only extra + /// OPTIONAL so the client never has to supply one. A dropped parameter, a REORDERING, or a required + /// Darling-only addition each still fail. /// [Theory] [InlineData("get_blocking", "server_name,hours_back,limit,as_of")] @@ -97,30 +101,47 @@ private static (string Name, bool Optional)[] McpParams(string toolName) public void ParamContract_MatchesLite(string toolName, string expectedCsv) { var expected = expectedCsv.Split(','); - var actual = McpParams(toolName).Select(p => p.Name).ToArray(); + var actual = McpParams(toolName); + var actualNames = actual.Select(p => p.Name).ToArray(); Assert.True(actual.Length >= expected.Length, - $"{toolName} dropped a parameter Lite has: [{string.Join(",", actual)}]"); - Assert.Equal(expected, actual.Take(expected.Length).ToArray()); + $"{toolName} dropped a parameter Lite has: [{string.Join(",", actualNames)}]"); + + /* Every Lite name, in Lite's order, with Darling's own additions filtered out. */ + Assert.Equal(expected, actualNames.Where(n => expected.Contains(n, StringComparer.Ordinal)).ToArray()); + + /* And nothing Darling adds on top may be required, or a Lite-shaped call could not be made at all. */ + foreach (var extra in actual.Where(p => !expected.Contains(p.Name, StringComparer.Ordinal))) + Assert.True(extra.Optional, $"{toolName}.{extra.Name} is Darling-only and must be optional"); } /// - /// #2159's dedup_key, pinned as a TRAILING OPTIONAL parameter on exactly the three incident readers + /// #2159's dedup_key, pinned as an APPENDED OPTIONAL parameter on exactly the three incident readers /// that can resolve a fingerprint — and pinned as absent everywhere else. /// - /// Optional and trailing is what keeps true, so it is asserted - /// here rather than left to review. Absent on the trend tools because a per-minute count series has no single + /// It was pinned as the LAST parameter until #2495 appended as_of to both SKUs behind it. + /// Moving dedup_key to keep it last would have relocated an already-shipped parameter to preserve a + /// property (position) that no caller of an MCP tool can observe, so what is pinned now is what the + /// property was always standing in for, and what keeps true: + /// dedup_key is optional, and it sits AFTER every parameter Lite has, so a Lite-shaped call never + /// meets it. Absent on the trend tools because a per-minute count series has no single /// incident to resolve to, and absent on get_blocked_process_xml because it is reached FROM an incident /// the operator has already identified rather than used to find one. /// [Fact] - public void ParamContract_DedupKeyIsATrailingOptionalOnTheIncidentReaders() + public void ParamContract_DedupKeyIsAnAppendedOptionalOnTheIncidentReaders() { foreach (var tool in new[] { "get_blocking", "get_deadlocks", "get_deadlock_detail" }) { var ps = McpParams(tool); - Assert.Equal("dedup_key", ps[^1].Name); - Assert.True(ps[^1].Optional, $"{tool}.dedup_key must be optional so Lite-shaped calls still work"); + var names = ps.Select(p => p.Name).ToArray(); + + Assert.Contains("dedup_key", names); + Assert.True(ps.Single(p => p.Name == "dedup_key").Optional, + $"{tool}.dedup_key must be optional so Lite-shaped calls still work"); + Assert.True( + Array.IndexOf(names, "dedup_key") > Array.IndexOf(names, "limit"), + $"{tool}.dedup_key must sit after every parameter Lite has, so a Lite-shaped call never meets it"); } foreach (var tool in new[] { "get_blocking_trend", "get_deadlock_trend", "get_blocked_process_xml" }) diff --git a/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs b/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs index 3889dc5bb9..3ce8fea11a 100644 --- a/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs @@ -103,14 +103,21 @@ private static (string Name, bool Optional)[] McpParams(string toolName) } /// - /// Lite's parameter contract, pinned as a PREFIX rather than as the whole list (#2235). + /// Lite's parameter contract, pinned as an ORDERED SUBSEQUENCE of Darling's (#2235, widened by #2495). /// /// This asserted the full list, which was the same thing until get_top_queries_by_cpu gained a - /// trailing optional group_by that Lite does not have. The guarantee that matters was never "the - /// lists are identical" — it is that a client written against LITE's contract still calls Darling - /// correctly, which a trailing optional parameter preserves both positionally and by name. Anything - /// appended must therefore stay optional AND stay last; a REORDERING that keeps the same names still - /// fails, because the prefix is compared element-wise rather than as a joined substring. + /// trailing optional group_by that Lite does not have; it then asserted a PREFIX, which held only + /// while every parameter Lite gained after that landed before group_by. #2495 appended as_of + /// to BOTH SKUs — last on each, which is the convention group_by itself established — so Lite's + /// list is no longer a prefix of Darling's: Darling reads …, min_dop, group_by, as_of while Lite + /// reads …, min_dop, as_of. + /// + /// The guarantee that matters was never "the lists are identical", and it is not positional either: + /// MCP invokes by NAME, and the only C# call sites (the /api/read dispatch) do not pass Darling's + /// extra optionals at all. It is that a client written against Lite's contract still calls Darling + /// correctly, which needs exactly two things — every Lite name present in Lite's relative order, and + /// every Darling-only extra OPTIONAL so the client never has to supply one. Both are asserted here, so a + /// dropped parameter, a REORDERING, or a required Darling-only addition all still fail. /// [Theory] [InlineData("get_cpu_utilization", "server_name,hours_back,as_of")] @@ -130,29 +137,45 @@ private static (string Name, bool Optional)[] McpParams(string toolName) public void ParamContract_MatchesLite(string toolName, string expectedCsv) { var expected = expectedCsv.Split(','); - var actual = McpParams(toolName).Select(p => p.Name).ToArray(); + var actual = McpParams(toolName); + var actualNames = actual.Select(p => p.Name).ToArray(); Assert.True(actual.Length >= expected.Length, - $"{toolName} dropped a parameter Lite has: [{string.Join(",", actual)}]"); - Assert.Equal(expected, actual.Take(expected.Length).ToArray()); + $"{toolName} dropped a parameter Lite has: [{string.Join(",", actualNames)}]"); + + /* Every Lite name, in Lite's order, with Darling's own additions filtered out. */ + Assert.Equal(expected, actualNames.Where(n => expected.Contains(n, StringComparer.Ordinal)).ToArray()); + + /* And nothing Darling adds on top may be required, or a Lite-shaped call could not be made at all. */ + foreach (var extra in actual.Where(p => !expected.Contains(p.Name, StringComparer.Ordinal))) + Assert.True(extra.Optional, $"{toolName}.{extra.Name} is Darling-only and must be optional"); } /// - /// #2235's group_by, pinned as a TRAILING OPTIONAL on get_top_queries_by_cpu and absent from + /// #2235's group_by, pinned as an APPENDED OPTIONAL on get_top_queries_by_cpu and absent from /// its siblings. /// - /// Trailing and optional is what keeps true, so it is asserted - /// rather than left to review. Absent on get_top_procedures_by_cpu because procedure stats are + /// It was pinned as the LAST parameter until #2495 appended as_of to both SKUs behind it. + /// Moving group_by to keep it last would have relocated an already-shipped parameter to preserve a + /// property (position) that no caller of an MCP tool can observe, so what is pinned now is what the + /// property was always standing in for: group_by is optional, and it sits AFTER every parameter Lite + /// has, so a Lite-shaped call never meets it. Absent on get_top_procedures_by_cpu because procedure stats are /// ALREADY keyed on the object — the rollup would be a no-op there — and absent on /// get_query_store_top because Query Store keys on query_id, which does not fragment the way a /// shape hash does, so the same option would imply a grouping that surface cannot perform. /// [Fact] - public void ParamContract_GroupByIsATrailingOptionalOnTopQueriesOnly() + public void ParamContract_GroupByIsAnAppendedOptionalOnTopQueriesOnly() { - var ps = McpParams("get_top_queries_by_cpu"); - Assert.Equal("group_by", ps[^1].Name); - Assert.True(ps[^1].Optional, "group_by must be optional so Lite-shaped calls still work"); + var names = McpParams("get_top_queries_by_cpu").Select(p => p.Name).ToArray(); + + Assert.Contains("group_by", names); + Assert.True( + McpParams("get_top_queries_by_cpu").Single(p => p.Name == "group_by").Optional, + "group_by must be optional so Lite-shaped calls still work"); + Assert.True( + Array.IndexOf(names, "group_by") > Array.IndexOf(names, "min_dop"), + "group_by must sit after every parameter Lite has, so a Lite-shaped call never meets it"); foreach (var tool in new[] { "get_top_procedures_by_cpu", "get_query_store_top" }) Assert.DoesNotContain("group_by", McpParams(tool).Select(p => p.Name)); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs index b3fa72ea34..8aca0139d3 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs @@ -58,8 +58,9 @@ public static async Task GetServerConfigChanges( var windowEndNaive = NaiveUtc(windowEnd); var windowStart = windowEndNaive.AddHours(-hours_back); var snapshots = await DarlingConfigHistoryReader.GetServerConfigSnapshotsAsync(postgres, resolved.ServerId); - /* The tool reads the full unbounded history and only lower-bounds, so pass DateTime.MaxValue as the - (no-op) upper edge — the shared both-edges diff then reproduces the prior behavior exactly. */ + /* Unanchored, the tool still reads the full history and only lower-bounds — see UpperEdge, which + keeps DateTime.MaxValue as the (no-op) upper edge so the shared both-edges diff reproduces the + prior behaviour exactly. An as_of anchor is what closes the upper edge. */ var changes = ConfigChangeDiff.DiffServerConfigChanges(snapshots, windowStart, UpperEdge(as_of, windowEndNaive)); if (changes.Count == 0) return NoChanges(resolved.ServerName, hours_back, DistinctCaptures(snapshots.Select(s => s.CaptureTime))); @@ -311,11 +312,15 @@ private static string Scope(bool? isGlobal, bool? isSession) => private static DateTime NaiveUtc(DateTime utc) => DateTime.SpecifyKind(utc, DateTimeKind.Unspecified); /// - /// The diff's upper edge. With no as_of the read is still open-ended - /// () — byte-for-byte the pre-#2495 behaviour, and deliberately not - /// "now": these snapshots are stamped by the collector, so a capture whose clock ran a little ahead of - /// the service's would drop out of a window it belongs in. An anchored read bounds at the anchor, - /// because that is the whole point of sending one. + /// The diff's upper edge. With no as_of the read stays open-ended () + /// — byte-for-byte the pre-#2495 behaviour, which is the whole compatibility contract of that change; an + /// anchored read bounds at the anchor, because that is the point of sending one. + /// + /// Lite's twin computes now for the same unanchored case rather than an open edge, and the + /// two are NOT reconciled here on purpose. capture_time is stamped DateTime.UtcNow by the + /// collector in the same process that later reads it, so a snapshot can never carry a timestamp after + /// the read's own now — the two edges cannot select different rows, and unifying them would be an + /// unrelated behaviour change to two shipped reads inside a change that promises not to make one. /// private static DateTime UpperEdge(string? asOf, DateTime anchorNaiveUtc) => string.IsNullOrWhiteSpace(asOf) ? DateTime.MaxValue : anchorNaiveUtc; diff --git a/Lite.Tests/AsOfWindowAnchorTests.cs b/Lite.Tests/AsOfWindowAnchorTests.cs index 4555514d20..6a5e60fddc 100644 --- a/Lite.Tests/AsOfWindowAnchorTests.cs +++ b/Lite.Tests/AsOfWindowAnchorTests.cs @@ -159,6 +159,11 @@ public async Task AnUnusableAnchor_IsRefused_RatherThanAnsweredAsOfNow() Assert.StartsWith("Invalid as_of", garbage, StringComparison.Ordinal); Assert.DoesNotContain(RecentWait, garbage, StringComparison.Ordinal); + /* The one a general date parser would let through: "01/02/2026" is M/d/yyyy under the invariant + culture, so a caller who meant 1 February would silently get a window around 2 January. */ + var ambiguous = await McpWaitTools.GetWaitStats(_dataService, _serverManager, "TestServer", 4, 20, "01/02/2026"); + Assert.StartsWith("Invalid as_of", ambiguous, StringComparison.Ordinal); + var future = await McpWaitTools.GetWaitStats( _dataService, _serverManager, "TestServer", 4, 20, DateTime.UtcNow.AddDays(1).ToString("o")); Assert.Contains("future", future, StringComparison.Ordinal); diff --git a/PerformanceMonitor.Common/Mcp/McpHelpers.cs b/PerformanceMonitor.Common/Mcp/McpHelpers.cs index 5b4b88d730..74c5926bba 100644 --- a/PerformanceMonitor.Common/Mcp/McpHelpers.cs +++ b/PerformanceMonitor.Common/Mcp/McpHelpers.cs @@ -114,6 +114,28 @@ internal static class McpHelpers /// public static readonly TimeSpan AsOfFutureTolerance = TimeSpan.FromMinutes(5); + /// + /// The ONLY spellings as_of accepts — an explicit allowlist rather than a general date parse. + /// + /// would be + /// the obvious choice and is the wrong one: under the invariant culture it also accepts 08/18/2026 + /// as M/d/yyyy, so 01/02/2026 silently resolves to 2 January for a caller who meant 1 + /// February. That is the failure this parameter exists to remove — an answer to a subtly different + /// question, with nothing to say so — one step removed from the silent fall back to "now" that IS refused. + /// An allowlist makes the parser agree with the documented contract instead of exceeding it. + /// + /// K matches an empty offset, a Z, and a ±HH:mm, which is what lets one format + /// cover all three documented shapes. A space in place of the T is deliberately NOT accepted: the + /// refusal names the forms that work, which is more useful than guessing at a fifth one. + /// + private static readonly string[] AsOfFormats = + { + "yyyy-MM-dd", + "yyyy-MM-ddTHH:mmK", + "yyyy-MM-ddTHH:mm:ssK", + "yyyy-MM-ddTHH:mm:ss.FFFFFFFK", + }; + /// /// Resolves the END of a windowed read from the optional as_of anchor: /// receives when the caller sent nothing (the pre-#2495 behaviour, exactly), @@ -147,13 +169,15 @@ internal static class McpHelpers local time — the store is UTC throughout, the caller is an agent on some other machine, and a silent local-time reading would shift the window by the host's offset with nothing to show for it. AdjustToUniversal then normalises an explicit offset ("...+02:00") into the same UTC instant. */ - if (!DateTime.TryParse( - asOf, + if (!DateTime.TryParseExact( + asOf.Trim(), + AsOfFormats, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)) { - return $"Invalid as_of value '{asOf}'. Expected an ISO-8601 UTC instant, e.g. '2026-08-18T14:30:00Z' or '2026-08-18'."; + return $"Invalid as_of value '{asOf}'. Expected an ISO-8601 UTC instant: '2026-08-18T14:30:00Z', " + + "'2026-08-18T14:30:00' (read as UTC), '2026-08-18T16:30:00+02:00', or '2026-08-18' for midnight UTC."; } if (parsed > now + AsOfFutureTolerance) From 042175fbeee72c3765403ad1af816fd11d382e5a Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:51:29 +0100 Subject: [PATCH 03/10] Make the anchor pins fail loudly when reflection comes up empty (#2495) Both catalog pins are of the form "no read is in set A but not set B", so a lookup that quietly returned false for a read it could not FIND would empty the failing set and make them pass for the wrong reason -- the exact shape of a coverage check that converts an open question into false confidence. The tool method is now asserted to exist rather than defaulted, and a partially loadable service assembly is reported rather than silently enumerated as whichever types happened to load. Co-Authored-By: Claude Opus 5 --- .../Darling.Tests/AsOfWindowAnchorTests.cs | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/Darling/Darling.Tests/AsOfWindowAnchorTests.cs b/Darling/Darling.Tests/AsOfWindowAnchorTests.cs index f74d1cb9df..405f45a0a3 100644 --- a/Darling/Darling.Tests/AsOfWindowAnchorTests.cs +++ b/Darling/Darling.Tests/AsOfWindowAnchorTests.cs @@ -245,15 +245,37 @@ get_store_metrics windows in days over the store's own growth series. */ string.Join(", ", unanchored)); } + /// + /// Whether the [McpServerTool] method behind a read name takes the anchor. + /// + /// Both pins above are of the form "no read is in set A but not set B", so a lookup that quietly + /// returned false for a read it could not FIND would empty the failing set and make them pass for the + /// wrong reason. The method is therefore asserted to exist rather than defaulted, and a partially + /// loadable assembly is reported rather than silently enumerated as the types that happened to load. + /// private static bool ToolTakesAnAnchor(string readName) { - var method = typeof(DarlingMcpDataTools).Assembly.GetTypes() + Type[] types; + try + { + types = typeof(DarlingMcpDataTools).Assembly.GetTypes(); + } + catch (System.Reflection.ReflectionTypeLoadException ex) + { + Assert.Fail( + "the service assembly did not fully load, so a missing tool would look like a passing pin: " + + string.Join("; ", ex.LoaderExceptions.Where(e => e is not null).Select(e => e!.Message).Distinct())); + return false; + } + + var method = types .SelectMany(t => t.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)) .FirstOrDefault(m => m.GetCustomAttributes(typeof(ModelContextProtocol.Server.McpServerToolAttribute), false) .Cast() .Any(a => a.Name == readName)); - return method is not null && method.GetParameters().Any(p => p.Name == "as_of"); + Assert.True(method is not null, $"no [McpServerTool] method is named '{readName}', which the read dispatch serves"); + return method!.GetParameters().Any(p => p.Name == "as_of"); } } From 18836f596e726409981cf039bb058d6792d590c2 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:56:18 +0100 Subject: [PATCH 04/10] Correct the read counts the rebase moved (#2495) Rebasing onto dev picked up #2497's get_blocking_stats, which is a windowed read and took the anchor under the same rule as everything else. The changelog still said 52/45. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c08168662..a6697d2a23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- **Every windowed read can be anchored at a past incident, not just at now** ([#2495]) - every MCP read that takes `hours_back` measured it backwards from *now*, so no caller could ask what a server was doing during last Tuesday's incident. Widening `hours_back` until Tuesday falls inside is not the same question: for an aggregate read a wider window is a **different answer**, not the same answer with more rows - it changes what a top-N returns, what an average is taken over, and how much a capped read truncates. **`as_of`** is now an optional ISO-8601 UTC anchor on **52 Darling reads and 45 Lite reads** (identical name, semantics and description on both SKUs, from one shared constant), moving the window's END off now while `hours_back` stays its LENGTH - so "the four hours around 03:00 last Tuesday" is one call instead of a 170-hour window filtered by hand. It reaches the web surface too: `/api/read/{name}` is machine-derived from the MCP catalog, so each read's descriptor carries `as_of` and a test pins the two surfaces together in both directions. **Backward compatible by construction** - the anchor defaults to now, so a caller sending only `hours_back` gets byte-for-byte the window it always got, and `ValidateWindow` returns exactly what `ValidateHoursBack` returned when no anchor is sent. Following the `ValidateTop` convention, an anchor we cannot use is **refused** rather than quietly replaced: an unparseable value, or one in the future (past a five-minute client-clock allowance), comes back as a message, because a read that silently reverts to now is indistinguishable from a correct one. An anchor older than anything the store still holds is **not** refused - retention is per-deployment, per-server and per-collector, so a hardcoded floor would be a guess; it returns the read's own `empty` / `unavailable` status, which is unambiguous because the caller chose the window. Three reads that bounded only the LOWER edge (`get_cpu_utilization`, `get_tempdb_trend`, `get_alert_history`) now bound both, which for alert history is a correctness fix as well: its cap is applied by the database, so trimming after the read would have spent the whole `LIMIT` on rows newer than the anchor. Left unanchored on purpose, and stated in the instructions: the analysis family (`analyze_server`, `get_analysis_facts`, `compare_analysis`, `get_analysis_findings`) windows inside the analysis engine rather than at the read, and `get_pvs_stats` / `get_fleet_overview` mix a latest-snapshot measurement with a windowed one, where anchoring only the windowed half would return a result whose two halves describe different instants. +- **Every windowed read can be anchored at a past incident, not just at now** ([#2495]) - every MCP read that takes `hours_back` measured it backwards from *now*, so no caller could ask what a server was doing during last Tuesday's incident. Widening `hours_back` until Tuesday falls inside is not the same question: for an aggregate read a wider window is a **different answer**, not the same answer with more rows - it changes what a top-N returns, what an average is taken over, and how much a capped read truncates. **`as_of`** is now an optional ISO-8601 UTC anchor on **53 Darling reads and 46 Lite reads** (identical name, semantics and description on both SKUs, from one shared constant), moving the window's END off now while `hours_back` stays its LENGTH - so "the four hours around 03:00 last Tuesday" is one call instead of a 170-hour window filtered by hand. It reaches the web surface too: `/api/read/{name}` is machine-derived from the MCP catalog, so each read's descriptor carries `as_of` and a test pins the two surfaces together in both directions. **Backward compatible by construction** - the anchor defaults to now, so a caller sending only `hours_back` gets byte-for-byte the window it always got, and `ValidateWindow` returns exactly what `ValidateHoursBack` returned when no anchor is sent. Following the `ValidateTop` convention, an anchor we cannot use is **refused** rather than quietly replaced: an unparseable value, or one in the future (past a five-minute client-clock allowance), comes back as a message, because a read that silently reverts to now is indistinguishable from a correct one. An anchor older than anything the store still holds is **not** refused - retention is per-deployment, per-server and per-collector, so a hardcoded floor would be a guess; it returns the read's own `empty` / `unavailable` status, which is unambiguous because the caller chose the window. Three reads that bounded only the LOWER edge (`get_cpu_utilization`, `get_tempdb_trend`, `get_alert_history`) now bound both, which for alert history is a correctness fix as well: its cap is applied by the database, so trimming after the read would have spent the whole `LIMIT` on rows newer than the anchor. Left unanchored on purpose, and stated in the instructions: the analysis family (`analyze_server`, `get_analysis_facts`, `compare_analysis`, `get_analysis_findings`) windows inside the analysis engine rather than at the read, and `get_pvs_stats` / `get_fleet_overview` mix a latest-snapshot measurement with a windowed one, where anchoring only the windowed half would return a result whose two halves describe different instants. - **Five ready-made dashboards, so a new user's Custom Views page is not an empty one** ([#2480]) - no custom view ships seeded, so the first thing a new user meets at `#/views` is a blank page and a blank canvas over an 82-read catalog. The first-run hero already knew what would help and rendered it as three **inert** chips - "Top waits by server", "CPU trend over time", "Slowest procedures by database", no handler, no href - which is the [#2437] defect shape (a promise rendered as a caption) on the one page nobody arrives at with context. Those chips are now the real templates: **Server health at a glance**, **CPU investigation**, **Blocking and deadlocks**, **Memory pressure** and **Configuration review**, each created in one click against a server picked beside them, and they sit in the existing "New from template" menu alongside the notebook seeds ([#1563] D7) rather than in a second affordance. **Templates, not seeded rows**: seeding would need a migration rung, a `StorageVersion` bump, four pinned test files and a Viewer probe sentinel for content that is not schema - and a seeded row RESURRECTS itself on the next upgrade after the user deletes it, with no reset path if an editing seat breaks one. A template is created only when asked for, and what lands is an ordinary view the user owns, edits and can delete for good. The two halves of the menu behave differently on purpose: a notebook template links to the composer pre-filled because its composed panels re-scope live from the notebook's own controls, while a dashboard template is v1 READ panels whose `server` param is **static** (`renderView` threads variables and range into composed panels only), so it is created against the server chosen in the menu - and the server name goes into the view NAME, so two servers' copies of one template do not collide on the unique-name constraint. **Honest on day one**, which for a starter dashboard is the whole game - it is the first screen a UAT tester opens, on the store with the least data it will ever have. Every table and chart panel carries its own empty-state sentence (a count pin, not a spot-check), and the two reads that CANNOT be honest on a fresh install are deliberately absent rather than merely unused: analysis findings (the pass writes nothing for 24 hours) and Query Store (a target with it off has nothing, ever). The chart case is the one that bites without looking like it - `get_blocking_trend` and `get_deadlock_trend` answer an IDLE server with `trend: []` and no `{status,message}` envelope, so those panels say an empty trend means none happened rather than inheriting a sentence about collection. Every empty and failure state is stated rather than blank: a fleet with no servers says why the dashboards are unavailable instead of rendering an empty picker, and a refused create surfaces the backend's own message verbatim (with a 409 getting its own sentence, since a second click of the same template is the likely failure) because flattening it to "could not create" would hide the one line that says which panel is wrong. Pinned by the same invariant the built-in pages carry - every read exists in the shipped dispatch, every parameter key is one its read binds, every viz is in the shipped vocabulary - and each of the five definitions was fed to the service's own `ValidateDefinition`, the authority that would refuse the POST. - **The web dashboard's server page gets the viewer's tabs: twelve sections, 61 of the 82 served reads, and a time range** ([#2475]) - the service dispatched **82** reads at `GET /api/read/{name}` and the built-in pages reached **23** of them; `#/server/{name}` was one scroll of seven panels against the desktop viewer's **65** `TabItem`s. The gap was never backend work - `panels.js` has been a generic renderer over those reads since #1562 - so this is descriptors over the unchanged `renderPanel` seam: twelve sub-tabs (Overview, Wait Stats, CPU, Memory, Blocking, File I/O, Queries, Configuration, Config Changes, Activity, System Events, Collection Health) carrying ~70 panels, reaching **61** reads. The header carries the WHY beneath the band badge - `Warning` has three unrelated causes (a real metric breach, a server awaiting its first collection, a collector error), so a badge reading "Warning" with no way to ask why is [#2422] rebuilt on a new surface; the fleet's own reason string and the fleet card's severity chips are rendered there, the chips through `fleet.js`'s own `metricBands` so there is one implementation rather than two, and neither is re-derived in the browser (R1). Every web grid that renders query text now puts it immediately right of its time/identity anchor ([#1949]) and the pin that enforced that on one array enforces it on all seven. The tab id rides in the hash (`#/server/{name}/{tab}`) so a section is deep-linkable and survives the 60s refresh, and an unknown or absent id resolves to Overview, which is what keeps every existing `#/server/{name}` link working. A page-level range picker (1h/4h/12h/24h/7d/30d) is the twin of the viewer's toolbar presets - **not** persisted, because a page that reopens on a 30-day window is slow for a reason the reader cannot see; panels whose read takes no window at all say "latest snapshot" rather than inheriting a label that would misdescribe them. Two reads were previously **unreachable from a browser** because they require a parameter no UI collected - `get_wait_trend` needs a `wait_type` and `get_perfmon_trend` a `counter_name` - and both now have a picker, the wait one seeded from the rows of the table directly above it so the picker and the table cannot disagree. **No fifth viz kind was added**: the property-grid shape that tempted one is served by `stat` (the reads returning a flat object) and `table` (the ones already returning rows), and a fifth kind that lived only in `panels.js` would be a page-only special case, while doing it properly means `KnownVizList`, `derive.js` and an editor config arm - composer surface this change does not need. What the browser genuinely cannot do is **stated in the tab where a reader goes looking for it** rather than left as a page that quietly lacks a feature: plan analysis, the query heatmap, cached-plan retrieval and actual-plan re-execution need a plan renderer and a command back to the monitored server, and the block-chain view and interactive deadlock graph need a graph viewer - the Blocking tab hands over the captured blocked-process-report and deadlock-graph XML verbatim instead of pretending. Every data panel supplies its own empty-state sentence and both helpers THROW without one. vizTable's generic "No rows in this window" reads as a fault on a collector that is off, opt-in, or daily; and the chart case was worse - `get_blocking_trend` and `get_deadlock_trend` answer an IDLE server with `trend: []` and no `{status,message}` envelope at all, so a perfectly healthy server was told its blocking chart did not have "enough data points to chart yet". `vizLine` now renders a descriptor's `emptyText` at exactly ZERO rows and still falls through at one (where the chart's own sentence is the true one) and when no `emptyText` was authored, so every stored view predating this is unchanged. A read feeding several panels on one tab is fetched ONCE (`fanout`) rather than per descriptor - `readTool` has no cache, so `get_collection_health`, which rolls up seven days of collector logs and computes sweep pressure, was running three times to open its own tab; six such duplicates existed across five tabs and a pin now refuses a seventh. Guarded by an invariant rather than by spot-checks: every read name the module mentions must exist in the shipped dispatch, every parameter key must be one its read actually binds (an unknown query key is silently ignored, so `limit` sent to a read binding `top` quietly returns the default), every viz must be in the shipped vocabulary, and no `get_pg_*` read may appear until the fleet payload can tell a PostgreSQL target from a SQL Server one - it carries `engine_edition`, not a `CollectorTargetEngine`, so a PostgreSQL panel today would render on all 42 SQL Servers, permanently empty. From a9f333d2febb621fbd2aab4f525e13fa1aa5bb3c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:57:43 +0100 Subject: [PATCH 05/10] Say in the README that a windowed tool can be anchored (#2495) The MCP section told readers most tools take server_name and hours_back, which was the whole story until as_of existed. A parameter nobody knows about is one nobody sends. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b2691e6eb3..2e13f13d80 100644 --- a/README.md +++ b/README.md @@ -384,7 +384,7 @@ claude mcp add --transport http --scope user sql-monitor http://localhost:5151/ | Plan Analysis | `analyze_query_plan`, `analyze_procedure_plan`, `analyze_query_store_plan`, `analyze_plan_xml`, `get_plan_xml` | | Diagnostic Analysis | `analyze_server`, `get_analysis_facts`, `compare_analysis`, `audit_config`, `get_analysis_findings`, `mute_analysis_finding` | -Most tools accept optional `server_name` and `hours_back` parameters. If only one server is configured, `server_name` is auto-resolved. The MCP server binds to `localhost` only and does not accept remote connections. (Darling adds windowed-trend and fleet-overview tools plus agent-driven write tools — Custom Views authoring, alert-settings and mute-rule tuning, and bulk add/remove servers — and supports an opt-in LAN endpoint — see [Darling/README.md](Darling/README.md).) +Most tools accept optional `server_name` and `hours_back` parameters. If only one server is configured, `server_name` is auto-resolved. Every tool that takes `hours_back` also takes an optional `as_of` — an ISO-8601 UTC instant that moves the END of the window off "now", so a past incident is one call (`as_of` its end, `hours_back` its length) rather than a very wide window filtered by hand. The MCP server binds to `localhost` only and does not accept remote connections. (Darling adds windowed-trend and fleet-overview tools plus agent-driven write tools — Custom Views authoring, alert-settings and mute-rule tuning, and bulk add/remove servers — and supports an opt-in LAN endpoint — see [Darling/README.md](Darling/README.md).) --- From 83d9e41a3ffc9bb0ec07de09ffe6b8fcc98ec2b3 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:09:07 +0100 Subject: [PATCH 06/10] Say why three reads resolve the anchor without ValidateWindow (#2495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review asked, reasonably, why get_collection_log / get_current_waits_trend / get_blocking_stats call ResolveAsOf rather than the shared ValidateWindow that ~50 sibling reads now use, leaving hours_back unvalidated on exactly three tools. Because the "free" swap is not free: those three have never capped hours_back — they Math.Abs() it — so ValidateWindow would impose the 168-hour ceiling every other read carries, and take reach away from the read whose whole premise is looking further back than the default. That is a functional regression on the tool this issue was reported against, inside a change that promises an unanchored call does not move. The reasoning now sits at all six sites, so the next reader gets it before the one-line "cleanup" rather than after. Co-Authored-By: Claude Opus 5 --- .../Mcp/DarlingMcpDataTools.cs | 15 +++++++++++++++ Lite/Mcp/McpHealthTools.cs | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index 907e59637c..b81098a95a 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -1101,6 +1101,11 @@ public static async Task GetCollectionLog( var invalidLimit = McpHelpers.ValidateTop(limit); if (invalidLimit != null) return invalidLimit; + /* ResolveAsOf here, deliberately NOT ValidateWindow. These three reads have never capped + hours_back -- they Math.Abs() it and window on the result -- so routing them through the + shared validator would impose the 168-hour ceiling every other read carries, and take reach + away from exactly the read whose premise is looking FURTHER back than the default. The anchor + is validated because it is new; the span keeps the behaviour callers already have. */ var anchorError = McpHelpers.ResolveAsOf(as_of, out var windowEnd); if (anchorError != null) return anchorError; @@ -1183,6 +1188,11 @@ public static async Task GetCurrentWaitsTrend( var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; + /* ResolveAsOf here, deliberately NOT ValidateWindow. These three reads have never capped + hours_back -- they Math.Abs() it and window on the result -- so routing them through the + shared validator would impose the 168-hour ceiling every other read carries, and take reach + away from exactly the read whose premise is looking FURTHER back than the default. The anchor + is validated because it is new; the span keeps the behaviour callers already have. */ var anchorError = McpHelpers.ResolveAsOf(as_of, out var windowEnd); if (anchorError != null) return anchorError; @@ -1252,6 +1262,11 @@ public static async Task GetBlockingStats( var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); if (error != null) return error; + /* ResolveAsOf here, deliberately NOT ValidateWindow. These three reads have never capped + hours_back -- they Math.Abs() it and window on the result -- so routing them through the + shared validator would impose the 168-hour ceiling every other read carries, and take reach + away from exactly the read whose premise is looking FURTHER back than the default. The anchor + is validated because it is new; the span keeps the behaviour callers already have. */ var anchorError = McpHelpers.ResolveAsOf(as_of, out var windowEnd); if (anchorError != null) return anchorError; diff --git a/Lite/Mcp/McpHealthTools.cs b/Lite/Mcp/McpHealthTools.cs index a2d27cfdb7..b6f103be7b 100644 --- a/Lite/Mcp/McpHealthTools.cs +++ b/Lite/Mcp/McpHealthTools.cs @@ -278,6 +278,11 @@ public static async Task GetCollectionLog( var invalidLimit = McpHelpers.ValidateTop(limit); if (invalidLimit != null) return invalidLimit; + /* ResolveAsOf here, deliberately NOT ValidateWindow. These three reads have never capped + hours_back -- they Math.Abs() it and window on the result -- so routing them through the + shared validator would impose the 168-hour ceiling every other read carries, and take reach + away from exactly the read whose premise is looking FURTHER back than the default. The anchor + is validated because it is new; the span keeps the behaviour callers already have. */ var anchorError = McpHelpers.ResolveAsOf(as_of, out var windowEnd); if (anchorError != null) return anchorError; @@ -356,6 +361,11 @@ public static async Task GetCurrentWaitsTrend( var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; + /* ResolveAsOf here, deliberately NOT ValidateWindow. These three reads have never capped + hours_back -- they Math.Abs() it and window on the result -- so routing them through the + shared validator would impose the 168-hour ceiling every other read carries, and take reach + away from exactly the read whose premise is looking FURTHER back than the default. The anchor + is validated because it is new; the span keeps the behaviour callers already have. */ var anchorError = McpHelpers.ResolveAsOf(as_of, out var windowEnd); if (anchorError != null) return anchorError; @@ -419,6 +429,11 @@ public static async Task GetBlockingStats( var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); if (error != null) return error; + /* ResolveAsOf here, deliberately NOT ValidateWindow. These three reads have never capped + hours_back -- they Math.Abs() it and window on the result -- so routing them through the + shared validator would impose the 168-hour ceiling every other read carries, and take reach + away from exactly the read whose premise is looking FURTHER back than the default. The anchor + is validated because it is new; the span keeps the behaviour callers already have. */ var anchorError = McpHelpers.ResolveAsOf(as_of, out var windowEnd); if (anchorError != null) return anchorError; From f5119c9e02c44ca55c5f197ddf60939387cc448b Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:52:46 +0100 Subject: [PATCH 07/10] Anchor the four reads #2505 added, and re-pin their contracts (#2495) Rebasing onto dev picked up get_query_store_regressions, get_procedure_duration_trend, get_query_store_duration_trend and get_health_parser_significant_waits. All four are windowed reads, so they take as_of under the same rule as the rest -- on both SKUs, through the Lite service methods behind them, and on the web catalog and dispatch. Two of them needed a decision rather than a substitution: - get_query_store_regressions hands its "what does zero regressions mean" branch to a private helper that probes for a BEFORE. The anchor is threaded into the helper rather than re-resolved there, because a window the probe computed for itself would be a different window than the one it is explaining. - Lite's get_blocking_trend already pins one instant for its two reads, for the reason its comment gives. That instant is now the anchor, so both reads move onto the past window together instead of one of them. Read counts are 57 Darling / 50 Lite. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs | 14 +++++++------- .../DarlingQueryStoreRegressionsTests.cs | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6697d2a23..ec9a608c43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- **Every windowed read can be anchored at a past incident, not just at now** ([#2495]) - every MCP read that takes `hours_back` measured it backwards from *now*, so no caller could ask what a server was doing during last Tuesday's incident. Widening `hours_back` until Tuesday falls inside is not the same question: for an aggregate read a wider window is a **different answer**, not the same answer with more rows - it changes what a top-N returns, what an average is taken over, and how much a capped read truncates. **`as_of`** is now an optional ISO-8601 UTC anchor on **53 Darling reads and 46 Lite reads** (identical name, semantics and description on both SKUs, from one shared constant), moving the window's END off now while `hours_back` stays its LENGTH - so "the four hours around 03:00 last Tuesday" is one call instead of a 170-hour window filtered by hand. It reaches the web surface too: `/api/read/{name}` is machine-derived from the MCP catalog, so each read's descriptor carries `as_of` and a test pins the two surfaces together in both directions. **Backward compatible by construction** - the anchor defaults to now, so a caller sending only `hours_back` gets byte-for-byte the window it always got, and `ValidateWindow` returns exactly what `ValidateHoursBack` returned when no anchor is sent. Following the `ValidateTop` convention, an anchor we cannot use is **refused** rather than quietly replaced: an unparseable value, or one in the future (past a five-minute client-clock allowance), comes back as a message, because a read that silently reverts to now is indistinguishable from a correct one. An anchor older than anything the store still holds is **not** refused - retention is per-deployment, per-server and per-collector, so a hardcoded floor would be a guess; it returns the read's own `empty` / `unavailable` status, which is unambiguous because the caller chose the window. Three reads that bounded only the LOWER edge (`get_cpu_utilization`, `get_tempdb_trend`, `get_alert_history`) now bound both, which for alert history is a correctness fix as well: its cap is applied by the database, so trimming after the read would have spent the whole `LIMIT` on rows newer than the anchor. Left unanchored on purpose, and stated in the instructions: the analysis family (`analyze_server`, `get_analysis_facts`, `compare_analysis`, `get_analysis_findings`) windows inside the analysis engine rather than at the read, and `get_pvs_stats` / `get_fleet_overview` mix a latest-snapshot measurement with a windowed one, where anchoring only the windowed half would return a result whose two halves describe different instants. +- **Every windowed read can be anchored at a past incident, not just at now** ([#2495]) - every MCP read that takes `hours_back` measured it backwards from *now*, so no caller could ask what a server was doing during last Tuesday's incident. Widening `hours_back` until Tuesday falls inside is not the same question: for an aggregate read a wider window is a **different answer**, not the same answer with more rows - it changes what a top-N returns, what an average is taken over, and how much a capped read truncates. **`as_of`** is now an optional ISO-8601 UTC anchor on **57 Darling reads and 50 Lite reads** (identical name, semantics and description on both SKUs, from one shared constant), moving the window's END off now while `hours_back` stays its LENGTH - so "the four hours around 03:00 last Tuesday" is one call instead of a 170-hour window filtered by hand. It reaches the web surface too: `/api/read/{name}` is machine-derived from the MCP catalog, so each read's descriptor carries `as_of` and a test pins the two surfaces together in both directions. **Backward compatible by construction** - the anchor defaults to now, so a caller sending only `hours_back` gets byte-for-byte the window it always got, and `ValidateWindow` returns exactly what `ValidateHoursBack` returned when no anchor is sent. Following the `ValidateTop` convention, an anchor we cannot use is **refused** rather than quietly replaced: an unparseable value, or one in the future (past a five-minute client-clock allowance), comes back as a message, because a read that silently reverts to now is indistinguishable from a correct one. An anchor older than anything the store still holds is **not** refused - retention is per-deployment, per-server and per-collector, so a hardcoded floor would be a guess; it returns the read's own `empty` / `unavailable` status, which is unambiguous because the caller chose the window. Three reads that bounded only the LOWER edge (`get_cpu_utilization`, `get_tempdb_trend`, `get_alert_history`) now bound both, which for alert history is a correctness fix as well: its cap is applied by the database, so trimming after the read would have spent the whole `LIMIT` on rows newer than the anchor. Left unanchored on purpose, and stated in the instructions: the analysis family (`analyze_server`, `get_analysis_facts`, `compare_analysis`, `get_analysis_findings`) windows inside the analysis engine rather than at the read, and `get_pvs_stats` / `get_fleet_overview` mix a latest-snapshot measurement with a windowed one, where anchoring only the windowed half would return a result whose two halves describe different instants. - **Five ready-made dashboards, so a new user's Custom Views page is not an empty one** ([#2480]) - no custom view ships seeded, so the first thing a new user meets at `#/views` is a blank page and a blank canvas over an 82-read catalog. The first-run hero already knew what would help and rendered it as three **inert** chips - "Top waits by server", "CPU trend over time", "Slowest procedures by database", no handler, no href - which is the [#2437] defect shape (a promise rendered as a caption) on the one page nobody arrives at with context. Those chips are now the real templates: **Server health at a glance**, **CPU investigation**, **Blocking and deadlocks**, **Memory pressure** and **Configuration review**, each created in one click against a server picked beside them, and they sit in the existing "New from template" menu alongside the notebook seeds ([#1563] D7) rather than in a second affordance. **Templates, not seeded rows**: seeding would need a migration rung, a `StorageVersion` bump, four pinned test files and a Viewer probe sentinel for content that is not schema - and a seeded row RESURRECTS itself on the next upgrade after the user deletes it, with no reset path if an editing seat breaks one. A template is created only when asked for, and what lands is an ordinary view the user owns, edits and can delete for good. The two halves of the menu behave differently on purpose: a notebook template links to the composer pre-filled because its composed panels re-scope live from the notebook's own controls, while a dashboard template is v1 READ panels whose `server` param is **static** (`renderView` threads variables and range into composed panels only), so it is created against the server chosen in the menu - and the server name goes into the view NAME, so two servers' copies of one template do not collide on the unique-name constraint. **Honest on day one**, which for a starter dashboard is the whole game - it is the first screen a UAT tester opens, on the store with the least data it will ever have. Every table and chart panel carries its own empty-state sentence (a count pin, not a spot-check), and the two reads that CANNOT be honest on a fresh install are deliberately absent rather than merely unused: analysis findings (the pass writes nothing for 24 hours) and Query Store (a target with it off has nothing, ever). The chart case is the one that bites without looking like it - `get_blocking_trend` and `get_deadlock_trend` answer an IDLE server with `trend: []` and no `{status,message}` envelope, so those panels say an empty trend means none happened rather than inheriting a sentence about collection. Every empty and failure state is stated rather than blank: a fleet with no servers says why the dashboards are unavailable instead of rendering an empty picker, and a refused create surfaces the backend's own message verbatim (with a 409 getting its own sentence, since a second click of the same template is the likely failure) because flattening it to "could not create" would hide the one line that says which panel is wrong. Pinned by the same invariant the built-in pages carry - every read exists in the shipped dispatch, every parameter key is one its read binds, every viz is in the shipped vocabulary - and each of the five definitions was fed to the service's own `ValidateDefinition`, the authority that would refuse the POST. - **The web dashboard's server page gets the viewer's tabs: twelve sections, 61 of the 82 served reads, and a time range** ([#2475]) - the service dispatched **82** reads at `GET /api/read/{name}` and the built-in pages reached **23** of them; `#/server/{name}` was one scroll of seven panels against the desktop viewer's **65** `TabItem`s. The gap was never backend work - `panels.js` has been a generic renderer over those reads since #1562 - so this is descriptors over the unchanged `renderPanel` seam: twelve sub-tabs (Overview, Wait Stats, CPU, Memory, Blocking, File I/O, Queries, Configuration, Config Changes, Activity, System Events, Collection Health) carrying ~70 panels, reaching **61** reads. The header carries the WHY beneath the band badge - `Warning` has three unrelated causes (a real metric breach, a server awaiting its first collection, a collector error), so a badge reading "Warning" with no way to ask why is [#2422] rebuilt on a new surface; the fleet's own reason string and the fleet card's severity chips are rendered there, the chips through `fleet.js`'s own `metricBands` so there is one implementation rather than two, and neither is re-derived in the browser (R1). Every web grid that renders query text now puts it immediately right of its time/identity anchor ([#1949]) and the pin that enforced that on one array enforces it on all seven. The tab id rides in the hash (`#/server/{name}/{tab}`) so a section is deep-linkable and survives the 60s refresh, and an unknown or absent id resolves to Overview, which is what keeps every existing `#/server/{name}` link working. A page-level range picker (1h/4h/12h/24h/7d/30d) is the twin of the viewer's toolbar presets - **not** persisted, because a page that reopens on a 30-day window is slow for a reason the reader cannot see; panels whose read takes no window at all say "latest snapshot" rather than inheriting a label that would misdescribe them. Two reads were previously **unreachable from a browser** because they require a parameter no UI collected - `get_wait_trend` needs a `wait_type` and `get_perfmon_trend` a `counter_name` - and both now have a picker, the wait one seeded from the rows of the table directly above it so the picker and the table cannot disagree. **No fifth viz kind was added**: the property-grid shape that tempted one is served by `stat` (the reads returning a flat object) and `table` (the ones already returning rows), and a fifth kind that lived only in `panels.js` would be a page-only special case, while doing it properly means `KnownVizList`, `derive.js` and an editor config arm - composer surface this change does not need. What the browser genuinely cannot do is **stated in the tab where a reader goes looking for it** rather than left as a page that quietly lacks a feature: plan analysis, the query heatmap, cached-plan retrieval and actual-plan re-execution need a plan renderer and a command back to the monitored server, and the block-chain view and interactive deadlock graph need a graph viewer - the Blocking tab hands over the captured blocked-process-report and deadlock-graph XML verbatim instead of pretending. Every data panel supplies its own empty-state sentence and both helpers THROW without one. vizTable's generic "No rows in this window" reads as a fault on a collector that is off, opt-in, or daily; and the chart case was worse - `get_blocking_trend` and `get_deadlock_trend` answer an IDLE server with `trend: []` and no `{status,message}` envelope at all, so a perfectly healthy server was told its blocking chart did not have "enough data points to chart yet". `vizLine` now renders a descriptor's `emptyText` at exactly ZERO rows and still falls through at one (where the chart's own sentence is the true one) and when no `emptyText` was authored, so every stored view predating this is unchanged. A read feeding several panels on one tab is fetched ONCE (`fanout`) rather than per descriptor - `readTool` has no cache, so `get_collection_health`, which rolls up seven days of collector logs and computes sweep pressure, was running three times to open its own tab; six such duplicates existed across five tabs and a pin now refuses a seventh. Guarded by an invariant rather than by spot-checks: every read name the module mentions must exist in the shipped dispatch, every parameter key must be one its read actually binds (an unknown query key is silently ignored, so `limit` sent to a read binding `top` quietly returns the default), every viz must be in the shipped vocabulary, and no `get_pg_*` read may appear until the fleet payload can tell a PostgreSQL target from a SQL Server one - it carries `engine_edition`, not a `CollectorTargetEngine`, so a PostgreSQL panel today would render on all 42 SQL Servers, permanently empty. diff --git a/Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs b/Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs index 8f6e0aef09..7a028053ee 100644 --- a/Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs @@ -75,13 +75,13 @@ private static (string Name, bool Optional)[] McpParams(string toolName) } [Theory] - [InlineData("get_memory_trend", "server_name,hours_back")] - [InlineData("get_perfmon_trend", "counter_name,server_name,hours_back")] - [InlineData("get_file_io_trend", "server_name,hours_back")] - [InlineData("get_query_trend", "query_hash,database_name,server_name,hours_back")] - [InlineData("get_query_duration_trend", "server_name,hours_back")] - [InlineData("get_procedure_duration_trend", "server_name,hours_back")] - [InlineData("get_query_store_duration_trend", "server_name,hours_back")] + [InlineData("get_memory_trend", "server_name,hours_back,as_of")] + [InlineData("get_perfmon_trend", "counter_name,server_name,hours_back,as_of")] + [InlineData("get_file_io_trend", "server_name,hours_back,as_of")] + [InlineData("get_query_trend", "query_hash,database_name,server_name,hours_back,as_of")] + [InlineData("get_query_duration_trend", "server_name,hours_back,as_of")] + [InlineData("get_procedure_duration_trend", "server_name,hours_back,as_of")] + [InlineData("get_query_store_duration_trend", "server_name,hours_back,as_of")] public void ParamContract_MatchesLite(string toolName, string expectedCsv) { Assert.Equal(expectedCsv.Split(','), McpParams(toolName).Select(p => p.Name).ToArray()); diff --git a/Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs b/Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs index 4c7e8ce029..64dfb4515c 100644 --- a/Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs +++ b/Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs @@ -71,7 +71,7 @@ public void ParamContract_AllOptional_MatchesLite() .Select(x => (x.Name!, x.HasDefaultValue)) .ToArray(); - Assert.Equal(new[] { "server_name", "hours_back", "database_name", "limit" }, p.Select(x => x.Item1).ToArray()); + Assert.Equal(new[] { "server_name", "hours_back", "database_name", "limit", "as_of" }, p.Select(x => x.Item1).ToArray()); Assert.All(p, x => Assert.True(x.Item2, $"{x.Item1} must be optional")); } From 25d72797f1250643d56d7841099f7047c6dfdcfb Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:15:52 +0100 Subject: [PATCH 08/10] Make eight tools that ignored their own anchor use it, and pin the category (#2495) Review found eight tools that advertised as_of, validated it, refused a bad one correctly -- and then computed their window from DateTime.UtcNow anyway. All eight came from resolving the last rebase in dev's favour, all eight compiled, and all eight passed a green suite. That is #2495's own defect one level up: the caller gets NOW labelled as their window, and the validation succeeding is what makes them believe it. Fixed: - Darling get_blocking_trend / get_deadlock_trend recomputed now, taking their denominator (the capture counts) with them. - Lite get_deadlock_trend did the same. - Lite get_blocking_trend passed already-UTC instants into fromDate/toDate, which GetTimeRange treats as SERVER-LOCAL and converts again -- so the window was shifted by the monitored server's offset, in the unanchored case too. Both it and its capture count now thread asOfUtc, which is the UTC-safe branch and still one instant for the pair. The capture-count helpers learned the anchor so that pairing stays possible. - Lite get_file_io_trend / get_memory_trend / get_query_duration_trend / get_wait_types validated the anchor and never handed it to the query. And pinned, because eight at once is a category rather than eight mistakes: an anchored tool must name no DateTime.UtcNow at all, must use the instant it resolved, and -- on Lite -- must pass asOfUtc to every service read that accepts one. The first form of that pin was written as "does not ASSIGN UtcNow" and PASSED against a reintroduced bug, because the body still contained the word windowEnd from its own `out var`; a check that green-lights the defect it exists for is worse than none, so it is an absolute now. Verified by reintroducing one bug of each form and watching it go red. Co-Authored-By: Claude Opus 5 --- .../Darling.Tests/AsOfWindowAnchorTests.cs | 169 ++++++++++++++++++ .../Mcp/DarlingMcpBlockingTools.cs | 4 +- Lite/Mcp/McpBlockingTools.cs | 21 ++- Lite/Mcp/McpIoTools.cs | 2 +- Lite/Mcp/McpMemoryTools.cs | 2 +- Lite/Mcp/McpQueryTools.cs | 2 +- Lite/Mcp/McpWaitTools.cs | 2 +- Lite/Services/LocalDataService.Blocking.cs | 12 +- 8 files changed, 191 insertions(+), 23 deletions(-) diff --git a/Darling/Darling.Tests/AsOfWindowAnchorTests.cs b/Darling/Darling.Tests/AsOfWindowAnchorTests.cs index 405f45a0a3..8145d60d1e 100644 --- a/Darling/Darling.Tests/AsOfWindowAnchorTests.cs +++ b/Darling/Darling.Tests/AsOfWindowAnchorTests.cs @@ -7,8 +7,12 @@ */ using System; +using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Npgsql; @@ -253,6 +257,171 @@ get_store_metrics windows in days over the store's own growth series. */ /// wrong reason. The method is therefore asserted to exist rather than defaulted, and a partially /// loadable assembly is reported rather than silently enumerated as the types that happened to load. /// + /// + /// Every tool that ADVERTISES as_of actually USES the instant it resolved. + /// + /// Why this exists. The convention's failure mode is not a compile error and not a wrong + /// number — it is a tool that takes the parameter, validates it, refuses a bad one correctly, and then + /// computes its window from anyway. The caller gets "now" labelled as their + /// window, the validation succeeding is what makes them believe it, and nothing in the result says + /// otherwise. That is #2495's own defect, one level up. + /// + /// It is not hypothetical: rebasing this work onto a moving dev produced eight of + /// them at once, across both SKUs, every one of which compiled and passed every other test here. Review + /// caught them; this catches the next eight. Both SKUs are scanned from ONE test because the category is + /// not per-SKU — the two copies drifted together. + /// + /// A source scan rather than a behavioural assertion, for the reason + /// gives about the JS it pins: the alternative is ~110 live round-trips + /// to prove a property that is visible in the text, and a property nobody checks is the one that breaks. + /// It reads the SHIPPED files, so it cannot drift into agreeing with a stale copy of itself. + /// + [Theory] + [InlineData("Darling/PerformanceMonitor.Darling.Service/Mcp")] + [InlineData("Lite/Mcp")] + public void EveryToolThatTakesTheAnchor_ActuallyUsesIt(string mcpDirectory) + { + var offenders = new List(); + var examined = 0; + var anchorable = AnchorableServiceMethods(); + + foreach (var file in RepoFilesIn(mcpDirectory)) + { + var source = File.ReadAllText(file).Replace("\r\n", "\n", StringComparison.Ordinal); + var marks = Regex.Matches(source, @"McpServerTool\(Name = ""([a-z_0-9]+)"""); + + for (var i = 0; i < marks.Count; i++) + { + var end = i + 1 < marks.Count ? marks[i + 1].Index : source.Length; + var declaration = source.IndexOf("public static", marks[i].Index, StringComparison.Ordinal); + if (declaration < 0 || declaration > end) + { + continue; + } + + var signatureEnd = CloseParenAfter(source, source.IndexOf('(', declaration)); + if (signatureEnd < 0) + { + continue; + } + + if (!source[declaration..signatureEnd].Contains("as_of", StringComparison.Ordinal)) + { + continue; + } + + examined++; + var body = source[signatureEnd..end]; + + /* The anchor reaches the window either as the resolved local, or by being forwarded whole + to a shared collector (the system_health family resolves it one level down). */ + var reaches = body.Contains("windowEnd", StringComparison.Ordinal) + || body.Contains("anchorEnd", StringComparison.Ordinal) + || Regex.IsMatch(body, @"\w+Async\([^;]*\bas_of\b"); + + /* + An anchored tool's ONLY source of "now" is the anchor it resolved, so it must not name + DateTime.UtcNow at all. Written as an absolute rather than as "does not ASSIGN UtcNow" + on evidence: the assignment form passed while get_file_io_trend was demonstrably broken, + because the body still contained the word windowEnd from its own `out var`. A check that + green-lights the defect it was written for is worse than none. + */ + var namesNow = body.Contains("DateTime.UtcNow", StringComparison.Ordinal); + + /* + The other half, and the one the reaches-check cannot see: Lite's tools resolve the anchor + and then hand it to LocalDataService. A read that CAN take asOfUtc and is not given it is + a tool that validated an anchor, believed itself anchored, and queried the present. Five + of those shipped past a green suite before this line existed. + */ + var dropped = Regex.Matches(body, @"dataService\.(\w+)\(") + .Where(m => anchorable.Contains(m.Groups[1].Value) && !CallPasses(body, m.Index, "asOfUtc")) + .Select(m => m.Groups[1].Value) + .Distinct() + .ToArray(); + + if (!reaches || namesNow || dropped.Length > 0) + { + offenders.Add($"{Path.GetFileName(file)}:{marks[i].Groups[1].Value}" + + (reaches ? "" : " (never uses the resolved anchor)") + + (namesNow ? " (names DateTime.UtcNow)" : "") + + (dropped.Length > 0 ? $" (anchor not passed to {string.Join(", ", dropped)})" : "")); + } + } + } + + /* A scan that parsed nothing passes for free, which is the worst outcome a check like this can have: + it converts an open question into confidence. Both SKUs anchor dozens of reads, so a handful means + the signature extraction broke rather than that the surface shrank. */ + Assert.True( + examined >= 40, + $"only {examined} anchored tools were found under {mcpDirectory} — the scan is broken, not the surface"); + + Assert.True( + offenders.Count == 0, + "these tools advertise as_of and then answer as of NOW, which is worse than not offering it — " + + "the validation succeeds, so the caller believes the window moved: " + string.Join("; ", offenders)); + } + + /// + /// The LocalDataService reads that ACCEPT the anchor, read off the shipped source rather than + /// listed here — a transcribed list would go stale in exactly the direction that makes the pin pass. + /// + private static HashSet AnchorableServiceMethods() + { + var methods = new HashSet(StringComparer.Ordinal); + foreach (var file in RepoFilesIn("Lite/Services").Where(f => Path.GetFileName(f).StartsWith("LocalDataService", StringComparison.Ordinal))) + { + var source = File.ReadAllText(file).Replace("\r\n", "\n", StringComparison.Ordinal); + foreach (Match m in Regex.Matches(source, @"public (?:async )?[\w<>?,\[\]\(\) ]+ (\w+)\(([^{]*?)\)\s*(?:=>|\n\s*\{)")) + { + if (m.Groups[2].Value.Contains("asOfUtc", StringComparison.Ordinal)) + { + methods.Add(m.Groups[1].Value); + } + } + } + + Assert.True(methods.Count >= 40, $"only {methods.Count} anchor-taking service methods found — the scan is broken"); + return methods; + } + + /// Whether one call expression passes an argument whose text contains . + private static bool CallPasses(string body, int callStart, string argument) + { + var open = body.IndexOf('(', callStart); + var close = CloseParenAfter(body, open); + return close > open && body[open..close].Contains(argument, StringComparison.Ordinal); + } + + private static int CloseParenAfter(string source, int open) + { + var depth = 0; + for (var i = open; i < source.Length; i++) + { + if (source[i] == '(') depth++; + else if (source[i] == ')' && --depth == 0) return i; + } + + return -1; + } + + private static string[] RepoFilesIn(string relativeDirectory, [CallerFilePath] string thisFile = "") + { + for (var dir = new DirectoryInfo(Path.GetDirectoryName(thisFile)!); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, relativeDirectory); + if (Directory.Exists(candidate)) + { + var files = Directory.GetFiles(candidate, "*.cs"); + Assert.NotEmpty(files); + return files; + } + } + + throw new DirectoryNotFoundException($"Could not locate {relativeDirectory} walking up from {thisFile}"); + } + private static bool ToolTakesAnAnchor(string readName) { Type[] types; diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs index c6781fc180..71113d50bd 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs @@ -349,7 +349,7 @@ public static async Task GetBlockingTrend( try { - var now = DateTime.UtcNow; + var now = windowEnd; var start = now.AddHours(-hours_back); var points = await DarlingBlockingTrendReader.GetBlockingTrendAsync( postgres, resolved.ServerId, start, now); @@ -399,7 +399,7 @@ public static async Task GetDeadlockTrend( try { - var now = DateTime.UtcNow; + var now = windowEnd; var start = now.AddHours(-hours_back); var points = await DarlingBlockingTrendReader.GetDeadlockTrendAsync( postgres, resolved.ServerId, start, now); diff --git a/Lite/Mcp/McpBlockingTools.cs b/Lite/Mcp/McpBlockingTools.cs index 91a39cd02c..1aeae0b8f8 100644 --- a/Lite/Mcp/McpBlockingTools.cs +++ b/Lite/Mcp/McpBlockingTools.cs @@ -252,12 +252,14 @@ public static async Task GetBlockingTrend( /* One instant for BOTH reads. Resolving now separately in the trend and the capture count lets a row arrive between them, and the two answers exist to be compared -- Darling's twin pins a single now for exactly this reason. That instant is the as_of anchor when one - was sent, so both reads move together onto the past window rather than one of them. */ - var windowEnd = anchorEnd; - var windowStart = windowEnd.AddHours(-Math.Abs(hours_back)); + was sent, so both reads move together onto the past window rather than one of them. + Threaded as asOfUtc rather than as fromDate/toDate: those two are SERVER-LOCAL and are + converted back to UTC inside GetTimeRange, so handing them an instant that is already UTC + shifts the window by the monitored server's offset -- silently, and in the unanchored case + too (review catch). asOfUtc is the UTC-safe branch, and one value still means one instant. */ var points = await dataService.GetBlockingTrendAsync( - resolved.ServerId, hours_back, windowStart, windowEnd); + resolved.ServerId, hours_back, asOfUtc: anchorEnd); if (points.Count == 0) { @@ -270,7 +272,7 @@ means nothing at all is known about the window. The stored tables cannot tell th nothing. Darling's twin makes the same distinction with the same words. */ var captures = await dataService.GetBlockingCaptureCountsAsync( - resolved.ServerId, hours_back, windowStart, windowEnd); + resolved.ServerId, hours_back, asOfUtc: anchorEnd); return await EmptyTrend( "blocking", resolved.ServerName, hours_back, captures, () => dataService.HasAnyBlockingCollectorRunAsync(resolved.ServerId)); @@ -307,18 +309,15 @@ public static async Task GetDeadlockTrend( var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - /* Same single-instant discipline as the blocking trend above. */ - var dlWindowEnd = DateTime.UtcNow; - var dlWindowStart = dlWindowEnd.AddHours(-Math.Abs(hours_back)); - + /* Same single-instant discipline as the blocking trend above, threaded the same UTC-safe way. */ var points = await dataService.GetDeadlockTrendAsync( - resolved.ServerId, hours_back, dlWindowStart, dlWindowEnd); + resolved.ServerId, hours_back, asOfUtc: windowEnd); if (points.Count == 0) { /* Same two facts as the blocking trend above, same denominator, same reason. */ var captures = await dataService.GetDeadlockCaptureCountsAsync( - resolved.ServerId, hours_back, dlWindowStart, dlWindowEnd); + resolved.ServerId, hours_back, asOfUtc: windowEnd); return await EmptyTrend( /* SINGULAR: the subject lands in "No {subject} was recorded", and "no deadlocks was recorded" is not a sentence. It also reads correctly in the other two, diff --git a/Lite/Mcp/McpIoTools.cs b/Lite/Mcp/McpIoTools.cs index ea35700dc9..021e2ce783 100644 --- a/Lite/Mcp/McpIoTools.cs +++ b/Lite/Mcp/McpIoTools.cs @@ -71,7 +71,7 @@ public static async Task GetFileIoTrend( var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var points = await dataService.GetFileIoLatencyTrendAsync(resolved.ServerId, hours_back); + var points = await dataService.GetFileIoLatencyTrendAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (points.Count == 0) { diff --git a/Lite/Mcp/McpMemoryTools.cs b/Lite/Mcp/McpMemoryTools.cs index 10e7d05f61..8055accfe1 100644 --- a/Lite/Mcp/McpMemoryTools.cs +++ b/Lite/Mcp/McpMemoryTools.cs @@ -63,7 +63,7 @@ public static async Task GetMemoryTrend( var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var points = await dataService.GetMemoryTrendAsync(resolved.ServerId, hours_back); + var points = await dataService.GetMemoryTrendAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (points.Count == 0) { diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index e75f97c7e5..70fa2a56a2 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -408,7 +408,7 @@ public static async Task GetQueryDurationTrend( var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var points = await dataService.GetQueryDurationTrendAsync(resolved.ServerId, hours_back); + var points = await dataService.GetQueryDurationTrendAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (points.Count == 0) { diff --git a/Lite/Mcp/McpWaitTools.cs b/Lite/Mcp/McpWaitTools.cs index 504a6157c6..c8cf903522 100644 --- a/Lite/Mcp/McpWaitTools.cs +++ b/Lite/Mcp/McpWaitTools.cs @@ -74,7 +74,7 @@ public static async Task GetWaitTypes( var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); if (hoursError != null) return hoursError; - var types = await dataService.GetDistinctWaitTypesAsync(resolved.ServerId, hours_back); + var types = await dataService.GetDistinctWaitTypesAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd); if (types.Count == 0) { diff --git a/Lite/Services/LocalDataService.Blocking.cs b/Lite/Services/LocalDataService.Blocking.cs index 16a3362648..ec511f39c2 100644 --- a/Lite/Services/LocalDataService.Blocking.cs +++ b/Lite/Services/LocalDataService.Blocking.cs @@ -774,8 +774,8 @@ GROUP BY DATE_TRUNC('minute', deadlock_time) /// same state. /// public Task> GetBlockingCaptureCountsAsync( - int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) => - GetCaptureCountsAsync(serverId, hoursBack, "collector_name IN ('blocked_process_report', 'dmv_blocking_snapshot')", fromDate, toDate); + int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) => + GetCaptureCountsAsync(serverId, hoursBack, "collector_name IN ('blocked_process_report', 'dmv_blocking_snapshot')", fromDate, toDate, asOfUtc); /// /// Successful runs of the deadlock collector inside the window. One capture path here, not two — @@ -783,8 +783,8 @@ public Task> GetBlockingCaptureCountsAsync( /// fallback to count. /// public Task> GetDeadlockCaptureCountsAsync( - int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) => - GetCaptureCountsAsync(serverId, hoursBack, "collector_name = 'deadlocks'", fromDate, toDate); + int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) => + GetCaptureCountsAsync(serverId, hoursBack, "collector_name = 'deadlocks'", fromDate, toDate, asOfUtc); /// /// Whether either blocking collector has EVER run successfully for this server, ignoring any window. @@ -818,12 +818,12 @@ public Task HasAnyDeadlockCollectorRunAsync(int serverId) => point of asking both is that their answers are compared. Darling's twin takes explicit bounds for the same reason. */ private async Task> GetCaptureCountsAsync( - int serverId, int hoursBack, string collectorPredicate, DateTime? fromDate = null, DateTime? toDate = null) + int serverId, int hoursBack, string collectorPredicate, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate, asOfUtc); command.CommandText = @" SELECT From 9a1d300a5841b9f2927d4e8bb3c64739d84af548 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:17:15 +0100 Subject: [PATCH 09/10] Stop the server-local anchor test passing at offset zero (#2495) It read whatever ServerTimeHelper.UtcOffsetMinutes happened to be, which on any normal machine is 0 -- and at 0 a correct server-local conversion and no conversion at all give identical results, so the test could not fail for the bug its own summary names. It now SETS UTC+5:30, saved and restored, the pattern SystemEventsReaderTests already uses; the half hour also catches an implementation that rounds to whole hours. Second vacuity found in this PR by the same question -- would this fail if the thing it guards were broken -- so it is worth saying plainly: a check that cannot go red is not weak evidence, it is confidence with nothing behind it. Co-Authored-By: Claude Opus 5 --- Lite.Tests/AsOfWindowAnchorTests.cs | 43 ++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/Lite.Tests/AsOfWindowAnchorTests.cs b/Lite.Tests/AsOfWindowAnchorTests.cs index 6a5e60fddc..02b0c5124d 100644 --- a/Lite.Tests/AsOfWindowAnchorTests.cs +++ b/Lite.Tests/AsOfWindowAnchorTests.cs @@ -190,26 +190,43 @@ public async Task AnAnchorBeforeAnyData_ReturnsTheReadsOwnMiss_NotARefusal() /// The anchor reaches a read whose window is built by the SERVER-LOCAL helper, not just the UTC one. /// Two families, two time bases, one instant — a bug here would shift the window by the monitored /// server's offset and still look plausible. + /// + /// The offset is SET, not read (review catch). Reading whatever + /// ServerTimeHelper.UtcOffsetMinutes happens to be means running at 0 on any normal machine, and + /// at 0 a correct conversion and no conversion at all produce identical results — the test would pass + /// vacuously for exactly the bug its own summary names. UTC+5:30 is used because a half-hour offset also + /// catches an implementation that rounds to whole hours. Saved and restored, and this class shares the + /// server-time-helper collection so the write cannot land under a sibling class mid-read. /// [Fact] public async Task TheAnchorAlsoMoves_AServerLocalWindow() { - var now = DateTime.UtcNow; - var incident = now.AddHours(-30); + var savedOffset = ServerTimeHelper.UtcOffsetMinutes; + try + { + ServerTimeHelper.UtcOffsetMinutes = 330; // UTC+5:30 + + var now = DateTime.UtcNow; + var incident = now.AddHours(-30); - /* sample_time is the monitored server's LOCAL wall clock, so the fixture is seeded in that base — - the read's window is server-local and the anchor arrives in UTC, and the point of the test is - that the conversion between them happens exactly once. */ - var offset = ServerTimeHelper.UtcOffsetMinutes; - await SeedCpuAsync(incident.AddMinutes(offset), 91); - await SeedCpuAsync(now.AddMinutes(-10).AddMinutes(offset), 12); + /* sample_time is the monitored server's LOCAL wall clock, so the fixture is seeded in that base — + the read's window is server-local and the anchor arrives in UTC, and the point of the test is + that the conversion between them happens exactly once. */ + var offset = ServerTimeHelper.UtcOffsetMinutes; + await SeedCpuAsync(incident.AddMinutes(offset), 91); + await SeedCpuAsync(now.AddMinutes(-10).AddMinutes(offset), 12); - var anchored = await McpCpuTools.GetCpuUtilization( - _dataService, _serverManager, "TestServer", 4, incident.AddMinutes(30).ToString("o")); - Assert.Equal(new[] { 91 }, SqlCpuIn(anchored)); + var anchored = await McpCpuTools.GetCpuUtilization( + _dataService, _serverManager, "TestServer", 4, incident.AddMinutes(30).ToString("o")); + Assert.Equal(new[] { 91 }, SqlCpuIn(anchored)); - var live = await McpCpuTools.GetCpuUtilization(_dataService, _serverManager, "TestServer"); - Assert.Equal(new[] { 12 }, SqlCpuIn(live)); + var live = await McpCpuTools.GetCpuUtilization(_dataService, _serverManager, "TestServer"); + Assert.Equal(new[] { 12 }, SqlCpuIn(live)); + } + finally + { + ServerTimeHelper.UtcOffsetMinutes = savedOffset; + } } // ── helpers ── From 2d8276ac95e3c08ac9b41ca83f550e5b1efc297e Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:23:27 +0100 Subject: [PATCH 10/10] Reunite a stranded summary with the member it describes (#2495) DocCommentHygieneTests caught it: inserting the new pin anchored on `private static bool ToolTakesAnAnchor(...)` put it BELOW that member's doc block, so the block ended up documenting the new test and ToolTakesAnAnchor carried none. The rule the repo already knows and I did not follow: anchor a doc-comment insert on the PREVIOUS member's closing brace, never on the next member's signature. Both blocks are intact; only the stranded one moved. Co-Authored-By: Claude Opus 5 --- Darling/Darling.Tests/AsOfWindowAnchorTests.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Darling/Darling.Tests/AsOfWindowAnchorTests.cs b/Darling/Darling.Tests/AsOfWindowAnchorTests.cs index 8145d60d1e..a2ca1fb5d3 100644 --- a/Darling/Darling.Tests/AsOfWindowAnchorTests.cs +++ b/Darling/Darling.Tests/AsOfWindowAnchorTests.cs @@ -249,14 +249,6 @@ get_store_metrics windows in days over the store's own growth series. */ string.Join(", ", unanchored)); } - /// - /// Whether the [McpServerTool] method behind a read name takes the anchor. - /// - /// Both pins above are of the form "no read is in set A but not set B", so a lookup that quietly - /// returned false for a read it could not FIND would empty the failing set and make them pass for the - /// wrong reason. The method is therefore asserted to exist rather than defaulted, and a partially - /// loadable assembly is reported rather than silently enumerated as the types that happened to load. - /// /// /// Every tool that ADVERTISES as_of actually USES the instant it resolved. /// @@ -422,6 +414,14 @@ private static string[] RepoFilesIn(string relativeDirectory, [CallerFilePath] s throw new DirectoryNotFoundException($"Could not locate {relativeDirectory} walking up from {thisFile}"); } + /// + /// Whether the [McpServerTool] method behind a read name takes the anchor. + /// + /// Both pins above are of the form "no read is in set A but not set B", so a lookup that quietly + /// returned false for a read it could not FIND would empty the failing set and make them pass for the + /// wrong reason. The method is therefore asserted to exist rather than defaulted, and a partially + /// loadable assembly is reported rather than silently enumerated as the types that happened to load. + /// private static bool ToolTakesAnAnchor(string readName) { Type[] types;