Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Lite's portable ZIP is self-contained, which HALVED it** ([#2501]) - `Publish Lite` is now `-r win-x64 --self-contained` in both `build.yml` and `nightly.yml`, so neither Lite artifact has a .NET prerequisite any more and the failure [#2489] documented stops existing: a tester who unzips onto a stock Windows Server no longer meets the .NET host's bare `You must install .NET to run this application` before a line of our code runs. **The size went the opposite way from what bundling a runtime suggests.** The old publish was RID-agnostic, so it copied every platform its packages ship - **537 MB of `runtimes\` on a 565 MB tree** (osx 130, linux-x64 116, linux-arm64 70, win-arm64 56, then win-x86, musl, loongarch64 and riscv64), of which only the **52 MB `win-x64`** folder could ever load on Windows. `DuckDB.NET.Bindings.Full` is most of it, SkiaSharp and SqlClient behind it. Dropping ~485 MB of unloadable native payload beats the cost of bundling .NET, WPF and ASP.NET Core by roughly two to one: measured on one commit and one SDK, **565 MB tree / 212.7 MB zipped becomes 277 MB / 114.2 MB**. It matters most for the **nightly** ZIP, which is the UAT download and is not offered as a `Setup.exe` at all. **A RID-specific publish needed two more files than the flag.** `Lite/packages.lock.json` had only a `net10.0-windows7.0` target, and a RID restore adds `net10.0-windows7.0/win-x64` to it - after which the `dotnet restore --locked-mode` that BOTH workflows run before the publish fails `NU1004: the project's runtime identifiers have changed`, because locked mode compares the PROJECT's RID set (empty) against the lock file's (win-x64). Reproduced locally; that is a red CI run on every PR, not the future `--no-restore` trap it was filed as. The fix is `<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>` in `PerformanceMonitorLite.csproj`, so the project itself asks for that graph and one committed lock file satisfies the RID-less locked-mode restore and the RID publish alike; `RuntimeIdentifiers` (plural) sets no RID on the build, so a plain `dotnet build` stays RID-agnostic and `Lite.Tests` is untouched. **SignPath needed nothing** - the `Lite` artifact-configuration slug already receives both shapes today, and the signed re-zip reads `signed/Lite/*`, inheriting whatever shape `publish/Lite` has. Auto-update is unaffected; the ZIP is not a Velopack channel. `LiteRuntimePrerequisiteDocsTests` went red on the flag alone (3 of its 7 facts) and was rewritten to state every claim BOTH ways round: [#2499]'s version asserted only that the docs DID name the runtimes, so two of its facts stayed green while the prose went stale. It now also derives the lock file's RID coverage from the `-r` flags in the workflows, and every new assertion was proven red with its fix reverted.

### Fixed
- **On a PostgreSQL target, 55 more reads stopped telling you to go and check a collector that will never run** ([#2532]) - [#2511] wired the engine-capability helper into the reads whose collectors are gated off on Azure SQL Database, which was the right scope for the edition axis: a collector that runs on every SQL Server had nothing to say. The engine-KIND axis [#2530] added changes that arithmetic completely - on a PostgreSQL target **every** SQL Server read is a permanent gap - so `get_database_config`, `get_wait_stats`, `get_top_queries_by_cpu` and the rest still answered "unavailable - the config collector may not have run yet" about a collector that does not run on that engine at all. True, and pointing at the wrong cause. A human will get PostgreSQL tabs when the [#2530] UI waves land; an **agent** over MCP has no tabs and asks by name, which is half the product's surface. **72 Darling reads and 71 Lite reads** now ask on the miss path (up from 17 on each), covering every server-scoped read served by an identifiable collector. Deliberately NOT wired, with reasons: the rollups that read a dozen collectors and can name none of them (`analyze_server`, `get_analysis_facts`, `compare_analysis`, `get_daily_summary`, `get_daily_summary_range`, `get_server_summary`), the reads served by `collection_log`, which a PostgreSQL target genuinely populates (`get_collection_health`, `get_collection_log`), and everything that resolves no server at all (`list_servers`, `get_fleet_overview`, `get_store_metrics`, the alert and custom-view tools). `CapturePathByCollector` gains 28 noun phrases so the message names a surface - "the sys.dm_os_wait_stats cumulative totals" - rather than "the data this read is served from". Every call sits AFTER the read came back empty, so a server whose registry row disagrees with its collected rows still gets its data.
- **Eight PostgreSQL reads told a SQL Server target its vacuum was healthy, and stock PostgreSQL was told to go and check a collector that will never run** ([#2532]) - [#2530] taught the miss vocabulary the engine-KIND axis but wired only the *engine half* of the dispatch gate into it, so the PostgreSQL collectors' own `AppliesTo` gates were still invisible to it, and none of the eight `get_pg_*` reads asked the question at all. A SQL Server target asked `get_pg_xmin_horizon` got `no_holder` - "nothing is holding back the xmin horizon", a confident all-clear about a mechanism that engine does not have - and `get_pg_autovacuum_health` reported `no_pending_maintenance`, which is a fabricated clean bill of health rather than a weak one. `CollectorEngineCapability.TargetsWithEngineKind` now sweeps every target shape an engine kind permits, fixing `IsAurora` (nothing turns a stock PostgreSQL server into an Aurora one) and varying `PostgresMajorVersion`, `PostgresVersionNum` and `IsInRecovery`, all three of which an upgrade or a writer connection moves. So `pg_wait_stats` and `pg_statement_stats`, which read `aurora_stat_system_waits()` and `aurora_stat_statements()`, are permanent gaps on stock PostgreSQL and say so - while `pg_stat_io`'s PG16 floor and the writer-only autovacuum read keep the `unavailable` vocabulary that correctly sends an operator to look. The message names the real reason: a foreign DIALECT is stopped by the dispatch gate's engine half, a same-dialect gap is the collector's own gate, and one sentence for both would have told a PostgreSQL operator that a PostgreSQL collector is not written for PostgreSQL. Shipped with the guard [#2532] made its prerequisite: `EveryFactAPostgresGateReads_IsVariedBySweepOrFixedByKind` decodes the IL of every PostgreSQL `AppliesTo` and fails the build if the sweep leaves a fact it reads at its CLR default - the twin of [#2518]'s edition-axis guard, and the reason a sweep can be trusted not to over-claim silently.
- **The Overview lanes skewed apart when one lane's numbers got long** ([#2533]) - the five lanes are five SEPARATE plots, and `SyncXAxes` only ever gave them the same X-axis LIMITS. Nothing gave them the same pixel geometry: each plot sizes its own left gutter from its own Y tick labels, so on a server whose wait time runs into six figures the Wait ms/sec lane began its data area ~27 px right of the I/O latency lane reading `0.4`, and the lanes stopped lining up on the time axis. Every value stayed correct throughout - which is why the tooltips still read true, and what identified this as layout rather than data. **Not a regression**, and the reporter's "I think this worked before" is right for his machine: `MinimumSize`, `Layout.Fixed` and `PixelPadding` have never appeared in any SKU on any branch, so nothing was removed - alignment was always incidental, holding only while the five lanes happened to need labels of similar width, and five- or six-digit wait ms/sec is what breaks the coincidence. The same build therefore looks right on one server and skewed on another. The lanes now share ONE gutter, sized to the widest lane's own natural gutter and re-derived on every refresh - a frozen constant would have to cover six-digit wait times and would then steal that width from every server whose lanes read in single digits, and it ratchets back down when the big values go away. The mechanism is `Axes.Left.MinimumSize` rather than a fixed layout, because a fixed layout pins all four sides and the bottom lane legitimately needs more bottom room than the other four - it alone draws the time labels. This is the second alignment defect in these lanes and the first one shipped with nothing that could have caught it, so the fix comes with headless render tests: ScottPlot draws without a window, so CI now builds lanes with deliberately mismatched magnitudes, renders them, and compares their data rectangles - shown failing at 20.02 px on the unfixed control before it passed. **Darling viewer only.** Lite's Overview control is the same five separate plots and carries the same latent defect, but nobody has reported hitting it there; the aligner lives in the shared `PerformanceMonitor.Ui`, so picking Lite up later is one call site. Reported by @ghauan.
- **A SQL Server read aimed at a PostgreSQL target says so, instead of telling you to check collection** ([#2530]) - [#2511] taught the miss vocabulary the engine-EDITION axis, so a read served by a collector gated off on Azure SQL Database answers `not_collected` naming the engine. A PostgreSQL target got `unavailable` - "never collected, check that collection is running" - which is true and points at the wrong cause: those collectors do not apply to that engine at all. That was the honest answer while nothing in the store distinguished a PostgreSQL target from an unconnected one; now something does. The answer is derived the same way the edition axis is, from the collectors' own dispatch gate rather than from a list, so flipping a definition's target engine moves it. **Unknown targets keep making no claim** - a pre-v82 row, a server that has not connected since the rung landed, and a token a newer build wrote all stay silent, because the distinction added is "known to be PostgreSQL" and never "not known to be SQL Server". It runs both ways: a `get_pg_*` read aimed at a known SQL Server target now says the same thing in reverse. Limit worth knowing: only the reads [#2511] wired to the capability helper can answer on either axis, so a read like `get_database_config` - whose collector runs on every SQL Server, so the edition axis gave it nothing to say - still reports `unavailable` on a PostgreSQL target.
Expand Down
31 changes: 22 additions & 9 deletions Darling/Darling.Tests/EngineCapabilityMissTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -669,15 +669,28 @@ engine that has no extended events at all. */
Assert.Contains("server_config", auroraServerConfig, StringComparison.Ordinal);
Assert.Contains("Aurora PostgreSQL", auroraServerConfig, StringComparison.Ordinal);

/* The LIMIT of this change, asserted rather than left to be discovered: only the reads #2511
wired to the capability helper can answer on either axis. get_database_config is not one of
them — its collector runs on every SQL Server, so the edition axis gave it nothing to say and
it was never wired — and on a PostgreSQL target it therefore still reports the old
`unavailable`. The kind axis makes EVERY SQL Server read a permanent gap on a PostgreSQL
target, which is a much wider wiring job than the twelve gated collectors; it is filed rather
than smuggled in, and this line is what stops the gap being mistaken for a fix that regressed. */
Assert.Equal("unavailable", DarlingMcpTestData.StatusOf(
await DarlingMcpConfigTools.GetDatabaseConfig(postgres, PostgresServerName)));
/* The limit this line used to assert is GONE (#2532). It read: only the reads #2511 wired to
the capability helper can answer on either axis, so get_database_config — whose collector runs
on every SQL Server, which gave the edition axis nothing to say — still reported the old
`unavailable` on a PostgreSQL target. The kind axis makes EVERY SQL Server read a permanent
gap there, and the wiring has now caught up, so the read that was the named example of the
gap is the one asserted here.

Kept as an explicit pair rather than deleted: this is the read the issue quoted, and a pin
that only ever covered collectors #2511 had already wired could not tell the wiring apart
from the twelve gates it started with. */
var auroraDatabaseConfig = await DarlingMcpConfigTools.GetDatabaseConfig(postgres, PostgresServerName);
Assert.Equal("not_collected", DarlingMcpTestData.StatusOf(auroraDatabaseConfig));
Assert.Contains("database_config", auroraDatabaseConfig, StringComparison.Ordinal);
Assert.Contains("Aurora PostgreSQL", auroraDatabaseConfig, StringComparison.Ordinal);
Assert.DoesNotContain("may not have run yet", auroraDatabaseConfig, StringComparison.Ordinal);

/* And a read from a family that has NOTHING to do with configuration, whose collector is gated
off on no engine edition at all — so nothing but the kind axis could ever make it speak. */
var auroraWaitStats = await DarlingMcpDataTools.GetWaitStats(postgres, PostgresServerName);
Assert.Equal("not_collected", DarlingMcpTestData.StatusOf(auroraWaitStats));
Assert.Contains("wait_stats", auroraWaitStats, StringComparison.Ordinal);
Assert.Contains("Aurora PostgreSQL", auroraWaitStats, StringComparison.Ordinal);

/* ── Stock PostgreSQL: the same answer, named for the engine it actually is ── */
var stockFlags = await DarlingMcpConfigTools.GetTraceFlags(postgres, BoxServerName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ public static async Task<string> GetBlocking(
var rows = await DarlingBlockingReader.GetRecentBlockedProcessReportsAsync(
postgres, resolved.ServerId, now.AddHours(-hours_back), now);
if (rows.Count == 0)
return McpHelpers.Status("empty", "No blocking events found in the specified time range.");
return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "blocked_process_report")
?? McpHelpers.Status("empty", "No blocking events found in the specified time range.");

/* #2159: fingerprint the WHOLE window, then filter, then cap. Capping first would let `limit`
discard the very incident the key names — the caller asked for one specific incident, not for
Expand Down Expand Up @@ -170,7 +171,8 @@ public static async Task<string> GetDeadlocks(
var rows = await DarlingBlockingReader.GetRecentDeadlocksAsync(
postgres, resolved.ServerId, now.AddHours(-hours_back), now);
if (rows.Count == 0)
return McpHelpers.Status("empty", "No deadlocks found in the specified time range.");
return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "deadlocks")
?? McpHelpers.Status("empty", "No deadlocks found in the specified time range.");

/* #2159: see get_blocking — fingerprint the window, filter, then cap. */
var examined = rows.Count;
Expand Down Expand Up @@ -243,7 +245,8 @@ public static async Task<string> GetDeadlockDetail(
consume one of the `limit` slots the caller wanted spent on real graphs. */
var candidates = rows.Where(r => r.HasDeadlockXml).ToList();
if (candidates.Count == 0)
return McpHelpers.Status("empty", "No deadlock XML available in the specified time range.");
return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "deadlocks")
?? McpHelpers.Status("empty", "No deadlock XML available in the specified time range.");

var examined = candidates.Count;
var keys = DarlingIncidentFingerprint.DeadlockKeys(
Expand Down Expand Up @@ -309,7 +312,8 @@ public static async Task<string> GetBlockedProcessXml(
postgres, resolved.ServerId, now.AddHours(-hours_back), now);
var withXml = rows.Where(r => r.HasReportXml).Take(limit).ToList();
if (withXml.Count == 0)
return McpHelpers.Status("empty", "No blocked process report XML available in the specified time range.");
return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "blocked_process_report")
?? McpHelpers.Status("empty", "No blocked process report XML available in the specified time range.");

var result = withXml.Select(r => new
{
Expand Down Expand Up @@ -364,6 +368,12 @@ means nothing at all is known about the window. The stored tables cannot tell th
from collection_log, which records a SUCCESS with zero rows for a collector that ran
and saw nothing. Probed only here, on the path that already found nothing.
*/
var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "blocked_process_report");
if (gated != null)
{
return gated;
}

var captures = await DarlingBlockingTrendReader.GetBlockingCaptureCountsAsync(
postgres, resolved.ServerId, start, now);
return await EmptyTrend(
Expand Down Expand Up @@ -407,6 +417,12 @@ public static async Task<string> GetDeadlockTrend(
if (points.Count == 0)
{
/* Same two facts as the blocking trend above, same denominator, same reason. */
var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "deadlocks");
if (gated != null)
{
return gated;
}

var captures = await DarlingBlockingTrendReader.GetDeadlockCaptureCountsAsync(
postgres, resolved.ServerId, start, now);
return await EmptyTrend(
Expand Down Expand Up @@ -466,6 +482,12 @@ The LCK% filter is deliberately NOT applied to the probe. A server that has coll
wait stats for months and never taken a lock wait is the all-clear this branch is for,
and filtering the probe the same way would call that server uncollected.
*/
var gated = await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "wait_stats");
if (gated != null)
{
return gated;
}

return await DarlingDataReader.HasAnyWaitStatAsync(postgres, resolved.ServerId)
? McpHelpers.Status(
"empty",
Expand Down
Loading
Loading