From 96d7b867c81cf53758a811d1990b3f44871274c1 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:50:50 -0400 Subject: [PATCH 01/17] Azure SQL Database servers show their service objective, not the host's hardware On an Azure SQL Database the collected logical CPUs, sockets, cores per socket, hyperthread ratio and physical memory are the host's, not the database's allocation. get_server_properties (Lite and Darling) now returns those five as null with vcore_count and a hardware_note, the web Server Properties list hides them and shows the service objective and vCores, and the FinOps Server Inventory and utilization card stop presenting the host's memory and cores. Every other engine edition is unchanged. --- .../AzureSqlDatabaseHardwareTests.cs | 252 ++++++++++++++++ .../Mcp/DarlingDataReader.cs | 12 +- .../Mcp/DarlingMcpDataTools.cs | 90 +++--- .../wwwroot/js/pages/server-tabs.js | 17 +- .../wwwroot/js/panels.js | 14 +- .../FinOpsTab.Loaders.cs | 14 +- .../ViewerDataService.FinOps.Utilization.cs | 8 +- .../ViewerDataService.FinOps.cs | 34 ++- Lite.Tests/AzureSqlDatabaseHardwareTests.cs | 273 ++++++++++++++++++ Lite/Controls/FinOpsTab.xaml.cs | 14 +- Lite/Mcp/McpServerInfoTools.cs | 88 +++--- .../LocalDataService.FinOps.Utilization.cs | 8 +- Lite/Services/LocalDataService.FinOps.cs | 33 ++- Lite/Services/LocalDataService.ServerInfo.cs | 11 +- .../ServerHardwareScope.cs | 91 ++++++ 15 files changed, 850 insertions(+), 109 deletions(-) create mode 100644 Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs create mode 100644 Lite.Tests/AzureSqlDatabaseHardwareTests.cs create mode 100644 PerformanceMonitor.Common/ServerHardwareScope.cs diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs new file mode 100644 index 000000000..1efa39a96 --- /dev/null +++ b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs @@ -0,0 +1,252 @@ +/* + * 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.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Viewer; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// On an Azure SQL Database (engine edition 5) the collected server_properties hardware columns are the +/// HOST's: a 1-vCore serverless General Purpose database read 2 logical CPUs, 0 sockets, 32 cores per socket, a +/// hyperthread ratio of 64 and 911.9 GB of physical memory. Nothing may present them as the database's; the service +/// objective and the vcore_count parsed from it describe the database. +/// +/// Pinned where the rule is applied: the get_server_properties payload (which the web Server Properties +/// list reads through /api/read), the web list's own tiles, the FinOps Server Inventory row the grid binds, and +/// the FinOps utilization card's sentences. Every test has an edition-3 twin that keeps today's values. Lite.Tests +/// pins the same table for the other app, in the same words. +/// +public sealed class AzureSqlDatabaseHardwareTests +{ + private static readonly string[] s_hostKeys = + ["cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; + + private static DarlingDataReader.ServerPropertiesReadRow HostRow(int engineEdition, string edition, string? objective, int? vcores) => new( + new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), edition, "12.0.2000.8", "RTM", null, + engineEdition, 2, 64, 933_888L, 0, 32, false, false, null, objective, null, null, vcores); + + private static JsonElement Payload(DarlingDataReader.ServerPropertiesReadRow row) => + JsonDocument.Parse(DarlingMcpDataTools.ServerPropertiesPayload("Srv", row)).RootElement.Clone(); + + // ── get_server_properties ── + + [Fact] + public void GetServerProperties_OnAzureSqlDatabase_ReturnsTheHostsFiveAsNull_AndTheDatabasesVcores_WithANote() + { + var json = Payload(HostRow(5, "SQL Azure", "GP_S_Gen5_1", 1)); + + foreach (var key in s_hostKeys) + Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + + Assert.Equal("GP_S_Gen5_1", json.GetProperty("service_objective").GetString()); + Assert.Equal(1, json.GetProperty("vcore_count").GetInt32()); + var note = json.GetProperty("hardware_note").GetString(); + Assert.Equal(ServerHardwareScope.McpHardwareNote, note); + Assert.Contains("service_objective", note, StringComparison.Ordinal); + Assert.Contains("vcore_count", note, StringComparison.Ordinal); + Assert.Contains("not this database's allocation", note, StringComparison.Ordinal); + + var names = json.EnumerateObject().Select(p => p.Name).ToList(); + Assert.Equal(names.IndexOf("service_objective") + 1, names.IndexOf("vcore_count")); + Assert.Equal(5, json.GetProperty("engine_edition").GetInt32()); + } + + [Fact] + public void GetServerProperties_OnAzureSqlDatabase_WithNoVcores_StillHidesTheHost_AndReturnsANullVcoreCount() + { + var json = Payload(HostRow(5, "SQL Azure", "S0", null)); + + foreach (var key in s_hostKeys) + Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + Assert.Equal(JsonValueKind.Null, json.GetProperty("vcore_count").ValueKind); + Assert.True(json.TryGetProperty("hardware_note", out _)); + } + + [Fact] + public void GetServerProperties_OnEdition3_IsUnchanged() + { + var json = Payload(HostRow(3, "Enterprise Edition (64-bit)", null, null)); + + Assert.Equal(2, json.GetProperty("cpu_count").GetInt32()); + Assert.Equal(64, json.GetProperty("hyperthread_ratio").GetInt32()); + Assert.Equal(0, json.GetProperty("socket_count").GetInt32()); + Assert.Equal(32, json.GetProperty("cores_per_socket").GetInt32()); + Assert.Equal(933_888L, json.GetProperty("physical_memory_mb").GetInt64()); + Assert.False(json.TryGetProperty("vcore_count", out _), "an engine that is not an Azure SQL Database gets no new key"); + Assert.False(json.TryGetProperty("hardware_note", out _), "an engine that is not an Azure SQL Database gets no new key"); + Assert.Equal( + new[] + { + "server", "captured_at", "edition", "engine_edition", "product_version", "product_level", "product_update_level", + "cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb", "is_hadr_enabled", + "is_clustered", "enterprise_features", "service_objective", "utc_offset_minutes", "time_zone_id", "time_zone_note", + }, + json.EnumerateObject().Select(p => p.Name).ToArray()); + } + + [Fact] + public void ServerPropertiesRead_CarriesTheStoredVcoreCount() + { + Assert.Contains("vcore_count", DarlingDataReader.LatestServerPropertiesSql, StringComparison.Ordinal); + Assert.Equal(1, HostRow(5, "SQL Azure", "GP_S_Gen5_1", 1).VcoreCount); + } + + // ── the web Server Properties list ── + + [Fact] + public void WebServerProperties_DescriptorHidesTheHostFive_OnEdition5_AndShowsTheServiceObjectiveAndVcores() + { + var js = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "server-tabs.js"); + var start = js.IndexOf("const PROPERTY_STATS = [", StringComparison.Ordinal); + Assert.True(start > 0, "PROPERTY_STATS is missing"); + var list = js[start..js.IndexOf("];", start, StringComparison.Ordinal)]; + + Assert.Contains("const AZURE_SQL_DATABASE = { key: \"engine_edition\", equals: 5 };", js, StringComparison.Ordinal); + foreach (var key in s_hostKeys) + { + var line = list.Split('\n').Single(l => l.Contains($"key: \"{key}\"", StringComparison.Ordinal)); + Assert.Contains("hideWhen: AZURE_SQL_DATABASE", line, StringComparison.Ordinal); + } + + Assert.Contains("{ key: \"vcore_count\", label: \"vCores\", format: \"int\", showWhen: AZURE_SQL_DATABASE }", list, StringComparison.Ordinal); + var objective = list.Split('\n').Single(l => l.Contains("key: \"service_objective\"", StringComparison.Ordinal)); + Assert.DoesNotContain("hideWhen", objective, StringComparison.Ordinal); + Assert.DoesNotContain("showWhen", objective, StringComparison.Ordinal); + } + + [Fact] + public void WebStatRenderer_DropsTilesByTheirCondition_OnEdition5_AndKeepsEveryTileOtherwise() + { + var panels = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "panels.js"); + Assert.Contains("const stats = visibleStats(Array.isArray(desc.stats) ? desc.stats : [], data);", panels, StringComparison.Ordinal); + + var script = """ + const m = await import(process.argv[1]); + const H = { key: "engine_edition", equals: 5 }; + const stats = [ + { key: "cpu_count", hideWhen: H }, { key: "physical_memory_mb", hideWhen: H }, + { key: "service_objective" }, { key: "vcore_count", showWhen: H }, + ]; + const names = (d) => m.visibleStats(stats, d).map((s) => s.key).join(","); + console.log(JSON.stringify({ e5: names({ engine_edition: 5 }), e3: names({ engine_edition: 3 }), none: names({}) })); + """; + var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + psi.ArgumentList.Add("--input-type=module"); + psi.ArgumentList.Add("-e"); + psi.ArgumentList.Add(script); + psi.ArgumentList.Add(new Uri(PathTo("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "panels.js")).AbsoluteUri); + + Process proc; + try { proc = Process.Start(psi)!; } + catch (Win32Exception) { return; } // Node is not installed; the source pins above still hold the change in place. + + using (proc) + { + var output = proc.StandardOutput.ReadToEnd().Trim(); + var error = proc.StandardError.ReadToEnd(); + Assert.True(proc.WaitForExit(20000) && proc.ExitCode == 0, "node failed: " + error); + using var doc = JsonDocument.Parse(output); + Assert.Equal("service_objective,vcore_count", doc.RootElement.GetProperty("e5").GetString()); + Assert.Equal("cpu_count,physical_memory_mb,service_objective", doc.RootElement.GetProperty("e3").GetString()); + Assert.Equal("cpu_count,physical_memory_mb,service_objective", doc.RootElement.GetProperty("none").GetString()); + } + } + + // ── FinOps Server Inventory row ── + + [Fact] + public void InventoryRow_OnAzureSqlDatabase_LeavesTheMemoryAndCoreCellsBlank_AndSaysWhy() + { + var row = new ServerPropertyRow + { + Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, PhysicalMemoryMb = 933_888, SocketCount = 0, CoresPerSocket = 32, + }; + + Assert.Null(row.CpuCount); + Assert.Null(row.PhysicalMemoryMb); + Assert.Null(row.SocketCount); + Assert.Null(row.CoresPerSocket); + Assert.Equal(ServerHardwareScope.InventoryHardwareNote, row.HardwareUnavailableReason); + } + + [Fact] + public void InventoryRow_OnAzureSqlDatabase_DoesNotDependOnTheOrderTheLoaderAssignsInAndKeepsAReadsOwnReason() + { + var row = new ServerPropertyRow { CpuCount = 2, PhysicalMemoryMb = 933_888, SocketCount = 0, CoresPerSocket = 32 }; + Assert.Equal(2, row.CpuCount); + + row.EngineEdition = 5; + row.HardwareUnavailableReason = "Hardware read denied"; + + Assert.Null(row.CpuCount); + Assert.Equal("Hardware read denied", row.HardwareUnavailableReason); + } + + [Fact] + public void InventoryRow_OnEdition3_KeepsItsHardware_AndHasNoNote() + { + var row = new ServerPropertyRow + { + Edition = "Enterprise Edition (64-bit)", EngineEdition = 3, CpuCount = 16, PhysicalMemoryMb = 131_072, SocketCount = 2, CoresPerSocket = 4, + }; + + Assert.Equal(16, row.CpuCount); + Assert.Equal(131_072L, row.PhysicalMemoryMb); + Assert.Equal(2, row.SocketCount); + Assert.Equal(4, row.CoresPerSocket); + Assert.Null(row.HardwareUnavailableReason); + } + + // ── FinOps utilization card ── + + [Fact] + public void OverProvisionedSentence_OnAzureSqlDatabase_CitesNoPhysicalMemoryShare() + { + var onAzure = ServerHardwareScope.OverProvisionedExplanation(3.2m, 11, 0.4, azureSqlDatabase: true); + var onBox = ServerHardwareScope.OverProvisionedExplanation(3.2m, 11, 0.4, azureSqlDatabase: false); + + Assert.DoesNotContain("physical", onAzure, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("buffer pool", onAzure, StringComparison.OrdinalIgnoreCase); + Assert.Equal( + "CPU is lightly loaded (avg 3.2%, max 11%) and buffer pool uses only 0% of physical RAM. This server may have more resources than it needs.", + onBox); + } + + [Fact] + public void FinOpsUtilizationCard_AsksTheSharedRule_BeforeItShowsPhysicalMemory() + { + var tab = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "FinOpsTab.Loaders.cs"); + var read = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Utilization.cs"); + + Assert.Contains("ServerHardwareScope.HardwareIsTheHosts(data.EngineEdition)", tab, StringComparison.Ordinal); + Assert.Contains("FinOpsPhysicalMemoryText.Text = azureSqlDb ? ServerHardwareScope.NotApplicable", tab, StringComparison.Ordinal); + Assert.Contains("ServerHardwareScope.OverProvisionedExplanation(", tab, StringComparison.Ordinal); + Assert.Contains("ServerHardwareScope.RightSizedExplanation(", tab, StringComparison.Ordinal); + Assert.DoesNotContain("of physical RAM", tab, StringComparison.Ordinal); + Assert.Contains("COALESCE(vcore_count, cpu_count) AS cpu_count, engine_edition", read, StringComparison.Ordinal); + Assert.Contains("EngineEdition = reader.IsDBNull(16)", read, StringComparison.Ordinal); + } + + [Fact] + public void GetServerPropertiesTool_BuildsItsPayloadThroughTheScopedBuilder() + { + var tool = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"); + + Assert.Contains("return ServerPropertiesPayload(resolved.ServerName, row);", tool, StringComparison.Ordinal); + Assert.Contains("ServerHardwareScope.ScopeServerPropertiesPayload(", tool, StringComparison.Ordinal); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs index 379207452..967afc447 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs @@ -265,12 +265,14 @@ public sealed record ServerListRow( /// is the offset IN FORCE at that collection (V16; null on a pre-V16 row) /// and the engine's own zone name beside it (V134, #3653 item 13, Q8) — null /// where the engine cannot say, which is every SQL Server before 2022 and a real, common value rather than - /// a miss. + /// a miss. is the vCore count the collector parses from an Azure SQL Database's + /// service objective (null off Azure SQL Database, and for a DTU-model objective that names no vCores) — what + /// describes the database there, where and its neighbours describe the HOST. public sealed record ServerPropertiesReadRow( DateTime CollectionTime, string Edition, string ProductVersion, string ProductLevel, string? ProductUpdateLevel, int EngineEdition, int CpuCount, int HyperthreadRatio, long PhysicalMemoryMb, int SocketCount, int CoresPerSocket, bool IsHadrEnabled, bool IsClustered, string? EnterpriseFeatures, string? ServiceObjective, - int? UtcOffsetMinutes = null, string? TimeZoneId = null); + int? UtcOffsetMinutes = null, string? TimeZoneId = null, int? VcoreCount = null); /* ─────────────────────────── CPU ─────────────────────────── */ @@ -3023,7 +3025,8 @@ private static int AgeSeconds(DateTime nowUtc, PerServerCollectionHealthScan rea enterprise_features, service_objective, utc_offset_minutes, - time_zone_id + time_zone_id, + vcore_count FROM server_properties WHERE server_id = $1 ORDER BY collection_time DESC @@ -3061,7 +3064,8 @@ LIMIT 1 /* V16 / V134 (#3653 item 13): both nullable in the store and both read null-or-value — a 0 offset would claim UTC of a row that never recorded one, and an empty zone would claim a name. */ reader.IsDBNull(15) ? null : reader.GetInt32(15), - reader.IsDBNull(16) ? null : reader.GetString(16)); + reader.IsDBNull(16) ? null : reader.GetString(16), + reader.IsDBNull(17) ? null : reader.GetInt32(17)); } /* ─────────────────────────── parameter helpers ─────────────────────────── */ diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index e8911e7ad..172df01b9 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -1751,7 +1751,7 @@ a status word (about 200 characters at most), and only a regressed row carries o }; } - [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, memory, socket/core topology, HADR, clustering, and the clock (utc_offset_minutes, time_zone_id). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected, and on a stalled collector it is the only sign of staleness. time_zone_id is CURRENT_TIMEZONE_ID() (SQL Server 2022+/Azure SQL only); null means a pre-2022 engine, so only the offset in force at captured_at is known, and an instant across a DST transition from it can read an hour off. <> Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, clustering, and the server's clock: utc_offset_minutes is the UTC offset in force when the snapshot was collected, and time_zone_id is the engine's own time-zone name (CURRENT_TIMEZONE_ID(), SQL Server 2022+ and Azure SQL only) - a null time_zone_id means a pre-2022 engine, where only the offset is known and any instant on the far side of a DST transition from the snapshot is placed an hour off by that offset. Use for capacity planning and edition-aware recommendations. LATEST IS A TIME: this reads the newest properties snapshot, not a window, and captured_at is the instant it was collected - a core count or memory figure here is what the server reported AT that stamp, and on a server whose collector has stalled the stamp is the only thing that says how stale it is.")] + [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, memory, socket/core topology, HADR, clustering, and the clock (utc_offset_minutes, time_zone_id). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected, and on a stalled collector it is the only sign of staleness. time_zone_id is CURRENT_TIMEZONE_ID() (SQL Server 2022+/Azure SQL only); null means a pre-2022 engine, so only the offset in force at captured_at is known, and an instant across a DST transition from it can read an hour off. <> Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, clustering, and the server's clock: utc_offset_minutes is the UTC offset in force when the snapshot was collected, and time_zone_id is the engine's own time-zone name (CURRENT_TIMEZONE_ID(), SQL Server 2022+ and Azure SQL only) - a null time_zone_id means a pre-2022 engine, where only the offset is known and any instant on the far side of a DST transition from the snapshot is placed an hour off by that offset. Use for capacity planning and edition-aware recommendations. LATEST IS A TIME: this reads the newest properties snapshot, not a window, and captured_at is the instant it was collected - a core count or memory figure here is what the server reported AT that stamp, and on a server whose collector has stalled the stamp is the only thing that says how stale it is. ON AN AZURE SQL DATABASE (engine_edition 5) the host's hardware is not the database's allocation: cpu_count, hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb come back null with a hardware_note, and service_objective with vcore_count says what the database is given.")] public static async Task GetServerProperties( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, @@ -1767,41 +1767,7 @@ public static async Task GetServerProperties( return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "server_properties", cancellationToken) ?? McpHelpers.Status("unavailable", "No server properties available. The properties collector may not have run yet."); - return JsonSerializer.Serialize(new - { - server = resolved.ServerName, - /* #3653: captured_at, the #3637 census's one spelling for a latest read's stamp. This tool - stamped itself as collection_time before that vocabulary existed and was carried as a - named allowance; the web surface read none of its keys by that name, so the cut-over is - clean - no alias, because the census is the contract and a second key for one instant is - the drift it exists to refuse. */ - captured_at = row.CollectionTime.ToString("o"), - edition = row.Edition, - engine_edition = row.EngineEdition, - product_version = row.ProductVersion, - product_level = row.ProductLevel, - product_update_level = string.IsNullOrEmpty(row.ProductUpdateLevel) ? null : row.ProductUpdateLevel, - cpu_count = row.CpuCount, - hyperthread_ratio = row.HyperthreadRatio, - socket_count = row.SocketCount, - cores_per_socket = row.CoresPerSocket, - physical_memory_mb = row.PhysicalMemoryMb, - is_hadr_enabled = row.IsHadrEnabled, - is_clustered = row.IsClustered, - enterprise_features = string.IsNullOrEmpty(row.EnterpriseFeatures) ? null : row.EnterpriseFeatures, - service_objective = string.IsNullOrEmpty(row.ServiceObjective) ? null : row.ServiceObjective, - /* V134 (#3653 item 13, Q8): the clock pair. The offset is the one IN FORCE at captured_at, - which is exact for an instant on the same side of a DST transition and an hour wrong for - one on the other (#3231); the zone is what can tell the two apart. NULL is a real answer - for the zone - CURRENT_TIMEZONE_ID() is SQL Server 2022+ / Azure SQL only - and the note - says what it means rather than leaving a caller to read it as "not collected". Byte-for-byte - the keys Lite's tool emits. */ - utc_offset_minutes = row.UtcOffsetMinutes, - time_zone_id = string.IsNullOrEmpty(row.TimeZoneId) ? null : row.TimeZoneId, - time_zone_note = string.IsNullOrEmpty(row.TimeZoneId) - ? "time_zone_id is null: a pre-2022 engine (CURRENT_TIMEZONE_ID() is SQL Server 2022+ / Azure SQL only), so only the offset in force at captured_at is known." - : "time_zone_id is the engine's own zone (CURRENT_TIMEZONE_ID()); utc_offset_minutes is the offset that zone had at captured_at." - }, McpHelpers.JsonOptions); + return ServerPropertiesPayload(resolved.ServerName, row); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -1809,6 +1775,58 @@ the keys Lite's tool emits. */ } } + /// + /// The get_server_properties payload for one snapshot. On an Azure SQL Database (engine edition 5) the stored + /// cpu_count, hyperthread_ratio, socket_count, cores_per_socket and + /// physical_memory_mb are the HOST's, so they come back null, vcore_count (what the service objective + /// gives the database) rides beside service_objective, and a hardware_note says why. Every other + /// edition keeps the payload it always had, key for key. Lite's tool emits the same shape in the same words. + /// + internal static string ServerPropertiesPayload(string serverName, DarlingDataReader.ServerPropertiesReadRow row) + { + var payload = new + { + server = serverName, + /* #3653: captured_at, the #3637 census's one spelling for a latest read's stamp. This tool + stamped itself as collection_time before that vocabulary existed and was carried as a + named allowance; the web surface read none of its keys by that name, so the cut-over is + clean - no alias, because the census is the contract and a second key for one instant is + the drift it exists to refuse. */ + captured_at = row.CollectionTime.ToString("o"), + edition = row.Edition, + engine_edition = row.EngineEdition, + product_version = row.ProductVersion, + product_level = row.ProductLevel, + product_update_level = string.IsNullOrEmpty(row.ProductUpdateLevel) ? null : row.ProductUpdateLevel, + cpu_count = row.CpuCount, + hyperthread_ratio = row.HyperthreadRatio, + socket_count = row.SocketCount, + cores_per_socket = row.CoresPerSocket, + physical_memory_mb = row.PhysicalMemoryMb, + is_hadr_enabled = row.IsHadrEnabled, + is_clustered = row.IsClustered, + enterprise_features = string.IsNullOrEmpty(row.EnterpriseFeatures) ? null : row.EnterpriseFeatures, + service_objective = string.IsNullOrEmpty(row.ServiceObjective) ? null : row.ServiceObjective, + /* V134 (#3653 item 13, Q8): the clock pair. The offset is the one IN FORCE at captured_at, + which is exact for an instant on the same side of a DST transition and an hour wrong for + one on the other (#3231); the zone is what can tell the two apart. NULL is a real answer + for the zone - CURRENT_TIMEZONE_ID() is SQL Server 2022+ / Azure SQL only - and the note + says what it means rather than leaving a caller to read it as "not collected". Byte-for-byte + the keys Lite's tool emits. */ + utc_offset_minutes = row.UtcOffsetMinutes, + time_zone_id = string.IsNullOrEmpty(row.TimeZoneId) ? null : row.TimeZoneId, + time_zone_note = string.IsNullOrEmpty(row.TimeZoneId) + ? "time_zone_id is null: a pre-2022 engine (CURRENT_TIMEZONE_ID() is SQL Server 2022+ / Azure SQL only), so only the offset in force at captured_at is known." + : "time_zone_id is the engine's own zone (CURRENT_TIMEZONE_ID()); utc_offset_minutes is the offset that zone had at captured_at." + }; + + if (!ServerHardwareScope.HardwareIsTheHosts(row.EngineEdition)) + return JsonSerializer.Serialize(payload, McpHelpers.JsonOptions); + + var scoped = JsonSerializer.SerializeToNode(payload, McpHelpers.JsonOptions)!.AsObject(); + return ServerHardwareScope.ScopeServerPropertiesPayload(scoped, row.VcoreCount).ToJsonString(McpHelpers.JsonOptions); + } + /* ─────────────────────────── list_servers helpers ─────────────────────────── */ /// diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js index 85ff06dca..ec316e165 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js @@ -2436,18 +2436,25 @@ const OVERVIEW_STATS = [ { key: "last_collection", label: "Last collection", format: "reltime", small: true }, ]; +/* An Azure SQL Database (engine_edition 5) reports the HOST's logical CPUs, sockets, cores per socket, hyperthread + ratio and physical memory, none of which is the database's allocation (a 1-vCore serverless database read "2 CPUs, + 0 sockets, 32 cores/socket, HT ratio 64, 911.9 GB"). get_server_properties returns those five as null there, and + these tiles are not drawn; the service objective and its vCores say what the database is given. */ +const AZURE_SQL_DATABASE = { key: "engine_edition", equals: 5 }; + const PROPERTY_STATS = [ { key: "product_version", label: "Version", format: "text", small: true }, { key: "edition", label: "Edition", format: "text", small: true }, { key: "product_level", label: "Level", format: "text", small: true }, - { key: "cpu_count", label: "Logical CPUs", format: "int" }, - { key: "socket_count", label: "Sockets", format: "int" }, - { key: "cores_per_socket", label: "Cores/socket", format: "int" }, - { key: "hyperthread_ratio", label: "HT ratio", format: "int" }, - { key: "physical_memory_mb", label: "Physical memory", format: "mb" }, + { key: "cpu_count", label: "Logical CPUs", format: "int", hideWhen: AZURE_SQL_DATABASE }, + { key: "socket_count", label: "Sockets", format: "int", hideWhen: AZURE_SQL_DATABASE }, + { key: "cores_per_socket", label: "Cores/socket", format: "int", hideWhen: AZURE_SQL_DATABASE }, + { key: "hyperthread_ratio", label: "HT ratio", format: "int", hideWhen: AZURE_SQL_DATABASE }, + { key: "physical_memory_mb", label: "Physical memory", format: "mb", hideWhen: AZURE_SQL_DATABASE }, { key: "is_clustered", label: "Clustered", format: "bool" }, { key: "is_hadr_enabled", label: "Always On", format: "bool" }, { key: "service_objective", label: "Service objective", format: "text", small: true }, + { key: "vcore_count", label: "vCores", format: "int", showWhen: AZURE_SQL_DATABASE }, ]; const DAILY_STATS = [ diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js index f0b514ad7..23fcf6005 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js @@ -197,11 +197,23 @@ function cell(row, c) { return el("td", { class: cls.join(" ") || null, text }); } +/* A stat tile may depend on another value in the same payload: `hideWhen: { key, equals }` drops it when that value + equals `equals`, `showWhen: { key, equals }` keeps it only then. The Server Properties list uses the pair on + `engine_edition`: an Azure SQL Database (5) reports the HOST's CPUs, sockets and memory, which are not the + database's, so those tiles are not drawn and its vCores tile is (a tile with neither field is always drawn). */ +export function visibleStats(stats, data) { + return stats.filter((s) => { + if (s.hideWhen && getPath(data, s.hideWhen.key) === s.hideWhen.equals) return false; + if (s.showWhen && getPath(data, s.showWhen.key) !== s.showWhen.equals) return false; + return true; + }); +} + /* stat: desc = { stats:[{key,label,format,small?,sev?}], emptyText? } over the tool's top-level object. A stat descriptor may carry a PRE-COMPUTED severity (`sev`/`severity`, e.g. "Critical") — colored here from that hint only (R1: the browser never re-derives a band); absent the hint the value keeps the default color. */ function vizStat(data, desc) { - const stats = Array.isArray(desc.stats) ? desc.stats : []; + const stats = visibleStats(Array.isArray(desc.stats) ? desc.stats : [], data); if (!stats.length) return emptyStrip(NO_FIELDS_MSG); /* The stat twin of vizLine's zero-points guard, and it exists for the same failure (#2530). Several reads answer their HEALTHY case with a data body carrying a prose `finding` and none of the summary keys — diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs index e6a291b58..d0b8185ce 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs @@ -289,18 +289,22 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) var bpPct = data.PhysicalMemoryMb > 0 ? (double)data.BufferPoolMb / data.PhysicalMemoryMb * 100.0 : 0; - FinOpsMemoryRatioText.Text = $"{bpPct:N0}%"; - SetBar(FinOpsMemoryRatioBar, FinOpsMemRatioFilled, FinOpsMemRatioEmpty, bpPct); - FinOpsPhysicalMemoryText.Text = $"{data.PhysicalMemoryMb:N0} MB"; + /* An Azure SQL Database's physical memory is the HOST's, not the database's allocation, so neither the figure nor + the buffer pool's share of it is shown; the verdict and the health score below still read the stored value. */ + var azureSqlDb = ServerHardwareScope.HardwareIsTheHosts(data.EngineEdition); + FinOpsMemoryRatioText.Text = azureSqlDb ? ServerHardwareScope.NotApplicable : $"{bpPct:N0}%"; + SetBar(FinOpsMemoryRatioBar, FinOpsMemRatioFilled, FinOpsMemRatioEmpty, azureSqlDb ? 0 : bpPct); + + FinOpsPhysicalMemoryText.Text = azureSqlDb ? ServerHardwareScope.NotApplicable : $"{data.PhysicalMemoryMb:N0} MB"; FinOpsTargetMemoryText.Text = $"{data.TargetMemoryMb:N0} MB"; FinOpsTotalMemoryText.Text = $"{data.TotalMemoryMb:N0} MB"; FinOpsBufferPoolText.Text = $"{data.BufferPoolMb:N0} MB"; FinOpsClassificationExplanation.Text = data.ProvisioningStatus switch { - "RIGHT_SIZED" => $"CPU is moderately loaded (avg {data.AvgCpuPct:N1}%, p95 {data.P95CpuPct:N1}%) and memory is well-utilized (buffer pool uses {bpPct:N0}% of physical RAM). No action needed.", - "OVER_PROVISIONED" => $"CPU is lightly loaded (avg {data.AvgCpuPct:N1}%, max {data.MaxCpuPct}%) and buffer pool uses only {bpPct:N0}% of physical RAM. This server may have more resources than it needs.", + "RIGHT_SIZED" => ServerHardwareScope.RightSizedExplanation(data.AvgCpuPct, data.P95CpuPct, bpPct, azureSqlDb), + "OVER_PROVISIONED" => ServerHardwareScope.OverProvisionedExplanation(data.AvgCpuPct, data.MaxCpuPct, bpPct, azureSqlDb), /* The reason comes from the same place as the verdict. This branch used to read "P95CpuPct > 85 ? CPU : memory ratio is {x} (threshold: 0.95)", so a server flagged for grant pressure or worker saturation would have been explained as a memory ratio that no longer diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs index 75ab68ac0..68d0bf1e2 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs @@ -55,7 +55,7 @@ ORDER BY collection_time DESC LIMIT 1 ), server_info AS ( - SELECT COALESCE(vcore_count, cpu_count) AS cpu_count + SELECT COALESCE(vcore_count, cpu_count) AS cpu_count, engine_edition FROM server_properties WHERE server_id = $1 ORDER BY collection_time DESC @@ -91,7 +91,8 @@ FROM v_memory_grant_stats COALESCE(g.max_grant_waiters, 0), COALESCE(g.grant_timeouts, 0), COALESCE(g.forced_grants, 0), - COALESCE(g.grant_utilization_pct, 0) + COALESCE(g.grant_utilization_pct, 0), + s.engine_edition FROM cpu_stats c CROSS JOIN mem_latest m LEFT JOIN server_info s ON true @@ -147,7 +148,8 @@ LEFT JOIN server_info s ON true GrantUtilizationPct = reader.IsDBNull(15) ? 0m : Convert.ToDecimal(reader.GetValue(15)), MaxWorkersCount = maxWorkers, CurrentWorkersCount = currentWorkers, - CpuCount = reader.IsDBNull(11) ? 0 : Convert.ToInt32(reader.GetValue(11)) + CpuCount = reader.IsDBNull(11) ? 0 : Convert.ToInt32(reader.GetValue(11)), + EngineEdition = reader.IsDBNull(16) ? 0 : Convert.ToInt32(reader.GetValue(16)) }; } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs index 4159712f2..7d78d01ac 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.Linq; using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Common; namespace PerformanceMonitor.Darling.Viewer; @@ -114,6 +115,11 @@ public sealed class UtilizationEfficiencyRow public int MaxWorkersCount { get; set; } public int CurrentWorkersCount { get; set; } public int CpuCount { get; set; } + + /// The engine edition of the server these figures describe (SERVERPROPERTY('EngineEdition'), 0 when unread). + /// On an Azure SQL Database (5) is the HOST's, so the card does not show it and its + /// verdict sentence cites no share of it; the verdict itself is unchanged. + public int EngineEdition { get; set; } public string ProvisioningStatus { get; set; } = ""; // FinOps cost — proportional to the server's monthly budget (0 = hidden) @@ -252,10 +258,23 @@ public sealed class ServerPropertyRow public string ProductVersion { get; set; } = ""; public string HostOsVersion { get; set; } = ""; public int EngineEdition { get; set; } - public int CpuCount { get; set; } - public long PhysicalMemoryMb { get; set; } - public int? SocketCount { get; set; } - public int? CoresPerSocket { get; set; } + + /* The four hardware cells below read as ABSENT for an Azure SQL Database (engine edition 5): its collected + sys.dm_os_sys_info values are the HOST's, not the database's allocation (a 1-vCore serverless database read 2 CPUs, + 0 sockets, 32 cores per socket and 911.9 GB), and the grid draws an absent value as a blank cell. The stored values + are kept behind the properties, so the order the loader assigns them in does not matter and no calculation loses + its input. */ + private int _cpuCount; + private long _physicalMemoryMb; + private int? _socketCount; + private int? _coresPerSocket; + private string? _hardwareUnavailableReason; + private bool HostHardware => ServerHardwareScope.HardwareIsTheHosts(EngineEdition); + + public int? CpuCount { get => HostHardware ? null : _cpuCount; set => _cpuCount = value ?? 0; } + public long? PhysicalMemoryMb { get => HostHardware ? null : _physicalMemoryMb; set => _physicalMemoryMb = value ?? 0L; } + public int? SocketCount { get => HostHardware ? null : _socketCount; set => _socketCount = value; } + public int? CoresPerSocket { get => HostHardware ? null : _coresPerSocket; set => _coresPerSocket = value; } /// The server's LOCAL start clock (sys.dm_os_sys_info) — stored verbatim, shown as-is like Lite. public DateTime? SqlServerStartTime { get; set; } /// @@ -344,7 +363,12 @@ public sealed class ServerPropertyRow /// what #1663 made possible — before it, a login without VIEW SERVER STATE lost the ENTIRE server_properties /// row, so there was nothing to annotate. /// - public string? HardwareUnavailableReason { get; set; } + public string? HardwareUnavailableReason + { + /* An Azure SQL Database's blank hardware cells say why, in the column that already carries a read's own reason. */ + get => _hardwareUnavailableReason ?? (HostHardware ? ServerHardwareScope.InventoryHardwareNote : null); + set => _hardwareUnavailableReason = value; + } /// Per-server FinOps budget (servers.monthly_cost_usd from darling.json); 0 hides the cost columns. public decimal MonthlyCost { get; set; } diff --git a/Lite.Tests/AzureSqlDatabaseHardwareTests.cs b/Lite.Tests/AzureSqlDatabaseHardwareTests.cs new file mode 100644 index 000000000..9839cf34e --- /dev/null +++ b/Lite.Tests/AzureSqlDatabaseHardwareTests.cs @@ -0,0 +1,273 @@ +/* + * 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.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitor.Common; +using PerformanceMonitorLite.Mcp; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// On an Azure SQL Database (engine edition 5) the collected server_properties hardware columns are the +/// HOST's: a 1-vCore serverless General Purpose database read 2 logical CPUs, 0 sockets, 32 cores per socket, a +/// hyperthread ratio of 64 and 911.9 GB of physical memory. Nothing may present them as the database's. What does +/// describe the database is the service objective and the vcore_count parsed from it. +/// +/// The rule is pinned where it is applied: get_server_properties (the payload the web Server Properties +/// list reads too), the FinOps Server Inventory row the grid binds, and the FinOps utilization card's sentences. Every +/// test has an edition-3 twin that keeps today's values, so the rule cannot leak into the boxed engine. The +/// Darling.Tests twin pins the same table for the other app, in the same words. +/// +public sealed class AzureSqlDatabaseHardwareTests : IClassFixture, IDisposable +{ + private const int ServerId = -487_001; + private readonly SharedDuckDbFixture _fixture; + private DuckDBConnection? _seedConn; + + public AzureSqlDatabaseHardwareTests(SharedDuckDbFixture fixture) + { + fixture.ResetData(); + _fixture = fixture; + } + + public void Dispose() => _seedConn?.Dispose(); + + private static ServerPropertiesRow HostRow(int engineEdition, string edition, string? objective, int? vcores) => new() + { + Edition = edition, + ProductVersion = "12.0.2000.8", + ProductLevel = "RTM", + EngineEdition = engineEdition, + CpuCount = 2, + HyperthreadRatio = 64, + PhysicalMemoryMb = 933_888, + SocketCount = 0, + CoresPerSocket = 32, + ServiceObjective = objective ?? "", + VcoreCount = vcores, + CollectionTime = new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), + }; + + private static JsonElement Payload(ServerPropertiesRow row) => + JsonDocument.Parse(McpServerInfoTools.ServerPropertiesPayload("Srv", row)).RootElement.Clone(); + + private static readonly string[] s_hostKeys = + ["cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; + + // ── get_server_properties ── + + [Fact] + public void GetServerProperties_OnAzureSqlDatabase_ReturnsTheHostsFiveAsNull_AndTheDatabasesVcores_WithANote() + { + var json = Payload(HostRow(5, "SQL Azure", "GP_S_Gen5_1", 1)); + + foreach (var key in s_hostKeys) + Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + + Assert.Equal("GP_S_Gen5_1", json.GetProperty("service_objective").GetString()); + Assert.Equal(1, json.GetProperty("vcore_count").GetInt32()); + var note = json.GetProperty("hardware_note").GetString(); + Assert.Equal(ServerHardwareScope.McpHardwareNote, note); + Assert.Contains("service_objective", note, StringComparison.Ordinal); + Assert.Contains("vcore_count", note, StringComparison.Ordinal); + Assert.Contains("not this database's allocation", note, StringComparison.Ordinal); + + /* vcore_count sits beside service_objective, and the rest of the payload is still there. */ + var names = json.EnumerateObject().Select(p => p.Name).ToList(); + Assert.Equal(names.IndexOf("service_objective") + 1, names.IndexOf("vcore_count")); + Assert.Equal("SQL Azure", json.GetProperty("edition").GetString()); + Assert.Equal(5, json.GetProperty("engine_edition").GetInt32()); + } + + [Fact] + public void GetServerProperties_OnAzureSqlDatabase_WithNoVcores_StillHidesTheHost_AndReturnsANullVcoreCount() + { + var json = Payload(HostRow(5, "SQL Azure", "S0", null)); + + foreach (var key in s_hostKeys) + Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + Assert.Equal(JsonValueKind.Null, json.GetProperty("vcore_count").ValueKind); + Assert.True(json.TryGetProperty("hardware_note", out _)); + } + + [Fact] + public void GetServerProperties_OnEdition3_IsUnchanged() + { + var json = Payload(HostRow(3, "Enterprise Edition (64-bit)", null, null)); + + Assert.Equal(2, json.GetProperty("cpu_count").GetInt32()); + Assert.Equal(64, json.GetProperty("hyperthread_ratio").GetInt32()); + Assert.Equal(0, json.GetProperty("socket_count").GetInt32()); + Assert.Equal(32, json.GetProperty("cores_per_socket").GetInt32()); + Assert.Equal(933_888L, json.GetProperty("physical_memory_mb").GetInt64()); + Assert.False(json.TryGetProperty("vcore_count", out _), "an engine that is not an Azure SQL Database gets no new key"); + Assert.False(json.TryGetProperty("hardware_note", out _), "an engine that is not an Azure SQL Database gets no new key"); + Assert.Equal( + new[] + { + "server", "captured_at", "edition", "engine_edition", "product_version", "product_level", "product_update_level", + "cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb", "is_hadr_enabled", + "is_clustered", "enterprise_features", "service_objective", "utc_offset_minutes", "time_zone_id", "time_zone_note", + }, + json.EnumerateObject().Select(p => p.Name).ToArray()); + } + + [Fact] + public async Task LatestServerProperties_ReadsTheVcoreCountTheCollectorStored() + { + await SeedAsync(engineEdition: 5, objective: "GP_S_Gen5_1", vcores: 1); + + var row = await new LocalDataService(_fixture.DuckDb).GetLatestServerPropertiesAsync(ServerId); + + Assert.NotNull(row); + Assert.Equal(1, row!.VcoreCount); + Assert.Equal(5, row.EngineEdition); + var json = Payload(row); + Assert.Equal(1, json.GetProperty("vcore_count").GetInt32()); + Assert.Equal(JsonValueKind.Null, json.GetProperty("physical_memory_mb").ValueKind); + } + + private async Task SeedAsync(int engineEdition, string? objective, int? vcores) + { + using var readLock = _fixture.DuckDb.AcquireReadLock(); + if (_seedConn is null) + { + _seedConn = _fixture.DuckDb.CreateConnection(); + await _seedConn.OpenAsync(); + } + + using var cmd = _seedConn.CreateCommand(); + cmd.CommandText = @" +INSERT INTO server_properties + (collection_id, collection_time, server_id, server_name, edition, product_version, product_level, engine_edition, + cpu_count, hyperthread_ratio, physical_memory_mb, socket_count, cores_per_socket, service_objective, vcore_count) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)"; + void P(object? v) => cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); + P(-487_001L); P(DateTime.UtcNow); P(ServerId); P("AzureHardwareSrv"); P("SQL Azure"); P("12.0.2000.8"); P("RTM"); + P(engineEdition); P(2); P(64); P(933_888L); P(0); P(32); P(objective); P(vcores); + await cmd.ExecuteNonQueryAsync(); + } + + // ── FinOps Server Inventory row ── + + [Fact] + public void InventoryRow_OnAzureSqlDatabase_LeavesTheMemoryAndCoreCellsBlank_AndSaysWhy() + { + var row = new ServerPropertyRow + { + Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, PhysicalMemoryMb = 933_888, SocketCount = 0, CoresPerSocket = 32, + }; + + Assert.Null(row.CpuCount); + Assert.Null(row.PhysicalMemoryMb); + Assert.Null(row.SocketCount); + Assert.Null(row.CoresPerSocket); + Assert.Equal(ServerHardwareScope.InventoryHardwareNote, row.HardwareUnavailableReason); + } + + [Fact] + public void InventoryRow_OnAzureSqlDatabase_DoesNotDependOnTheOrderTheLoaderAssignsInAndKeepsAReadsOwnReason() + { + var row = new ServerPropertyRow { CpuCount = 2, PhysicalMemoryMb = 933_888, SocketCount = 0, CoresPerSocket = 32 }; + Assert.Equal(2, row.CpuCount); + + row.EngineEdition = 5; + row.HardwareUnavailableReason = "Hardware read denied"; + + Assert.Null(row.CpuCount); + Assert.Equal("Hardware read denied", row.HardwareUnavailableReason); + } + + [Fact] + public void InventoryRow_OnEdition3_KeepsItsHardware_AndHasNoNote() + { + var row = new ServerPropertyRow + { + Edition = "Enterprise Edition (64-bit)", EngineEdition = 3, CpuCount = 16, PhysicalMemoryMb = 131_072, SocketCount = 2, CoresPerSocket = 4, + }; + + Assert.Equal(16, row.CpuCount); + Assert.Equal(131_072L, row.PhysicalMemoryMb); + Assert.Equal(2, row.SocketCount); + Assert.Equal(4, row.CoresPerSocket); + Assert.Null(row.HardwareUnavailableReason); + } + + // ── FinOps utilization card ── + + [Fact] + public void OverProvisionedSentence_OnAzureSqlDatabase_CitesNoPhysicalMemoryShare() + { + var onAzure = ServerHardwareScope.OverProvisionedExplanation(3.2m, 11, 0.4, azureSqlDatabase: true); + var onBox = ServerHardwareScope.OverProvisionedExplanation(3.2m, 11, 0.4, azureSqlDatabase: false); + + Assert.DoesNotContain("physical", onAzure, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("buffer pool", onAzure, StringComparison.OrdinalIgnoreCase); + Assert.Contains("lightly loaded", onAzure, StringComparison.Ordinal); + Assert.Equal( + "CPU is lightly loaded (avg 3.2%, max 11%) and buffer pool uses only 0% of physical RAM. This server may have more resources than it needs.", + onBox); + } + + [Fact] + public void RightSizedSentence_OnAzureSqlDatabase_CitesNoPhysicalMemoryShare_AndEdition3KeepsItsWords() + { + var onAzure = ServerHardwareScope.RightSizedExplanation(40m, 62m, 71.0, azureSqlDatabase: true); + var onBox = ServerHardwareScope.RightSizedExplanation(40m, 62m, 71.0, azureSqlDatabase: false); + + Assert.DoesNotContain("physical", onAzure, StringComparison.OrdinalIgnoreCase); + Assert.Equal( + "CPU is moderately loaded (avg 40.0%, p95 62.0%) and memory is well-utilized (buffer pool uses 71% of physical RAM). No action needed.", + onBox); + } + + // ── the wiring, pinned at the source ── + + private static string ReadRepoFile(string relativePath, [CallerFilePath] string thisFile = "") + { + var dir = Path.GetDirectoryName(thisFile)!; + var parts = relativePath.Split('/'); + while (dir is not null && !File.Exists(Path.Combine(new[] { dir }.Concat(parts).ToArray()))) + dir = Path.GetDirectoryName(dir); + + Assert.NotNull(dir); + return File.ReadAllText(Path.Combine(new[] { dir! }.Concat(parts).ToArray())); + } + + [Fact] + public void FinOpsUtilizationCard_AsksTheSharedRule_BeforeItShowsPhysicalMemory() + { + var tab = ReadRepoFile("Lite/Controls/FinOpsTab.xaml.cs"); + var read = ReadRepoFile("Lite/Services/LocalDataService.FinOps.Utilization.cs"); + + Assert.Contains("ServerHardwareScope.HardwareIsTheHosts(data.EngineEdition)", tab, StringComparison.Ordinal); + Assert.Contains("PhysicalMemoryText.Text = azureSqlDb ? ServerHardwareScope.NotApplicable", tab, StringComparison.Ordinal); + Assert.Contains("ServerHardwareScope.OverProvisionedExplanation(", tab, StringComparison.Ordinal); + Assert.Contains("ServerHardwareScope.RightSizedExplanation(", tab, StringComparison.Ordinal); + Assert.DoesNotContain("of physical RAM", tab, StringComparison.Ordinal); + Assert.Contains("COALESCE(vcore_count, cpu_count) AS cpu_count, engine_edition", read, StringComparison.Ordinal); + Assert.Contains("EngineEdition = reader.IsDBNull(16)", read, StringComparison.Ordinal); + } + + [Fact] + public void GetServerPropertiesTool_BuildsItsPayloadThroughTheScopedBuilder() + { + var tool = ReadRepoFile("Lite/Mcp/McpServerInfoTools.cs"); + + Assert.Contains("return ServerPropertiesPayload(resolved.ServerName, row);", tool, StringComparison.Ordinal); + Assert.Contains("ServerHardwareScope.ScopeServerPropertiesPayload(", tool, StringComparison.Ordinal); + } +} diff --git a/Lite/Controls/FinOpsTab.xaml.cs b/Lite/Controls/FinOpsTab.xaml.cs index 98eb60b19..2428e7c1a 100644 --- a/Lite/Controls/FinOpsTab.xaml.cs +++ b/Lite/Controls/FinOpsTab.xaml.cs @@ -419,10 +419,14 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) var bpPct = data.PhysicalMemoryMb > 0 ? (double)data.BufferPoolMb / data.PhysicalMemoryMb * 100.0 : 0; - MemoryRatioText.Text = $"{bpPct:N0}%"; - SetBar(MemoryRatioBar, MemRatioFilled, MemRatioEmpty, bpPct); - PhysicalMemoryText.Text = $"{data.PhysicalMemoryMb:N0} MB"; + /* An Azure SQL Database's physical memory is the HOST's, not the database's allocation, so neither the figure nor + the buffer pool's share of it is shown; the verdict and the health score below still read the stored value. */ + var azureSqlDb = ServerHardwareScope.HardwareIsTheHosts(data.EngineEdition); + MemoryRatioText.Text = azureSqlDb ? ServerHardwareScope.NotApplicable : $"{bpPct:N0}%"; + SetBar(MemoryRatioBar, MemRatioFilled, MemRatioEmpty, azureSqlDb ? 0 : bpPct); + + PhysicalMemoryText.Text = azureSqlDb ? ServerHardwareScope.NotApplicable : $"{data.PhysicalMemoryMb:N0} MB"; TargetMemoryText.Text = $"{data.TargetMemoryMb:N0} MB"; TotalMemoryText.Text = $"{data.TotalMemoryMb:N0} MB"; BufferPoolText.Text = $"{data.BufferPoolMb:N0} MB"; @@ -430,8 +434,8 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) /* Contextual explanation — one sentence describing WHY this classification */ ClassificationExplanation.Text = data.ProvisioningStatus switch { - "RIGHT_SIZED" => $"CPU is moderately loaded (avg {data.AvgCpuPct:N1}%, p95 {data.P95CpuPct:N1}%) and memory is well-utilized (buffer pool uses {bpPct:N0}% of physical RAM). No action needed.", - "OVER_PROVISIONED" => $"CPU is lightly loaded (avg {data.AvgCpuPct:N1}%, max {data.MaxCpuPct}%) and buffer pool uses only {bpPct:N0}% of physical RAM. This server may have more resources than it needs.", + "RIGHT_SIZED" => ServerHardwareScope.RightSizedExplanation(data.AvgCpuPct, data.P95CpuPct, bpPct, azureSqlDb), + "OVER_PROVISIONED" => ServerHardwareScope.OverProvisionedExplanation(data.AvgCpuPct, data.MaxCpuPct, bpPct, azureSqlDb), /* The reason comes from the same place as the verdict. This branch used to read "P95CpuPct > 85 ? CPU : memory ratio is {x} (threshold: 0.95)", so a server flagged for grant pressure or worker saturation would have been explained as a memory ratio that no longer diff --git a/Lite/Mcp/McpServerInfoTools.cs b/Lite/Mcp/McpServerInfoTools.cs index 6108e750d..e715a79d0 100644 --- a/Lite/Mcp/McpServerInfoTools.cs +++ b/Lite/Mcp/McpServerInfoTools.cs @@ -9,7 +9,7 @@ namespace PerformanceMonitorLite.Mcp; [McpServerToolType] public sealed class McpServerInfoTools { - [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, memory, socket/core topology, HADR, clustering, and the clock (utc_offset_minutes, time_zone_id). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected, and on a stalled collector it is the only sign of staleness. time_zone_id is CURRENT_TIMEZONE_ID() (SQL Server 2022+/Azure SQL only); null means a pre-2022 engine, so only the offset in force at captured_at is known, and an instant across a DST transition from it can read an hour off. <> Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, clustering, and the server's clock: utc_offset_minutes is the UTC offset in force when the snapshot was collected, and time_zone_id is the engine's own time-zone name (CURRENT_TIMEZONE_ID(), SQL Server 2022+ and Azure SQL only) - a null time_zone_id means a pre-2022 engine, where only the offset is known and any instant on the far side of a DST transition from the snapshot is placed an hour off by that offset. Use for capacity planning and edition-aware recommendations. LATEST IS A TIME: this reads the newest properties snapshot, not a window, and captured_at is the instant it was collected - a core count or memory figure here is what the server reported AT that stamp, and on a server whose collector has stalled the stamp is the only thing that says how stale it is.")] + [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, memory, socket/core topology, HADR, clustering, and the clock (utc_offset_minutes, time_zone_id). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected, and on a stalled collector it is the only sign of staleness. time_zone_id is CURRENT_TIMEZONE_ID() (SQL Server 2022+/Azure SQL only); null means a pre-2022 engine, so only the offset in force at captured_at is known, and an instant across a DST transition from it can read an hour off. <> Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, clustering, and the server's clock: utc_offset_minutes is the UTC offset in force when the snapshot was collected, and time_zone_id is the engine's own time-zone name (CURRENT_TIMEZONE_ID(), SQL Server 2022+ and Azure SQL only) - a null time_zone_id means a pre-2022 engine, where only the offset is known and any instant on the far side of a DST transition from the snapshot is placed an hour off by that offset. Use for capacity planning and edition-aware recommendations. LATEST IS A TIME: this reads the newest properties snapshot, not a window, and captured_at is the instant it was collected - a core count or memory figure here is what the server reported AT that stamp, and on a server whose collector has stalled the stamp is the only thing that says how stale it is. ON AN AZURE SQL DATABASE (engine_edition 5) the host's hardware is not the database's allocation: cpu_count, hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb come back null with a hardware_note, and service_objective with vcore_count says what the database is given.")] public static async Task GetServerProperties( LocalDataService dataService, ServerManager serverManager, @@ -25,40 +25,7 @@ public static async Task GetServerProperties( return await McpEngineCapability.NotCollectedStatusAsync(dataService, resolved.ServerId, resolved.ServerName, "server_properties") ?? McpHelpers.Status("unavailable", "No server properties available. The properties collector may not have run yet."); - return JsonSerializer.Serialize(new - { - server = resolved.ServerName, - /* #3653: captured_at, the #3637 census's one spelling for a latest read's stamp. This tool - stamped itself as collection_time before that vocabulary existed and was carried as a - named allowance on both SKUs; a cut-over rather than an alias, because the census is the - contract and a second key for one instant is the drift it exists to refuse. */ - captured_at = row.CollectionTime.ToString("o"), - edition = row.Edition, - engine_edition = row.EngineEdition, - product_version = row.ProductVersion, - product_level = row.ProductLevel, - product_update_level = string.IsNullOrEmpty(row.ProductUpdateLevel) ? null : row.ProductUpdateLevel, - cpu_count = row.CpuCount, - hyperthread_ratio = row.HyperthreadRatio, - socket_count = row.SocketCount, - cores_per_socket = row.CoresPerSocket, - physical_memory_mb = row.PhysicalMemoryMb, - is_hadr_enabled = row.IsHadrEnabled, - is_clustered = row.IsClustered, - enterprise_features = string.IsNullOrEmpty(row.EnterpriseFeatures) ? null : row.EnterpriseFeatures, - service_objective = string.IsNullOrEmpty(row.ServiceObjective) ? null : row.ServiceObjective, - /* v63 (#3653 item 13, Q8): the clock pair. The offset is the one IN FORCE at captured_at, - which is exact for an instant on the same side of a DST transition and an hour wrong for - one on the other (#3231); the zone is what can tell the two apart. NULL is a real answer - for the zone - CURRENT_TIMEZONE_ID() is SQL Server 2022+ / Azure SQL only - and the note - says what it means rather than leaving a caller to read it as "not collected". Byte-for-byte - the keys Darling's tool emits. */ - utc_offset_minutes = row.UtcOffsetMinutes, - time_zone_id = string.IsNullOrEmpty(row.TimeZoneId) ? null : row.TimeZoneId, - time_zone_note = string.IsNullOrEmpty(row.TimeZoneId) - ? "time_zone_id is null: a pre-2022 engine (CURRENT_TIMEZONE_ID() is SQL Server 2022+ / Azure SQL only), so only the offset in force at captured_at is known." - : "time_zone_id is the engine's own zone (CURRENT_TIMEZONE_ID()); utc_offset_minutes is the offset that zone had at captured_at." - }, McpHelpers.JsonOptions); + return ServerPropertiesPayload(resolved.ServerName, row); } catch (Exception ex) { @@ -66,6 +33,57 @@ the keys Darling's tool emits. */ } } + /// + /// The get_server_properties payload for one snapshot. On an Azure SQL Database (engine edition 5) the stored + /// cpu_count, hyperthread_ratio, socket_count, cores_per_socket and + /// physical_memory_mb are the HOST's, so they come back null, vcore_count (what the service objective + /// gives the database) rides beside service_objective, and a hardware_note says why. Every other + /// edition keeps the payload it always had, key for key. Lite's tool emits the same shape in the same words. + /// + internal static string ServerPropertiesPayload(string serverName, ServerPropertiesRow row) + { + var payload = new + { + server = serverName, + /* #3653: captured_at, the #3637 census's one spelling for a latest read's stamp. This tool + stamped itself as collection_time before that vocabulary existed and was carried as a + named allowance on both SKUs; a cut-over rather than an alias, because the census is the + contract and a second key for one instant is the drift it exists to refuse. */ + captured_at = row.CollectionTime.ToString("o"), + edition = row.Edition, + engine_edition = row.EngineEdition, + product_version = row.ProductVersion, + product_level = row.ProductLevel, + product_update_level = string.IsNullOrEmpty(row.ProductUpdateLevel) ? null : row.ProductUpdateLevel, + cpu_count = row.CpuCount, + hyperthread_ratio = row.HyperthreadRatio, + socket_count = row.SocketCount, + cores_per_socket = row.CoresPerSocket, + physical_memory_mb = row.PhysicalMemoryMb, + is_hadr_enabled = row.IsHadrEnabled, + is_clustered = row.IsClustered, + enterprise_features = string.IsNullOrEmpty(row.EnterpriseFeatures) ? null : row.EnterpriseFeatures, + service_objective = string.IsNullOrEmpty(row.ServiceObjective) ? null : row.ServiceObjective, + /* v63 (#3653 item 13, Q8): the clock pair. The offset is the one IN FORCE at captured_at, + which is exact for an instant on the same side of a DST transition and an hour wrong for + one on the other (#3231); the zone is what can tell the two apart. NULL is a real answer + for the zone - CURRENT_TIMEZONE_ID() is SQL Server 2022+ / Azure SQL only - and the note + says what it means rather than leaving a caller to read it as "not collected". Byte-for-byte + the keys Darling's tool emits. */ + utc_offset_minutes = row.UtcOffsetMinutes, + time_zone_id = string.IsNullOrEmpty(row.TimeZoneId) ? null : row.TimeZoneId, + time_zone_note = string.IsNullOrEmpty(row.TimeZoneId) + ? "time_zone_id is null: a pre-2022 engine (CURRENT_TIMEZONE_ID() is SQL Server 2022+ / Azure SQL only), so only the offset in force at captured_at is known." + : "time_zone_id is the engine's own zone (CURRENT_TIMEZONE_ID()); utc_offset_minutes is the offset that zone had at captured_at." + }; + + if (!ServerHardwareScope.HardwareIsTheHosts(row.EngineEdition)) + return JsonSerializer.Serialize(payload, McpHelpers.JsonOptions); + + var scoped = JsonSerializer.SerializeToNode(payload, McpHelpers.JsonOptions)!.AsObject(); + return ServerHardwareScope.ScopeServerPropertiesPayload(scoped, row.VcoreCount).ToJsonString(McpHelpers.JsonOptions); + } + [McpServerTool(Name = "get_database_sizes"), Description("Gets database file sizes, space usage, and volume free space. Shows each database file with total size, used space, auto-growth settings, and the underlying volume's capacity. Use for capacity planning and identifying space pressure. LATEST IS A TIME: this reads the newest size snapshot, not a window, and captured_at is the instant it was collected - a volume's free space here is what it was AT that stamp, and a file that grew since is not reflected until the next collection.")] public static async Task GetDatabaseSizes( LocalDataService dataService, diff --git a/Lite/Services/LocalDataService.FinOps.Utilization.cs b/Lite/Services/LocalDataService.FinOps.Utilization.cs index 312c180ab..4658f1c2d 100644 --- a/Lite/Services/LocalDataService.FinOps.Utilization.cs +++ b/Lite/Services/LocalDataService.FinOps.Utilization.cs @@ -53,7 +53,7 @@ ORDER BY collection_time DESC LIMIT 1 ), server_info AS ( - SELECT COALESCE(vcore_count, cpu_count) AS cpu_count + SELECT COALESCE(vcore_count, cpu_count) AS cpu_count, engine_edition FROM v_server_properties WHERE server_id = $1 ORDER BY collection_time DESC @@ -89,7 +89,8 @@ FROM v_memory_grant_stats COALESCE(g.max_grant_waiters, 0), COALESCE(g.grant_timeouts, 0), COALESCE(g.forced_grants, 0), - COALESCE(g.grant_utilization_pct, 0) + COALESCE(g.grant_utilization_pct, 0), + s.engine_edition FROM cpu_stats c CROSS JOIN mem_latest m LEFT JOIN server_info s ON true @@ -139,7 +140,8 @@ LEFT JOIN server_info s ON true GrantUtilizationPct = reader.IsDBNull(15) ? 0m : Convert.ToDecimal(reader.GetValue(15)), MaxWorkersCount = maxWorkers, CurrentWorkersCount = currentWorkers, - CpuCount = reader.IsDBNull(11) ? 0 : Convert.ToInt32(reader.GetValue(11)) + CpuCount = reader.IsDBNull(11) ? 0 : Convert.ToInt32(reader.GetValue(11)), + EngineEdition = reader.IsDBNull(16) ? 0 : Convert.ToInt32(reader.GetValue(16)) }; } diff --git a/Lite/Services/LocalDataService.FinOps.cs b/Lite/Services/LocalDataService.FinOps.cs index 0ad4a6631..3759ac293 100644 --- a/Lite/Services/LocalDataService.FinOps.cs +++ b/Lite/Services/LocalDataService.FinOps.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.Linq; using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Common; using PerformanceMonitor.Ui; namespace PerformanceMonitorLite.Services; @@ -97,6 +98,11 @@ public class UtilizationEfficiencyRow public int MaxWorkersCount { get; set; } public int CurrentWorkersCount { get; set; } public int CpuCount { get; set; } + + /// The engine edition of the server these figures describe (SERVERPROPERTY('EngineEdition'), 0 when unread). + /// On an Azure SQL Database (5) is the HOST's, so the card does not show it and its + /// verdict sentence cites no share of it; the verdict itself is unchanged. + public int EngineEdition { get; set; } public string ProvisioningStatus { get; set; } = ""; // FinOps cost — proportional to server monthly budget @@ -234,10 +240,22 @@ public class ServerPropertyRow public string? ProductLevel { get; set; } public string? ProductUpdateLevel { get; set; } public int EngineEdition { get; set; } - public int CpuCount { get; set; } - public long PhysicalMemoryMb { get; set; } - public int? SocketCount { get; set; } - public int? CoresPerSocket { get; set; } + + /* The four hardware cells below read as ABSENT for an Azure SQL Database (engine edition 5): its sys.dm_os_sys_info + values are the HOST's, not the database's allocation (a 1-vCore serverless database read 2 CPUs, 0 sockets, 32 + cores per socket and 911.9 GB), and the grid draws an absent value as a blank cell. The read values are kept + behind the properties, so the order the loader assigns them in does not matter and no calculation loses its input. */ + private int _cpuCount; + private long _physicalMemoryMb; + private int? _socketCount; + private int? _coresPerSocket; + private string? _hardwareUnavailableReason; + private bool HostHardware => ServerHardwareScope.HardwareIsTheHosts(EngineEdition); + + public int? CpuCount { get => HostHardware ? null : _cpuCount; set => _cpuCount = value ?? 0; } + public long? PhysicalMemoryMb { get => HostHardware ? null : _physicalMemoryMb; set => _physicalMemoryMb = value ?? 0L; } + public int? SocketCount { get => HostHardware ? null : _socketCount; set => _socketCount = value; } + public int? CoresPerSocket { get => HostHardware ? null : _coresPerSocket; set => _coresPerSocket = value; } public DateTime? SqlServerStartTime { get; set; } public DateTime? LastUpdated { get; set; } public bool? IsHadrEnabled { get; set; } @@ -249,7 +267,12 @@ public class ServerPropertyRow /// without VIEW DATABASE STATE). Null when hardware inventory is available. Surfaced as a /// non-alarming note in the FinOps Server Inventory grid (#1535). /// - public string? HardwareUnavailableReason { get; set; } + public string? HardwareUnavailableReason + { + /* An Azure SQL Database's blank hardware cells say why, in the column that already carries a read's own reason. */ + get => _hardwareUnavailableReason ?? (HostHardware ? ServerHardwareScope.InventoryHardwareNote : null); + set => _hardwareUnavailableReason = value; + } public decimal? AvgCpuPct { get; set; } public decimal? StorageTotalGb { get; set; } diff --git a/Lite/Services/LocalDataService.ServerInfo.cs b/Lite/Services/LocalDataService.ServerInfo.cs index bfa6d9ee2..cb5b2d21e 100644 --- a/Lite/Services/LocalDataService.ServerInfo.cs +++ b/Lite/Services/LocalDataService.ServerInfo.cs @@ -28,7 +28,7 @@ public partial class LocalDataService engine_edition, cpu_count, hyperthread_ratio, physical_memory_mb, socket_count, cores_per_socket, is_hadr_enabled, is_clustered, enterprise_features, service_objective, collection_time, - utc_offset_minutes, time_zone_id + utc_offset_minutes, time_zone_id, vcore_count FROM v_server_properties WHERE server_id = $1 ORDER BY collection_time DESC @@ -59,7 +59,10 @@ ORDER BY collection_time DESC /* v42 / v63 (#3653 item 13): both nullable in the store and both read null-or-value — a 0 offset would claim UTC of a row that never recorded one, and "" would claim a zone name. */ UtcOffsetMinutes = reader.IsDBNull(15) ? null : reader.GetInt32(15), - TimeZoneId = reader.IsDBNull(16) ? null : reader.GetString(16) + TimeZoneId = reader.IsDBNull(16) ? null : reader.GetString(16), + /* The vCore count parsed from an Azure SQL Database's service objective; null elsewhere and for a + DTU-model objective. It is what describes the database where cpu_count describes the host. */ + VcoreCount = reader.IsDBNull(17) ? null : reader.GetInt32(17) }; } @@ -242,6 +245,10 @@ public class ServerPropertiesRow /// The engine's own time-zone name from CURRENT_TIMEZONE_ID() (v63, #3653 item 13, Q8); null /// where the engine cannot say — every SQL Server before 2022 — which is a real value, not a miss. public string? TimeZoneId { get; set; } + + /// The vCore count parsed from an Azure SQL Database's service objective (null off Azure SQL Database, + /// and for a DTU-model objective that names no vCores). + public int? VcoreCount { get; set; } } public class DatabaseSizeStatsRow diff --git a/PerformanceMonitor.Common/ServerHardwareScope.cs b/PerformanceMonitor.Common/ServerHardwareScope.cs new file mode 100644 index 000000000..220da8a9f --- /dev/null +++ b/PerformanceMonitor.Common/ServerHardwareScope.cs @@ -0,0 +1,91 @@ +/* + * 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.Collections.Generic; +using System.Globalization; +using System.Text.Json.Nodes; + +namespace PerformanceMonitor.Common; + +/// +/// Whose hardware the collected server_properties hardware columns describe. +/// +/// On an Azure SQL Database (SERVERPROPERTY('EngineEdition') 5) sys.dm_os_sys_info reports the +/// HOST the database runs on, not what the database is given: a 1-vCore serverless General Purpose database showed +/// 2 logical CPUs, 0 sockets, 32 cores per socket, a hyperthread ratio of 64 and 911.9 GB of physical memory. What +/// does describe the database is the service objective (DATABASEPROPERTYEX('ServiceObjective'), for example +/// GP_S_Gen5_1) and the vcore_count the collector parses out of it. +/// +/// Every surface that SHOWS cpu_count, socket_count, cores_per_socket, +/// hyperthread_ratio or physical_memory_mb asks this class first, so the rule and the words live in +/// one place for both apps. The collector and every sizing or verdict calculation keep reading the stored +/// values; this decides only what is presented as the database's. +/// +public static class ServerHardwareScope +{ + /// SERVERPROPERTY('EngineEdition') for an Azure SQL Database. + public const int AzureSqlDatabaseEngineEdition = 5; + + /// True when the stored hardware columns are the host's and must not be presented as the server's own. + public static bool HardwareIsTheHosts(int? engineEdition) => engineEdition == AzureSqlDatabaseEngineEdition; + + /// + /// The hardware_note get_server_properties returns on an Azure SQL Database, word for word in both + /// apps (the web Server Properties list reads the same payload). + /// + public const string McpHardwareNote = + "cpu_count, hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb are null on an Azure SQL Database: " + + "the host's hardware is not this database's allocation. Read service_objective and vcore_count for what the database is given."; + + /// The five columns that describe the host on an Azure SQL Database, by their get_server_properties key. + public static readonly IReadOnlyList HostHardwareKeys = + ["cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; + + /// + /// Rewrites a get_server_properties payload for an Azure SQL Database: the five host-hardware keys become + /// null where they stand, vcore_count is inserted right after service_objective (null for a DTU-model + /// objective that names no vCores), and hardware_note is appended. Both apps call it, so the shape and the + /// words cannot drift apart. A payload of any other edition never reaches it. + /// + public static JsonObject ScopeServerPropertiesPayload(JsonObject payload, int? vcoreCount) + { + foreach (var key in HostHardwareKeys) + payload[key] = null; + + var at = payload.IndexOf("service_objective"); + payload.Insert(at < 0 ? payload.Count : at + 1, "vcore_count", vcoreCount.HasValue ? JsonValue.Create(vcoreCount.Value) : null); + payload["hardware_note"] = McpHardwareNote; + return payload; + } + + /// The Hardware Note a FinOps Server Inventory row carries for an Azure SQL Database whose collector read gave no reason of its own. + public const string InventoryHardwareNote = + "Azure SQL Database: the host's hardware is not this database's allocation; see its service objective."; + + /// What the FinOps utilization card shows where it would have shown the host's physical memory. + public const string NotApplicable = "n/a"; + + /// + /// The FinOps utilization verdict sentence for a server whose provisioning is RIGHT_SIZED. Off an Azure SQL + /// Database it is the long-standing sentence, byte for byte; on one it drops the clause that compares the buffer + /// pool with the HOST's physical memory. + /// + public static string RightSizedExplanation(decimal avgCpuPct, decimal p95CpuPct, double bufferPoolPctOfPhysical, bool azureSqlDatabase) => + azureSqlDatabase + ? string.Create(CultureInfo.CurrentCulture, $"CPU is moderately loaded (avg {avgCpuPct:N1}%, p95 {p95CpuPct:N1}%). No action needed.") + : string.Create(CultureInfo.CurrentCulture, $"CPU is moderately loaded (avg {avgCpuPct:N1}%, p95 {p95CpuPct:N1}%) and memory is well-utilized (buffer pool uses {bufferPoolPctOfPhysical:N0}% of physical RAM). No action needed."); + + /// + /// The FinOps utilization verdict sentence for a server whose provisioning is OVER_PROVISIONED. Same rule as + /// : an Azure SQL Database's sentence cites no buffer pool share of the host's RAM. + /// + public static string OverProvisionedExplanation(decimal avgCpuPct, int maxCpuPct, double bufferPoolPctOfPhysical, bool azureSqlDatabase) => + azureSqlDatabase + ? string.Create(CultureInfo.CurrentCulture, $"CPU is lightly loaded (avg {avgCpuPct:N1}%, max {maxCpuPct}%). This database may have more resources than it needs.") + : string.Create(CultureInfo.CurrentCulture, $"CPU is lightly loaded (avg {avgCpuPct:N1}%, max {maxCpuPct}%) and buffer pool uses only {bufferPoolPctOfPhysical:N0}% of physical RAM. This server may have more resources than it needs."); +} From fb7543260c5e9213a79dbaec5f06a23795ceb30c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:10:55 -0400 Subject: [PATCH 02/17] Azure SQL Database hardware tests read repo files as they are, and the server properties pin expects vcore_count after the clock pair --- Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs | 4 ++-- Darling/Darling.Tests/TimeHonestyRungTests.cs | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs index 1efa39a96..ec60ef85d 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs @@ -110,7 +110,7 @@ public void ServerPropertiesRead_CarriesTheStoredVcoreCount() [Fact] public void WebServerProperties_DescriptorHidesTheHostFive_OnEdition5_AndShowsTheServiceObjectiveAndVcores() { - var js = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "server-tabs.js"); + var js = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "server-tabs.js"); var start = js.IndexOf("const PROPERTY_STATS = [", StringComparison.Ordinal); Assert.True(start > 0, "PROPERTY_STATS is missing"); var list = js[start..js.IndexOf("];", start, StringComparison.Ordinal)]; @@ -131,7 +131,7 @@ public void WebServerProperties_DescriptorHidesTheHostFive_OnEdition5_AndShowsTh [Fact] public void WebStatRenderer_DropsTilesByTheirCondition_OnEdition5_AndKeepsEveryTileOtherwise() { - var panels = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "panels.js"); + var panels = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "panels.js"); Assert.Contains("const stats = visibleStats(Array.isArray(desc.stats) ? desc.stats : [], data);", panels, StringComparison.Ordinal); var script = """ diff --git a/Darling/Darling.Tests/TimeHonestyRungTests.cs b/Darling/Darling.Tests/TimeHonestyRungTests.cs index 95b344fdf..57e9bdf2e 100644 --- a/Darling/Darling.Tests/TimeHonestyRungTests.cs +++ b/Darling/Darling.Tests/TimeHonestyRungTests.cs @@ -394,7 +394,9 @@ ReadLatestCpuCoreAsync sibling holding the read byte-identical — the columns a public void TheServerPropertiesRead_CarriesTheClockPair_AndThePayloadPublishesIt() { var sql = DarlingDataReader.LatestServerPropertiesSql.Replace("\r\n", "\n", StringComparison.Ordinal); - Assert.Contains("service_objective,\n utc_offset_minutes,\n time_zone_id\nFROM server_properties", Dedent(sql), StringComparison.Ordinal); + /* The clock pair still follows the pre-rung columns; vcore_count (what describes an Azure SQL Database where + cpu_count describes its host) is projected after it, so the pair's position is unchanged. */ + Assert.Contains("service_objective,\n utc_offset_minutes,\n time_zone_id,\n vcore_count\nFROM server_properties", Dedent(sql), StringComparison.Ordinal); Assert.Equal(typeof(int?), typeof(DarlingDataReader.ServerPropertiesReadRow).GetProperty("UtcOffsetMinutes")!.PropertyType); Assert.Equal(typeof(string), typeof(DarlingDataReader.ServerPropertiesReadRow).GetProperty("TimeZoneId")!.PropertyType); From 6f1e0002cd74956aeadcec5ad2e1e5e451b896cc Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:52:54 -0400 Subject: [PATCH 03/17] Azure SQL Database CPU attribution, FinOps CPU count and health score no longer use the host's hardware On an Azure SQL Database sys.dm_os_sys_info describes the host: a 1-vCore serverless General Purpose database read 2 CPUs and 911.9 GB of memory. Three calculations still divided by or scored those values. Each now uses the database's own figure where one is collected (vcore_count from the service objective) and is not applicable otherwise. A DTU objective names no vCores, so its CPU count is not applicable and nothing is computed from the host. SQL Server and Managed Instance are unchanged. - CpuAttribution has an overload that takes the engine edition and vcore_count. On edition 5 it divides by the vCores, or omits the ratio with a not-applicable note. Both top-queries and top-procedures tools, in Lite and Darling, call it. - The FinOps utilization read resolves its CPU count through the edition in SQL, in both apps, so edition 5 never falls back to the host's count. The card shows n/a for it. - The FinOps health score leaves its memory term out on edition 5, because that term is the buffer pool's share of the host's physical memory. CPU and storage carry the score, and a tooltip says so. --- .../AzureSqlDatabaseHardwareTests.cs | 4 +- .../AzureSqlDatabaseHostMathTests.cs | 233 ++++++++++++++ .../Mcp/DarlingMcpDataTools.cs | 8 +- .../FinOpsTab.Loaders.cs | 17 +- .../ViewerDataService.FinOps.Utilization.cs | 6 +- .../ViewerDataService.FinOps.cs | 33 +- Lite.Tests/AzureSqlDatabaseHardwareTests.cs | 4 +- Lite.Tests/AzureSqlDatabaseHostMathTests.cs | 293 ++++++++++++++++++ Lite/Controls/FinOpsTab.xaml.cs | 17 +- Lite/Mcp/McpQueryTools.cs | 8 +- .../LocalDataService.FinOps.Utilization.cs | 6 +- Lite/Services/LocalDataService.FinOps.cs | 33 +- PerformanceMonitor.Common/CpuAttribution.cs | 65 +++- .../ServerHardwareScope.cs | 38 ++- 14 files changed, 728 insertions(+), 37 deletions(-) create mode 100644 Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs create mode 100644 Lite.Tests/AzureSqlDatabaseHostMathTests.cs diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs index ec60ef85d..6bda1de44 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs @@ -237,7 +237,9 @@ public void FinOpsUtilizationCard_AsksTheSharedRule_BeforeItShowsPhysicalMemory( Assert.Contains("ServerHardwareScope.OverProvisionedExplanation(", tab, StringComparison.Ordinal); Assert.Contains("ServerHardwareScope.RightSizedExplanation(", tab, StringComparison.Ordinal); Assert.DoesNotContain("of physical RAM", tab, StringComparison.Ordinal); - Assert.Contains("COALESCE(vcore_count, cpu_count) AS cpu_count, engine_edition", read, StringComparison.Ordinal); + /* The CPU count is resolved through the edition (AzureSqlDatabaseHostMathTests pins the CASE): off edition 5 it is still + COALESCE(vcore_count, cpu_count), and the edition still rides along for the card. */ + Assert.Contains("ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, engine_edition", read, StringComparison.Ordinal); Assert.Contains("EngineEdition = reader.IsDBNull(16)", read, StringComparison.Ordinal); } diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs new file mode 100644 index 000000000..45d7eb533 --- /dev/null +++ b/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs @@ -0,0 +1,233 @@ +/* + * 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.Globalization; +using System.Linq; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Viewer; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info describes the HOST: a 1-vCore serverless +/// General Purpose database read 2 logical CPUs and 911.9 GB of physical memory. +/// pins that nothing SHOWS those values as the database's. These pins are the calculations that USED them: the attributed-CPU +/// denominator, the FinOps utilization card's CPU count, and the FinOps health score. +/// +/// The rule: on an Azure SQL Database each of those uses the database's own figure where one is collected (the +/// vcore_count parsed from the service objective) and is otherwise NOT APPLICABLE. A DTU-model objective has no vCore +/// count, so its CPU count is not applicable and nothing is computed from the host's. SQL Server (editions 1 to 4) and Managed +/// Instance (8) behave exactly as before, and every test has that twin. Lite.Tests pins the same table for the other app, in +/// the same words. +/// +public sealed class AzureSqlDatabaseHostMathTests +{ + private static readonly DateTime s_start = new(2026, 9, 30, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime s_end = s_start.AddHours(1); + + // ── CPU attribution ── + + /// Half of one CPU for an hour is 1,800 CPU-seconds; half of the host's two would be 3,600. + private static CpuAttribution.Result Attribute(int? engineEdition, int storedCpuCount, int? vcoreCount) => + CpuAttribution.Compute( + rankedCpuSeconds: 900, s_start, s_end, + sampleCount: 60, firstSampleUtc: s_start, lastSampleUtc: s_end, avgSqlCpuPercent: 50, + engineEdition, storedCpuCount, vcoreCount); + + [Fact] + public void Attribution_OnAzureSqlDatabase_WithVcores_DividesByTheVcores_NotTheHostsCpus() + { + var result = Attribute(5, storedCpuCount: 2, vcoreCount: 1); + + Assert.Equal(1800, result.SqlCpuSecondsInWindow); + Assert.Equal(0.5, result.AttributedCpuRatio); + Assert.Null(result.Note); + } + + [Theory] + [InlineData(null)] + [InlineData(0)] + public void Attribution_OnAzureSqlDatabase_WithNoVcores_IsNotApplicable_AndComputesNothingFromTheHost(int? vcoreCount) + { + var result = Attribute(5, storedCpuCount: 2, vcoreCount); + + Assert.Equal(900, result.RankedCpuSeconds); + Assert.Null(result.SqlCpuSecondsInWindow); + Assert.Null(result.AttributedCpuRatio); + Assert.Equal(CpuAttribution.CoreCountNotApplicableNote, result.Note); + Assert.Contains("not applicable", result.Note, StringComparison.Ordinal); + Assert.Contains("DTU", result.Note, StringComparison.Ordinal); + Assert.DoesNotContain("no server_properties snapshot", result.Note, StringComparison.Ordinal); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(8)] + public void Attribution_OnEveryOtherEdition_IsTheStoredCountMathItAlwaysWas(int engineEdition) + { + var legacy = CpuAttribution.Compute(900, s_start, s_end, 60, s_start, s_end, 50, 8); + + Assert.Equal(legacy, Attribute(engineEdition, storedCpuCount: 8, vcoreCount: null)); + /* A vcore_count beside a non-Azure edition is not read: only edition 5 resolves it. */ + Assert.Equal(legacy, Attribute(engineEdition, storedCpuCount: 8, vcoreCount: 1)); + Assert.Equal(14400, legacy.SqlCpuSecondsInWindow); + } + + [Fact] + public void Attribution_WithNoServerPropertiesRow_KeepsItsUnavailableNote() + { + var result = Attribute(engineEdition: null, storedCpuCount: 0, vcoreCount: null); + + Assert.Null(result.AttributedCpuRatio); + Assert.Equal(CpuAttribution.CoreCountUnavailableNote, result.Note); + Assert.Contains("no server_properties snapshot", result.Note, StringComparison.Ordinal); + } + + [Fact] + public void OwnCpuCount_IsTheVcoresOnAzureSqlDatabase_AndTheStoredCountEverywhereElse() + { + Assert.Equal(1, ServerHardwareScope.OwnCpuCount(5, cpuCount: 2, vcoreCount: 1)); + Assert.Null(ServerHardwareScope.OwnCpuCount(5, cpuCount: 2, vcoreCount: null)); + Assert.Null(ServerHardwareScope.OwnCpuCount(5, cpuCount: 2, vcoreCount: 0)); + Assert.Equal(16, ServerHardwareScope.OwnCpuCount(3, cpuCount: 16, vcoreCount: null)); + Assert.Equal(4, ServerHardwareScope.OwnCpuCount(8, cpuCount: 4, vcoreCount: null)); + Assert.Null(ServerHardwareScope.OwnCpuCount(3, cpuCount: null, vcoreCount: null)); + } + + [Fact] + public void TopQueriesAndTopProceduresTools_PassTheEditionAndTheVcores_NotOnlyTheStoredCount() + { + var tool = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"); + + Assert.Equal(2, CountOf(tool, "properties?.EngineEdition, properties?.CpuCount ?? 0, properties?.VcoreCount);")); + Assert.DoesNotContain("properties?.CpuCount ?? 0);", tool, StringComparison.Ordinal); + } + + // ── FinOps utilization card: the CPU count ── + + [Fact] + public void CpuCountText_OnAzureSqlDatabase_IsNotApplicableWithNoVcores_AndTheVcoresOtherwise() + { + Assert.Equal(ServerHardwareScope.NotApplicable, ServerHardwareScope.CpuCountText(5, 0)); + Assert.Equal("n/a", ServerHardwareScope.CpuCountText(5, 0)); + Assert.Equal("1", ServerHardwareScope.CpuCountText(5, 1)); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(8)] + [InlineData(null)] + public void CpuCountText_OnEveryOtherEdition_IsTheCountAsItAlwaysWas(int? engineEdition) + { + Assert.Equal(1234.ToString("N0", CultureInfo.CurrentCulture), ServerHardwareScope.CpuCountText(engineEdition, 1234)); + Assert.Equal("16", ServerHardwareScope.CpuCountText(engineEdition, 16)); + Assert.Equal("0", ServerHardwareScope.CpuCountText(engineEdition, 0)); + } + + /// The CASE the Viewer read uses, as one line. Lite's read carries the same line, so the two apps resolve the + /// count the same way. + private static readonly string s_cpuCountCase = + $"SELECT CASE WHEN engine_edition = {ServerHardwareScope.AzureSqlDatabaseEngineEdition} THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, engine_edition"; + + [Fact] + public void UtilizationRead_ResolvesTheCpuCountThroughTheEdition_NeverFallingBackToTheHostsCount() + { + var sql = ViewerDataService.UtilizationEfficiencySql; + + Assert.Contains(s_cpuCountCase, sql, StringComparison.Ordinal); + /* Only edition 5 lacks the fall-back: the bare COALESCE that took the host's count is gone. */ + Assert.DoesNotContain("SELECT COALESCE(vcore_count, cpu_count) AS cpu_count", sql, StringComparison.Ordinal); + Assert.Equal(1, CountOf(sql, "COALESCE(vcore_count, cpu_count)")); + } + + [Fact] + public void UtilizationRead_ResolvesTheCpuCountTheSameWayLiteDoes() + { + var lite = ReadRepoFile("Lite", "Services", "LocalDataService.FinOps.Utilization.cs"); + + Assert.Contains(s_cpuCountCase, lite, StringComparison.Ordinal); + } + + // ── FinOps utilization card: the health score ── + + /// CPU p95 of 7% scores 95 and 50% free storage scores 100. Buffer pool 40 GB of a 933,888 MB host is 4%, which + /// scores 60 when memory counts: 95 * 0.4 + 60 * 0.3 + 100 * 0.3 = 86, the score a 1-vCore database showed. + private static UtilizationEfficiencyRow Utilization(int engineEdition, int bufferPoolMb, int physicalMemoryMb) => new() + { + EngineEdition = engineEdition, + P95CpuPct = 7m, + BufferPoolMb = bufferPoolMb, + PhysicalMemoryMb = physicalMemoryMb, + FreeSpacePct = 50m, + }; + + [Fact] + public void HealthScore_OnAzureSqlDatabase_LeavesMemoryOut_AndDoesNotMoveWithTheHostsMemory() + { + Assert.Equal(97, Utilization(5, 40_960, 933_888).ComputeHealthScore()); + Assert.Equal(97, Utilization(5, 40_960, 65_536).ComputeHealthScore()); + Assert.Equal(97, Utilization(5, 800_000, 933_888).ComputeHealthScore()); + Assert.Equal(97, Utilization(5, 0, 0).ComputeHealthScore()); + Assert.Equal(FinOpsHealthCalculator.Overall(95, null, 100), Utilization(5, 40_960, 933_888).ComputeHealthScore()); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(8)] + public void HealthScore_OnEveryOtherEdition_KeepsItsMemoryTerm(int engineEdition) + { + Assert.Equal(86, Utilization(engineEdition, 40_960, 933_888).ComputeHealthScore()); + Assert.Equal(98, Utilization(engineEdition, 600_000, 933_888).ComputeHealthScore()); + } + + [Fact] + public void Overall_WithNoMemoryScore_WeighsCpuAndStorageOverTheirOwnSeventyPercent() + { + Assert.Equal(100, FinOpsHealthCalculator.Overall(100, null, 100)); + Assert.Equal(0, FinOpsHealthCalculator.Overall(0, null, 0)); + Assert.Equal(97, FinOpsHealthCalculator.Overall(95, null, 100)); + Assert.Equal(62, FinOpsHealthCalculator.Overall(80, null, 40)); + /* With a memory score, the long-standing arithmetic, byte for byte. */ + Assert.Equal(86, FinOpsHealthCalculator.Overall(95, 60, 100)); + Assert.Equal(62, FinOpsHealthCalculator.Overall(80, 60, 40)); + } + + // ── the wiring, pinned at the source ── + + private static int CountOf(string text, string needle) + { + var count = 0; + for (var at = text.IndexOf(needle, StringComparison.Ordinal); at >= 0; at = text.IndexOf(needle, at + needle.Length, StringComparison.Ordinal)) + count++; + return count; + } + + [Fact] + public void FinOpsUtilizationCard_AsksTheSharedRule_ForTheCpuCountAndTheHealthScore() + { + var tab = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "FinOpsTab.Loaders.cs"); + + Assert.Contains("FinOpsCpuCountText.Text = ServerHardwareScope.CpuCountText(data.EngineEdition, data.CpuCount);", tab, StringComparison.Ordinal); + Assert.DoesNotContain("data.CpuCount.ToString(", tab, StringComparison.Ordinal); + Assert.Contains("data.HealthScore = data.ComputeHealthScore();", tab, StringComparison.Ordinal); + Assert.Contains("FinOpsHealthScoreBorder.ToolTip = azureSqlDb ? ServerHardwareScope.HealthScoreWithoutMemoryNote : null;", tab, StringComparison.Ordinal); + Assert.DoesNotContain("FinOpsHealthCalculator.MemoryScore(", tab, StringComparison.Ordinal); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index 172df01b9..111f9d528 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -660,11 +660,13 @@ ratio is omitted rather than invented when a denominator piece is missing. The t await Task.WhenAll(cpuAggregateTask, propertiesTask); var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; + /* The core count is the server's own: on an Azure SQL Database the stored cpu_count is the HOST's, so this divides by its + vcore_count, or omits the ratio for a DTU objective that names no vCores (see CpuAttribution). */ var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuUs) / 1_000_000.0, attrStart, attrEnd, cpuAggregate.SampleCount, cpuAggregate.FirstSample, cpuAggregate.LastSample, cpuAggregate.AvgSqlCpuPercent, - properties?.CpuCount ?? 0); + properties?.EngineEdition, properties?.CpuCount ?? 0, properties?.VcoreCount); var result = rows.Select(r => new { @@ -825,11 +827,13 @@ same concurrent independent reads. */ await Task.WhenAll(cpuAggregateTask, propertiesTask); var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; + /* The core count is the server's own: on an Azure SQL Database the stored cpu_count is the HOST's, so this divides by its + vcore_count, or omits the ratio for a DTU objective that names no vCores (see CpuAttribution). */ var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuUs) / 1_000_000.0, attrStart, attrEnd, cpuAggregate.SampleCount, cpuAggregate.FirstSample, cpuAggregate.LastSample, cpuAggregate.AvgSqlCpuPercent, - properties?.CpuCount ?? 0); + properties?.EngineEdition, properties?.CpuCount ?? 0, properties?.VcoreCount); var result = rows.Select(r => new { diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs index d0b8185ce..779863d37 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs @@ -271,7 +271,8 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) FinOpsP95CpuText.Text = $"{data.P95CpuPct:N2}%"; FinOpsMaxCpuText.Text = $"{data.MaxCpuPct}%"; FinOpsCpuSamplesText.Text = data.CpuSamples.ToString("N0"); - FinOpsCpuCountText.Text = data.CpuCount.ToString("N0"); + /* n/a on an Azure SQL Database whose service objective names no vCores: the host's count is never shown as the database's. */ + FinOpsCpuCountText.Text = ServerHardwareScope.CpuCountText(data.EngineEdition, data.CpuCount); FinOpsWorkerThreadsText.Text = $"{data.CurrentWorkersCount:N0} / {data.MaxWorkersCount:N0}"; SetBar(FinOpsAvgCpuBar, FinOpsAvgCpuFilled, FinOpsAvgCpuEmpty, (double)data.AvgCpuPct); @@ -291,7 +292,8 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) : 0; /* An Azure SQL Database's physical memory is the HOST's, not the database's allocation, so neither the figure nor - the buffer pool's share of it is shown; the verdict and the health score below still read the stored value. */ + the buffer pool's share of it is shown, and the health score below leaves its memory term out. The verdict reads none + of it: its inputs are the database's own CPU, its workspace-memory grants and its worker threads. */ var azureSqlDb = ServerHardwareScope.HardwareIsTheHosts(data.EngineEdition); FinOpsMemoryRatioText.Text = azureSqlDb ? ServerHardwareScope.NotApplicable : $"{bpPct:N0}%"; SetBar(FinOpsMemoryRatioBar, FinOpsMemRatioFilled, FinOpsMemRatioEmpty, azureSqlDb ? 0 : bpPct); @@ -329,12 +331,11 @@ pressure or worker saturation would have been explained as a memory ratio that n FinOpsTotalCostCard.Visibility = Visibility.Collapsed; } - /* Health score */ - var bpRatio = data.PhysicalMemoryMb > 0 ? (decimal)data.BufferPoolMb / data.PhysicalMemoryMb : 0m; - var cpuScore = FinOpsHealthCalculator.CpuScore(data.P95CpuPct); - var memScore = FinOpsHealthCalculator.MemoryScore(bpRatio); - var storScore = FinOpsHealthCalculator.StorageScore(data.FreeSpacePct); - data.HealthScore = FinOpsHealthCalculator.Overall(cpuScore, memScore, storScore); + /* Health score. Its memory term is the buffer pool's share of physical memory, and on an Azure SQL Database that + memory is the HOST's: the term is left out (not scored as zero, not scored as a default), so CPU and storage carry + the score and the tooltip says so. */ + data.HealthScore = data.ComputeHealthScore(); + FinOpsHealthScoreBorder.ToolTip = azureSqlDb ? ServerHardwareScope.HealthScoreWithoutMemoryNote : null; FinOpsHealthScoreText.Text = $"Health: {data.HealthScore}"; FinOpsHealthScoreBorder.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(data.HealthScoreColor)); FinOpsHealthScoreBorder.Visibility = Visibility.Visible; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs index 68d0bf1e2..ff74a249c 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs @@ -54,8 +54,12 @@ FROM v_memory_stats ORDER BY collection_time DESC LIMIT 1 ), +/* cpu_count is the count the server itself has. On an Azure SQL Database (engine_edition 5) the stored cpu_count + describes the HOST (a 1-vCore serverless database read 2), so there it is the vcore_count parsed from the service + objective, and NULL for an objective that names no vCores (a DTU model): never the host count. Every other edition + reads as it always did. The same CASE is in the Lite read. */ server_info AS ( - SELECT COALESCE(vcore_count, cpu_count) AS cpu_count, engine_edition + SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, engine_edition FROM server_properties WHERE server_id = $1 ORDER BY collection_time DESC diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs index 7d78d01ac..d351f4b74 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs @@ -114,11 +114,15 @@ public sealed class UtilizationEfficiencyRow public int MaxWorkersCount { get; set; } public int CurrentWorkersCount { get; set; } + + /// The server's OWN CPU count, 0 when there is none. On an Azure SQL Database ( 5) the + /// stored cpu_count is the HOST's, so this is the vcore_count parsed from the service objective and 0 for an + /// objective that names none (a DTU model), which the card shows as n/a. It is never the host's count. public int CpuCount { get; set; } /// The engine edition of the server these figures describe (SERVERPROPERTY('EngineEdition'), 0 when unread). - /// On an Azure SQL Database (5) is the HOST's, so the card does not show it and its - /// verdict sentence cites no share of it; the verdict itself is unchanged. + /// On an Azure SQL Database (5) is the HOST's, so the card does not show it, its verdict + /// sentence cites no share of it, and the health score has no memory term. The verdict itself reads none of it. public int EngineEdition { get; set; } public string ProvisioningStatus { get; set; } = ""; @@ -130,6 +134,20 @@ public sealed class UtilizationEfficiencyRow public decimal FreeSpacePct { get; set; } public int HealthScore { get; set; } public string HealthScoreColor => FinOpsHealthCalculator.ScoreColor(HealthScore); + + /// + /// The health score for these figures: CPU p95, the buffer pool's share of physical memory, and free storage. On an + /// Azure SQL Database ( 5) physical memory is the HOST's, so the memory term is left out, not + /// scored as zero and not scored as a default, and CPU and storage carry the whole score. Anywhere else the score is + /// what it always was. + /// + public int ComputeHealthScore() + { + var bpRatio = PhysicalMemoryMb > 0 ? (decimal)BufferPoolMb / PhysicalMemoryMb : 0m; + int? memScore = ServerHardwareScope.HardwareIsTheHosts(EngineEdition) ? null : FinOpsHealthCalculator.MemoryScore(bpRatio); + return FinOpsHealthCalculator.Overall( + FinOpsHealthCalculator.CpuScore(P95CpuPct), memScore, FinOpsHealthCalculator.StorageScore(FreeSpacePct)); + } } /// Per-database resource usage (Database Resources sub-tab). @@ -498,8 +516,15 @@ public static int StorageScore(decimal freeSpacePct) return (int)(freeSpacePct * 5); } - public static int Overall(int cpu, int memory, int storage) => - (int)(cpu * 0.40 + memory * 0.30 + storage * 0.30); + /// + /// The overall score: CPU 40%, memory 30%, storage 30%. A null means memory is not + /// applicable (an Azure SQL Database's physical memory is the host's), so the term is left out, not scored as zero and + /// not scored as a default: CPU and storage keep their 40:30 weights over their own 70%. + /// + public static int Overall(int cpu, int? memory, int storage) => + memory is int mem + ? (int)(cpu * 0.40 + mem * 0.30 + storage * 0.30) + : (cpu * 40 + storage * 30) / 70; /* integer weights, so no floating-point error can truncate 100 to 99 */ public static string ScoreColor(int score) => score switch { diff --git a/Lite.Tests/AzureSqlDatabaseHardwareTests.cs b/Lite.Tests/AzureSqlDatabaseHardwareTests.cs index 9839cf34e..9343fc81f 100644 --- a/Lite.Tests/AzureSqlDatabaseHardwareTests.cs +++ b/Lite.Tests/AzureSqlDatabaseHardwareTests.cs @@ -258,7 +258,9 @@ public void FinOpsUtilizationCard_AsksTheSharedRule_BeforeItShowsPhysicalMemory( Assert.Contains("ServerHardwareScope.OverProvisionedExplanation(", tab, StringComparison.Ordinal); Assert.Contains("ServerHardwareScope.RightSizedExplanation(", tab, StringComparison.Ordinal); Assert.DoesNotContain("of physical RAM", tab, StringComparison.Ordinal); - Assert.Contains("COALESCE(vcore_count, cpu_count) AS cpu_count, engine_edition", read, StringComparison.Ordinal); + /* The CPU count is resolved through the edition (AzureSqlDatabaseHostMathTests pins the CASE): off edition 5 it is still + COALESCE(vcore_count, cpu_count), and the edition still rides along for the card. */ + Assert.Contains("ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, engine_edition", read, StringComparison.Ordinal); Assert.Contains("EngineEdition = reader.IsDBNull(16)", read, StringComparison.Ordinal); } diff --git a/Lite.Tests/AzureSqlDatabaseHostMathTests.cs b/Lite.Tests/AzureSqlDatabaseHostMathTests.cs new file mode 100644 index 000000000..089cb689b --- /dev/null +++ b/Lite.Tests/AzureSqlDatabaseHostMathTests.cs @@ -0,0 +1,293 @@ +/* + * 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.Runtime.CompilerServices; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitor.Common; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info describes the HOST: a 1-vCore serverless +/// General Purpose database read 2 logical CPUs and 911.9 GB of physical memory. +/// pins that nothing SHOWS those values as the database's. These pins are the calculations that USED them: the attributed-CPU +/// denominator, the FinOps utilization card's CPU count, and the FinOps health score. +/// +/// The rule: on an Azure SQL Database each of those uses the database's own figure where one is collected (the +/// vcore_count parsed from the service objective) and is otherwise NOT APPLICABLE. A DTU-model objective has no vCore +/// count, so its CPU count is not applicable and nothing is computed from the host's. SQL Server (editions 1 to 4) and Managed +/// Instance (8) behave exactly as before, and every test has that twin. The Darling.Tests twin pins the same table for the +/// other app, in the same words. +/// +public sealed class AzureSqlDatabaseHostMathTests : IClassFixture, IDisposable +{ + private const int ServerId = -487_002; + private static readonly DateTime s_start = new(2026, 9, 30, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime s_end = s_start.AddHours(1); + + private readonly SharedDuckDbFixture _fixture; + private DuckDBConnection? _seedConn; + + public AzureSqlDatabaseHostMathTests(SharedDuckDbFixture fixture) + { + fixture.ResetData(); + _fixture = fixture; + } + + public void Dispose() => _seedConn?.Dispose(); + + // ── CPU attribution ── + + /// Half of one CPU for an hour is 1,800 CPU-seconds; half of the host's two would be 3,600. + private static CpuAttribution.Result Attribute(int? engineEdition, int storedCpuCount, int? vcoreCount) => + CpuAttribution.Compute( + rankedCpuSeconds: 900, s_start, s_end, + sampleCount: 60, firstSampleUtc: s_start, lastSampleUtc: s_end, avgSqlCpuPercent: 50, + engineEdition, storedCpuCount, vcoreCount); + + [Fact] + public void Attribution_OnAzureSqlDatabase_WithVcores_DividesByTheVcores_NotTheHostsCpus() + { + var result = Attribute(5, storedCpuCount: 2, vcoreCount: 1); + + Assert.Equal(1800, result.SqlCpuSecondsInWindow); + Assert.Equal(0.5, result.AttributedCpuRatio); + Assert.Null(result.Note); + } + + [Theory] + [InlineData(null)] + [InlineData(0)] + public void Attribution_OnAzureSqlDatabase_WithNoVcores_IsNotApplicable_AndComputesNothingFromTheHost(int? vcoreCount) + { + var result = Attribute(5, storedCpuCount: 2, vcoreCount); + + Assert.Equal(900, result.RankedCpuSeconds); + Assert.Null(result.SqlCpuSecondsInWindow); + Assert.Null(result.AttributedCpuRatio); + Assert.Equal(CpuAttribution.CoreCountNotApplicableNote, result.Note); + Assert.Contains("not applicable", result.Note, StringComparison.Ordinal); + Assert.Contains("DTU", result.Note, StringComparison.Ordinal); + Assert.DoesNotContain("no server_properties snapshot", result.Note, StringComparison.Ordinal); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(8)] + public void Attribution_OnEveryOtherEdition_IsTheStoredCountMathItAlwaysWas(int engineEdition) + { + var legacy = CpuAttribution.Compute(900, s_start, s_end, 60, s_start, s_end, 50, 8); + + Assert.Equal(legacy, Attribute(engineEdition, storedCpuCount: 8, vcoreCount: null)); + /* A vcore_count beside a non-Azure edition is not read: only edition 5 resolves it. */ + Assert.Equal(legacy, Attribute(engineEdition, storedCpuCount: 8, vcoreCount: 1)); + Assert.Equal(14400, legacy.SqlCpuSecondsInWindow); + } + + [Fact] + public void Attribution_WithNoServerPropertiesRow_KeepsItsUnavailableNote() + { + var result = Attribute(engineEdition: null, storedCpuCount: 0, vcoreCount: null); + + Assert.Null(result.AttributedCpuRatio); + Assert.Equal(CpuAttribution.CoreCountUnavailableNote, result.Note); + Assert.Contains("no server_properties snapshot", result.Note, StringComparison.Ordinal); + } + + [Fact] + public void OwnCpuCount_IsTheVcoresOnAzureSqlDatabase_AndTheStoredCountEverywhereElse() + { + Assert.Equal(1, ServerHardwareScope.OwnCpuCount(5, cpuCount: 2, vcoreCount: 1)); + Assert.Null(ServerHardwareScope.OwnCpuCount(5, cpuCount: 2, vcoreCount: null)); + Assert.Null(ServerHardwareScope.OwnCpuCount(5, cpuCount: 2, vcoreCount: 0)); + Assert.Equal(16, ServerHardwareScope.OwnCpuCount(3, cpuCount: 16, vcoreCount: null)); + Assert.Equal(4, ServerHardwareScope.OwnCpuCount(8, cpuCount: 4, vcoreCount: null)); + Assert.Null(ServerHardwareScope.OwnCpuCount(3, cpuCount: null, vcoreCount: null)); + } + + [Fact] + public void TopQueriesAndTopProceduresTools_PassTheEditionAndTheVcores_NotOnlyTheStoredCount() + { + var tool = ReadRepoFile("Lite/Mcp/McpQueryTools.cs"); + + Assert.Equal(2, CountOf(tool, "properties?.EngineEdition, properties?.CpuCount ?? 0, properties?.VcoreCount);")); + Assert.DoesNotContain("properties?.CpuCount ?? 0);", tool, StringComparison.Ordinal); + } + + // ── FinOps utilization card: the CPU count ── + + [Fact] + public void CpuCountText_OnAzureSqlDatabase_IsNotApplicableWithNoVcores_AndTheVcoresOtherwise() + { + Assert.Equal(ServerHardwareScope.NotApplicable, ServerHardwareScope.CpuCountText(5, 0)); + Assert.Equal("n/a", ServerHardwareScope.CpuCountText(5, 0)); + Assert.Equal("1", ServerHardwareScope.CpuCountText(5, 1)); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(8)] + [InlineData(null)] + public void CpuCountText_OnEveryOtherEdition_IsTheCountAsItAlwaysWas(int? engineEdition) + { + Assert.Equal(1234.ToString("N0", System.Globalization.CultureInfo.CurrentCulture), ServerHardwareScope.CpuCountText(engineEdition, 1234)); + Assert.Equal("16", ServerHardwareScope.CpuCountText(engineEdition, 16)); + Assert.Equal("0", ServerHardwareScope.CpuCountText(engineEdition, 0)); + } + + [Theory] + [InlineData(5, 1, 1)] // Azure SQL Database, vCore objective: its vCores, not the host's 2 + [InlineData(5, null, 0)] // Azure SQL Database, DTU objective: no count, never the host's 2 + [InlineData(3, null, 2)] // SQL Server: the stored count + [InlineData(8, null, 2)] // Managed Instance: the stored count + public async Task UtilizationRead_ResolvesTheCpuCountThroughTheEdition(int engineEdition, int? vcoreCount, int expectedCpuCount) + { + await SeedAsync(engineEdition, hostCpuCount: 2, vcoreCount); + + var row = await new LocalDataService(_fixture.DuckDb).GetUtilizationEfficiencyAsync(ServerId); + + Assert.NotNull(row); + Assert.Equal(expectedCpuCount, row!.CpuCount); + Assert.Equal(engineEdition, row.EngineEdition); + } + + // ── FinOps utilization card: the health score ── + + /// CPU p95 of 7% scores 95 and 50% free storage scores 100. Buffer pool 40 GB of a 933,888 MB host is 4%, which + /// scores 60 when memory counts: 95 * 0.4 + 60 * 0.3 + 100 * 0.3 = 86, the score a 1-vCore database showed. + private static UtilizationEfficiencyRow Utilization(int engineEdition, int bufferPoolMb, int physicalMemoryMb) => new() + { + EngineEdition = engineEdition, + P95CpuPct = 7m, + BufferPoolMb = bufferPoolMb, + PhysicalMemoryMb = physicalMemoryMb, + FreeSpacePct = 50m, + }; + + [Fact] + public void HealthScore_OnAzureSqlDatabase_LeavesMemoryOut_AndDoesNotMoveWithTheHostsMemory() + { + Assert.Equal(97, Utilization(5, 40_960, 933_888).ComputeHealthScore()); + Assert.Equal(97, Utilization(5, 40_960, 65_536).ComputeHealthScore()); + Assert.Equal(97, Utilization(5, 800_000, 933_888).ComputeHealthScore()); + Assert.Equal(97, Utilization(5, 0, 0).ComputeHealthScore()); + Assert.Equal(FinOpsHealthCalculator.Overall(95, null, 100), Utilization(5, 40_960, 933_888).ComputeHealthScore()); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(8)] + public void HealthScore_OnEveryOtherEdition_KeepsItsMemoryTerm(int engineEdition) + { + Assert.Equal(86, Utilization(engineEdition, 40_960, 933_888).ComputeHealthScore()); + Assert.Equal(98, Utilization(engineEdition, 600_000, 933_888).ComputeHealthScore()); + } + + [Fact] + public void Overall_WithNoMemoryScore_WeighsCpuAndStorageOverTheirOwnSeventyPercent() + { + Assert.Equal(100, FinOpsHealthCalculator.Overall(100, null, 100)); + Assert.Equal(0, FinOpsHealthCalculator.Overall(0, null, 0)); + Assert.Equal(97, FinOpsHealthCalculator.Overall(95, null, 100)); + Assert.Equal(62, FinOpsHealthCalculator.Overall(80, null, 40)); + /* With a memory score, the long-standing arithmetic, byte for byte. */ + Assert.Equal(86, FinOpsHealthCalculator.Overall(95, 60, 100)); + Assert.Equal(62, FinOpsHealthCalculator.Overall(80, 60, 40)); + } + + // ── the wiring, pinned at the source ── + + private static string ReadRepoFile(string relativePath, [CallerFilePath] string thisFile = "") + { + var dir = Path.GetDirectoryName(thisFile)!; + var parts = relativePath.Split('/'); + while (dir is not null && !File.Exists(Path.Combine(new[] { dir }.Concat(parts).ToArray()))) + dir = Path.GetDirectoryName(dir); + + Assert.NotNull(dir); + return File.ReadAllText(Path.Combine(new[] { dir! }.Concat(parts).ToArray())); + } + + private static int CountOf(string text, string needle) + { + var count = 0; + for (var at = text.IndexOf(needle, StringComparison.Ordinal); at >= 0; at = text.IndexOf(needle, at + needle.Length, StringComparison.Ordinal)) + count++; + return count; + } + + [Fact] + public void FinOpsUtilizationCard_AsksTheSharedRule_ForTheCpuCountAndTheHealthScore() + { + var tab = ReadRepoFile("Lite/Controls/FinOpsTab.xaml.cs"); + var read = ReadRepoFile("Lite/Services/LocalDataService.FinOps.Utilization.cs"); + + Assert.Contains("CpuCountText.Text = ServerHardwareScope.CpuCountText(data.EngineEdition, data.CpuCount);", tab, StringComparison.Ordinal); + Assert.DoesNotContain("data.CpuCount.ToString(", tab, StringComparison.Ordinal); + Assert.Contains("data.HealthScore = data.ComputeHealthScore();", tab, StringComparison.Ordinal); + Assert.Contains("HealthScoreBorder.ToolTip = azureSqlDb ? ServerHardwareScope.HealthScoreWithoutMemoryNote : null;", tab, StringComparison.Ordinal); + Assert.DoesNotContain("FinOpsHealthCalculator.MemoryScore(", tab, StringComparison.Ordinal); + + var edition = ServerHardwareScope.AzureSqlDatabaseEngineEdition; + Assert.Contains( + $"SELECT CASE WHEN engine_edition = {edition} THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, engine_edition", + read, StringComparison.Ordinal); + } + + // ── seeding ── + + private async Task SeedAsync(int engineEdition, int hostCpuCount, int? vcoreCount) + { + using var readLock = _fixture.DuckDb.AcquireReadLock(); + if (_seedConn is null) + { + _seedConn = _fixture.DuckDb.CreateConnection(); + await _seedConn.OpenAsync(); + } + + using (var cmd = _seedConn.CreateCommand()) + { + cmd.CommandText = @" +INSERT INTO server_properties + (collection_id, collection_time, server_id, server_name, edition, product_version, product_level, engine_edition, + cpu_count, hyperthread_ratio, physical_memory_mb, socket_count, cores_per_socket, service_objective, vcore_count) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)"; + void P(object? v) => cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); + P(-487_002L); P(DateTime.UtcNow); P(ServerId); P("AzureHostMathSrv"); P("SQL Azure"); P("12.0.2000.8"); P("RTM"); + P(engineEdition); P(hostCpuCount); P(64); P(933_888L); P(0); P(32); P(vcoreCount.HasValue ? "GP_S_Gen5_" + vcoreCount : "S0"); P(vcoreCount); + await cmd.ExecuteNonQueryAsync(); + } + + using (var cmd = _seedConn.CreateCommand()) + { + cmd.CommandText = @" +INSERT INTO memory_stats + (collection_id, collection_time, server_id, server_name, total_physical_memory_mb, available_physical_memory_mb, + target_server_memory_mb, total_server_memory_mb, buffer_pool_mb) +VALUES ($1, $2, $3, $4, 933888, 900000, 40000, 40000, 30000)"; + void P(object? v) => cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); + P(-487_003L); P(DateTime.UtcNow); P(ServerId); P("AzureHostMathSrv"); + await cmd.ExecuteNonQueryAsync(); + } + } +} diff --git a/Lite/Controls/FinOpsTab.xaml.cs b/Lite/Controls/FinOpsTab.xaml.cs index 2428e7c1a..c3314d3c9 100644 --- a/Lite/Controls/FinOpsTab.xaml.cs +++ b/Lite/Controls/FinOpsTab.xaml.cs @@ -401,7 +401,8 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) P95CpuText.Text = $"{data.P95CpuPct:N2}%"; MaxCpuText.Text = $"{data.MaxCpuPct}%"; CpuSamplesText.Text = data.CpuSamples.ToString("N0"); - CpuCountText.Text = data.CpuCount.ToString("N0"); + /* n/a on an Azure SQL Database whose service objective names no vCores: the host's count is never shown as the database's. */ + CpuCountText.Text = ServerHardwareScope.CpuCountText(data.EngineEdition, data.CpuCount); WorkerThreadsText.Text = $"{data.CurrentWorkersCount:N0} / {data.MaxWorkersCount:N0}"; SetBar(AvgCpuBar, AvgCpuFilled, AvgCpuEmpty, (double)data.AvgCpuPct); @@ -421,7 +422,8 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) : 0; /* An Azure SQL Database's physical memory is the HOST's, not the database's allocation, so neither the figure nor - the buffer pool's share of it is shown; the verdict and the health score below still read the stored value. */ + the buffer pool's share of it is shown, and the health score below leaves its memory term out. The verdict reads none + of it: its inputs are the database's own CPU, its workspace-memory grants and its worker threads. */ var azureSqlDb = ServerHardwareScope.HardwareIsTheHosts(data.EngineEdition); MemoryRatioText.Text = azureSqlDb ? ServerHardwareScope.NotApplicable : $"{bpPct:N0}%"; SetBar(MemoryRatioBar, MemRatioFilled, MemRatioEmpty, azureSqlDb ? 0 : bpPct); @@ -461,12 +463,11 @@ pressure or worker saturation would have been explained as a memory ratio that n } StorageCostCard.Visibility = Visibility.Collapsed; - /* Health score */ - var bpRatio = data.PhysicalMemoryMb > 0 ? (decimal)data.BufferPoolMb / data.PhysicalMemoryMb : 0m; - var cpuScore = FinOpsHealthCalculator.CpuScore(data.P95CpuPct); - var memScore = FinOpsHealthCalculator.MemoryScore(bpRatio); - var storScore = FinOpsHealthCalculator.StorageScore(data.FreeSpacePct); - data.HealthScore = FinOpsHealthCalculator.Overall(cpuScore, memScore, storScore); + /* Health score. Its memory term is the buffer pool's share of physical memory, and on an Azure SQL Database that + memory is the HOST's: the term is left out (not scored as zero, not scored as a default), so CPU and storage carry + the score and the tooltip says so. */ + data.HealthScore = data.ComputeHealthScore(); + HealthScoreBorder.ToolTip = azureSqlDb ? ServerHardwareScope.HealthScoreWithoutMemoryNote : null; HealthScoreText.Text = $"Health: {data.HealthScore}"; HealthScoreBorder.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(data.HealthScoreColor)); HealthScoreBorder.Visibility = Visibility.Visible; diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index 90d44b5f7..72e57d774 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -92,11 +92,13 @@ ratio is omitted rather than invented when a denominator piece is missing. One n await Task.WhenAll(cpuAggregateTask, propertiesTask); var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; + /* The core count is the server's own: on an Azure SQL Database the stored cpu_count is the HOST's, so this divides by its + vcore_count, or omits the ratio for a DTU objective that names no vCores (see CpuAttribution). */ var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuMs) / 1000.0, requestedStart, nowUtc, cpuAggregate.SampleCount, cpuAggregate.FirstSample, cpuAggregate.LastSample, cpuAggregate.AvgSqlCpuPercent, - properties?.CpuCount ?? 0); + properties?.EngineEdition, properties?.CpuCount ?? 0, properties?.VcoreCount); var result = rows.Select(r => new { @@ -219,11 +221,13 @@ public static async Task GetTopProceduresByCpu( await Task.WhenAll(cpuAggregateTask, propertiesTask); var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; + /* The core count is the server's own: on an Azure SQL Database the stored cpu_count is the HOST's, so this divides by its + vcore_count, or omits the ratio for a DTU objective that names no vCores (see CpuAttribution). */ var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuMs) / 1000.0, requestedStart, nowUtc, cpuAggregate.SampleCount, cpuAggregate.FirstSample, cpuAggregate.LastSample, cpuAggregate.AvgSqlCpuPercent, - properties?.CpuCount ?? 0); + properties?.EngineEdition, properties?.CpuCount ?? 0, properties?.VcoreCount); var result = rows.Select(r => new { diff --git a/Lite/Services/LocalDataService.FinOps.Utilization.cs b/Lite/Services/LocalDataService.FinOps.Utilization.cs index 4658f1c2d..4697eed9c 100644 --- a/Lite/Services/LocalDataService.FinOps.Utilization.cs +++ b/Lite/Services/LocalDataService.FinOps.Utilization.cs @@ -52,8 +52,12 @@ FROM v_memory_stats ORDER BY collection_time DESC LIMIT 1 ), +/* cpu_count is the count the server itself has. On an Azure SQL Database (engine_edition 5) the stored cpu_count + describes the HOST (a 1-vCore serverless database read 2), so there it is the vcore_count parsed from the service + objective, and NULL for an objective that names no vCores (a DTU model): never the host count. Every other edition + reads as it always did. The same CASE is in the Darling read. */ server_info AS ( - SELECT COALESCE(vcore_count, cpu_count) AS cpu_count, engine_edition + SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, engine_edition FROM v_server_properties WHERE server_id = $1 ORDER BY collection_time DESC diff --git a/Lite/Services/LocalDataService.FinOps.cs b/Lite/Services/LocalDataService.FinOps.cs index 3759ac293..859bd600d 100644 --- a/Lite/Services/LocalDataService.FinOps.cs +++ b/Lite/Services/LocalDataService.FinOps.cs @@ -97,11 +97,15 @@ public class UtilizationEfficiencyRow public int MaxWorkersCount { get; set; } public int CurrentWorkersCount { get; set; } + + /// The server's OWN CPU count, 0 when there is none. On an Azure SQL Database ( 5) the + /// stored cpu_count is the HOST's, so this is the vcore_count parsed from the service objective and 0 for an + /// objective that names none (a DTU model), which the card shows as n/a. It is never the host's count. public int CpuCount { get; set; } /// The engine edition of the server these figures describe (SERVERPROPERTY('EngineEdition'), 0 when unread). - /// On an Azure SQL Database (5) is the HOST's, so the card does not show it and its - /// verdict sentence cites no share of it; the verdict itself is unchanged. + /// On an Azure SQL Database (5) is the HOST's, so the card does not show it, its verdict + /// sentence cites no share of it, and the health score has no memory term. The verdict itself reads none of it. public int EngineEdition { get; set; } public string ProvisioningStatus { get; set; } = ""; @@ -113,6 +117,20 @@ public class UtilizationEfficiencyRow public decimal FreeSpacePct { get; set; } public int HealthScore { get; set; } public string HealthScoreColor => FinOpsHealthCalculator.ScoreColor(HealthScore); + + /// + /// The health score for these figures: CPU p95, the buffer pool's share of physical memory, and free storage. On an + /// Azure SQL Database ( 5) physical memory is the HOST's, so the memory term is left out, not + /// scored as zero and not scored as a default, and CPU and storage carry the whole score. Anywhere else the score is + /// what it always was. + /// + public int ComputeHealthScore() + { + var bpRatio = PhysicalMemoryMb > 0 ? (decimal)BufferPoolMb / PhysicalMemoryMb : 0m; + int? memScore = ServerHardwareScope.HardwareIsTheHosts(EngineEdition) ? null : FinOpsHealthCalculator.MemoryScore(bpRatio); + return FinOpsHealthCalculator.Overall( + FinOpsHealthCalculator.CpuScore(P95CpuPct), memScore, FinOpsHealthCalculator.StorageScore(FreeSpacePct)); + } } public class DatabaseResourceUsageRow @@ -394,8 +412,15 @@ public static int StorageScore(decimal freeSpacePct) return (int)(freeSpacePct * 5); } - public static int Overall(int cpu, int memory, int storage) => - (int)(cpu * 0.40 + memory * 0.30 + storage * 0.30); + /// + /// The overall score: CPU 40%, memory 30%, storage 30%. A null means memory is not + /// applicable (an Azure SQL Database's physical memory is the host's), so the term is left out, not scored as zero and + /// not scored as a default: CPU and storage keep their 40:30 weights over their own 70%. + /// + public static int Overall(int cpu, int? memory, int storage) => + memory is int mem + ? (int)(cpu * 0.40 + mem * 0.30 + storage * 0.30) + : (cpu * 40 + storage * 30) / 70; /* integer weights, so no floating-point error can truncate 100 to 99 */ public static string ScoreColor(int score) => score switch { diff --git a/PerformanceMonitor.Common/CpuAttribution.cs b/PerformanceMonitor.Common/CpuAttribution.cs index 64b74fe90..cf0aef76f 100644 --- a/PerformanceMonitor.Common/CpuAttribution.cs +++ b/PerformanceMonitor.Common/CpuAttribution.cs @@ -24,6 +24,13 @@ namespace PerformanceMonitor.Common /// (the cpu_utilization series both stores already collect) × core count (server_properties) × window /// seconds. When a piece is missing — no CPU samples, no properties snapshot, or the series covers too /// little of the window — the ratio is OMITTED, never invented (#2320's explicit degrade rule). + /// + /// The core count is the server's OWN. On an Azure SQL Database sys.dm_os_sys_info reports the + /// HOST's CPUs (a 1-vCore serverless database read 2), and a denominator built from them is wrong by the ratio of + /// the two. There the count is the vcore_count parsed from the service objective, and a DTU-model objective, + /// which names no vCores, has none: the ratio is omitted with instead of + /// being computed from the host. holds that rule; the overload that + /// takes the engine edition applies it, so a caller holding a server_properties row cannot forget it. /// public static class CpuAttribution { @@ -42,6 +49,15 @@ public static class CpuAttribution /// sampling noise between the two series. public const double OverAttributionThreshold = 1.1; + /// The note when no server_properties row gave a core count at all. + public const string CoreCountUnavailableNote = + "core count unavailable (no server_properties snapshot), so measured CPU-seconds cannot be computed; ratio omitted rather than invented"; + + /// The note on an Azure SQL Database whose service objective names no vCores: the host's core count is + /// not this database's allocation, so there is no core count to multiply by. The ratio is omitted, not estimated. + public const string CoreCountNotApplicableNote = + "core count not applicable: on an Azure SQL Database the host's core count is not this database's allocation and its service objective names no vCores (DTU model), so measured CPU-seconds cannot be computed; ratio omitted rather than invented"; + /// /// A null always comes with a saying why. /// is usually null alongside it (the denominator could not be @@ -70,7 +86,51 @@ public static Result Compute( DateTime? firstSampleUtc, DateTime? lastSampleUtc, double? avgSqlCpuPercent, - int cpuCount) + int cpuCount) => + ComputeCore(rankedCpuSeconds, windowStartUtc, windowEndUtc, sampleCount, firstSampleUtc, lastSampleUtc, + avgSqlCpuPercent, cpuCount, CoreCountUnavailableNote); + + /// + /// The computation for a caller that holds the server's server_properties columns: the same as the + /// overload above, with the core count resolved through . Off an + /// Azure SQL Database (or with no row, null) that is + /// unchanged. On one it is , or no count at all for a DTU-model objective, and + /// then the ratio is omitted with rather than computed from the + /// host's CPUs. Both SKUs' top-queries and top-procedures tools call this one. + /// + public static Result Compute( + double rankedCpuSeconds, + DateTime windowStartUtc, + DateTime windowEndUtc, + int sampleCount, + DateTime? firstSampleUtc, + DateTime? lastSampleUtc, + double? avgSqlCpuPercent, + int? engineEdition, + int cpuCount, + int? vcoreCount) + { + if (!ServerHardwareScope.HardwareIsTheHosts(engineEdition)) + { + return Compute(rankedCpuSeconds, windowStartUtc, windowEndUtc, sampleCount, firstSampleUtc, lastSampleUtc, + avgSqlCpuPercent, cpuCount); + } + + var own = ServerHardwareScope.OwnCpuCount(engineEdition, cpuCount, vcoreCount); + return ComputeCore(rankedCpuSeconds, windowStartUtc, windowEndUtc, sampleCount, firstSampleUtc, lastSampleUtc, + avgSqlCpuPercent, own ?? 0, CoreCountNotApplicableNote); + } + + private static Result ComputeCore( + double rankedCpuSeconds, + DateTime windowStartUtc, + DateTime windowEndUtc, + int sampleCount, + DateTime? firstSampleUtc, + DateTime? lastSampleUtc, + double? avgSqlCpuPercent, + int cpuCount, + string noCoreCountNote) { var ranked = Math.Round(rankedCpuSeconds, 1); var windowSeconds = (windowEndUtc - windowStartUtc).TotalSeconds; @@ -89,8 +149,7 @@ public static Result Compute( if (cpuCount <= 0) { - return new Result(ranked, null, null, - "core count unavailable (no server_properties snapshot), so measured CPU-seconds cannot be computed; ratio omitted rather than invented"); + return new Result(ranked, null, null, noCoreCountNote); } var coverageStart = firstSampleUtc.Value > windowStartUtc ? firstSampleUtc.Value : windowStartUtc; diff --git a/PerformanceMonitor.Common/ServerHardwareScope.cs b/PerformanceMonitor.Common/ServerHardwareScope.cs index 220da8a9f..5cffee5fe 100644 --- a/PerformanceMonitor.Common/ServerHardwareScope.cs +++ b/PerformanceMonitor.Common/ServerHardwareScope.cs @@ -23,8 +23,13 @@ namespace PerformanceMonitor.Common; /// /// Every surface that SHOWS cpu_count, socket_count, cores_per_socket, /// hyperthread_ratio or physical_memory_mb asks this class first, so the rule and the words live in -/// one place for both apps. The collector and every sizing or verdict calculation keep reading the stored -/// values; this decides only what is presented as the database's. +/// one place for both apps. The collector keeps storing the values as read. +/// +/// Every calculation that would DIVIDE BY or SCORE one of them asks this class too: the attributed-CPU +/// denominator (), the FinOps utilization card's CPU count and the FinOps health score. On +/// an Azure SQL Database each of those uses the database's own figure where one is collected (vcore_count) and is +/// otherwise NOT APPLICABLE. None of them falls back to the host's count or memory, because a number built from the +/// host reads as the database's and is wrong. A DTU-model objective names no vCores, so its CPU count is not applicable. /// public static class ServerHardwareScope { @@ -70,6 +75,35 @@ public static JsonObject ScopeServerPropertiesPayload(JsonObject payload, int? v /// What the FinOps utilization card shows where it would have shown the host's physical memory. public const string NotApplicable = "n/a"; + /// + /// The CPU count a calculation may treat as the server's OWN. Off an Azure SQL Database it is the stored + /// , as it always was. On one the stored count is the HOST's, so the answer is the + /// the collector parsed from the service objective, and null (not applicable) + /// when the objective names none (a DTU-model objective, or an elastic pool). It is never the host's count. + /// + public static int? OwnCpuCount(int? engineEdition, int? cpuCount, int? vcoreCount) => + HardwareIsTheHosts(engineEdition) + ? (vcoreCount > 0 ? vcoreCount : null) + : cpuCount; + + /// + /// The FinOps utilization card's CPU Count text. The FinOps read already resolves the count through + /// in SQL and hands over 0 where there is none, so a 0 on an Azure SQL Database reads + /// here. Anywhere else the text is the count with thousands separators, as it always was. + /// + public static string CpuCountText(int? engineEdition, int cpuCount) => + HardwareIsTheHosts(engineEdition) && cpuCount <= 0 + ? NotApplicable + : cpuCount.ToString("N0", CultureInfo.CurrentCulture); + + /// + /// The tooltip on the FinOps health score when it has no memory term. The score's memory term is the buffer pool's + /// share of physical memory, and an Azure SQL Database's physical memory is the host's, so that term is left out + /// and CPU and storage carry the whole score. + /// + public const string HealthScoreWithoutMemoryNote = + "Memory is not part of this score: an Azure SQL Database's physical memory is the host's, not this database's allocation. The score is CPU and storage only."; + /// /// The FinOps utilization verdict sentence for a server whose provisioning is RIGHT_SIZED. Off an Azure SQL /// Database it is the long-standing sentence, byte for byte; on one it drops the clause that compares the buffer From ae8768be302938ea6bb178d6d90f566a8e7cdcb2 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:03:32 -0400 Subject: [PATCH 04/17] Right-sizing test seeds an Azure SQL Database with vCores, and a DTU database gets no CPU advice The CPU count on an Azure SQL Database is now its vCore count, or none for a DTU objective, never the host's count. The test that expects the CPU rule to fire on edition 5 seeded the host's 32 CPUs with no vcore_count, which is the DTU case. It now seeds a 32-vCore objective. A new test pins that a DTU database gets no CPU advice. --- Lite.Tests/FinOpsTests.cs | 21 +++++++++++++++++---- Lite.Tests/TestDataSeeder.cs | 9 ++++++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/Lite.Tests/FinOpsTests.cs b/Lite.Tests/FinOpsTests.cs index 45a62dc37..9f02ea82a 100644 --- a/Lite.Tests/FinOpsTests.cs +++ b/Lite.Tests/FinOpsTests.cs @@ -74,14 +74,27 @@ public async Task NoCpuSamples_CpuAndVmRightSizingAdviseNothing() [Fact] public async Task AzureSqlDatabaseHostMemory_MemoryAndVmRightSizingAdviseNothing() { - // Edition 5 reports the HOST's 256 GB as physical memory and the database uses a sliver of it. - var recs = await RunRecommendationsAsync(s => s.SeedRightSizingScenarioAsync(engineEdition: 5, withCpuSamples: true)); + // Edition 5 reports the HOST's 256 GB as physical memory and the database uses a sliver of it. Its service + // objective names 32 vCores, which are the database's own CPU count. + var recs = await RunRecommendationsAsync(s => s.SeedRightSizingScenarioAsync(engineEdition: 5, withCpuSamples: true, vcoreCount: 32)); PrintRecommendations("AZURE SQL DATABASE HOST MEMORY", recs); Assert.DoesNotContain(recs, r => r.Finding.StartsWith("Memory over-provisioned", StringComparison.Ordinal)); Assert.DoesNotContain(recs, r => r.Category == "Hardware"); - // The CPU rule is not one of the two that stand down on a database. - Assert.Contains(recs, r => r.Finding.StartsWith("CPU over-provisioned", StringComparison.Ordinal)); + // The CPU rule is not one of the two that stand down on a database: it reads the vCores the objective names. + Assert.Contains(recs, r => r.Finding.StartsWith("CPU over-provisioned (32 cores", StringComparison.Ordinal)); + } + + [Fact] + public async Task AzureSqlDatabaseWithNoVcores_CpuRightSizingAdvisesNothing_BecauseTheHostsCpuCountIsNotTheDatabases() + { + // A DTU objective names no vCores. The stored cpu_count is the HOST's 32, so the CPU rule has no count to work from. + var recs = await RunRecommendationsAsync(s => s.SeedRightSizingScenarioAsync(engineEdition: 5, withCpuSamples: true)); + PrintRecommendations("AZURE SQL DATABASE, NO VCORES", recs); + + Assert.DoesNotContain(recs, r => r.Finding.StartsWith("CPU over-provisioned", StringComparison.Ordinal)); + Assert.DoesNotContain(recs, r => r.Finding.Contains("32 cores", StringComparison.Ordinal)); + Assert.DoesNotContain(recs, r => r.Category == "Hardware"); } [Theory] diff --git a/Lite.Tests/TestDataSeeder.cs b/Lite.Tests/TestDataSeeder.cs index 9392319d2..859fca0ba 100644 --- a/Lite.Tests/TestDataSeeder.cs +++ b/Lite.Tests/TestDataSeeder.cs @@ -2141,9 +2141,11 @@ await SeedServerPropertiesAsync(cpuCount: 32, htRatio: 2, physicalMemMb: 262_144 /// A 32-core, 256 GB server whose CPU and memory both read as over-provisioned, on the given engine edition, /// with or without CPU samples. The right-sizing rules stand down for a server with no CPU sample (its P95 of 0 /// is not a measurement), and the memory and VM rules stand down on Azure SQL Database (edition 5), whose - /// physical_memory_mb is the HOST's memory and not the database's. + /// physical_memory_mb is the HOST's memory and not the database's. An Azure SQL Database also stores the HOST's 32 + /// CPUs as cpu_count: pass to give it the vCore count its service objective names, or + /// leave it null for a DTU objective, which has no CPU count of its own. /// - public async Task SeedRightSizingScenarioAsync(int engineEdition, bool withCpuSamples) + public async Task SeedRightSizingScenarioAsync(int engineEdition, bool withCpuSamples, int? vcoreCount = null) { await ClearTestDataAsync(); await SeedTestServerAsync(); @@ -2155,7 +2157,8 @@ public async Task SeedRightSizingScenarioAsync(int engineEdition, bool withCpuSa await SeedMemoryStatsAsync(totalPhysicalMb: 262_144, bufferPoolMb: 40_960, targetMb: 245_760); await SeedServerPropertiesAsync(cpuCount: 32, htRatio: 2, physicalMemMb: 262_144, - edition: engineEdition == 5 ? "SQL Azure" : "Enterprise Edition", engineEdition: engineEdition); + edition: engineEdition == 5 ? "SQL Azure" : "Enterprise Edition", engineEdition: engineEdition, + serviceObjective: vcoreCount.HasValue ? $"GP_Gen5_{vcoreCount}" : null, vcoreCount: vcoreCount); await SeedFileSizeAsync(totalDataSizeMb: 51_200); } From fc9ad864d4445b9e273bde4c29ba71377442e0fb Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:12:15 -0400 Subject: [PATCH 05/17] Keep the memory figures on an Azure SQL Database: they come from memory_stats, not the host On an Azure SQL Database the FinOps memory figures read memory_stats, where total_physical_memory_mb is filled from committed_target_kb. That is the database's own memory limit (1,838 MB on a 1-vCore General Purpose database), not the host's 911.9 GB. Only server_properties holds the host's memory. - The Utilization card shows Physical Memory and Buffer Pool % on every edition. On an Azure SQL Database the caption reads "Memory limit" and the verdict sentences name the database's memory limit. - The health score keeps its memory term on every edition. FinOpsHealthCalculator.Overall takes an int memory score again. - The memory and VM right-sizing rules still skip an Azure SQL Database, now because its memory comes with its service objective and cannot be resized on its own. - Comments and tests that called the memory_stats figure the host's are corrected. New tests seed memory_stats and server_properties with different values, so a read that swaps the two tables fails. - A DTU-model objective and an elastic pool both have no vCore count. The wording now names both. --- .../AzureSqlDatabaseHardwareTests.cs | 26 +- .../AzureSqlDatabaseHostMathTests.cs | 45 +-- .../AzureSqlDatabaseMemoryScopeTests.cs | 255 +++++++++++++ .../ViewerFinOpsRecommendationsTests.cs | 2 +- .../Mcp/DarlingDataReader.cs | 2 +- .../Mcp/DarlingMcpDataTools.cs | 4 +- .../FinOpsTab.Loaders.cs | 19 +- .../FinOpsTab.xaml | 4 +- ...iewerDataService.FinOps.Recommendations.cs | 9 +- .../ViewerDataService.FinOps.Utilization.cs | 4 +- .../ViewerDataService.FinOps.cs | 34 +- Lite.Tests/AzureSqlDatabaseHardwareTests.cs | 43 +-- Lite.Tests/AzureSqlDatabaseHostMathTests.cs | 59 ++- .../AzureSqlDatabaseMemoryScopeTests.cs | 346 ++++++++++++++++++ Lite.Tests/FinOpsTests.cs | 12 +- Lite.Tests/TestDataSeeder.cs | 20 +- Lite/Controls/FinOpsTab.xaml | 4 +- Lite/Controls/FinOpsTab.xaml.cs | 19 +- Lite/Mcp/McpQueryTools.cs | 4 +- ...LocalDataService.FinOps.Recommendations.cs | 9 +- .../LocalDataService.FinOps.Utilization.cs | 4 +- Lite/Services/LocalDataService.FinOps.cs | 34 +- Lite/Services/LocalDataService.ServerInfo.cs | 4 +- PerformanceMonitor.Common/CpuAttribution.cs | 8 +- .../ServerHardwareScope.cs | 58 +-- 25 files changed, 788 insertions(+), 240 deletions(-) create mode 100644 Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs create mode 100644 Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs index 6bda1de44..c09a80ec3 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs @@ -23,12 +23,13 @@ namespace Darling.Tests; /// On an Azure SQL Database (engine edition 5) the collected server_properties hardware columns are the /// HOST's: a 1-vCore serverless General Purpose database read 2 logical CPUs, 0 sockets, 32 cores per socket, a /// hyperthread ratio of 64 and 911.9 GB of physical memory. Nothing may present them as the database's; the service -/// objective and the vcore_count parsed from it describe the database. +/// objective and the vcore_count parsed from it describe the database. The memory_stats table is a different +/// source: its memory figures are the database's own and are pinned in . /// /// Pinned where the rule is applied: the get_server_properties payload (which the web Server Properties /// list reads through /api/read), the web list's own tiles, the FinOps Server Inventory row the grid binds, and -/// the FinOps utilization card's sentences. Every test has an edition-3 twin that keeps today's values. Lite.Tests -/// pins the same table for the other app, in the same words. +/// the words the FinOps utilization card takes from the shared rule. Every test has an edition-3 twin that keeps today's +/// values. Lite.Tests pins the same table for the other app, in the same words. /// public sealed class AzureSqlDatabaseHardwareTests { @@ -214,26 +215,15 @@ public void InventoryRow_OnEdition3_KeepsItsHardware_AndHasNoNote() // ── FinOps utilization card ── [Fact] - public void OverProvisionedSentence_OnAzureSqlDatabase_CitesNoPhysicalMemoryShare() - { - var onAzure = ServerHardwareScope.OverProvisionedExplanation(3.2m, 11, 0.4, azureSqlDatabase: true); - var onBox = ServerHardwareScope.OverProvisionedExplanation(3.2m, 11, 0.4, azureSqlDatabase: false); - - Assert.DoesNotContain("physical", onAzure, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("buffer pool", onAzure, StringComparison.OrdinalIgnoreCase); - Assert.Equal( - "CPU is lightly loaded (avg 3.2%, max 11%) and buffer pool uses only 0% of physical RAM. This server may have more resources than it needs.", - onBox); - } - - [Fact] - public void FinOpsUtilizationCard_AsksTheSharedRule_BeforeItShowsPhysicalMemory() + public void FinOpsUtilizationCard_AsksTheSharedRule_ForTheWordsAroundItsMemoryFigures() { var tab = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "FinOpsTab.Loaders.cs"); + var xaml = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "FinOpsTab.xaml"); var read = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Utilization.cs"); Assert.Contains("ServerHardwareScope.HardwareIsTheHosts(data.EngineEdition)", tab, StringComparison.Ordinal); - Assert.Contains("FinOpsPhysicalMemoryText.Text = azureSqlDb ? ServerHardwareScope.NotApplicable", tab, StringComparison.Ordinal); + Assert.Contains("FinOpsPhysicalMemoryCaption.Text = ServerHardwareScope.PhysicalMemoryCaption(data.EngineEdition);", tab, StringComparison.Ordinal); + Assert.Contains("x:Name=\"FinOpsPhysicalMemoryCaption\"", xaml, StringComparison.Ordinal); Assert.Contains("ServerHardwareScope.OverProvisionedExplanation(", tab, StringComparison.Ordinal); Assert.Contains("ServerHardwareScope.RightSizedExplanation(", tab, StringComparison.Ordinal); Assert.DoesNotContain("of physical RAM", tab, StringComparison.Ordinal); diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs index 45d7eb533..bb31cbdf5 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs @@ -19,14 +19,15 @@ namespace Darling.Tests; /// /// On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info describes the HOST: a 1-vCore serverless /// General Purpose database read 2 logical CPUs and 911.9 GB of physical memory. -/// pins that nothing SHOWS those values as the database's. These pins are the calculations that USED them: the attributed-CPU -/// denominator, the FinOps utilization card's CPU count, and the FinOps health score. +/// pins that nothing SHOWS those server_properties values as the database's. These pins are the calculations that USED +/// the host's CPU count: the attributed-CPU denominator and the FinOps utilization card's CPU count. The memory figures come +/// from a different table and are pinned in . /// /// The rule: on an Azure SQL Database each of those uses the database's own figure where one is collected (the -/// vcore_count parsed from the service objective) and is otherwise NOT APPLICABLE. A DTU-model objective has no vCore -/// count, so its CPU count is not applicable and nothing is computed from the host's. SQL Server (editions 1 to 4) and Managed -/// Instance (8) behave exactly as before, and every test has that twin. Lite.Tests pins the same table for the other app, in -/// the same words. +/// vcore_count parsed from the service objective) and is otherwise NOT APPLICABLE. A DTU-model objective or an elastic +/// pool has no vCore count, so its CPU count is not applicable and nothing is computed from the host's. SQL Server +/// (editions 1 to 4) and Managed Instance (8) behave exactly as before, and every test has that twin. Lite.Tests pins the +/// same table for the other app, in the same words. /// public sealed class AzureSqlDatabaseHostMathTests { @@ -64,7 +65,7 @@ public void Attribution_OnAzureSqlDatabase_WithNoVcores_IsNotApplicable_AndCompu Assert.Null(result.AttributedCpuRatio); Assert.Equal(CpuAttribution.CoreCountNotApplicableNote, result.Note); Assert.Contains("not applicable", result.Note, StringComparison.Ordinal); - Assert.Contains("DTU", result.Note, StringComparison.Ordinal); + Assert.Contains("a DTU-model objective or an elastic pool", result.Note, StringComparison.Ordinal); Assert.DoesNotContain("no server_properties snapshot", result.Note, StringComparison.Ordinal); } @@ -164,8 +165,9 @@ public void UtilizationRead_ResolvesTheCpuCountTheSameWayLiteDoes() // ── FinOps utilization card: the health score ── - /// CPU p95 of 7% scores 95 and 50% free storage scores 100. Buffer pool 40 GB of a 933,888 MB host is 4%, which - /// scores 60 when memory counts: 95 * 0.4 + 60 * 0.3 + 100 * 0.3 = 86, the score a 1-vCore database showed. + /// CPU p95 of 7% scores 95 and 50% free storage scores 100. Buffer pool 40 GB of 933,888 MB is 4%, which scores 60: + /// 95 * 0.4 + 60 * 0.3 + 100 * 0.3 = 86. The same arithmetic runs on every edition, an Azure SQL Database included, with the + /// figures memory_stats holds for it (see ). private static UtilizationEfficiencyRow Utilization(int engineEdition, int bufferPoolMb, int physicalMemoryMb) => new() { EngineEdition = engineEdition, @@ -175,40 +177,18 @@ public void UtilizationRead_ResolvesTheCpuCountTheSameWayLiteDoes() FreeSpacePct = 50m, }; - [Fact] - public void HealthScore_OnAzureSqlDatabase_LeavesMemoryOut_AndDoesNotMoveWithTheHostsMemory() - { - Assert.Equal(97, Utilization(5, 40_960, 933_888).ComputeHealthScore()); - Assert.Equal(97, Utilization(5, 40_960, 65_536).ComputeHealthScore()); - Assert.Equal(97, Utilization(5, 800_000, 933_888).ComputeHealthScore()); - Assert.Equal(97, Utilization(5, 0, 0).ComputeHealthScore()); - Assert.Equal(FinOpsHealthCalculator.Overall(95, null, 100), Utilization(5, 40_960, 933_888).ComputeHealthScore()); - } - [Theory] [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)] [InlineData(8)] - public void HealthScore_OnEveryOtherEdition_KeepsItsMemoryTerm(int engineEdition) + public void HealthScore_OnSqlServerAndManagedInstance_ScoresItsMemoryTerm(int engineEdition) { Assert.Equal(86, Utilization(engineEdition, 40_960, 933_888).ComputeHealthScore()); Assert.Equal(98, Utilization(engineEdition, 600_000, 933_888).ComputeHealthScore()); } - [Fact] - public void Overall_WithNoMemoryScore_WeighsCpuAndStorageOverTheirOwnSeventyPercent() - { - Assert.Equal(100, FinOpsHealthCalculator.Overall(100, null, 100)); - Assert.Equal(0, FinOpsHealthCalculator.Overall(0, null, 0)); - Assert.Equal(97, FinOpsHealthCalculator.Overall(95, null, 100)); - Assert.Equal(62, FinOpsHealthCalculator.Overall(80, null, 40)); - /* With a memory score, the long-standing arithmetic, byte for byte. */ - Assert.Equal(86, FinOpsHealthCalculator.Overall(95, 60, 100)); - Assert.Equal(62, FinOpsHealthCalculator.Overall(80, 60, 40)); - } - // ── the wiring, pinned at the source ── private static int CountOf(string text, string needle) @@ -227,7 +207,6 @@ public void FinOpsUtilizationCard_AsksTheSharedRule_ForTheCpuCountAndTheHealthSc Assert.Contains("FinOpsCpuCountText.Text = ServerHardwareScope.CpuCountText(data.EngineEdition, data.CpuCount);", tab, StringComparison.Ordinal); Assert.DoesNotContain("data.CpuCount.ToString(", tab, StringComparison.Ordinal); Assert.Contains("data.HealthScore = data.ComputeHealthScore();", tab, StringComparison.Ordinal); - Assert.Contains("FinOpsHealthScoreBorder.ToolTip = azureSqlDb ? ServerHardwareScope.HealthScoreWithoutMemoryNote : null;", tab, StringComparison.Ordinal); Assert.DoesNotContain("FinOpsHealthCalculator.MemoryScore(", tab, StringComparison.Ordinal); } } diff --git a/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs new file mode 100644 index 000000000..42ddf9e31 --- /dev/null +++ b/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs @@ -0,0 +1,255 @@ +/* + * 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.Text.RegularExpressions; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Viewer; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// Two collected tables hold a "physical memory" figure, and on an Azure SQL Database (engine edition 5) only one of them is +/// the host's. server_properties.physical_memory_mb comes from sys.dm_os_sys_info.physical_memory_kb and is the +/// HOST's (911.9 GB for a 1-vCore serverless General Purpose database). memory_stats.total_physical_memory_mb comes from +/// committed_target_kb there, which is the database's own memory limit (1,838 MB for that same database), and the +/// buffer pool and server-memory counters beside it are the database's too. +/// +/// So the FinOps utilization card's Physical Memory and Buffer Pool %, its verdict sentences and the health score's memory +/// term, which all read memory_stats, are shown and scored on an Azure SQL Database exactly as on SQL Server. What reads +/// server_properties (get_server_properties, the web Server Properties tiles, the Server Inventory hardware cells) +/// still hides the host's values. The figures here are the two tables' different values (1,838 MB and 933,836 MB), so a read that +/// takes the wrong table shows up as the wrong number. The Viewer's reads run against PostgreSQL, which this suite does not stand +/// up, so they are pinned as SQL text. Lite.Tests pins the same table for the other app, in the same words. +/// +public sealed class AzureSqlDatabaseMemoryScopeTests +{ + /// What memory_stats holds on the 1-vCore database: committed_target_kb / 1024. + private const int DatabaseMemoryLimitMb = 1_838; + + /// What server_properties holds for the same database: the host's physical memory. + private const long HostPhysicalMemoryMb = 933_836; + + private static readonly string[] s_hostKeys = + ["cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; + + /// Comments wrap, so a pin on their words reads them with every run of whitespace as one space. + private static string Flatten(string text) => Regex.Replace(text, @"\s+", " "); + + // ── the utilization read: memory_stats, not server_properties ── + + [Fact] + public void UtilizationRead_TakesTheMemoryFiguresFromMemoryStats_NeverFromServerProperties() + { + var sql = ViewerDataService.UtilizationEfficiencySql; + var start = sql.IndexOf("mem_latest AS (", StringComparison.Ordinal); + Assert.True(start > 0, "the mem_latest CTE is missing"); + var memLatest = sql[start..sql.IndexOf("),", start, StringComparison.Ordinal)]; + + Assert.Contains("FROM v_memory_stats", memLatest, StringComparison.Ordinal); + Assert.Contains("total_physical_memory_mb", memLatest, StringComparison.Ordinal); + Assert.Contains("buffer_pool_mb", memLatest, StringComparison.Ordinal); + Assert.Contains("m.total_physical_memory_mb,", sql, StringComparison.Ordinal); + Assert.Contains("m.buffer_pool_mb,", sql, StringComparison.Ordinal); + /* The only CTE that reads server_properties takes the CPU count and the edition, and no memory column at all. */ + Assert.False( + Regex.IsMatch(sql, @"(?CPU p95 of 7% scores 95 and 50% free storage scores 100. The buffer pool is 1,100 MB. Against the database's + /// 1,838 MB limit that is 60%, which scores 100: 95 * 0.4 + 100 * 0.3 + 100 * 0.3 = 98. Against the host's 933,836 MB it + /// would be 0.1%, which scores 60 and gives 86. Left out altogether it gives 97. + private static UtilizationEfficiencyRow Scored(int engineEdition, int physicalMemoryMb) => new() + { + EngineEdition = engineEdition, + P95CpuPct = 7m, + BufferPoolMb = 1_100, + PhysicalMemoryMb = physicalMemoryMb, + FreeSpacePct = 50m, + }; + + [Fact] + public void HealthScore_OnAzureSqlDatabase_CarriesTheMemoryTermScoredFromMemoryStats() + { + var score = Scored(5, DatabaseMemoryLimitMb).ComputeHealthScore(); + + Assert.Equal(98, score); + Assert.NotEqual(97, score); // the memory term was not left out + Assert.NotEqual(86, score); // and it was not scored against the host's memory + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(8)] + public void HealthScore_DoesNotDependOnTheEngineEdition(int engineEdition) + { + var baseline = Scored(3, DatabaseMemoryLimitMb).ComputeHealthScore(); + + Assert.Equal(98, baseline); + Assert.Equal(baseline, Scored(engineEdition, DatabaseMemoryLimitMb).ComputeHealthScore()); + /* And the score does move with the figure memory_stats holds, on an Azure SQL Database as anywhere. */ + Assert.Equal(86, Scored(engineEdition, (int)HostPhysicalMemoryMb).ComputeHealthScore()); + } + + // ── the verdict sentences cite the buffer pool's share again ── + + [Fact] + public void RightSizedSentence_OnAzureSqlDatabase_CitesTheBufferPoolShare_OfTheDatabasesMemoryLimit() + { + var onAzure = ServerHardwareScope.RightSizedExplanation(40m, 62m, 71.0, azureSqlDatabase: true); + var onBox = ServerHardwareScope.RightSizedExplanation(40m, 62m, 71.0, azureSqlDatabase: false); + + Assert.Equal( + "CPU is moderately loaded (avg 40.0%, p95 62.0%) and memory is well-utilized (buffer pool uses 71% of the database's memory limit). No action needed.", + onAzure); + Assert.DoesNotContain("physical RAM", onAzure, StringComparison.Ordinal); + Assert.Equal( + "CPU is moderately loaded (avg 40.0%, p95 62.0%) and memory is well-utilized (buffer pool uses 71% of physical RAM). No action needed.", + onBox); + } + + [Fact] + public void OverProvisionedSentence_OnAzureSqlDatabase_CitesTheBufferPoolShare_OfTheDatabasesMemoryLimit() + { + var onAzure = ServerHardwareScope.OverProvisionedExplanation(3.2m, 11, 40.0, azureSqlDatabase: true); + var onBox = ServerHardwareScope.OverProvisionedExplanation(3.2m, 11, 40.0, azureSqlDatabase: false); + + Assert.Equal( + "CPU is lightly loaded (avg 3.2%, max 11%) and buffer pool uses only 40% of the database's memory limit. This database may have more resources than it needs.", + onAzure); + Assert.DoesNotContain("physical RAM", onAzure, StringComparison.Ordinal); + Assert.Equal( + "CPU is lightly loaded (avg 3.2%, max 11%) and buffer pool uses only 40% of physical RAM. This server may have more resources than it needs.", + onBox); + } + + // ── what reads server_properties stays the host's, and stays hidden ── + + private static DarlingDataReader.ServerPropertiesReadRow StoredRow(int engineEdition) => new( + new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), engineEdition == 5 ? "SQL Azure" : "Enterprise Edition (64-bit)", + "12.0.2000.8", "RTM", null, engineEdition, 2, 64, HostPhysicalMemoryMb, 0, 32, false, false, null, + engineEdition == 5 ? "GP_S_Gen5_1" : null, null, null, engineEdition == 5 ? 1 : null); + + [Fact] + public void ServerPropertiesReads_OnAzureSqlDatabase_StayTheHostsAndNull_WhateverMemoryStatsHolds() + { + var stored = StoredRow(5); + /* The stored figure is the host's, which is why it is hidden: it is not memory_stats' 1,838. */ + Assert.Equal(HostPhysicalMemoryMb, stored.PhysicalMemoryMb); + + /* get_server_properties (the payload the web Server Properties tiles read through /api/read). */ + var json = JsonDocument.Parse(DarlingMcpDataTools.ServerPropertiesPayload("Srv", stored)).RootElement; + foreach (var key in s_hostKeys) + Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + + /* The FinOps Server Inventory row. */ + var inventory = new ServerPropertyRow + { + EngineEdition = stored.EngineEdition, CpuCount = stored.CpuCount, PhysicalMemoryMb = stored.PhysicalMemoryMb, + SocketCount = stored.SocketCount, CoresPerSocket = stored.CoresPerSocket, + }; + Assert.Null(inventory.CpuCount); + Assert.Null(inventory.PhysicalMemoryMb); + Assert.Null(inventory.SocketCount); + Assert.Null(inventory.CoresPerSocket); + } + + [Fact] + public void ServerPropertiesReads_OnSqlServer_KeepTheirHardware() + { + var json = JsonDocument.Parse(DarlingMcpDataTools.ServerPropertiesPayload("Srv", StoredRow(3))).RootElement; + + Assert.Equal(HostPhysicalMemoryMb, json.GetProperty("physical_memory_mb").GetInt64()); + Assert.Equal(2, json.GetProperty("cpu_count").GetInt32()); + } + + [Fact] + public void ServerPropertiesReads_TakeTheirHardwareFromServerProperties_NeverFromMemoryStats() + { + Assert.Contains("FROM server_properties", DarlingDataReader.LatestServerPropertiesSql, StringComparison.Ordinal); + Assert.DoesNotContain("memory_stats", DarlingDataReader.LatestServerPropertiesSql, StringComparison.Ordinal); + Assert.Contains("sp.physical_memory_mb", ViewerDataService.ServerInventorySql, StringComparison.Ordinal); + Assert.Contains("FROM server_properties", ViewerDataService.ServerInventorySql, StringComparison.Ordinal); + Assert.DoesNotContain("memory_stats", ViewerDataService.ServerInventorySql, StringComparison.Ordinal); + } + + // ── the card and the recommendations, pinned at the source ── + + [Fact] + public void FinOpsUtilizationCard_ShowsPhysicalMemoryAndTheBufferPoolShare_OnEveryEdition() + { + var tab = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "FinOpsTab.Loaders.cs"); + + Assert.Contains("FinOpsMemoryRatioText.Text = $\"{bpPct:N0}%\";", tab, StringComparison.Ordinal); + Assert.Contains("SetBar(FinOpsMemoryRatioBar, FinOpsMemRatioFilled, FinOpsMemRatioEmpty, bpPct);", tab, StringComparison.Ordinal); + Assert.Contains("FinOpsPhysicalMemoryText.Text = $\"{data.PhysicalMemoryMb:N0} MB\";", tab, StringComparison.Ordinal); + Assert.DoesNotContain("ServerHardwareScope.NotApplicable", tab, StringComparison.Ordinal); + /* The health score has its memory term everywhere, so there is no tooltip explaining an absent one. */ + Assert.DoesNotContain("HealthScoreWithoutMemoryNote", tab, StringComparison.Ordinal); + Assert.DoesNotContain("FinOpsHealthScoreBorder.ToolTip", tab, StringComparison.Ordinal); + Assert.Contains("data.HealthScore = data.ComputeHealthScore();", tab, StringComparison.Ordinal); + } + + [Theory] + [InlineData(1, "Physical: ")] + [InlineData(2, "Physical: ")] + [InlineData(3, "Physical: ")] + [InlineData(4, "Physical: ")] + [InlineData(5, "Memory limit: ")] + [InlineData(8, "Physical: ")] + [InlineData(null, "Physical: ")] + public void PhysicalMemoryCaption_NamesTheDatabasesLimit_OnAzureSqlDatabase_AndPhysicalMemoryEverywhereElse(int? engineEdition, string expected) + { + Assert.Equal(expected, ServerHardwareScope.PhysicalMemoryCaption(engineEdition)); + } + + [Fact] + public void FinOpsUtilizationCard_ExplainsTheBufferPoolShareForADatabase_InTheWordsBothAppsUse() + { + var xaml = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "FinOpsTab.xaml"); + + Assert.Contains("On an Azure SQL Database it is the share of the database's memory limit.", xaml, StringComparison.Ordinal); + } + + [Fact] + public void MemoryRecommendation_NeverDividesByServerPropertiesMemory_SoItCannotPrintTheHostsGigabytes() + { + /* "Memory over-provisioned (P95 SQL memory uses 0% of 911GB RAM)" is the only text either app builds in the form + "{percent} of {n}GB RAM". Its divisor is util.PhysicalMemoryMb, read from memory_stats through the utilization read, + and the rules file reads no physical-memory column of its own. */ + var rules = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Recommendations.cs"); + + Assert.Contains("of {util.PhysicalMemoryMb / 1024}GB RAM", rules, StringComparison.Ordinal); + Assert.False( + Regex.IsMatch(rules, @"physical_memory_mb"), + "the recommendation rules must not read a physical-memory column of their own"); + } + + [Fact] + public void MemoryAndVmRules_OnAzureSqlDatabase_SayWhyTheyStandDown_AndDoNotCallTheMemoryTheHosts() + { + var rules = Flatten(ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Recommendations.cs")); + + Assert.DoesNotContain("reports the HOST's memory", rules, StringComparison.Ordinal); + Assert.DoesNotContain("its memory figure is the host's", rules, StringComparison.Ordinal); + Assert.Contains("its memory comes with its service objective and cannot be resized on its own", rules, StringComparison.Ordinal); + Assert.Contains("its cores and memory come with its service objective", rules, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/ViewerFinOpsRecommendationsTests.cs b/Darling/Darling.Tests/ViewerFinOpsRecommendationsTests.cs index c9e7c6b01..2641574bc 100644 --- a/Darling/Darling.Tests/ViewerFinOpsRecommendationsTests.cs +++ b/Darling/Darling.Tests/ViewerFinOpsRecommendationsTests.cs @@ -475,7 +475,7 @@ public void CpuAndVmRightSizing_StandDownWithNoCpuSample() } [Fact] - public void MemoryAndVmRightSizing_StandDownOnAzureSqlDatabaseHostMemory() + public void MemoryAndVmRightSizing_StandDownOnAzureSqlDatabase() { var body = RightSizingRulesSource(); const string notAzureSqlDatabase = diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs index 967afc447..d917877ed 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs @@ -266,7 +266,7 @@ public sealed record ServerListRow( /// and the engine's own zone name beside it (V134, #3653 item 13, Q8) — null /// where the engine cannot say, which is every SQL Server before 2022 and a real, common value rather than /// a miss. is the vCore count the collector parses from an Azure SQL Database's - /// service objective (null off Azure SQL Database, and for a DTU-model objective that names no vCores) — what + /// service objective (null off Azure SQL Database, and for a DTU-model objective or an elastic pool, which name no vCores) — what /// describes the database there, where and its neighbours describe the HOST. public sealed record ServerPropertiesReadRow( DateTime CollectionTime, string Edition, string ProductVersion, string ProductLevel, string? ProductUpdateLevel, diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index 111f9d528..9788f9213 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -661,7 +661,7 @@ ratio is omitted rather than invented when a denominator piece is missing. The t var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; /* The core count is the server's own: on an Azure SQL Database the stored cpu_count is the HOST's, so this divides by its - vcore_count, or omits the ratio for a DTU objective that names no vCores (see CpuAttribution). */ + vcore_count, or omits the ratio for a DTU-model objective or an elastic pool (see CpuAttribution). */ var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuUs) / 1_000_000.0, attrStart, attrEnd, @@ -828,7 +828,7 @@ same concurrent independent reads. */ var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; /* The core count is the server's own: on an Azure SQL Database the stored cpu_count is the HOST's, so this divides by its - vcore_count, or omits the ratio for a DTU objective that names no vCores (see CpuAttribution). */ + vcore_count, or omits the ratio for a DTU-model objective or an elastic pool (see CpuAttribution). */ var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuUs) / 1_000_000.0, attrStart, attrEnd, diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs index 344bedf71..4f814f0cb 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs @@ -292,14 +292,15 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) ? (double)data.BufferPoolMb / data.PhysicalMemoryMb * 100.0 : 0; - /* An Azure SQL Database's physical memory is the HOST's, not the database's allocation, so neither the figure nor - the buffer pool's share of it is shown, and the health score below leaves its memory term out. The verdict reads none - of it: its inputs are the database's own CPU, its workspace-memory grants and its worker threads. */ + /* Physical memory and the buffer pool's share of it come from memory_stats, which on an Azure SQL Database is the + database's own (its memory limit, from committed_target_kb), not the host's RAM. So both are shown on every + edition, and only the caption and the verdict's wording change there. */ var azureSqlDb = ServerHardwareScope.HardwareIsTheHosts(data.EngineEdition); - FinOpsMemoryRatioText.Text = azureSqlDb ? ServerHardwareScope.NotApplicable : $"{bpPct:N0}%"; - SetBar(FinOpsMemoryRatioBar, FinOpsMemRatioFilled, FinOpsMemRatioEmpty, azureSqlDb ? 0 : bpPct); + FinOpsMemoryRatioText.Text = $"{bpPct:N0}%"; + SetBar(FinOpsMemoryRatioBar, FinOpsMemRatioFilled, FinOpsMemRatioEmpty, bpPct); - FinOpsPhysicalMemoryText.Text = azureSqlDb ? ServerHardwareScope.NotApplicable : $"{data.PhysicalMemoryMb:N0} MB"; + FinOpsPhysicalMemoryCaption.Text = ServerHardwareScope.PhysicalMemoryCaption(data.EngineEdition); + FinOpsPhysicalMemoryText.Text = $"{data.PhysicalMemoryMb:N0} MB"; FinOpsTargetMemoryText.Text = $"{data.TargetMemoryMb:N0} MB"; FinOpsTotalMemoryText.Text = $"{data.TotalMemoryMb:N0} MB"; FinOpsBufferPoolText.Text = $"{data.BufferPoolMb:N0} MB"; @@ -332,11 +333,9 @@ pressure or worker saturation would have been explained as a memory ratio that n FinOpsTotalCostCard.Visibility = Visibility.Collapsed; } - /* Health score. Its memory term is the buffer pool's share of physical memory, and on an Azure SQL Database that - memory is the HOST's: the term is left out (not scored as zero, not scored as a default), so CPU and storage carry - the score and the tooltip says so. */ + /* Health score: CPU, memory and storage on every edition. The memory term reads memory_stats, which on an Azure SQL + Database is the database's own. */ data.HealthScore = data.ComputeHealthScore(); - FinOpsHealthScoreBorder.ToolTip = azureSqlDb ? ServerHardwareScope.HealthScoreWithoutMemoryNote : null; FinOpsHealthScoreText.Text = $"Health: {data.HealthScore}"; FinOpsHealthScoreBorder.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(data.HealthScoreColor)); FinOpsHealthScoreBorder.Visibility = Visibility.Visible; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml index f35536041..31896f3cf 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml @@ -375,7 +375,7 @@ + ToolTip="Buffer Pool / Physical Memory — how much of physical RAM is used by the buffer pool. On an Azure SQL Database it is the share of the database's memory limit."/> @@ -388,7 +388,7 @@ - + diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs index 13c5e562d..b57d21fe3 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs @@ -466,9 +466,10 @@ public async Task> GetRecommendationsAsync(int serverId, try { var util = await GetUtilizationEfficiencyAsync(serverId, cancellationToken); - /* Azure SQL Database (engine_edition 5) reports the HOST's memory as physical_memory_mb (911.9 GB for a - 1-vCore database), so the ratio below would call every database over-provisioned. There is no - RAM to shrink on a database: skip it. Managed Instance (8) and SQL Server are unchanged. */ + /* No memory advice on an Azure SQL Database (engine_edition 5): its memory comes with its service objective + and cannot be resized on its own. util.PhysicalMemoryMb is the database's own memory limit there + (memory_stats, filled from committed_target_kb), not the host's, so the skip is not about a wrong + denominator: there is nothing to resize. Managed Instance (8) and SQL Server are unchanged. */ if (util != null && util.PhysicalMemoryMb > 8192 && await GetRecommendationEngineEditionAsync(serverId, cancellationToken) != CollectorEngineCapability.AzureSqlDatabaseEngineEdition) { @@ -613,7 +614,7 @@ public async Task> GetRecommendationsAsync(int serverId, try { var vmUtil = await GetUtilizationEfficiencyAsync(serverId, cancellationToken); - /* No VM to resize on Azure SQL Database (a service objective, and its memory figure is the host's), + /* No VM to resize on Azure SQL Database (its cores and memory come with its service objective), and no advice from a window with no CPU sample (its P95 of 0 is not a measurement). */ if (vmUtil != null && vmUtil.HasCpuSample && await GetRecommendationEngineEditionAsync(serverId, cancellationToken) != CollectorEngineCapability.AzureSqlDatabaseEngineEdition) diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs index c6661834d..4280f70de 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs @@ -56,8 +56,8 @@ LIMIT 1 ), /* cpu_count is the count the server itself has. On an Azure SQL Database (engine_edition 5) the stored cpu_count describes the HOST (a 1-vCore serverless database read 2), so there it is the vcore_count parsed from the service - objective, and NULL for an objective that names no vCores (a DTU model): never the host count. Every other edition - reads as it always did. The same CASE is in the Lite read. */ + objective, and NULL for an objective that names no vCores (a DTU-model objective or an elastic pool): never the + host count. Every other edition reads as it always did. The same CASE is in the Lite read. */ server_info AS ( SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, engine_edition FROM server_properties diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs index 69b411005..30da9f9fe 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs @@ -94,6 +94,11 @@ public sealed class UtilizationEfficiencyRow public long CpuSamples { get; set; } public int TotalMemoryMb { get; set; } public int TargetMemoryMb { get; set; } + + /// From memory_stats.total_physical_memory_mb. On SQL Server and Managed Instance that is the machine's physical + /// memory. On an Azure SQL Database ( 5) the collector fills it from committed_target_kb, + /// which is the database's own memory limit, not the host's RAM: the host's is server_properties.physical_memory_mb, + /// which this row never reads. So the card shows it, and the health score and the verdict use it, on every edition. public int PhysicalMemoryMb { get; set; } public int BufferPoolMb { get; set; } public decimal MemoryRatio { get; set; } @@ -117,12 +122,13 @@ public sealed class UtilizationEfficiencyRow /// The server's OWN CPU count, 0 when there is none. On an Azure SQL Database ( 5) the /// stored cpu_count is the HOST's, so this is the vcore_count parsed from the service objective and 0 for an - /// objective that names none (a DTU model), which the card shows as n/a. It is never the host's count. + /// objective that names none (a DTU-model objective or an elastic pool), which the card shows as n/a. It is never the host's + /// count. public int CpuCount { get; set; } /// The engine edition of the server these figures describe (SERVERPROPERTY('EngineEdition'), 0 when unread). - /// On an Azure SQL Database (5) is the HOST's, so the card does not show it, its verdict - /// sentence cites no share of it, and the health score has no memory term. The verdict itself reads none of it. + /// The card uses it to name the memory figure (Physical, or Memory limit on an Azure SQL Database) and to show a CPU count + /// that is not applicable as n/a. public int EngineEdition { get; set; } public string ProvisioningStatus { get; set; } = ""; @@ -144,17 +150,16 @@ public sealed class UtilizationEfficiencyRow public string HealthScoreColor => FinOpsHealthCalculator.ScoreColor(HealthScore); /// - /// The health score for these figures: CPU p95, the buffer pool's share of physical memory, and free storage. On an - /// Azure SQL Database ( 5) physical memory is the HOST's, so the memory term is left out, not - /// scored as zero and not scored as a default, and CPU and storage carry the whole score. Anywhere else the score is - /// what it always was. + /// The health score for these figures: CPU p95, the buffer pool's share of physical memory, and free storage. The memory + /// term reads and , which come from memory_stats. On an Azure + /// SQL Database those are the database's own (its memory limit, not the host's RAM), so the score is worked the same way on + /// every edition. /// public int ComputeHealthScore() { var bpRatio = PhysicalMemoryMb > 0 ? (decimal)BufferPoolMb / PhysicalMemoryMb : 0m; - int? memScore = ServerHardwareScope.HardwareIsTheHosts(EngineEdition) ? null : FinOpsHealthCalculator.MemoryScore(bpRatio); return FinOpsHealthCalculator.Overall( - FinOpsHealthCalculator.CpuScore(P95CpuPct), memScore, FinOpsHealthCalculator.StorageScore(FreeSpacePct)); + FinOpsHealthCalculator.CpuScore(P95CpuPct), FinOpsHealthCalculator.MemoryScore(bpRatio), FinOpsHealthCalculator.StorageScore(FreeSpacePct)); } } @@ -524,15 +529,8 @@ public static int StorageScore(decimal freeSpacePct) return (int)(freeSpacePct * 5); } - /// - /// The overall score: CPU 40%, memory 30%, storage 30%. A null means memory is not - /// applicable (an Azure SQL Database's physical memory is the host's), so the term is left out, not scored as zero and - /// not scored as a default: CPU and storage keep their 40:30 weights over their own 70%. - /// - public static int Overall(int cpu, int? memory, int storage) => - memory is int mem - ? (int)(cpu * 0.40 + mem * 0.30 + storage * 0.30) - : (cpu * 40 + storage * 30) / 70; /* integer weights, so no floating-point error can truncate 100 to 99 */ + public static int Overall(int cpu, int memory, int storage) => + (int)(cpu * 0.40 + memory * 0.30 + storage * 0.30); public static string ScoreColor(int score) => score switch { diff --git a/Lite.Tests/AzureSqlDatabaseHardwareTests.cs b/Lite.Tests/AzureSqlDatabaseHardwareTests.cs index 9343fc81f..b4f23fc71 100644 --- a/Lite.Tests/AzureSqlDatabaseHardwareTests.cs +++ b/Lite.Tests/AzureSqlDatabaseHardwareTests.cs @@ -24,12 +24,13 @@ namespace PerformanceMonitorLite.Tests; /// On an Azure SQL Database (engine edition 5) the collected server_properties hardware columns are the /// HOST's: a 1-vCore serverless General Purpose database read 2 logical CPUs, 0 sockets, 32 cores per socket, a /// hyperthread ratio of 64 and 911.9 GB of physical memory. Nothing may present them as the database's. What does -/// describe the database is the service objective and the vcore_count parsed from it. +/// describe the database is the service objective and the vcore_count parsed from it. The memory_stats table is +/// a different source: its memory figures are the database's own and are pinned in . /// /// The rule is pinned where it is applied: get_server_properties (the payload the web Server Properties -/// list reads too), the FinOps Server Inventory row the grid binds, and the FinOps utilization card's sentences. Every -/// test has an edition-3 twin that keeps today's values, so the rule cannot leak into the boxed engine. The -/// Darling.Tests twin pins the same table for the other app, in the same words. +/// list reads too), the FinOps Server Inventory row the grid binds, and the words the FinOps utilization card takes from +/// the shared rule. Every test has an edition-3 twin that keeps today's values, so the rule cannot leak into the boxed +/// engine. The Darling.Tests twin pins the same table for the other app, in the same words. /// public sealed class AzureSqlDatabaseHardwareTests : IClassFixture, IDisposable { @@ -206,34 +207,6 @@ public void InventoryRow_OnEdition3_KeepsItsHardware_AndHasNoNote() Assert.Null(row.HardwareUnavailableReason); } - // ── FinOps utilization card ── - - [Fact] - public void OverProvisionedSentence_OnAzureSqlDatabase_CitesNoPhysicalMemoryShare() - { - var onAzure = ServerHardwareScope.OverProvisionedExplanation(3.2m, 11, 0.4, azureSqlDatabase: true); - var onBox = ServerHardwareScope.OverProvisionedExplanation(3.2m, 11, 0.4, azureSqlDatabase: false); - - Assert.DoesNotContain("physical", onAzure, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("buffer pool", onAzure, StringComparison.OrdinalIgnoreCase); - Assert.Contains("lightly loaded", onAzure, StringComparison.Ordinal); - Assert.Equal( - "CPU is lightly loaded (avg 3.2%, max 11%) and buffer pool uses only 0% of physical RAM. This server may have more resources than it needs.", - onBox); - } - - [Fact] - public void RightSizedSentence_OnAzureSqlDatabase_CitesNoPhysicalMemoryShare_AndEdition3KeepsItsWords() - { - var onAzure = ServerHardwareScope.RightSizedExplanation(40m, 62m, 71.0, azureSqlDatabase: true); - var onBox = ServerHardwareScope.RightSizedExplanation(40m, 62m, 71.0, azureSqlDatabase: false); - - Assert.DoesNotContain("physical", onAzure, StringComparison.OrdinalIgnoreCase); - Assert.Equal( - "CPU is moderately loaded (avg 40.0%, p95 62.0%) and memory is well-utilized (buffer pool uses 71% of physical RAM). No action needed.", - onBox); - } - // ── the wiring, pinned at the source ── private static string ReadRepoFile(string relativePath, [CallerFilePath] string thisFile = "") @@ -248,13 +221,15 @@ private static string ReadRepoFile(string relativePath, [CallerFilePath] string } [Fact] - public void FinOpsUtilizationCard_AsksTheSharedRule_BeforeItShowsPhysicalMemory() + public void FinOpsUtilizationCard_AsksTheSharedRule_ForTheWordsAroundItsMemoryFigures() { var tab = ReadRepoFile("Lite/Controls/FinOpsTab.xaml.cs"); + var xaml = ReadRepoFile("Lite/Controls/FinOpsTab.xaml"); var read = ReadRepoFile("Lite/Services/LocalDataService.FinOps.Utilization.cs"); Assert.Contains("ServerHardwareScope.HardwareIsTheHosts(data.EngineEdition)", tab, StringComparison.Ordinal); - Assert.Contains("PhysicalMemoryText.Text = azureSqlDb ? ServerHardwareScope.NotApplicable", tab, StringComparison.Ordinal); + Assert.Contains("PhysicalMemoryCaption.Text = ServerHardwareScope.PhysicalMemoryCaption(data.EngineEdition);", tab, StringComparison.Ordinal); + Assert.Contains("x:Name=\"PhysicalMemoryCaption\"", xaml, StringComparison.Ordinal); Assert.Contains("ServerHardwareScope.OverProvisionedExplanation(", tab, StringComparison.Ordinal); Assert.Contains("ServerHardwareScope.RightSizedExplanation(", tab, StringComparison.Ordinal); Assert.DoesNotContain("of physical RAM", tab, StringComparison.Ordinal); diff --git a/Lite.Tests/AzureSqlDatabaseHostMathTests.cs b/Lite.Tests/AzureSqlDatabaseHostMathTests.cs index 089cb689b..9a2951daa 100644 --- a/Lite.Tests/AzureSqlDatabaseHostMathTests.cs +++ b/Lite.Tests/AzureSqlDatabaseHostMathTests.cs @@ -21,14 +21,15 @@ namespace PerformanceMonitorLite.Tests; /// /// On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info describes the HOST: a 1-vCore serverless /// General Purpose database read 2 logical CPUs and 911.9 GB of physical memory. -/// pins that nothing SHOWS those values as the database's. These pins are the calculations that USED them: the attributed-CPU -/// denominator, the FinOps utilization card's CPU count, and the FinOps health score. +/// pins that nothing SHOWS those server_properties values as the database's. These pins are the calculations that USED +/// the host's CPU count: the attributed-CPU denominator and the FinOps utilization card's CPU count. The memory figures come +/// from a different table and are pinned in . /// /// The rule: on an Azure SQL Database each of those uses the database's own figure where one is collected (the -/// vcore_count parsed from the service objective) and is otherwise NOT APPLICABLE. A DTU-model objective has no vCore -/// count, so its CPU count is not applicable and nothing is computed from the host's. SQL Server (editions 1 to 4) and Managed -/// Instance (8) behave exactly as before, and every test has that twin. The Darling.Tests twin pins the same table for the -/// other app, in the same words. +/// vcore_count parsed from the service objective) and is otherwise NOT APPLICABLE. A DTU-model objective or an elastic +/// pool has no vCore count, so its CPU count is not applicable and nothing is computed from the host's. SQL Server +/// (editions 1 to 4) and Managed Instance (8) behave exactly as before, and every test has that twin. The Darling.Tests twin +/// pins the same table for the other app, in the same words. /// public sealed class AzureSqlDatabaseHostMathTests : IClassFixture, IDisposable { @@ -78,7 +79,7 @@ public void Attribution_OnAzureSqlDatabase_WithNoVcores_IsNotApplicable_AndCompu Assert.Null(result.AttributedCpuRatio); Assert.Equal(CpuAttribution.CoreCountNotApplicableNote, result.Note); Assert.Contains("not applicable", result.Note, StringComparison.Ordinal); - Assert.Contains("DTU", result.Note, StringComparison.Ordinal); + Assert.Contains("a DTU-model objective or an elastic pool", result.Note, StringComparison.Ordinal); Assert.DoesNotContain("no server_properties snapshot", result.Note, StringComparison.Ordinal); } @@ -154,7 +155,7 @@ public void CpuCountText_OnEveryOtherEdition_IsTheCountAsItAlwaysWas(int? engine [Theory] [InlineData(5, 1, 1)] // Azure SQL Database, vCore objective: its vCores, not the host's 2 - [InlineData(5, null, 0)] // Azure SQL Database, DTU objective: no count, never the host's 2 + [InlineData(5, null, 0)] // Azure SQL Database, DTU-model objective or elastic pool: no count, never the host's 2 [InlineData(3, null, 2)] // SQL Server: the stored count [InlineData(8, null, 2)] // Managed Instance: the stored count public async Task UtilizationRead_ResolvesTheCpuCountThroughTheEdition(int engineEdition, int? vcoreCount, int expectedCpuCount) @@ -170,8 +171,9 @@ public async Task UtilizationRead_ResolvesTheCpuCountThroughTheEdition(int engin // ── FinOps utilization card: the health score ── - /// CPU p95 of 7% scores 95 and 50% free storage scores 100. Buffer pool 40 GB of a 933,888 MB host is 4%, which - /// scores 60 when memory counts: 95 * 0.4 + 60 * 0.3 + 100 * 0.3 = 86, the score a 1-vCore database showed. + /// CPU p95 of 7% scores 95 and 50% free storage scores 100. Buffer pool 40 GB of 933,888 MB is 4%, which scores 60: + /// 95 * 0.4 + 60 * 0.3 + 100 * 0.3 = 86. The same arithmetic runs on every edition, an Azure SQL Database included, with the + /// figures memory_stats holds for it (see ). private static UtilizationEfficiencyRow Utilization(int engineEdition, int bufferPoolMb, int physicalMemoryMb) => new() { EngineEdition = engineEdition, @@ -181,40 +183,18 @@ public async Task UtilizationRead_ResolvesTheCpuCountThroughTheEdition(int engin FreeSpacePct = 50m, }; - [Fact] - public void HealthScore_OnAzureSqlDatabase_LeavesMemoryOut_AndDoesNotMoveWithTheHostsMemory() - { - Assert.Equal(97, Utilization(5, 40_960, 933_888).ComputeHealthScore()); - Assert.Equal(97, Utilization(5, 40_960, 65_536).ComputeHealthScore()); - Assert.Equal(97, Utilization(5, 800_000, 933_888).ComputeHealthScore()); - Assert.Equal(97, Utilization(5, 0, 0).ComputeHealthScore()); - Assert.Equal(FinOpsHealthCalculator.Overall(95, null, 100), Utilization(5, 40_960, 933_888).ComputeHealthScore()); - } - [Theory] [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)] [InlineData(8)] - public void HealthScore_OnEveryOtherEdition_KeepsItsMemoryTerm(int engineEdition) + public void HealthScore_OnSqlServerAndManagedInstance_ScoresItsMemoryTerm(int engineEdition) { Assert.Equal(86, Utilization(engineEdition, 40_960, 933_888).ComputeHealthScore()); Assert.Equal(98, Utilization(engineEdition, 600_000, 933_888).ComputeHealthScore()); } - [Fact] - public void Overall_WithNoMemoryScore_WeighsCpuAndStorageOverTheirOwnSeventyPercent() - { - Assert.Equal(100, FinOpsHealthCalculator.Overall(100, null, 100)); - Assert.Equal(0, FinOpsHealthCalculator.Overall(0, null, 0)); - Assert.Equal(97, FinOpsHealthCalculator.Overall(95, null, 100)); - Assert.Equal(62, FinOpsHealthCalculator.Overall(80, null, 40)); - /* With a memory score, the long-standing arithmetic, byte for byte. */ - Assert.Equal(86, FinOpsHealthCalculator.Overall(95, 60, 100)); - Assert.Equal(62, FinOpsHealthCalculator.Overall(80, 60, 40)); - } - // ── the wiring, pinned at the source ── private static string ReadRepoFile(string relativePath, [CallerFilePath] string thisFile = "") @@ -245,7 +225,6 @@ public void FinOpsUtilizationCard_AsksTheSharedRule_ForTheCpuCountAndTheHealthSc Assert.Contains("CpuCountText.Text = ServerHardwareScope.CpuCountText(data.EngineEdition, data.CpuCount);", tab, StringComparison.Ordinal); Assert.DoesNotContain("data.CpuCount.ToString(", tab, StringComparison.Ordinal); Assert.Contains("data.HealthScore = data.ComputeHealthScore();", tab, StringComparison.Ordinal); - Assert.Contains("HealthScoreBorder.ToolTip = azureSqlDb ? ServerHardwareScope.HealthScoreWithoutMemoryNote : null;", tab, StringComparison.Ordinal); Assert.DoesNotContain("FinOpsHealthCalculator.MemoryScore(", tab, StringComparison.Ordinal); var edition = ServerHardwareScope.AzureSqlDatabaseEngineEdition; @@ -278,15 +257,25 @@ INSERT INTO server_properties await cmd.ExecuteNonQueryAsync(); } + /* memory_stats is not the host's table: on an Azure SQL Database its total_physical_memory_mb is the database's own + memory limit (committed_target_kb), 1,838 MB for a 1-vCore General Purpose database. */ using (var cmd = _seedConn.CreateCommand()) { cmd.CommandText = @" INSERT INTO memory_stats (collection_id, collection_time, server_id, server_name, total_physical_memory_mb, available_physical_memory_mb, target_server_memory_mb, total_server_memory_mb, buffer_pool_mb) -VALUES ($1, $2, $3, $4, 933888, 900000, 40000, 40000, 30000)"; +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"; void P(object? v) => cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); P(-487_003L); P(DateTime.UtcNow); P(ServerId); P("AzureHostMathSrv"); + if (engineEdition == 5) + { + P(1_838L); P(738L); P(1_800L); P(1_500L); P(1_100L); + } + else + { + P(933_888L); P(900_000L); P(40_000L); P(40_000L); P(30_000L); + } await cmd.ExecuteNonQueryAsync(); } } diff --git a/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs b/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs new file mode 100644 index 000000000..9b6a072ab --- /dev/null +++ b/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs @@ -0,0 +1,346 @@ +/* + * 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.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitor.Common; +using PerformanceMonitorLite.Mcp; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// Two collected tables hold a "physical memory" figure, and on an Azure SQL Database (engine edition 5) only one of them is +/// the host's. server_properties.physical_memory_mb comes from sys.dm_os_sys_info.physical_memory_kb and is the +/// HOST's (911.9 GB for a 1-vCore serverless General Purpose database). memory_stats.total_physical_memory_mb comes from +/// committed_target_kb there, which is the database's own memory limit (1,838 MB for that same database), and the +/// buffer pool and server-memory counters beside it are the database's too. +/// +/// So the FinOps utilization card's Physical Memory and Buffer Pool %, its verdict sentences and the health score's memory +/// term, which all read memory_stats, are shown and scored on an Azure SQL Database exactly as on SQL Server. What reads +/// server_properties (get_server_properties, the Server Inventory hardware cells) still hides the host's values. +/// Every test seeds BOTH tables with different values (1,838 MB and 933,836 MB), so a read that takes the wrong table shows up +/// as the wrong number. The Darling.Tests twin pins the same table for the other app, in the same words. +/// +public sealed class AzureSqlDatabaseMemoryScopeTests : IClassFixture, IDisposable +{ + private const int ServerId = -487_004; + + /// What memory_stats holds on the 1-vCore database: committed_target_kb / 1024. + private const int DatabaseMemoryLimitMb = 1_838; + + /// What server_properties holds for the same database: the host's physical memory. + private const long HostPhysicalMemoryMb = 933_836; + + private readonly SharedDuckDbFixture _fixture; + private DuckDBConnection? _seedConn; + + public AzureSqlDatabaseMemoryScopeTests(SharedDuckDbFixture fixture) + { + fixture.ResetData(); + _fixture = fixture; + } + + public void Dispose() => _seedConn?.Dispose(); + + // ── the utilization read: memory_stats, not server_properties ── + + [Theory] + [InlineData(5)] // Azure SQL Database + [InlineData(3)] // SQL Server + [InlineData(8)] // Managed Instance + public async Task UtilizationRead_TakesTheMemoryFiguresFromMemoryStats_OnEveryEdition(int engineEdition) + { + await SeedAsync(engineEdition); + + var row = await new LocalDataService(_fixture.DuckDb).GetUtilizationEfficiencyAsync(ServerId); + + Assert.NotNull(row); + Assert.Equal(engineEdition, row!.EngineEdition); + Assert.Equal(DatabaseMemoryLimitMb, row.PhysicalMemoryMb); + Assert.Equal(1_100, row.BufferPoolMb); + Assert.Equal(1_500, row.TotalMemoryMb); + Assert.Equal(1_800, row.TargetMemoryMb); + } + + // ── the health score keeps its memory term on an Azure SQL Database ── + + /// CPU p95 of 7% scores 95 and 50% free storage scores 100. The buffer pool is 1,100 MB. Against the database's + /// 1,838 MB limit that is 60%, which scores 100: 95 * 0.4 + 100 * 0.3 + 100 * 0.3 = 98. Against the host's 933,836 MB it + /// would be 0.1%, which scores 60 and gives 86. Left out altogether it gives 97. + private static UtilizationEfficiencyRow Scored(UtilizationEfficiencyRow row) + { + row.P95CpuPct = 7m; + row.FreeSpacePct = 50m; + return row; + } + + [Fact] + public async Task HealthScore_OnAzureSqlDatabase_CarriesTheMemoryTermScoredFromMemoryStats() + { + await SeedAsync(engineEdition: 5); + + var row = Scored((await new LocalDataService(_fixture.DuckDb).GetUtilizationEfficiencyAsync(ServerId))!); + + Assert.Equal(98, row.ComputeHealthScore()); + Assert.NotEqual(97, row.ComputeHealthScore()); // the memory term was not left out + Assert.NotEqual(86, row.ComputeHealthScore()); // and it was not scored against the host's memory + } + + [Theory] + [InlineData(3)] + [InlineData(8)] + public async Task HealthScore_OnSqlServerAndManagedInstance_IsTheSameScoreFromTheSameMemoryStats(int engineEdition) + { + await SeedAsync(engineEdition); + var service = new LocalDataService(_fixture.DuckDb); + + var row = Scored((await service.GetUtilizationEfficiencyAsync(ServerId))!); + + Assert.Equal(98, row.ComputeHealthScore()); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(8)] + public void HealthScore_DoesNotDependOnTheEngineEdition(int engineEdition) + { + UtilizationEfficiencyRow Row(int edition, int physicalMb) => Scored(new UtilizationEfficiencyRow + { + EngineEdition = edition, BufferPoolMb = 1_100, PhysicalMemoryMb = physicalMb, + }); + + var baseline = Row(3, DatabaseMemoryLimitMb).ComputeHealthScore(); + + Assert.Equal(98, baseline); + Assert.Equal(baseline, Row(engineEdition, DatabaseMemoryLimitMb).ComputeHealthScore()); + /* And the score does move with the figure memory_stats holds, on an Azure SQL Database as anywhere. */ + Assert.Equal(86, Row(engineEdition, (int)HostPhysicalMemoryMb).ComputeHealthScore()); + } + + // ── the verdict sentences cite the buffer pool's share again ── + + [Fact] + public void RightSizedSentence_OnAzureSqlDatabase_CitesTheBufferPoolShare_OfTheDatabasesMemoryLimit() + { + var onAzure = ServerHardwareScope.RightSizedExplanation(40m, 62m, 71.0, azureSqlDatabase: true); + var onBox = ServerHardwareScope.RightSizedExplanation(40m, 62m, 71.0, azureSqlDatabase: false); + + Assert.Equal( + "CPU is moderately loaded (avg 40.0%, p95 62.0%) and memory is well-utilized (buffer pool uses 71% of the database's memory limit). No action needed.", + onAzure); + Assert.DoesNotContain("physical RAM", onAzure, StringComparison.Ordinal); + Assert.Equal( + "CPU is moderately loaded (avg 40.0%, p95 62.0%) and memory is well-utilized (buffer pool uses 71% of physical RAM). No action needed.", + onBox); + } + + [Fact] + public void OverProvisionedSentence_OnAzureSqlDatabase_CitesTheBufferPoolShare_OfTheDatabasesMemoryLimit() + { + var onAzure = ServerHardwareScope.OverProvisionedExplanation(3.2m, 11, 40.0, azureSqlDatabase: true); + var onBox = ServerHardwareScope.OverProvisionedExplanation(3.2m, 11, 40.0, azureSqlDatabase: false); + + Assert.Equal( + "CPU is lightly loaded (avg 3.2%, max 11%) and buffer pool uses only 40% of the database's memory limit. This database may have more resources than it needs.", + onAzure); + Assert.DoesNotContain("physical RAM", onAzure, StringComparison.Ordinal); + Assert.Equal( + "CPU is lightly loaded (avg 3.2%, max 11%) and buffer pool uses only 40% of physical RAM. This server may have more resources than it needs.", + onBox); + } + + // ── what reads server_properties stays the host's, and stays hidden ── + + private static readonly string[] s_hostKeys = + ["cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; + + [Fact] + public async Task ServerPropertiesReads_OnAzureSqlDatabase_StayTheHostsAndNull_WhateverMemoryStatsHolds() + { + await SeedAsync(engineEdition: 5); + + var stored = await new LocalDataService(_fixture.DuckDb).GetLatestServerPropertiesAsync(ServerId); + + Assert.NotNull(stored); + /* The stored figure is the host's, which is why it is hidden: it is not memory_stats' 1,838. */ + Assert.Equal(HostPhysicalMemoryMb, stored!.PhysicalMemoryMb); + + /* get_server_properties (the payload the web Server Properties tiles read too). */ + var json = JsonDocument.Parse(McpServerInfoTools.ServerPropertiesPayload("Srv", stored)).RootElement; + foreach (var key in s_hostKeys) + Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + + /* The FinOps Server Inventory row. */ + var inventory = new ServerPropertyRow + { + EngineEdition = stored.EngineEdition, CpuCount = stored.CpuCount, PhysicalMemoryMb = stored.PhysicalMemoryMb, + SocketCount = stored.SocketCount, CoresPerSocket = stored.CoresPerSocket, + }; + Assert.Null(inventory.CpuCount); + Assert.Null(inventory.PhysicalMemoryMb); + Assert.Null(inventory.SocketCount); + Assert.Null(inventory.CoresPerSocket); + } + + [Fact] + public async Task ServerPropertiesReads_OnSqlServer_KeepTheirHardware() + { + await SeedAsync(engineEdition: 3); + + var stored = await new LocalDataService(_fixture.DuckDb).GetLatestServerPropertiesAsync(ServerId); + var json = JsonDocument.Parse(McpServerInfoTools.ServerPropertiesPayload("Srv", stored!)).RootElement; + + Assert.Equal(HostPhysicalMemoryMb, json.GetProperty("physical_memory_mb").GetInt64()); + Assert.Equal(2, json.GetProperty("cpu_count").GetInt32()); + } + + // ── the card and the recommendations, pinned at the source ── + + private static string ReadRepoFile(string relativePath, [CallerFilePath] string thisFile = "") + { + var dir = Path.GetDirectoryName(thisFile)!; + var parts = relativePath.Split('/'); + while (dir is not null && !File.Exists(Path.Combine(new[] { dir }.Concat(parts).ToArray()))) + dir = Path.GetDirectoryName(dir); + + Assert.NotNull(dir); + return File.ReadAllText(Path.Combine(new[] { dir! }.Concat(parts).ToArray())); + } + + /// Comments wrap, so a pin on their words reads them with every run of whitespace as one space. + private static string Flatten(string text) => Regex.Replace(text, @"\s+", " "); + + [Fact] + public void FinOpsUtilizationCard_ShowsPhysicalMemoryAndTheBufferPoolShare_OnEveryEdition() + { + var tab = ReadRepoFile("Lite/Controls/FinOpsTab.xaml.cs"); + + Assert.Contains("MemoryRatioText.Text = $\"{bpPct:N0}%\";", tab, StringComparison.Ordinal); + Assert.Contains("SetBar(MemoryRatioBar, MemRatioFilled, MemRatioEmpty, bpPct);", tab, StringComparison.Ordinal); + Assert.Contains("PhysicalMemoryText.Text = $\"{data.PhysicalMemoryMb:N0} MB\";", tab, StringComparison.Ordinal); + Assert.DoesNotContain("ServerHardwareScope.NotApplicable", tab, StringComparison.Ordinal); + /* The health score has its memory term everywhere, so there is no tooltip explaining an absent one. */ + Assert.DoesNotContain("HealthScoreWithoutMemoryNote", tab, StringComparison.Ordinal); + Assert.DoesNotContain("HealthScoreBorder.ToolTip", tab, StringComparison.Ordinal); + Assert.Contains("data.HealthScore = data.ComputeHealthScore();", tab, StringComparison.Ordinal); + } + + [Theory] + [InlineData(1, "Physical: ")] + [InlineData(2, "Physical: ")] + [InlineData(3, "Physical: ")] + [InlineData(4, "Physical: ")] + [InlineData(5, "Memory limit: ")] + [InlineData(8, "Physical: ")] + [InlineData(null, "Physical: ")] + public void PhysicalMemoryCaption_NamesTheDatabasesLimit_OnAzureSqlDatabase_AndPhysicalMemoryEverywhereElse(int? engineEdition, string expected) + { + Assert.Equal(expected, ServerHardwareScope.PhysicalMemoryCaption(engineEdition)); + } + + [Fact] + public void FinOpsUtilizationCard_ExplainsTheBufferPoolShareForADatabase_InTheWordsBothAppsUse() + { + var xaml = ReadRepoFile("Lite/Controls/FinOpsTab.xaml"); + + Assert.Contains("On an Azure SQL Database it is the share of the database's memory limit.", xaml, StringComparison.Ordinal); + } + + [Fact] + public void UtilizationRead_DoesNotReadServerPropertiesForMemory() + { + var read = ReadRepoFile("Lite/Services/LocalDataService.FinOps.Utilization.cs"); + var start = read.IndexOf("mem_latest AS (", StringComparison.Ordinal); + Assert.True(start > 0, "the mem_latest CTE is missing"); + var memLatest = read[start..read.IndexOf("),", start, StringComparison.Ordinal)]; + + Assert.Contains("FROM v_memory_stats", memLatest, StringComparison.Ordinal); + Assert.Contains("total_physical_memory_mb", memLatest, StringComparison.Ordinal); + Assert.DoesNotContain("server_properties", memLatest, StringComparison.Ordinal); + /* The one CTE that reads server_properties takes the CPU count and the edition, and nothing about memory. */ + var serverInfo = read[read.IndexOf("server_info AS (", StringComparison.Ordinal)..read.IndexOf("grants AS (", StringComparison.Ordinal)]; + Assert.DoesNotContain("physical_memory", serverInfo, StringComparison.Ordinal); + } + + [Fact] + public void MemoryRecommendation_NeverDividesByServerPropertiesMemory_SoItCannotPrintTheHostsGigabytes() + { + /* "Memory over-provisioned (P95 SQL memory uses 0% of 911GB RAM)" is the only text either app builds in the form + "{percent} of {n}GB RAM". Its divisor is util.PhysicalMemoryMb, read from memory_stats through the utilization read, + and the rules file reads no physical-memory column of its own. */ + var rules = ReadRepoFile("Lite/Services/LocalDataService.FinOps.Recommendations.cs"); + + Assert.Contains("of {util.PhysicalMemoryMb / 1024}GB RAM", rules, StringComparison.Ordinal); + Assert.DoesNotContain("physical_memory_mb", rules, StringComparison.Ordinal); + Assert.DoesNotContain("v_server_properties", rules, StringComparison.Ordinal); + } + + [Fact] + public void MemoryAndVmRules_OnAzureSqlDatabase_SayWhyTheyStandDown_AndDoNotCallTheMemoryTheHosts() + { + var rules = Flatten(ReadRepoFile("Lite/Services/LocalDataService.FinOps.Recommendations.cs")); + + Assert.DoesNotContain("reports the HOST's memory", rules, StringComparison.Ordinal); + Assert.DoesNotContain("its memory figure is the host's", rules, StringComparison.Ordinal); + Assert.Contains("its memory comes with its service objective and cannot be resized on its own", rules, StringComparison.Ordinal); + Assert.Contains("its cores and memory come with its service objective", rules, StringComparison.Ordinal); + } + + // ── seeding ── + + private async Task SeedAsync(int engineEdition) + { + using var readLock = _fixture.DuckDb.AcquireReadLock(); + if (_seedConn is null) + { + _seedConn = _fixture.DuckDb.CreateConnection(); + await _seedConn.OpenAsync(); + } + + /* The host's hardware, as sys.dm_os_sys_info reports it to a 1-vCore serverless General Purpose database. */ + using (var cmd = _seedConn.CreateCommand()) + { + cmd.CommandText = @" +INSERT INTO server_properties + (collection_id, collection_time, server_id, server_name, edition, product_version, product_level, engine_edition, + cpu_count, hyperthread_ratio, physical_memory_mb, socket_count, cores_per_socket, service_objective, vcore_count) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)"; + void P(object? v) => cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); + P(-487_004L); P(DateTime.UtcNow); P(ServerId); P("AzureMemoryScopeSrv"); P(engineEdition == 5 ? "SQL Azure" : "Enterprise Edition (64-bit)"); + P("12.0.2000.8"); P("RTM"); P(engineEdition); P(2); P(64); P(HostPhysicalMemoryMb); P(0); P(32); + P(engineEdition == 5 ? "GP_S_Gen5_1" : DBNull.Value); P(engineEdition == 5 ? 1 : DBNull.Value); + await cmd.ExecuteNonQueryAsync(); + } + + /* What memory_stats holds for the same server: on an Azure SQL Database the database's own limit and counters. */ + using (var cmd = _seedConn.CreateCommand()) + { + cmd.CommandText = @" +INSERT INTO memory_stats + (collection_id, collection_time, server_id, server_name, total_physical_memory_mb, available_physical_memory_mb, + target_server_memory_mb, total_server_memory_mb, buffer_pool_mb) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"; + void P(object? v) => cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); + P(-487_005L); P(DateTime.UtcNow); P(ServerId); P("AzureMemoryScopeSrv"); + P(DatabaseMemoryLimitMb); P(738); P(1_800); P(1_500); P(1_100); + await cmd.ExecuteNonQueryAsync(); + } + } +} diff --git a/Lite.Tests/FinOpsTests.cs b/Lite.Tests/FinOpsTests.cs index 9f02ea82a..e2c6b9920 100644 --- a/Lite.Tests/FinOpsTests.cs +++ b/Lite.Tests/FinOpsTests.cs @@ -72,15 +72,17 @@ public async Task NoCpuSamples_CpuAndVmRightSizingAdviseNothing() } [Fact] - public async Task AzureSqlDatabaseHostMemory_MemoryAndVmRightSizingAdviseNothing() + public async Task AzureSqlDatabase_MemoryAndVmRightSizingAdviseNothing_BecauseItsMemoryComesWithItsServiceObjective() { - // Edition 5 reports the HOST's 256 GB as physical memory and the database uses a sliver of it. Its service - // objective names 32 vCores, which are the database's own CPU count. + // The database uses 40,960 MB of its own 167,117 MB memory limit, a share that advises on any other edition. Its + // service objective names 32 vCores, which are the database's own CPU count. Its memory cannot be resized on its own, + // so the memory and VM rules have nothing to recommend, and no text can carry the host's 911 GB from server_properties. var recs = await RunRecommendationsAsync(s => s.SeedRightSizingScenarioAsync(engineEdition: 5, withCpuSamples: true, vcoreCount: 32)); - PrintRecommendations("AZURE SQL DATABASE HOST MEMORY", recs); + PrintRecommendations("AZURE SQL DATABASE MEMORY AND VM RULES", recs); Assert.DoesNotContain(recs, r => r.Finding.StartsWith("Memory over-provisioned", StringComparison.Ordinal)); Assert.DoesNotContain(recs, r => r.Category == "Hardware"); + Assert.DoesNotContain(recs, r => r.Finding.Contains("911GB", StringComparison.Ordinal)); // The CPU rule is not one of the two that stand down on a database: it reads the vCores the objective names. Assert.Contains(recs, r => r.Finding.StartsWith("CPU over-provisioned (32 cores", StringComparison.Ordinal)); } @@ -88,7 +90,7 @@ public async Task AzureSqlDatabaseHostMemory_MemoryAndVmRightSizingAdviseNothing [Fact] public async Task AzureSqlDatabaseWithNoVcores_CpuRightSizingAdvisesNothing_BecauseTheHostsCpuCountIsNotTheDatabases() { - // A DTU objective names no vCores. The stored cpu_count is the HOST's 32, so the CPU rule has no count to work from. + // A DTU-model objective names no vCores. The stored cpu_count is the HOST's 32, so the CPU rule has no count to work from. var recs = await RunRecommendationsAsync(s => s.SeedRightSizingScenarioAsync(engineEdition: 5, withCpuSamples: true)); PrintRecommendations("AZURE SQL DATABASE, NO VCORES", recs); diff --git a/Lite.Tests/TestDataSeeder.cs b/Lite.Tests/TestDataSeeder.cs index 859fca0ba..9065e5690 100644 --- a/Lite.Tests/TestDataSeeder.cs +++ b/Lite.Tests/TestDataSeeder.cs @@ -2140,10 +2140,14 @@ await SeedServerPropertiesAsync(cpuCount: 32, htRatio: 2, physicalMemMb: 262_144 /// /// A 32-core, 256 GB server whose CPU and memory both read as over-provisioned, on the given engine edition, /// with or without CPU samples. The right-sizing rules stand down for a server with no CPU sample (its P95 of 0 - /// is not a measurement), and the memory and VM rules stand down on Azure SQL Database (edition 5), whose - /// physical_memory_mb is the HOST's memory and not the database's. An Azure SQL Database also stores the HOST's 32 - /// CPUs as cpu_count: pass to give it the vCore count its service objective names, or - /// leave it null for a DTU objective, which has no CPU count of its own. + /// is not a measurement), and the memory and VM rules stand down on Azure SQL Database (edition 5), whose memory comes + /// with its service objective and cannot be resized on its own. + /// + /// The two tables differ on edition 5. server_properties holds the HOST's 32 CPUs and 933,836 MB. memory_stats holds + /// the database's own memory limit and counters: 167,117 MB (about 163 GB, what a 32-vCore Gen5 database is given) with the + /// same 40,960 MB buffer pool. Pass to give it the vCore count its service objective names, or + /// leave it null for a DTU-model objective or an elastic pool, which has no CPU count of its own. Every other edition + /// has 256 GB in both tables. /// public async Task SeedRightSizingScenarioAsync(int engineEdition, bool withCpuSamples, int? vcoreCount = null) { @@ -2155,9 +2159,11 @@ public async Task SeedRightSizingScenarioAsync(int engineEdition, bool withCpuSa await SeedCpuUtilizationAsync(8, 2); } - await SeedMemoryStatsAsync(totalPhysicalMb: 262_144, bufferPoolMb: 40_960, targetMb: 245_760); - await SeedServerPropertiesAsync(cpuCount: 32, htRatio: 2, physicalMemMb: 262_144, - edition: engineEdition == 5 ? "SQL Azure" : "Enterprise Edition", engineEdition: engineEdition, + var azureSqlDatabase = engineEdition == 5; + await SeedMemoryStatsAsync( + totalPhysicalMb: azureSqlDatabase ? 167_117 : 262_144, bufferPoolMb: 40_960, targetMb: azureSqlDatabase ? 163_840 : 245_760); + await SeedServerPropertiesAsync(cpuCount: 32, htRatio: 2, physicalMemMb: azureSqlDatabase ? 933_836 : 262_144, + edition: azureSqlDatabase ? "SQL Azure" : "Enterprise Edition", engineEdition: engineEdition, serviceObjective: vcoreCount.HasValue ? $"GP_Gen5_{vcoreCount}" : null, vcoreCount: vcoreCount); await SeedFileSizeAsync(totalDataSizeMb: 51_200); } diff --git a/Lite/Controls/FinOpsTab.xaml b/Lite/Controls/FinOpsTab.xaml index 2a94b44e0..876030eda 100644 --- a/Lite/Controls/FinOpsTab.xaml +++ b/Lite/Controls/FinOpsTab.xaml @@ -345,7 +345,7 @@ + ToolTip="Buffer Pool / Physical Memory — how much of physical RAM is used by the buffer pool. On an Azure SQL Database it is the share of the database's memory limit."/> @@ -369,7 +369,7 @@ - + diff --git a/Lite/Controls/FinOpsTab.xaml.cs b/Lite/Controls/FinOpsTab.xaml.cs index b5fe28e40..92c8a7f00 100644 --- a/Lite/Controls/FinOpsTab.xaml.cs +++ b/Lite/Controls/FinOpsTab.xaml.cs @@ -422,14 +422,15 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) ? (double)data.BufferPoolMb / data.PhysicalMemoryMb * 100.0 : 0; - /* An Azure SQL Database's physical memory is the HOST's, not the database's allocation, so neither the figure nor - the buffer pool's share of it is shown, and the health score below leaves its memory term out. The verdict reads none - of it: its inputs are the database's own CPU, its workspace-memory grants and its worker threads. */ + /* Physical memory and the buffer pool's share of it come from memory_stats, which on an Azure SQL Database is the + database's own (its memory limit, from committed_target_kb), not the host's RAM. So both are shown on every + edition, and only the caption and the verdict's wording change there. */ var azureSqlDb = ServerHardwareScope.HardwareIsTheHosts(data.EngineEdition); - MemoryRatioText.Text = azureSqlDb ? ServerHardwareScope.NotApplicable : $"{bpPct:N0}%"; - SetBar(MemoryRatioBar, MemRatioFilled, MemRatioEmpty, azureSqlDb ? 0 : bpPct); + MemoryRatioText.Text = $"{bpPct:N0}%"; + SetBar(MemoryRatioBar, MemRatioFilled, MemRatioEmpty, bpPct); - PhysicalMemoryText.Text = azureSqlDb ? ServerHardwareScope.NotApplicable : $"{data.PhysicalMemoryMb:N0} MB"; + PhysicalMemoryCaption.Text = ServerHardwareScope.PhysicalMemoryCaption(data.EngineEdition); + PhysicalMemoryText.Text = $"{data.PhysicalMemoryMb:N0} MB"; TargetMemoryText.Text = $"{data.TargetMemoryMb:N0} MB"; TotalMemoryText.Text = $"{data.TotalMemoryMb:N0} MB"; BufferPoolText.Text = $"{data.BufferPoolMb:N0} MB"; @@ -464,11 +465,9 @@ pressure or worker saturation would have been explained as a memory ratio that n } StorageCostCard.Visibility = Visibility.Collapsed; - /* Health score. Its memory term is the buffer pool's share of physical memory, and on an Azure SQL Database that - memory is the HOST's: the term is left out (not scored as zero, not scored as a default), so CPU and storage carry - the score and the tooltip says so. */ + /* Health score: CPU, memory and storage on every edition. The memory term reads memory_stats, which on an Azure SQL + Database is the database's own. */ data.HealthScore = data.ComputeHealthScore(); - HealthScoreBorder.ToolTip = azureSqlDb ? ServerHardwareScope.HealthScoreWithoutMemoryNote : null; HealthScoreText.Text = $"Health: {data.HealthScore}"; HealthScoreBorder.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(data.HealthScoreColor)); HealthScoreBorder.Visibility = Visibility.Visible; diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index 72e57d774..6e02f50c7 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -93,7 +93,7 @@ ratio is omitted rather than invented when a denominator piece is missing. One n var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; /* The core count is the server's own: on an Azure SQL Database the stored cpu_count is the HOST's, so this divides by its - vcore_count, or omits the ratio for a DTU objective that names no vCores (see CpuAttribution). */ + vcore_count, or omits the ratio for a DTU-model objective or an elastic pool (see CpuAttribution). */ var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuMs) / 1000.0, requestedStart, nowUtc, @@ -222,7 +222,7 @@ public static async Task GetTopProceduresByCpu( var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; /* The core count is the server's own: on an Azure SQL Database the stored cpu_count is the HOST's, so this divides by its - vcore_count, or omits the ratio for a DTU objective that names no vCores (see CpuAttribution). */ + vcore_count, or omits the ratio for a DTU-model objective or an elastic pool (see CpuAttribution). */ var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuMs) / 1000.0, requestedStart, nowUtc, diff --git a/Lite/Services/LocalDataService.FinOps.Recommendations.cs b/Lite/Services/LocalDataService.FinOps.Recommendations.cs index d01fdc9b7..6cde84b17 100644 --- a/Lite/Services/LocalDataService.FinOps.Recommendations.cs +++ b/Lite/Services/LocalDataService.FinOps.Recommendations.cs @@ -321,9 +321,10 @@ snapshot of buffer_pool_mb (data cache only). The earlier version could fire right after a service restart or on servers where plan cache / workspace memory dominates, falsely showing "buffer pool 0%". */ var util = await GetUtilizationEfficiencyAsync(serverId); - /* Azure SQL Database (engine_edition 5) reports the HOST's memory as physical_memory_mb (911.9 GB for a - 1-vCore database), so the ratio below would call every database over-provisioned. There is no - RAM to shrink on a database: skip it. Managed Instance (8) and SQL Server are unchanged. */ + /* No memory advice on an Azure SQL Database (engine_edition 5): its memory comes with its service objective + and cannot be resized on its own. util.PhysicalMemoryMb is the database's own memory limit there + (memory_stats, filled from committed_target_kb), not the host's, so the skip is not about a wrong + denominator: there is nothing to resize. Managed Instance (8) and SQL Server are unchanged. */ if (util != null && util.PhysicalMemoryMb > 8192 && await GetSqlEngineEditionAsync(serverId) != CollectorEngineCapability.AzureSqlDatabaseEngineEdition) { @@ -598,7 +599,7 @@ ORDER BY times_ran_long DESC try { var vmUtil = await GetUtilizationEfficiencyAsync(serverId); - /* No VM to resize on Azure SQL Database (a service objective, and its memory figure is the host's), + /* No VM to resize on Azure SQL Database (its cores and memory come with its service objective), and no advice from a window with no CPU sample (its P95 of 0 is not a measurement). */ if (vmUtil != null && vmUtil.HasCpuSample && await GetSqlEngineEditionAsync(serverId) != CollectorEngineCapability.AzureSqlDatabaseEngineEdition) diff --git a/Lite/Services/LocalDataService.FinOps.Utilization.cs b/Lite/Services/LocalDataService.FinOps.Utilization.cs index 0d337b567..6bfc52cbb 100644 --- a/Lite/Services/LocalDataService.FinOps.Utilization.cs +++ b/Lite/Services/LocalDataService.FinOps.Utilization.cs @@ -54,8 +54,8 @@ LIMIT 1 ), /* cpu_count is the count the server itself has. On an Azure SQL Database (engine_edition 5) the stored cpu_count describes the HOST (a 1-vCore serverless database read 2), so there it is the vcore_count parsed from the service - objective, and NULL for an objective that names no vCores (a DTU model): never the host count. Every other edition - reads as it always did. The same CASE is in the Darling read. */ + objective, and NULL for an objective that names no vCores (a DTU-model objective or an elastic pool): never the + host count. Every other edition reads as it always did. The same CASE is in the Darling read. */ server_info AS ( SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, engine_edition FROM v_server_properties diff --git a/Lite/Services/LocalDataService.FinOps.cs b/Lite/Services/LocalDataService.FinOps.cs index 7b668ec4e..5b953a5fe 100644 --- a/Lite/Services/LocalDataService.FinOps.cs +++ b/Lite/Services/LocalDataService.FinOps.cs @@ -77,6 +77,11 @@ public class UtilizationEfficiencyRow public long CpuSamples { get; set; } public int TotalMemoryMb { get; set; } public int TargetMemoryMb { get; set; } + + /// From memory_stats.total_physical_memory_mb. On SQL Server and Managed Instance that is the machine's physical + /// memory. On an Azure SQL Database ( 5) the collector fills it from committed_target_kb, + /// which is the database's own memory limit, not the host's RAM: the host's is server_properties.physical_memory_mb, + /// which this row never reads. So the card shows it, and the health score and the verdict use it, on every edition. public int PhysicalMemoryMb { get; set; } public int BufferPoolMb { get; set; } public decimal MemoryRatio { get; set; } @@ -100,12 +105,13 @@ public class UtilizationEfficiencyRow /// The server's OWN CPU count, 0 when there is none. On an Azure SQL Database ( 5) the /// stored cpu_count is the HOST's, so this is the vcore_count parsed from the service objective and 0 for an - /// objective that names none (a DTU model), which the card shows as n/a. It is never the host's count. + /// objective that names none (a DTU-model objective or an elastic pool), which the card shows as n/a. It is never the host's + /// count. public int CpuCount { get; set; } /// The engine edition of the server these figures describe (SERVERPROPERTY('EngineEdition'), 0 when unread). - /// On an Azure SQL Database (5) is the HOST's, so the card does not show it, its verdict - /// sentence cites no share of it, and the health score has no memory term. The verdict itself reads none of it. + /// The card uses it to name the memory figure (Physical, or Memory limit on an Azure SQL Database) and to show a CPU count + /// that is not applicable as n/a. public int EngineEdition { get; set; } public string ProvisioningStatus { get; set; } = ""; @@ -127,17 +133,16 @@ public class UtilizationEfficiencyRow public string HealthScoreColor => FinOpsHealthCalculator.ScoreColor(HealthScore); /// - /// The health score for these figures: CPU p95, the buffer pool's share of physical memory, and free storage. On an - /// Azure SQL Database ( 5) physical memory is the HOST's, so the memory term is left out, not - /// scored as zero and not scored as a default, and CPU and storage carry the whole score. Anywhere else the score is - /// what it always was. + /// The health score for these figures: CPU p95, the buffer pool's share of physical memory, and free storage. The memory + /// term reads and , which come from memory_stats. On an Azure + /// SQL Database those are the database's own (its memory limit, not the host's RAM), so the score is worked the same way on + /// every edition. /// public int ComputeHealthScore() { var bpRatio = PhysicalMemoryMb > 0 ? (decimal)BufferPoolMb / PhysicalMemoryMb : 0m; - int? memScore = ServerHardwareScope.HardwareIsTheHosts(EngineEdition) ? null : FinOpsHealthCalculator.MemoryScore(bpRatio); return FinOpsHealthCalculator.Overall( - FinOpsHealthCalculator.CpuScore(P95CpuPct), memScore, FinOpsHealthCalculator.StorageScore(FreeSpacePct)); + FinOpsHealthCalculator.CpuScore(P95CpuPct), FinOpsHealthCalculator.MemoryScore(bpRatio), FinOpsHealthCalculator.StorageScore(FreeSpacePct)); } } @@ -420,15 +425,8 @@ public static int StorageScore(decimal freeSpacePct) return (int)(freeSpacePct * 5); } - /// - /// The overall score: CPU 40%, memory 30%, storage 30%. A null means memory is not - /// applicable (an Azure SQL Database's physical memory is the host's), so the term is left out, not scored as zero and - /// not scored as a default: CPU and storage keep their 40:30 weights over their own 70%. - /// - public static int Overall(int cpu, int? memory, int storage) => - memory is int mem - ? (int)(cpu * 0.40 + mem * 0.30 + storage * 0.30) - : (cpu * 40 + storage * 30) / 70; /* integer weights, so no floating-point error can truncate 100 to 99 */ + public static int Overall(int cpu, int memory, int storage) => + (int)(cpu * 0.40 + memory * 0.30 + storage * 0.30); public static string ScoreColor(int score) => score switch { diff --git a/Lite/Services/LocalDataService.ServerInfo.cs b/Lite/Services/LocalDataService.ServerInfo.cs index cb5b2d21e..3a47a295d 100644 --- a/Lite/Services/LocalDataService.ServerInfo.cs +++ b/Lite/Services/LocalDataService.ServerInfo.cs @@ -61,7 +61,7 @@ ORDER BY collection_time DESC UtcOffsetMinutes = reader.IsDBNull(15) ? null : reader.GetInt32(15), TimeZoneId = reader.IsDBNull(16) ? null : reader.GetString(16), /* The vCore count parsed from an Azure SQL Database's service objective; null elsewhere and for a - DTU-model objective. It is what describes the database where cpu_count describes the host. */ + DTU-model objective or an elastic pool. It is what describes the database where cpu_count describes the host. */ VcoreCount = reader.IsDBNull(17) ? null : reader.GetInt32(17) }; } @@ -247,7 +247,7 @@ public class ServerPropertiesRow public string? TimeZoneId { get; set; } /// The vCore count parsed from an Azure SQL Database's service objective (null off Azure SQL Database, - /// and for a DTU-model objective that names no vCores). + /// and for a DTU-model objective or an elastic pool, which name no vCores). public int? VcoreCount { get; set; } } diff --git a/PerformanceMonitor.Common/CpuAttribution.cs b/PerformanceMonitor.Common/CpuAttribution.cs index cf0aef76f..a916a3977 100644 --- a/PerformanceMonitor.Common/CpuAttribution.cs +++ b/PerformanceMonitor.Common/CpuAttribution.cs @@ -27,8 +27,8 @@ namespace PerformanceMonitor.Common /// /// The core count is the server's OWN. On an Azure SQL Database sys.dm_os_sys_info reports the /// HOST's CPUs (a 1-vCore serverless database read 2), and a denominator built from them is wrong by the ratio of - /// the two. There the count is the vcore_count parsed from the service objective, and a DTU-model objective, - /// which names no vCores, has none: the ratio is omitted with instead of + /// the two. There the count is the vcore_count parsed from the service objective, and a DTU-model objective or an + /// elastic pool, which name no vCores, has none: the ratio is omitted with instead of /// being computed from the host. holds that rule; the overload that /// takes the engine edition applies it, so a caller holding a server_properties row cannot forget it. /// @@ -56,7 +56,7 @@ public static class CpuAttribution /// The note on an Azure SQL Database whose service objective names no vCores: the host's core count is /// not this database's allocation, so there is no core count to multiply by. The ratio is omitted, not estimated. public const string CoreCountNotApplicableNote = - "core count not applicable: on an Azure SQL Database the host's core count is not this database's allocation and its service objective names no vCores (DTU model), so measured CPU-seconds cannot be computed; ratio omitted rather than invented"; + "core count not applicable: on an Azure SQL Database the host's core count is not this database's allocation and its service objective names no vCores (a DTU-model objective or an elastic pool), so measured CPU-seconds cannot be computed; ratio omitted rather than invented"; /// /// A null always comes with a saying why. @@ -94,7 +94,7 @@ public static Result Compute( /// The computation for a caller that holds the server's server_properties columns: the same as the /// overload above, with the core count resolved through . Off an /// Azure SQL Database (or with no row, null) that is - /// unchanged. On one it is , or no count at all for a DTU-model objective, and + /// unchanged. On one it is , or no count at all for a DTU-model objective or an elastic pool, and /// then the ratio is omitted with rather than computed from the /// host's CPUs. Both SKUs' top-queries and top-procedures tools call this one. /// diff --git a/PerformanceMonitor.Common/ServerHardwareScope.cs b/PerformanceMonitor.Common/ServerHardwareScope.cs index 5cffee5fe..c1c71e9d7 100644 --- a/PerformanceMonitor.Common/ServerHardwareScope.cs +++ b/PerformanceMonitor.Common/ServerHardwareScope.cs @@ -25,11 +25,18 @@ namespace PerformanceMonitor.Common; /// hyperthread_ratio or physical_memory_mb asks this class first, so the rule and the words live in /// one place for both apps. The collector keeps storing the values as read. /// -/// Every calculation that would DIVIDE BY or SCORE one of them asks this class too: the attributed-CPU -/// denominator (), the FinOps utilization card's CPU count and the FinOps health score. On -/// an Azure SQL Database each of those uses the database's own figure where one is collected (vcore_count) and is -/// otherwise NOT APPLICABLE. None of them falls back to the host's count or memory, because a number built from the -/// host reads as the database's and is wrong. A DTU-model objective names no vCores, so its CPU count is not applicable. +/// Every calculation that would DIVIDE BY one of the host's CPU figures asks this class too: the attributed-CPU +/// denominator () and the FinOps utilization card's CPU count. On an Azure SQL Database each of +/// those uses the database's own figure where one is collected (vcore_count) and is otherwise NOT APPLICABLE. Neither +/// falls back to the host's count, because a number built from the host reads as the database's and is wrong. A DTU-model +/// objective or an elastic pool names no vCores, so its CPU count is not applicable. +/// +/// The host's memory is only what server_properties holds. memory_stats is a different table: on an +/// Azure SQL Database its total_physical_memory_mb is filled from committed_target_kb, the database's own memory +/// limit (1,838 MB on a 1-vCore General Purpose database whose server_properties row holds 911.9 GB), and its buffer +/// pool and server-memory counters are the database's too. So what reads memory_stats (the FinOps utilization card's +/// Physical Memory and Buffer Pool %, its verdict sentences and the health score's memory term) is shown and scored on every +/// edition alike. Only the words change: on an Azure SQL Database the figure is the database's memory limit, not physical RAM. /// public static class ServerHardwareScope { @@ -54,7 +61,7 @@ public static class ServerHardwareScope /// /// Rewrites a get_server_properties payload for an Azure SQL Database: the five host-hardware keys become /// null where they stand, vcore_count is inserted right after service_objective (null for a DTU-model - /// objective that names no vCores), and hardware_note is appended. Both apps call it, so the shape and the + /// objective or an elastic pool, which name no vCores), and hardware_note is appended. Both apps call it, so the shape and the /// words cannot drift apart. A payload of any other edition never reaches it. /// public static JsonObject ScopeServerPropertiesPayload(JsonObject payload, int? vcoreCount) @@ -72,7 +79,7 @@ public static JsonObject ScopeServerPropertiesPayload(JsonObject payload, int? v public const string InventoryHardwareNote = "Azure SQL Database: the host's hardware is not this database's allocation; see its service objective."; - /// What the FinOps utilization card shows where it would have shown the host's physical memory. + /// What the FinOps utilization card shows for a CPU count that is not applicable (see ). public const string NotApplicable = "n/a"; /// @@ -97,29 +104,32 @@ public static string CpuCountText(int? engineEdition, int cpuCount) => : cpuCount.ToString("N0", CultureInfo.CurrentCulture); /// - /// The tooltip on the FinOps health score when it has no memory term. The score's memory term is the buffer pool's - /// share of physical memory, and an Azure SQL Database's physical memory is the host's, so that term is left out - /// and CPU and storage carry the whole score. + /// What the FinOps utilization card measures the buffer pool against: physical RAM on SQL Server and Managed Instance, and + /// on an Azure SQL Database the database's memory limit. Both are memory_stats.total_physical_memory_mb, which on an + /// Azure SQL Database is committed_target_kb, so the figure is shown on every edition and only its name changes. + /// + private static string MemoryBasis(bool azureSqlDatabase) => azureSqlDatabase ? "the database's memory limit" : "physical RAM"; + + /// + /// The caption beside the utilization card's memory figure: "Physical: " on SQL Server and Managed Instance, "Memory limit: " + /// on an Azure SQL Database, where the figure is the database's own limit and not the host's RAM. /// - public const string HealthScoreWithoutMemoryNote = - "Memory is not part of this score: an Azure SQL Database's physical memory is the host's, not this database's allocation. The score is CPU and storage only."; + public static string PhysicalMemoryCaption(int? engineEdition) => + HardwareIsTheHosts(engineEdition) ? "Memory limit: " : "Physical: "; /// - /// The FinOps utilization verdict sentence for a server whose provisioning is RIGHT_SIZED. Off an Azure SQL - /// Database it is the long-standing sentence, byte for byte; on one it drops the clause that compares the buffer - /// pool with the HOST's physical memory. + /// The FinOps utilization verdict sentence for a server whose provisioning is RIGHT_SIZED. Off an Azure SQL Database it is + /// the long-standing sentence, byte for byte. On one it says the same, with the share named against the database's memory + /// limit instead of physical RAM. /// - public static string RightSizedExplanation(decimal avgCpuPct, decimal p95CpuPct, double bufferPoolPctOfPhysical, bool azureSqlDatabase) => - azureSqlDatabase - ? string.Create(CultureInfo.CurrentCulture, $"CPU is moderately loaded (avg {avgCpuPct:N1}%, p95 {p95CpuPct:N1}%). No action needed.") - : string.Create(CultureInfo.CurrentCulture, $"CPU is moderately loaded (avg {avgCpuPct:N1}%, p95 {p95CpuPct:N1}%) and memory is well-utilized (buffer pool uses {bufferPoolPctOfPhysical:N0}% of physical RAM). No action needed."); + public static string RightSizedExplanation(decimal avgCpuPct, decimal p95CpuPct, double bufferPoolPct, bool azureSqlDatabase) => + string.Create(CultureInfo.CurrentCulture, $"CPU is moderately loaded (avg {avgCpuPct:N1}%, p95 {p95CpuPct:N1}%) and memory is well-utilized (buffer pool uses {bufferPoolPct:N0}% of {MemoryBasis(azureSqlDatabase)}). No action needed."); /// /// The FinOps utilization verdict sentence for a server whose provisioning is OVER_PROVISIONED. Same rule as - /// : an Azure SQL Database's sentence cites no buffer pool share of the host's RAM. + /// : the long-standing sentence off an Azure SQL Database, and on one the same share named + /// against the database's memory limit, about "this database". /// - public static string OverProvisionedExplanation(decimal avgCpuPct, int maxCpuPct, double bufferPoolPctOfPhysical, bool azureSqlDatabase) => - azureSqlDatabase - ? string.Create(CultureInfo.CurrentCulture, $"CPU is lightly loaded (avg {avgCpuPct:N1}%, max {maxCpuPct}%). This database may have more resources than it needs.") - : string.Create(CultureInfo.CurrentCulture, $"CPU is lightly loaded (avg {avgCpuPct:N1}%, max {maxCpuPct}%) and buffer pool uses only {bufferPoolPctOfPhysical:N0}% of physical RAM. This server may have more resources than it needs."); + public static string OverProvisionedExplanation(decimal avgCpuPct, int maxCpuPct, double bufferPoolPct, bool azureSqlDatabase) => + string.Create(CultureInfo.CurrentCulture, $"CPU is lightly loaded (avg {avgCpuPct:N1}%, max {maxCpuPct}%) and buffer pool uses only {bufferPoolPct:N0}% of {MemoryBasis(azureSqlDatabase)}. This {(azureSqlDatabase ? "database" : "server")} may have more resources than it needs."); } From 4e8db862ad1f15807723d38eae0aa2b48f133a9f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:26:27 -0400 Subject: [PATCH 06/17] Azure SQL Database: the hardware fact, worker threads and health score stop using the host's figures On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info describes the host the database runs on, so the stored cpu_count, hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb are the host's, and the max_workers_count copied from the same DMV follows the host's CPUs. This finishes what the CPU attribution and FinOps utilization change left, in Lite and Darling alike. - SERVER_HARDWARE fact: on an Azure SQL Database it carries the database's own vCores and the HADR flag and nothing else (no topology, no memory), and is not emitted when the service objective names no vCores. The LPIM advisory, which gated on the host's memory, is left out there. Both fact collectors go through one shared builder. - Plan Server Context metadata (three readers): the vCores and no RAM figure on an Azure SQL Database; the Hardware row shows the vCores alone, or is dropped for a DTU objective. - Worker Threads card and the worker ceiling the provisioning verdict reads (point-in-time, 7-day trend and Server Inventory reads): no ceiling is carried on an Azure SQL Database, the card reads n/a, and nothing is scored against the host-derived maximum. - FinOps health score: a window with no CPU sample leaves the CPU term out instead of scoring the 0 it reads as (a full 100). Applies to every edition, in the utilization card and in the Server Inventory grid, with a tooltip that says so. - Memory utilization percentage: unchanged. It divides memory_stats columns, which the collector fills from the database's own committed target, so it is already the database's figure. A characterization test pins that. The Utilization() row helper in the two AzureSqlDatabaseHostMathTests files now carries a provisioning status, so its rows are measured windows and keep their CPU term. --- .../AzureSqlDatabaseHostLeftoversTests.cs | 336 ++++++++++ .../AzureSqlDatabaseHostMathTests.cs | 1 + .../PgFactCollector.Config.cs | 37 +- .../DarlingServerMetadataReader.cs | 17 +- .../FinOpsTab.Loaders.cs | 10 +- .../ViewerDataService.FinOps.Inventory.cs | 13 +- .../ViewerDataService.FinOps.Utilization.cs | 19 +- .../ViewerDataService.FinOps.cs | 35 +- .../AzureSqlDatabaseHostLeftoversTests.cs | 621 ++++++++++++++++++ Lite.Tests/AzureSqlDatabaseHostMathTests.cs | 1 + Lite/Analysis/DrillDownCollector.Plans.cs | 15 +- Lite/Analysis/DuckDbFactCollector.Config.cs | 35 +- Lite/Controls/FinOpsTab.xaml.cs | 10 +- ...ocalDataService.FinOps.ServerProperties.cs | 13 +- .../LocalDataService.FinOps.Utilization.cs | 19 +- Lite/Services/LocalDataService.FinOps.cs | 35 +- .../LocalDataService.PlanServerMetadata.cs | 17 +- .../FactCollectorHelpers.cs | 50 +- .../ServerHardwareScope.cs | 38 ++ .../ServerContextCard.cs | 8 +- 20 files changed, 1241 insertions(+), 89 deletions(-) create mode 100644 Darling/Darling.Tests/AzureSqlDatabaseHostLeftoversTests.cs create mode 100644 Lite.Tests/AzureSqlDatabaseHostLeftoversTests.cs diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHostLeftoversTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHostLeftoversTests.cs new file mode 100644 index 000000000..11b2208b1 --- /dev/null +++ b/Darling/Darling.Tests/AzureSqlDatabaseHostLeftoversTests.cs @@ -0,0 +1,336 @@ +/* + * 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.Collections.Generic; +using System.Globalization; +using System.Linq; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Analysis; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.PlanAnalysis; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// What left: on an Azure SQL Database (engine edition 5) sys.dm_os_sys_info +/// describes the HOST, so the stored server_properties.cpu_count, hyperthread_ratio, socket_count, +/// cores_per_socket and physical_memory_mb are the host's, and so is the max_workers_count that +/// memory_stats copies from the same DMV. Nothing may compute from them there. Where the database has its own figure +/// (vcore_count, parsed from the service objective) that is used; otherwise the answer is not applicable. +/// +/// The four things pinned here, each with an edition 5 case that has vCores, an edition 5 case without them (a DTU +/// objective), and the SQL Server / Managed Instance twin that must not move: +/// +/// the SERVER_HARDWARE analysis fact, the LPIM advisory that reads its memory, and the plan Server Context card; +/// the FinOps Worker Threads card and the worker ceiling the provisioning verdict reads; +/// the memory utilization percentage, which is NOT changed: it divides memory_stats columns, which the collector +/// fills from the database's own committed target, not from server_properties.physical_memory_mb; +/// the FinOps health score's CPU term, which is left out when the window holds no CPU sample (any edition). +/// +/// +/// The pure rules are run here; the PostgreSQL reads are pinned as text (their statements are public constants, and the +/// readers that consume them are pinned by the lines that route them through the shared rule), and Lite.Tests runs the same +/// table against a real DuckDB in the same words. +/// +public sealed class AzureSqlDatabaseHostLeftoversTests +{ + private static AnalysisContext Context() => new() + { + ServerId = 7, + ServerName = "LeftoversSrv", + TimeRangeStart = new DateTime(2026, 9, 30, 0, 0, 0, DateTimeKind.Utc), + TimeRangeEnd = new DateTime(2026, 9, 30, 1, 0, 0, DateTimeKind.Utc), + }; + + // ── 1. SERVER_HARDWARE fact, LPIM advisory, Server Context card ── + + [Fact] + public void ServerHardwareFact_OnAzureSqlDatabase_CarriesTheVcoresAndHadrOnly_NotTheHostsTopologyOrMemory() + { + var fact = FactCollectorHelpers.BuildServerHardwareFact( + Context(), hardwareIsTheHosts: true, + cpuCount: 1, hyperthreadRatio: 64, physicalMemoryMb: 933_836, socketCount: 0, coresPerSocket: 32, hadrEnabled: false); + + Assert.NotNull(fact); + Assert.Equal("SERVER_HARDWARE", fact!.Key); + Assert.Equal("config", fact.Source); + Assert.Equal(1, fact.Value); + Assert.Equal(new[] { "cpu_count", "hadr_enabled" }, fact.Metadata.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray()); + Assert.Equal(1, fact.Metadata["cpu_count"]); + Assert.Equal(0, fact.Metadata["hadr_enabled"]); + } + + [Fact] + public void ServerHardwareFact_OnAzureSqlDatabaseWithNoVcores_IsNotEmitted() + { + Assert.Null(FactCollectorHelpers.BuildServerHardwareFact( + Context(), hardwareIsTheHosts: true, + cpuCount: 0, hyperthreadRatio: 64, physicalMemoryMb: 933_836, socketCount: 0, coresPerSocket: 32, hadrEnabled: false)); + } + + [Fact] + public void ServerHardwareFact_OffAzureSqlDatabase_IsTheStoredTopologyAsItAlwaysWas() + { + var fact = FactCollectorHelpers.BuildServerHardwareFact( + Context(), hardwareIsTheHosts: false, + cpuCount: 16, hyperthreadRatio: 2, physicalMemoryMb: 65_536, socketCount: 2, coresPerSocket: 4, hadrEnabled: true); + + Assert.NotNull(fact); + Assert.Equal(16, fact!.Value); + Assert.Equal( + new[] { "cpu_count", "hyperthread_ratio", "physical_memory_mb", "socket_count", "cores_per_socket", "hadr_enabled" }, + fact.Metadata.Keys.ToArray()); + Assert.Equal(16, fact.Metadata["cpu_count"]); + Assert.Equal(2, fact.Metadata["hyperthread_ratio"]); + Assert.Equal(65_536, fact.Metadata["physical_memory_mb"]); + Assert.Equal(2, fact.Metadata["socket_count"]); + Assert.Equal(4, fact.Metadata["cores_per_socket"]); + Assert.Equal(1, fact.Metadata["hadr_enabled"]); + + /* No CPU count at all is no fact, on any edition, as before. */ + Assert.Null(FactCollectorHelpers.BuildServerHardwareFact( + Context(), hardwareIsTheHosts: false, + cpuCount: 0, hyperthreadRatio: 2, physicalMemoryMb: 65_536, socketCount: 2, coresPerSocket: 4, hadrEnabled: false)); + } + + [Fact] + public void LpimAdvisory_OnAzureSqlDatabase_IsNotRaisedFromTheHostsMemory_AndIsUnchangedElsewhere() + { + var onAzure = new List(); + FactCollectorHelpers.EmitServerHealthFacts( + Context(), onAzure, "SQL Azure", physicalMemMb: 933_836, + lockPagesInMemory: false, instantFileInit: null, memoryDumpCount: null, hardwareIsTheHosts: true); + Assert.DoesNotContain(onAzure, f => f.Key == "CONFIG_LPIM_DISABLED"); + + var onServer = new List(); + FactCollectorHelpers.EmitServerHealthFacts( + Context(), onServer, "Enterprise Edition (64-bit)", physicalMemMb: 933_836, + lockPagesInMemory: false, instantFileInit: null, memoryDumpCount: null); + var lpim = Assert.Single(onServer, f => f.Key == "CONFIG_LPIM_DISABLED"); + Assert.Equal(933_836, lpim.Metadata["physical_memory_mb"]); + } + + [Fact] + public void ServerContextCard_ShowsTheVcoresWithNoRam_WhenNoMemoryFigureIsCarried_AndTheRamOtherwise() + { + static string? Hardware(ServerMetadata metadata) => + ServerContextCard.Rows(metadata).SingleOrDefault(r => r.Label == "Hardware").Value; + + Assert.Equal("1 CPUs", Hardware(new ServerMetadata { Edition = "SQL Azure", CpuCount = 1, PhysicalMemoryMB = 0 })); + Assert.Null(Hardware(new ServerMetadata { Edition = "SQL Azure", CpuCount = 0, PhysicalMemoryMB = 0 })); + Assert.Equal( + string.Format(CultureInfo.CurrentCulture, "8 CPUs, {0:N0} MB RAM", 65_536L), + Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", CpuCount = 8, PhysicalMemoryMB = 65_536 })); + } + + [Fact] + public void FactCollectorAndServerMetadataReader_AskTheSharedRule_ForTheEditionsHardware() + { + /* The PostgreSQL statement reads the edition beside the count and scopes the count in SQL, identically to Lite's DuckDB read. */ + Assert.Contains( + "SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, hyperthread_ratio", + PgFactCollector.ServerPropertiesSql, StringComparison.Ordinal); + Assert.Contains("memory_dump_count, engine_edition", PgFactCollector.ServerPropertiesSql, StringComparison.Ordinal); + + var config = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Analysis", "PgFactCollector.Config.cs"); + Assert.Contains("FactCollectorHelpers.BuildServerHardwareFact(", config, StringComparison.Ordinal); + Assert.Contains("lpim, ifi, dumpCount, hardwareIsTheHosts);", config, StringComparison.Ordinal); + Assert.DoesNotContain("[\"hyperthread_ratio\"] = htRatio", config, StringComparison.Ordinal); + + Assert.Contains("engine_edition, vcore_count", DarlingServerMetadataReader.ServerMetadataSql, StringComparison.Ordinal); + Assert.Contains("props.engine_edition, props.vcore_count", DarlingServerMetadataReader.ServerMetadataSql, StringComparison.Ordinal); + var reader = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "DarlingServerMetadataReader.cs"); + Assert.Contains("ServerHardwareScope.OwnCpuCount(engineEdition, storedCpuCount, vcoreCount) ?? 0", reader, StringComparison.Ordinal); + Assert.Contains("ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L", reader, StringComparison.Ordinal); + } + + // ── 2. Worker Threads card and the worker ceiling ── + + [Theory] + [InlineData(5, 512, 0)] // Azure SQL Database: the ceiling follows the host's CPUs, so none is carried + [InlineData(3, 512, 512)] // SQL Server: as stored + [InlineData(8, 2560, 2560)] // Managed Instance: as stored + [InlineData(1, 512, 512)] + [InlineData(null, 512, 512)] // edition not read: as stored + public void OwnMaxWorkersCount_IsNoneOnAzureSqlDatabase_AndTheStoredCeilingEverywhereElse(int? engineEdition, int stored, int expected) + { + Assert.Equal(expected, ServerHardwareScope.OwnMaxWorkersCount(engineEdition, stored)); + } + + [Fact] + public void OwnPhysicalMemoryMb_IsNotApplicableOnAzureSqlDatabase_AndTheStoredFigureEverywhereElse() + { + Assert.Null(ServerHardwareScope.OwnPhysicalMemoryMb(5, 933_836)); + Assert.Null(ServerHardwareScope.OwnPhysicalMemoryMb(5, null)); + Assert.Equal(65_536L, ServerHardwareScope.OwnPhysicalMemoryMb(3, 65_536)); + Assert.Equal(65_536L, ServerHardwareScope.OwnPhysicalMemoryMb(8, 65_536)); + Assert.Equal(65_536L, ServerHardwareScope.OwnPhysicalMemoryMb(null, 65_536)); + Assert.Null(ServerHardwareScope.OwnPhysicalMemoryMb(3, null)); + } + + [Fact] + public void WorkerThreadsText_IsNotApplicableOnAzureSqlDatabase_AndInUseOverMaximumEverywhereElse() + { + Assert.Equal("n/a", ServerHardwareScope.WorkerThreadsText(5, 0, 512)); + Assert.Equal("n/a", ServerHardwareScope.WorkerThreadsText(5, 450, 512)); + Assert.Equal("n/a", ServerHardwareScope.WorkerThreadsText(5, 0, 0)); + + foreach (var engineEdition in new int?[] { 1, 2, 3, 4, 8, null }) + { + Assert.Equal( + string.Format(CultureInfo.CurrentCulture, "{0:N0} / {1:N0}", 1_200, 2_560), + ServerHardwareScope.WorkerThreadsText(engineEdition, 1_200, 2_560)); + } + } + + [Fact] + public void WorkerCeilingReads_ScopeTheCeilingToTheEdition_InAllThreePlaces() + { + /* Point-in-time read: the ceiling goes through the shared rule before the verdict and the card see it. */ + var utilization = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Utilization.cs"); + Assert.Contains( + "var maxWorkers = ServerHardwareScope.OwnMaxWorkersCount(engineEdition, reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)));", + utilization, StringComparison.Ordinal); + + /* 7-day trend: each day's ceiling is 0 on an Azure SQL Database, from the newest server_properties row's edition. */ + Assert.Contains("CASE WHEN s.engine_edition = 5 THEN 0 ELSE COALESCE(m.max_workers_count, 0) END", ViewerDataService.ProvisioningTrendSql, StringComparison.Ordinal); + Assert.Contains("LEFT JOIN server_info s ON true", ViewerDataService.ProvisioningTrendSql, StringComparison.Ordinal); + Assert.Contains("SELECT engine_edition\n FROM server_properties\n WHERE server_id = $1", Lf(ViewerDataService.ProvisioningTrendSql), StringComparison.Ordinal); + + /* Fleet read: the latest ceiling per server is NULL on an Azure SQL Database, which the verdict reads as unknown. */ + Assert.Contains( + "CASE WHEN props.engine_edition = 5 THEN NULL ELSE latest.max_workers_count END AS max_workers_count", + ViewerDataService.ServerMetricsSql, StringComparison.Ordinal); + Assert.Contains(") AS props ON true", ViewerDataService.ServerMetricsSql, StringComparison.Ordinal); + } + + private static string Lf(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal); + + // ── 4. Health score: the CPU term ── + + [Fact] + public void Overall_WithNoCpuScore_WeighsMemoryAndStorageOverTheirOwnSixtyPercent() + { + Assert.Equal(100, FinOpsHealthCalculator.Overall(null, 100, 100)); + Assert.Equal(0, FinOpsHealthCalculator.Overall(null, 0, 0)); + Assert.Equal(80, FinOpsHealthCalculator.Overall(null, 60, 100)); + Assert.Equal(60, FinOpsHealthCalculator.Overall(null, 80, 40)); + } + + [Fact] + public void Overall_WithNoCpuScoreAndNoMemoryScore_IsTheStorageScoreAlone() + { + Assert.Equal(100, FinOpsHealthCalculator.Overall(null, null, 100)); + Assert.Equal(0, FinOpsHealthCalculator.Overall(null, null, 0)); + Assert.Equal(37, FinOpsHealthCalculator.Overall(null, null, 37)); + } + + [Fact] + public void Overall_WithEveryTerm_IsTheLongStandingArithmetic() + { + Assert.Equal(86, FinOpsHealthCalculator.Overall(95, 60, 100)); + Assert.Equal(62, FinOpsHealthCalculator.Overall(80, 60, 40)); + Assert.Equal(100, FinOpsHealthCalculator.Overall(100, 100, 100)); + Assert.Equal(0, FinOpsHealthCalculator.Overall(0, 0, 0)); + /* The memory-less form from before is untouched too. */ + Assert.Equal(97, FinOpsHealthCalculator.Overall(95, null, 100)); + } + + /// Buffer pool 10% of physical memory scores 60 and 50% free storage scores 100, so the CPU term is the only one + /// that can tell a measured window from an empty one. + private static UtilizationEfficiencyRow Window(int engineEdition, string provisioningStatus, decimal p95CpuPct) => new() + { + EngineEdition = engineEdition, + ProvisioningStatus = provisioningStatus, + P95CpuPct = p95CpuPct, + PhysicalMemoryMb = 100_000, + BufferPoolMb = 10_000, + FreeSpacePct = 50m, + }; + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(8)] + public void HealthScore_WithNoCpuSample_LeavesTheCpuTermOut_AndDoesNotScoreTheZeroItReadsAs(int engineEdition) + { + /* With the CPU term: 100 * 0.4 + 60 * 0.3 + 100 * 0.3 = 88. Without it: memory and storage over their own 60%. */ + var noSample = Window(engineEdition, provisioningStatus: "", p95CpuPct: 0m); + + Assert.False(noSample.HasCpuSample); + Assert.Equal(80, noSample.ComputeHealthScore()); + Assert.Equal(FinOpsHealthCalculator.Overall(null, 60, 100), noSample.ComputeHealthScore()); + + /* Whatever the P95 field holds, an empty window's CPU is not scored. */ + Assert.Equal(80, Window(engineEdition, "", 95m).ComputeHealthScore()); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(8)] + public void HealthScore_WithACpuSample_IsTheLongStandingScore(int engineEdition) + { + /* 95 * 0.4 + 60 * 0.3 + 100 * 0.3 = 86, whatever verdict the window earned. */ + foreach (var status in new[] { ProvisioningVerdict.RightSized, ProvisioningVerdict.OverProvisioned, ProvisioningVerdict.UnderProvisioned }) + { + var measured = Window(engineEdition, status, 7m); + Assert.True(measured.HasCpuSample); + Assert.Equal(86, measured.ComputeHealthScore()); + } + + /* A genuinely idle measured window (p95 of 0) still scores its 100, which is the point of telling it from no sample. */ + Assert.Equal(88, Window(engineEdition, ProvisioningVerdict.OverProvisioned, 0m).ComputeHealthScore()); + } + + [Fact] + public void FinOpsTab_AsksTheSharedRules_ForTheWorkerThreadsCard_TheHealthTooltip_AndTheInventoryCpuTerm() + { + var tab = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "FinOpsTab.Loaders.cs"); + + Assert.Contains( + "FinOpsWorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.EngineEdition, data.CurrentWorkersCount, data.MaxWorkersCount);", + tab, StringComparison.Ordinal); + Assert.DoesNotContain("$\"{data.CurrentWorkersCount:N0} / {data.MaxWorkersCount:N0}\"", tab, StringComparison.Ordinal); + + Assert.Contains("if (!data.HasCpuSample)", tab, StringComparison.Ordinal); + Assert.Contains("FinOpsHealthScoreBorder.ToolTip = ServerHardwareScope.HealthScoreWithoutCpuNote;", tab, StringComparison.Ordinal); + + Assert.Contains( + "int? cpuScore = item.AvgCpuPct is decimal avgCpu ? FinOpsHealthCalculator.CpuScore(avgCpu) : null;", + tab, StringComparison.Ordinal); + Assert.DoesNotContain("item.AvgCpuPct ?? 0m", tab, StringComparison.Ordinal); + } + + // ── 3. memory utilization: unchanged, it is memory_stats ── + + [Fact] + public void MemoryUtilization_IsStillComputedFromMemoryStats_NotFromTheHostsPhysicalMemory() + { + /* The read is the memory_stats snapshot, which the collector fills from the database's own committed target on an + Azure SQL Database (1,838 MB for a 1-vCore General Purpose database), so dividing by it is database-scoped. */ + Assert.Contains("FROM v_memory_stats", DarlingDataReader.LatestMemoryStatsSql, StringComparison.Ordinal); + Assert.DoesNotContain("server_properties", DarlingDataReader.LatestMemoryStatsSql, StringComparison.Ordinal); + + var tool = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"); + Assert.Contains( + "? (stats.TotalPhysicalMemoryMb - stats.AvailablePhysicalMemoryMb) / stats.TotalPhysicalMemoryMb * 100", + tool, StringComparison.Ordinal); + Assert.Contains("memory_utilization_pct = Math.Round(utilization, 1),", tool, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs index 45d7eb533..1a17ca210 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs @@ -169,6 +169,7 @@ public void UtilizationRead_ResolvesTheCpuCountTheSameWayLiteDoes() private static UtilizationEfficiencyRow Utilization(int engineEdition, int bufferPoolMb, int physicalMemoryMb) => new() { EngineEdition = engineEdition, + ProvisioningStatus = ProvisioningVerdict.RightSized, // a measured window: a window with no CPU sample has no CPU term P95CpuPct = 7m, BufferPoolMb = bufferPoolMb, PhysicalMemoryMb = physicalMemoryMb, diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs index 9a45f3a00..1aa7d27f7 100644 --- a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using Npgsql; using PerformanceMonitor.Analysis; +using PerformanceMonitor.Common; namespace PerformanceMonitor.Darling.Analysis; @@ -308,10 +309,17 @@ private async Task CollectTraceFlagFactsAsync(AnalysisContext context, List + /// The newest server_properties row for the SERVER_HARDWARE fact. cpu_count is the count the server itself has: on an + /// Azure SQL Database (engine_edition 5) the stored cpu_count, hyperthread_ratio, physical_memory_mb, socket_count and + /// cores_per_socket describe the HOST, so there the count is the vcore_count parsed from the service objective (NULL for a DTU + /// objective, which leaves no fact) and carries none of the host's + /// topology or memory. Every other edition reads as it always did. Lite's DuckDbFactCollector carries the same CASE. + /// public const string ServerPropertiesSql = @" -SELECT COALESCE(vcore_count, cpu_count) AS cpu_count, hyperthread_ratio, physical_memory_mb, +SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, hyperthread_ratio, physical_memory_mb, socket_count, cores_per_socket, is_hadr_enabled, edition, product_version, - lock_pages_in_memory, instant_file_initialization_enabled, memory_dump_count + lock_pages_in_memory, instant_file_initialization_enabled, memory_dump_count, engine_edition FROM server_properties WHERE server_id = $1 ORDER BY collection_time DESC @@ -343,30 +351,19 @@ private async Task CollectServerPropertiesFactsAsync(AnalysisContext context, Li bool? lpim = reader.IsDBNull(8) ? (bool?)null : Convert.ToBoolean(reader.GetValue(8)); bool? ifi = reader.IsDBNull(9) ? (bool?)null : Convert.ToBoolean(reader.GetValue(9)); int? dumpCount = reader.IsDBNull(10) ? (int?)null : Convert.ToInt32(reader.GetValue(10)); + int? engineEdition = reader.IsDBNull(11) ? (int?)null : Convert.ToInt32(reader.GetValue(11)); + var hardwareIsTheHosts = ServerHardwareScope.HardwareIsTheHosts(engineEdition); - if (cpuCount == 0) return; + var hardwareFact = FactCollectorHelpers.BuildServerHardwareFact( + context, hardwareIsTheHosts, cpuCount, htRatio, physicalMemMb, socketCount, coresPerSocket, hadrEnabled); + if (hardwareFact is null) return; - facts.Add(new Fact - { - Source = "config", - Key = "SERVER_HARDWARE", - Value = cpuCount, - ServerId = context.ServerId, - Metadata = new Dictionary - { - ["cpu_count"] = cpuCount, - ["hyperthread_ratio"] = htRatio, - ["physical_memory_mb"] = physicalMemMb, - ["socket_count"] = socketCount, - ["cores_per_socket"] = coresPerSocket, - ["hadr_enabled"] = hadrEnabled ? 1 : 0 - } - }); + facts.Add(hardwareFact); // WS5 server-health advisories (advise-only). Gating mirrors the Lite/Dashboard // collectors so all consumers agree on what is worth flagging; a fact that would // score 0 is simply never emitted (noise control). - FactCollectorHelpers.EmitServerHealthFacts(context, facts, edition, physicalMemMb, lpim, ifi, dumpCount); + FactCollectorHelpers.EmitServerHealthFacts(context, facts, edition, physicalMemMb, lpim, ifi, dumpCount, hardwareIsTheHosts); } catch (Exception ex) when (!AnalysisShutdown.IsExpectedAbandon(ex, context.CancellationToken)) { diff --git a/Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs b/Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs index 9fdafda92..2d1ea3280 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs @@ -10,6 +10,7 @@ using System.Threading; using System.Threading.Tasks; using Npgsql; +using PerformanceMonitor.Common; using PerformanceMonitor.PlanAnalysis; namespace PerformanceMonitor.Darling.Storage; @@ -40,7 +41,7 @@ public static class DarlingServerMetadataReader /// public const string ServerMetadataSql = @" WITH props AS ( - SELECT server_name, edition, product_version, product_level, cpu_count, physical_memory_mb + SELECT server_name, edition, product_version, product_level, cpu_count, physical_memory_mb, engine_edition, vcore_count FROM server_properties WHERE server_id = $1 ORDER BY collection_time DESC @@ -85,7 +86,7 @@ LIMIT 1 ctfp.value_in_use, maxmem.value_in_use, dbconfig.database_name, dbconfig.compatibility_level, dbconfig.collation_name, dbconfig.is_read_committed_snapshot_on, dbconfig.is_auto_create_stats_on, dbconfig.is_auto_update_stats_on, dbconfig.is_auto_update_stats_async_on, - dbconfig.is_parameterization_forced + dbconfig.is_parameterization_forced, props.engine_edition, props.vcore_count FROM props LEFT JOIN maxdop ON true LEFT JOIN ctfp ON true @@ -115,14 +116,22 @@ LEFT JOIN maxmem ON true if (!await reader.ReadAsync(cancellationToken)) return null; + /* On an Azure SQL Database the stored cpu_count and physical_memory_mb are the HOST's, so the server context carries + the database's vCores (none for a DTU objective, which drops the Hardware row) and no RAM figure. Every other + edition reads as it always did. Lite's plan-metadata readers apply the same rule. */ + int? engineEdition = reader.IsDBNull(17) ? null : Convert.ToInt32(reader.GetValue(17)); + int? vcoreCount = reader.IsDBNull(18) ? null : Convert.ToInt32(reader.GetValue(18)); + int? storedCpuCount = reader.IsDBNull(4) ? null : Convert.ToInt32(reader.GetValue(4)); + long? storedPhysicalMemoryMb = reader.IsDBNull(5) ? null : Convert.ToInt64(reader.GetValue(5)); + return new ServerMetadata { ServerName = reader.IsDBNull(0) ? null : reader.GetString(0), Edition = reader.IsDBNull(1) ? null : reader.GetString(1), ProductVersion = reader.IsDBNull(2) ? null : reader.GetString(2), ProductLevel = reader.IsDBNull(3) ? null : reader.GetString(3), - CpuCount = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)), - PhysicalMemoryMB = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)), + CpuCount = ServerHardwareScope.OwnCpuCount(engineEdition, storedCpuCount, vcoreCount) ?? 0, + PhysicalMemoryMB = ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L, MaxDop = reader.IsDBNull(6) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(6))), CostThresholdForParallelism = reader.IsDBNull(7) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(7))), MaxServerMemoryMB = reader.IsDBNull(8) ? 0L : Convert.ToInt64(Convert.ToDouble(reader.GetValue(8))), diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs index 344bedf71..2804fafda 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs @@ -274,7 +274,8 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) FinOpsCpuSamplesText.Text = data.CpuSamples.ToString("N0"); /* n/a on an Azure SQL Database whose service objective names no vCores: the host's count is never shown as the database's. */ FinOpsCpuCountText.Text = ServerHardwareScope.CpuCountText(data.EngineEdition, data.CpuCount); - FinOpsWorkerThreadsText.Text = $"{data.CurrentWorkersCount:N0} / {data.MaxWorkersCount:N0}"; + /* n/a on an Azure SQL Database: the worker ceiling follows the host's CPUs and the in-use count is not collected there. */ + FinOpsWorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.EngineEdition, data.CurrentWorkersCount, data.MaxWorkersCount); SetBar(FinOpsAvgCpuBar, FinOpsAvgCpuFilled, FinOpsAvgCpuEmpty, (double)data.AvgCpuPct); SetBar(FinOpsP95CpuBar, FinOpsP95CpuFilled, FinOpsP95CpuEmpty, (double)data.P95CpuPct); @@ -337,6 +338,9 @@ pressure or worker saturation would have been explained as a memory ratio that n the score and the tooltip says so. */ data.HealthScore = data.ComputeHealthScore(); FinOpsHealthScoreBorder.ToolTip = azureSqlDb ? ServerHardwareScope.HealthScoreWithoutMemoryNote : null; + /* A window with no CPU sample has no CPU term either (ComputeHealthScore leaves it out), and the tooltip says so. */ + if (!data.HasCpuSample) + FinOpsHealthScoreBorder.ToolTip = ServerHardwareScope.HealthScoreWithoutCpuNote; FinOpsHealthScoreText.Text = $"Health: {data.HealthScore}"; FinOpsHealthScoreBorder.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(data.HealthScoreColor)); FinOpsHealthScoreBorder.Visibility = Visibility.Visible; @@ -586,7 +590,9 @@ and there is now exactly one read total. */ if (row.ProvisioningStatus != null) item.ProvisioningStatus = row.ProvisioningStatus; } - var cpuScore = FinOpsHealthCalculator.CpuScore(item.AvgCpuPct ?? 0m); + /* A server with no CPU sample in the window has a null average: its CPU term is left out, because scoring it + as 0% CPU would hand it a full 100 made from nothing. */ + int? cpuScore = item.AvgCpuPct is decimal avgCpu ? FinOpsHealthCalculator.CpuScore(avgCpu) : null; var memScore = 80; var storScore = FinOpsHealthCalculator.StorageScore(50); item.HealthScore = FinOpsHealthCalculator.Overall(cpuScore, memScore, storScore); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs index fb10fa584..bf71f10d3 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs @@ -57,11 +57,13 @@ FROM v_cpu_utilization_stats GROUP BY server_id ), /* Only the worker counts are consumed now: memory_ratio used to feed this read's own CASE, and that - CASE was the #2246 bug. The verdict comes from ProvisioningVerdict, so the division would be dead. */ + CASE was the #2246 bug. The verdict comes from ProvisioningVerdict, so the division would be dead. + max_workers_count is the HOST-derived ceiling on an Azure SQL Database (engine_edition 5), so there it is NULL (no + ceiling, which the verdict reads as unknown); every other edition keeps the stored figure. */ mem_latest AS ( SELECT s.server_id, - latest.max_workers_count, + CASE WHEN props.engine_edition = 5 THEN NULL ELSE latest.max_workers_count END AS max_workers_count, latest.current_workers_count FROM servers s CROSS JOIN LATERAL ( @@ -71,6 +73,13 @@ FROM v_memory_stats ORDER BY collection_time DESC LIMIT 1 ) AS latest + LEFT JOIN LATERAL ( + SELECT engine_edition + FROM server_properties + WHERE server_id = s.server_id + ORDER BY collection_time DESC + LIMIT 1 + ) AS props ON true ), /* Same workspace-memory pressure signals as the drill-down read, so the INVENTORY GRID cannot classify a server by a rule the drill-down no longer uses (#2246 — this grid is the screen the field report was diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs index c6661834d..d7fbe4f43 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs @@ -119,7 +119,12 @@ LEFT JOIN server_info s ON true var p95Cpu = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)); var memRatio = reader.IsDBNull(8) ? 0m : Convert.ToDecimal(reader.GetValue(8)); - var maxWorkers = reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)); + /* max_workers_count is read from sys.dm_os_sys_info, which derives it from the CPU count that DMV reports. On an Azure + SQL Database that count is the HOST's, so there no ceiling is carried: 0 reads as unknown in the verdict (never as + saturation) and the Worker Threads card reads n/a. Every other edition reads as it always did. The same scoping is + in the trend read and the fleet read, and in Lite's three. */ + int? engineEdition = reader.IsDBNull(16) ? null : Convert.ToInt32(reader.GetValue(16)); + var maxWorkers = ServerHardwareScope.OwnMaxWorkersCount(engineEdition, reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9))); var currentWorkers = reader.IsDBNull(10) ? 0 : Convert.ToInt32(reader.GetValue(10)); /* memory_ratio is still SELECTed and still displayed — it is a real fact about the instance — but it @@ -197,6 +202,15 @@ FROM v_memory_grant_stats WHERE server_id = $1 AND collection_time >= $2 GROUP BY CAST(collection_time AS DATE) +), +/* max_workers_count is the HOST-derived ceiling on an Azure SQL Database (engine_edition 5), so the days of one carry no + ceiling: 0, which the verdict reads as unknown. Same rule as the point-in-time read above. */ +server_info AS ( + SELECT engine_edition + FROM server_properties + WHERE server_id = $1 + ORDER BY collection_time DESC + LIMIT 1 ) SELECT c.day, @@ -208,11 +222,12 @@ GROUP BY CAST(collection_time AS DATE) COALESCE(g.grant_timeouts, 0), COALESCE(g.forced_grants, 0), COALESCE(g.grant_utilization_pct, 0), - COALESCE(m.max_workers_count, 0), + CASE WHEN s.engine_edition = 5 THEN 0 ELSE COALESCE(m.max_workers_count, 0) END, COALESCE(m.current_workers_count, 0) FROM daily_cpu c LEFT JOIN daily_mem m ON m.day = c.day LEFT JOIN daily_grants g ON g.day = c.day +LEFT JOIN server_info s ON true ORDER BY c.day"; public async Task> GetProvisioningTrendAsync(int serverId, CancellationToken cancellationToken = default) diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs index 69b411005..4ecfa94db 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs @@ -146,15 +146,16 @@ public sealed class UtilizationEfficiencyRow /// /// The health score for these figures: CPU p95, the buffer pool's share of physical memory, and free storage. On an /// Azure SQL Database ( 5) physical memory is the HOST's, so the memory term is left out, not - /// scored as zero and not scored as a default, and CPU and storage carry the whole score. Anywhere else the score is - /// what it always was. + /// scored as zero and not scored as a default, and CPU and storage carry the whole score. A window with no CPU sample + /// ( false) leaves the CPU term out the same way: its p95 is a 0 that came from nothing, and + /// scoring that 0 would hand the server a full 100. Anywhere else the score is what it always was. /// public int ComputeHealthScore() { var bpRatio = PhysicalMemoryMb > 0 ? (decimal)BufferPoolMb / PhysicalMemoryMb : 0m; int? memScore = ServerHardwareScope.HardwareIsTheHosts(EngineEdition) ? null : FinOpsHealthCalculator.MemoryScore(bpRatio); - return FinOpsHealthCalculator.Overall( - FinOpsHealthCalculator.CpuScore(P95CpuPct), memScore, FinOpsHealthCalculator.StorageScore(FreeSpacePct)); + int? cpuScore = HasCpuSample ? FinOpsHealthCalculator.CpuScore(P95CpuPct) : null; + return FinOpsHealthCalculator.Overall(cpuScore, memScore, FinOpsHealthCalculator.StorageScore(FreeSpacePct)); } } @@ -525,14 +526,26 @@ public static int StorageScore(decimal freeSpacePct) } /// - /// The overall score: CPU 40%, memory 30%, storage 30%. A null means memory is not - /// applicable (an Azure SQL Database's physical memory is the host's), so the term is left out, not scored as zero and - /// not scored as a default: CPU and storage keep their 40:30 weights over their own 70%. + /// The overall score: CPU 40%, memory 30%, storage 30%. A null means the window held no CPU + /// sample (there is nothing to score, and scoring the 0 it reads as would be a full 100 made from nothing). A null + /// means memory is not applicable (an Azure SQL Database's physical memory is the host's). + /// Either way the term is left out, not scored as zero and not scored as a default, and the terms that remain keep + /// their weights over their own total: CPU and storage 40:30 over 70, memory and storage 30:30 over 60, storage alone + /// over 30. /// - public static int Overall(int cpu, int? memory, int storage) => - memory is int mem - ? (int)(cpu * 0.40 + mem * 0.30 + storage * 0.30) - : (cpu * 40 + storage * 30) / 70; /* integer weights, so no floating-point error can truncate 100 to 99 */ + public static int Overall(int? cpu, int? memory, int storage) + { + if (cpu is int cpuAll && memory is int memAll) + return (int)(cpuAll * 0.40 + memAll * 0.30 + storage * 0.30); + + /* integer weights, so no floating-point error can truncate 100 to 99. Storage is always present, so the + weight is never 0. */ + var weighted = storage * 30; + var weight = 30; + if (cpu is int cpuTerm) { weighted += cpuTerm * 40; weight += 40; } + if (memory is int memTerm) { weighted += memTerm * 30; weight += 30; } + return weighted / weight; + } public static string ScoreColor(int score) => score switch { diff --git a/Lite.Tests/AzureSqlDatabaseHostLeftoversTests.cs b/Lite.Tests/AzureSqlDatabaseHostLeftoversTests.cs new file mode 100644 index 000000000..7f6222a1e --- /dev/null +++ b/Lite.Tests/AzureSqlDatabaseHostLeftoversTests.cs @@ -0,0 +1,621 @@ +/* + * 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.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Common; +using PerformanceMonitor.PlanAnalysis; +using PerformanceMonitorLite.Analysis; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// What left: on an Azure SQL Database (engine edition 5) sys.dm_os_sys_info +/// describes the HOST, so the stored server_properties.cpu_count, hyperthread_ratio, socket_count, +/// cores_per_socket and physical_memory_mb are the host's, and so is the max_workers_count that +/// memory_stats copies from the same DMV. Nothing may compute from them there. Where the database has its own figure +/// (vcore_count, parsed from the service objective) that is used; otherwise the answer is not applicable. +/// +/// The four things pinned here, each with an edition 5 case that has vCores, an edition 5 case without them (a DTU +/// objective), and the SQL Server / Managed Instance twin that must not move: +/// +/// the SERVER_HARDWARE analysis fact, the LPIM advisory that reads its memory, and the plan Server Context card; +/// the FinOps Worker Threads card and the worker ceiling the provisioning verdict reads; +/// the memory utilization percentage, which is NOT changed: it divides memory_stats columns, which the collector +/// fills from the database's own committed target, not from server_properties.physical_memory_mb; +/// the FinOps health score's CPU term, which is left out when the window holds no CPU sample (any edition). +/// +/// +/// memory_stats seeds on edition 5 use what the collector stores there: the database's own memory limit +/// (1,838 MB for a 1-vCore General Purpose database), never the host's 911.9 GB. The Darling.Tests twin pins the same table for +/// the other app, in the same words. +/// +public sealed class AzureSqlDatabaseHostLeftoversTests +{ + // ── 1. SERVER_HARDWARE fact, LPIM advisory, Server Context card ── + + [Fact] + public void ServerHardwareFact_OnAzureSqlDatabase_CarriesTheVcoresAndHadrOnly_NotTheHostsTopologyOrMemory() + { + var fact = FactCollectorHelpers.BuildServerHardwareFact( + TestDataSeeder.CreateTestContext(), hardwareIsTheHosts: true, + cpuCount: 1, hyperthreadRatio: 64, physicalMemoryMb: 933_836, socketCount: 0, coresPerSocket: 32, hadrEnabled: false); + + Assert.NotNull(fact); + Assert.Equal("SERVER_HARDWARE", fact!.Key); + Assert.Equal("config", fact.Source); + Assert.Equal(1, fact.Value); + Assert.Equal(new[] { "cpu_count", "hadr_enabled" }, fact.Metadata.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray()); + Assert.Equal(1, fact.Metadata["cpu_count"]); + Assert.Equal(0, fact.Metadata["hadr_enabled"]); + } + + [Fact] + public void ServerHardwareFact_OnAzureSqlDatabaseWithNoVcores_IsNotEmitted() + { + Assert.Null(FactCollectorHelpers.BuildServerHardwareFact( + TestDataSeeder.CreateTestContext(), hardwareIsTheHosts: true, + cpuCount: 0, hyperthreadRatio: 64, physicalMemoryMb: 933_836, socketCount: 0, coresPerSocket: 32, hadrEnabled: false)); + } + + [Fact] + public void ServerHardwareFact_OffAzureSqlDatabase_IsTheStoredTopologyAsItAlwaysWas() + { + var fact = FactCollectorHelpers.BuildServerHardwareFact( + TestDataSeeder.CreateTestContext(), hardwareIsTheHosts: false, + cpuCount: 16, hyperthreadRatio: 2, physicalMemoryMb: 65_536, socketCount: 2, coresPerSocket: 4, hadrEnabled: true); + + Assert.NotNull(fact); + Assert.Equal(16, fact!.Value); + Assert.Equal( + new[] { "cpu_count", "hyperthread_ratio", "physical_memory_mb", "socket_count", "cores_per_socket", "hadr_enabled" }, + fact.Metadata.Keys.ToArray()); + Assert.Equal(16, fact.Metadata["cpu_count"]); + Assert.Equal(2, fact.Metadata["hyperthread_ratio"]); + Assert.Equal(65_536, fact.Metadata["physical_memory_mb"]); + Assert.Equal(2, fact.Metadata["socket_count"]); + Assert.Equal(4, fact.Metadata["cores_per_socket"]); + Assert.Equal(1, fact.Metadata["hadr_enabled"]); + + /* No CPU count at all is no fact, on any edition, as before. */ + Assert.Null(FactCollectorHelpers.BuildServerHardwareFact( + TestDataSeeder.CreateTestContext(), hardwareIsTheHosts: false, + cpuCount: 0, hyperthreadRatio: 2, physicalMemoryMb: 65_536, socketCount: 2, coresPerSocket: 4, hadrEnabled: false)); + } + + [Fact] + public void LpimAdvisory_OnAzureSqlDatabase_IsNotRaisedFromTheHostsMemory_AndIsUnchangedElsewhere() + { + var context = TestDataSeeder.CreateTestContext(); + + var onAzure = new List(); + FactCollectorHelpers.EmitServerHealthFacts( + context, onAzure, "SQL Azure", physicalMemMb: 933_836, + lockPagesInMemory: false, instantFileInit: null, memoryDumpCount: null, hardwareIsTheHosts: true); + Assert.DoesNotContain(onAzure, f => f.Key == "CONFIG_LPIM_DISABLED"); + + var onServer = new List(); + FactCollectorHelpers.EmitServerHealthFacts( + context, onServer, "Enterprise Edition (64-bit)", physicalMemMb: 933_836, + lockPagesInMemory: false, instantFileInit: null, memoryDumpCount: null); + var lpim = Assert.Single(onServer, f => f.Key == "CONFIG_LPIM_DISABLED"); + Assert.Equal(933_836, lpim.Metadata["physical_memory_mb"]); + } + + [Fact] + public void ServerContextCard_ShowsTheVcoresWithNoRam_WhenNoMemoryFigureIsCarried_AndTheRamOtherwise() + { + static string? Hardware(ServerMetadata metadata) => + ServerContextCard.Rows(metadata).SingleOrDefault(r => r.Label == "Hardware").Value; + + Assert.Equal("1 CPUs", Hardware(new ServerMetadata { Edition = "SQL Azure", CpuCount = 1, PhysicalMemoryMB = 0 })); + Assert.Null(Hardware(new ServerMetadata { Edition = "SQL Azure", CpuCount = 0, PhysicalMemoryMB = 0 })); + Assert.Equal( + string.Format(CultureInfo.CurrentCulture, "8 CPUs, {0:N0} MB RAM", 65_536L), + Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", CpuCount = 8, PhysicalMemoryMB = 65_536 })); + } + + // ── 2. Worker Threads card and the worker ceiling ── + + [Theory] + [InlineData(5, 512, 0)] // Azure SQL Database: the ceiling follows the host's CPUs, so none is carried + [InlineData(3, 512, 512)] // SQL Server: as stored + [InlineData(8, 2560, 2560)] // Managed Instance: as stored + [InlineData(1, 512, 512)] + [InlineData(null, 512, 512)] // edition not read: as stored + public void OwnMaxWorkersCount_IsNoneOnAzureSqlDatabase_AndTheStoredCeilingEverywhereElse(int? engineEdition, int stored, int expected) + { + Assert.Equal(expected, ServerHardwareScope.OwnMaxWorkersCount(engineEdition, stored)); + } + + [Fact] + public void OwnPhysicalMemoryMb_IsNotApplicableOnAzureSqlDatabase_AndTheStoredFigureEverywhereElse() + { + Assert.Null(ServerHardwareScope.OwnPhysicalMemoryMb(5, 933_836)); + Assert.Null(ServerHardwareScope.OwnPhysicalMemoryMb(5, null)); + Assert.Equal(65_536L, ServerHardwareScope.OwnPhysicalMemoryMb(3, 65_536)); + Assert.Equal(65_536L, ServerHardwareScope.OwnPhysicalMemoryMb(8, 65_536)); + Assert.Equal(65_536L, ServerHardwareScope.OwnPhysicalMemoryMb(null, 65_536)); + Assert.Null(ServerHardwareScope.OwnPhysicalMemoryMb(3, null)); + } + + [Fact] + public void WorkerThreadsText_IsNotApplicableOnAzureSqlDatabase_AndInUseOverMaximumEverywhereElse() + { + Assert.Equal("n/a", ServerHardwareScope.WorkerThreadsText(5, 0, 512)); + Assert.Equal("n/a", ServerHardwareScope.WorkerThreadsText(5, 450, 512)); + Assert.Equal("n/a", ServerHardwareScope.WorkerThreadsText(5, 0, 0)); + + foreach (var engineEdition in new int?[] { 1, 2, 3, 4, 8, null }) + { + Assert.Equal( + string.Format(CultureInfo.CurrentCulture, "{0:N0} / {1:N0}", 1_200, 2_560), + ServerHardwareScope.WorkerThreadsText(engineEdition, 1_200, 2_560)); + } + } + + // ── 4. Health score: the CPU term ── + + [Fact] + public void Overall_WithNoCpuScore_WeighsMemoryAndStorageOverTheirOwnSixtyPercent() + { + Assert.Equal(100, FinOpsHealthCalculator.Overall(null, 100, 100)); + Assert.Equal(0, FinOpsHealthCalculator.Overall(null, 0, 0)); + Assert.Equal(80, FinOpsHealthCalculator.Overall(null, 60, 100)); + Assert.Equal(60, FinOpsHealthCalculator.Overall(null, 80, 40)); + } + + [Fact] + public void Overall_WithNoCpuScoreAndNoMemoryScore_IsTheStorageScoreAlone() + { + Assert.Equal(100, FinOpsHealthCalculator.Overall(null, null, 100)); + Assert.Equal(0, FinOpsHealthCalculator.Overall(null, null, 0)); + Assert.Equal(37, FinOpsHealthCalculator.Overall(null, null, 37)); + } + + [Fact] + public void Overall_WithEveryTerm_IsTheLongStandingArithmetic() + { + Assert.Equal(86, FinOpsHealthCalculator.Overall(95, 60, 100)); + Assert.Equal(62, FinOpsHealthCalculator.Overall(80, 60, 40)); + Assert.Equal(100, FinOpsHealthCalculator.Overall(100, 100, 100)); + Assert.Equal(0, FinOpsHealthCalculator.Overall(0, 0, 0)); + /* The memory-less form from before is untouched too. */ + Assert.Equal(97, FinOpsHealthCalculator.Overall(95, null, 100)); + } + + /// Buffer pool 10% of physical memory scores 60 and 50% free storage scores 100, so the CPU term is the only one + /// that can tell a measured window from an empty one. + private static UtilizationEfficiencyRow Window(int engineEdition, string provisioningStatus, decimal p95CpuPct) => new() + { + EngineEdition = engineEdition, + ProvisioningStatus = provisioningStatus, + P95CpuPct = p95CpuPct, + PhysicalMemoryMb = 100_000, + BufferPoolMb = 10_000, + FreeSpacePct = 50m, + }; + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(8)] + public void HealthScore_WithNoCpuSample_LeavesTheCpuTermOut_AndDoesNotScoreTheZeroItReadsAs(int engineEdition) + { + /* With the CPU term: 100 * 0.4 + 60 * 0.3 + 100 * 0.3 = 88. Without it: memory and storage over their own 60%. */ + var noSample = Window(engineEdition, provisioningStatus: "", p95CpuPct: 0m); + + Assert.False(noSample.HasCpuSample); + Assert.Equal(80, noSample.ComputeHealthScore()); + Assert.Equal(FinOpsHealthCalculator.Overall(null, 60, 100), noSample.ComputeHealthScore()); + + /* Whatever the P95 field holds, an empty window's CPU is not scored. */ + Assert.Equal(80, Window(engineEdition, "", 95m).ComputeHealthScore()); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(8)] + public void HealthScore_WithACpuSample_IsTheLongStandingScore(int engineEdition) + { + /* 95 * 0.4 + 60 * 0.3 + 100 * 0.3 = 86, whatever verdict the window earned. */ + foreach (var status in new[] { ProvisioningVerdict.RightSized, ProvisioningVerdict.OverProvisioned, ProvisioningVerdict.UnderProvisioned }) + { + var measured = Window(engineEdition, status, 7m); + Assert.True(measured.HasCpuSample); + Assert.Equal(86, measured.ComputeHealthScore()); + } + + /* A genuinely idle measured window (p95 of 0) still scores its 100, which is the point of telling it from no sample. */ + Assert.Equal(88, Window(engineEdition, ProvisioningVerdict.OverProvisioned, 0m).ComputeHealthScore()); + } + + // ── the wiring, pinned at the source ── + + private static string ReadRepoFile(string relativePath, [CallerFilePath] string thisFile = "") + { + var dir = Path.GetDirectoryName(thisFile)!; + var parts = relativePath.Split('/'); + while (dir is not null && !File.Exists(Path.Combine(new[] { dir }.Concat(parts).ToArray()))) + dir = Path.GetDirectoryName(dir); + + Assert.NotNull(dir); + return File.ReadAllText(Path.Combine(new[] { dir! }.Concat(parts).ToArray())); + } + + [Fact] + public void FinOpsTab_AsksTheSharedRules_ForTheWorkerThreadsCard_TheHealthTooltip_AndTheInventoryCpuTerm() + { + var tab = ReadRepoFile("Lite/Controls/FinOpsTab.xaml.cs"); + + Assert.Contains( + "WorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.EngineEdition, data.CurrentWorkersCount, data.MaxWorkersCount);", + tab, StringComparison.Ordinal); + Assert.DoesNotContain("$\"{data.CurrentWorkersCount:N0} / {data.MaxWorkersCount:N0}\"", tab, StringComparison.Ordinal); + + Assert.Contains("if (!data.HasCpuSample)", tab, StringComparison.Ordinal); + Assert.Contains("HealthScoreBorder.ToolTip = ServerHardwareScope.HealthScoreWithoutCpuNote;", tab, StringComparison.Ordinal); + + Assert.Contains( + "int? cpuScore = item.AvgCpuPct is decimal avgCpu ? FinOpsHealthCalculator.CpuScore(avgCpu) : null;", + tab, StringComparison.Ordinal); + Assert.DoesNotContain("item.AvgCpuPct ?? 0m", tab, StringComparison.Ordinal); + } + + [Fact] + public void WorkerCeilingReads_ScopeTheCeilingToTheEdition_InAllThreePlaces() + { + var utilization = ReadRepoFile("Lite/Services/LocalDataService.FinOps.Utilization.cs"); + var fleet = ReadRepoFile("Lite/Services/LocalDataService.FinOps.ServerProperties.cs"); + + Assert.Contains( + "var maxWorkers = ServerHardwareScope.OwnMaxWorkersCount(engineEdition, reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)));", + utilization, StringComparison.Ordinal); + Assert.Contains("CASE WHEN s.engine_edition = 5 THEN 0 ELSE COALESCE(m.max_workers_count, 0) END", utilization, StringComparison.Ordinal); + Assert.Contains("LEFT JOIN server_info s ON true", utilization, StringComparison.Ordinal); + Assert.Contains("CASE WHEN props.engine_edition = 5 THEN NULL ELSE latest.max_workers_count END AS max_workers_count", fleet, StringComparison.Ordinal); + Assert.Contains(") AS props ON true", fleet, StringComparison.Ordinal); + } + + [Fact] + public void FactCollectorAndPlanReaders_AskTheSharedRule_ForTheEditionsHardware() + { + var config = ReadRepoFile("Lite/Analysis/DuckDbFactCollector.Config.cs"); + Assert.Contains("lock_pages_in_memory, instant_file_initialization_enabled, memory_dump_count, engine_edition", config, StringComparison.Ordinal); + Assert.Contains("FactCollectorHelpers.BuildServerHardwareFact(", config, StringComparison.Ordinal); + Assert.Contains("lpim, ifi, dumpCount, hardwareIsTheHosts);", config, StringComparison.Ordinal); + Assert.DoesNotContain("[\"hyperthread_ratio\"] = htRatio", config, StringComparison.Ordinal); + + var planMetadata = ReadRepoFile("Lite/Services/LocalDataService.PlanServerMetadata.cs"); + var drillDown = ReadRepoFile("Lite/Analysis/DrillDownCollector.Plans.cs"); + foreach (var source in new[] { planMetadata, drillDown }) + { + Assert.Contains("ServerHardwareScope.OwnCpuCount(engineEdition, storedCpuCount, vcoreCount) ?? 0", source, StringComparison.Ordinal); + Assert.Contains("ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L", source, StringComparison.Ordinal); + } + } + + [Fact] + public void MemoryUtilization_IsStillComputedFromMemoryStats_NotFromTheHostsPhysicalMemory() + { + var read = ReadRepoFile("Lite/Services/LocalDataService.Memory.cs"); + Assert.Contains("FROM v_memory_stats", read, StringComparison.Ordinal); + Assert.Contains( + "public double MemoryUtilizationPercent => TotalPhysicalMemoryMb > 0 ? UsedPhysicalMemoryMb / TotalPhysicalMemoryMb * 100 : 0;", + read, StringComparison.Ordinal); + + var tool = ReadRepoFile("Lite/Mcp/McpMemoryTools.cs"); + Assert.Contains("memory_utilization_pct = Math.Round(stats.MemoryUtilizationPercent, 1),", tool, StringComparison.Ordinal); + } +} + +/// +/// The seeded half of : real DuckDB reads against rows seeded the way the +/// collectors store them. +/// +public sealed class AzureSqlDatabaseHostLeftoversReadTests : IClassFixture, IDisposable +{ + private const int ServerId = -488_101; + private readonly SharedDuckDbFixture _fixture; + private readonly DateTime _now = DateTime.UtcNow; + private DuckDBConnection? _seedConn; + private long _nextId = -488_101_000; + + public AzureSqlDatabaseHostLeftoversReadTests(SharedDuckDbFixture fixture) + { + fixture.ResetData(); + _fixture = fixture; + } + + public void Dispose() => _seedConn?.Dispose(); + + // ── 1. SERVER_HARDWARE fact, end to end through the collector ── + + private async Task> CollectFactsAsync(int engineEdition, int? vcoreCount) + { + using var seeder = new TestDataSeeder(_fixture.DuckDb); + /* An Azure SQL Database as the collector stores it: the HOST's 2 logical CPUs, hyperthread ratio 64, 0 sockets, 32 + cores per socket and 911.9 GB, plus the service objective's vCores. */ + await seeder.SeedServerPropertiesAsync( + cpuCount: 2, htRatio: 64, physicalMemMb: 933_836, socketCount: 0, coresPerSocket: 32, + edition: "SQL Azure", engineEdition: engineEdition, + serviceObjective: vcoreCount.HasValue ? "GP_S_Gen5_" + vcoreCount : "S0", vcoreCount: vcoreCount); + + var facts = await new DuckDbFactCollector(_fixture.DuckDb).CollectFactsAsync(TestDataSeeder.CreateTestContext()); + return facts.ToDictionary(f => f.Key, f => f); + } + + [Fact] + public async Task Collector_OnAzureSqlDatabaseWithVcores_EmitsTheVcoresAndNoHostTopologyOrMemory() + { + var facts = await CollectFactsAsync(engineEdition: 5, vcoreCount: 1); + + var hardware = Assert.Contains("SERVER_HARDWARE", facts); + Assert.Equal(1, hardware.Value); + Assert.Equal(new[] { "cpu_count", "hadr_enabled" }, hardware.Metadata.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray()); + Assert.DoesNotContain(facts.Values, f => f.Metadata.ContainsKey("physical_memory_mb")); + Assert.DoesNotContain(facts.Values, f => f.Metadata.ContainsKey("cores_per_socket")); + } + + [Fact] + public async Task Collector_OnAzureSqlDatabaseWithNoVcores_EmitsNoServerHardwareFact_AndNothingFromTheHost() + { + var facts = await CollectFactsAsync(engineEdition: 5, vcoreCount: null); + + Assert.DoesNotContain("SERVER_HARDWARE", facts.Keys); + Assert.DoesNotContain(facts.Values, f => f.Metadata.ContainsKey("physical_memory_mb")); + Assert.DoesNotContain(facts.Values, f => f.Metadata.ContainsKey("cores_per_socket")); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(8)] + public async Task Collector_OnEveryOtherEdition_EmitsTheStoredTopologyAsItAlwaysDid(int engineEdition) + { + using var seeder = new TestDataSeeder(_fixture.DuckDb); + await seeder.SeedServerPropertiesAsync( + cpuCount: 16, htRatio: 2, physicalMemMb: 65_536, socketCount: 2, coresPerSocket: 4, engineEdition: engineEdition); + + var facts = await new DuckDbFactCollector(_fixture.DuckDb).CollectFactsAsync(TestDataSeeder.CreateTestContext()); + var hardware = Assert.Single(facts, f => f.Key == "SERVER_HARDWARE"); + + Assert.Equal(16, hardware.Value); + Assert.Equal(2, hardware.Metadata["hyperthread_ratio"]); + Assert.Equal(65_536, hardware.Metadata["physical_memory_mb"]); + Assert.Equal(2, hardware.Metadata["socket_count"]); + Assert.Equal(4, hardware.Metadata["cores_per_socket"]); + Assert.Equal(0, hardware.Metadata["hadr_enabled"]); + } + + // ── 1. plan Server Context metadata: the two DuckDB readers ── + + private async Task SeedConnectionAsync() + { + if (_seedConn is null) + { + _seedConn = _fixture.DuckDb.CreateConnection(); + await _seedConn.OpenAsync(); + } + return _seedConn; + } + + private async Task SeedServerPropertiesAsync(int serverId, int engineEdition, int storedCpuCount, long storedPhysicalMemoryMb, int? vcoreCount) + { + using var readLock = _fixture.DuckDb.AcquireReadLock(); + var conn = await SeedConnectionAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @" +INSERT INTO server_properties + (collection_id, collection_time, server_id, server_name, edition, product_version, product_level, engine_edition, + cpu_count, hyperthread_ratio, physical_memory_mb, socket_count, cores_per_socket, service_objective, vcore_count) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)"; + void P(object? v) => cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); + P(_nextId--); P(_now); P(serverId); P("LeftoversSrv" + serverId); + P(engineEdition == 5 ? "SQL Azure" : "Enterprise Edition (64-bit)"); P("12.0.2000.8"); P("RTM"); + P(engineEdition); P(storedCpuCount); P(64); P(storedPhysicalMemoryMb); P(0); P(32); + P(vcoreCount.HasValue ? "GP_S_Gen5_" + vcoreCount : (engineEdition == 5 ? "S0" : null)); P(vcoreCount); + await cmd.ExecuteNonQueryAsync(); + } + + /// The database's vCores, never the host's CPU count or memory. + public static IEnumerable PlanMetadataCases() => + [ + [5, 2, 933_836L, 1, 1, 0L], // Azure SQL Database with vCores: the vCores, no RAM + [5, 2, 933_836L, null, 0, 0L], // Azure SQL Database, DTU objective: no CPU count, no RAM (the card drops its Hardware row) + [3, 8, 65_536L, null, 8, 65_536L], // SQL Server: as stored + [8, 8, 65_536L, null, 8, 65_536L], // Managed Instance: as stored + ]; + + [Theory] + [MemberData(nameof(PlanMetadataCases))] + public async Task PlanMetadata_LocalDataServiceRead_CarriesTheDatabasesOwnHardware( + int engineEdition, int storedCpuCount, long storedPhysicalMemoryMb, int? vcoreCount, int expectedCpuCount, long expectedPhysicalMemoryMb) + { + await SeedServerPropertiesAsync(ServerId, engineEdition, storedCpuCount, storedPhysicalMemoryMb, vcoreCount); + + var metadata = await new LocalDataService(_fixture.DuckDb).GetServerMetadataForPlanAnalysisAsync(ServerId); + + Assert.NotNull(metadata); + Assert.Equal(expectedCpuCount, metadata!.CpuCount); + Assert.Equal(expectedPhysicalMemoryMb, metadata.PhysicalMemoryMB); + } + + [Theory] + [MemberData(nameof(PlanMetadataCases))] + public async Task PlanMetadata_DrillDownRead_CarriesTheDatabasesOwnHardware( + int engineEdition, int storedCpuCount, long storedPhysicalMemoryMb, int? vcoreCount, int expectedCpuCount, long expectedPhysicalMemoryMb) + { + await SeedServerPropertiesAsync(ServerId, engineEdition, storedCpuCount, storedPhysicalMemoryMb, vcoreCount); + + var read = typeof(DrillDownCollector).GetMethod("ReadServerMetadataForPlanAnalysisAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + var metadata = await (Task)read.Invoke(new DrillDownCollector(_fixture.DuckDb), [ServerId, CancellationToken.None])!; + + Assert.NotNull(metadata); + Assert.Equal(expectedCpuCount, metadata!.CpuCount); + Assert.Equal(expectedPhysicalMemoryMb, metadata.PhysicalMemoryMB); + } + + // ── 2. worker ceiling: point-in-time read, 7-day trend, fleet read ── + + /// One server with a measured CPU window (average and p95 50%, so the verdict is RIGHT_SIZED unless something else + /// decides it) and the memory_stats the collector stores: on an Azure SQL Database the database's own memory limit + /// (1,838 MB for a 1-vCore General Purpose database), a ceiling of 512 workers copied from the host-derived DMV, and NO + /// in-use count (the collector stores NULL there). + private async Task SeedServerWithMemoryAsync(int serverId, int engineEdition, int? vcoreCount, int? currentWorkers) + { + await SeedServerPropertiesAsync(serverId, engineEdition, storedCpuCount: 2, storedPhysicalMemoryMb: engineEdition == 5 ? 933_836 : 65_536, vcoreCount); + + using var readLock = _fixture.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, $4, $2, 50, 2)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); + cmd.Parameters.Add(new DuckDBParameter { Value = _now }); + cmd.Parameters.Add(new DuckDBParameter { Value = serverId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "LeftoversSrv" + serverId }); + await cmd.ExecuteNonQueryAsync(); + } + + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = @" +INSERT INTO memory_stats + (collection_id, collection_time, server_id, server_name, total_physical_memory_mb, available_physical_memory_mb, + target_server_memory_mb, total_server_memory_mb, buffer_pool_mb, max_workers_count, current_workers_count) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 512, $10)"; + void P(object? v) => cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); + var physical = engineEdition == 5 ? 1_838d : 65_536d; + P(_nextId--); P(_now); P(serverId); P("LeftoversSrv" + serverId); + P(physical); P(physical / 2); P(physical * 0.9); P(physical * 0.8); P(physical * 0.5); P(currentWorkers); + await cmd.ExecuteNonQueryAsync(); + } + } + + /// (edition, vCores, in-use workers stored, expected ceiling, expected verdict). + public static IEnumerable WorkerCeilingCases() => + [ + [5, 1, null, 0, "RIGHT_SIZED"], // Azure SQL Database with vCores, as stored: no in-use count, no ceiling + [5, null, null, 0, "RIGHT_SIZED"], // Azure SQL Database, DTU objective + [5, 1, 450, 0, "RIGHT_SIZED"], // even an in-use count against the host-derived 512 is not saturation + [3, null, 450, 512, "UNDER_PROVISIONED"], // SQL Server: 450 of 512 is worker saturation, as it always was + [8, null, 450, 512, "UNDER_PROVISIONED"], // Managed Instance: the same + [3, null, 40, 512, "RIGHT_SIZED"], + ]; + + [Theory] + [MemberData(nameof(WorkerCeilingCases))] + public async Task UtilizationRead_CarriesNoWorkerCeilingOnAzureSqlDatabase_AndNeverCallsItSaturated( + int engineEdition, int? vcoreCount, int? currentWorkers, int expectedCeiling, string expectedVerdict) + { + await SeedServerWithMemoryAsync(ServerId, engineEdition, vcoreCount, currentWorkers); + + var row = await new LocalDataService(_fixture.DuckDb).GetUtilizationEfficiencyAsync(ServerId); + + Assert.NotNull(row); + Assert.Equal(engineEdition, row!.EngineEdition); + Assert.Equal(expectedCeiling, row.MaxWorkersCount); + Assert.Equal(currentWorkers ?? 0, row.CurrentWorkersCount); + Assert.Equal(expectedVerdict, row.ProvisioningStatus); + Assert.Equal( + engineEdition == 5 ? "n/a" : string.Format(CultureInfo.CurrentCulture, "{0:N0} / {1:N0}", currentWorkers ?? 0, 512), + ServerHardwareScope.WorkerThreadsText(row.EngineEdition, row.CurrentWorkersCount, row.MaxWorkersCount)); + } + + [Theory] + [MemberData(nameof(WorkerCeilingCases))] + public async Task TrendRead_CarriesNoWorkerCeilingOnAzureSqlDatabase_AndNeverCallsADaySaturated( + int engineEdition, int? vcoreCount, int? currentWorkers, int expectedCeiling, string expectedVerdict) + { + _ = expectedCeiling; + await SeedServerWithMemoryAsync(ServerId, engineEdition, vcoreCount, currentWorkers); + + var days = await new LocalDataService(_fixture.DuckDb).GetProvisioningTrendAsync(ServerId); + + var day = Assert.Single(days); + Assert.Equal(expectedVerdict, day.Status); + } + + [Fact] + public async Task FleetRead_CarriesNoWorkerCeilingOnAzureSqlDatabase_AndNeverCallsAServerSaturated() + { + const int azureVcores = ServerId - 1; + const int azureDtu = ServerId - 2; + const int sqlServer = ServerId - 3; + const int managedInstance = ServerId - 4; + await SeedServerWithMemoryAsync(azureVcores, engineEdition: 5, vcoreCount: 1, currentWorkers: 450); + await SeedServerWithMemoryAsync(azureDtu, engineEdition: 5, vcoreCount: null, currentWorkers: 450); + await SeedServerWithMemoryAsync(sqlServer, engineEdition: 3, vcoreCount: null, currentWorkers: 450); + await SeedServerWithMemoryAsync(managedInstance, engineEdition: 8, vcoreCount: null, currentWorkers: 450); + + var metrics = await new LocalDataService(_fixture.DuckDb).GetServerMetricsAsync(); + + Assert.Equal("RIGHT_SIZED", metrics[azureVcores].ProvisioningStatus); + Assert.Equal("RIGHT_SIZED", metrics[azureDtu].ProvisioningStatus); + Assert.Equal("UNDER_PROVISIONED", metrics[sqlServer].ProvisioningStatus); + Assert.Equal("UNDER_PROVISIONED", metrics[managedInstance].ProvisioningStatus); + } + + // ── 3. memory utilization: unchanged, it is memory_stats ── + + [Theory] + [InlineData(5, 1_838, 1_000)] // Azure SQL Database: the database's own memory limit, as the collector stores it + [InlineData(3, 65_536, 16_384)] // SQL Server + [InlineData(8, 65_536, 16_384)] // Managed Instance + public async Task MemoryUtilization_IsComputedFromMemoryStats_OnEveryEdition_AndNeverFromTheHostsPhysicalMemory( + int engineEdition, double totalMb, double availableMb) + { + /* server_properties carries the HOST's 911.9 GB on an Azure SQL Database. Were the percentage divided by it, the + figure would be ~100%; it is divided by the memory_stats total, so it is what it always was. */ + await SeedServerPropertiesAsync(ServerId, engineEdition, storedCpuCount: 2, storedPhysicalMemoryMb: 933_836, vcoreCount: engineEdition == 5 ? 1 : null); + + using (var readLock = _fixture.DuckDb.AcquireReadLock()) + { + var conn = await SeedConnectionAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @" +INSERT INTO memory_stats + (collection_id, collection_time, server_id, server_name, total_physical_memory_mb, available_physical_memory_mb, + target_server_memory_mb, total_server_memory_mb, buffer_pool_mb) +VALUES ($1, $2, $3, $4, $5, $6, $5, $5, $6)"; + void P(object? v) => cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); + P(_nextId--); P(_now); P(ServerId); P("LeftoversSrv"); P(totalMb); P(availableMb); + await cmd.ExecuteNonQueryAsync(); + } + + var stats = await new LocalDataService(_fixture.DuckDb).GetLatestMemoryStatsAsync(ServerId); + + Assert.NotNull(stats); + Assert.Equal(totalMb, stats!.TotalPhysicalMemoryMb); + Assert.Equal((totalMb - availableMb) / totalMb * 100, stats.MemoryUtilizationPercent, precision: 6); + Assert.InRange(stats.MemoryUtilizationPercent, 1, 99); + } +} diff --git a/Lite.Tests/AzureSqlDatabaseHostMathTests.cs b/Lite.Tests/AzureSqlDatabaseHostMathTests.cs index 089cb689b..e78141053 100644 --- a/Lite.Tests/AzureSqlDatabaseHostMathTests.cs +++ b/Lite.Tests/AzureSqlDatabaseHostMathTests.cs @@ -175,6 +175,7 @@ public async Task UtilizationRead_ResolvesTheCpuCountThroughTheEdition(int engin private static UtilizationEfficiencyRow Utilization(int engineEdition, int bufferPoolMb, int physicalMemoryMb) => new() { EngineEdition = engineEdition, + ProvisioningStatus = ProvisioningVerdict.RightSized, // a measured window: a window with no CPU sample has no CPU term P95CpuPct = 7m, BufferPoolMb = bufferPoolMb, PhysicalMemoryMb = physicalMemoryMb, diff --git a/Lite/Analysis/DrillDownCollector.Plans.cs b/Lite/Analysis/DrillDownCollector.Plans.cs index a0554cd5f..58a58bdb0 100644 --- a/Lite/Analysis/DrillDownCollector.Plans.cs +++ b/Lite/Analysis/DrillDownCollector.Plans.cs @@ -152,7 +152,9 @@ FROM v_server_config AS c WHERE c.server_id = $1 AND c.configuration_name = 'max degree of parallelism' AND c.capture_time = (SELECT MAX(capture_time) FROM v_server_config WHERE server_id = $1) - LIMIT 1) AS max_dop + LIMIT 1) AS max_dop, + p.engine_edition, + p.vcore_count FROM v_server_properties AS p WHERE p.server_id = $1 ORDER BY p.collection_time DESC @@ -162,13 +164,20 @@ ORDER BY p.collection_time DESC using var reader = await cmd.ExecuteReaderAsync(cancellationToken); if (!await reader.ReadAsync(cancellationToken)) return null; + /* Same rule as LocalDataService.GetServerMetadataForPlanAnalysisAsync: an Azure SQL Database's stored cpu_count and + physical_memory_mb are the HOST's, so the drill-down's server context carries the vCores and no RAM figure. */ + int? engineEdition = reader.IsDBNull(6) ? null : Convert.ToInt32(reader.GetValue(6)); + int? vcoreCount = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)); + int? storedCpuCount = reader.IsDBNull(3) ? null : reader.GetInt32(3); + long? storedPhysicalMemoryMb = reader.IsDBNull(4) ? null : Convert.ToInt64(reader.GetValue(4)); + return new PerformanceMonitor.PlanAnalysis.ServerMetadata { Edition = reader.IsDBNull(0) ? null : reader.GetString(0), ProductVersion = reader.IsDBNull(1) ? null : reader.GetString(1), ProductLevel = reader.IsDBNull(2) ? null : reader.GetString(2), - CpuCount = reader.IsDBNull(3) ? 0 : reader.GetInt32(3), - PhysicalMemoryMB = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)), + CpuCount = ServerHardwareScope.OwnCpuCount(engineEdition, storedCpuCount, vcoreCount) ?? 0, + PhysicalMemoryMB = ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L, MaxDop = reader.IsDBNull(5) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(5))), }; } diff --git a/Lite/Analysis/DuckDbFactCollector.Config.cs b/Lite/Analysis/DuckDbFactCollector.Config.cs index 518aa0872..d87d30e12 100644 --- a/Lite/Analysis/DuckDbFactCollector.Config.cs +++ b/Lite/Analysis/DuckDbFactCollector.Config.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using DuckDB.NET.Data; using PerformanceMonitor.Analysis; +using PerformanceMonitor.Common; using PerformanceMonitor.PlanAnalysis; using PerformanceMonitorLite.Database; @@ -324,10 +325,15 @@ private async Task CollectServerPropertiesFactsAsync(AnalysisContext context, Li await connection.OpenAsync(context.CancellationToken); using var cmd = connection.CreateCommand(); + /* cpu_count is the count the server itself has. On an Azure SQL Database (engine_edition 5) the stored + cpu_count, hyperthread_ratio, physical_memory_mb, socket_count and cores_per_socket describe the HOST, so + there the count is the vcore_count parsed from the service objective (NULL for a DTU objective, which + leaves no fact) and FactCollectorHelpers.BuildServerHardwareFact carries none of the host's topology or + memory. Every other edition reads as it always did. The same CASE is in Darling's PgFactCollector. */ cmd.CommandText = @" -SELECT COALESCE(vcore_count, cpu_count) AS cpu_count, hyperthread_ratio, physical_memory_mb, +SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, hyperthread_ratio, physical_memory_mb, socket_count, cores_per_socket, is_hadr_enabled, edition, product_version, - lock_pages_in_memory, instant_file_initialization_enabled, memory_dump_count + lock_pages_in_memory, instant_file_initialization_enabled, memory_dump_count, engine_edition FROM v_server_properties WHERE server_id = $1 ORDER BY collection_time DESC @@ -348,30 +354,19 @@ ORDER BY collection_time DESC bool? lpim = reader.IsDBNull(8) ? (bool?)null : Convert.ToBoolean(reader.GetValue(8)); bool? ifi = reader.IsDBNull(9) ? (bool?)null : Convert.ToBoolean(reader.GetValue(9)); int? dumpCount = reader.IsDBNull(10) ? (int?)null : Convert.ToInt32(reader.GetValue(10)); + int? engineEdition = reader.IsDBNull(11) ? (int?)null : Convert.ToInt32(reader.GetValue(11)); + var hardwareIsTheHosts = ServerHardwareScope.HardwareIsTheHosts(engineEdition); - if (cpuCount == 0) return; + var hardwareFact = FactCollectorHelpers.BuildServerHardwareFact( + context, hardwareIsTheHosts, cpuCount, htRatio, physicalMemMb, socketCount, coresPerSocket, hadrEnabled); + if (hardwareFact is null) return; - facts.Add(new Fact - { - Source = "config", - Key = "SERVER_HARDWARE", - Value = cpuCount, - ServerId = context.ServerId, - Metadata = new Dictionary - { - ["cpu_count"] = cpuCount, - ["hyperthread_ratio"] = htRatio, - ["physical_memory_mb"] = physicalMemMb, - ["socket_count"] = socketCount, - ["cores_per_socket"] = coresPerSocket, - ["hadr_enabled"] = hadrEnabled ? 1 : 0 - } - }); + facts.Add(hardwareFact); // WS5 server-health advisories (advise-only). Gating mirrors the Dashboard collector so // both apps agree on what is worth flagging; a fact that would score 0 is simply never // emitted (noise control). - FactCollectorHelpers.EmitServerHealthFacts(context, facts, edition, physicalMemMb, lpim, ifi, dumpCount); + FactCollectorHelpers.EmitServerHealthFacts(context, facts, edition, physicalMemMb, lpim, ifi, dumpCount, hardwareIsTheHosts); } catch (Exception ex) when (!AnalysisAbandon.IsExpected(ex, context.CancellationToken)) { diff --git a/Lite/Controls/FinOpsTab.xaml.cs b/Lite/Controls/FinOpsTab.xaml.cs index b5fe28e40..82ad05c77 100644 --- a/Lite/Controls/FinOpsTab.xaml.cs +++ b/Lite/Controls/FinOpsTab.xaml.cs @@ -404,7 +404,8 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) CpuSamplesText.Text = data.CpuSamples.ToString("N0"); /* n/a on an Azure SQL Database whose service objective names no vCores: the host's count is never shown as the database's. */ CpuCountText.Text = ServerHardwareScope.CpuCountText(data.EngineEdition, data.CpuCount); - WorkerThreadsText.Text = $"{data.CurrentWorkersCount:N0} / {data.MaxWorkersCount:N0}"; + /* n/a on an Azure SQL Database: the worker ceiling follows the host's CPUs and the in-use count is not collected there. */ + WorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.EngineEdition, data.CurrentWorkersCount, data.MaxWorkersCount); SetBar(AvgCpuBar, AvgCpuFilled, AvgCpuEmpty, (double)data.AvgCpuPct); SetBar(P95CpuBar, P95CpuFilled, P95CpuEmpty, (double)data.P95CpuPct); @@ -469,6 +470,9 @@ pressure or worker saturation would have been explained as a memory ratio that n the score and the tooltip says so. */ data.HealthScore = data.ComputeHealthScore(); HealthScoreBorder.ToolTip = azureSqlDb ? ServerHardwareScope.HealthScoreWithoutMemoryNote : null; + /* A window with no CPU sample has no CPU term either (ComputeHealthScore leaves it out), and the tooltip says so. */ + if (!data.HasCpuSample) + HealthScoreBorder.ToolTip = ServerHardwareScope.HealthScoreWithoutCpuNote; HealthScoreText.Text = $"Health: {data.HealthScore}"; HealthScoreBorder.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(data.HealthScoreColor)); HealthScoreBorder.Visibility = Visibility.Visible; @@ -737,7 +741,9 @@ private async System.Threading.Tasks.Task LoadServerInventoryAsync(bool forceRef // Compute health scores for each server foreach (var item in data) { - var cpuScore = FinOpsHealthCalculator.CpuScore(item.AvgCpuPct ?? 0m); + /* A server with no CPU sample in the window has a null average: its CPU term is left out, because scoring it + as 0% CPU would hand it a full 100 made from nothing. */ + int? cpuScore = item.AvgCpuPct is decimal avgCpu ? FinOpsHealthCalculator.CpuScore(avgCpu) : null; var memScore = 80; // Default — we don't have buffer pool ratio in inventory var storScore = FinOpsHealthCalculator.StorageScore(50); // Default — no file-level free space in inventory item.HealthScore = FinOpsHealthCalculator.Overall(cpuScore, memScore, storScore); diff --git a/Lite/Services/LocalDataService.FinOps.ServerProperties.cs b/Lite/Services/LocalDataService.FinOps.ServerProperties.cs index c18a35348..40e2cf39d 100644 --- a/Lite/Services/LocalDataService.FinOps.ServerProperties.cs +++ b/Lite/Services/LocalDataService.FinOps.ServerProperties.cs @@ -234,11 +234,13 @@ FROM v_cpu_utilization_stats GROUP BY server_id ), /* Only the worker counts are consumed now: memory_ratio used to feed this read's own CASE, and that - CASE was the #2246 bug. The verdict comes from ProvisioningVerdict, so the division would be dead. */ + CASE was the #2246 bug. The verdict comes from ProvisioningVerdict, so the division would be dead. + max_workers_count is the HOST-derived ceiling on an Azure SQL Database (engine_edition 5), so there it is NULL (no + ceiling, which the verdict reads as unknown); every other edition keeps the stored figure. */ mem_latest AS ( SELECT s.server_id, - latest.max_workers_count, + CASE WHEN props.engine_edition = 5 THEN NULL ELSE latest.max_workers_count END AS max_workers_count, latest.current_workers_count FROM known_servers s LEFT JOIN LATERAL ( @@ -248,6 +250,13 @@ FROM v_memory_stats ORDER BY collection_time DESC LIMIT 1 ) AS latest ON true + LEFT JOIN LATERAL ( + SELECT engine_edition + FROM v_server_properties + WHERE server_id = s.server_id + ORDER BY collection_time DESC + LIMIT 1 + ) AS props ON true ), /* Same workspace-memory pressure signals as the drill-down read, so the INVENTORY GRID cannot classify a server by a rule the drill-down no longer uses (#2246 — this grid is the screen the field report was diff --git a/Lite/Services/LocalDataService.FinOps.Utilization.cs b/Lite/Services/LocalDataService.FinOps.Utilization.cs index 0d337b567..46297c972 100644 --- a/Lite/Services/LocalDataService.FinOps.Utilization.cs +++ b/Lite/Services/LocalDataService.FinOps.Utilization.cs @@ -111,7 +111,12 @@ LEFT JOIN server_info s ON true var p95Cpu = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)); var memRatio = reader.IsDBNull(8) ? 0m : Convert.ToDecimal(reader.GetValue(8)); - var maxWorkers = reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)); + /* max_workers_count is read from sys.dm_os_sys_info, which derives it from the CPU count that DMV reports. On an Azure + SQL Database that count is the HOST's, so there no ceiling is carried: 0 reads as unknown in the verdict (never as + saturation) and the Worker Threads card reads n/a. Every other edition reads as it always did. The same scoping is + in the trend read and the fleet read, and in Darling's three. */ + int? engineEdition = reader.IsDBNull(16) ? null : Convert.ToInt32(reader.GetValue(16)); + var maxWorkers = ServerHardwareScope.OwnMaxWorkersCount(engineEdition, reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9))); var currentWorkers = reader.IsDBNull(10) ? 0 : Convert.ToInt32(reader.GetValue(10)); /* memory_ratio is still SELECTed and still displayed — it is a real fact about the instance — but it @@ -198,6 +203,15 @@ FROM v_memory_grant_stats WHERE server_id = $1 AND collection_time >= $2 GROUP BY CAST(collection_time AS DATE) +), +/* max_workers_count is the HOST-derived ceiling on an Azure SQL Database (engine_edition 5), so the days of one carry no + ceiling: 0, which the verdict reads as unknown. Same rule as the point-in-time read above. */ +server_info AS ( + SELECT engine_edition + FROM v_server_properties + WHERE server_id = $1 + ORDER BY collection_time DESC + LIMIT 1 ) SELECT c.day, @@ -209,11 +223,12 @@ GROUP BY CAST(collection_time AS DATE) COALESCE(g.grant_timeouts, 0), COALESCE(g.forced_grants, 0), COALESCE(g.grant_utilization_pct, 0), - COALESCE(m.max_workers_count, 0), + CASE WHEN s.engine_edition = 5 THEN 0 ELSE COALESCE(m.max_workers_count, 0) END, COALESCE(m.current_workers_count, 0) FROM daily_cpu c LEFT JOIN daily_mem m ON m.day = c.day LEFT JOIN daily_grants g ON g.day = c.day +LEFT JOIN server_info s ON true ORDER BY c.day"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); diff --git a/Lite/Services/LocalDataService.FinOps.cs b/Lite/Services/LocalDataService.FinOps.cs index 7b668ec4e..b8892e6d3 100644 --- a/Lite/Services/LocalDataService.FinOps.cs +++ b/Lite/Services/LocalDataService.FinOps.cs @@ -129,15 +129,16 @@ public class UtilizationEfficiencyRow /// /// The health score for these figures: CPU p95, the buffer pool's share of physical memory, and free storage. On an /// Azure SQL Database ( 5) physical memory is the HOST's, so the memory term is left out, not - /// scored as zero and not scored as a default, and CPU and storage carry the whole score. Anywhere else the score is - /// what it always was. + /// scored as zero and not scored as a default, and CPU and storage carry the whole score. A window with no CPU sample + /// ( false) leaves the CPU term out the same way: its p95 is a 0 that came from nothing, and + /// scoring that 0 would hand the server a full 100. Anywhere else the score is what it always was. /// public int ComputeHealthScore() { var bpRatio = PhysicalMemoryMb > 0 ? (decimal)BufferPoolMb / PhysicalMemoryMb : 0m; int? memScore = ServerHardwareScope.HardwareIsTheHosts(EngineEdition) ? null : FinOpsHealthCalculator.MemoryScore(bpRatio); - return FinOpsHealthCalculator.Overall( - FinOpsHealthCalculator.CpuScore(P95CpuPct), memScore, FinOpsHealthCalculator.StorageScore(FreeSpacePct)); + int? cpuScore = HasCpuSample ? FinOpsHealthCalculator.CpuScore(P95CpuPct) : null; + return FinOpsHealthCalculator.Overall(cpuScore, memScore, FinOpsHealthCalculator.StorageScore(FreeSpacePct)); } } @@ -421,14 +422,26 @@ public static int StorageScore(decimal freeSpacePct) } /// - /// The overall score: CPU 40%, memory 30%, storage 30%. A null means memory is not - /// applicable (an Azure SQL Database's physical memory is the host's), so the term is left out, not scored as zero and - /// not scored as a default: CPU and storage keep their 40:30 weights over their own 70%. + /// The overall score: CPU 40%, memory 30%, storage 30%. A null means the window held no CPU + /// sample (there is nothing to score, and scoring the 0 it reads as would be a full 100 made from nothing). A null + /// means memory is not applicable (an Azure SQL Database's physical memory is the host's). + /// Either way the term is left out, not scored as zero and not scored as a default, and the terms that remain keep + /// their weights over their own total: CPU and storage 40:30 over 70, memory and storage 30:30 over 60, storage alone + /// over 30. /// - public static int Overall(int cpu, int? memory, int storage) => - memory is int mem - ? (int)(cpu * 0.40 + mem * 0.30 + storage * 0.30) - : (cpu * 40 + storage * 30) / 70; /* integer weights, so no floating-point error can truncate 100 to 99 */ + public static int Overall(int? cpu, int? memory, int storage) + { + if (cpu is int cpuAll && memory is int memAll) + return (int)(cpuAll * 0.40 + memAll * 0.30 + storage * 0.30); + + /* integer weights, so no floating-point error can truncate 100 to 99. Storage is always present, so the + weight is never 0. */ + var weighted = storage * 30; + var weight = 30; + if (cpu is int cpuTerm) { weighted += cpuTerm * 40; weight += 40; } + if (memory is int memTerm) { weighted += memTerm * 30; weight += 30; } + return weighted / weight; + } public static string ScoreColor(int score) => score switch { diff --git a/Lite/Services/LocalDataService.PlanServerMetadata.cs b/Lite/Services/LocalDataService.PlanServerMetadata.cs index 909bb83cc..5f7ae0cf5 100644 --- a/Lite/Services/LocalDataService.PlanServerMetadata.cs +++ b/Lite/Services/LocalDataService.PlanServerMetadata.cs @@ -9,6 +9,7 @@ using System; using System.Threading.Tasks; using DuckDB.NET.Data; +using PerformanceMonitor.Common; using PerformanceMonitor.PlanAnalysis; namespace PerformanceMonitorLite.Services; @@ -92,7 +93,9 @@ FROM v_database_config AS d WHERE d.server_id = $1 AND d.database_name = $2 AND d.capture_time = (SELECT MAX(capture_time) FROM v_database_config WHERE server_id = $1 AND database_name = $2) - LIMIT 1) AS is_parameterization_forced + LIMIT 1) AS is_parameterization_forced, + p.engine_edition, + p.vcore_count FROM v_server_properties AS p WHERE p.server_id = $1 ORDER BY p.collection_time DESC @@ -119,13 +122,21 @@ ORDER BY p.collection_time DESC using var reader = await command.ExecuteReaderAsync(); if (!await reader.ReadAsync()) return null; + /* On an Azure SQL Database the stored cpu_count and physical_memory_mb are the HOST's, so the Server Context + card's Hardware row carries the database's vCores (none for a DTU objective, which drops the row) and no + RAM figure. Every other edition reads as it always did. */ + int? engineEdition = reader.IsDBNull(16) ? null : Convert.ToInt32(reader.GetValue(16)); + int? vcoreCount = reader.IsDBNull(17) ? null : Convert.ToInt32(reader.GetValue(17)); + int? storedCpuCount = reader.IsDBNull(3) ? null : reader.GetInt32(3); + long? storedPhysicalMemoryMb = reader.IsDBNull(4) ? null : ToInt64(reader.GetValue(4)); + return new ServerMetadata { Edition = reader.IsDBNull(0) ? null : reader.GetString(0), ProductVersion = reader.IsDBNull(1) ? null : reader.GetString(1), ProductLevel = reader.IsDBNull(2) ? null : reader.GetString(2), - CpuCount = reader.IsDBNull(3) ? 0 : reader.GetInt32(3), - PhysicalMemoryMB = reader.IsDBNull(4) ? 0L : ToInt64(reader.GetValue(4)), + CpuCount = ServerHardwareScope.OwnCpuCount(engineEdition, storedCpuCount, vcoreCount) ?? 0, + PhysicalMemoryMB = ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L, MaxDop = reader.IsDBNull(5) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(5))), CostThresholdForParallelism = reader.IsDBNull(6) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(6))), MaxServerMemoryMB = reader.IsDBNull(7) ? 0L : ToInt64(reader.GetValue(7)), diff --git a/PerformanceMonitor.Analysis/FactCollectorHelpers.cs b/PerformanceMonitor.Analysis/FactCollectorHelpers.cs index dc75e9852..2ed6ffab6 100644 --- a/PerformanceMonitor.Analysis/FactCollectorHelpers.cs +++ b/PerformanceMonitor.Analysis/FactCollectorHelpers.cs @@ -21,6 +21,46 @@ namespace PerformanceMonitor.Analysis; /// public static class FactCollectorHelpers { + /// + /// Builds the SERVER_HARDWARE fact from the newest server_properties row, or returns null when there is no CPU count to + /// carry (no fact at all: every reader of it takes it by key and treats an absent fact as "not collected"). + /// + /// Off an Azure SQL Database the fact is what it always was. ON one (), the stored + /// cpu_count, hyperthread_ratio, physical_memory_mb, socket_count and cores_per_socket describe + /// the HOST, not what the database is given (a 1-vCore serverless database read 2 CPUs and 911.9 GB), so the fact carries only + /// the database's own CPU count: is then the vcore_count parsed from the service objective, + /// and it is 0 for an objective that names none (a DTU model), which leaves no fact. Its metadata holds no host topology and no + /// host memory, so nothing that reads the fact (the MAXDOP advice, the config audit, the LPIM advisory) can compute from them. + /// + /// Both apps call this, so the two collectors cannot drift in what the fact carries. + /// + public static Fact? BuildServerHardwareFact( + AnalysisContext context, bool hardwareIsTheHosts, int cpuCount, int hyperthreadRatio, long physicalMemoryMb, + int socketCount, int coresPerSocket, bool hadrEnabled) + { + if (cpuCount == 0) + return null; + + var metadata = new Dictionary { ["cpu_count"] = cpuCount }; + if (!hardwareIsTheHosts) + { + metadata["hyperthread_ratio"] = hyperthreadRatio; + metadata["physical_memory_mb"] = physicalMemoryMb; + metadata["socket_count"] = socketCount; + metadata["cores_per_socket"] = coresPerSocket; + } + metadata["hadr_enabled"] = hadrEnabled ? 1 : 0; + + return new Fact + { + Source = "config", + Key = "SERVER_HARDWARE", + Value = cpuCount, + ServerId = context.ServerId, + Metadata = metadata + }; + } + /// /// RAM floor below which LPIM-off is not worth flagging — on a small buffer pool the OS paging /// SQL out is not the practical risk it is on a large dedicated host. @@ -32,12 +72,16 @@ public static class FactCollectorHelpers /// latest server_properties values, applying the noise-control gating both apps share: /// • IFI: emit whenever the value is known (Value = enabled bit) — universally good advice. /// • LPIM: emit only on non-Express editions with meaningful RAM (Value = enabled bit) — so a - /// tiny instance never flags. When LPIM is ON the emitted Value scores 0 (harmless). + /// tiny instance never flags. When LPIM is ON the emitted Value scores 0 (harmless). The RAM + /// gate reads server_properties.physical_memory_mb, which on an Azure SQL Database is the + /// HOST's, so leaves the advisory out there: the gate has no + /// figure of the database's own to read. /// • Dumps: emit whenever the count is known (Value = count) — the scorer flags count > 0. /// public static void EmitServerHealthFacts( AnalysisContext context, List facts, string edition, long physicalMemMb, - bool? lockPagesInMemory, bool? instantFileInit, int? memoryDumpCount) + bool? lockPagesInMemory, bool? instantFileInit, int? memoryDumpCount, + bool hardwareIsTheHosts = false) { var isExpress = edition.Contains("Express", StringComparison.OrdinalIgnoreCase); @@ -56,7 +100,7 @@ public static void EmitServerHealthFacts( }); } - if (lockPagesInMemory.HasValue && !isExpress && physicalMemMb >= LpimAdvisoryMinPhysicalMemoryMb) + if (lockPagesInMemory.HasValue && !isExpress && !hardwareIsTheHosts && physicalMemMb >= LpimAdvisoryMinPhysicalMemoryMb) { facts.Add(new Fact { diff --git a/PerformanceMonitor.Common/ServerHardwareScope.cs b/PerformanceMonitor.Common/ServerHardwareScope.cs index 5cffee5fe..9cda27cb1 100644 --- a/PerformanceMonitor.Common/ServerHardwareScope.cs +++ b/PerformanceMonitor.Common/ServerHardwareScope.cs @@ -122,4 +122,42 @@ public static string OverProvisionedExplanation(decimal avgCpuPct, int maxCpuPct azureSqlDatabase ? string.Create(CultureInfo.CurrentCulture, $"CPU is lightly loaded (avg {avgCpuPct:N1}%, max {maxCpuPct}%). This database may have more resources than it needs.") : string.Create(CultureInfo.CurrentCulture, $"CPU is lightly loaded (avg {avgCpuPct:N1}%, max {maxCpuPct}%) and buffer pool uses only {bufferPoolPctOfPhysical:N0}% of physical RAM. This server may have more resources than it needs."); + + /// + /// The physical memory (server_properties.physical_memory_mb) a calculation may treat as the server's OWN. Off an + /// Azure SQL Database it is the stored figure, as it always was. On one the stored figure is the HOST's and no + /// database-scoped figure is collected to stand in for it, so the answer is null (not applicable). This is about the + /// server_properties column only: the memory_stats memory figures come from the engine's own memory counters and + /// its committed target, which describe the database, and they are not scoped here. + /// + public static long? OwnPhysicalMemoryMb(int? engineEdition, long? physicalMemoryMb) => + HardwareIsTheHosts(engineEdition) ? null : physicalMemoryMb; + + /// + /// The worker-thread maximum a calculation may treat as the server's OWN. The stored figure is + /// sys.dm_os_sys_info.max_workers_count, which the engine derives from the same DMV's CPU count, so on an Azure SQL + /// Database it follows the host's CPUs and is not what the database is given. No database-scoped maximum is collected, so + /// there the answer is 0, which the provisioning verdict already reads as "unknown" and which never implies saturation. + /// Anywhere else it is the stored figure. + /// + public static int OwnMaxWorkersCount(int? engineEdition, int maxWorkersCount) => + HardwareIsTheHosts(engineEdition) ? 0 : maxWorkersCount; + + /// + /// The FinOps utilization card's worker-thread text. Off an Azure SQL Database it is "in use / maximum", as it always was. On + /// one the maximum is not the database's and the in-use count is not collected (current_workers_count is NULL there), so + /// the card reads instead of a count against a host-derived maximum. + /// + public static string WorkerThreadsText(int? engineEdition, int currentWorkers, int maxWorkers) => + HardwareIsTheHosts(engineEdition) + ? NotApplicable + : string.Create(CultureInfo.CurrentCulture, $"{currentWorkers:N0} / {maxWorkers:N0}"); + + /// + /// The tooltip on the FinOps health score when it has no CPU term: the 24-hour window held no CPU sample, so CPU is left + /// out, not scored as zero and not scored as a default. It is not specific to an Azure SQL Database, so it lives beside the + /// memory note and both apps read the same words. + /// + public const string HealthScoreWithoutCpuNote = + "CPU is not part of this score: the last 24 hours hold no CPU sample."; } diff --git a/PerformanceMonitor.PlanAnalysis/ServerContextCard.cs b/PerformanceMonitor.PlanAnalysis/ServerContextCard.cs index 12587f8b7..3b16859f0 100644 --- a/PerformanceMonitor.PlanAnalysis/ServerContextCard.cs +++ b/PerformanceMonitor.PlanAnalysis/ServerContextCard.cs @@ -57,9 +57,13 @@ public static List Rows(ServerMetadata? metadata) serverLine += $", {metadata.ProductVersion}"; rows.Add(new ServerContextRow("Server", serverLine)); - // Hardware — dropped entirely when CpuCount is 0 (no hardware facts captured), matching PS. + // Hardware — dropped entirely when CpuCount is 0 (no hardware facts captured), matching PS. The RAM clause is + // left off when no memory figure is carried: an Azure SQL Database's stored physical memory is the HOST's, so its + // readers hand over the database's vCores and no RAM (see ServerHardwareScope.OwnPhysicalMemoryMb). if (metadata.CpuCount > 0) - rows.Add(new ServerContextRow("Hardware", $"{metadata.CpuCount} CPUs, {metadata.PhysicalMemoryMB:N0} MB RAM")); + rows.Add(new ServerContextRow("Hardware", metadata.PhysicalMemoryMB > 0 + ? $"{metadata.CpuCount} CPUs, {metadata.PhysicalMemoryMB:N0} MB RAM" + : $"{metadata.CpuCount} CPUs")); // Instance settings — PS always shows these three rows, even when the value is 0 (a real // "MAXDOP 0" or "cost threshold 0" is a fact worth showing, not a missing one). From 3b3f1a7d596d45b9a883cf979409ebb2fac668df Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:38:10 -0400 Subject: [PATCH 07/17] Azure SQL Database: cpu_count and the worker ceiling are the database's own; only memory, sockets, cores per socket and hyperthread ratio are the host's (source changes; tests not yet updated) --- .../PgFactCollector.Config.cs | 11 +-- .../Mcp/DarlingMcpDataTools.cs | 10 +-- .../Mcp/DarlingMcpTools.cs | 11 +-- .../wwwroot/js/pages/server-tabs.js | 11 +-- .../DarlingServerMetadataReader.cs | 11 ++- .../FinOpsTab.Loaders.cs | 4 +- .../ViewerDataService.FinOps.Inventory.cs | 17 +--- .../ViewerDataService.FinOps.Utilization.cs | 26 ++---- .../ViewerDataService.FinOps.cs | 22 ++--- Lite/Analysis/DrillDownCollector.Plans.cs | 9 ++- Lite/Analysis/DuckDbFactCollector.Config.cs | 11 +-- Lite/Controls/FinOpsTab.xaml.cs | 4 +- Lite/Mcp/McpAnalysisTools.cs | 11 +-- Lite/Mcp/McpServerInfoTools.cs | 10 +-- ...ocalDataService.FinOps.ServerProperties.cs | 17 +--- .../LocalDataService.FinOps.Utilization.cs | 26 ++---- Lite/Services/LocalDataService.FinOps.cs | 24 +++--- .../LocalDataService.PlanServerMetadata.cs | 10 ++- PerformanceMonitor.Analysis/FactAdvice.cs | 47 +++++------ .../FactCollectorHelpers.cs | 19 +++-- .../FactRemediation.cs | 31 +++++++ .../MemoryStatsCollector.cs | 7 +- .../ProvisioningVerdict.cs | 14 ++-- .../ServerHardwareScope.cs | 81 +++++++++---------- .../ServerContextCard.cs | 18 +++-- .../ServerMetadata.cs | 12 +++ 26 files changed, 247 insertions(+), 227 deletions(-) diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs index 1aa7d27f7..740395bc4 100644 --- a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs @@ -310,11 +310,12 @@ private async Task CollectTraceFlagFactsAsync(AnalysisContext context, List - /// The newest server_properties row for the SERVER_HARDWARE fact. cpu_count is the count the server itself has: on an - /// Azure SQL Database (engine_edition 5) the stored cpu_count, hyperthread_ratio, physical_memory_mb, socket_count and - /// cores_per_socket describe the HOST, so there the count is the vcore_count parsed from the service objective (NULL for a DTU - /// objective, which leaves no fact) and carries none of the host's - /// topology or memory. Every other edition reads as it always did. Lite's DuckDbFactCollector carries the same CASE. + /// The newest server_properties row for the SERVER_HARDWARE fact. On an Azure SQL Database (engine_edition 5) the CPU + /// count is the vcore_count parsed from the service objective (NULL for a DTU objective or an elastic pool, which leaves no + /// fact), not the stored cpu_count: that is the number of schedulers the database can see, which can be higher than its vCores + /// (a 1-vCore database reads 2). There hyperthread_ratio, physical_memory_mb, socket_count and cores_per_socket describe the + /// HOST, and carries none of them; the recommended MAXDOP is taken + /// from the vCores. Every other edition reads as it always did. Lite's DuckDbFactCollector carries the same CASE. /// public const string ServerPropertiesSql = @" SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, hyperthread_ratio, physical_memory_mb, diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index f01fb0b22..0bbf6e25d 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -1763,7 +1763,7 @@ a status word (about 200 characters at most), and only a regressed row carries o }; } - [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, memory, socket/core topology, HADR, clustering, and the clock (utc_offset_minutes, time_zone_id). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected, and on a stalled collector it is the only sign of staleness. time_zone_id is CURRENT_TIMEZONE_ID() (SQL Server 2022+/Azure SQL only); null means a pre-2022 engine, so only the offset in force at captured_at is known, and an instant across a DST transition from it can read an hour off. <> Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, clustering, and the server's clock: utc_offset_minutes is the UTC offset in force when the snapshot was collected, and time_zone_id is the engine's own time-zone name (CURRENT_TIMEZONE_ID(), SQL Server 2022+ and Azure SQL only) - a null time_zone_id means a pre-2022 engine, where only the offset is known and any instant on the far side of a DST transition from the snapshot is placed an hour off by that offset. Use for capacity planning and edition-aware recommendations. LATEST IS A TIME: this reads the newest properties snapshot, not a window, and captured_at is the instant it was collected - a core count or memory figure here is what the server reported AT that stamp, and on a server whose collector has stalled the stamp is the only thing that says how stale it is. ON AN AZURE SQL DATABASE (engine_edition 5) the host's hardware is not the database's allocation: cpu_count, hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb come back null with a hardware_note, and service_objective with vcore_count says what the database is given.")] + [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, memory, socket/core topology, HADR, clustering, and the clock (utc_offset_minutes, time_zone_id). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected, and on a stalled collector it is the only sign of staleness. time_zone_id is CURRENT_TIMEZONE_ID() (SQL Server 2022+/Azure SQL only); null means a pre-2022 engine, so only the offset in force at captured_at is known, and an instant across a DST transition from it can read an hour off. <> Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, clustering, and the server's clock: utc_offset_minutes is the UTC offset in force when the snapshot was collected, and time_zone_id is the engine's own time-zone name (CURRENT_TIMEZONE_ID(), SQL Server 2022+ and Azure SQL only) - a null time_zone_id means a pre-2022 engine, where only the offset is known and any instant on the far side of a DST transition from the snapshot is placed an hour off by that offset. Use for capacity planning and edition-aware recommendations. LATEST IS A TIME: this reads the newest properties snapshot, not a window, and captured_at is the instant it was collected - a core count or memory figure here is what the server reported AT that stamp, and on a server whose collector has stalled the stamp is the only thing that says how stale it is. ON AN AZURE SQL DATABASE (engine_edition 5) the host's hardware is not the database's allocation: hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb come back null with a hardware_note, cpu_count is the database's own scheduler count (it can be higher than its vCores), and service_objective with vcore_count says what the database is given.")] public static async Task GetServerProperties( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, @@ -1789,10 +1789,10 @@ public static async Task GetServerProperties( /// /// The get_server_properties payload for one snapshot. On an Azure SQL Database (engine edition 5) the stored - /// cpu_count, hyperthread_ratio, socket_count, cores_per_socket and - /// physical_memory_mb are the HOST's, so they come back null, vcore_count (what the service objective - /// gives the database) rides beside service_objective, and a hardware_note says why. Every other - /// edition keeps the payload it always had, key for key. Lite's tool emits the same shape in the same words. + /// hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb are the HOST's, so + /// they come back null, cpu_count (the database's own scheduler count) passes through, vcore_count (what the + /// service objective gives the database) rides beside service_objective, and a hardware_note says why. Every + /// other edition keeps the payload it always had, key for key. Lite's tool emits the same shape in the same words. /// internal static string ServerPropertiesPayload(string serverName, DarlingDataReader.ServerPropertiesReadRow row) { diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs index 4405090c5..26bc3e708 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs @@ -776,8 +776,9 @@ the collector missed changes nothing about what the server is configured to. (Ph var totalDbSizeMb = factsByKey.TryGetValue("DATABASE_TOTAL_SIZE_MB", out var dbFact) ? dbFact.Value : 0; var editionName = AuditEditionName(edition); - var coresPerSocket = factsByKey.TryGetValue("SERVER_HARDWARE", out var hwFact) - && hwFact.Metadata.TryGetValue("cores_per_socket", out var cps) ? (int)cps : 0; + /* cores_per_socket off an Azure SQL Database; there the fact carries its vCores instead, because the stored + cores_per_socket is the host's (see FactRemediation.MaxdopBasisFrom). */ + var maxdopBasis = FactRemediation.MaxdopBasisFrom(factsByKey); var recommendations = new List(); @@ -810,11 +811,11 @@ the collector missed changes nothing about what the server is configured to. (Ph } } - // MAXDOP audit — topology-based (min(cores-per-socket, 8)), NOT edition-based. + // MAXDOP audit — topology-based (min(cores-per-socket, 8); min(vCores, 8) on an Azure SQL Database), NOT edition-based. if (factsByKey.TryGetValue("CONFIG_MAXDOP", out var maxdopFact)) { var maxdop = (int)maxdopFact.Value; - var recommended = (int)FactRemediation.RecommendedMaxdop(coresPerSocket); + var recommended = (int)FactRemediation.RecommendedMaxdop(maxdopBasis.Cores); if (maxdop == 0) { @@ -822,7 +823,7 @@ the collector missed changes nothing about what the server is configured to. (Ph $"MAXDOP is 0 (unlimited). This lets one query fan out across all schedulers, " + $"leading to CXPACKET waits and thread exhaustion under load. Microsoft's guidance is " + $"topology-based: keep MAXDOP at or under the logical processors in a single NUMA node, capped at 8. " + - $"Start with {recommended} (this server's cores-per-socket, capped at 8) and adjust to the workload.")); + $"Start with {recommended} ({(maxdopBasis.FromVcores ? "this database's vCores" : "this server's cores-per-socket")}, capped at 8) and adjust to the workload.")); } else if (maxdop == 1 && recommended > 1) { diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js index 6ade8b3e4..c30cd935c 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js @@ -2456,17 +2456,18 @@ const OVERVIEW_STATS = [ { key: "last_collection", label: "Last collection", format: "reltime", small: true }, ]; -/* An Azure SQL Database (engine_edition 5) reports the HOST's logical CPUs, sockets, cores per socket, hyperthread - ratio and physical memory, none of which is the database's allocation (a 1-vCore serverless database read "2 CPUs, - 0 sockets, 32 cores/socket, HT ratio 64, 911.9 GB"). get_server_properties returns those five as null there, and - these tiles are not drawn; the service objective and its vCores say what the database is given. */ +/* An Azure SQL Database (engine_edition 5) reports the HOST's sockets, cores per socket, hyperthread ratio and physical + memory, none of which is the database's allocation (a 1-vCore database read "0 sockets, 32 cores/socket, HT ratio 64, + about 912 GB"). get_server_properties returns those four as null there, and these tiles are not drawn. Logical CPUs + is the database's own scheduler count (a 1-vCore database reads 2), so that tile is drawn; the service objective and its + vCores say what the database is given. */ const AZURE_SQL_DATABASE = { key: "engine_edition", equals: 5 }; const PROPERTY_STATS = [ { key: "product_version", label: "Version", format: "text", small: true }, { key: "edition", label: "Edition", format: "text", small: true }, { key: "product_level", label: "Level", format: "text", small: true }, - { key: "cpu_count", label: "Logical CPUs", format: "int", hideWhen: AZURE_SQL_DATABASE }, + { key: "cpu_count", label: "Logical CPUs", format: "int" }, { key: "socket_count", label: "Sockets", format: "int", hideWhen: AZURE_SQL_DATABASE }, { key: "cores_per_socket", label: "Cores/socket", format: "int", hideWhen: AZURE_SQL_DATABASE }, { key: "hyperthread_ratio", label: "HT ratio", format: "int", hideWhen: AZURE_SQL_DATABASE }, diff --git a/Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs b/Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs index 2d1ea3280..59256d1e0 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs @@ -116,9 +116,10 @@ LEFT JOIN maxmem ON true if (!await reader.ReadAsync(cancellationToken)) return null; - /* On an Azure SQL Database the stored cpu_count and physical_memory_mb are the HOST's, so the server context carries - the database's vCores (none for a DTU objective, which drops the Hardware row) and no RAM figure. Every other - edition reads as it always did. Lite's plan-metadata readers apply the same rule. */ + /* On an Azure SQL Database the server context's Hardware row names the vCores (n/a for a DTU objective or an elastic + pool) and no RAM: the stored physical_memory_mb is the HOST's. The stored cpu_count there is the database's own + scheduler count and is not what the card shows. Every other edition reads as it always did. Lite's plan-metadata + readers apply the same rule. */ int? engineEdition = reader.IsDBNull(17) ? null : Convert.ToInt32(reader.GetValue(17)); int? vcoreCount = reader.IsDBNull(18) ? null : Convert.ToInt32(reader.GetValue(18)); int? storedCpuCount = reader.IsDBNull(4) ? null : Convert.ToInt32(reader.GetValue(4)); @@ -130,8 +131,10 @@ edition reads as it always did. Lite's plan-metadata readers apply the same rule Edition = reader.IsDBNull(1) ? null : reader.GetString(1), ProductVersion = reader.IsDBNull(2) ? null : reader.GetString(2), ProductLevel = reader.IsDBNull(3) ? null : reader.GetString(3), - CpuCount = ServerHardwareScope.OwnCpuCount(engineEdition, storedCpuCount, vcoreCount) ?? 0, + CpuCount = storedCpuCount ?? 0, PhysicalMemoryMB = ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L, + EngineEdition = engineEdition, + VcoreCount = vcoreCount, MaxDop = reader.IsDBNull(6) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(6))), CostThresholdForParallelism = reader.IsDBNull(7) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(7))), MaxServerMemoryMB = reader.IsDBNull(8) ? 0L : Convert.ToInt64(Convert.ToDouble(reader.GetValue(8))), diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs index e797eb5bc..8951a1e8e 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs @@ -274,8 +274,8 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) FinOpsCpuSamplesText.Text = data.CpuSamples.ToString("N0"); /* n/a on an Azure SQL Database whose service objective names no vCores: the host's count is never shown as the database's. */ FinOpsCpuCountText.Text = ServerHardwareScope.CpuCountText(data.EngineEdition, data.CpuCount); - /* n/a on an Azure SQL Database: the worker ceiling follows the host's CPUs and the in-use count is not collected there. */ - FinOpsWorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.EngineEdition, data.CurrentWorkersCount, data.MaxWorkersCount); + /* The in-use count is n/a where it was not collected (NULL on an Azure SQL Database), never 0; the maximum shows as stored. */ + FinOpsWorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.CurrentWorkersCount, data.MaxWorkersCount); SetBar(FinOpsAvgCpuBar, FinOpsAvgCpuFilled, FinOpsAvgCpuEmpty, (double)data.AvgCpuPct); SetBar(FinOpsP95CpuBar, FinOpsP95CpuFilled, FinOpsP95CpuEmpty, (double)data.P95CpuPct); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs index bf71f10d3..c7cbaac4f 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs @@ -57,13 +57,11 @@ FROM v_cpu_utilization_stats GROUP BY server_id ), /* Only the worker counts are consumed now: memory_ratio used to feed this read's own CASE, and that - CASE was the #2246 bug. The verdict comes from ProvisioningVerdict, so the division would be dead. - max_workers_count is the HOST-derived ceiling on an Azure SQL Database (engine_edition 5), so there it is NULL (no - ceiling, which the verdict reads as unknown); every other edition keeps the stored figure. */ + CASE was the #2246 bug. The verdict comes from ProvisioningVerdict, so the division would be dead. */ mem_latest AS ( SELECT s.server_id, - CASE WHEN props.engine_edition = 5 THEN NULL ELSE latest.max_workers_count END AS max_workers_count, + latest.max_workers_count, latest.current_workers_count FROM servers s CROSS JOIN LATERAL ( @@ -73,13 +71,6 @@ FROM v_memory_stats ORDER BY collection_time DESC LIMIT 1 ) AS latest - LEFT JOIN LATERAL ( - SELECT engine_edition - FROM server_properties - WHERE server_id = s.server_id - ORDER BY collection_time DESC - LIMIT 1 - ) AS props ON true ), /* Same workspace-memory pressure signals as the drill-down read, so the INVENTORY GRID cannot classify a server by a rule the drill-down no longer uses (#2246 — this grid is the screen the field report was @@ -164,7 +155,7 @@ GROUP BY s.server_id c.max_cpu_pct, c.p95_cpu_pct, COALESCE(m.max_workers_count, 0), - COALESCE(m.current_workers_count, 0), + m.current_workers_count, COALESCE(g.max_grant_waiters, 0), COALESCE(g.grant_timeouts, 0), COALESCE(g.forced_grants, 0), @@ -327,7 +318,7 @@ cannot disagree — they now call the same predicate. The old inline CASE was co forcedGrants: reader.IsDBNull(10) ? 0L : Convert.ToInt64(reader.GetValue(10)), grantUtilizationPercent: reader.IsDBNull(11) ? 0m : Convert.ToDecimal(reader.GetValue(11)), maxWorkers: reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6)), - currentWorkers: reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7))); + currentWorkers: reader.IsDBNull(7) ? (int?)null : Convert.ToInt32(reader.GetValue(7))); } /// diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs index ffedd4ba8..9f0a3d7e8 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs @@ -119,13 +119,9 @@ LEFT JOIN server_info s ON true var p95Cpu = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)); var memRatio = reader.IsDBNull(8) ? 0m : Convert.ToDecimal(reader.GetValue(8)); - /* max_workers_count is read from sys.dm_os_sys_info, which derives it from the CPU count that DMV reports. On an Azure - SQL Database that count is the HOST's, so there no ceiling is carried: 0 reads as unknown in the verdict (never as - saturation) and the Worker Threads card reads n/a. Every other edition reads as it always did. The same scoping is - in the trend read and the fleet read, and in Lite's three. */ - int? engineEdition = reader.IsDBNull(16) ? null : Convert.ToInt32(reader.GetValue(16)); - var maxWorkers = ServerHardwareScope.OwnMaxWorkersCount(engineEdition, reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9))); - var currentWorkers = reader.IsDBNull(10) ? 0 : Convert.ToInt32(reader.GetValue(10)); + var maxWorkers = reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)); + /* NULL is "not collected" (an Azure SQL Database), not 0 workers in use: it shows as n/a and the verdict reads it as unknown. */ + int? currentWorkers = reader.IsDBNull(10) ? null : Convert.ToInt32(reader.GetValue(10)); /* memory_ratio is still SELECTed and still displayed — it is a real fact about the instance — but it is no longer part of the verdict: Total over Target Server Memory converges at 1.0 on any warmed @@ -202,15 +198,6 @@ FROM v_memory_grant_stats WHERE server_id = $1 AND collection_time >= $2 GROUP BY CAST(collection_time AS DATE) -), -/* max_workers_count is the HOST-derived ceiling on an Azure SQL Database (engine_edition 5), so the days of one carry no - ceiling: 0, which the verdict reads as unknown. Same rule as the point-in-time read above. */ -server_info AS ( - SELECT engine_edition - FROM server_properties - WHERE server_id = $1 - ORDER BY collection_time DESC - LIMIT 1 ) SELECT c.day, @@ -222,12 +209,11 @@ LIMIT 1 COALESCE(g.grant_timeouts, 0), COALESCE(g.forced_grants, 0), COALESCE(g.grant_utilization_pct, 0), - CASE WHEN s.engine_edition = 5 THEN 0 ELSE COALESCE(m.max_workers_count, 0) END, - COALESCE(m.current_workers_count, 0) + COALESCE(m.max_workers_count, 0), + m.current_workers_count FROM daily_cpu c LEFT JOIN daily_mem m ON m.day = c.day LEFT JOIN daily_grants g ON g.day = c.day -LEFT JOIN server_info s ON true ORDER BY c.day"; public async Task> GetProvisioningTrendAsync(int serverId, CancellationToken cancellationToken = default) @@ -255,7 +241,7 @@ public async Task> GetProvisioningTrendAsync(int serv forcedGrants: reader.IsDBNull(7) ? 0L : Convert.ToInt64(reader.GetValue(7)), grantUtilizationPercent: reader.IsDBNull(8) ? 0m : Convert.ToDecimal(reader.GetValue(8)), maxWorkers: reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)), - currentWorkers: reader.IsDBNull(10) ? 0 : Convert.ToInt32(reader.GetValue(10))); + currentWorkers: reader.IsDBNull(10) ? (int?)null : Convert.ToInt32(reader.GetValue(10))); items.Add(new ProvisioningTrendRow { diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs index 8740c9c43..8034d2189 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs @@ -118,12 +118,15 @@ public sealed class UtilizationEfficiencyRow public decimal GrantUtilizationPct { get; set; } public int MaxWorkersCount { get; set; } - public int CurrentWorkersCount { get; set; } - /// The server's OWN CPU count, 0 when there is none. On an Azure SQL Database ( 5) the - /// stored cpu_count is the HOST's, so this is the vcore_count parsed from the service objective and 0 for an - /// objective that names none (a DTU-model objective or an elastic pool), which the card shows as n/a. It is never the host's - /// count. + /// Workers in use at the latest sample. null where the collector cannot read it (an Azure SQL Database stores + /// NULL), which the card shows as n/a: it is never 0, and the verdict treats it as unknown. + public int? CurrentWorkersCount { get; set; } + + /// The CPU count CPU percent is measured against, 0 when there is none. On an Azure SQL Database + /// ( 5) that is the vcore_count parsed from the service objective, not the stored + /// cpu_count (the schedulers the database can see, which can be higher than its vCores), and it is 0 for an objective + /// that names none (a DTU-model objective or an elastic pool), which the card shows as n/a. public int CpuCount { get; set; } /// The engine edition of the server these figures describe (SERVERPROPERTY('EngineEdition'), 0 when unread). @@ -292,9 +295,10 @@ public sealed class ServerPropertyRow public string HostOsVersion { get; set; } = ""; public int EngineEdition { get; set; } - /* The four hardware cells below read as ABSENT for an Azure SQL Database (engine edition 5): its collected - sys.dm_os_sys_info values are the HOST's, not the database's allocation (a 1-vCore serverless database read 2 CPUs, - 0 sockets, 32 cores per socket and 911.9 GB), and the grid draws an absent value as a blank cell. The stored values + /* Three of the four hardware cells below read as ABSENT for an Azure SQL Database (engine edition 5): its collected + sys.dm_os_sys_info memory, socket count and cores per socket are the HOST's, not the database's allocation (a 1-vCore + database read 0 sockets, 32 cores per socket and about 912 GB), and the grid draws an absent value as a blank cell. The + CPU count is the database's own scheduler count (a 1-vCore database reads 2), so it is shown as stored. The stored values are kept behind the properties, so the order the loader assigns them in does not matter and no calculation loses its input. */ private int _cpuCount; @@ -304,7 +308,7 @@ its input. */ private string? _hardwareUnavailableReason; private bool HostHardware => ServerHardwareScope.HardwareIsTheHosts(EngineEdition); - public int? CpuCount { get => HostHardware ? null : _cpuCount; set => _cpuCount = value ?? 0; } + public int? CpuCount { get => _cpuCount; set => _cpuCount = value ?? 0; } public long? PhysicalMemoryMb { get => HostHardware ? null : _physicalMemoryMb; set => _physicalMemoryMb = value ?? 0L; } public int? SocketCount { get => HostHardware ? null : _socketCount; set => _socketCount = value; } public int? CoresPerSocket { get => HostHardware ? null : _coresPerSocket; set => _coresPerSocket = value; } diff --git a/Lite/Analysis/DrillDownCollector.Plans.cs b/Lite/Analysis/DrillDownCollector.Plans.cs index 58a58bdb0..c04a4bce6 100644 --- a/Lite/Analysis/DrillDownCollector.Plans.cs +++ b/Lite/Analysis/DrillDownCollector.Plans.cs @@ -164,8 +164,9 @@ ORDER BY p.collection_time DESC using var reader = await cmd.ExecuteReaderAsync(cancellationToken); if (!await reader.ReadAsync(cancellationToken)) return null; - /* Same rule as LocalDataService.GetServerMetadataForPlanAnalysisAsync: an Azure SQL Database's stored cpu_count and - physical_memory_mb are the HOST's, so the drill-down's server context carries the vCores and no RAM figure. */ + /* Same rule as LocalDataService.GetServerMetadataForPlanAnalysisAsync: an Azure SQL Database's stored + physical_memory_mb is the HOST's, so the drill-down's server context carries no RAM figure, and its Hardware row + names the vCores. */ int? engineEdition = reader.IsDBNull(6) ? null : Convert.ToInt32(reader.GetValue(6)); int? vcoreCount = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)); int? storedCpuCount = reader.IsDBNull(3) ? null : reader.GetInt32(3); @@ -176,8 +177,10 @@ ORDER BY p.collection_time DESC Edition = reader.IsDBNull(0) ? null : reader.GetString(0), ProductVersion = reader.IsDBNull(1) ? null : reader.GetString(1), ProductLevel = reader.IsDBNull(2) ? null : reader.GetString(2), - CpuCount = ServerHardwareScope.OwnCpuCount(engineEdition, storedCpuCount, vcoreCount) ?? 0, + CpuCount = storedCpuCount ?? 0, PhysicalMemoryMB = ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L, + EngineEdition = engineEdition, + VcoreCount = vcoreCount, MaxDop = reader.IsDBNull(5) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(5))), }; } diff --git a/Lite/Analysis/DuckDbFactCollector.Config.cs b/Lite/Analysis/DuckDbFactCollector.Config.cs index d87d30e12..1bfa7f3d5 100644 --- a/Lite/Analysis/DuckDbFactCollector.Config.cs +++ b/Lite/Analysis/DuckDbFactCollector.Config.cs @@ -325,11 +325,12 @@ private async Task CollectServerPropertiesFactsAsync(AnalysisContext context, Li await connection.OpenAsync(context.CancellationToken); using var cmd = connection.CreateCommand(); - /* cpu_count is the count the server itself has. On an Azure SQL Database (engine_edition 5) the stored - cpu_count, hyperthread_ratio, physical_memory_mb, socket_count and cores_per_socket describe the HOST, so - there the count is the vcore_count parsed from the service objective (NULL for a DTU objective, which - leaves no fact) and FactCollectorHelpers.BuildServerHardwareFact carries none of the host's topology or - memory. Every other edition reads as it always did. The same CASE is in Darling's PgFactCollector. */ + /* On an Azure SQL Database (engine_edition 5) the CPU count is the vcore_count parsed from the service objective + (NULL for a DTU objective or an elastic pool, which leaves no fact), not the stored cpu_count: that is the number + of schedulers the database can see, which can be higher than its vCores (a 1-vCore database reads 2). There + hyperthread_ratio, physical_memory_mb, socket_count and cores_per_socket describe the HOST, and + FactCollectorHelpers.BuildServerHardwareFact carries none of them; the recommended MAXDOP is taken from the + vCores. Every other edition reads as it always did. The same CASE is in Darling's PgFactCollector. */ cmd.CommandText = @" SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, hyperthread_ratio, physical_memory_mb, socket_count, cores_per_socket, is_hadr_enabled, edition, product_version, diff --git a/Lite/Controls/FinOpsTab.xaml.cs b/Lite/Controls/FinOpsTab.xaml.cs index 76c2eefa2..425f130d7 100644 --- a/Lite/Controls/FinOpsTab.xaml.cs +++ b/Lite/Controls/FinOpsTab.xaml.cs @@ -404,8 +404,8 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) CpuSamplesText.Text = data.CpuSamples.ToString("N0"); /* n/a on an Azure SQL Database whose service objective names no vCores: the host's count is never shown as the database's. */ CpuCountText.Text = ServerHardwareScope.CpuCountText(data.EngineEdition, data.CpuCount); - /* n/a on an Azure SQL Database: the worker ceiling follows the host's CPUs and the in-use count is not collected there. */ - WorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.EngineEdition, data.CurrentWorkersCount, data.MaxWorkersCount); + /* The in-use count is n/a where it was not collected (NULL on an Azure SQL Database), never 0; the maximum shows as stored. */ + WorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.CurrentWorkersCount, data.MaxWorkersCount); SetBar(AvgCpuBar, AvgCpuFilled, AvgCpuEmpty, (double)data.AvgCpuPct); SetBar(P95CpuBar, P95CpuFilled, P95CpuEmpty, (double)data.P95CpuPct); diff --git a/Lite/Mcp/McpAnalysisTools.cs b/Lite/Mcp/McpAnalysisTools.cs index 562049968..7f4b54ace 100644 --- a/Lite/Mcp/McpAnalysisTools.cs +++ b/Lite/Mcp/McpAnalysisTools.cs @@ -595,8 +595,9 @@ the collector missed changes nothing about what the server is configured to. (Ph var totalDbSizeMb = factsByKey.TryGetValue("DATABASE_TOTAL_SIZE_MB", out var dbFact) ? dbFact.Value : 0; var editionName = AuditEditionName(edition); - var coresPerSocket = factsByKey.TryGetValue("SERVER_HARDWARE", out var hwFact) - && hwFact.Metadata.TryGetValue("cores_per_socket", out var cps) ? (int)cps : 0; + /* cores_per_socket off an Azure SQL Database; there the fact carries its vCores instead, because the stored + cores_per_socket is the host's (see FactRemediation.MaxdopBasisFrom). */ + var maxdopBasis = FactRemediation.MaxdopBasisFrom(factsByKey); var recommendations = new List(); @@ -629,11 +630,11 @@ the collector missed changes nothing about what the server is configured to. (Ph } } - // MAXDOP audit — topology-based (min(cores-per-socket, 8)), NOT edition-based. + // MAXDOP audit — topology-based (min(cores-per-socket, 8); min(vCores, 8) on an Azure SQL Database), NOT edition-based. if (factsByKey.TryGetValue("CONFIG_MAXDOP", out var maxdopFact)) { var maxdop = (int)maxdopFact.Value; - var recommended = (int)FactRemediation.RecommendedMaxdop(coresPerSocket); + var recommended = (int)FactRemediation.RecommendedMaxdop(maxdopBasis.Cores); if (maxdop == 0) { @@ -641,7 +642,7 @@ the collector missed changes nothing about what the server is configured to. (Ph $"MAXDOP is 0 (unlimited). This lets one query fan out across all schedulers, " + $"leading to CXPACKET waits and thread exhaustion under load. Microsoft's guidance is " + $"topology-based: keep MAXDOP at or under the logical processors in a single NUMA node, capped at 8. " + - $"Start with {recommended} (this server's cores-per-socket, capped at 8) and adjust to the workload.")); + $"Start with {recommended} ({(maxdopBasis.FromVcores ? "this database's vCores" : "this server's cores-per-socket")}, capped at 8) and adjust to the workload.")); } else if (maxdop == 1 && recommended > 1) { diff --git a/Lite/Mcp/McpServerInfoTools.cs b/Lite/Mcp/McpServerInfoTools.cs index e715a79d0..bfe40b32d 100644 --- a/Lite/Mcp/McpServerInfoTools.cs +++ b/Lite/Mcp/McpServerInfoTools.cs @@ -9,7 +9,7 @@ namespace PerformanceMonitorLite.Mcp; [McpServerToolType] public sealed class McpServerInfoTools { - [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, memory, socket/core topology, HADR, clustering, and the clock (utc_offset_minutes, time_zone_id). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected, and on a stalled collector it is the only sign of staleness. time_zone_id is CURRENT_TIMEZONE_ID() (SQL Server 2022+/Azure SQL only); null means a pre-2022 engine, so only the offset in force at captured_at is known, and an instant across a DST transition from it can read an hour off. <> Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, clustering, and the server's clock: utc_offset_minutes is the UTC offset in force when the snapshot was collected, and time_zone_id is the engine's own time-zone name (CURRENT_TIMEZONE_ID(), SQL Server 2022+ and Azure SQL only) - a null time_zone_id means a pre-2022 engine, where only the offset is known and any instant on the far side of a DST transition from the snapshot is placed an hour off by that offset. Use for capacity planning and edition-aware recommendations. LATEST IS A TIME: this reads the newest properties snapshot, not a window, and captured_at is the instant it was collected - a core count or memory figure here is what the server reported AT that stamp, and on a server whose collector has stalled the stamp is the only thing that says how stale it is. ON AN AZURE SQL DATABASE (engine_edition 5) the host's hardware is not the database's allocation: cpu_count, hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb come back null with a hardware_note, and service_objective with vcore_count says what the database is given.")] + [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, memory, socket/core topology, HADR, clustering, and the clock (utc_offset_minutes, time_zone_id). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected, and on a stalled collector it is the only sign of staleness. time_zone_id is CURRENT_TIMEZONE_ID() (SQL Server 2022+/Azure SQL only); null means a pre-2022 engine, so only the offset in force at captured_at is known, and an instant across a DST transition from it can read an hour off. <> Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, clustering, and the server's clock: utc_offset_minutes is the UTC offset in force when the snapshot was collected, and time_zone_id is the engine's own time-zone name (CURRENT_TIMEZONE_ID(), SQL Server 2022+ and Azure SQL only) - a null time_zone_id means a pre-2022 engine, where only the offset is known and any instant on the far side of a DST transition from the snapshot is placed an hour off by that offset. Use for capacity planning and edition-aware recommendations. LATEST IS A TIME: this reads the newest properties snapshot, not a window, and captured_at is the instant it was collected - a core count or memory figure here is what the server reported AT that stamp, and on a server whose collector has stalled the stamp is the only thing that says how stale it is. ON AN AZURE SQL DATABASE (engine_edition 5) the host's hardware is not the database's allocation: hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb come back null with a hardware_note, cpu_count is the database's own scheduler count (it can be higher than its vCores), and service_objective with vcore_count says what the database is given.")] public static async Task GetServerProperties( LocalDataService dataService, ServerManager serverManager, @@ -35,10 +35,10 @@ public static async Task GetServerProperties( /// /// The get_server_properties payload for one snapshot. On an Azure SQL Database (engine edition 5) the stored - /// cpu_count, hyperthread_ratio, socket_count, cores_per_socket and - /// physical_memory_mb are the HOST's, so they come back null, vcore_count (what the service objective - /// gives the database) rides beside service_objective, and a hardware_note says why. Every other - /// edition keeps the payload it always had, key for key. Lite's tool emits the same shape in the same words. + /// hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb are the HOST's, so + /// they come back null, cpu_count (the database's own scheduler count) passes through, vcore_count (what the + /// service objective gives the database) rides beside service_objective, and a hardware_note says why. Every + /// other edition keeps the payload it always had, key for key. Darling's tool emits the same shape in the same words. /// internal static string ServerPropertiesPayload(string serverName, ServerPropertiesRow row) { diff --git a/Lite/Services/LocalDataService.FinOps.ServerProperties.cs b/Lite/Services/LocalDataService.FinOps.ServerProperties.cs index 40e2cf39d..3c96878cf 100644 --- a/Lite/Services/LocalDataService.FinOps.ServerProperties.cs +++ b/Lite/Services/LocalDataService.FinOps.ServerProperties.cs @@ -234,13 +234,11 @@ FROM v_cpu_utilization_stats GROUP BY server_id ), /* Only the worker counts are consumed now: memory_ratio used to feed this read's own CASE, and that - CASE was the #2246 bug. The verdict comes from ProvisioningVerdict, so the division would be dead. - max_workers_count is the HOST-derived ceiling on an Azure SQL Database (engine_edition 5), so there it is NULL (no - ceiling, which the verdict reads as unknown); every other edition keeps the stored figure. */ + CASE was the #2246 bug. The verdict comes from ProvisioningVerdict, so the division would be dead. */ mem_latest AS ( SELECT s.server_id, - CASE WHEN props.engine_edition = 5 THEN NULL ELSE latest.max_workers_count END AS max_workers_count, + latest.max_workers_count, latest.current_workers_count FROM known_servers s LEFT JOIN LATERAL ( @@ -250,13 +248,6 @@ FROM v_memory_stats ORDER BY collection_time DESC LIMIT 1 ) AS latest ON true - LEFT JOIN LATERAL ( - SELECT engine_edition - FROM v_server_properties - WHERE server_id = s.server_id - ORDER BY collection_time DESC - LIMIT 1 - ) AS props ON true ), /* Same workspace-memory pressure signals as the drill-down read, so the INVENTORY GRID cannot classify a server by a rule the drill-down no longer uses (#2246 — this grid is the screen the field report was @@ -330,7 +321,7 @@ GROUP BY server_id c.max_cpu_pct, c.p95_cpu_pct, COALESCE(m.max_workers_count, 0), - COALESCE(m.current_workers_count, 0), + m.current_workers_count, COALESCE(g.max_grant_waiters, 0), COALESCE(g.grant_timeouts, 0), COALESCE(g.forced_grants, 0), @@ -389,6 +380,6 @@ FROM known_servers s forcedGrants: reader.IsDBNull(10) ? 0L : ToInt64(reader.GetValue(10)), grantUtilizationPercent: reader.IsDBNull(11) ? 0m : Convert.ToDecimal(reader.GetValue(11)), maxWorkers: reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6)), - currentWorkers: reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7))); + currentWorkers: reader.IsDBNull(7) ? (int?)null : Convert.ToInt32(reader.GetValue(7))); } } diff --git a/Lite/Services/LocalDataService.FinOps.Utilization.cs b/Lite/Services/LocalDataService.FinOps.Utilization.cs index a9ba4141d..bdb4e575e 100644 --- a/Lite/Services/LocalDataService.FinOps.Utilization.cs +++ b/Lite/Services/LocalDataService.FinOps.Utilization.cs @@ -111,13 +111,9 @@ LEFT JOIN server_info s ON true var p95Cpu = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)); var memRatio = reader.IsDBNull(8) ? 0m : Convert.ToDecimal(reader.GetValue(8)); - /* max_workers_count is read from sys.dm_os_sys_info, which derives it from the CPU count that DMV reports. On an Azure - SQL Database that count is the HOST's, so there no ceiling is carried: 0 reads as unknown in the verdict (never as - saturation) and the Worker Threads card reads n/a. Every other edition reads as it always did. The same scoping is - in the trend read and the fleet read, and in Darling's three. */ - int? engineEdition = reader.IsDBNull(16) ? null : Convert.ToInt32(reader.GetValue(16)); - var maxWorkers = ServerHardwareScope.OwnMaxWorkersCount(engineEdition, reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9))); - var currentWorkers = reader.IsDBNull(10) ? 0 : Convert.ToInt32(reader.GetValue(10)); + var maxWorkers = reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)); + /* NULL is "not collected" (an Azure SQL Database), not 0 workers in use: it shows as n/a and the verdict reads it as unknown. */ + int? currentWorkers = reader.IsDBNull(10) ? null : Convert.ToInt32(reader.GetValue(10)); /* memory_ratio is still SELECTed and still displayed — it is a real fact about the instance — but it is no longer part of the verdict: Total over Target Server Memory converges at 1.0 on any warmed @@ -203,15 +199,6 @@ FROM v_memory_grant_stats WHERE server_id = $1 AND collection_time >= $2 GROUP BY CAST(collection_time AS DATE) -), -/* max_workers_count is the HOST-derived ceiling on an Azure SQL Database (engine_edition 5), so the days of one carry no - ceiling: 0, which the verdict reads as unknown. Same rule as the point-in-time read above. */ -server_info AS ( - SELECT engine_edition - FROM v_server_properties - WHERE server_id = $1 - ORDER BY collection_time DESC - LIMIT 1 ) SELECT c.day, @@ -223,12 +210,11 @@ LIMIT 1 COALESCE(g.grant_timeouts, 0), COALESCE(g.forced_grants, 0), COALESCE(g.grant_utilization_pct, 0), - CASE WHEN s.engine_edition = 5 THEN 0 ELSE COALESCE(m.max_workers_count, 0) END, - COALESCE(m.current_workers_count, 0) + COALESCE(m.max_workers_count, 0), + m.current_workers_count FROM daily_cpu c LEFT JOIN daily_mem m ON m.day = c.day LEFT JOIN daily_grants g ON g.day = c.day -LEFT JOIN server_info s ON true ORDER BY c.day"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); @@ -250,7 +236,7 @@ LEFT JOIN server_info s ON true forcedGrants: reader.IsDBNull(7) ? 0L : ToInt64(reader.GetValue(7)), grantUtilizationPercent: reader.IsDBNull(8) ? 0m : Convert.ToDecimal(reader.GetValue(8)), maxWorkers: reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)), - currentWorkers: reader.IsDBNull(10) ? 0 : Convert.ToInt32(reader.GetValue(10))); + currentWorkers: reader.IsDBNull(10) ? (int?)null : Convert.ToInt32(reader.GetValue(10))); items.Add(new ProvisioningTrendRow { diff --git a/Lite/Services/LocalDataService.FinOps.cs b/Lite/Services/LocalDataService.FinOps.cs index 40cbce828..52c1cc297 100644 --- a/Lite/Services/LocalDataService.FinOps.cs +++ b/Lite/Services/LocalDataService.FinOps.cs @@ -101,12 +101,15 @@ public class UtilizationEfficiencyRow public decimal GrantUtilizationPct { get; set; } public int MaxWorkersCount { get; set; } - public int CurrentWorkersCount { get; set; } - /// The server's OWN CPU count, 0 when there is none. On an Azure SQL Database ( 5) the - /// stored cpu_count is the HOST's, so this is the vcore_count parsed from the service objective and 0 for an - /// objective that names none (a DTU-model objective or an elastic pool), which the card shows as n/a. It is never the host's - /// count. + /// Workers in use at the latest sample. null where the collector cannot read it (an Azure SQL Database stores + /// NULL), which the card shows as n/a: it is never 0, and the verdict treats it as unknown. + public int? CurrentWorkersCount { get; set; } + + /// The CPU count CPU percent is measured against, 0 when there is none. On an Azure SQL Database + /// ( 5) that is the vcore_count parsed from the service objective, not the stored + /// cpu_count (the schedulers the database can see, which can be higher than its vCores), and it is 0 for an objective + /// that names none (a DTU-model objective or an elastic pool), which the card shows as n/a. public int CpuCount { get; set; } /// The engine edition of the server these figures describe (SERVERPROPERTY('EngineEdition'), 0 when unread). @@ -274,10 +277,11 @@ public class ServerPropertyRow public string? ProductUpdateLevel { get; set; } public int EngineEdition { get; set; } - /* The four hardware cells below read as ABSENT for an Azure SQL Database (engine edition 5): its sys.dm_os_sys_info - values are the HOST's, not the database's allocation (a 1-vCore serverless database read 2 CPUs, 0 sockets, 32 - cores per socket and 911.9 GB), and the grid draws an absent value as a blank cell. The read values are kept - behind the properties, so the order the loader assigns them in does not matter and no calculation loses its input. */ + /* Three of the four hardware cells below read as ABSENT for an Azure SQL Database (engine edition 5): its + sys.dm_os_sys_info memory, socket count and cores per socket are the HOST's, not the database's allocation (a 1-vCore + database read 0 sockets, 32 cores per socket and about 912 GB), and the grid draws an absent value as a blank cell. The + CPU count is the database's own scheduler count (a 1-vCore database reads 2), so it is shown as read. The read values are + kept behind the properties, so the order the loader assigns them in does not matter and no calculation loses its input. */ private int _cpuCount; private long _physicalMemoryMb; private int? _socketCount; @@ -285,7 +289,7 @@ public class ServerPropertyRow private string? _hardwareUnavailableReason; private bool HostHardware => ServerHardwareScope.HardwareIsTheHosts(EngineEdition); - public int? CpuCount { get => HostHardware ? null : _cpuCount; set => _cpuCount = value ?? 0; } + public int? CpuCount { get => _cpuCount; set => _cpuCount = value ?? 0; } public long? PhysicalMemoryMb { get => HostHardware ? null : _physicalMemoryMb; set => _physicalMemoryMb = value ?? 0L; } public int? SocketCount { get => HostHardware ? null : _socketCount; set => _socketCount = value; } public int? CoresPerSocket { get => HostHardware ? null : _coresPerSocket; set => _coresPerSocket = value; } diff --git a/Lite/Services/LocalDataService.PlanServerMetadata.cs b/Lite/Services/LocalDataService.PlanServerMetadata.cs index 5f7ae0cf5..30474d9dd 100644 --- a/Lite/Services/LocalDataService.PlanServerMetadata.cs +++ b/Lite/Services/LocalDataService.PlanServerMetadata.cs @@ -122,9 +122,9 @@ ORDER BY p.collection_time DESC using var reader = await command.ExecuteReaderAsync(); if (!await reader.ReadAsync()) return null; - /* On an Azure SQL Database the stored cpu_count and physical_memory_mb are the HOST's, so the Server Context - card's Hardware row carries the database's vCores (none for a DTU objective, which drops the row) and no - RAM figure. Every other edition reads as it always did. */ + /* On an Azure SQL Database the Server Context card's Hardware row names the vCores (n/a for a DTU objective or an + elastic pool) and no RAM: the stored physical_memory_mb is the HOST's. The stored cpu_count there is the + database's own scheduler count and is not what the card shows. Every other edition reads as it always did. */ int? engineEdition = reader.IsDBNull(16) ? null : Convert.ToInt32(reader.GetValue(16)); int? vcoreCount = reader.IsDBNull(17) ? null : Convert.ToInt32(reader.GetValue(17)); int? storedCpuCount = reader.IsDBNull(3) ? null : reader.GetInt32(3); @@ -135,8 +135,10 @@ RAM figure. Every other edition reads as it always did. */ Edition = reader.IsDBNull(0) ? null : reader.GetString(0), ProductVersion = reader.IsDBNull(1) ? null : reader.GetString(1), ProductLevel = reader.IsDBNull(2) ? null : reader.GetString(2), - CpuCount = ServerHardwareScope.OwnCpuCount(engineEdition, storedCpuCount, vcoreCount) ?? 0, + CpuCount = storedCpuCount ?? 0, PhysicalMemoryMB = ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L, + EngineEdition = engineEdition, + VcoreCount = vcoreCount, MaxDop = reader.IsDBNull(5) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(5))), CostThresholdForParallelism = reader.IsDBNull(6) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(6))), MaxServerMemoryMB = reader.IsDBNull(7) ? 0L : ToInt64(reader.GetValue(7)), diff --git a/PerformanceMonitor.Analysis/FactAdvice.cs b/PerformanceMonitor.Analysis/FactAdvice.cs index 9954596f9..fed988b28 100644 --- a/PerformanceMonitor.Analysis/FactAdvice.cs +++ b/PerformanceMonitor.Analysis/FactAdvice.cs @@ -449,14 +449,6 @@ private sealed record StoryAdvice(string? h, string? i, string? r); private static long? FactValue(IReadOnlyDictionary facts, string key) => facts.TryGetValue(key, out var f) ? (long)Math.Round(f.Value) : (long?)null; - /// - /// Cores-per-socket from SERVER_HARDWARE metadata — the per-NUMA-node proxy MAXDOP guidance keys - /// on (NUMA node count itself is not collected). 0 when absent. - /// - private static int CoresPerSocket(IReadOnlyDictionary facts) => - facts.TryGetValue("SERVER_HARDWARE", out var hw) - && hw.Metadata.TryGetValue("cores_per_socket", out var c) ? (int)c : 0; - /// /// The collection-gap caveat appended to every THREADPOOL-family block: under live thread /// exhaustion the collector is itself a query waiting for a worker, so a gap in Collection Health @@ -482,9 +474,9 @@ private static AdviceBlock ComposeThreadpoolParallel(IReadOnlyDictionary @@ -500,14 +492,14 @@ private static AdviceBlock ComposeThreadpoolMixed(IReadOnlyDictionary - private static string ParallelGuardCore(long? maxdop, long? ctfp, int cores, long rec) + private static string ParallelGuardCore(long? maxdop, long? ctfp, FactRemediation.MaxdopBasis basis, long rec) { var sb = new StringBuilder(); @@ -528,7 +520,7 @@ private static string ParallelGuardCore(long? maxdop, long? ctfp, int cores, lon .Append(maxdop?.ToString() ?? "not readable this window") .Append(" and cost threshold for parallelism is ") .Append(ctfp?.ToString() ?? "not readable this window") - .Append(cores > 0 ? $" (cores per socket {cores})." : "."); + .Append(basis.Cores > 0 ? $" {basis.Note}." : "."); var ctfpGuarded = ctfp is >= 50; var maxdopGuarded = maxdop is > 0 && maxdop <= rec; @@ -554,9 +546,9 @@ private static string ParallelGuardCore(long? maxdop, long? ctfp, int cores, lon sb.Append(" cost threshold for parallelism is already past the trivial-query cutoff"); if (maxdop is 0) - sb.Append($", and cap MAXDOP at {rec} (this server's per-NUMA-node processor count, capped at 8) instead of unlimited"); + sb.Append($", and cap MAXDOP at {rec} ({basis.Source}, capped at 8) instead of unlimited"); else if (maxdop > rec) - sb.Append($", and lower MAXDOP from {maxdop} to {rec} (the per-NUMA-node processor count, capped at 8)"); + sb.Append($", and lower MAXDOP from {maxdop} to {rec} ({basis.Short}, capped at 8)"); else sb.Append($"; MAXDOP at {maxdop} is already within the ≤ {rec} guidance"); sb.Append(". Then go after the specific high-DOP offenders"); @@ -587,16 +579,17 @@ private static AdviceBlock ComposeConfigMaxdop(IReadOnlyDictionary if (maxdop is null) return fallback; - var cores = CoresPerSocket(facts); - var rec = FactRemediation.RecommendedMaxdop(cores); - var coresNote = cores > 0 ? $" (cores per socket {cores})" : string.Empty; + var basis = FactRemediation.MaxdopBasisFrom(facts); + var rec = FactRemediation.RecommendedMaxdop(basis.Cores); + var coresNote = basis.Cores > 0 ? $" {basis.Note}" : string.Empty; + var cappedFrom = basis.FromVcores ? "vCores" : "cores-per-socket"; string headline, remediation; if (maxdop == 0) { headline = "MAXDOP is 0 — a single query can fan out across every scheduler (up to 64)"; remediation = - $"Set MAXDOP to {rec} — this server's cores-per-socket capped at 8{coresNote}, the per-NUMA-node " + + $"Set MAXDOP to {rec} — this {(basis.FromVcores ? "database" : "server")}'s {cappedFrom} capped at 8{coresNote}, the per-NUMA-node " + "proxy; the SKU is irrelevant to the right value. The Apply button runs sp_configure + " + "RECONFIGURE, an online metadata change. On hardware with more than 16 logical processors " + "per NUMA node you can raise it by hand. Raise Cost Threshold for Parallelism in the same pass " + @@ -608,13 +601,13 @@ private static AdviceBlock ComposeConfigMaxdop(IReadOnlyDictionary remediation = $"MAXDOP 1 forces every query serial: large analytical queries, index rebuilds, and DBCC run " + $"far slower. Unless this was set deliberately to fix a specific parallelism problem, set MAXDOP " + - $"to {rec} (cores-per-socket capped at 8{coresNote}) via sp_configure + RECONFIGURE, an online change."; + $"to {rec} ({cappedFrom} capped at 8{coresNote}) via sp_configure + RECONFIGURE, an online change."; } else { headline = $"MAXDOP is {maxdop} — above this server's topology-based guidance of {rec}"; remediation = - $"Lower MAXDOP from {maxdop} to {rec} (cores-per-socket capped at 8{coresNote}, the per-NUMA-node " + + $"Lower MAXDOP from {maxdop} to {rec} ({cappedFrom} capped at 8{coresNote}, the per-NUMA-node " + "proxy; the SKU is irrelevant). The Apply button runs sp_configure + RECONFIGURE, an online " + "metadata change. On hardware with more than 16 logical processors per NUMA node a higher value " + "can be justified by hand. Pair it with a sane Cost Threshold for Parallelism if that finding fired."; @@ -661,8 +654,8 @@ private static string MaxdopCtfpClause(IReadOnlyDictionary facts) if (maxdop is null && ctfp is null) return string.Empty; - var cores = CoresPerSocket(facts); - var rec = FactRemediation.RecommendedMaxdop(cores); + var basis = FactRemediation.MaxdopBasisFrom(facts); + var rec = FactRemediation.RecommendedMaxdop(basis.Cores); var sb = new StringBuilder("This server's MAXDOP is ") .Append(maxdop?.ToString() ?? "not readable this window") .Append(" and cost threshold for parallelism is ") @@ -675,7 +668,7 @@ private static string MaxdopCtfpClause(IReadOnlyDictionary facts) else if (ctfp is not null && ctfp < 50) recs.Add($"raise cost threshold for parallelism from {ctfp} toward 50"); if (maxdop is 0) - recs.Add($"cap MAXDOP at {rec} (the per-NUMA-node processor count, ≤ 8)"); + recs.Add($"cap MAXDOP at {rec} ({basis.Short}, ≤ 8)"); else if (maxdop is not null && maxdop > rec) recs.Add($"lower MAXDOP from {maxdop} to {rec}"); diff --git a/PerformanceMonitor.Analysis/FactCollectorHelpers.cs b/PerformanceMonitor.Analysis/FactCollectorHelpers.cs index 2ed6ffab6..6c74bea41 100644 --- a/PerformanceMonitor.Analysis/FactCollectorHelpers.cs +++ b/PerformanceMonitor.Analysis/FactCollectorHelpers.cs @@ -26,11 +26,14 @@ public static class FactCollectorHelpers /// carry (no fact at all: every reader of it takes it by key and treats an absent fact as "not collected"). /// /// Off an Azure SQL Database the fact is what it always was. ON one (), the stored - /// cpu_count, hyperthread_ratio, physical_memory_mb, socket_count and cores_per_socket describe - /// the HOST, not what the database is given (a 1-vCore serverless database read 2 CPUs and 911.9 GB), so the fact carries only - /// the database's own CPU count: is then the vcore_count parsed from the service objective, - /// and it is 0 for an objective that names none (a DTU model), which leaves no fact. Its metadata holds no host topology and no - /// host memory, so nothing that reads the fact (the MAXDOP advice, the config audit, the LPIM advisory) can compute from them. + /// hyperthread_ratio, physical_memory_mb, socket_count and cores_per_socket describe the HOST, not + /// what the database is given (a 1-vCore database read 0 sockets, 32 cores per socket and about 912 GB), so the fact carries none + /// of them. It carries the vCores instead: is then the vcore_count parsed from the service + /// objective (not the stored cpu_count, the schedulers the database can see, which can be higher), and it is 0 for an + /// objective that names none (a DTU model or an elastic pool), which leaves no fact. The vCores ride twice: as cpu_count, + /// and as vcore_count, the figure the recommended MAXDOP is taken from where an Azure SQL Database has no + /// cores_per_socket to give (see FactRemediation.MaxdopBasisFrom). Nothing that reads the fact (the MAXDOP advice, + /// the config audit, the LPIM advisory) can compute from the host's topology or memory. /// /// Both apps call this, so the two collectors cannot drift in what the fact carries. /// @@ -42,7 +45,11 @@ public static class FactCollectorHelpers return null; var metadata = new Dictionary { ["cpu_count"] = cpuCount }; - if (!hardwareIsTheHosts) + if (hardwareIsTheHosts) + { + metadata["vcore_count"] = cpuCount; + } + else { metadata["hyperthread_ratio"] = hyperthreadRatio; metadata["physical_memory_mb"] = physicalMemoryMb; diff --git a/PerformanceMonitor.Analysis/FactRemediation.cs b/PerformanceMonitor.Analysis/FactRemediation.cs index e57235414..6d07e5257 100644 --- a/PerformanceMonitor.Analysis/FactRemediation.cs +++ b/PerformanceMonitor.Analysis/FactRemediation.cs @@ -170,6 +170,37 @@ public static long RecommendedMaxdop(int coresPerSocket) return Math.Min(coresPerSocket, MaxdopCap); } + /// + /// The core count the recommended MAXDOP is taken from, and what to call it. Off an Azure SQL Database it is the + /// SERVER_HARDWARE fact's cores_per_socket, the per-NUMA-node proxy. On one the fact carries no host topology (the + /// stored cores_per_socket is the machine's, not the database's), so the proxy comes from its vcore_count instead, + /// and every sentence that names it says "vCores", never "cores per socket". is 0 when the fact carries + /// neither (no fact, or a DTU objective or an elastic pool, which have no vCores), which leaves the safe general cap of 8. + /// + public readonly record struct MaxdopBasis(int Cores, bool FromVcores) + { + /// The parenthetical that states the figure, such as "(cores per socket 4)" or "(4 vCores)"; empty when unknown. + public string Note => Cores <= 0 ? string.Empty : FromVcores ? $"({Cores} vCores)" : $"(cores per socket {Cores})"; + + /// What the recommended MAXDOP is capped from, as a noun phrase: "this server's per-NUMA-node processor count" or "this database's vCores". + public string Source => FromVcores ? "this database's vCores" : "this server's per-NUMA-node processor count"; + + /// The same figure named without "this server's" / "this database's": "the per-NUMA-node processor count" or "the database's vCores". + public string Short => FromVcores ? "the database's vCores" : "the per-NUMA-node processor count"; + } + + /// Reads the off the SERVER_HARDWARE fact; cores_per_socket when it carries one, else vcore_count. + public static MaxdopBasis MaxdopBasisFrom(IReadOnlyDictionary facts) + { + if (!facts.TryGetValue("SERVER_HARDWARE", out var hardware)) + return default; + if (hardware.Metadata.TryGetValue("cores_per_socket", out var perSocket)) + return new MaxdopBasis((int)perSocket, FromVcores: false); + return hardware.Metadata.TryGetValue("vcore_count", out var vcores) + ? new MaxdopBasis((int)vcores, FromVcores: true) + : default; + } + /// /// Builds the server-level config action for a CONFIG_* finding (WS3), or null when the /// drill-down carries no bad server-config setting. Parallel to — diff --git a/PerformanceMonitor.Collectors/MemoryStatsCollector.cs b/PerformanceMonitor.Collectors/MemoryStatsCollector.cs index e2e82b06b..151551785 100644 --- a/PerformanceMonitor.Collectors/MemoryStatsCollector.cs +++ b/PerformanceMonitor.Collectors/MemoryStatsCollector.cs @@ -17,7 +17,8 @@ namespace PerformanceMonitor.Collectors; /// Server memory statistics from sys.dm_os_sys_memory + performance counters, with an Azure SQL /// DB variant (edition 5) that approximates from sys.dm_os_sys_info committed targets and reports /// current_workers_count as NULL — elastic pools enforce VIEW SERVER PERFORMANCE STATE on -/// sys.dm_os_schedulers regardless of DB-scoped grants (#857). Azure MI (edition 8) behaves like +/// sys.dm_os_schedulers regardless of DB-scoped grants (#857). The NULL is stored as NULL, not as 0, so a reader can tell +/// "not collected" from "no workers in use". Azure MI (edition 8) behaves like /// on-prem. Extracted verbatim from Lite's RemoteCollectorService.Memory.cs. Single row per cycle; /// zero rows when the query returns none. /// @@ -41,7 +42,7 @@ public readonly record struct Row( decimal BufferPoolMb, decimal PlanCacheMb, int MaxWorkersCount, - int CurrentWorkersCount); + int? CurrentWorkersCount); private const string AzureSqlDbQueryText = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; @@ -188,7 +189,7 @@ public override async ValueTask> ReadAsync(DbDataReader reader, Collec reader.IsDBNull(8) ? 0m : reader.GetDecimal(8), reader.IsDBNull(9) ? 0m : reader.GetDecimal(9), reader.IsDBNull(10) ? 0 : reader.GetInt32(10), - reader.IsDBNull(11) ? 0 : reader.GetInt32(11))); + reader.IsDBNull(11) ? null : reader.GetInt32(11))); return rows; } diff --git a/PerformanceMonitor.Common/ProvisioningVerdict.cs b/PerformanceMonitor.Common/ProvisioningVerdict.cs index beb16aa4d..22c1684f2 100644 --- a/PerformanceMonitor.Common/ProvisioningVerdict.cs +++ b/PerformanceMonitor.Common/ProvisioningVerdict.cs @@ -90,7 +90,8 @@ public static class ProvisioningVerdict /// Peak granted-over-target workspace memory, as a percentage. /// The instance's worker-thread ceiling; 0 or negative means unknown, which /// cannot imply saturation. - /// Workers in use at the latest sample. + /// Workers in use at the latest sample; null where the collector cannot read it (an + /// Azure SQL Database), which is unknown: it never counts as zero in use and it cannot imply saturation. public static string Evaluate( decimal avgCpuPercent, decimal maxCpuPercent, @@ -100,7 +101,7 @@ public static string Evaluate( long forcedGrants, decimal grantUtilizationPercent, int maxWorkers, - int currentWorkers) + int? currentWorkers) { /* Any of these three is a query that asked for workspace memory and did not simply get it. They are counts of events, not levels, so there is no threshold to tune — and on a fleet with no memory @@ -111,7 +112,8 @@ public static string Evaluate( rule the collector gates follow, where an unclassified target must never be gated off by assumption. */ var workerPressure = maxWorkers > 0 - && currentWorkers / (double)maxWorkers > HighWorkerRatio; + && currentWorkers is int inUse + && inUse / (double)maxWorkers > HighWorkerRatio; if (p95CpuPercent > HighCpuP95Percent || memoryPressure || workerPressure) { @@ -146,7 +148,7 @@ public static string UnderProvisionedReason( long grantTimeouts, long forcedGrants, int maxWorkers, - int currentWorkers) + int? currentWorkers) { if (p95CpuPercent > HighCpuP95Percent) { @@ -161,9 +163,9 @@ public static string UnderProvisionedReason( + $"{forcedGrants} forced grant(s). This server may need more memory."; } - if (maxWorkers > 0 && currentWorkers / (double)maxWorkers > HighWorkerRatio) + if (maxWorkers > 0 && currentWorkers is int inUse && inUse / (double)maxWorkers > HighWorkerRatio) { - return $"Worker threads are near the limit: {currentWorkers} of {maxWorkers} in use " + return $"Worker threads are near the limit: {inUse} of {maxWorkers} in use " + $"(threshold: {HighWorkerRatio:P0}). This server may need more CPU capacity."; } diff --git a/PerformanceMonitor.Common/ServerHardwareScope.cs b/PerformanceMonitor.Common/ServerHardwareScope.cs index e09b7e972..af12be1ef 100644 --- a/PerformanceMonitor.Common/ServerHardwareScope.cs +++ b/PerformanceMonitor.Common/ServerHardwareScope.cs @@ -15,25 +15,27 @@ namespace PerformanceMonitor.Common; /// /// Whose hardware the collected server_properties hardware columns describe. /// -/// On an Azure SQL Database (SERVERPROPERTY('EngineEdition') 5) sys.dm_os_sys_info reports the -/// HOST the database runs on, not what the database is given: a 1-vCore serverless General Purpose database showed -/// 2 logical CPUs, 0 sockets, 32 cores per socket, a hyperthread ratio of 64 and 911.9 GB of physical memory. What -/// does describe the database is the service objective (DATABASEPROPERTYEX('ServiceObjective'), for example +/// On an Azure SQL Database (SERVERPROPERTY('EngineEdition') 5) sys.dm_os_sys_info mixes two kinds of +/// figure. cpu_count and max_workers_count are the database's own: cpu_count is the number of +/// schedulers the database can see, which can be higher than its vCores (a 1-vCore General Purpose database reads 2, a +/// 2-vCore Hyperscale database reads 2), and the worker ceiling is the database's own too. Four figures are the HOST's: +/// physical_memory_mb (about 912 GB), socket_count, cores_per_socket and hyperthread_ratio. +/// What the database is GIVEN is its service objective (DATABASEPROPERTYEX('ServiceObjective'), for example /// GP_S_Gen5_1) and the vcore_count the collector parses out of it. /// -/// Every surface that SHOWS cpu_count, socket_count, cores_per_socket, -/// hyperthread_ratio or physical_memory_mb asks this class first, so the rule and the words live in -/// one place for both apps. The collector keeps storing the values as read. +/// Every surface that SHOWS socket_count, cores_per_socket, hyperthread_ratio or +/// physical_memory_mb asks this class first, so the rule and the words live in one place for both apps. The +/// collector keeps storing the values as read. cpu_count is shown as stored, as the database's own scheduler count. /// -/// Every calculation that would DIVIDE BY one of the host's CPU figures asks this class too: the attributed-CPU -/// denominator () and the FinOps utilization card's CPU count. On an Azure SQL Database each of -/// those uses the database's own figure where one is collected (vcore_count) and is otherwise NOT APPLICABLE. Neither -/// falls back to the host's count, because a number built from the host reads as the database's and is wrong. A DTU-model -/// objective or an elastic pool names no vCores, so its CPU count is not applicable. +/// Every calculation that would DIVIDE BY a CPU count asks this class too: the attributed-CPU denominator +/// () and the FinOps utilization card's CPU count. On an Azure SQL Database CPU percent is +/// measured against the vCores the service objective gives the database, not against the schedulers it can see, so those use +/// the database's vcore_count and are NOT APPLICABLE where the objective names none (a DTU-model objective or an +/// elastic pool). Neither falls back to cpu_count, because a percentage spread over a count above the vCores reads low. /// /// The host's memory is only what server_properties holds. memory_stats is a different table: on an /// Azure SQL Database its total_physical_memory_mb is filled from committed_target_kb, the database's own memory -/// limit (1,838 MB on a 1-vCore General Purpose database whose server_properties row holds 911.9 GB), and its buffer +/// limit (1,838 MB on a 1-vCore General Purpose database whose server_properties row holds about 912 GB), and its buffer /// pool and server-memory counters are the database's too. So what reads memory_stats (the FinOps utilization card's /// Physical Memory and Buffer Pool %, its verdict sentences and the health score's memory term) is shown and scored on every /// edition alike. Only the words change: on an Azure SQL Database the figure is the database's memory limit, not physical RAM. @@ -51,18 +53,20 @@ public static class ServerHardwareScope /// apps (the web Server Properties list reads the same payload). /// public const string McpHardwareNote = - "cpu_count, hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb are null on an Azure SQL Database: " + - "the host's hardware is not this database's allocation. Read service_objective and vcore_count for what the database is given."; + "hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb are null on an Azure SQL Database: " + + "they describe the host machine, not this database. cpu_count is the database's own scheduler count and can be higher " + + "than its vCores. Read service_objective and vcore_count for what the database is given."; - /// The five columns that describe the host on an Azure SQL Database, by their get_server_properties key. + /// The four columns that describe the host on an Azure SQL Database, by their get_server_properties key. public static readonly IReadOnlyList HostHardwareKeys = - ["cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; + ["hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; /// - /// Rewrites a get_server_properties payload for an Azure SQL Database: the five host-hardware keys become - /// null where they stand, vcore_count is inserted right after service_objective (null for a DTU-model - /// objective or an elastic pool, which name no vCores), and hardware_note is appended. Both apps call it, so the shape and the - /// words cannot drift apart. A payload of any other edition never reaches it. + /// Rewrites a get_server_properties payload for an Azure SQL Database: the four host-hardware keys become + /// null where they stand, cpu_count passes through (it is the database's own scheduler count), vcore_count + /// is inserted right after service_objective (null for a DTU-model objective or an elastic pool, which name no + /// vCores), and hardware_note is appended. Both apps call it, so the shape and the words cannot drift apart. A + /// payload of any other edition never reaches it. /// public static JsonObject ScopeServerPropertiesPayload(JsonObject payload, int? vcoreCount) { @@ -77,16 +81,19 @@ public static JsonObject ScopeServerPropertiesPayload(JsonObject payload, int? v /// The Hardware Note a FinOps Server Inventory row carries for an Azure SQL Database whose collector read gave no reason of its own. public const string InventoryHardwareNote = - "Azure SQL Database: the host's hardware is not this database's allocation; see its service objective."; + "Azure SQL Database: memory, sockets, cores per socket and hyperthread ratio are the host's, not this database's. " + + "Logical CPUs is the database's own scheduler count; see its service objective for its vCores."; /// What the FinOps utilization card shows for a CPU count that is not applicable (see ). public const string NotApplicable = "n/a"; /// - /// The CPU count a calculation may treat as the server's OWN. Off an Azure SQL Database it is the stored - /// , as it always was. On one the stored count is the HOST's, so the answer is the - /// the collector parsed from the service objective, and null (not applicable) - /// when the objective names none (a DTU-model objective, or an elastic pool). It is never the host's count. + /// The CPU count a calculation that divides CPU percent by it may use. Off an Azure SQL Database it is the stored + /// , as it always was. On one the stored count is the number of schedulers the database can see, + /// which can be higher than its vCores (a 1-vCore General Purpose database reads 2), and CPU percent there is measured + /// against the vCores. So the answer is the the collector parsed from the service objective, + /// and null (not applicable) when the objective names none (a DTU-model objective, or an elastic pool). It is never + /// the scheduler count. /// public static int? OwnCpuCount(int? engineEdition, int? cpuCount, int? vcoreCount) => HardwareIsTheHosts(engineEdition) @@ -144,24 +151,12 @@ public static string OverProvisionedExplanation(decimal avgCpuPct, int maxCpuPct HardwareIsTheHosts(engineEdition) ? null : physicalMemoryMb; /// - /// The worker-thread maximum a calculation may treat as the server's OWN. The stored figure is - /// sys.dm_os_sys_info.max_workers_count, which the engine derives from the same DMV's CPU count, so on an Azure SQL - /// Database it follows the host's CPUs and is not what the database is given. No database-scoped maximum is collected, so - /// there the answer is 0, which the provisioning verdict already reads as "unknown" and which never implies saturation. - /// Anywhere else it is the stored figure. + /// The FinOps utilization card's worker-thread text: "in use / maximum". The in-use count is NULL where the collector cannot + /// read it (an Azure SQL Database: its query hard-codes NULL), and then it reads , never 0. The + /// maximum is the engine's own figure on every edition and is shown as stored. /// - public static int OwnMaxWorkersCount(int? engineEdition, int maxWorkersCount) => - HardwareIsTheHosts(engineEdition) ? 0 : maxWorkersCount; - - /// - /// The FinOps utilization card's worker-thread text. Off an Azure SQL Database it is "in use / maximum", as it always was. On - /// one the maximum is not the database's and the in-use count is not collected (current_workers_count is NULL there), so - /// the card reads instead of a count against a host-derived maximum. - /// - public static string WorkerThreadsText(int? engineEdition, int currentWorkers, int maxWorkers) => - HardwareIsTheHosts(engineEdition) - ? NotApplicable - : string.Create(CultureInfo.CurrentCulture, $"{currentWorkers:N0} / {maxWorkers:N0}"); + public static string WorkerThreadsText(int? currentWorkers, int maxWorkers) => + string.Create(CultureInfo.CurrentCulture, $"{(currentWorkers is int inUse ? inUse.ToString("N0", CultureInfo.CurrentCulture) : NotApplicable)} / {maxWorkers:N0}"); /// /// The tooltip on the FinOps health score when it has no CPU term: the 24-hour window held no CPU sample, so CPU is left diff --git a/PerformanceMonitor.PlanAnalysis/ServerContextCard.cs b/PerformanceMonitor.PlanAnalysis/ServerContextCard.cs index 3b16859f0..c5fc59f40 100644 --- a/PerformanceMonitor.PlanAnalysis/ServerContextCard.cs +++ b/PerformanceMonitor.PlanAnalysis/ServerContextCard.cs @@ -7,6 +7,7 @@ */ using System.Collections.Generic; +using PerformanceMonitor.Common; namespace PerformanceMonitor.PlanAnalysis; @@ -57,13 +58,16 @@ public static List Rows(ServerMetadata? metadata) serverLine += $", {metadata.ProductVersion}"; rows.Add(new ServerContextRow("Server", serverLine)); - // Hardware — dropped entirely when CpuCount is 0 (no hardware facts captured), matching PS. The RAM clause is - // left off when no memory figure is carried: an Azure SQL Database's stored physical memory is the HOST's, so its - // readers hand over the database's vCores and no RAM (see ServerHardwareScope.OwnPhysicalMemoryMb). - if (metadata.CpuCount > 0) - rows.Add(new ServerContextRow("Hardware", metadata.PhysicalMemoryMB > 0 - ? $"{metadata.CpuCount} CPUs, {metadata.PhysicalMemoryMB:N0} MB RAM" - : $"{metadata.CpuCount} CPUs")); + // Hardware. An Azure SQL Database (engine edition 5) names the vCores its service objective gives it, labeled + // "vCores", and reads n/a where the objective names none (a DTU model or an elastic pool): its stored CPU count is the + // schedulers it can see, which can be higher than its vCores, and its stored memory is the HOST's, so neither is shown. + // Any other engine drops the row when CpuCount is 0 (no hardware facts captured), matching PS. + if (ServerHardwareScope.HardwareIsTheHosts(metadata.EngineEdition)) + rows.Add(new ServerContextRow("Hardware", metadata.VcoreCount is > 0 + ? $"{metadata.VcoreCount} vCores" + : ServerHardwareScope.NotApplicable)); + else if (metadata.CpuCount > 0) + rows.Add(new ServerContextRow("Hardware", $"{metadata.CpuCount} CPUs, {metadata.PhysicalMemoryMB:N0} MB RAM")); // Instance settings — PS always shows these three rows, even when the value is 0 (a real // "MAXDOP 0" or "cost threshold 0" is a fact worth showing, not a missing one). diff --git a/PerformanceMonitor.PlanAnalysis/ServerMetadata.cs b/PerformanceMonitor.PlanAnalysis/ServerMetadata.cs index 75773d2ed..ef74978b4 100644 --- a/PerformanceMonitor.PlanAnalysis/ServerMetadata.cs +++ b/PerformanceMonitor.PlanAnalysis/ServerMetadata.cs @@ -28,6 +28,18 @@ public class ServerMetadata public int CpuCount { get; set; } public long PhysicalMemoryMB { get; set; } + /// + /// SERVERPROPERTY('EngineEdition') of the stored properties row; null when it was not read. 5 is an Azure SQL + /// Database, whose Hardware row names its vCores and no RAM (see ). + /// + public int? EngineEdition { get; set; } + + /// + /// The vCores parsed from an Azure SQL Database's service objective; null for any other engine, and for an objective that + /// names none (a DTU-model objective or an elastic pool). + /// + public int? VcoreCount { get; set; } + // Instance settings public int MaxDop { get; set; } public int CostThresholdForParallelism { get; set; } From cd500fb11383cee3a29ff41d04da7c40d2b34817 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:12:07 -0400 Subject: [PATCH 08/17] Azure SQL Database: show the memory limit and Logical CPUs under their real names, and update the pins to the corrected host-figure rule Four columns are the host's (physical memory, sockets, cores per socket, hyperthread ratio). cpu_count is the database's own scheduler count, so it shows again and no calculation treats it as the host's. Memory-tab captions, the CPU unit label, the database-scoped MAXDOP advice text and the NULL in-use worker count are covered by tests that fail if the source is swapped. --- .../AzureSqlDatabaseHardwareTests.cs | 50 +- .../AzureSqlDatabaseHostLeftoversTests.cs | 405 ++++++++++++---- .../AzureSqlDatabaseHostMathTests.cs | 23 +- .../AzureSqlDatabaseMemoryScopeTests.cs | 12 +- .../PgFactCollector.Resources.cs | 5 +- .../Mcp/DarlingDataReader.cs | 22 +- .../Mcp/DarlingMcpDataTools.cs | 15 +- .../Mcp/DarlingMcpTools.cs | 2 +- .../wwwroot/js/pages/server-tabs.js | 9 +- .../wwwroot/js/panels.js | 5 +- .../wwwroot/js/view-templates.js | 9 +- .../FinOpsTab.Loaders.cs | 5 +- .../FinOpsTab.xaml | 2 +- .../ViewerDataService.FinOps.Utilization.cs | 8 +- .../ViewerDataService.Memory.cs | 20 +- .../ViewerServerTab.Memory.cs | 5 + .../ViewerServerTab.xaml | 4 +- Lite.Tests/AzureSqlDatabaseHardwareTests.cs | 34 +- .../AzureSqlDatabaseHostLeftoversTests.cs | 453 ++++++++++++++---- Lite.Tests/AzureSqlDatabaseHostMathTests.cs | 31 +- .../AzureSqlDatabaseMemoryScopeTests.cs | 13 +- Lite.Tests/FinOpsTests.cs | 8 +- Lite.Tests/MemoryCollectorsDefinitionTests.cs | 29 +- Lite.Tests/TestDataSeeder.cs | 3 +- .../Analysis/DuckDbFactCollector.Resources.cs | 5 +- Lite/Controls/FinOpsTab.xaml | 2 +- Lite/Controls/FinOpsTab.xaml.cs | 5 +- Lite/Controls/ServerTab.Charts.cs | 6 + Lite/Controls/ServerTab.xaml | 4 +- Lite/Mcp/McpAnalysisTools.cs | 2 +- Lite/Mcp/McpMemoryTools.cs | 5 +- Lite/Mcp/McpQueryTools.cs | 10 +- .../LocalDataService.FinOps.Utilization.cs | 8 +- Lite/Services/LocalDataService.Memory.cs | 18 +- Lite/Services/LocalDataService.ServerInfo.cs | 2 +- PerformanceMonitor.Analysis/FactAdvice.cs | 19 +- PerformanceMonitor.Common/CpuAttribution.cs | 22 +- .../ServerHardwareScope.cs | 29 +- 38 files changed, 989 insertions(+), 320 deletions(-) diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs index c09a80ec3..efb4a4711 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs @@ -20,11 +20,12 @@ namespace Darling.Tests; /// -/// On an Azure SQL Database (engine edition 5) the collected server_properties hardware columns are the -/// HOST's: a 1-vCore serverless General Purpose database read 2 logical CPUs, 0 sockets, 32 cores per socket, a -/// hyperthread ratio of 64 and 911.9 GB of physical memory. Nothing may present them as the database's; the service -/// objective and the vcore_count parsed from it describe the database. The memory_stats table is a different -/// source: its memory figures are the database's own and are pinned in . +/// On an Azure SQL Database (engine edition 5) four of the collected server_properties hardware columns are the +/// HOST's: a 1-vCore serverless General Purpose database read 0 sockets, 32 cores per socket, a hyperthread ratio of 64 and +/// 911.9 GB of physical memory. Nothing may present those as the database's. The fifth column, cpu_count, is the +/// database's own scheduler count (that database read 2), so it is shown as read. The service objective and the +/// vcore_count parsed from it describe the allocation. The memory_stats table is a different source: its memory +/// figures are the database's own and are pinned in . /// /// Pinned where the rule is applied: the get_server_properties payload (which the web Server Properties /// list reads through /api/read), the web list's own tiles, the FinOps Server Inventory row the grid binds, and @@ -33,8 +34,9 @@ namespace Darling.Tests; /// public sealed class AzureSqlDatabaseHardwareTests { + /// The four columns that describe the host on an Azure SQL Database. cpu_count is not among them. private static readonly string[] s_hostKeys = - ["cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; + ["hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; private static DarlingDataReader.ServerPropertiesReadRow HostRow(int engineEdition, string edition, string? objective, int? vcores) => new( new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), edition, "12.0.2000.8", "RTM", null, @@ -46,20 +48,24 @@ private static JsonElement Payload(DarlingDataReader.ServerPropertiesReadRow row // ── get_server_properties ── [Fact] - public void GetServerProperties_OnAzureSqlDatabase_ReturnsTheHostsFiveAsNull_AndTheDatabasesVcores_WithANote() + public void GetServerProperties_OnAzureSqlDatabase_ReturnsTheHostsFourAsNull_PassesItsOwnCpuCountThrough_AndReturnsTheVcores_WithANote() { var json = Payload(HostRow(5, "SQL Azure", "GP_S_Gen5_1", 1)); foreach (var key in s_hostKeys) Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + /* The stored count is the database's own scheduler count (2 for this 1-vCore database), not the host's, and not the vCores. */ + Assert.Equal(2, json.GetProperty("cpu_count").GetInt32()); + Assert.Equal("GP_S_Gen5_1", json.GetProperty("service_objective").GetString()); Assert.Equal(1, json.GetProperty("vcore_count").GetInt32()); var note = json.GetProperty("hardware_note").GetString(); Assert.Equal(ServerHardwareScope.McpHardwareNote, note); Assert.Contains("service_objective", note, StringComparison.Ordinal); Assert.Contains("vcore_count", note, StringComparison.Ordinal); - Assert.Contains("not this database's allocation", note, StringComparison.Ordinal); + Assert.Contains("describe the host machine, not this database", note, StringComparison.Ordinal); + Assert.Contains("cpu_count is the database's own scheduler count", note, StringComparison.Ordinal); var names = json.EnumerateObject().Select(p => p.Name).ToList(); Assert.Equal(names.IndexOf("service_objective") + 1, names.IndexOf("vcore_count")); @@ -67,12 +73,13 @@ public void GetServerProperties_OnAzureSqlDatabase_ReturnsTheHostsFiveAsNull_And } [Fact] - public void GetServerProperties_OnAzureSqlDatabase_WithNoVcores_StillHidesTheHost_AndReturnsANullVcoreCount() + public void GetServerProperties_OnAzureSqlDatabase_WithNoVcores_StillHidesTheHost_KeepsItsCpuCount_AndReturnsANullVcoreCount() { var json = Payload(HostRow(5, "SQL Azure", "S0", null)); foreach (var key in s_hostKeys) Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + Assert.Equal(2, json.GetProperty("cpu_count").GetInt32()); Assert.Equal(JsonValueKind.Null, json.GetProperty("vcore_count").ValueKind); Assert.True(json.TryGetProperty("hardware_note", out _)); } @@ -109,7 +116,7 @@ public void ServerPropertiesRead_CarriesTheStoredVcoreCount() // ── the web Server Properties list ── [Fact] - public void WebServerProperties_DescriptorHidesTheHostFive_OnEdition5_AndShowsTheServiceObjectiveAndVcores() + public void WebServerProperties_DescriptorHidesTheHostFour_OnEdition5_KeepsLogicalCpus_AndShowsTheServiceObjectiveAndVcores() { var js = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "server-tabs.js"); var start = js.IndexOf("const PROPERTY_STATS = [", StringComparison.Ordinal); @@ -123,6 +130,12 @@ public void WebServerProperties_DescriptorHidesTheHostFive_OnEdition5_AndShowsTh Assert.Contains("hideWhen: AZURE_SQL_DATABASE", line, StringComparison.Ordinal); } + /* Logical CPUs is the database's own scheduler count on edition 5, so its tile is drawn there like any other. */ + var cpuCount = list.Split('\n').Single(l => l.Contains("key: \"cpu_count\"", StringComparison.Ordinal)); + Assert.Contains("label: \"Logical CPUs\"", cpuCount, StringComparison.Ordinal); + Assert.DoesNotContain("hideWhen", cpuCount, StringComparison.Ordinal); + Assert.DoesNotContain("showWhen", cpuCount, StringComparison.Ordinal); + Assert.Contains("{ key: \"vcore_count\", label: \"vCores\", format: \"int\", showWhen: AZURE_SQL_DATABASE }", list, StringComparison.Ordinal); var objective = list.Split('\n').Single(l => l.Contains("key: \"service_objective\"", StringComparison.Ordinal)); Assert.DoesNotContain("hideWhen", objective, StringComparison.Ordinal); @@ -139,7 +152,7 @@ public void WebStatRenderer_DropsTilesByTheirCondition_OnEdition5_AndKeepsEveryT const m = await import(process.argv[1]); const H = { key: "engine_edition", equals: 5 }; const stats = [ - { key: "cpu_count", hideWhen: H }, { key: "physical_memory_mb", hideWhen: H }, + { key: "cpu_count" }, { key: "socket_count", hideWhen: H }, { key: "physical_memory_mb", hideWhen: H }, { key: "service_objective" }, { key: "vcore_count", showWhen: H }, ]; const names = (d) => m.visibleStats(stats, d).map((s) => s.key).join(","); @@ -161,23 +174,23 @@ public void WebStatRenderer_DropsTilesByTheirCondition_OnEdition5_AndKeepsEveryT var error = proc.StandardError.ReadToEnd(); Assert.True(proc.WaitForExit(20000) && proc.ExitCode == 0, "node failed: " + error); using var doc = JsonDocument.Parse(output); - Assert.Equal("service_objective,vcore_count", doc.RootElement.GetProperty("e5").GetString()); - Assert.Equal("cpu_count,physical_memory_mb,service_objective", doc.RootElement.GetProperty("e3").GetString()); - Assert.Equal("cpu_count,physical_memory_mb,service_objective", doc.RootElement.GetProperty("none").GetString()); + Assert.Equal("cpu_count,service_objective,vcore_count", doc.RootElement.GetProperty("e5").GetString()); + Assert.Equal("cpu_count,socket_count,physical_memory_mb,service_objective", doc.RootElement.GetProperty("e3").GetString()); + Assert.Equal("cpu_count,socket_count,physical_memory_mb,service_objective", doc.RootElement.GetProperty("none").GetString()); } } // ── FinOps Server Inventory row ── [Fact] - public void InventoryRow_OnAzureSqlDatabase_LeavesTheMemoryAndCoreCellsBlank_AndSaysWhy() + public void InventoryRow_OnAzureSqlDatabase_LeavesTheHostCellsBlank_ShowsItsOwnCpuCount_AndSaysWhy() { var row = new ServerPropertyRow { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, PhysicalMemoryMb = 933_888, SocketCount = 0, CoresPerSocket = 32, }; - Assert.Null(row.CpuCount); + Assert.Equal(2, row.CpuCount); Assert.Null(row.PhysicalMemoryMb); Assert.Null(row.SocketCount); Assert.Null(row.CoresPerSocket); @@ -188,12 +201,13 @@ public void InventoryRow_OnAzureSqlDatabase_LeavesTheMemoryAndCoreCellsBlank_And public void InventoryRow_OnAzureSqlDatabase_DoesNotDependOnTheOrderTheLoaderAssignsInAndKeepsAReadsOwnReason() { var row = new ServerPropertyRow { CpuCount = 2, PhysicalMemoryMb = 933_888, SocketCount = 0, CoresPerSocket = 32 }; - Assert.Equal(2, row.CpuCount); + Assert.Equal(933_888L, row.PhysicalMemoryMb); row.EngineEdition = 5; row.HardwareUnavailableReason = "Hardware read denied"; - Assert.Null(row.CpuCount); + Assert.Null(row.PhysicalMemoryMb); + Assert.Equal(2, row.CpuCount); Assert.Equal("Hardware read denied", row.HardwareUnavailableReason); } diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHostLeftoversTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHostLeftoversTests.cs index c1998aaf3..9ecfd4817 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseHostLeftoversTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseHostLeftoversTests.cs @@ -23,20 +23,27 @@ namespace Darling.Tests; /// -/// What left: on an Azure SQL Database (engine edition 5) sys.dm_os_sys_info -/// describes the HOST, so the stored server_properties.cpu_count, hyperthread_ratio, socket_count, -/// cores_per_socket and physical_memory_mb are the host's, and so is the max_workers_count that -/// memory_stats copies from the same DMV. Nothing may compute from them there. Where the database has its own figure -/// (vcore_count, parsed from the service objective) that is used; otherwise the answer is not applicable. +/// What left. On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info +/// returns four figures that describe the HOST machine and not the database: physical_memory_mb (about 912 GB), +/// socket_count, cores_per_socket and hyperthread_ratio. Nothing may compute from them there. The other two +/// columns it fills are the database's own: cpu_count is the number of schedulers the database can see (a 1-vCore General +/// Purpose database reads 2, and the count can be higher than the vCores) and max_workers_count is the database's own worker +/// ceiling, so both are shown as stored. What a CPU percent, a CPU count shown beside one, and a recommended MAXDOP are taken from +/// is NOT cpu_count: it is the vCores the service objective gives the database (vcore_count), and an objective that +/// names none (a DTU-model objective or an elastic pool) has no such count, so those answers read n/a. /// -/// The four things pinned here, each with an edition 5 case that has vCores, an edition 5 case without them (a DTU -/// objective), and the SQL Server / Managed Instance twin that must not move: +/// Pinned here, each with an edition 5 case that has vCores, an edition 5 case without them, and the SQL Server / Managed +/// Instance twin that must not move: /// -/// the SERVER_HARDWARE analysis fact, the LPIM advisory that reads its memory, and the plan Server Context card; -/// the FinOps Worker Threads card and the worker ceiling the provisioning verdict reads; +/// the SERVER_HARDWARE analysis fact (the vCores and none of the host's figures), the MAXDOP recommendation read from it, the +/// LPIM advisory that reads its memory, and the plan Server Context card; +/// the FinOps Worker Threads card: the in-use count is n/a where the collector could not read it (NULL), never 0, and the +/// ceiling is shown as stored; /// the memory utilization percentage, which is NOT changed: it divides memory_stats columns, which the collector /// fills from the database's own committed target, not from server_properties.physical_memory_mb; -/// the FinOps health score's CPU term, which is left out when the window holds no CPU sample (any edition). +/// the FinOps health score's CPU term, which is left out when the window holds no CPU sample (any edition); +/// the words over the Memory tab's first two figures (the viewer, and the web tiles over get_memory_stats), which on an +/// Azure SQL Database are the database's memory limit and the room left under it. /// /// /// The pure rules are run here; the PostgreSQL reads are pinned as text (their statements are public constants, and the @@ -53,21 +60,35 @@ public sealed class AzureSqlDatabaseHostLeftoversTests TimeRangeEnd = new DateTime(2026, 9, 30, 1, 0, 0, DateTimeKind.Utc), }; - // ── 1. SERVER_HARDWARE fact, LPIM advisory, Server Context card ── + // ── 1. SERVER_HARDWARE fact, MAXDOP recommendation, LPIM advisory, Server Context card ── + + private static Fact HardwareFact(bool azureSqlDatabase, int cpuCount, int coresPerSocket) => + FactCollectorHelpers.BuildServerHardwareFact( + Context(), hardwareIsTheHosts: azureSqlDatabase, + cpuCount: cpuCount, hyperthreadRatio: 64, physicalMemoryMb: 933_836, socketCount: 0, coresPerSocket: coresPerSocket, + hadrEnabled: false)!; + + private static Dictionary Facts(params Fact[] facts) => facts.ToDictionary(f => f.Key, f => f); + + private static Fact Config(string key, double value) => new() { Source = "config", Key = key, Value = value }; [Fact] public void ServerHardwareFact_OnAzureSqlDatabase_CarriesTheVcoresAndHadrOnly_NotTheHostsTopologyOrMemory() { + /* The host's four figures go in (hyperthread ratio 64, 933,836 MB, 0 sockets, 32 cores per socket); none may come out. */ var fact = FactCollectorHelpers.BuildServerHardwareFact( Context(), hardwareIsTheHosts: true, - cpuCount: 1, hyperthreadRatio: 64, physicalMemoryMb: 933_836, socketCount: 0, coresPerSocket: 32, hadrEnabled: false); + cpuCount: 4, hyperthreadRatio: 64, physicalMemoryMb: 933_836, socketCount: 0, coresPerSocket: 32, hadrEnabled: false); Assert.NotNull(fact); Assert.Equal("SERVER_HARDWARE", fact!.Key); Assert.Equal("config", fact.Source); - Assert.Equal(1, fact.Value); - Assert.Equal(new[] { "cpu_count", "hadr_enabled" }, fact.Metadata.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray()); - Assert.Equal(1, fact.Metadata["cpu_count"]); + Assert.Equal(4, fact.Value); + Assert.Equal( + new[] { "cpu_count", "hadr_enabled", "vcore_count" }, + fact.Metadata.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray()); + Assert.Equal(4, fact.Metadata["cpu_count"]); + Assert.Equal(4, fact.Metadata["vcore_count"]); Assert.Equal(0, fact.Metadata["hadr_enabled"]); } @@ -104,69 +125,168 @@ public void ServerHardwareFact_OffAzureSqlDatabase_IsTheStoredTopologyAsItAlways cpuCount: 0, hyperthreadRatio: 2, physicalMemoryMb: 65_536, socketCount: 2, coresPerSocket: 4, hadrEnabled: false)); } + /// The recommended MAXDOP on an Azure SQL Database follows its vCores. The host's 32 cores per socket go into the + /// fact builder every time, so a fact that carried them would make every row here read 8. + [Theory] + [InlineData(1, 1)] + [InlineData(2, 2)] + [InlineData(4, 4)] + [InlineData(8, 8)] + [InlineData(16, 8)] + [InlineData(80, 8)] + public void MaxdopBasis_OnAzureSqlDatabase_FollowsTheVcores_NeverTheHostsCoresPerSocket(int vcores, int expectedMaxdop) + { + var basis = FactRemediation.MaxdopBasisFrom(Facts(HardwareFact(azureSqlDatabase: true, cpuCount: vcores, coresPerSocket: 32))); + + Assert.True(basis.FromVcores); + Assert.Equal(vcores, basis.Cores); + Assert.Equal($"({vcores} vCores)", basis.Note); + Assert.Equal(expectedMaxdop, FactRemediation.RecommendedMaxdop(basis.Cores)); + } + + [Fact] + public void MaxdopBasis_OffAzureSqlDatabase_IsTheCoresPerSocketAsItAlwaysWas() + { + var basis = FactRemediation.MaxdopBasisFrom(Facts(HardwareFact(azureSqlDatabase: false, cpuCount: 16, coresPerSocket: 4))); + + Assert.False(basis.FromVcores); + Assert.Equal(4, basis.Cores); + Assert.Equal("(cores per socket 4)", basis.Note); + Assert.Equal(4, FactRemediation.RecommendedMaxdop(basis.Cores)); + Assert.Equal(8, FactRemediation.RecommendedMaxdop(FactRemediation.MaxdopBasisFrom( + Facts(HardwareFact(azureSqlDatabase: false, cpuCount: 64, coresPerSocket: 32))).Cores)); + } + + /// A DTU-model objective or an elastic pool gives the fact builder no vCores, so there is no fact: no basis, no figure + /// to state, and the recommendation is the long-standing cap of 8 with nothing said about cores. + [Fact] + public void MaxdopBasis_OnAzureSqlDatabaseWithNoVcores_IsNotApplicable_AndTheAdviceIsTheStaticCap() + { + var basis = FactRemediation.MaxdopBasisFrom(Facts()); + + Assert.Equal(0, basis.Cores); + Assert.False(basis.FromVcores); + Assert.Equal(string.Empty, basis.Note); + Assert.Equal(8, FactRemediation.RecommendedMaxdop(basis.Cores)); + + var advice = FactAdvice.Compose("CONFIG_MAXDOP", Facts(Config("CONFIG_MAXDOP", 0)))!; + Assert.Contains("Set MAXDOP to 8", advice.Remediation, StringComparison.Ordinal); + Assert.DoesNotContain("vCores", advice.Remediation, StringComparison.Ordinal); + Assert.DoesNotContain("cores per socket", advice.Remediation, StringComparison.Ordinal); + } + + /// The words an Azure SQL Database's advice uses are its vCores, never "cores per socket", and its MAXDOP is set with + /// the database-scoped statement (it has no instance option to configure). + [Fact] + public void MaxdopAdvice_OnAzureSqlDatabase_NamesTheVcores_NeverCoresPerSocket_AndTheDatabaseScopedStatement() + { + var hardware = HardwareFact(azureSqlDatabase: true, cpuCount: 4, coresPerSocket: 32); + + var zero = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 0)))!; + Assert.Contains("Set MAXDOP to 4", zero.Remediation, StringComparison.Ordinal); + Assert.Contains("this database's vCores capped at 8 (4 vCores)", zero.Remediation, StringComparison.Ordinal); + Assert.Contains("ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = 4", zero.Remediation, StringComparison.Ordinal); + + var one = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 1)))!; + Assert.Contains("set MAXDOP to 4 (vCores capped at 8 (4 vCores)) with ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = 4", one.Remediation, StringComparison.Ordinal); + + var above = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 16)))!; + Assert.Contains("above this database's topology-based guidance of 4", above.Headline, StringComparison.Ordinal); + Assert.Contains("Lower MAXDOP from 16 to 4 (vCores capped at 8 (4 vCores)", above.Remediation, StringComparison.Ordinal); + + var parallel = FactAdvice.Compose("THREADPOOL_PARALLEL", Facts(hardware, Config("CONFIG_MAXDOP", 0), Config("CONFIG_CTFP", 5)))!; + Assert.Contains("(4 vCores)", parallel.Remediation, StringComparison.Ordinal); + Assert.Contains("cap MAXDOP at 4 (this database's vCores, capped at 8)", parallel.Remediation, StringComparison.Ordinal); + + var clause = FactAdvice.Compose("CXPACKET", Facts(hardware, Config("CONFIG_MAXDOP", 0), Config("CONFIG_CTFP", 50)))!; + Assert.Contains("cap MAXDOP at 4 (the database's vCores, ≤ 8)", clause.Remediation, StringComparison.Ordinal); + + foreach (var text in new[] { zero.Remediation, one.Remediation, above.Remediation, above.Headline, parallel.Remediation, clause.Remediation }) + { + Assert.DoesNotContain("cores per socket", text, StringComparison.Ordinal); + Assert.DoesNotContain("cores-per-socket", text, StringComparison.Ordinal); + Assert.DoesNotContain("sp_configure", text, StringComparison.Ordinal); + } + } + + /// The same advice off an Azure SQL Database, word for word as it always was. + [Fact] + public void MaxdopAdvice_OffAzureSqlDatabase_IsTheLongStandingText() + { + var hardware = HardwareFact(azureSqlDatabase: false, cpuCount: 16, coresPerSocket: 4); + + var zero = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 0)))!; + Assert.Contains( + "Set MAXDOP to 4 — this server's cores-per-socket capped at 8 (cores per socket 4), the per-NUMA-node proxy; " + + "the SKU is irrelevant to the right value. The Apply button runs sp_configure + RECONFIGURE, an online metadata change.", + zero.Remediation, StringComparison.Ordinal); + + var one = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 1)))!; + Assert.Contains("set MAXDOP to 4 (cores-per-socket capped at 8 (cores per socket 4)) via sp_configure + RECONFIGURE, an online change.", + one.Remediation, StringComparison.Ordinal); + + var above = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 16)))!; + Assert.Contains("above this server's topology-based guidance of 4", above.Headline, StringComparison.Ordinal); + + var parallel = FactAdvice.Compose("THREADPOOL_PARALLEL", Facts(hardware, Config("CONFIG_MAXDOP", 0), Config("CONFIG_CTFP", 5)))!; + Assert.Contains("cost threshold for parallelism is 5 (cores per socket 4).", parallel.Remediation, StringComparison.Ordinal); + Assert.Contains("cap MAXDOP at 4 (this server's per-NUMA-node processor count, capped at 8)", parallel.Remediation, StringComparison.Ordinal); + } + [Fact] public void LpimAdvisory_OnAzureSqlDatabase_IsNotRaisedFromTheHostsMemory_AndIsUnchangedElsewhere() { + var context = Context(); + var onAzure = new List(); FactCollectorHelpers.EmitServerHealthFacts( - Context(), onAzure, "SQL Azure", physicalMemMb: 933_836, + context, onAzure, "SQL Azure", physicalMemMb: 933_836, lockPagesInMemory: false, instantFileInit: null, memoryDumpCount: null, hardwareIsTheHosts: true); Assert.DoesNotContain(onAzure, f => f.Key == "CONFIG_LPIM_DISABLED"); var onServer = new List(); FactCollectorHelpers.EmitServerHealthFacts( - Context(), onServer, "Enterprise Edition (64-bit)", physicalMemMb: 933_836, + context, onServer, "Enterprise Edition (64-bit)", physicalMemMb: 933_836, lockPagesInMemory: false, instantFileInit: null, memoryDumpCount: null); var lpim = Assert.Single(onServer, f => f.Key == "CONFIG_LPIM_DISABLED"); Assert.Equal(933_836, lpim.Metadata["physical_memory_mb"]); } [Fact] - public void ServerContextCard_ShowsTheVcoresWithNoRam_WhenNoMemoryFigureIsCarried_AndTheRamOtherwise() + public void ServerContextCard_OnAzureSqlDatabase_ShowsTheVcores_NeverTheSchedulerCountOrTheHostsRam_AndNaWhereThereAreNone() { static string? Hardware(ServerMetadata metadata) => ServerContextCard.Rows(metadata).SingleOrDefault(r => r.Label == "Hardware").Value; - Assert.Equal("1 CPUs", Hardware(new ServerMetadata { Edition = "SQL Azure", CpuCount = 1, PhysicalMemoryMB = 0 })); - Assert.Null(Hardware(new ServerMetadata { Edition = "SQL Azure", CpuCount = 0, PhysicalMemoryMB = 0 })); - Assert.Equal( - string.Format(CultureInfo.CurrentCulture, "8 CPUs, {0:N0} MB RAM", 65_536L), - Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", CpuCount = 8, PhysicalMemoryMB = 65_536 })); + /* The stored count (2) is the schedulers the database can see; its service objective gives it 1 vCore. The host's RAM, + had a reader let it through, is not printed either. */ + Assert.Equal("1 vCores", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = 1, PhysicalMemoryMB = 0 })); + Assert.Equal("1 vCores", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = 1, PhysicalMemoryMB = 933_836 })); + Assert.Equal("4 vCores", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = 4 })); + + /* A DTU-model objective or an elastic pool names no vCores: the row is still there, and it reads n/a, never the stored 2. */ + Assert.Equal("n/a", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = null })); + Assert.Equal("n/a", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = 0 })); + Assert.Equal("n/a", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 0, VcoreCount = null })); } [Fact] - public void FactCollectorAndServerMetadataReader_AskTheSharedRule_ForTheEditionsHardware() + public void ServerContextCard_OffAzureSqlDatabase_IsTheLongStandingRow() { - /* The PostgreSQL statement reads the edition beside the count and scopes the count in SQL, identically to Lite's DuckDB read. */ - Assert.Contains( - "SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, hyperthread_ratio", - PgFactCollector.ServerPropertiesSql, StringComparison.Ordinal); - Assert.Contains("memory_dump_count, engine_edition", PgFactCollector.ServerPropertiesSql, StringComparison.Ordinal); + static string? Hardware(ServerMetadata metadata) => + ServerContextCard.Rows(metadata).SingleOrDefault(r => r.Label == "Hardware").Value; - var config = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Analysis", "PgFactCollector.Config.cs"); - Assert.Contains("FactCollectorHelpers.BuildServerHardwareFact(", config, StringComparison.Ordinal); - Assert.Contains("lpim, ifi, dumpCount, hardwareIsTheHosts);", config, StringComparison.Ordinal); - Assert.DoesNotContain("[\"hyperthread_ratio\"] = htRatio", config, StringComparison.Ordinal); + var expected = string.Format(CultureInfo.CurrentCulture, "8 CPUs, {0:N0} MB RAM", 65_536L); + Assert.Equal(expected, Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = 3, CpuCount = 8, PhysicalMemoryMB = 65_536 })); + Assert.Equal(expected, Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = 8, CpuCount = 8, PhysicalMemoryMB = 65_536 })); + Assert.Equal(expected, Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = null, CpuCount = 8, PhysicalMemoryMB = 65_536 })); - Assert.Contains("engine_edition, vcore_count", DarlingServerMetadataReader.ServerMetadataSql, StringComparison.Ordinal); - Assert.Contains("props.engine_edition, props.vcore_count", DarlingServerMetadataReader.ServerMetadataSql, StringComparison.Ordinal); - var reader = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "DarlingServerMetadataReader.cs"); - Assert.Contains("ServerHardwareScope.OwnCpuCount(engineEdition, storedCpuCount, vcoreCount) ?? 0", reader, StringComparison.Ordinal); - Assert.Contains("ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L", reader, StringComparison.Ordinal); + /* A VcoreCount a non-Azure server somehow carries changes nothing, and no CPU count drops the row as it always did. */ + Assert.Equal(expected, Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = 3, CpuCount = 8, VcoreCount = 2, PhysicalMemoryMB = 65_536 })); + Assert.Null(Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = 3, CpuCount = 0, PhysicalMemoryMB = 0 })); } - // ── 2. Worker Threads card and the worker ceiling ── - - [Theory] - [InlineData(5, 512, 0)] // Azure SQL Database: the ceiling follows the host's CPUs, so none is carried - [InlineData(3, 512, 512)] // SQL Server: as stored - [InlineData(8, 2560, 2560)] // Managed Instance: as stored - [InlineData(1, 512, 512)] - [InlineData(null, 512, 512)] // edition not read: as stored - public void OwnMaxWorkersCount_IsNoneOnAzureSqlDatabase_AndTheStoredCeilingEverywhereElse(int? engineEdition, int stored, int expected) - { - Assert.Equal(expected, ServerHardwareScope.OwnMaxWorkersCount(engineEdition, stored)); - } + // ── 2. Worker Threads card and the verdict's worker term ── [Fact] public void OwnPhysicalMemoryMb_IsNotApplicableOnAzureSqlDatabase_AndTheStoredFigureEverywhereElse() @@ -180,44 +300,44 @@ public void OwnPhysicalMemoryMb_IsNotApplicableOnAzureSqlDatabase_AndTheStoredFi } [Fact] - public void WorkerThreadsText_IsNotApplicableOnAzureSqlDatabase_AndInUseOverMaximumEverywhereElse() + public void WorkerThreadsText_ReadsNotApplicableForAnInUseCountThatWasNotCollected_AndNeverZero() { - Assert.Equal("n/a", ServerHardwareScope.WorkerThreadsText(5, 0, 512)); - Assert.Equal("n/a", ServerHardwareScope.WorkerThreadsText(5, 450, 512)); - Assert.Equal("n/a", ServerHardwareScope.WorkerThreadsText(5, 0, 0)); + /* NULL in use is "not collected" (an Azure SQL Database stores NULL), and it is not 0: the ceiling beside it is the + database's own and is shown as stored. */ + Assert.Equal("n/a / 512", ServerHardwareScope.WorkerThreadsText(null, 512)); + Assert.Equal("n/a / 479", ServerHardwareScope.WorkerThreadsText(null, 479)); - foreach (var engineEdition in new int?[] { 1, 2, 3, 4, 8, null }) - { - Assert.Equal( - string.Format(CultureInfo.CurrentCulture, "{0:N0} / {1:N0}", 1_200, 2_560), - ServerHardwareScope.WorkerThreadsText(engineEdition, 1_200, 2_560)); - } + /* A genuine zero in use reads 0. */ + Assert.Equal("0 / 512", ServerHardwareScope.WorkerThreadsText(0, 512)); + + Assert.Equal( + string.Format(CultureInfo.CurrentCulture, "{0:N0} / {1:N0}", 1_200, 2_560), + ServerHardwareScope.WorkerThreadsText(1_200, 2_560)); } [Fact] - public void WorkerCeilingReads_ScopeTheCeilingToTheEdition_InAllThreePlaces() + public void Verdict_NeverReadsAnUnknownInUseWorkerCountAsSaturation() { - /* Point-in-time read: the ceiling goes through the shared rule before the verdict and the card see it. */ - var utilization = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Utilization.cs"); - Assert.Contains( - "var maxWorkers = ServerHardwareScope.OwnMaxWorkersCount(engineEdition, reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)));", - utilization, StringComparison.Ordinal); + /* Quiet CPU (50%) and no memory pressure: nothing but the worker term can decide this window. */ + static string Verdict(int maxWorkers, int? currentWorkers) => ProvisioningVerdict.Evaluate( + avgCpuPercent: 50m, maxCpuPercent: 50m, p95CpuPercent: 50m, maxGrantWaiters: 0, grantTimeouts: 0, forcedGrants: 0, + grantUtilizationPercent: 50m, maxWorkers, currentWorkers); - /* 7-day trend: each day's ceiling is 0 on an Azure SQL Database, from the newest server_properties row's edition. */ - Assert.Contains("CASE WHEN s.engine_edition = 5 THEN 0 ELSE COALESCE(m.max_workers_count, 0) END", ViewerDataService.ProvisioningTrendSql, StringComparison.Ordinal); - Assert.Contains("LEFT JOIN server_info s ON true", ViewerDataService.ProvisioningTrendSql, StringComparison.Ordinal); - Assert.Contains("SELECT engine_edition\n FROM server_properties\n WHERE server_id = $1", Lf(ViewerDataService.ProvisioningTrendSql), StringComparison.Ordinal); + Assert.Equal(ProvisioningVerdict.RightSized, Verdict(512, null)); + Assert.Equal(ProvisioningVerdict.RightSized, Verdict(512, 0)); + Assert.Equal(ProvisioningVerdict.RightSized, Verdict(512, 40)); + Assert.Equal(ProvisioningVerdict.UnderProvisioned, Verdict(512, 450)); + Assert.Equal(ProvisioningVerdict.RightSized, Verdict(0, 450)); // no ceiling known is not saturation - /* Fleet read: the latest ceiling per server is NULL on an Azure SQL Database, which the verdict reads as unknown. */ + Assert.Equal( + "No under-provisioning condition is currently met.", + ProvisioningVerdict.UnderProvisionedReason(50m, 0, 0, 0, 512, null)); Assert.Contains( - "CASE WHEN props.engine_edition = 5 THEN NULL ELSE latest.max_workers_count END AS max_workers_count", - ViewerDataService.ServerMetricsSql, StringComparison.Ordinal); - Assert.Contains(") AS props ON true", ViewerDataService.ServerMetricsSql, StringComparison.Ordinal); + "Worker threads are near the limit: 450 of 512 in use", + ProvisioningVerdict.UnderProvisionedReason(50m, 0, 0, 0, 512, 450), StringComparison.Ordinal); } - private static string Lf(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal); - - // ── 4. Health score: the CPU term ── + // ── 3. Health score: the CPU term ── [Fact] public void Overall_WithNoCpuScore_WeighsMemoryAndStorageOverTheirOwnSixtyPercent() @@ -290,13 +410,70 @@ public void HealthScore_WithACpuSample_IsTheLongStandingScore(int engineEdition) Assert.Equal(88, Window(engineEdition, ProvisioningVerdict.OverProvisioned, 0m).ComputeHealthScore()); } + // ── 4. Words: the CPU unit and the Memory tab ── + + [Fact] + public void CpuCountUnit_IsVcoresOnAzureSqlDatabase_AndCpusEverywhereElse() + { + Assert.Equal(" vCores,", ServerHardwareScope.CpuCountUnit(5)); + foreach (var engineEdition in new int?[] { 1, 2, 3, 4, 8, null }) + Assert.Equal(" CPUs,", ServerHardwareScope.CpuCountUnit(engineEdition)); + } + + [Fact] + public void MemoryTabLabels_NameTheDatabasesLimitOnAzureSqlDatabase_AndPhysicalMemoryEverywhereElse() + { + /* On an Azure SQL Database the collector stores the database's committed target as the first figure and the target + minus what is committed as the second, so neither is physical memory. */ + Assert.Equal("Memory limit", ServerHardwareScope.MemoryTabTotalLabel(5)); + Assert.Equal("Available under limit", ServerHardwareScope.MemoryTabAvailableLabel(5)); + + foreach (var engineEdition in new int?[] { 1, 2, 3, 4, 8, null }) + { + Assert.Equal("Physical Memory", ServerHardwareScope.MemoryTabTotalLabel(engineEdition)); + Assert.Equal("Available Physical", ServerHardwareScope.MemoryTabAvailableLabel(engineEdition)); + } + + /* The utilization card's caption and the Memory tab's label are the same words. */ + Assert.Equal(ServerHardwareScope.MemoryTabTotalLabel(5) + ": ", ServerHardwareScope.PhysicalMemoryCaption(5)); + Assert.Equal("Physical: ", ServerHardwareScope.PhysicalMemoryCaption(3)); + } + + // ── the wiring, pinned at the source ── + + [Fact] + public void FactCollectorAndServerMetadataReader_AskTheSharedRule_ForTheEditionsHardware() + { + /* The PostgreSQL statement reads the edition beside the count and scopes the count in SQL, identically to Lite's DuckDB read. */ + Assert.Contains( + "SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, hyperthread_ratio", + PgFactCollector.ServerPropertiesSql, StringComparison.Ordinal); + Assert.Contains("memory_dump_count, engine_edition", PgFactCollector.ServerPropertiesSql, StringComparison.Ordinal); + + var config = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Analysis", "PgFactCollector.Config.cs"); + Assert.Contains("FactCollectorHelpers.BuildServerHardwareFact(", config, StringComparison.Ordinal); + Assert.Contains("lpim, ifi, dumpCount, hardwareIsTheHosts);", config, StringComparison.Ordinal); + Assert.DoesNotContain("[\"hyperthread_ratio\"] = htRatio", config, StringComparison.Ordinal); + + Assert.Contains("engine_edition, vcore_count", DarlingServerMetadataReader.ServerMetadataSql, StringComparison.Ordinal); + Assert.Contains("props.engine_edition, props.vcore_count", DarlingServerMetadataReader.ServerMetadataSql, StringComparison.Ordinal); + var reader = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "DarlingServerMetadataReader.cs"); + Assert.Contains("PhysicalMemoryMB = ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L,", reader, StringComparison.Ordinal); + Assert.Contains("EngineEdition = engineEdition,", reader, StringComparison.Ordinal); + Assert.Contains("VcoreCount = vcoreCount,", reader, StringComparison.Ordinal); + } + [Fact] - public void FinOpsTab_AsksTheSharedRules_ForTheWorkerThreadsCard_TheHealthTooltip_AndTheInventoryCpuTerm() + public void FinOpsTab_AsksTheSharedRules_ForTheCpuUnit_TheWorkerThreadsCard_TheHealthTooltip_AndTheInventoryCpuTerm() { var tab = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "FinOpsTab.Loaders.cs"); + var xaml = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "FinOpsTab.xaml"); + + Assert.Contains("FinOpsCpuCountUnitText.Text = ServerHardwareScope.CpuCountUnit(data.EngineEdition);", tab, StringComparison.Ordinal); + Assert.Contains("x:Name=\"FinOpsCpuCountUnitText\"", xaml, StringComparison.Ordinal); Assert.Contains( - "FinOpsWorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.EngineEdition, data.CurrentWorkersCount, data.MaxWorkersCount);", + "FinOpsWorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.CurrentWorkersCount, data.MaxWorkersCount);", tab, StringComparison.Ordinal); Assert.DoesNotContain("$\"{data.CurrentWorkersCount:N0} / {data.MaxWorkersCount:N0}\"", tab, StringComparison.Ordinal); @@ -310,15 +487,40 @@ public void FinOpsTab_AsksTheSharedRules_ForTheWorkerThreadsCard_TheHealthToolti Assert.DoesNotContain("item.AvgCpuPct ?? 0m", tab, StringComparison.Ordinal); } - // ── 3. memory utilization: unchanged, it is memory_stats ── + [Fact] + public void WorkerReads_KeepANullInUseCountNull_InAllThreePlaces() + { + var utilization = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Utilization.cs"); + var inventory = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Inventory.cs"); + + /* Point-in-time read, 7-day trend and fleet read: the in-use count is read as NULL, not coalesced to 0. */ + Assert.Contains("int? currentWorkers = reader.IsDBNull(10) ? null : Convert.ToInt32(reader.GetValue(10));", utilization, StringComparison.Ordinal); + Assert.Contains("currentWorkers: reader.IsDBNull(10) ? (int?)null : Convert.ToInt32(reader.GetValue(10)));", utilization, StringComparison.Ordinal); + Assert.Contains("currentWorkers: reader.IsDBNull(7) ? (int?)null : Convert.ToInt32(reader.GetValue(7)));", inventory, StringComparison.Ordinal); + Assert.DoesNotContain("COALESCE(m.current_workers_count", utilization, StringComparison.Ordinal); + Assert.DoesNotContain("COALESCE(m.current_workers_count", inventory, StringComparison.Ordinal); + Assert.DoesNotContain("COALESCE(m.current_workers_count", ViewerDataService.ProvisioningTrendSql, StringComparison.Ordinal); + Assert.DoesNotContain("COALESCE(m.current_workers_count", ViewerDataService.ServerMetricsSql, StringComparison.Ordinal); + Assert.Contains("m.current_workers_count", ViewerDataService.ProvisioningTrendSql, StringComparison.Ordinal); + + /* The ceiling is the engine's own figure on every edition: no CASE zeroes it on an Azure SQL Database. */ + Assert.DoesNotContain("CASE WHEN s.engine_edition = 5 THEN 0 ELSE COALESCE(m.max_workers_count, 0) END", ViewerDataService.ProvisioningTrendSql, StringComparison.Ordinal); + Assert.DoesNotContain("CASE WHEN props.engine_edition = 5 THEN NULL ELSE latest.max_workers_count END", ViewerDataService.ServerMetricsSql, StringComparison.Ordinal); + } [Fact] public void MemoryUtilization_IsStillComputedFromMemoryStats_NotFromTheHostsPhysicalMemory() { /* The read is the memory_stats snapshot, which the collector fills from the database's own committed target on an - Azure SQL Database (1,838 MB for a 1-vCore General Purpose database), so dividing by it is database-scoped. */ - Assert.Contains("FROM v_memory_stats", DarlingDataReader.LatestMemoryStatsSql, StringComparison.Ordinal); - Assert.DoesNotContain("server_properties", DarlingDataReader.LatestMemoryStatsSql, StringComparison.Ordinal); + Azure SQL Database (1,838 MB for a 1-vCore General Purpose database), so dividing by it is database-scoped. The one + thing the statement takes from server_properties is the engine edition, which names the figures on the Memory tab. */ + foreach (var sql in new[] { DarlingDataReader.LatestMemoryStatsSql, ViewerDataService.LatestMemoryStatsSql }) + { + Assert.Contains("FROM v_memory_stats", sql, StringComparison.Ordinal); + Assert.Contains("SELECT sp.engine_edition", sql, StringComparison.Ordinal); + Assert.DoesNotContain("sp.physical_memory_mb", sql, StringComparison.Ordinal); + Assert.DoesNotContain("sp.cpu_count", sql, StringComparison.Ordinal); + } var tool = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"); Assert.Contains( @@ -326,4 +528,41 @@ public void MemoryUtilization_IsStillComputedFromMemoryStats_NotFromTheHostsPhys tool, StringComparison.Ordinal); Assert.Contains("memory_utilization_pct = Math.Round(utilization, 1),", tool, StringComparison.Ordinal); } + + [Fact] + public void MemoryTab_AsksTheSharedRule_ForTheNamesOfItsFirstTwoFigures_InTheViewer_TheMcpPayload_AndTheWebTiles() + { + var memory = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerServerTab.Memory.cs"); + var xaml = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerServerTab.xaml"); + Assert.Contains("PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(stats?.EngineEdition);", memory, StringComparison.Ordinal); + Assert.Contains("AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(stats?.EngineEdition);", memory, StringComparison.Ordinal); + Assert.Contains("x:Name=\"PhysicalMemoryLabel\"", xaml, StringComparison.Ordinal); + Assert.Contains("x:Name=\"AvailablePhysicalMemoryLabel\"", xaml, StringComparison.Ordinal); + + var dataService = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.Memory.cs"); + Assert.Contains("reader.IsDBNull(11) ? null : Convert.ToInt32(reader.GetValue(11)));", dataService, StringComparison.Ordinal); + var serviceReader = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingDataReader.cs"); + Assert.Contains("reader.IsDBNull(11) ? null : Convert.ToInt32(reader.GetValue(11)));", serviceReader, StringComparison.Ordinal); + + var tool = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"); + Assert.Contains("engine_edition = stats.EngineEdition", tool, StringComparison.Ordinal); + + /* The web tiles read get_memory_stats: the same two figures appear twice, each drawn only on its own side of the + engine_edition 5 condition (panels.js visibleStats), under the words the viewer uses. */ + var tabs = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "server-tabs.js"); + var templates = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "view-templates.js"); + var tabsStart = tabs.IndexOf("const MEMORY_STATS = [", StringComparison.Ordinal); + Assert.True(tabsStart > 0, "MEMORY_STATS is missing"); + var list = tabs[tabsStart..tabs.IndexOf("];", tabsStart, StringComparison.Ordinal)]; + /* The built-in page names the condition once; the ready-made dashboard is a literal the template tests read without + running it, so it writes the condition out in each tile. */ + foreach (var (source, condition) in new[] { (list, "AZURE_SQL_DATABASE"), (templates, "{ key: \"engine_edition\", equals: 5 }") }) + { + Assert.Contains($"{{ key: \"total_physical_memory_mb\", label: \"Physical\", format: \"mb\", hideWhen: {condition} }}", source, StringComparison.Ordinal); + Assert.Contains($"{{ key: \"total_physical_memory_mb\", label: \"Memory limit\", format: \"mb\", showWhen: {condition} }}", source, StringComparison.Ordinal); + Assert.Contains($"{{ key: \"available_physical_memory_mb\", label: \"Available\", format: \"mb\", hideWhen: {condition} }}", source, StringComparison.Ordinal); + Assert.Contains($"{{ key: \"available_physical_memory_mb\", label: \"Available under limit\", format: \"mb\", showWhen: {condition} }}", source, StringComparison.Ordinal); + } + Assert.Contains("const AZURE_SQL_DATABASE = { key: \"engine_edition\", equals: 5 };", tabs, StringComparison.Ordinal); + } } diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs index 02dcd642b..bb0fc0d31 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs @@ -17,15 +17,16 @@ namespace Darling.Tests; /// -/// On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info describes the HOST: a 1-vCore serverless -/// General Purpose database read 2 logical CPUs and 911.9 GB of physical memory. -/// pins that nothing SHOWS those server_properties values as the database's. These pins are the calculations that USED -/// the host's CPU count: the attributed-CPU denominator and the FinOps utilization card's CPU count. The memory figures come -/// from a different table and are pinned in . +/// On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info reports the HOST's memory, sockets and cores per +/// socket, while its cpu_count is the database's own scheduler count, which is not the CPU the database is given: a +/// 1-vCore serverless General Purpose database read 2. pins what is SHOWN. These +/// pins are the calculations that must count the CPU the database is GIVEN (its vCores) and not the schedulers it can see: +/// the attributed-CPU denominator and the FinOps utilization card's CPU count. The memory figures come from a different +/// table and are pinned in . /// /// The rule: on an Azure SQL Database each of those uses the database's own figure where one is collected (the /// vcore_count parsed from the service objective) and is otherwise NOT APPLICABLE. A DTU-model objective or an elastic -/// pool has no vCore count, so its CPU count is not applicable and nothing is computed from the host's. SQL Server +/// pool has no vCore count, so its CPU count is not applicable and nothing is computed from the stored scheduler count. SQL Server /// (editions 1 to 4) and Managed Instance (8) behave exactly as before, and every test has that twin. Lite.Tests pins the /// same table for the other app, in the same words. /// @@ -36,7 +37,7 @@ public sealed class AzureSqlDatabaseHostMathTests // ── CPU attribution ── - /// Half of one CPU for an hour is 1,800 CPU-seconds; half of the host's two would be 3,600. + /// Half of one CPU for an hour is 1,800 CPU-seconds; half of the two schedulers the database can see would be 3,600. private static CpuAttribution.Result Attribute(int? engineEdition, int storedCpuCount, int? vcoreCount) => CpuAttribution.Compute( rankedCpuSeconds: 900, s_start, s_end, @@ -44,7 +45,7 @@ private static CpuAttribution.Result Attribute(int? engineEdition, int storedCpu engineEdition, storedCpuCount, vcoreCount); [Fact] - public void Attribution_OnAzureSqlDatabase_WithVcores_DividesByTheVcores_NotTheHostsCpus() + public void Attribution_OnAzureSqlDatabase_WithVcores_DividesByTheVcores_NotTheStoredSchedulerCount() { var result = Attribute(5, storedCpuCount: 2, vcoreCount: 1); @@ -56,7 +57,7 @@ public void Attribution_OnAzureSqlDatabase_WithVcores_DividesByTheVcores_NotTheH [Theory] [InlineData(null)] [InlineData(0)] - public void Attribution_OnAzureSqlDatabase_WithNoVcores_IsNotApplicable_AndComputesNothingFromTheHost(int? vcoreCount) + public void Attribution_OnAzureSqlDatabase_WithNoVcores_IsNotApplicable_AndComputesNothingFromTheStoredCount(int? vcoreCount) { var result = Attribute(5, storedCpuCount: 2, vcoreCount); @@ -145,12 +146,12 @@ public void CpuCountText_OnEveryOtherEdition_IsTheCountAsItAlwaysWas(int? engine $"SELECT CASE WHEN engine_edition = {ServerHardwareScope.AzureSqlDatabaseEngineEdition} THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, engine_edition"; [Fact] - public void UtilizationRead_ResolvesTheCpuCountThroughTheEdition_NeverFallingBackToTheHostsCount() + public void UtilizationRead_ResolvesTheCpuCountThroughTheEdition_NeverFallingBackToTheStoredSchedulerCount() { var sql = ViewerDataService.UtilizationEfficiencySql; Assert.Contains(s_cpuCountCase, sql, StringComparison.Ordinal); - /* Only edition 5 lacks the fall-back: the bare COALESCE that took the host's count is gone. */ + /* Only edition 5 lacks the fall-back: the bare COALESCE that took the stored scheduler count is gone. */ Assert.DoesNotContain("SELECT COALESCE(vcore_count, cpu_count) AS cpu_count", sql, StringComparison.Ordinal); Assert.Equal(1, CountOf(sql, "COALESCE(vcore_count, cpu_count)")); } diff --git a/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs index 482425711..5b6c042a7 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs @@ -28,7 +28,7 @@ namespace Darling.Tests; /// So the FinOps utilization card's Physical Memory and Buffer Pool %, its verdict sentences and the health score's memory /// term, which all read memory_stats, are shown and scored on an Azure SQL Database exactly as on SQL Server. What reads /// server_properties (get_server_properties, the web Server Properties tiles, the Server Inventory hardware cells) -/// still hides the host's values. The figures here are the two tables' different values (1,838 MB and 933,836 MB), so a read that +/// still hides the host's memory, sockets, cores per socket and hyperthread ratio. The figures here are the two tables' different values (1,838 MB and 933,836 MB), so a read that /// takes the wrong table shows up as the wrong number. The Viewer's reads run against PostgreSQL, which this suite does not stand /// up, so they are pinned as SQL text. Lite.Tests pins the same table for the other app, in the same words. /// @@ -40,8 +40,9 @@ public sealed class AzureSqlDatabaseMemoryScopeTests /// What server_properties holds for the same database: the host's physical memory. private const long HostPhysicalMemoryMb = 933_836; + /// The four columns that describe the host on an Azure SQL Database. cpu_count is not among them. private static readonly string[] s_hostKeys = - ["cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; + ["hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; /// Comments wrap, so a pin on their words reads them with every run of whitespace as one space. private static string Flatten(string text) => Regex.Replace(text, @"\s+", " "); @@ -141,7 +142,7 @@ public void OverProvisionedSentence_OnAzureSqlDatabase_CitesTheBufferPoolShare_O onBox); } - // ── what reads server_properties stays the host's, and stays hidden ── + // ── what reads server_properties hides the host's four figures ── private static DarlingDataReader.ServerPropertiesReadRow StoredRow(int engineEdition) => new( new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), engineEdition == 5 ? "SQL Azure" : "Enterprise Edition (64-bit)", @@ -149,7 +150,7 @@ public void OverProvisionedSentence_OnAzureSqlDatabase_CitesTheBufferPoolShare_O engineEdition == 5 ? "GP_S_Gen5_1" : null, null, null, engineEdition == 5 ? 1 : null); [Fact] - public void ServerPropertiesReads_OnAzureSqlDatabase_StayTheHostsAndNull_WhateverMemoryStatsHolds() + public void ServerPropertiesReads_OnAzureSqlDatabase_HideTheHostsFourFigures_AndShowTheDatabasesOwnCpuCount() { var stored = StoredRow(5); /* The stored figure is the host's, which is why it is hidden: it is not memory_stats' 1,838. */ @@ -159,6 +160,7 @@ public void ServerPropertiesReads_OnAzureSqlDatabase_StayTheHostsAndNull_Whateve var json = JsonDocument.Parse(DarlingMcpDataTools.ServerPropertiesPayload("Srv", stored)).RootElement; foreach (var key in s_hostKeys) Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + Assert.Equal(2, json.GetProperty("cpu_count").GetInt32()); /* The FinOps Server Inventory row. */ var inventory = new ServerPropertyRow @@ -166,7 +168,7 @@ public void ServerPropertiesReads_OnAzureSqlDatabase_StayTheHostsAndNull_Whateve EngineEdition = stored.EngineEdition, CpuCount = stored.CpuCount, PhysicalMemoryMb = stored.PhysicalMemoryMb, SocketCount = stored.SocketCount, CoresPerSocket = stored.CoresPerSocket, }; - Assert.Null(inventory.CpuCount); + Assert.Equal(2, inventory.CpuCount); Assert.Null(inventory.PhysicalMemoryMb); Assert.Null(inventory.SocketCount); Assert.Null(inventory.CoresPerSocket); diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs index 5ad13965e..43a861b6f 100644 --- a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs @@ -34,8 +34,9 @@ ORDER BY collection_time DESC /// Collects memory stats: total physical RAM, buffer pool size, target memory — the newest sample /// within its collector's lookback of the window's end (#3896). /// These facts enable RESOURCE-based memory recommendations in the config audit: max server memory is - /// sized against the host's physical RAM, and no check in that audit branches on the edition (which the - /// payload reports for context only). + /// sized against the server's physical RAM (on an Azure SQL Database the collector stores the database's own memory limit in + /// that column, not the host's RAM), and no check in that audit branches on the edition (which the payload reports for + /// context only). /// private async Task CollectMemoryFactsAsync(AnalysisContext context, List facts) { diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs index ac318f6f5..27c483d7d 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs @@ -61,11 +61,14 @@ public sealed record WaitStatRow(string WaitType, long TotalWaitingTasks, long T public sealed record WaitTrendPoint(DateTime CollectionTime, double WaitTimeMsPerSecond, double SignalWaitTimeMsPerSecond); /// The latest memory_stats snapshot (Lite's MemoryStatsRow); utilization is - /// computed by the tool. + /// computed by the tool. EngineEdition is the server's engine edition from its latest server_properties row, null + /// when none is stored: on an Azure SQL Database (5) TotalPhysicalMemoryMb is the database's memory limit and + /// AvailablePhysicalMemoryMb the room left under it. public sealed record MemoryStatsRow( DateTime CollectionTime, double TotalPhysicalMemoryMb, double AvailablePhysicalMemoryMb, double TotalPageFileMb, double AvailablePageFileMb, string SystemMemoryState, string SqlMemoryModel, - double TargetServerMemoryMb, double TotalServerMemoryMb, double BufferPoolMb, double PlanCacheMb); + double TargetServerMemoryMb, double TotalServerMemoryMb, double BufferPoolMb, double PlanCacheMb, + int? EngineEdition = null); /// One memory clerk's footprint at the latest snapshot. public sealed record MemoryClerkRow(string ClerkType, double MemoryMb); @@ -272,7 +275,8 @@ public sealed record ServerListRow( /// where the engine cannot say, which is every SQL Server before 2022 and a real, common value rather than /// a miss. is the vCore count the collector parses from an Azure SQL Database's /// service objective (null off Azure SQL Database, and for a DTU-model objective or an elastic pool, which name no vCores) — what - /// describes the database there, where and its neighbours describe the HOST. + /// the database is given there, where is the schedulers it can see (possibly more than its vCores) and + /// the memory, socket, cores-per-socket and hyperthread figures beside it describe the HOST. public sealed record ServerPropertiesReadRow( DateTime CollectionTime, string Edition, string ProductVersion, string ProductLevel, string? ProductUpdateLevel, int EngineEdition, int CpuCount, int HyperthreadRatio, long PhysicalMemoryMb, int SocketCount, int CoresPerSocket, @@ -689,7 +693,14 @@ public static async Task> GetWaitTrendAsync( CAST(target_server_memory_mb AS double precision), CAST(total_server_memory_mb AS double precision), CAST(buffer_pool_mb AS double precision), - CAST(plan_cache_mb AS double precision) + CAST(plan_cache_mb AS double precision), + ( + SELECT sp.engine_edition + FROM v_server_properties AS sp + WHERE sp.server_id = $1 + ORDER BY sp.collection_time DESC + LIMIT 1 + ) FROM v_memory_stats WHERE server_id = $1 ORDER BY collection_time DESC @@ -719,7 +730,8 @@ LIMIT 1 reader.IsDBNull(7) ? 0 : reader.GetDouble(7), reader.IsDBNull(8) ? 0 : reader.GetDouble(8), reader.IsDBNull(9) ? 0 : reader.GetDouble(9), - reader.IsDBNull(10) ? 0 : reader.GetDouble(10)); + reader.IsDBNull(10) ? 0 : reader.GetDouble(10), + reader.IsDBNull(11) ? null : Convert.ToInt32(reader.GetValue(11))); } /// diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index 0bbf6e25d..cd3be319e 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -326,7 +326,10 @@ public static async Task GetMemoryStats( target_server_memory_mb = stats.TargetServerMemoryMb, total_server_memory_mb = stats.TotalServerMemoryMb, buffer_pool_mb = stats.BufferPoolMb, - plan_cache_mb = stats.PlanCacheMb + plan_cache_mb = stats.PlanCacheMb, + /* On an Azure SQL Database (5) total_physical_memory_mb is the database's memory limit and + available_physical_memory_mb the room left under it, not the host's RAM. */ + engine_edition = stats.EngineEdition }, McpHelpers.JsonOptions); } catch (Exception ex) when (ex is not OperationCanceledException) @@ -668,8 +671,9 @@ ratio is omitted rather than invented when a denominator piece is missing. The t await Task.WhenAll(cpuAggregateTask, propertiesTask); var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; - /* The core count is the server's own: on an Azure SQL Database the stored cpu_count is the HOST's, so this divides by its - vcore_count, or omits the ratio for a DTU-model objective or an elastic pool (see CpuAttribution). */ + /* The core count is what the server is given: on an Azure SQL Database the stored cpu_count is the schedulers it can see, + which can be more than its vCores, so this divides by its vcore_count, or omits the ratio for a DTU-model objective or + an elastic pool (see CpuAttribution). */ var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuUs) / 1_000_000.0, attrStart, attrEnd, @@ -835,8 +839,9 @@ same concurrent independent reads. */ await Task.WhenAll(cpuAggregateTask, propertiesTask); var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; - /* The core count is the server's own: on an Azure SQL Database the stored cpu_count is the HOST's, so this divides by its - vcore_count, or omits the ratio for a DTU-model objective or an elastic pool (see CpuAttribution). */ + /* The core count is what the server is given: on an Azure SQL Database the stored cpu_count is the schedulers it can see, + which can be more than its vCores, so this divides by its vcore_count, or omits the ratio for a DTU-model objective or + an elastic pool (see CpuAttribution). */ var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuUs) / 1_000_000.0, attrStart, attrEnd, diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs index 26bc3e708..4236a1a9e 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs @@ -776,7 +776,7 @@ the collector missed changes nothing about what the server is configured to. (Ph var totalDbSizeMb = factsByKey.TryGetValue("DATABASE_TOTAL_SIZE_MB", out var dbFact) ? dbFact.Value : 0; var editionName = AuditEditionName(edition); - /* cores_per_socket off an Azure SQL Database; there the fact carries its vCores instead, because the stored + /* The recommended MAXDOP follows the cores per socket the SERVER_HARDWARE fact carries. On an Azure SQL Database the fact carries its vCores instead, because the stored cores_per_socket is the host's (see FactRemediation.MaxdopBasisFrom). */ var maxdopBasis = FactRemediation.MaxdopBasisFrom(factsByKey); diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js index c30cd935c..0c9b88de4 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js @@ -2535,9 +2535,14 @@ const SCHEDULER_STATS = [ { key: "recommendation", label: "Recommendation", format: "text", small: true }, ]; +/* On an Azure SQL Database (engine_edition 5) the collector fills total_physical_memory_mb from the database's own committed + target and available_physical_memory_mb as that target minus what is committed, so the first two tiles are the database's + memory limit and the room left under it, not the host's RAM, and are named that way. */ const MEMORY_STATS = [ - { key: "total_physical_memory_mb", label: "Physical", format: "mb" }, - { key: "available_physical_memory_mb", label: "Available", format: "mb" }, + { key: "total_physical_memory_mb", label: "Physical", format: "mb", hideWhen: AZURE_SQL_DATABASE }, + { key: "total_physical_memory_mb", label: "Memory limit", format: "mb", showWhen: AZURE_SQL_DATABASE }, + { key: "available_physical_memory_mb", label: "Available", format: "mb", hideWhen: AZURE_SQL_DATABASE }, + { key: "available_physical_memory_mb", label: "Available under limit", format: "mb", showWhen: AZURE_SQL_DATABASE }, { key: "memory_utilization_pct", label: "Utilization", format: "pct" }, { key: "total_server_memory_mb", label: "Total server", format: "mb" }, { key: "target_server_memory_mb", label: "Target server", format: "mb" }, diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js index 8ebfedcab..5dff9c648 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js @@ -220,8 +220,9 @@ function cell(row, c) { /* A stat tile may depend on another value in the same payload: `hideWhen: { key, equals }` drops it when that value equals `equals`, `showWhen: { key, equals }` keeps it only then. The Server Properties list uses the pair on - `engine_edition`: an Azure SQL Database (5) reports the HOST's CPUs, sockets and memory, which are not the - database's, so those tiles are not drawn and its vCores tile is (a tile with neither field is always drawn). */ + `engine_edition`: an Azure SQL Database (5) reports the HOST's sockets, cores per socket, hyperthread ratio and + physical memory, which are not the database's, so those tiles are not drawn and its vCores tile is (a tile with + neither field is always drawn, so its own logical CPU count still is). */ export function visibleStats(stats, data) { return stats.filter((s) => { if (s.hideWhen && getPath(data, s.hideWhen.key) === s.hideWhen.equals) return false; diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js index cd0105274..23b8b544c 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js @@ -307,8 +307,13 @@ export const DASHBOARD_TEMPLATES = [ viz: "stat", span: 2, stats: [ - { key: "total_physical_memory_mb", label: "Physical", format: "mb" }, - { key: "available_physical_memory_mb", label: "Available", format: "mb" }, + /* On an Azure SQL Database (engine_edition 5) the memory figures are the database's memory limit and the room left + under it, so those tiles are captioned that way; panels.js reads `hideWhen` and `showWhen`. The condition is written + out in each tile because this file is a literal that the template tests read without running it. */ + { key: "total_physical_memory_mb", label: "Physical", format: "mb", hideWhen: { key: "engine_edition", equals: 5 } }, + { key: "total_physical_memory_mb", label: "Memory limit", format: "mb", showWhen: { key: "engine_edition", equals: 5 } }, + { key: "available_physical_memory_mb", label: "Available", format: "mb", hideWhen: { key: "engine_edition", equals: 5 } }, + { key: "available_physical_memory_mb", label: "Available under limit", format: "mb", showWhen: { key: "engine_edition", equals: 5 } }, { key: "memory_utilization_pct", label: "Utilization", format: "pct" }, { key: "total_server_memory_mb", label: "Total server", format: "mb" }, { key: "target_server_memory_mb", label: "Target server", format: "mb" }, diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs index 8951a1e8e..70cd9de6a 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs @@ -230,6 +230,7 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) FinOpsProvisioningStatusBorder.Background = new SolidColorBrush(Colors.Gray); FinOpsAvgCpuText.Text = FinOpsP95CpuText.Text = FinOpsMaxCpuText.Text = FinOpsCpuSamplesText.Text = "-"; FinOpsCpuCountText.Text = "-"; + FinOpsCpuCountUnitText.Text = ServerHardwareScope.CpuCountUnit(null); FinOpsWorkerThreadsText.Text = "-"; FinOpsAvgCpuBar.Width = FinOpsP95CpuBar.Width = FinOpsMaxCpuBar.Width = 0; FinOpsMemoryUtilBar.Width = FinOpsMemoryRatioBar.Width = 0; @@ -272,8 +273,10 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) FinOpsP95CpuText.Text = $"{data.P95CpuPct:N2}%"; FinOpsMaxCpuText.Text = $"{data.MaxCpuPct}%"; FinOpsCpuSamplesText.Text = data.CpuSamples.ToString("N0"); - /* n/a on an Azure SQL Database whose service objective names no vCores: the host's count is never shown as the database's. */ + /* On an Azure SQL Database the count is its vCores, named as vCores, and n/a where its service objective names none: the + scheduler count it can see is never shown as the CPU it is given. */ FinOpsCpuCountText.Text = ServerHardwareScope.CpuCountText(data.EngineEdition, data.CpuCount); + FinOpsCpuCountUnitText.Text = ServerHardwareScope.CpuCountUnit(data.EngineEdition); /* The in-use count is n/a where it was not collected (NULL on an Azure SQL Database), never 0; the maximum shows as stored. */ FinOpsWorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.CurrentWorkersCount, data.MaxWorkersCount); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml index 31896f3cf..9738e94b9 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml @@ -350,7 +350,7 @@ - + diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs index 9f0a3d7e8..1313f271b 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs @@ -54,10 +54,10 @@ FROM v_memory_stats ORDER BY collection_time DESC LIMIT 1 ), -/* cpu_count is the count the server itself has. On an Azure SQL Database (engine_edition 5) the stored cpu_count - describes the HOST (a 1-vCore serverless database read 2), so there it is the vcore_count parsed from the service - objective, and NULL for an objective that names no vCores (a DTU-model objective or an elastic pool): never the - host count. Every other edition reads as it always did. The same CASE is in the Lite read. */ +/* cpu_count is the count CPU percent is measured against. On an Azure SQL Database (engine_edition 5) the stored cpu_count + is the number of schedulers the database can see (a 1-vCore serverless database read 2), not what it is given, so there it + is the vcore_count parsed from the service objective, and NULL for an objective that names no vCores (a DTU-model objective + or an elastic pool): never the scheduler count. Every other edition reads as it always did. The same CASE is in the Lite read. */ server_info AS ( SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, engine_edition FROM server_properties diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs index 7924b74b9..4b77cfc07 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs @@ -21,7 +21,10 @@ namespace PerformanceMonitor.Darling.Viewer; /// MemoryStatsRow (LocalDataService.Memory.cs). The eight MB metrics are /// numeric(18,2) in the store and CAST to double precision in the read for the typed GetDouble /// reader; the two state strings are text. Lite's computed UsedPhysicalMemoryMb / -/// MemoryUtilizationPercent helpers are dropped — the summary strip displays only these fields. +/// MemoryUtilizationPercent helpers are dropped — the summary strip displays only these fields. +/// EngineEdition is the server's engine edition from its latest server_properties row, null when none is stored. On an +/// Azure SQL Database (5) TotalPhysicalMemoryMb is the database's memory limit and AvailablePhysicalMemoryMb the room +/// left under it, so the strip names them that way. public sealed record MemoryStatsRow( DateTime CollectionTime, double TotalPhysicalMemoryMb, @@ -33,7 +36,8 @@ public sealed record MemoryStatsRow( double TargetServerMemoryMb, double TotalServerMemoryMb, double BufferPoolMb, - double PlanCacheMb); + double PlanCacheMb, + int? EngineEdition = null); /// One point of the Memory Overview's memory-grant overlay line: total granted MB across all /// pools at a collection (or, for a singleton bucket, a raw collection). Lite crammed this into its @@ -131,7 +135,14 @@ public sealed partial class ViewerDataService CAST(target_server_memory_mb AS double precision) AS target_server_memory_mb, CAST(total_server_memory_mb AS double precision) AS total_server_memory_mb, CAST(buffer_pool_mb AS double precision) AS buffer_pool_mb, - CAST(plan_cache_mb AS double precision) AS plan_cache_mb + CAST(plan_cache_mb AS double precision) AS plan_cache_mb, + ( + SELECT sp.engine_edition + FROM v_server_properties AS sp + WHERE sp.server_id = $1 + ORDER BY sp.collection_time DESC + LIMIT 1 + ) AS engine_edition FROM v_memory_stats WHERE server_id = $1 ORDER BY collection_time DESC @@ -353,7 +364,8 @@ ORDER BY sample_time reader.IsDBNull(7) ? 0 : reader.GetDouble(7), reader.IsDBNull(8) ? 0 : reader.GetDouble(8), reader.IsDBNull(9) ? 0 : reader.GetDouble(9), - reader.IsDBNull(10) ? 0 : reader.GetDouble(10)); + reader.IsDBNull(10) ? 0 : reader.GetDouble(10), + reader.IsDBNull(11) ? null : Convert.ToInt32(reader.GetValue(11))); } /// Total granted MB across all pools per collection over the window — the Overview memory diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs index 97310aeee..28225b7d2 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs @@ -125,6 +125,11 @@ width and the nested one multiplies against a contention count that no longer ex /// The Overview summary strip — Lite's UpdateMemorySummary verbatim. private void RenderMemorySummary(MemoryStatsRow? stats) { + /* On an Azure SQL Database the first two figures are the database's memory limit and the room left under it, not the + host's RAM, so they are named that way (the same words the FinOps utilization card uses). */ + PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(stats?.EngineEdition); + AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(stats?.EngineEdition); + if (stats == null) { PhysicalMemoryText.Text = "--"; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml index b7a7fab44..c0cda7064 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml @@ -2142,7 +2142,7 @@ - + @@ -2162,7 +2162,7 @@ - + diff --git a/Lite.Tests/AzureSqlDatabaseHardwareTests.cs b/Lite.Tests/AzureSqlDatabaseHardwareTests.cs index b4f23fc71..b87af9aa8 100644 --- a/Lite.Tests/AzureSqlDatabaseHardwareTests.cs +++ b/Lite.Tests/AzureSqlDatabaseHardwareTests.cs @@ -21,11 +21,12 @@ namespace PerformanceMonitorLite.Tests; /// -/// On an Azure SQL Database (engine edition 5) the collected server_properties hardware columns are the -/// HOST's: a 1-vCore serverless General Purpose database read 2 logical CPUs, 0 sockets, 32 cores per socket, a -/// hyperthread ratio of 64 and 911.9 GB of physical memory. Nothing may present them as the database's. What does -/// describe the database is the service objective and the vcore_count parsed from it. The memory_stats table is -/// a different source: its memory figures are the database's own and are pinned in . +/// On an Azure SQL Database (engine edition 5) four of the collected server_properties hardware columns are the +/// HOST's: a 1-vCore serverless General Purpose database read 0 sockets, 32 cores per socket, a hyperthread ratio of 64 and +/// 911.9 GB of physical memory. Nothing may present those as the database's. The fifth column, cpu_count, is the database's +/// own scheduler count (that database read 2), so it is shown as read. What describes the allocation is the service objective and +/// the vcore_count parsed from it. The memory_stats table is a different source: its memory figures are the database's +/// own and are pinned in . /// /// The rule is pinned where it is applied: get_server_properties (the payload the web Server Properties /// list reads too), the FinOps Server Inventory row the grid binds, and the words the FinOps utilization card takes from @@ -65,26 +66,31 @@ public AzureSqlDatabaseHardwareTests(SharedDuckDbFixture fixture) private static JsonElement Payload(ServerPropertiesRow row) => JsonDocument.Parse(McpServerInfoTools.ServerPropertiesPayload("Srv", row)).RootElement.Clone(); + /// The four columns that describe the host on an Azure SQL Database. cpu_count is not among them. private static readonly string[] s_hostKeys = - ["cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; + ["hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; // ── get_server_properties ── [Fact] - public void GetServerProperties_OnAzureSqlDatabase_ReturnsTheHostsFiveAsNull_AndTheDatabasesVcores_WithANote() + public void GetServerProperties_OnAzureSqlDatabase_ReturnsTheHostsFourAsNull_PassesItsOwnCpuCountThrough_AndReturnsTheVcores_WithANote() { var json = Payload(HostRow(5, "SQL Azure", "GP_S_Gen5_1", 1)); foreach (var key in s_hostKeys) Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + /* The stored count is the database's own scheduler count (2 for this 1-vCore database), not the host's, and not the vCores. */ + Assert.Equal(2, json.GetProperty("cpu_count").GetInt32()); + Assert.Equal("GP_S_Gen5_1", json.GetProperty("service_objective").GetString()); Assert.Equal(1, json.GetProperty("vcore_count").GetInt32()); var note = json.GetProperty("hardware_note").GetString(); Assert.Equal(ServerHardwareScope.McpHardwareNote, note); Assert.Contains("service_objective", note, StringComparison.Ordinal); Assert.Contains("vcore_count", note, StringComparison.Ordinal); - Assert.Contains("not this database's allocation", note, StringComparison.Ordinal); + Assert.Contains("describe the host machine, not this database", note, StringComparison.Ordinal); + Assert.Contains("cpu_count is the database's own scheduler count", note, StringComparison.Ordinal); /* vcore_count sits beside service_objective, and the rest of the payload is still there. */ var names = json.EnumerateObject().Select(p => p.Name).ToList(); @@ -94,12 +100,13 @@ public void GetServerProperties_OnAzureSqlDatabase_ReturnsTheHostsFiveAsNull_And } [Fact] - public void GetServerProperties_OnAzureSqlDatabase_WithNoVcores_StillHidesTheHost_AndReturnsANullVcoreCount() + public void GetServerProperties_OnAzureSqlDatabase_WithNoVcores_StillHidesTheHost_KeepsItsCpuCount_AndReturnsANullVcoreCount() { var json = Payload(HostRow(5, "SQL Azure", "S0", null)); foreach (var key in s_hostKeys) Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + Assert.Equal(2, json.GetProperty("cpu_count").GetInt32()); Assert.Equal(JsonValueKind.Null, json.GetProperty("vcore_count").ValueKind); Assert.True(json.TryGetProperty("hardware_note", out _)); } @@ -165,14 +172,14 @@ INSERT INTO server_properties // ── FinOps Server Inventory row ── [Fact] - public void InventoryRow_OnAzureSqlDatabase_LeavesTheMemoryAndCoreCellsBlank_AndSaysWhy() + public void InventoryRow_OnAzureSqlDatabase_LeavesTheHostCellsBlank_ShowsItsOwnCpuCount_AndSaysWhy() { var row = new ServerPropertyRow { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, PhysicalMemoryMb = 933_888, SocketCount = 0, CoresPerSocket = 32, }; - Assert.Null(row.CpuCount); + Assert.Equal(2, row.CpuCount); Assert.Null(row.PhysicalMemoryMb); Assert.Null(row.SocketCount); Assert.Null(row.CoresPerSocket); @@ -183,12 +190,13 @@ public void InventoryRow_OnAzureSqlDatabase_LeavesTheMemoryAndCoreCellsBlank_And public void InventoryRow_OnAzureSqlDatabase_DoesNotDependOnTheOrderTheLoaderAssignsInAndKeepsAReadsOwnReason() { var row = new ServerPropertyRow { CpuCount = 2, PhysicalMemoryMb = 933_888, SocketCount = 0, CoresPerSocket = 32 }; - Assert.Equal(2, row.CpuCount); + Assert.Equal(933_888L, row.PhysicalMemoryMb); row.EngineEdition = 5; row.HardwareUnavailableReason = "Hardware read denied"; - Assert.Null(row.CpuCount); + Assert.Null(row.PhysicalMemoryMb); + Assert.Equal(2, row.CpuCount); Assert.Equal("Hardware read denied", row.HardwareUnavailableReason); } diff --git a/Lite.Tests/AzureSqlDatabaseHostLeftoversTests.cs b/Lite.Tests/AzureSqlDatabaseHostLeftoversTests.cs index e7e0d5662..9ba9d8d8e 100644 --- a/Lite.Tests/AzureSqlDatabaseHostLeftoversTests.cs +++ b/Lite.Tests/AzureSqlDatabaseHostLeftoversTests.cs @@ -26,20 +26,27 @@ namespace PerformanceMonitorLite.Tests; /// -/// What left: on an Azure SQL Database (engine edition 5) sys.dm_os_sys_info -/// describes the HOST, so the stored server_properties.cpu_count, hyperthread_ratio, socket_count, -/// cores_per_socket and physical_memory_mb are the host's, and so is the max_workers_count that -/// memory_stats copies from the same DMV. Nothing may compute from them there. Where the database has its own figure -/// (vcore_count, parsed from the service objective) that is used; otherwise the answer is not applicable. +/// What left. On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info +/// returns four figures that describe the HOST machine and not the database: physical_memory_mb (about 912 GB), +/// socket_count, cores_per_socket and hyperthread_ratio. Nothing may compute from them there. The other two +/// columns it fills are the database's own: cpu_count is the number of schedulers the database can see (a 1-vCore General +/// Purpose database reads 2, and the count can be higher than the vCores) and max_workers_count is the database's own worker +/// ceiling, so both are shown as stored. What a CPU percent, a CPU count shown beside one, and a recommended MAXDOP are taken from +/// is NOT cpu_count: it is the vCores the service objective gives the database (vcore_count), and an objective that +/// names none (a DTU-model objective or an elastic pool) has no such count, so those answers read n/a. /// -/// The four things pinned here, each with an edition 5 case that has vCores, an edition 5 case without them (a DTU -/// objective), and the SQL Server / Managed Instance twin that must not move: +/// Pinned here, each with an edition 5 case that has vCores, an edition 5 case without them, and the SQL Server / Managed +/// Instance twin that must not move: /// -/// the SERVER_HARDWARE analysis fact, the LPIM advisory that reads its memory, and the plan Server Context card; -/// the FinOps Worker Threads card and the worker ceiling the provisioning verdict reads; +/// the SERVER_HARDWARE analysis fact (the vCores and none of the host's figures), the MAXDOP recommendation read from it, the +/// LPIM advisory that reads its memory, and the plan Server Context card; +/// the FinOps Worker Threads card: the in-use count is n/a where the collector could not read it (NULL), never 0, and the +/// ceiling is shown as stored; /// the memory utilization percentage, which is NOT changed: it divides memory_stats columns, which the collector /// fills from the database's own committed target, not from server_properties.physical_memory_mb; -/// the FinOps health score's CPU term, which is left out when the window holds no CPU sample (any edition). +/// the FinOps health score's CPU term, which is left out when the window holds no CPU sample (any edition); +/// the words over the Memory tab's first two figures, which on an Azure SQL Database are the database's memory limit and the +/// room left under it. /// /// /// memory_stats seeds on edition 5 use what the collector stores there: the database's own memory limit @@ -48,21 +55,35 @@ namespace PerformanceMonitorLite.Tests; /// public sealed class AzureSqlDatabaseHostLeftoversTests { - // ── 1. SERVER_HARDWARE fact, LPIM advisory, Server Context card ── + // ── 1. SERVER_HARDWARE fact, MAXDOP recommendation, LPIM advisory, Server Context card ── + + private static Fact HardwareFact(bool azureSqlDatabase, int cpuCount, int coresPerSocket) => + FactCollectorHelpers.BuildServerHardwareFact( + TestDataSeeder.CreateTestContext(), hardwareIsTheHosts: azureSqlDatabase, + cpuCount: cpuCount, hyperthreadRatio: 64, physicalMemoryMb: 933_836, socketCount: 0, coresPerSocket: coresPerSocket, + hadrEnabled: false)!; + + private static Dictionary Facts(params Fact[] facts) => facts.ToDictionary(f => f.Key, f => f); + + private static Fact Config(string key, double value) => new() { Source = "config", Key = key, Value = value }; [Fact] public void ServerHardwareFact_OnAzureSqlDatabase_CarriesTheVcoresAndHadrOnly_NotTheHostsTopologyOrMemory() { + /* The host's four figures go in (hyperthread ratio 64, 933,836 MB, 0 sockets, 32 cores per socket); none may come out. */ var fact = FactCollectorHelpers.BuildServerHardwareFact( TestDataSeeder.CreateTestContext(), hardwareIsTheHosts: true, - cpuCount: 1, hyperthreadRatio: 64, physicalMemoryMb: 933_836, socketCount: 0, coresPerSocket: 32, hadrEnabled: false); + cpuCount: 4, hyperthreadRatio: 64, physicalMemoryMb: 933_836, socketCount: 0, coresPerSocket: 32, hadrEnabled: false); Assert.NotNull(fact); Assert.Equal("SERVER_HARDWARE", fact!.Key); Assert.Equal("config", fact.Source); - Assert.Equal(1, fact.Value); - Assert.Equal(new[] { "cpu_count", "hadr_enabled" }, fact.Metadata.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray()); - Assert.Equal(1, fact.Metadata["cpu_count"]); + Assert.Equal(4, fact.Value); + Assert.Equal( + new[] { "cpu_count", "hadr_enabled", "vcore_count" }, + fact.Metadata.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray()); + Assert.Equal(4, fact.Metadata["cpu_count"]); + Assert.Equal(4, fact.Metadata["vcore_count"]); Assert.Equal(0, fact.Metadata["hadr_enabled"]); } @@ -99,6 +120,114 @@ public void ServerHardwareFact_OffAzureSqlDatabase_IsTheStoredTopologyAsItAlways cpuCount: 0, hyperthreadRatio: 2, physicalMemoryMb: 65_536, socketCount: 2, coresPerSocket: 4, hadrEnabled: false)); } + /// The recommended MAXDOP on an Azure SQL Database follows its vCores. The host's 32 cores per socket go into the + /// fact builder every time, so a fact that carried them would make every row here read 8. + [Theory] + [InlineData(1, 1)] + [InlineData(2, 2)] + [InlineData(4, 4)] + [InlineData(8, 8)] + [InlineData(16, 8)] + [InlineData(80, 8)] + public void MaxdopBasis_OnAzureSqlDatabase_FollowsTheVcores_NeverTheHostsCoresPerSocket(int vcores, int expectedMaxdop) + { + var basis = FactRemediation.MaxdopBasisFrom(Facts(HardwareFact(azureSqlDatabase: true, cpuCount: vcores, coresPerSocket: 32))); + + Assert.True(basis.FromVcores); + Assert.Equal(vcores, basis.Cores); + Assert.Equal($"({vcores} vCores)", basis.Note); + Assert.Equal(expectedMaxdop, FactRemediation.RecommendedMaxdop(basis.Cores)); + } + + [Fact] + public void MaxdopBasis_OffAzureSqlDatabase_IsTheCoresPerSocketAsItAlwaysWas() + { + var basis = FactRemediation.MaxdopBasisFrom(Facts(HardwareFact(azureSqlDatabase: false, cpuCount: 16, coresPerSocket: 4))); + + Assert.False(basis.FromVcores); + Assert.Equal(4, basis.Cores); + Assert.Equal("(cores per socket 4)", basis.Note); + Assert.Equal(4, FactRemediation.RecommendedMaxdop(basis.Cores)); + Assert.Equal(8, FactRemediation.RecommendedMaxdop(FactRemediation.MaxdopBasisFrom( + Facts(HardwareFact(azureSqlDatabase: false, cpuCount: 64, coresPerSocket: 32))).Cores)); + } + + /// A DTU-model objective or an elastic pool gives the fact builder no vCores, so there is no fact: no basis, no figure + /// to state, and the recommendation is the long-standing cap of 8 with nothing said about cores. + [Fact] + public void MaxdopBasis_OnAzureSqlDatabaseWithNoVcores_IsNotApplicable_AndTheAdviceIsTheStaticCap() + { + var basis = FactRemediation.MaxdopBasisFrom(Facts()); + + Assert.Equal(0, basis.Cores); + Assert.False(basis.FromVcores); + Assert.Equal(string.Empty, basis.Note); + Assert.Equal(8, FactRemediation.RecommendedMaxdop(basis.Cores)); + + var advice = FactAdvice.Compose("CONFIG_MAXDOP", Facts(Config("CONFIG_MAXDOP", 0)))!; + Assert.Contains("Set MAXDOP to 8", advice.Remediation, StringComparison.Ordinal); + Assert.DoesNotContain("vCores", advice.Remediation, StringComparison.Ordinal); + Assert.DoesNotContain("cores per socket", advice.Remediation, StringComparison.Ordinal); + } + + /// The words an Azure SQL Database's advice uses are its vCores, never "cores per socket", and its MAXDOP is set with + /// the database-scoped statement (it has no instance option to configure). + [Fact] + public void MaxdopAdvice_OnAzureSqlDatabase_NamesTheVcores_NeverCoresPerSocket_AndTheDatabaseScopedStatement() + { + var hardware = HardwareFact(azureSqlDatabase: true, cpuCount: 4, coresPerSocket: 32); + + var zero = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 0)))!; + Assert.Contains("Set MAXDOP to 4", zero.Remediation, StringComparison.Ordinal); + Assert.Contains("this database's vCores capped at 8 (4 vCores)", zero.Remediation, StringComparison.Ordinal); + Assert.Contains("ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = 4", zero.Remediation, StringComparison.Ordinal); + + var one = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 1)))!; + Assert.Contains("set MAXDOP to 4 (vCores capped at 8 (4 vCores)) with ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = 4", one.Remediation, StringComparison.Ordinal); + + var above = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 16)))!; + Assert.Contains("above this database's topology-based guidance of 4", above.Headline, StringComparison.Ordinal); + Assert.Contains("Lower MAXDOP from 16 to 4 (vCores capped at 8 (4 vCores)", above.Remediation, StringComparison.Ordinal); + + var parallel = FactAdvice.Compose("THREADPOOL_PARALLEL", Facts(hardware, Config("CONFIG_MAXDOP", 0), Config("CONFIG_CTFP", 5)))!; + Assert.Contains("(4 vCores)", parallel.Remediation, StringComparison.Ordinal); + Assert.Contains("cap MAXDOP at 4 (this database's vCores, capped at 8)", parallel.Remediation, StringComparison.Ordinal); + + var clause = FactAdvice.Compose("CXPACKET", Facts(hardware, Config("CONFIG_MAXDOP", 0), Config("CONFIG_CTFP", 50)))!; + Assert.Contains("cap MAXDOP at 4 (the database's vCores, ≤ 8)", clause.Remediation, StringComparison.Ordinal); + + foreach (var text in new[] { zero.Remediation, one.Remediation, above.Remediation, above.Headline, parallel.Remediation, clause.Remediation }) + { + Assert.DoesNotContain("cores per socket", text, StringComparison.Ordinal); + Assert.DoesNotContain("cores-per-socket", text, StringComparison.Ordinal); + Assert.DoesNotContain("sp_configure", text, StringComparison.Ordinal); + } + } + + /// The same advice off an Azure SQL Database, word for word as it always was. + [Fact] + public void MaxdopAdvice_OffAzureSqlDatabase_IsTheLongStandingText() + { + var hardware = HardwareFact(azureSqlDatabase: false, cpuCount: 16, coresPerSocket: 4); + + var zero = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 0)))!; + Assert.Contains( + "Set MAXDOP to 4 — this server's cores-per-socket capped at 8 (cores per socket 4), the per-NUMA-node proxy; " + + "the SKU is irrelevant to the right value. The Apply button runs sp_configure + RECONFIGURE, an online metadata change.", + zero.Remediation, StringComparison.Ordinal); + + var one = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 1)))!; + Assert.Contains("set MAXDOP to 4 (cores-per-socket capped at 8 (cores per socket 4)) via sp_configure + RECONFIGURE, an online change.", + one.Remediation, StringComparison.Ordinal); + + var above = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 16)))!; + Assert.Contains("above this server's topology-based guidance of 4", above.Headline, StringComparison.Ordinal); + + var parallel = FactAdvice.Compose("THREADPOOL_PARALLEL", Facts(hardware, Config("CONFIG_MAXDOP", 0), Config("CONFIG_CTFP", 5)))!; + Assert.Contains("cost threshold for parallelism is 5 (cores per socket 4).", parallel.Remediation, StringComparison.Ordinal); + Assert.Contains("cap MAXDOP at 4 (this server's per-NUMA-node processor count, capped at 8)", parallel.Remediation, StringComparison.Ordinal); + } + [Fact] public void LpimAdvisory_OnAzureSqlDatabase_IsNotRaisedFromTheHostsMemory_AndIsUnchangedElsewhere() { @@ -119,31 +248,41 @@ public void LpimAdvisory_OnAzureSqlDatabase_IsNotRaisedFromTheHostsMemory_AndIsU } [Fact] - public void ServerContextCard_ShowsTheVcoresWithNoRam_WhenNoMemoryFigureIsCarried_AndTheRamOtherwise() + public void ServerContextCard_OnAzureSqlDatabase_ShowsTheVcores_NeverTheSchedulerCountOrTheHostsRam_AndNaWhereThereAreNone() { static string? Hardware(ServerMetadata metadata) => ServerContextCard.Rows(metadata).SingleOrDefault(r => r.Label == "Hardware").Value; - Assert.Equal("1 CPUs", Hardware(new ServerMetadata { Edition = "SQL Azure", CpuCount = 1, PhysicalMemoryMB = 0 })); - Assert.Null(Hardware(new ServerMetadata { Edition = "SQL Azure", CpuCount = 0, PhysicalMemoryMB = 0 })); - Assert.Equal( - string.Format(CultureInfo.CurrentCulture, "8 CPUs, {0:N0} MB RAM", 65_536L), - Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", CpuCount = 8, PhysicalMemoryMB = 65_536 })); - } + /* The stored count (2) is the schedulers the database can see; its service objective gives it 1 vCore. The host's RAM, + had a reader let it through, is not printed either. */ + Assert.Equal("1 vCores", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = 1, PhysicalMemoryMB = 0 })); + Assert.Equal("1 vCores", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = 1, PhysicalMemoryMB = 933_836 })); + Assert.Equal("4 vCores", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = 4 })); - // ── 2. Worker Threads card and the worker ceiling ── + /* A DTU-model objective or an elastic pool names no vCores: the row is still there, and it reads n/a, never the stored 2. */ + Assert.Equal("n/a", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = null })); + Assert.Equal("n/a", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = 0 })); + Assert.Equal("n/a", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 0, VcoreCount = null })); + } - [Theory] - [InlineData(5, 512, 0)] // Azure SQL Database: the ceiling follows the host's CPUs, so none is carried - [InlineData(3, 512, 512)] // SQL Server: as stored - [InlineData(8, 2560, 2560)] // Managed Instance: as stored - [InlineData(1, 512, 512)] - [InlineData(null, 512, 512)] // edition not read: as stored - public void OwnMaxWorkersCount_IsNoneOnAzureSqlDatabase_AndTheStoredCeilingEverywhereElse(int? engineEdition, int stored, int expected) + [Fact] + public void ServerContextCard_OffAzureSqlDatabase_IsTheLongStandingRow() { - Assert.Equal(expected, ServerHardwareScope.OwnMaxWorkersCount(engineEdition, stored)); + static string? Hardware(ServerMetadata metadata) => + ServerContextCard.Rows(metadata).SingleOrDefault(r => r.Label == "Hardware").Value; + + var expected = string.Format(CultureInfo.CurrentCulture, "8 CPUs, {0:N0} MB RAM", 65_536L); + Assert.Equal(expected, Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = 3, CpuCount = 8, PhysicalMemoryMB = 65_536 })); + Assert.Equal(expected, Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = 8, CpuCount = 8, PhysicalMemoryMB = 65_536 })); + Assert.Equal(expected, Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = null, CpuCount = 8, PhysicalMemoryMB = 65_536 })); + + /* A VcoreCount a non-Azure server somehow carries changes nothing, and no CPU count drops the row as it always did. */ + Assert.Equal(expected, Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = 3, CpuCount = 8, VcoreCount = 2, PhysicalMemoryMB = 65_536 })); + Assert.Null(Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = 3, CpuCount = 0, PhysicalMemoryMB = 0 })); } + // ── 2. Worker Threads card and the verdict's worker term ── + [Fact] public void OwnPhysicalMemoryMb_IsNotApplicableOnAzureSqlDatabase_AndTheStoredFigureEverywhereElse() { @@ -156,21 +295,44 @@ public void OwnPhysicalMemoryMb_IsNotApplicableOnAzureSqlDatabase_AndTheStoredFi } [Fact] - public void WorkerThreadsText_IsNotApplicableOnAzureSqlDatabase_AndInUseOverMaximumEverywhereElse() + public void WorkerThreadsText_ReadsNotApplicableForAnInUseCountThatWasNotCollected_AndNeverZero() { - Assert.Equal("n/a", ServerHardwareScope.WorkerThreadsText(5, 0, 512)); - Assert.Equal("n/a", ServerHardwareScope.WorkerThreadsText(5, 450, 512)); - Assert.Equal("n/a", ServerHardwareScope.WorkerThreadsText(5, 0, 0)); + /* NULL in use is "not collected" (an Azure SQL Database stores NULL), and it is not 0: the ceiling beside it is the + database's own and is shown as stored. */ + Assert.Equal("n/a / 512", ServerHardwareScope.WorkerThreadsText(null, 512)); + Assert.Equal("n/a / 479", ServerHardwareScope.WorkerThreadsText(null, 479)); - foreach (var engineEdition in new int?[] { 1, 2, 3, 4, 8, null }) - { - Assert.Equal( - string.Format(CultureInfo.CurrentCulture, "{0:N0} / {1:N0}", 1_200, 2_560), - ServerHardwareScope.WorkerThreadsText(engineEdition, 1_200, 2_560)); - } + /* A genuine zero in use reads 0. */ + Assert.Equal("0 / 512", ServerHardwareScope.WorkerThreadsText(0, 512)); + + Assert.Equal( + string.Format(CultureInfo.CurrentCulture, "{0:N0} / {1:N0}", 1_200, 2_560), + ServerHardwareScope.WorkerThreadsText(1_200, 2_560)); + } + + [Fact] + public void Verdict_NeverReadsAnUnknownInUseWorkerCountAsSaturation() + { + /* Quiet CPU (50%) and no memory pressure: nothing but the worker term can decide this window. */ + static string Verdict(int maxWorkers, int? currentWorkers) => ProvisioningVerdict.Evaluate( + avgCpuPercent: 50m, maxCpuPercent: 50m, p95CpuPercent: 50m, maxGrantWaiters: 0, grantTimeouts: 0, forcedGrants: 0, + grantUtilizationPercent: 50m, maxWorkers, currentWorkers); + + Assert.Equal(ProvisioningVerdict.RightSized, Verdict(512, null)); + Assert.Equal(ProvisioningVerdict.RightSized, Verdict(512, 0)); + Assert.Equal(ProvisioningVerdict.RightSized, Verdict(512, 40)); + Assert.Equal(ProvisioningVerdict.UnderProvisioned, Verdict(512, 450)); + Assert.Equal(ProvisioningVerdict.RightSized, Verdict(0, 450)); // no ceiling known is not saturation + + Assert.Equal( + "No under-provisioning condition is currently met.", + ProvisioningVerdict.UnderProvisionedReason(50m, 0, 0, 0, 512, null)); + Assert.Contains( + "Worker threads are near the limit: 450 of 512 in use", + ProvisioningVerdict.UnderProvisionedReason(50m, 0, 0, 0, 512, 450), StringComparison.Ordinal); } - // ── 4. Health score: the CPU term ── + // ── 3. Health score: the CPU term ── [Fact] public void Overall_WithNoCpuScore_WeighsMemoryAndStorageOverTheirOwnSixtyPercent() @@ -243,6 +405,35 @@ public void HealthScore_WithACpuSample_IsTheLongStandingScore(int engineEdition) Assert.Equal(88, Window(engineEdition, ProvisioningVerdict.OverProvisioned, 0m).ComputeHealthScore()); } + // ── 4. Words: the CPU unit and the Memory tab ── + + [Fact] + public void CpuCountUnit_IsVcoresOnAzureSqlDatabase_AndCpusEverywhereElse() + { + Assert.Equal(" vCores,", ServerHardwareScope.CpuCountUnit(5)); + foreach (var engineEdition in new int?[] { 1, 2, 3, 4, 8, null }) + Assert.Equal(" CPUs,", ServerHardwareScope.CpuCountUnit(engineEdition)); + } + + [Fact] + public void MemoryTabLabels_NameTheDatabasesLimitOnAzureSqlDatabase_AndPhysicalMemoryEverywhereElse() + { + /* On an Azure SQL Database the collector stores the database's committed target as the first figure and the target + minus what is committed as the second, so neither is physical memory. */ + Assert.Equal("Memory limit", ServerHardwareScope.MemoryTabTotalLabel(5)); + Assert.Equal("Available under limit", ServerHardwareScope.MemoryTabAvailableLabel(5)); + + foreach (var engineEdition in new int?[] { 1, 2, 3, 4, 8, null }) + { + Assert.Equal("Physical Memory", ServerHardwareScope.MemoryTabTotalLabel(engineEdition)); + Assert.Equal("Available Physical", ServerHardwareScope.MemoryTabAvailableLabel(engineEdition)); + } + + /* The utilization card's caption and the Memory tab's label are the same words. */ + Assert.Equal(ServerHardwareScope.MemoryTabTotalLabel(5) + ": ", ServerHardwareScope.PhysicalMemoryCaption(5)); + Assert.Equal("Physical: ", ServerHardwareScope.PhysicalMemoryCaption(3)); + } + // ── the wiring, pinned at the source ── private static string ReadRepoFile(string relativePath, [CallerFilePath] string thisFile = "") @@ -257,12 +448,16 @@ private static string ReadRepoFile(string relativePath, [CallerFilePath] string } [Fact] - public void FinOpsTab_AsksTheSharedRules_ForTheWorkerThreadsCard_TheHealthTooltip_AndTheInventoryCpuTerm() + public void FinOpsTab_AsksTheSharedRules_ForTheCpuUnit_TheWorkerThreadsCard_TheHealthTooltip_AndTheInventoryCpuTerm() { var tab = ReadRepoFile("Lite/Controls/FinOpsTab.xaml.cs"); + var xaml = ReadRepoFile("Lite/Controls/FinOpsTab.xaml"); + + Assert.Contains("CpuCountUnitText.Text = ServerHardwareScope.CpuCountUnit(data.EngineEdition);", tab, StringComparison.Ordinal); + Assert.Contains("x:Name=\"CpuCountUnitText\"", xaml, StringComparison.Ordinal); Assert.Contains( - "WorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.EngineEdition, data.CurrentWorkersCount, data.MaxWorkersCount);", + "WorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.CurrentWorkersCount, data.MaxWorkersCount);", tab, StringComparison.Ordinal); Assert.DoesNotContain("$\"{data.CurrentWorkersCount:N0} / {data.MaxWorkersCount:N0}\"", tab, StringComparison.Ordinal); @@ -277,18 +472,16 @@ public void FinOpsTab_AsksTheSharedRules_ForTheWorkerThreadsCard_TheHealthToolti } [Fact] - public void WorkerCeilingReads_ScopeTheCeilingToTheEdition_InAllThreePlaces() + public void WorkerReads_KeepANullInUseCountNull_InAllThreePlaces() { var utilization = ReadRepoFile("Lite/Services/LocalDataService.FinOps.Utilization.cs"); var fleet = ReadRepoFile("Lite/Services/LocalDataService.FinOps.ServerProperties.cs"); - Assert.Contains( - "var maxWorkers = ServerHardwareScope.OwnMaxWorkersCount(engineEdition, reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)));", - utilization, StringComparison.Ordinal); - Assert.Contains("CASE WHEN s.engine_edition = 5 THEN 0 ELSE COALESCE(m.max_workers_count, 0) END", utilization, StringComparison.Ordinal); - Assert.Contains("LEFT JOIN server_info s ON true", utilization, StringComparison.Ordinal); - Assert.Contains("CASE WHEN props.engine_edition = 5 THEN NULL ELSE latest.max_workers_count END AS max_workers_count", fleet, StringComparison.Ordinal); - Assert.Contains(") AS props ON true", fleet, StringComparison.Ordinal); + Assert.Contains("int? currentWorkers = reader.IsDBNull(10) ? null : Convert.ToInt32(reader.GetValue(10));", utilization, StringComparison.Ordinal); + Assert.Contains("currentWorkers: reader.IsDBNull(10) ? (int?)null : Convert.ToInt32(reader.GetValue(10)));", utilization, StringComparison.Ordinal); + Assert.Contains("currentWorkers: reader.IsDBNull(7) ? (int?)null : Convert.ToInt32(reader.GetValue(7)));", fleet, StringComparison.Ordinal); + Assert.DoesNotContain("COALESCE(m.current_workers_count, 0)", utilization, StringComparison.Ordinal); + Assert.DoesNotContain("COALESCE(m.current_workers_count, 0)", fleet, StringComparison.Ordinal); } [Fact] @@ -304,8 +497,9 @@ public void FactCollectorAndPlanReaders_AskTheSharedRule_ForTheEditionsHardware( var drillDown = ReadRepoFile("Lite/Analysis/DrillDownCollector.Plans.cs"); foreach (var source in new[] { planMetadata, drillDown }) { - Assert.Contains("ServerHardwareScope.OwnCpuCount(engineEdition, storedCpuCount, vcoreCount) ?? 0", source, StringComparison.Ordinal); - Assert.Contains("ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L", source, StringComparison.Ordinal); + Assert.Contains("PhysicalMemoryMB = ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L,", source, StringComparison.Ordinal); + Assert.Contains("EngineEdition = engineEdition,", source, StringComparison.Ordinal); + Assert.Contains("VcoreCount = vcoreCount,", source, StringComparison.Ordinal); } } @@ -321,6 +515,21 @@ public void MemoryUtilization_IsStillComputedFromMemoryStats_NotFromTheHostsPhys var tool = ReadRepoFile("Lite/Mcp/McpMemoryTools.cs"); Assert.Contains("memory_utilization_pct = Math.Round(stats.MemoryUtilizationPercent, 1),", tool, StringComparison.Ordinal); } + + [Fact] + public void MemoryTab_AsksTheSharedRule_ForTheNamesOfItsFirstTwoFigures() + { + var charts = ReadRepoFile("Lite/Controls/ServerTab.Charts.cs"); + var xaml = ReadRepoFile("Lite/Controls/ServerTab.xaml"); + + Assert.Contains("PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(stats?.EngineEdition);", charts, StringComparison.Ordinal); + Assert.Contains("AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(stats?.EngineEdition);", charts, StringComparison.Ordinal); + Assert.Contains("x:Name=\"PhysicalMemoryLabel\"", xaml, StringComparison.Ordinal); + Assert.Contains("x:Name=\"AvailablePhysicalMemoryLabel\"", xaml, StringComparison.Ordinal); + + var tool = ReadRepoFile("Lite/Mcp/McpMemoryTools.cs"); + Assert.Contains("engine_edition = stats.EngineEdition", tool, StringComparison.Ordinal); + } } /// @@ -348,8 +557,8 @@ public AzureSqlDatabaseHostLeftoversReadTests(SharedDuckDbFixture fixture) private async Task> CollectFactsAsync(int engineEdition, int? vcoreCount) { using var seeder = new TestDataSeeder(_fixture.DuckDb); - /* An Azure SQL Database as the collector stores it: the HOST's 2 logical CPUs, hyperthread ratio 64, 0 sockets, 32 - cores per socket and 911.9 GB, plus the service objective's vCores. */ + /* An Azure SQL Database as the collector stores it: the database's own 2 schedulers in cpu_count, the host's hyperthread + ratio 64, 0 sockets, 32 cores per socket and 911.9 GB, plus the service objective's vCores. */ await seeder.SeedServerPropertiesAsync( cpuCount: 2, htRatio: 64, physicalMemMb: 933_836, socketCount: 0, coresPerSocket: 32, edition: "SQL Azure", engineEdition: engineEdition, @@ -362,13 +571,23 @@ await seeder.SeedServerPropertiesAsync( [Fact] public async Task Collector_OnAzureSqlDatabaseWithVcores_EmitsTheVcoresAndNoHostTopologyOrMemory() { - var facts = await CollectFactsAsync(engineEdition: 5, vcoreCount: 1); + var facts = await CollectFactsAsync(engineEdition: 5, vcoreCount: 4); var hardware = Assert.Contains("SERVER_HARDWARE", facts); - Assert.Equal(1, hardware.Value); - Assert.Equal(new[] { "cpu_count", "hadr_enabled" }, hardware.Metadata.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray()); + Assert.Equal(4, hardware.Value); + Assert.Equal( + new[] { "cpu_count", "hadr_enabled", "vcore_count" }, + hardware.Metadata.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray()); + Assert.Equal(4, hardware.Metadata["cpu_count"]); + Assert.Equal(4, hardware.Metadata["vcore_count"]); Assert.DoesNotContain(facts.Values, f => f.Metadata.ContainsKey("physical_memory_mb")); Assert.DoesNotContain(facts.Values, f => f.Metadata.ContainsKey("cores_per_socket")); + + /* The recommended MAXDOP read off the collected fact follows the 4 vCores, not the 32 cores per socket stored beside them. */ + var basis = FactRemediation.MaxdopBasisFrom(facts); + Assert.True(basis.FromVcores); + Assert.Equal(4, basis.Cores); + Assert.Equal(4, FactRemediation.RecommendedMaxdop(basis.Cores)); } [Fact] @@ -379,6 +598,7 @@ public async Task Collector_OnAzureSqlDatabaseWithNoVcores_EmitsNoServerHardware Assert.DoesNotContain("SERVER_HARDWARE", facts.Keys); Assert.DoesNotContain(facts.Values, f => f.Metadata.ContainsKey("physical_memory_mb")); Assert.DoesNotContain(facts.Values, f => f.Metadata.ContainsKey("cores_per_socket")); + Assert.Equal(0, FactRemediation.MaxdopBasisFrom(facts).Cores); } [Theory] @@ -402,6 +622,7 @@ await seeder.SeedServerPropertiesAsync( Assert.Equal(2, hardware.Metadata["socket_count"]); Assert.Equal(4, hardware.Metadata["cores_per_socket"]); Assert.Equal(0, hardware.Metadata["hadr_enabled"]); + Assert.False(hardware.Metadata.ContainsKey("vcore_count")); } // ── 1. plan Server Context metadata: the two DuckDB readers ── @@ -434,19 +655,25 @@ INSERT INTO server_properties await cmd.ExecuteNonQueryAsync(); } - /// The database's vCores, never the host's CPU count or memory. + /// (edition, stored cpu_count, stored physical memory, vCores, expected CpuCount, expected RAM, expected Hardware row). + /// The stored count is carried as read (on an Azure SQL Database it is the database's own scheduler count, 2 for a 1-vCore + /// database), the host's RAM never, and the card shows the vCores. public static IEnumerable PlanMetadataCases() => [ - [5, 2, 933_836L, 1, 1, 0L], // Azure SQL Database with vCores: the vCores, no RAM - [5, 2, 933_836L, null, 0, 0L], // Azure SQL Database, DTU objective: no CPU count, no RAM (the card drops its Hardware row) - [3, 8, 65_536L, null, 8, 65_536L], // SQL Server: as stored - [8, 8, 65_536L, null, 8, 65_536L], // Managed Instance: as stored + [5, 2, 933_836L, 1, 2, 0L, "1 vCores"], // Azure SQL Database with vCores: the vCores, never the 2 it can see, no RAM + [5, 2, 933_836L, null, 2, 0L, "n/a"], // Azure SQL Database, DTU objective: the row is there and reads n/a + [3, 8, 65_536L, null, 8, 65_536L, "8 CPUs, {RAM} MB RAM"], // SQL Server: as stored + [8, 8, 65_536L, null, 8, 65_536L, "8 CPUs, {RAM} MB RAM"], // Managed Instance: as stored ]; + private static string HardwareRow(ServerMetadata metadata) => + ServerContextCard.Rows(metadata).Single(r => r.Label == "Hardware").Value; + [Theory] [MemberData(nameof(PlanMetadataCases))] - public async Task PlanMetadata_LocalDataServiceRead_CarriesTheDatabasesOwnHardware( - int engineEdition, int storedCpuCount, long storedPhysicalMemoryMb, int? vcoreCount, int expectedCpuCount, long expectedPhysicalMemoryMb) + public async Task PlanMetadata_LocalDataServiceRead_CarriesTheDatabasesOwnFigures( + int engineEdition, int storedCpuCount, long storedPhysicalMemoryMb, int? vcoreCount, int expectedCpuCount, + long expectedPhysicalMemoryMb, string expectedHardwareRow) { await SeedServerPropertiesAsync(ServerId, engineEdition, storedCpuCount, storedPhysicalMemoryMb, vcoreCount); @@ -455,12 +682,16 @@ public async Task PlanMetadata_LocalDataServiceRead_CarriesTheDatabasesOwnHardwa Assert.NotNull(metadata); Assert.Equal(expectedCpuCount, metadata!.CpuCount); Assert.Equal(expectedPhysicalMemoryMb, metadata.PhysicalMemoryMB); + Assert.Equal(engineEdition, metadata.EngineEdition); + Assert.Equal(vcoreCount, metadata.VcoreCount); + Assert.Equal(expectedHardwareRow.Replace("{RAM}", 65_536L.ToString("N0", CultureInfo.CurrentCulture)), HardwareRow(metadata)); } [Theory] [MemberData(nameof(PlanMetadataCases))] - public async Task PlanMetadata_DrillDownRead_CarriesTheDatabasesOwnHardware( - int engineEdition, int storedCpuCount, long storedPhysicalMemoryMb, int? vcoreCount, int expectedCpuCount, long expectedPhysicalMemoryMb) + public async Task PlanMetadata_DrillDownRead_CarriesTheDatabasesOwnFigures( + int engineEdition, int storedCpuCount, long storedPhysicalMemoryMb, int? vcoreCount, int expectedCpuCount, + long expectedPhysicalMemoryMb, string expectedHardwareRow) { await SeedServerPropertiesAsync(ServerId, engineEdition, storedCpuCount, storedPhysicalMemoryMb, vcoreCount); @@ -470,14 +701,17 @@ public async Task PlanMetadata_DrillDownRead_CarriesTheDatabasesOwnHardware( Assert.NotNull(metadata); Assert.Equal(expectedCpuCount, metadata!.CpuCount); Assert.Equal(expectedPhysicalMemoryMb, metadata.PhysicalMemoryMB); + Assert.Equal(engineEdition, metadata.EngineEdition); + Assert.Equal(vcoreCount, metadata.VcoreCount); + Assert.Equal(expectedHardwareRow.Replace("{RAM}", 65_536L.ToString("N0", CultureInfo.CurrentCulture)), HardwareRow(metadata)); } - // ── 2. worker ceiling: point-in-time read, 7-day trend, fleet read ── + // ── 2. in-use worker count: point-in-time read, 7-day trend, fleet read ── /// One server with a measured CPU window (average and p95 50%, so the verdict is RIGHT_SIZED unless something else - /// decides it) and the memory_stats the collector stores: on an Azure SQL Database the database's own memory limit - /// (1,838 MB for a 1-vCore General Purpose database), a ceiling of 512 workers copied from the host-derived DMV, and NO - /// in-use count (the collector stores NULL there). + /// decides it) and the memory_stats the collector stores: on an Azure SQL Database the database's own memory limit (1,838 MB + /// for a 1-vCore General Purpose database), the database's own ceiling of 512 workers, and NO in-use count (the collector + /// stores NULL there). private async Task SeedServerWithMemoryAsync(int serverId, int engineEdition, int? vcoreCount, int? currentWorkers) { await SeedServerPropertiesAsync(serverId, engineEdition, storedCpuCount: 2, storedPhysicalMemoryMb: engineEdition == 5 ? 933_836 : 65_536, vcoreCount); @@ -513,21 +747,23 @@ INSERT INTO memory_stats } } - /// (edition, vCores, in-use workers stored, expected ceiling, expected verdict). - public static IEnumerable WorkerCeilingCases() => + /// (edition, vCores, in-use workers stored, expected verdict, expected Worker Threads text). The ceiling is the stored + /// 512 on every edition. + public static IEnumerable WorkerCases() => [ - [5, 1, null, 0, "RIGHT_SIZED"], // Azure SQL Database with vCores, as stored: no in-use count, no ceiling - [5, null, null, 0, "RIGHT_SIZED"], // Azure SQL Database, DTU objective - [5, 1, 450, 0, "RIGHT_SIZED"], // even an in-use count against the host-derived 512 is not saturation - [3, null, 450, 512, "UNDER_PROVISIONED"], // SQL Server: 450 of 512 is worker saturation, as it always was - [8, null, 450, 512, "UNDER_PROVISIONED"], // Managed Instance: the same - [3, null, 40, 512, "RIGHT_SIZED"], + [5, 1, null, "RIGHT_SIZED", "n/a / 512"], // Azure SQL Database with vCores, as stored: no in-use count + [5, null, null, "RIGHT_SIZED", "n/a / 512"], // Azure SQL Database, DTU objective + [3, null, 450, "UNDER_PROVISIONED", "450 / 512"], // SQL Server: 450 of 512 is worker saturation, as it always was + [8, null, 450, "UNDER_PROVISIONED", "450 / 512"], // Managed Instance: the same + [3, null, 40, "RIGHT_SIZED", "40 / 512"], + [3, null, 0, "RIGHT_SIZED", "0 / 512"], // a genuine zero reads 0 + [3, null, null, "RIGHT_SIZED", "n/a / 512"], // a NULL on any edition reads n/a, never 0 ]; [Theory] - [MemberData(nameof(WorkerCeilingCases))] - public async Task UtilizationRead_CarriesNoWorkerCeilingOnAzureSqlDatabase_AndNeverCallsItSaturated( - int engineEdition, int? vcoreCount, int? currentWorkers, int expectedCeiling, string expectedVerdict) + [MemberData(nameof(WorkerCases))] + public async Task UtilizationRead_KeepsANullInUseCountNull_AndTheCeilingAsStored( + int engineEdition, int? vcoreCount, int? currentWorkers, string expectedVerdict, string expectedText) { await SeedServerWithMemoryAsync(ServerId, engineEdition, vcoreCount, currentWorkers); @@ -535,20 +771,18 @@ public async Task UtilizationRead_CarriesNoWorkerCeilingOnAzureSqlDatabase_AndNe Assert.NotNull(row); Assert.Equal(engineEdition, row!.EngineEdition); - Assert.Equal(expectedCeiling, row.MaxWorkersCount); - Assert.Equal(currentWorkers ?? 0, row.CurrentWorkersCount); + Assert.Equal(512, row.MaxWorkersCount); + Assert.Equal(currentWorkers, row.CurrentWorkersCount); Assert.Equal(expectedVerdict, row.ProvisioningStatus); - Assert.Equal( - engineEdition == 5 ? "n/a" : string.Format(CultureInfo.CurrentCulture, "{0:N0} / {1:N0}", currentWorkers ?? 0, 512), - ServerHardwareScope.WorkerThreadsText(row.EngineEdition, row.CurrentWorkersCount, row.MaxWorkersCount)); + Assert.Equal(expectedText, ServerHardwareScope.WorkerThreadsText(row.CurrentWorkersCount, row.MaxWorkersCount)); } [Theory] - [MemberData(nameof(WorkerCeilingCases))] - public async Task TrendRead_CarriesNoWorkerCeilingOnAzureSqlDatabase_AndNeverCallsADaySaturated( - int engineEdition, int? vcoreCount, int? currentWorkers, int expectedCeiling, string expectedVerdict) + [MemberData(nameof(WorkerCases))] + public async Task TrendRead_KeepsANullInUseCountNull_AndNeverCallsADaySaturated( + int engineEdition, int? vcoreCount, int? currentWorkers, string expectedVerdict, string expectedText) { - _ = expectedCeiling; + _ = expectedText; await SeedServerWithMemoryAsync(ServerId, engineEdition, vcoreCount, currentWorkers); var days = await new LocalDataService(_fixture.DuckDb).GetProvisioningTrendAsync(ServerId); @@ -558,16 +792,18 @@ public async Task TrendRead_CarriesNoWorkerCeilingOnAzureSqlDatabase_AndNeverCal } [Fact] - public async Task FleetRead_CarriesNoWorkerCeilingOnAzureSqlDatabase_AndNeverCallsAServerSaturated() + public async Task FleetRead_KeepsANullInUseCountNull_AndNeverCallsAServerSaturated() { const int azureVcores = ServerId - 1; const int azureDtu = ServerId - 2; const int sqlServer = ServerId - 3; const int managedInstance = ServerId - 4; - await SeedServerWithMemoryAsync(azureVcores, engineEdition: 5, vcoreCount: 1, currentWorkers: 450); - await SeedServerWithMemoryAsync(azureDtu, engineEdition: 5, vcoreCount: null, currentWorkers: 450); + const int sqlServerNoCount = ServerId - 5; + await SeedServerWithMemoryAsync(azureVcores, engineEdition: 5, vcoreCount: 1, currentWorkers: null); + await SeedServerWithMemoryAsync(azureDtu, engineEdition: 5, vcoreCount: null, currentWorkers: null); await SeedServerWithMemoryAsync(sqlServer, engineEdition: 3, vcoreCount: null, currentWorkers: 450); await SeedServerWithMemoryAsync(managedInstance, engineEdition: 8, vcoreCount: null, currentWorkers: 450); + await SeedServerWithMemoryAsync(sqlServerNoCount, engineEdition: 3, vcoreCount: null, currentWorkers: null); var metrics = await new LocalDataService(_fixture.DuckDb).GetServerMetricsAsync(); @@ -575,6 +811,7 @@ public async Task FleetRead_CarriesNoWorkerCeilingOnAzureSqlDatabase_AndNeverCal Assert.Equal("RIGHT_SIZED", metrics[azureDtu].ProvisioningStatus); Assert.Equal("UNDER_PROVISIONED", metrics[sqlServer].ProvisioningStatus); Assert.Equal("UNDER_PROVISIONED", metrics[managedInstance].ProvisioningStatus); + Assert.Equal("RIGHT_SIZED", metrics[sqlServerNoCount].ProvisioningStatus); } // ── 3. memory utilization: unchanged, it is memory_stats ── @@ -610,5 +847,31 @@ INSERT INTO memory_stats Assert.Equal(totalMb, stats!.TotalPhysicalMemoryMb); Assert.Equal((totalMb - availableMb) / totalMb * 100, stats.MemoryUtilizationPercent, precision: 6); Assert.InRange(stats.MemoryUtilizationPercent, 1, 99); + Assert.Equal(engineEdition, stats.EngineEdition); + } + + [Fact] + public async Task MemoryStatsRead_CarriesNoEngineEdition_WhenNoServerPropertiesRowIsStored() + { + using (var readLock = _fixture.DuckDb.AcquireReadLock()) + { + var conn = await SeedConnectionAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @" +INSERT INTO memory_stats + (collection_id, collection_time, server_id, server_name, total_physical_memory_mb, available_physical_memory_mb, + target_server_memory_mb, total_server_memory_mb, buffer_pool_mb) +VALUES ($1, $2, $3, $4, 1000, 400, 1000, 1000, 400)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); + cmd.Parameters.Add(new DuckDBParameter { Value = _now }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "LeftoversSrv" }); + await cmd.ExecuteNonQueryAsync(); + } + + var stats = await new LocalDataService(_fixture.DuckDb).GetLatestMemoryStatsAsync(ServerId); + + Assert.NotNull(stats); + Assert.Null(stats!.EngineEdition); } } diff --git a/Lite.Tests/AzureSqlDatabaseHostMathTests.cs b/Lite.Tests/AzureSqlDatabaseHostMathTests.cs index cf6fd74b6..393cfec8f 100644 --- a/Lite.Tests/AzureSqlDatabaseHostMathTests.cs +++ b/Lite.Tests/AzureSqlDatabaseHostMathTests.cs @@ -19,15 +19,16 @@ namespace PerformanceMonitorLite.Tests; /// -/// On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info describes the HOST: a 1-vCore serverless -/// General Purpose database read 2 logical CPUs and 911.9 GB of physical memory. -/// pins that nothing SHOWS those server_properties values as the database's. These pins are the calculations that USED -/// the host's CPU count: the attributed-CPU denominator and the FinOps utilization card's CPU count. The memory figures come -/// from a different table and are pinned in . +/// On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info reports the HOST's memory, sockets and cores per +/// socket, while its cpu_count is the database's own scheduler count, which is not the CPU the database is given: a +/// 1-vCore serverless General Purpose database read 2. pins what is SHOWN. These +/// pins are the calculations that must count the CPU the database is GIVEN (its vCores) and not the schedulers it can see: +/// the attributed-CPU denominator and the FinOps utilization card's CPU count. The memory figures come from a different +/// table and are pinned in . /// /// The rule: on an Azure SQL Database each of those uses the database's own figure where one is collected (the /// vcore_count parsed from the service objective) and is otherwise NOT APPLICABLE. A DTU-model objective or an elastic -/// pool has no vCore count, so its CPU count is not applicable and nothing is computed from the host's. SQL Server +/// pool has no vCore count, so its CPU count is not applicable and nothing is computed from the stored scheduler count. SQL Server /// (editions 1 to 4) and Managed Instance (8) behave exactly as before, and every test has that twin. The Darling.Tests twin /// pins the same table for the other app, in the same words. /// @@ -50,7 +51,7 @@ public AzureSqlDatabaseHostMathTests(SharedDuckDbFixture fixture) // ── CPU attribution ── - /// Half of one CPU for an hour is 1,800 CPU-seconds; half of the host's two would be 3,600. + /// Half of one CPU for an hour is 1,800 CPU-seconds; half of the two schedulers the database can see would be 3,600. private static CpuAttribution.Result Attribute(int? engineEdition, int storedCpuCount, int? vcoreCount) => CpuAttribution.Compute( rankedCpuSeconds: 900, s_start, s_end, @@ -58,7 +59,7 @@ private static CpuAttribution.Result Attribute(int? engineEdition, int storedCpu engineEdition, storedCpuCount, vcoreCount); [Fact] - public void Attribution_OnAzureSqlDatabase_WithVcores_DividesByTheVcores_NotTheHostsCpus() + public void Attribution_OnAzureSqlDatabase_WithVcores_DividesByTheVcores_NotTheStoredSchedulerCount() { var result = Attribute(5, storedCpuCount: 2, vcoreCount: 1); @@ -70,7 +71,7 @@ public void Attribution_OnAzureSqlDatabase_WithVcores_DividesByTheVcores_NotTheH [Theory] [InlineData(null)] [InlineData(0)] - public void Attribution_OnAzureSqlDatabase_WithNoVcores_IsNotApplicable_AndComputesNothingFromTheHost(int? vcoreCount) + public void Attribution_OnAzureSqlDatabase_WithNoVcores_IsNotApplicable_AndComputesNothingFromTheStoredCount(int? vcoreCount) { var result = Attribute(5, storedCpuCount: 2, vcoreCount); @@ -154,13 +155,13 @@ public void CpuCountText_OnEveryOtherEdition_IsTheCountAsItAlwaysWas(int? engine } [Theory] - [InlineData(5, 1, 1)] // Azure SQL Database, vCore objective: its vCores, not the host's 2 - [InlineData(5, null, 0)] // Azure SQL Database, DTU-model objective or elastic pool: no count, never the host's 2 + [InlineData(5, 1, 1)] // Azure SQL Database, vCore objective: its vCores, not the 2 schedulers it can see + [InlineData(5, null, 0)] // Azure SQL Database, DTU-model objective or elastic pool: no count, never the stored 2 [InlineData(3, null, 2)] // SQL Server: the stored count [InlineData(8, null, 2)] // Managed Instance: the stored count public async Task UtilizationRead_ResolvesTheCpuCountThroughTheEdition(int engineEdition, int? vcoreCount, int expectedCpuCount) { - await SeedAsync(engineEdition, hostCpuCount: 2, vcoreCount); + await SeedAsync(engineEdition, storedCpuCount: 2, vcoreCount); var row = await new LocalDataService(_fixture.DuckDb).GetUtilizationEfficiencyAsync(ServerId); @@ -236,7 +237,7 @@ public void FinOpsUtilizationCard_AsksTheSharedRule_ForTheCpuCountAndTheHealthSc // ── seeding ── - private async Task SeedAsync(int engineEdition, int hostCpuCount, int? vcoreCount) + private async Task SeedAsync(int engineEdition, int storedCpuCount, int? vcoreCount) { using var readLock = _fixture.DuckDb.AcquireReadLock(); if (_seedConn is null) @@ -254,11 +255,11 @@ INSERT INTO server_properties VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)"; void P(object? v) => cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); P(-487_002L); P(DateTime.UtcNow); P(ServerId); P("AzureHostMathSrv"); P("SQL Azure"); P("12.0.2000.8"); P("RTM"); - P(engineEdition); P(hostCpuCount); P(64); P(933_888L); P(0); P(32); P(vcoreCount.HasValue ? "GP_S_Gen5_" + vcoreCount : "S0"); P(vcoreCount); + P(engineEdition); P(storedCpuCount); P(64); P(933_888L); P(0); P(32); P(vcoreCount.HasValue ? "GP_S_Gen5_" + vcoreCount : "S0"); P(vcoreCount); await cmd.ExecuteNonQueryAsync(); } - /* memory_stats is not the host's table: on an Azure SQL Database its total_physical_memory_mb is the database's own + /* memory_stats is not the table that holds the host's memory: on an Azure SQL Database its total_physical_memory_mb is the database's own memory limit (committed_target_kb), 1,838 MB for a 1-vCore General Purpose database. */ using (var cmd = _seedConn.CreateCommand()) { diff --git a/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs b/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs index 6c52189a3..09b73d23c 100644 --- a/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs +++ b/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs @@ -30,7 +30,8 @@ namespace PerformanceMonitorLite.Tests; /// /// So the FinOps utilization card's Physical Memory and Buffer Pool %, its verdict sentences and the health score's memory /// term, which all read memory_stats, are shown and scored on an Azure SQL Database exactly as on SQL Server. What reads -/// server_properties (get_server_properties, the Server Inventory hardware cells) still hides the host's values. +/// server_properties (get_server_properties, the Server Inventory hardware cells) still hides the host's memory, +/// sockets, cores per socket and hyperthread ratio. /// Every test seeds BOTH tables with different values (1,838 MB and 933,836 MB), so a read that takes the wrong table shows up /// as the wrong number. The Darling.Tests twin pins the same table for the other app, in the same words. /// @@ -167,13 +168,14 @@ public void OverProvisionedSentence_OnAzureSqlDatabase_CitesTheBufferPoolShare_O onBox); } - // ── what reads server_properties stays the host's, and stays hidden ── + // ── what reads server_properties hides the host's four figures ── + /// The four columns that describe the host on an Azure SQL Database. cpu_count is not among them. private static readonly string[] s_hostKeys = - ["cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; + ["hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; [Fact] - public async Task ServerPropertiesReads_OnAzureSqlDatabase_StayTheHostsAndNull_WhateverMemoryStatsHolds() + public async Task ServerPropertiesReads_OnAzureSqlDatabase_HideTheHostsFourFigures_AndShowTheDatabasesOwnCpuCount() { await SeedAsync(engineEdition: 5); @@ -187,6 +189,7 @@ public async Task ServerPropertiesReads_OnAzureSqlDatabase_StayTheHostsAndNull_W var json = JsonDocument.Parse(McpServerInfoTools.ServerPropertiesPayload("Srv", stored)).RootElement; foreach (var key in s_hostKeys) Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + Assert.Equal(2, json.GetProperty("cpu_count").GetInt32()); /* The FinOps Server Inventory row. */ var inventory = new ServerPropertyRow @@ -194,7 +197,7 @@ public async Task ServerPropertiesReads_OnAzureSqlDatabase_StayTheHostsAndNull_W EngineEdition = stored.EngineEdition, CpuCount = stored.CpuCount, PhysicalMemoryMb = stored.PhysicalMemoryMb, SocketCount = stored.SocketCount, CoresPerSocket = stored.CoresPerSocket, }; - Assert.Null(inventory.CpuCount); + Assert.Equal(2, inventory.CpuCount); Assert.Null(inventory.PhysicalMemoryMb); Assert.Null(inventory.SocketCount); Assert.Null(inventory.CoresPerSocket); diff --git a/Lite.Tests/FinOpsTests.cs b/Lite.Tests/FinOpsTests.cs index e2c6b9920..b1c927ba7 100644 --- a/Lite.Tests/FinOpsTests.cs +++ b/Lite.Tests/FinOpsTests.cs @@ -76,21 +76,21 @@ public async Task AzureSqlDatabase_MemoryAndVmRightSizingAdviseNothing_BecauseIt { // The database uses 40,960 MB of its own 167,117 MB memory limit, a share that advises on any other edition. Its // service objective names 32 vCores, which are the database's own CPU count. Its memory cannot be resized on its own, - // so the memory and VM rules have nothing to recommend, and no text can carry the host's 911 GB from server_properties. + // so the memory and VM rules have nothing to recommend. var recs = await RunRecommendationsAsync(s => s.SeedRightSizingScenarioAsync(engineEdition: 5, withCpuSamples: true, vcoreCount: 32)); PrintRecommendations("AZURE SQL DATABASE MEMORY AND VM RULES", recs); Assert.DoesNotContain(recs, r => r.Finding.StartsWith("Memory over-provisioned", StringComparison.Ordinal)); Assert.DoesNotContain(recs, r => r.Category == "Hardware"); - Assert.DoesNotContain(recs, r => r.Finding.Contains("911GB", StringComparison.Ordinal)); // The CPU rule is not one of the two that stand down on a database: it reads the vCores the objective names. Assert.Contains(recs, r => r.Finding.StartsWith("CPU over-provisioned (32 cores", StringComparison.Ordinal)); } [Fact] - public async Task AzureSqlDatabaseWithNoVcores_CpuRightSizingAdvisesNothing_BecauseTheHostsCpuCountIsNotTheDatabases() + public async Task AzureSqlDatabaseWithNoVcores_CpuRightSizingAdvisesNothing_BecauseTheStoredSchedulerCountIsNotTheCpuItIsGiven() { - // A DTU-model objective names no vCores. The stored cpu_count is the HOST's 32, so the CPU rule has no count to work from. + // A DTU-model objective names no vCores. The stored cpu_count (32 here) is the schedulers the database can see, not the + // CPU it is given, so the CPU rule has no count to work from. var recs = await RunRecommendationsAsync(s => s.SeedRightSizingScenarioAsync(engineEdition: 5, withCpuSamples: true)); PrintRecommendations("AZURE SQL DATABASE, NO VCORES", recs); diff --git a/Lite.Tests/MemoryCollectorsDefinitionTests.cs b/Lite.Tests/MemoryCollectorsDefinitionTests.cs index 6b0b7e9f5..132ad1a17 100644 --- a/Lite.Tests/MemoryCollectorsDefinitionTests.cs +++ b/Lite.Tests/MemoryCollectorsDefinitionTests.cs @@ -58,8 +58,10 @@ public void NamesAndColumns_MatchDispatchAndSchema() MemoryStatsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray()); } + /// An Azure SQL Database's query returns NULL for the in-use worker count (it cannot read it). That NULL is stored as NULL, + /// not as 0, so a reader can tell "not collected" from "no workers in use". [Fact] - public async Task ReadAsync_MapsSingleRow_WithNullDefaults() + public async Task ReadAsync_KeepsANullInUseWorkerCountNull_NotZero() { using var reader = new FakeCollectorDataReader( new object[] { 64000m, 12000m, 8000m, 6000m, "Available", "Conventional", 48000m, 40000m, 30000m, 2000m, 704, DBNull.Value }); @@ -67,14 +69,35 @@ public async Task ReadAsync_MapsSingleRow_WithNullDefaults() var rows = await MemoryStatsCollector.Instance.ReadAsync(reader, CollectorTestContext.Make(s_deltas), CancellationToken.None); var row = Assert.Single(rows); - Assert.Equal(0, row.CurrentWorkersCount); + Assert.Null(row.CurrentWorkersCount); + Assert.Equal(704, row.MaxWorkersCount); Assert.Equal("Available", row.SystemMemoryState); var writer = new RecordingCollectorRowWriter(); MemoryStatsCollector.Instance.WritePayload(row, writer, CollectorTestContext.Make(s_deltas)); Assert.Equal(12, writer.Values.Count); Assert.Equal(64000m, writer.Values[0]); - Assert.Equal(0, writer.Values[11]); + Assert.Equal(704, writer.Values[10]); + Assert.Null(writer.Values[11]); + } + + [Fact] + public async Task ReadAsync_KeepsAnInUseWorkerCountAsRead_IncludingAGenuineZero() + { + foreach (var inUse in new[] { 0, 37 }) + { + using var reader = new FakeCollectorDataReader( + new object[] { 64000m, 12000m, 8000m, 6000m, "Available", "Conventional", 48000m, 40000m, 30000m, 2000m, 704, inUse }); + + var rows = await MemoryStatsCollector.Instance.ReadAsync(reader, CollectorTestContext.Make(s_deltas), CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Equal(inUse, row.CurrentWorkersCount); + + var writer = new RecordingCollectorRowWriter(); + MemoryStatsCollector.Instance.WritePayload(row, writer, CollectorTestContext.Make(s_deltas)); + Assert.Equal(inUse, writer.Values[11]); + } } [Fact] diff --git a/Lite.Tests/TestDataSeeder.cs b/Lite.Tests/TestDataSeeder.cs index 9065e5690..8fbf36d06 100644 --- a/Lite.Tests/TestDataSeeder.cs +++ b/Lite.Tests/TestDataSeeder.cs @@ -2143,7 +2143,8 @@ await SeedServerPropertiesAsync(cpuCount: 32, htRatio: 2, physicalMemMb: 262_144 /// is not a measurement), and the memory and VM rules stand down on Azure SQL Database (edition 5), whose memory comes /// with its service objective and cannot be resized on its own. /// - /// The two tables differ on edition 5. server_properties holds the HOST's 32 CPUs and 933,836 MB. memory_stats holds + /// The two tables differ on edition 5. server_properties holds the host's 933,836 MB and a cpu_count of 32, which is the + /// schedulers the database can see, not the CPU it is given. memory_stats holds /// the database's own memory limit and counters: 167,117 MB (about 163 GB, what a 32-vCore Gen5 database is given) with the /// same 40,960 MB buffer pool. Pass to give it the vCore count its service objective names, or /// leave it null for a DTU-model objective or an elastic pool, which has no CPU count of its own. Every other edition diff --git a/Lite/Analysis/DuckDbFactCollector.Resources.cs b/Lite/Analysis/DuckDbFactCollector.Resources.cs index e9cbb9f20..518cd5ea6 100644 --- a/Lite/Analysis/DuckDbFactCollector.Resources.cs +++ b/Lite/Analysis/DuckDbFactCollector.Resources.cs @@ -17,8 +17,9 @@ public partial class DuckDbFactCollector /// Collects memory stats: total physical RAM, buffer pool size, target memory — the newest sample /// within its collector's lookback of the window's end (#3896). /// These facts enable RESOURCE-based memory recommendations in the config audit: max server memory is - /// sized against the host's physical RAM, and no check in that audit branches on the edition (which the - /// payload reports for context only). + /// sized against the server's physical RAM (on an Azure SQL Database the collector stores the database's own memory limit in + /// that column, not the host's RAM), and no check in that audit branches on the edition (which the payload reports for + /// context only). /// private async Task CollectMemoryFactsAsync(AnalysisContext context, List facts) { diff --git a/Lite/Controls/FinOpsTab.xaml b/Lite/Controls/FinOpsTab.xaml index 876030eda..f9b840cc1 100644 --- a/Lite/Controls/FinOpsTab.xaml +++ b/Lite/Controls/FinOpsTab.xaml @@ -298,7 +298,7 @@ - + diff --git a/Lite/Controls/FinOpsTab.xaml.cs b/Lite/Controls/FinOpsTab.xaml.cs index 425f130d7..709800714 100644 --- a/Lite/Controls/FinOpsTab.xaml.cs +++ b/Lite/Controls/FinOpsTab.xaml.cs @@ -363,6 +363,7 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) ProvisioningStatusBorder.Background = new SolidColorBrush(Colors.Gray); AvgCpuText.Text = P95CpuText.Text = MaxCpuText.Text = CpuSamplesText.Text = "-"; CpuCountText.Text = "-"; + CpuCountUnitText.Text = ServerHardwareScope.CpuCountUnit(null); WorkerThreadsText.Text = "-"; AvgCpuBar.Width = P95CpuBar.Width = MaxCpuBar.Width = 0; MemoryUtilBar.Width = MemoryRatioBar.Width = 0; @@ -402,8 +403,10 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) P95CpuText.Text = $"{data.P95CpuPct:N2}%"; MaxCpuText.Text = $"{data.MaxCpuPct}%"; CpuSamplesText.Text = data.CpuSamples.ToString("N0"); - /* n/a on an Azure SQL Database whose service objective names no vCores: the host's count is never shown as the database's. */ + /* On an Azure SQL Database the count is its vCores, named as vCores, and n/a where its service objective names none: the + scheduler count it can see is never shown as the CPU it is given. */ CpuCountText.Text = ServerHardwareScope.CpuCountText(data.EngineEdition, data.CpuCount); + CpuCountUnitText.Text = ServerHardwareScope.CpuCountUnit(data.EngineEdition); /* The in-use count is n/a where it was not collected (NULL on an Azure SQL Database), never 0; the maximum shows as stored. */ WorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.CurrentWorkersCount, data.MaxWorkersCount); diff --git a/Lite/Controls/ServerTab.Charts.cs b/Lite/Controls/ServerTab.Charts.cs index a97b87707..ee4b6b7d7 100644 --- a/Lite/Controls/ServerTab.Charts.cs +++ b/Lite/Controls/ServerTab.Charts.cs @@ -12,6 +12,7 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Media; +using PerformanceMonitor.Common; using PerformanceMonitorLite.Helpers; using PerformanceMonitorLite.Models; using PerformanceMonitorLite.Services; @@ -30,6 +31,11 @@ public partial class ServerTab : UserControl private void UpdateMemorySummary(MemoryStatsRow? stats) { + /* On an Azure SQL Database the first two figures are the database's memory limit and the room left under it, not the + host's RAM, so they are named that way (the same words the FinOps utilization card uses). */ + PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(stats?.EngineEdition); + AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(stats?.EngineEdition); + if (stats == null) { PhysicalMemoryText.Text = "--"; diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml index 75de1589e..8e6b6df91 100644 --- a/Lite/Controls/ServerTab.xaml +++ b/Lite/Controls/ServerTab.xaml @@ -1491,7 +1491,7 @@ - + @@ -1511,7 +1511,7 @@ - + diff --git a/Lite/Mcp/McpAnalysisTools.cs b/Lite/Mcp/McpAnalysisTools.cs index 7f4b54ace..ae149ded4 100644 --- a/Lite/Mcp/McpAnalysisTools.cs +++ b/Lite/Mcp/McpAnalysisTools.cs @@ -595,7 +595,7 @@ the collector missed changes nothing about what the server is configured to. (Ph var totalDbSizeMb = factsByKey.TryGetValue("DATABASE_TOTAL_SIZE_MB", out var dbFact) ? dbFact.Value : 0; var editionName = AuditEditionName(edition); - /* cores_per_socket off an Azure SQL Database; there the fact carries its vCores instead, because the stored + /* The recommended MAXDOP follows the cores per socket the SERVER_HARDWARE fact carries. On an Azure SQL Database the fact carries its vCores instead, because the stored cores_per_socket is the host's (see FactRemediation.MaxdopBasisFrom). */ var maxdopBasis = FactRemediation.MaxdopBasisFrom(factsByKey); diff --git a/Lite/Mcp/McpMemoryTools.cs b/Lite/Mcp/McpMemoryTools.cs index 318a904dd..42ac343c0 100644 --- a/Lite/Mcp/McpMemoryTools.cs +++ b/Lite/Mcp/McpMemoryTools.cs @@ -41,7 +41,10 @@ public static async Task GetMemoryStats( target_server_memory_mb = stats.TargetServerMemoryMb, total_server_memory_mb = stats.TotalServerMemoryMb, buffer_pool_mb = stats.BufferPoolMb, - plan_cache_mb = stats.PlanCacheMb + plan_cache_mb = stats.PlanCacheMb, + /* On an Azure SQL Database (5) total_physical_memory_mb is the database's memory limit and + available_physical_memory_mb the room left under it, not the host's RAM. */ + engine_edition = stats.EngineEdition }, McpHelpers.JsonOptions); } catch (Exception ex) diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index 6e02f50c7..84c9ac1be 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -92,8 +92,9 @@ ratio is omitted rather than invented when a denominator piece is missing. One n await Task.WhenAll(cpuAggregateTask, propertiesTask); var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; - /* The core count is the server's own: on an Azure SQL Database the stored cpu_count is the HOST's, so this divides by its - vcore_count, or omits the ratio for a DTU-model objective or an elastic pool (see CpuAttribution). */ + /* The core count is what the server is given: on an Azure SQL Database the stored cpu_count is the schedulers it can see, + which can be more than its vCores, so this divides by its vcore_count, or omits the ratio for a DTU-model objective or + an elastic pool (see CpuAttribution). */ var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuMs) / 1000.0, requestedStart, nowUtc, @@ -221,8 +222,9 @@ public static async Task GetTopProceduresByCpu( await Task.WhenAll(cpuAggregateTask, propertiesTask); var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; - /* The core count is the server's own: on an Azure SQL Database the stored cpu_count is the HOST's, so this divides by its - vcore_count, or omits the ratio for a DTU-model objective or an elastic pool (see CpuAttribution). */ + /* The core count is what the server is given: on an Azure SQL Database the stored cpu_count is the schedulers it can see, + which can be more than its vCores, so this divides by its vcore_count, or omits the ratio for a DTU-model objective or + an elastic pool (see CpuAttribution). */ var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuMs) / 1000.0, requestedStart, nowUtc, diff --git a/Lite/Services/LocalDataService.FinOps.Utilization.cs b/Lite/Services/LocalDataService.FinOps.Utilization.cs index bdb4e575e..39132facb 100644 --- a/Lite/Services/LocalDataService.FinOps.Utilization.cs +++ b/Lite/Services/LocalDataService.FinOps.Utilization.cs @@ -52,10 +52,10 @@ FROM v_memory_stats ORDER BY collection_time DESC LIMIT 1 ), -/* cpu_count is the count the server itself has. On an Azure SQL Database (engine_edition 5) the stored cpu_count - describes the HOST (a 1-vCore serverless database read 2), so there it is the vcore_count parsed from the service - objective, and NULL for an objective that names no vCores (a DTU-model objective or an elastic pool): never the - host count. Every other edition reads as it always did. The same CASE is in the Darling read. */ +/* cpu_count is the count CPU percent is measured against. On an Azure SQL Database (engine_edition 5) the stored cpu_count + is the number of schedulers the database can see (a 1-vCore serverless database read 2), not what it is given, so there it + is the vcore_count parsed from the service objective, and NULL for an objective that names no vCores (a DTU-model objective + or an elastic pool): never the scheduler count. Every other edition reads as it always did. The same CASE is in the Darling read. */ server_info AS ( SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, engine_edition FROM v_server_properties diff --git a/Lite/Services/LocalDataService.Memory.cs b/Lite/Services/LocalDataService.Memory.cs index c4c7349c5..0ec118526 100644 --- a/Lite/Services/LocalDataService.Memory.cs +++ b/Lite/Services/LocalDataService.Memory.cs @@ -39,7 +39,14 @@ public partial class LocalDataService target_server_memory_mb, total_server_memory_mb, buffer_pool_mb, - plan_cache_mb + plan_cache_mb, + ( + SELECT sp.engine_edition + FROM v_server_properties AS sp + WHERE sp.server_id = $1 + ORDER BY sp.collection_time DESC + LIMIT 1 + ) AS engine_edition FROM v_memory_stats WHERE server_id = $1 ORDER BY collection_time DESC @@ -65,7 +72,8 @@ ORDER BY collection_time DESC TargetServerMemoryMb = reader.IsDBNull(7) ? 0 : ToDouble(reader.GetValue(7)), TotalServerMemoryMb = reader.IsDBNull(8) ? 0 : ToDouble(reader.GetValue(8)), BufferPoolMb = reader.IsDBNull(9) ? 0 : ToDouble(reader.GetValue(9)), - PlanCacheMb = reader.IsDBNull(10) ? 0 : ToDouble(reader.GetValue(10)) + PlanCacheMb = reader.IsDBNull(10) ? 0 : ToDouble(reader.GetValue(10)), + EngineEdition = reader.IsDBNull(11) ? null : Convert.ToInt32(reader.GetValue(11)) }; } @@ -441,6 +449,12 @@ public class MemoryStatsRow public double TotalServerMemoryMb { get; set; } public double BufferPoolMb { get; set; } public double PlanCacheMb { get; set; } + + /// The server's engine edition from its latest server_properties row, or null when none is stored. On an Azure + /// SQL Database (5) is the database's memory limit and + /// the room left under it, so the Memory tab and the MCP payload name them that way. + public int? EngineEdition { get; set; } + public double UsedPhysicalMemoryMb => TotalPhysicalMemoryMb - AvailablePhysicalMemoryMb; public double MemoryUtilizationPercent => TotalPhysicalMemoryMb > 0 ? UsedPhysicalMemoryMb / TotalPhysicalMemoryMb * 100 : 0; } diff --git a/Lite/Services/LocalDataService.ServerInfo.cs b/Lite/Services/LocalDataService.ServerInfo.cs index 3a47a295d..d22797ad2 100644 --- a/Lite/Services/LocalDataService.ServerInfo.cs +++ b/Lite/Services/LocalDataService.ServerInfo.cs @@ -61,7 +61,7 @@ ORDER BY collection_time DESC UtcOffsetMinutes = reader.IsDBNull(15) ? null : reader.GetInt32(15), TimeZoneId = reader.IsDBNull(16) ? null : reader.GetString(16), /* The vCore count parsed from an Azure SQL Database's service objective; null elsewhere and for a - DTU-model objective or an elastic pool. It is what describes the database where cpu_count describes the host. */ + DTU-model objective or an elastic pool. It is what the database is given, where cpu_count is the schedulers it can see. */ VcoreCount = reader.IsDBNull(17) ? null : reader.GetInt32(17) }; } diff --git a/PerformanceMonitor.Analysis/FactAdvice.cs b/PerformanceMonitor.Analysis/FactAdvice.cs index fed988b28..65a43dfc7 100644 --- a/PerformanceMonitor.Analysis/FactAdvice.cs +++ b/PerformanceMonitor.Analysis/FactAdvice.cs @@ -583,6 +583,15 @@ private static AdviceBlock ComposeConfigMaxdop(IReadOnlyDictionary var rec = FactRemediation.RecommendedMaxdop(basis.Cores); var coresNote = basis.Cores > 0 ? $" {basis.Note}" : string.Empty; var cappedFrom = basis.FromVcores ? "vCores" : "cores-per-socket"; + /* MAXDOP is an instance option (sp_configure) everywhere except an Azure SQL Database, which has no instance setting for it: + there it is the database-scoped MAXDOP, set with ALTER DATABASE SCOPED CONFIGURATION. Only that engine carries vCores + instead of cores per socket, so the vCores basis is the one that names the database-scoped statement. */ + var applySentence = basis.FromVcores + ? $"An Azure SQL Database has no instance setting for it; set it per database with ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = {rec}, an online change." + : "The Apply button runs sp_configure + RECONFIGURE, an online metadata change."; + var viaClause = basis.FromVcores + ? $"with ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = {rec}" + : "via sp_configure + RECONFIGURE"; string headline, remediation; if (maxdop == 0) @@ -590,8 +599,7 @@ private static AdviceBlock ComposeConfigMaxdop(IReadOnlyDictionary headline = "MAXDOP is 0 — a single query can fan out across every scheduler (up to 64)"; remediation = $"Set MAXDOP to {rec} — this {(basis.FromVcores ? "database" : "server")}'s {cappedFrom} capped at 8{coresNote}, the per-NUMA-node " + - "proxy; the SKU is irrelevant to the right value. The Apply button runs sp_configure + " + - "RECONFIGURE, an online metadata change. On hardware with more than 16 logical processors " + + $"proxy; the SKU is irrelevant to the right value. {applySentence} On hardware with more than 16 logical processors " + "per NUMA node you can raise it by hand. Raise Cost Threshold for Parallelism in the same pass " + "if its companion finding fired."; } @@ -601,15 +609,14 @@ private static AdviceBlock ComposeConfigMaxdop(IReadOnlyDictionary remediation = $"MAXDOP 1 forces every query serial: large analytical queries, index rebuilds, and DBCC run " + $"far slower. Unless this was set deliberately to fix a specific parallelism problem, set MAXDOP " + - $"to {rec} ({cappedFrom} capped at 8{coresNote}) via sp_configure + RECONFIGURE, an online change."; + $"to {rec} ({cappedFrom} capped at 8{coresNote}) {viaClause}, an online change."; } else { - headline = $"MAXDOP is {maxdop} — above this server's topology-based guidance of {rec}"; + headline = $"MAXDOP is {maxdop} — above this {(basis.FromVcores ? "database" : "server")}'s topology-based guidance of {rec}"; remediation = $"Lower MAXDOP from {maxdop} to {rec} ({cappedFrom} capped at 8{coresNote}, the per-NUMA-node " + - "proxy; the SKU is irrelevant). The Apply button runs sp_configure + RECONFIGURE, an online " + - "metadata change. On hardware with more than 16 logical processors per NUMA node a higher value " + + $"proxy; the SKU is irrelevant). {applySentence} On hardware with more than 16 logical processors per NUMA node a higher value " + "can be justified by hand. Pair it with a sane Cost Threshold for Parallelism if that finding fired."; } diff --git a/PerformanceMonitor.Common/CpuAttribution.cs b/PerformanceMonitor.Common/CpuAttribution.cs index a916a3977..a0eb2351d 100644 --- a/PerformanceMonitor.Common/CpuAttribution.cs +++ b/PerformanceMonitor.Common/CpuAttribution.cs @@ -25,12 +25,13 @@ namespace PerformanceMonitor.Common /// seconds. When a piece is missing — no CPU samples, no properties snapshot, or the series covers too /// little of the window — the ratio is OMITTED, never invented (#2320's explicit degrade rule). /// - /// The core count is the server's OWN. On an Azure SQL Database sys.dm_os_sys_info reports the - /// HOST's CPUs (a 1-vCore serverless database read 2), and a denominator built from them is wrong by the ratio of - /// the two. There the count is the vcore_count parsed from the service objective, and a DTU-model objective or an - /// elastic pool, which name no vCores, has none: the ratio is omitted with instead of - /// being computed from the host. holds that rule; the overload that - /// takes the engine edition applies it, so a caller holding a server_properties row cannot forget it. + /// The core count is what the server is given. On an Azure SQL Database the stored cpu_count is the + /// number of schedulers the database can see (a 1-vCore serverless database read 2), not the CPU it is given, and a + /// denominator built from it is wrong by the ratio of the two. There the count is the vcore_count parsed from the + /// service objective, and a DTU-model objective or an elastic pool, which name no vCores, has none: the ratio is omitted + /// with instead of being computed from the scheduler count. + /// holds that rule; the overload that takes the engine edition applies it, so + /// a caller holding a server_properties row cannot forget it. /// public static class CpuAttribution { @@ -53,10 +54,11 @@ public static class CpuAttribution public const string CoreCountUnavailableNote = "core count unavailable (no server_properties snapshot), so measured CPU-seconds cannot be computed; ratio omitted rather than invented"; - /// The note on an Azure SQL Database whose service objective names no vCores: the host's core count is - /// not this database's allocation, so there is no core count to multiply by. The ratio is omitted, not estimated. + /// The note on an Azure SQL Database whose service objective names no vCores: the stored CPU count is the + /// schedulers the database can see, not the CPU it is given, so there is no core count to multiply by. The ratio is + /// omitted, not estimated. public const string CoreCountNotApplicableNote = - "core count not applicable: on an Azure SQL Database the host's core count is not this database's allocation and its service objective names no vCores (a DTU-model objective or an elastic pool), so measured CPU-seconds cannot be computed; ratio omitted rather than invented"; + "core count not applicable: on an Azure SQL Database the stored CPU count is the schedulers the database can see, not the CPU it is given, and its service objective names no vCores (a DTU-model objective or an elastic pool), so measured CPU-seconds cannot be computed; ratio omitted rather than invented"; /// /// A null always comes with a saying why. @@ -96,7 +98,7 @@ public static Result Compute( /// Azure SQL Database (or with no row, null) that is /// unchanged. On one it is , or no count at all for a DTU-model objective or an elastic pool, and /// then the ratio is omitted with rather than computed from the - /// host's CPUs. Both SKUs' top-queries and top-procedures tools call this one. + /// stored scheduler count. Both SKUs' top-queries and top-procedures tools call this one. /// public static Result Compute( double rankedCpuSeconds, diff --git a/PerformanceMonitor.Common/ServerHardwareScope.cs b/PerformanceMonitor.Common/ServerHardwareScope.cs index af12be1ef..0e8572fb7 100644 --- a/PerformanceMonitor.Common/ServerHardwareScope.cs +++ b/PerformanceMonitor.Common/ServerHardwareScope.cs @@ -110,6 +110,14 @@ public static string CpuCountText(int? engineEdition, int cpuCount) => ? NotApplicable : cpuCount.ToString("N0", CultureInfo.CurrentCulture); + /// + /// The unit that follows the FinOps utilization card's CPU count: " vCores," on an Azure SQL Database, where the count is the + /// vCores its service objective gives it (see ) and so is not a count of CPUs, and " CPUs," + /// everywhere else. The leading space and trailing comma are part of the text, as the card draws it between two other figures. + /// + public static string CpuCountUnit(int? engineEdition) => + HardwareIsTheHosts(engineEdition) ? " vCores," : " CPUs,"; + /// /// What the FinOps utilization card measures the buffer pool against: physical RAM on SQL Server and Managed Instance, and /// on an Azure SQL Database the database's memory limit. Both are memory_stats.total_physical_memory_mb, which on an @@ -117,12 +125,31 @@ public static string CpuCountText(int? engineEdition, int cpuCount) => /// private static string MemoryBasis(bool azureSqlDatabase) => azureSqlDatabase ? "the database's memory limit" : "physical RAM"; + private const string MemoryLimitLabel = "Memory limit"; + /// /// The caption beside the utilization card's memory figure: "Physical: " on SQL Server and Managed Instance, "Memory limit: " /// on an Azure SQL Database, where the figure is the database's own limit and not the host's RAM. /// public static string PhysicalMemoryCaption(int? engineEdition) => - HardwareIsTheHosts(engineEdition) ? "Memory limit: " : "Physical: "; + HardwareIsTheHosts(engineEdition) ? MemoryLimitLabel + ": " : "Physical: "; + + /// + /// The label over the Memory tab's first figure (memory_stats.total_physical_memory_mb): "Physical Memory" on SQL Server + /// and Managed Instance, "Memory limit" on an Azure SQL Database, where the collector fills the column from the database's + /// own committed target and not from the host's RAM. The same word uses. + /// + public static string MemoryTabTotalLabel(int? engineEdition) => + HardwareIsTheHosts(engineEdition) ? MemoryLimitLabel : "Physical Memory"; + + /// + /// The label over the Memory tab's second figure (memory_stats.available_physical_memory_mb): "Available Physical" on SQL + /// Server and Managed Instance, where it is the operating system's available physical memory, and "Available under limit" on + /// an Azure SQL Database, where the collector computes it as the committed target minus what is committed, which is the room + /// left under the database's memory limit and not a count of free RAM. + /// + public static string MemoryTabAvailableLabel(int? engineEdition) => + HardwareIsTheHosts(engineEdition) ? "Available under limit" : "Available Physical"; /// /// The FinOps utilization verdict sentence for a server whose provisioning is RIGHT_SIZED. Off an Azure SQL Database it is From bcbd94c4c0e9d2bc8917b5fcad2afaaf2e789884 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:45:56 -0400 Subject: [PATCH 09/17] Pin the audit_config MAXDOP recommendation (vCores on an Azure SQL Database) and the Server Inventory note words --- .../AzureSqlDatabaseHardwareTests.cs | 2 + .../AzureSqlDatabaseHostLeftoversTests.cs | 13 +++++ Lite.Tests/AuditConfigEditionTests.cs | 52 +++++++++++++++++-- Lite.Tests/AzureSqlDatabaseHardwareTests.cs | 2 + 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs index efb4a4711..1c9e5fb46 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs @@ -195,6 +195,8 @@ public void InventoryRow_OnAzureSqlDatabase_LeavesTheHostCellsBlank_ShowsItsOwnC Assert.Null(row.SocketCount); Assert.Null(row.CoresPerSocket); Assert.Equal(ServerHardwareScope.InventoryHardwareNote, row.HardwareUnavailableReason); + Assert.Contains("memory, sockets, cores per socket and hyperthread ratio are the host's", row.HardwareUnavailableReason, StringComparison.Ordinal); + Assert.Contains("Logical CPUs is the database's own scheduler count", row.HardwareUnavailableReason, StringComparison.Ordinal); } [Fact] diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHostLeftoversTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHostLeftoversTests.cs index 9ecfd4817..6eb7145d4 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseHostLeftoversTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseHostLeftoversTests.cs @@ -565,4 +565,17 @@ engine_edition 5 condition (panels.js visibleStats), under the words the viewer } Assert.Contains("const AZURE_SQL_DATABASE = { key: \"engine_edition\", equals: 5 };", tabs, StringComparison.Ordinal); } + + /// audit_config reads PostgreSQL, which this suite does not stand up, so the tool is pinned at the source: its MAXDOP + /// recommendation comes from the shared basis (the vCores on an Azure SQL Database, cores per socket elsewhere) and says which. + /// Lite.Tests runs the same tool end to end over a seeded store. + [Fact] + public void AuditConfig_TakesItsMaxdopRecommendationFromTheSharedBasis_AndNamesTheDatabasesVcores() + { + var tool = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpTools.cs"); + + Assert.Contains("var maxdopBasis = FactRemediation.MaxdopBasisFrom(factsByKey);", tool, StringComparison.Ordinal); + Assert.Contains("var recommended = (int)FactRemediation.RecommendedMaxdop(maxdopBasis.Cores);", tool, StringComparison.Ordinal); + Assert.Contains("(maxdopBasis.FromVcores ? \"this database's vCores\" : \"this server's cores-per-socket\")", tool, StringComparison.Ordinal); + } } diff --git a/Lite.Tests/AuditConfigEditionTests.cs b/Lite.Tests/AuditConfigEditionTests.cs index afa9aaada..d80d5e60a 100644 --- a/Lite.Tests/AuditConfigEditionTests.cs +++ b/Lite.Tests/AuditConfigEditionTests.cs @@ -118,6 +118,50 @@ public async Task AuditConfig_WithNoConfigRows_AnswersNotCollectedOnAzureSqlDb_A Assert.Contains("may not have run yet", box, StringComparison.Ordinal); } + /// The MAXDOP audit follows the vCores on an Azure SQL Database. The seeded row holds the host's 32 cores per socket + /// beside 4 vCores, so a recommendation taken from cores per socket would read 8 and one taken from the vCores reads 4. An + /// Azure SQL Database collects no server_config in production; the rows are seeded here to reach the recommendation. + [Fact] + public async Task AuditConfig_OnAzureSqlDatabase_RecommendsMaxdopFromTheVcores_NotTheHostsCoresPerSocket() + { + await SeedServerPropertiesAsync(_azureServerId, AzureServerName, CollectorEngineCapability.AzureSqlDatabaseEngineEdition, vcoreCount: 4, coresPerSocket: 32); + await SeedServerConfigAsync(_azureServerId, AzureServerName); + + var json = await McpAnalysisTools.AuditConfig(new AnalysisService(_duckDb), new LocalDataService(_duckDb), _serverManager, AzureServerName); + + var maxdop = MaxdopRecommendation(json); + Assert.Equal(4, maxdop.GetProperty("suggested_value").GetInt32()); + var text = maxdop.GetProperty("recommendation").GetString()!; + Assert.Contains("Start with 4 (this database's vCores, capped at 8)", text, StringComparison.Ordinal); + Assert.DoesNotContain("cores-per-socket", text, StringComparison.Ordinal); + } + + /// The same rows on SQL Server recommend from cores per socket, as they always did. + [Fact] + public async Task AuditConfig_OffAzureSqlDatabase_RecommendsMaxdopFromCoresPerSocket_AsItAlwaysDid() + { + await SeedServerPropertiesAsync(_boxServerId, BoxServerName, engineEdition: 3, vcoreCount: null, coresPerSocket: 6); + await SeedServerConfigAsync(_boxServerId, BoxServerName); + + var json = await McpAnalysisTools.AuditConfig(new AnalysisService(_duckDb), new LocalDataService(_duckDb), _serverManager, BoxServerName); + + var maxdop = MaxdopRecommendation(json); + Assert.Equal(6, maxdop.GetProperty("suggested_value").GetInt32()); + Assert.Contains("Start with 6 (this server's cores-per-socket, capped at 8)", maxdop.GetProperty("recommendation").GetString(), StringComparison.Ordinal); + } + + private static JsonElement MaxdopRecommendation(string json) + { + using var doc = JsonDocument.Parse(json); + foreach (var r in doc.RootElement.GetProperty("recommendations").EnumerateArray()) + { + if (r.GetProperty("setting").GetString() == "max degree of parallelism") + return r.Clone(); + } + + throw new Xunit.Sdk.XunitException("audit_config returned no MAXDOP recommendation: " + json); + } + private static string StatusOf(string json) => JsonDocument.Parse(json).RootElement.GetProperty("status").GetString()!; @@ -134,7 +178,7 @@ private async Task SeedConnectionAsync() /// One collected server_properties row: the NOT NULL edition and hardware columns are /// filled with values nothing here reads, plus the one column these tests vary. - private async Task SeedServerPropertiesAsync(int serverId, string serverName, int engineEdition) + private async Task SeedServerPropertiesAsync(int serverId, string serverName, int engineEdition, int? vcoreCount = null, int? coresPerSocket = null) { using var readLock = _duckDb.AcquireReadLock(); var connection = await SeedConnectionAsync(); @@ -144,13 +188,15 @@ private async Task SeedServerPropertiesAsync(int serverId, string serverName, in INSERT INTO server_properties (collection_id, collection_time, server_id, server_name, edition, product_version, product_level, engine_edition, - cpu_count, hyperthread_ratio, physical_memory_mb) -VALUES ($1, $2, $3, $4, 'Test Edition', '16.0.4150.1', 'RTM', $5, 8, 1, 16384)"; + cpu_count, hyperthread_ratio, physical_memory_mb, cores_per_socket, vcore_count) +VALUES ($1, $2, $3, $4, 'Test Edition', '16.0.4150.1', 'RTM', $5, 8, 1, 16384, $6, $7)"; cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); cmd.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow }); cmd.Parameters.Add(new DuckDBParameter { Value = serverId }); cmd.Parameters.Add(new DuckDBParameter { Value = serverName }); cmd.Parameters.Add(new DuckDBParameter { Value = engineEdition }); + cmd.Parameters.Add(new DuckDBParameter { Value = (object?)coresPerSocket ?? DBNull.Value }); + cmd.Parameters.Add(new DuckDBParameter { Value = (object?)vcoreCount ?? DBNull.Value }); await cmd.ExecuteNonQueryAsync(); } diff --git a/Lite.Tests/AzureSqlDatabaseHardwareTests.cs b/Lite.Tests/AzureSqlDatabaseHardwareTests.cs index b87af9aa8..26caeec03 100644 --- a/Lite.Tests/AzureSqlDatabaseHardwareTests.cs +++ b/Lite.Tests/AzureSqlDatabaseHardwareTests.cs @@ -184,6 +184,8 @@ public void InventoryRow_OnAzureSqlDatabase_LeavesTheHostCellsBlank_ShowsItsOwnC Assert.Null(row.SocketCount); Assert.Null(row.CoresPerSocket); Assert.Equal(ServerHardwareScope.InventoryHardwareNote, row.HardwareUnavailableReason); + Assert.Contains("memory, sockets, cores per socket and hyperthread ratio are the host's", row.HardwareUnavailableReason, StringComparison.Ordinal); + Assert.Contains("Logical CPUs is the database's own scheduler count", row.HardwareUnavailableReason, StringComparison.Ordinal); } [Fact] From a9cce5b3cdb8aae70b8dbd42fc9aa40952d9e728 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:53:32 -0400 Subject: [PATCH 10/17] Name the Azure SQL Database own-figures tests for what they pin, and fix a stale comment about cpu_count --- ...s.cs => AzureSqlDatabaseOwnFiguresTests.cs} | 4 ++-- Darling/Darling.Tests/TimeHonestyRungTests.cs | 4 ++-- ...s.cs => AzureSqlDatabaseOwnFiguresTests.cs} | 18 +++++++++--------- 3 files changed, 13 insertions(+), 13 deletions(-) rename Darling/Darling.Tests/{AzureSqlDatabaseHostLeftoversTests.cs => AzureSqlDatabaseOwnFiguresTests.cs} (99%) rename Lite.Tests/{AzureSqlDatabaseHostLeftoversTests.cs => AzureSqlDatabaseOwnFiguresTests.cs} (98%) diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHostLeftoversTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs similarity index 99% rename from Darling/Darling.Tests/AzureSqlDatabaseHostLeftoversTests.cs rename to Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs index 6eb7145d4..b5318c2fe 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseHostLeftoversTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs @@ -50,12 +50,12 @@ namespace Darling.Tests; /// readers that consume them are pinned by the lines that route them through the shared rule), and Lite.Tests runs the same /// table against a real DuckDB in the same words. /// -public sealed class AzureSqlDatabaseHostLeftoversTests +public sealed class AzureSqlDatabaseOwnFiguresTests { private static AnalysisContext Context() => new() { ServerId = 7, - ServerName = "LeftoversSrv", + ServerName = "OwnFiguresSrv", TimeRangeStart = new DateTime(2026, 9, 30, 0, 0, 0, DateTimeKind.Utc), TimeRangeEnd = new DateTime(2026, 9, 30, 1, 0, 0, DateTimeKind.Utc), }; diff --git a/Darling/Darling.Tests/TimeHonestyRungTests.cs b/Darling/Darling.Tests/TimeHonestyRungTests.cs index 57e9bdf2e..45f35ba55 100644 --- a/Darling/Darling.Tests/TimeHonestyRungTests.cs +++ b/Darling/Darling.Tests/TimeHonestyRungTests.cs @@ -394,8 +394,8 @@ ReadLatestCpuCoreAsync sibling holding the read byte-identical — the columns a public void TheServerPropertiesRead_CarriesTheClockPair_AndThePayloadPublishesIt() { var sql = DarlingDataReader.LatestServerPropertiesSql.Replace("\r\n", "\n", StringComparison.Ordinal); - /* The clock pair still follows the pre-rung columns; vcore_count (what describes an Azure SQL Database where - cpu_count describes its host) is projected after it, so the pair's position is unchanged. */ + /* The clock pair still follows the pre-rung columns; vcore_count (what an Azure SQL Database is given, where + cpu_count is the schedulers it can see) is projected after it, so the pair's position is unchanged. */ Assert.Contains("service_objective,\n utc_offset_minutes,\n time_zone_id,\n vcore_count\nFROM server_properties", Dedent(sql), StringComparison.Ordinal); Assert.Equal(typeof(int?), typeof(DarlingDataReader.ServerPropertiesReadRow).GetProperty("UtcOffsetMinutes")!.PropertyType); diff --git a/Lite.Tests/AzureSqlDatabaseHostLeftoversTests.cs b/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs similarity index 98% rename from Lite.Tests/AzureSqlDatabaseHostLeftoversTests.cs rename to Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs index 9ba9d8d8e..ac0de4072 100644 --- a/Lite.Tests/AzureSqlDatabaseHostLeftoversTests.cs +++ b/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs @@ -53,7 +53,7 @@ namespace PerformanceMonitorLite.Tests; /// (1,838 MB for a 1-vCore General Purpose database), never the host's 911.9 GB. The Darling.Tests twin pins the same table for /// the other app, in the same words. /// -public sealed class AzureSqlDatabaseHostLeftoversTests +public sealed class AzureSqlDatabaseOwnFiguresTests { // ── 1. SERVER_HARDWARE fact, MAXDOP recommendation, LPIM advisory, Server Context card ── @@ -533,10 +533,10 @@ public void MemoryTab_AsksTheSharedRule_ForTheNamesOfItsFirstTwoFigures() } /// -/// The seeded half of : real DuckDB reads against rows seeded the way the +/// The seeded half of : real DuckDB reads against rows seeded the way the /// collectors store them. /// -public sealed class AzureSqlDatabaseHostLeftoversReadTests : IClassFixture, IDisposable +public sealed class AzureSqlDatabaseOwnFiguresReadTests : IClassFixture, IDisposable { private const int ServerId = -488_101; private readonly SharedDuckDbFixture _fixture; @@ -544,7 +544,7 @@ public sealed class AzureSqlDatabaseHostLeftoversReadTests : IClassFixture cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); - P(_nextId--); P(_now); P(serverId); P("LeftoversSrv" + serverId); + P(_nextId--); P(_now); P(serverId); P("OwnFiguresSrv" + serverId); P(engineEdition == 5 ? "SQL Azure" : "Enterprise Edition (64-bit)"); P("12.0.2000.8"); P("RTM"); P(engineEdition); P(storedCpuCount); P(64); P(storedPhysicalMemoryMb); P(0); P(32); P(vcoreCount.HasValue ? "GP_S_Gen5_" + vcoreCount : (engineEdition == 5 ? "S0" : null)); P(vcoreCount); @@ -728,7 +728,7 @@ INSERT INTO cpu_utilization_stats cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); cmd.Parameters.Add(new DuckDBParameter { Value = _now }); cmd.Parameters.Add(new DuckDBParameter { Value = serverId }); - cmd.Parameters.Add(new DuckDBParameter { Value = "LeftoversSrv" + serverId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "OwnFiguresSrv" + serverId }); await cmd.ExecuteNonQueryAsync(); } @@ -741,7 +741,7 @@ INSERT INTO memory_stats VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 512, $10)"; void P(object? v) => cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); var physical = engineEdition == 5 ? 1_838d : 65_536d; - P(_nextId--); P(_now); P(serverId); P("LeftoversSrv" + serverId); + P(_nextId--); P(_now); P(serverId); P("OwnFiguresSrv" + serverId); P(physical); P(physical / 2); P(physical * 0.9); P(physical * 0.8); P(physical * 0.5); P(currentWorkers); await cmd.ExecuteNonQueryAsync(); } @@ -837,7 +837,7 @@ INSERT INTO memory_stats target_server_memory_mb, total_server_memory_mb, buffer_pool_mb) VALUES ($1, $2, $3, $4, $5, $6, $5, $5, $6)"; void P(object? v) => cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); - P(_nextId--); P(_now); P(ServerId); P("LeftoversSrv"); P(totalMb); P(availableMb); + P(_nextId--); P(_now); P(ServerId); P("OwnFiguresSrv"); P(totalMb); P(availableMb); await cmd.ExecuteNonQueryAsync(); } @@ -865,7 +865,7 @@ INSERT INTO memory_stats cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); cmd.Parameters.Add(new DuckDBParameter { Value = _now }); cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); - cmd.Parameters.Add(new DuckDBParameter { Value = "LeftoversSrv" }); + cmd.Parameters.Add(new DuckDBParameter { Value = "OwnFiguresSrv" }); await cmd.ExecuteNonQueryAsync(); } From 3b79ba261ac8f37fba0dbb88e13e7fe233300b95 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:04:34 -0400 Subject: [PATCH 11/17] Clear two build warnings: a duplicate using directive and a property named after a type --- Lite/Controls/ServerTab.Charts.cs | 1 - PerformanceMonitor.Analysis/FactAdvice.cs | 4 ++-- PerformanceMonitor.Analysis/FactRemediation.cs | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Lite/Controls/ServerTab.Charts.cs b/Lite/Controls/ServerTab.Charts.cs index ee4b6b7d7..f084c6b80 100644 --- a/Lite/Controls/ServerTab.Charts.cs +++ b/Lite/Controls/ServerTab.Charts.cs @@ -12,7 +12,6 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Media; -using PerformanceMonitor.Common; using PerformanceMonitorLite.Helpers; using PerformanceMonitorLite.Models; using PerformanceMonitorLite.Services; diff --git a/PerformanceMonitor.Analysis/FactAdvice.cs b/PerformanceMonitor.Analysis/FactAdvice.cs index 65a43dfc7..c23d42d2d 100644 --- a/PerformanceMonitor.Analysis/FactAdvice.cs +++ b/PerformanceMonitor.Analysis/FactAdvice.cs @@ -548,7 +548,7 @@ private static string ParallelGuardCore(long? maxdop, long? ctfp, FactRemediatio if (maxdop is 0) sb.Append($", and cap MAXDOP at {rec} ({basis.Source}, capped at 8) instead of unlimited"); else if (maxdop > rec) - sb.Append($", and lower MAXDOP from {maxdop} to {rec} ({basis.Short}, capped at 8)"); + sb.Append($", and lower MAXDOP from {maxdop} to {rec} ({basis.Bare}, capped at 8)"); else sb.Append($"; MAXDOP at {maxdop} is already within the ≤ {rec} guidance"); sb.Append(". Then go after the specific high-DOP offenders"); @@ -675,7 +675,7 @@ private static string MaxdopCtfpClause(IReadOnlyDictionary facts) else if (ctfp is not null && ctfp < 50) recs.Add($"raise cost threshold for parallelism from {ctfp} toward 50"); if (maxdop is 0) - recs.Add($"cap MAXDOP at {rec} ({basis.Short}, ≤ 8)"); + recs.Add($"cap MAXDOP at {rec} ({basis.Bare}, ≤ 8)"); else if (maxdop is not null && maxdop > rec) recs.Add($"lower MAXDOP from {maxdop} to {rec}"); diff --git a/PerformanceMonitor.Analysis/FactRemediation.cs b/PerformanceMonitor.Analysis/FactRemediation.cs index 6d07e5257..4b27c58d7 100644 --- a/PerformanceMonitor.Analysis/FactRemediation.cs +++ b/PerformanceMonitor.Analysis/FactRemediation.cs @@ -186,7 +186,7 @@ public readonly record struct MaxdopBasis(int Cores, bool FromVcores) public string Source => FromVcores ? "this database's vCores" : "this server's per-NUMA-node processor count"; /// The same figure named without "this server's" / "this database's": "the per-NUMA-node processor count" or "the database's vCores". - public string Short => FromVcores ? "the database's vCores" : "the per-NUMA-node processor count"; + public string Bare => FromVcores ? "the database's vCores" : "the per-NUMA-node processor count"; } /// Reads the off the SERVER_HARDWARE fact; cores_per_socket when it carries one, else vcore_count. From e3c2bee968926b3db7823cffeb81fb78c35b9e19 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:30:12 -0400 Subject: [PATCH 12/17] Darling get_memory_stats and the Viewer Memory tab read the server_properties table, not the v_server_properties view that the Darling store does not have Both reads added a subselect for engine_edition from v_server_properties, which exists in Lite only. On PostgreSQL it failed with 42P01 (relation does not exist), so get_memory_stats and the Viewer Memory summary errored on every Darling server. They now read the base table, as the other Darling reads of server_properties do. A new source-text pin reads the service and viewer code with the comments removed. It fails when any name used after FROM or JOIN that starts with v_ is not a view the Darling store creates (PgSchemaGenerator.AllPassthroughViews and PayloadResolvingViews). It needs no database. --- .../DarlingReadsUseOnlyStoreViewsTests.cs | 64 +++++++++++++++++++ .../Mcp/DarlingDataReader.cs | 2 +- .../ViewerDataService.Memory.cs | 2 +- 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 Darling/Darling.Tests/DarlingReadsUseOnlyStoreViewsTests.cs diff --git a/Darling/Darling.Tests/DarlingReadsUseOnlyStoreViewsTests.cs b/Darling/Darling.Tests/DarlingReadsUseOnlyStoreViewsTests.cs new file mode 100644 index 000000000..fff7fc4ac --- /dev/null +++ b/Darling/Darling.Tests/DarlingReadsUseOnlyStoreViewsTests.cs @@ -0,0 +1,64 @@ +/* + * 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.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using PerformanceMonitor.Darling.Storage; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// Every v_ name that a Darling read uses as a FROM or JOIN target must be a view the Darling store creates. +/// Lite's reads query v_server_properties, which has no Darling twin, and a read ported from Lite that keeps the name fails +/// on PostgreSQL with 42P01 (the relation does not exist). Only the live tests, which need a database, see that failure. This +/// pin reads the source text of the service and the viewer, with the comments removed, so it fails without one. +/// The store's views are and . +/// +public sealed class DarlingReadsUseOnlyStoreViewsTests +{ + private static readonly Regex BlockComment = new(@"/\*.*?\*/", RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex LineComment = new(@"//[^\r\n]*", RegexOptions.Compiled); + private static readonly Regex ViewTarget = new(@"\b(?:FROM|JOIN)\s+(v_[a-z0-9_]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + [Fact] + public void EveryViewNamedAsAFromOrJoinTarget_InTheServiceAndViewerSql_IsOneTheStoreCreates() + { + var storeViews = new HashSet( + PgSchemaGenerator.AllPassthroughViews.Concat(PgSchemaGenerator.PayloadResolvingViews), StringComparer.OrdinalIgnoreCase); + var unknown = new SortedSet(StringComparer.Ordinal); + var scanned = 0; + + foreach (var project in new[] { "PerformanceMonitor.Darling.Service", "PerformanceMonitor.Darling.Viewer" }) + { + foreach (var file in Directory.EnumerateFiles(PathTo("Darling", project), "*.cs", SearchOption.AllDirectories)) + { + var segments = file.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (segments.Contains("bin") || segments.Contains("obj")) + continue; + + var sqlAndCode = LineComment.Replace(BlockComment.Replace(File.ReadAllText(file), " "), string.Empty); + scanned++; + foreach (Match target in ViewTarget.Matches(sqlAndCode)) + { + if (!storeViews.Contains(target.Groups[1].Value)) + unknown.Add($"{target.Groups[1].Value} in {Path.GetFileName(file)}"); + } + } + } + + Assert.True(scanned > 20, $"Only {scanned} source files were scanned, so this pin is not reading the code it guards."); + Assert.True( + unknown.Count == 0, + "These views are not views the Darling store creates (read the base table instead): " + string.Join(", ", unknown)); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs index 27c483d7d..c9080a6c8 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs @@ -696,7 +696,7 @@ public static async Task> GetWaitTrendAsync( CAST(plan_cache_mb AS double precision), ( SELECT sp.engine_edition - FROM v_server_properties AS sp + FROM server_properties AS sp WHERE sp.server_id = $1 ORDER BY sp.collection_time DESC LIMIT 1 diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs index 4b77cfc07..03002f3bf 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs @@ -138,7 +138,7 @@ public sealed partial class ViewerDataService CAST(plan_cache_mb AS double precision) AS plan_cache_mb, ( SELECT sp.engine_edition - FROM v_server_properties AS sp + FROM server_properties AS sp WHERE sp.server_id = $1 ORDER BY sp.collection_time DESC LIMIT 1 From a6cdbd8c1e9896c64cc242729e4409e04d96ab8e Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:57:14 -0400 Subject: [PATCH 13/17] get_memory_stats on an Azure SQL Database says what its memory figures are, and the FinOps CPU right-sizing text calls the vCores vCores get_memory_stats keeps the key names total_physical_memory_mb and available_physical_memory_mb on every edition. On an Azure SQL Database they are the database's memory limit and what is left under it, and a database that has grown to its limit reads about 100% in use, which is normal there but read as OS memory pressure. On that edition only, both apps now add a memory_note that says so, built by one shared helper in ServerHardwareScope. Other editions keep the payload key for key. The FinOps CPU right-sizing recommendation printed the vCores of an Azure SQL Database as "cores". It now names them vCores, as the utilization card does, and other editions keep "cores". The Darling Viewer's rule moves into a pure builder so it can be tested without a database. Also fixes the MemoryStatsRow doc comment that said the MCP payload names the memory figures as the Memory tab does, and a test seeder comment that said a DTU objective has no CPU count of its own (its cpu_count is its own scheduler count; it names no vCore count). --- .../AzureSqlDatabaseMemoryScopeTests.cs | 117 ++++++++++++++++++ .../AzureSqlDatabaseOwnFiguresTests.cs | 10 ++ .../Mcp/DarlingMcpDataTools.cs | 60 +++++---- ...iewerDataService.FinOps.Recommendations.cs | 47 ++++--- .../AzureSqlDatabaseMemoryScopeTests.cs | 88 +++++++++++++ Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs | 10 ++ Lite.Tests/FinOpsTests.cs | 18 ++- Lite.Tests/TestDataSeeder.cs | 4 +- Lite/Mcp/McpMemoryTools.cs | 52 +++++--- ...LocalDataService.FinOps.Recommendations.cs | 10 +- Lite/Services/LocalDataService.Memory.cs | 3 +- .../ServerHardwareScope.cs | 34 ++++- 12 files changed, 385 insertions(+), 68 deletions(-) diff --git a/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs index 5b6c042a7..7a825c018 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs @@ -256,4 +256,121 @@ public void MemoryAndVmRules_OnAzureSqlDatabase_SayWhyTheyStandDown_AndDoNotCall Assert.Contains("its memory comes with its service objective and cannot be resized on its own", rules, StringComparison.Ordinal); Assert.Contains("its cores and memory come with its service objective", rules, StringComparison.Ordinal); } + + // ── get_memory_stats: the keys keep their names, so an Azure SQL Database's payload says what they mean ── + + private static DarlingDataReader.MemoryStatsRow StatsRow(int? engineEdition, double totalMb, double availableMb) => new( + new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), totalMb, availableMb, 0, 0, + "Available physical memory is high", "CONVENTIONAL", totalMb, totalMb - availableMb, 1_100, 200, engineEdition); + + private static JsonElement MemoryPayload(DarlingDataReader.MemoryStatsRow stats) => + JsonDocument.Parse(DarlingMcpDataTools.MemoryStatsPayload("Srv", stats)).RootElement.Clone(); + + [Fact] + public void GetMemoryStats_OnAzureSqlDatabase_CarriesAMemoryNote_ThatCallsTheTotalTheDatabasesLimit_AndNearFullNormal() + { + /* A database that has grown to its limit has nothing left under it, so it reads 100% in use. On this edition that is the + normal state, and the note says so, because the same figure on SQL Server is an operating system short of memory. */ + var json = MemoryPayload(StatsRow(5, DatabaseMemoryLimitMb, 0)); + + var note = json.GetProperty("memory_note").GetString(); + Assert.Equal(ServerHardwareScope.McpMemoryNote, note); + Assert.Contains("total_physical_memory_mb is the database's memory limit (its committed target), not the host's memory", note, StringComparison.Ordinal); + Assert.Contains("available_physical_memory_mb is what is left under that limit", note, StringComparison.Ordinal); + Assert.Contains("a value near 100% is normal once the database has grown to its limit", note, StringComparison.Ordinal); + Assert.Contains("not memory pressure by itself", note, StringComparison.Ordinal); + + /* The figures and their names are as they were, and the note comes last. */ + Assert.Equal(DatabaseMemoryLimitMb, json.GetProperty("total_physical_memory_mb").GetDouble()); + Assert.Equal(0, json.GetProperty("available_physical_memory_mb").GetDouble()); + Assert.Equal(100, json.GetProperty("memory_utilization_pct").GetDouble()); + Assert.Equal(5, json.GetProperty("engine_edition").GetInt32()); + Assert.Equal("memory_note", json.EnumerateObject().Last().Name); + } + + [Theory] + [InlineData(3)] + [InlineData(8)] + [InlineData(null)] + public void GetMemoryStats_OffAzureSqlDatabase_IsUnchanged_AndCarriesNoMemoryNote(int? engineEdition) + { + var json = MemoryPayload(StatsRow(engineEdition, 65_536, 16_384)); + + Assert.False(json.TryGetProperty("memory_note", out _), "an engine that is not an Azure SQL Database gets no new key"); + Assert.Equal(75, json.GetProperty("memory_utilization_pct").GetDouble()); + Assert.Equal( + new[] + { + "server", "captured_at", "total_physical_memory_mb", "available_physical_memory_mb", "memory_utilization_pct", + "system_memory_state", "sql_memory_model", "target_server_memory_mb", "total_server_memory_mb", "buffer_pool_mb", + "plan_cache_mb", "engine_edition", + }, + json.EnumerateObject().Select(p => p.Name).ToArray()); + } + + [Fact] + public void GetMemoryStatsTool_BuildsItsPayloadThroughTheSharedNote() + { + var tool = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"); + + Assert.Contains("return MemoryStatsPayload(resolved.ServerName, stats);", tool, StringComparison.Ordinal); + Assert.Contains("ServerHardwareScope.WithMemoryNote(", tool, StringComparison.Ordinal); + } + + // ── the FinOps CPU right-sizing recommendation: vCores on an Azure SQL Database, cores everywhere else ── + + private static UtilizationEfficiencyRow CpuRow(int engineEdition) => new() + { + ProvisioningStatus = "OVER_PROVISIONED", + AvgCpuPct = 4m, + MaxCpuPct = 20, + P95CpuPct = 9m, + CpuCount = 32, + EngineEdition = engineEdition, + }; + + [Fact] + public void CpuRightSizingRecommendation_OnAzureSqlDatabase_NamesTheVcores_InTheFindingAndTheDetail() + { + /* The count is the vCores the service objective gives the database, which the utilization card already calls vCores. */ + var rec = ViewerDataService.BuildCpuRightSizingRecommendation(CpuRow(5), monthlyCost: 0m); + + Assert.NotNull(rec); + Assert.StartsWith("CPU over-provisioned (32 vCores, P95 = ", rec!.Finding, StringComparison.Ordinal); + Assert.Contains("across 32 vCores. Consider reducing to ~", rec.Detail, StringComparison.Ordinal); + Assert.EndsWith(" vCores.", rec.Detail, StringComparison.Ordinal); + Assert.DoesNotContain(" cores", rec.Finding + rec.Detail, StringComparison.Ordinal); + } + + [Theory] + [InlineData(3)] + [InlineData(8)] + [InlineData(0)] + public void CpuRightSizingRecommendation_OffAzureSqlDatabase_KeepsTheWordCores(int engineEdition) + { + var rec = ViewerDataService.BuildCpuRightSizingRecommendation(CpuRow(engineEdition), monthlyCost: 1_000m); + + Assert.NotNull(rec); + Assert.StartsWith("CPU over-provisioned (32 cores, P95 = ", rec!.Finding, StringComparison.Ordinal); + Assert.Contains("across 32 cores. Consider reducing to ~", rec.Detail, StringComparison.Ordinal); + Assert.EndsWith(" cores.", rec.Detail, StringComparison.Ordinal); + Assert.DoesNotContain("vCores", rec.Finding + rec.Detail, StringComparison.Ordinal); + Assert.True(rec.EstMonthlySavings > 0, "a server with a budget still gets its savings estimate"); + } + + [Fact] + public void CpuRightSizingRecommendation_AdvisesNothing_WithoutACpuSample_OnABusyServer_OrWithFourOrFewerCpus() + { + var noSample = CpuRow(3); + noSample.ProvisioningStatus = ""; + var busy = CpuRow(3); + busy.P95CpuPct = 30m; + var small = CpuRow(5); + small.CpuCount = 4; + + Assert.Null(ViewerDataService.BuildCpuRightSizingRecommendation(null, 0m)); + Assert.Null(ViewerDataService.BuildCpuRightSizingRecommendation(noSample, 0m)); + Assert.Null(ViewerDataService.BuildCpuRightSizingRecommendation(busy, 0m)); + Assert.Null(ViewerDataService.BuildCpuRightSizingRecommendation(small, 0m)); + } } diff --git a/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs index b5318c2fe..caccaba66 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs @@ -420,6 +420,16 @@ public void CpuCountUnit_IsVcoresOnAzureSqlDatabase_AndCpusEverywhereElse() Assert.Equal(" CPUs,", ServerHardwareScope.CpuCountUnit(engineEdition)); } + [Fact] + public void CpuCoreNoun_IsVcoresOnAzureSqlDatabase_AndCoresEverywhereElse() + { + /* The FinOps CPU right-sizing text prints the utilization read's count: the vCores on an Azure SQL Database, which it + names the way the utilization card does, and a CPU count everywhere else, in the word it has always used. */ + Assert.Equal("vCores", ServerHardwareScope.CpuCoreNoun(5)); + foreach (var engineEdition in new int?[] { 1, 2, 3, 4, 8, null }) + Assert.Equal("cores", ServerHardwareScope.CpuCoreNoun(engineEdition)); + } + [Fact] public void MemoryTabLabels_NameTheDatabasesLimitOnAzureSqlDatabase_AndPhysicalMemoryEverywhereElse() { diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index cd3be319e..19d17ded0 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -309,28 +309,7 @@ public static async Task GetMemoryStats( return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "memory_stats", cancellationToken) ?? McpHelpers.Status("unavailable", "No memory stats available."); - var utilization = stats.TotalPhysicalMemoryMb > 0 - ? (stats.TotalPhysicalMemoryMb - stats.AvailablePhysicalMemoryMb) / stats.TotalPhysicalMemoryMb * 100 - : 0; - - return JsonSerializer.Serialize(new - { - server = resolved.ServerName, - /* #3541 A10: the one stamp every latest-snapshot read publishes, under the one name. */ - captured_at = stats.CollectionTime.ToString("o"), - total_physical_memory_mb = stats.TotalPhysicalMemoryMb, - available_physical_memory_mb = stats.AvailablePhysicalMemoryMb, - memory_utilization_pct = Math.Round(utilization, 1), - system_memory_state = stats.SystemMemoryState, - sql_memory_model = stats.SqlMemoryModel, - target_server_memory_mb = stats.TargetServerMemoryMb, - total_server_memory_mb = stats.TotalServerMemoryMb, - buffer_pool_mb = stats.BufferPoolMb, - plan_cache_mb = stats.PlanCacheMb, - /* On an Azure SQL Database (5) total_physical_memory_mb is the database's memory limit and - available_physical_memory_mb the room left under it, not the host's RAM. */ - engine_edition = stats.EngineEdition - }, McpHelpers.JsonOptions); + return MemoryStatsPayload(resolved.ServerName, stats); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -338,6 +317,43 @@ public static async Task GetMemoryStats( } } + /// + /// The get_memory_stats payload for one snapshot. On an Azure SQL Database (engine edition 5) + /// total_physical_memory_mb is the database's memory limit and available_physical_memory_mb the room left under + /// it, not the host's RAM, and a utilization near 100% is normal there. The keys keep their names on every edition, so the + /// payload gains a memory_note that says so. Every other edition keeps the payload it always had, key for key. + /// Lite's tool emits the same shape in the same words. + /// + internal static string MemoryStatsPayload(string serverName, DarlingDataReader.MemoryStatsRow stats) + { + var utilization = stats.TotalPhysicalMemoryMb > 0 + ? (stats.TotalPhysicalMemoryMb - stats.AvailablePhysicalMemoryMb) / stats.TotalPhysicalMemoryMb * 100 + : 0; + + var payload = new + { + server = serverName, + /* #3541 A10: the one stamp every latest-snapshot read publishes, under the one name. */ + captured_at = stats.CollectionTime.ToString("o"), + total_physical_memory_mb = stats.TotalPhysicalMemoryMb, + available_physical_memory_mb = stats.AvailablePhysicalMemoryMb, + memory_utilization_pct = Math.Round(utilization, 1), + system_memory_state = stats.SystemMemoryState, + sql_memory_model = stats.SqlMemoryModel, + target_server_memory_mb = stats.TargetServerMemoryMb, + total_server_memory_mb = stats.TotalServerMemoryMb, + buffer_pool_mb = stats.BufferPoolMb, + plan_cache_mb = stats.PlanCacheMb, + engine_edition = stats.EngineEdition + }; + + if (!ServerHardwareScope.HardwareIsTheHosts(stats.EngineEdition)) + return JsonSerializer.Serialize(payload, McpHelpers.JsonOptions); + + var scoped = JsonSerializer.SerializeToNode(payload, McpHelpers.JsonOptions)!.AsObject(); + return ServerHardwareScope.WithMemoryNote(scoped).ToJsonString(McpHelpers.JsonOptions); + } + [McpServerTool(Name = "get_memory_clerks"), Description("Gets the top memory consumers by memory clerk type — shows which SQL Server components are using the most memory. LATEST IS A TIME: this reads the newest clerk snapshot, not a window, and captured_at is the instant it was collected.")] public static async Task GetMemoryClerks( NpgsqlDataSource postgres, diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs index b57d21fe3..82887538b 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs @@ -397,6 +397,33 @@ the savings estimates (#980). */ }; } + /// + /// The CPU right-sizing recommendation for one utilization row, or null when it has nothing to say. A window with no CPU + /// sample reads a P95 of 0, which is "idle" only because nothing was measured. The utilization row gives that window no verdict + /// (HasCpuSample is false), and the advice follows it. The count the text prints is the vCores the service objective + /// gives an Azure SQL Database, so there it is named "vCores", as the utilization card names it; on every other edition it + /// is the CPU count and the word stays "cores". Lite's recommendation reads the same rule. + /// + internal static RecommendationRow? BuildCpuRightSizingRecommendation(UtilizationEfficiencyRow? util, decimal monthlyCost) + { + if (util == null || !util.HasCpuSample || util.P95CpuPct >= 30 || util.CpuCount <= 4) + return null; + + var targetCores = Math.Max(4, (int)(util.CpuCount * (util.P95CpuPct / 70m))); + var savingsPct = 1m - ((decimal)targetCores / util.CpuCount); + var cpuNoun = ServerHardwareScope.CpuCoreNoun(util.EngineEdition); + return new RecommendationRow + { + Category = "Compute", + Severity = util.P95CpuPct < 15 ? "High" : "Medium", + Confidence = "Medium", + Finding = $"CPU over-provisioned ({util.CpuCount} {cpuNoun}, P95 = {util.P95CpuPct:N1}%)", + Detail = $"P95 CPU utilization is {util.P95CpuPct:N1}% (avg {util.AvgCpuPct:N1}%, max {util.MaxCpuPct}%) across {util.CpuCount} {cpuNoun}. " + + $"Consider reducing to ~{targetCores} {cpuNoun}.", + EstMonthlySavings = monthlyCost > 0 ? monthlyCost * savingsPct * 0.60m : null + }; + } + /// /// Runs every monitor-side FinOps recommendation check over the collected store and returns the consolidated /// list sorted by severity. All reads are async I/O (they don't block the UI thread), and each check is @@ -439,23 +466,9 @@ public async Task> GetRecommendationsAsync(int serverId, try { var util = await GetUtilizationEfficiencyAsync(serverId, cancellationToken); - /* A window with no CPU sample reads a P95 of 0, which is "idle" only because nothing was measured. - The utilization row gives that window no verdict (HasCpuSample is false); the advice follows it. */ - if (util != null && util.HasCpuSample && util.P95CpuPct < 30 && util.CpuCount > 4) - { - var targetCores = Math.Max(4, (int)(util.CpuCount * (util.P95CpuPct / 70m))); - var savingsPct = 1m - ((decimal)targetCores / util.CpuCount); - recommendations.Add(new RecommendationRow - { - Category = "Compute", - Severity = util.P95CpuPct < 15 ? "High" : "Medium", - Confidence = "Medium", - Finding = $"CPU over-provisioned ({util.CpuCount} cores, P95 = {util.P95CpuPct:N1}%)", - Detail = $"P95 CPU utilization is {util.P95CpuPct:N1}% (avg {util.AvgCpuPct:N1}%, max {util.MaxCpuPct}%) across {util.CpuCount} cores. " + - $"Consider reducing to ~{targetCores} cores.", - EstMonthlySavings = monthlyCost > 0 ? monthlyCost * savingsPct * 0.60m : null - }); - } + var cpuRecommendation = BuildCpuRightSizingRecommendation(util, monthlyCost); + if (cpuRecommendation != null) + recommendations.Add(cpuRecommendation); } catch (Exception ex) { diff --git a/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs b/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs index 09b73d23c..0c0b0551c 100644 --- a/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs +++ b/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs @@ -308,6 +308,94 @@ public void MemoryAndVmRules_OnAzureSqlDatabase_SayWhyTheyStandDown_AndDoNotCall Assert.Contains("its cores and memory come with its service objective", rules, StringComparison.Ordinal); } + // ── get_memory_stats: the keys keep their names, so an Azure SQL Database's payload says what they mean ── + + private static MemoryStatsRow StatsRow(int? engineEdition, double totalMb, double availableMb) => new() + { + CollectionTime = new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), + TotalPhysicalMemoryMb = totalMb, + AvailablePhysicalMemoryMb = availableMb, + SystemMemoryState = "Available physical memory is high", + SqlMemoryModel = "CONVENTIONAL", + TargetServerMemoryMb = totalMb, + TotalServerMemoryMb = totalMb - availableMb, + BufferPoolMb = 1_100, + PlanCacheMb = 200, + EngineEdition = engineEdition, + }; + + private static JsonElement MemoryPayload(MemoryStatsRow stats) => + JsonDocument.Parse(McpMemoryTools.MemoryStatsPayload("Srv", stats)).RootElement.Clone(); + + [Fact] + public void GetMemoryStats_OnAzureSqlDatabase_CarriesAMemoryNote_ThatCallsTheTotalTheDatabasesLimit_AndNearFullNormal() + { + /* A database that has grown to its limit has nothing left under it, so it reads 100% in use. On this edition that is the + normal state, and the note says so, because the same figure on SQL Server is an operating system short of memory. */ + var json = MemoryPayload(StatsRow(5, DatabaseMemoryLimitMb, 0)); + + var note = json.GetProperty("memory_note").GetString(); + Assert.Equal(ServerHardwareScope.McpMemoryNote, note); + Assert.Contains("total_physical_memory_mb is the database's memory limit (its committed target), not the host's memory", note, StringComparison.Ordinal); + Assert.Contains("available_physical_memory_mb is what is left under that limit", note, StringComparison.Ordinal); + Assert.Contains("a value near 100% is normal once the database has grown to its limit", note, StringComparison.Ordinal); + Assert.Contains("not memory pressure by itself", note, StringComparison.Ordinal); + + /* The figures and their names are as they were, and the note comes last. */ + Assert.Equal(DatabaseMemoryLimitMb, json.GetProperty("total_physical_memory_mb").GetDouble()); + Assert.Equal(0, json.GetProperty("available_physical_memory_mb").GetDouble()); + Assert.Equal(100, json.GetProperty("memory_utilization_pct").GetDouble()); + Assert.Equal(5, json.GetProperty("engine_edition").GetInt32()); + Assert.Equal("memory_note", json.EnumerateObject().Last().Name); + } + + [Theory] + [InlineData(3)] + [InlineData(8)] + [InlineData(null)] + public void GetMemoryStats_OffAzureSqlDatabase_IsUnchanged_AndCarriesNoMemoryNote(int? engineEdition) + { + var json = MemoryPayload(StatsRow(engineEdition, 65_536, 16_384)); + + Assert.False(json.TryGetProperty("memory_note", out _), "an engine that is not an Azure SQL Database gets no new key"); + Assert.Equal(75, json.GetProperty("memory_utilization_pct").GetDouble()); + Assert.Equal( + new[] + { + "server", "captured_at", "total_physical_memory_mb", "available_physical_memory_mb", "memory_utilization_pct", + "system_memory_state", "sql_memory_model", "target_server_memory_mb", "total_server_memory_mb", "buffer_pool_mb", + "plan_cache_mb", "engine_edition", + }, + json.EnumerateObject().Select(p => p.Name).ToArray()); + } + + [Theory] + [InlineData(5, true)] + [InlineData(3, false)] + public async Task GetMemoryStats_ReadFromTheStore_CarriesTheNoteOnlyWhenTheStoredEngineEditionIsAnAzureSqlDatabase(int engineEdition, bool carriesNote) + { + /* The edition rides in on the same read as the figures (the subselect over server_properties), so the note follows what + the collector stored and not anything the tool is told. */ + await SeedAsync(engineEdition); + + var stats = await new LocalDataService(_fixture.DuckDb).GetLatestMemoryStatsAsync(ServerId); + Assert.NotNull(stats); + var json = MemoryPayload(stats!); + + Assert.Equal(carriesNote, json.TryGetProperty("memory_note", out _)); + Assert.Equal(DatabaseMemoryLimitMb, json.GetProperty("total_physical_memory_mb").GetDouble()); + Assert.Equal(engineEdition, json.GetProperty("engine_edition").GetInt32()); + } + + [Fact] + public void GetMemoryStatsTool_BuildsItsPayloadThroughTheSharedNote() + { + var tool = ReadRepoFile("Lite/Mcp/McpMemoryTools.cs"); + + Assert.Contains("return MemoryStatsPayload(resolved.ServerName, stats);", tool, StringComparison.Ordinal); + Assert.Contains("ServerHardwareScope.WithMemoryNote(", tool, StringComparison.Ordinal); + } + // ── seeding ── private async Task SeedAsync(int engineEdition) diff --git a/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs b/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs index ac0de4072..cef089805 100644 --- a/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs +++ b/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs @@ -415,6 +415,16 @@ public void CpuCountUnit_IsVcoresOnAzureSqlDatabase_AndCpusEverywhereElse() Assert.Equal(" CPUs,", ServerHardwareScope.CpuCountUnit(engineEdition)); } + [Fact] + public void CpuCoreNoun_IsVcoresOnAzureSqlDatabase_AndCoresEverywhereElse() + { + /* The FinOps CPU right-sizing text prints the utilization read's count: the vCores on an Azure SQL Database, which it + names the way the utilization card does, and a CPU count everywhere else, in the word it has always used. */ + Assert.Equal("vCores", ServerHardwareScope.CpuCoreNoun(5)); + foreach (var engineEdition in new int?[] { 1, 2, 3, 4, 8, null }) + Assert.Equal("cores", ServerHardwareScope.CpuCoreNoun(engineEdition)); + } + [Fact] public void MemoryTabLabels_NameTheDatabasesLimitOnAzureSqlDatabase_AndPhysicalMemoryEverywhereElse() { diff --git a/Lite.Tests/FinOpsTests.cs b/Lite.Tests/FinOpsTests.cs index b1c927ba7..61dfa6c88 100644 --- a/Lite.Tests/FinOpsTests.cs +++ b/Lite.Tests/FinOpsTests.cs @@ -75,15 +75,20 @@ public async Task NoCpuSamples_CpuAndVmRightSizingAdviseNothing() public async Task AzureSqlDatabase_MemoryAndVmRightSizingAdviseNothing_BecauseItsMemoryComesWithItsServiceObjective() { // The database uses 40,960 MB of its own 167,117 MB memory limit, a share that advises on any other edition. Its - // service objective names 32 vCores, which are the database's own CPU count. Its memory cannot be resized on its own, + // service objective names 32 vCores, which is the CPU it is given. Its memory cannot be resized on its own, // so the memory and VM rules have nothing to recommend. var recs = await RunRecommendationsAsync(s => s.SeedRightSizingScenarioAsync(engineEdition: 5, withCpuSamples: true, vcoreCount: 32)); PrintRecommendations("AZURE SQL DATABASE MEMORY AND VM RULES", recs); Assert.DoesNotContain(recs, r => r.Finding.StartsWith("Memory over-provisioned", StringComparison.Ordinal)); Assert.DoesNotContain(recs, r => r.Category == "Hardware"); - // The CPU rule is not one of the two that stand down on a database: it reads the vCores the objective names. - Assert.Contains(recs, r => r.Finding.StartsWith("CPU over-provisioned (32 cores", StringComparison.Ordinal)); + // The CPU rule is not one of the two that stand down on a database: it reads the vCores the objective names, and it + // calls them vCores, as the utilization card does, in the finding and in the detail. + var cpu = Assert.Single(recs, r => r.Finding.StartsWith("CPU over-provisioned", StringComparison.Ordinal)); + Assert.StartsWith("CPU over-provisioned (32 vCores, P95 = ", cpu.Finding, StringComparison.Ordinal); + Assert.Contains("across 32 vCores. Consider reducing to ~", cpu.Detail, StringComparison.Ordinal); + Assert.EndsWith(" vCores.", cpu.Detail, StringComparison.Ordinal); + Assert.DoesNotContain(" cores", cpu.Finding + cpu.Detail, StringComparison.Ordinal); } [Fact] @@ -107,7 +112,12 @@ public async Task WithCpuSamplesOffAzureSqlDatabase_CpuMemoryAndVmRightSizingSti var recs = await RunRecommendationsAsync(s => s.SeedRightSizingScenarioAsync(engineEdition, withCpuSamples: true)); PrintRecommendations($"RIGHT-SIZING UNCHANGED (edition {engineEdition})", recs); - Assert.Contains(recs, r => r.Finding.StartsWith("CPU over-provisioned", StringComparison.Ordinal)); + // Off an Azure SQL Database the count is a CPU count and the word is the one it always was. + var cpu = Assert.Single(recs, r => r.Finding.StartsWith("CPU over-provisioned", StringComparison.Ordinal)); + Assert.StartsWith("CPU over-provisioned (32 cores, P95 = ", cpu.Finding, StringComparison.Ordinal); + Assert.Contains("across 32 cores. Consider reducing to ~", cpu.Detail, StringComparison.Ordinal); + Assert.EndsWith(" cores.", cpu.Detail, StringComparison.Ordinal); + Assert.DoesNotContain("vCores", cpu.Finding + cpu.Detail, StringComparison.Ordinal); Assert.Contains(recs, r => r.Finding.StartsWith("Memory over-provisioned", StringComparison.Ordinal)); Assert.Contains(recs, r => r.Category == "Hardware" && r.Finding.StartsWith("CPU: reduce from 32", StringComparison.Ordinal)); Assert.Contains(recs, r => r.Category == "Hardware" && r.Finding.StartsWith("Memory: reduce from 256GB", StringComparison.Ordinal)); diff --git a/Lite.Tests/TestDataSeeder.cs b/Lite.Tests/TestDataSeeder.cs index 8fbf36d06..afbd2496f 100644 --- a/Lite.Tests/TestDataSeeder.cs +++ b/Lite.Tests/TestDataSeeder.cs @@ -2147,8 +2147,8 @@ await SeedServerPropertiesAsync(cpuCount: 32, htRatio: 2, physicalMemMb: 262_144 /// schedulers the database can see, not the CPU it is given. memory_stats holds /// the database's own memory limit and counters: 167,117 MB (about 163 GB, what a 32-vCore Gen5 database is given) with the /// same 40,960 MB buffer pool. Pass to give it the vCore count its service objective names, or - /// leave it null for a DTU-model objective or an elastic pool, which has no CPU count of its own. Every other edition - /// has 256 GB in both tables. + /// leave it null for a DTU-model objective or an elastic pool, whose objective names no vCore count (its cpu_count is still its + /// own scheduler count). Every other edition has 256 GB in both tables. /// public async Task SeedRightSizingScenarioAsync(int engineEdition, bool withCpuSamples, int? vcoreCount = null) { diff --git a/Lite/Mcp/McpMemoryTools.cs b/Lite/Mcp/McpMemoryTools.cs index 42ac343c0..609358f56 100644 --- a/Lite/Mcp/McpMemoryTools.cs +++ b/Lite/Mcp/McpMemoryTools.cs @@ -28,24 +28,7 @@ public static async Task GetMemoryStats( ?? McpHelpers.Status("unavailable", "No memory stats available."); } - return JsonSerializer.Serialize(new - { - server = resolved.ServerName, - /* #3541 A10: the one stamp every latest-snapshot read publishes, under the one name. */ - captured_at = stats.CollectionTime.ToString("o"), - total_physical_memory_mb = stats.TotalPhysicalMemoryMb, - available_physical_memory_mb = stats.AvailablePhysicalMemoryMb, - memory_utilization_pct = Math.Round(stats.MemoryUtilizationPercent, 1), - system_memory_state = stats.SystemMemoryState, - sql_memory_model = stats.SqlMemoryModel, - target_server_memory_mb = stats.TargetServerMemoryMb, - total_server_memory_mb = stats.TotalServerMemoryMb, - buffer_pool_mb = stats.BufferPoolMb, - plan_cache_mb = stats.PlanCacheMb, - /* On an Azure SQL Database (5) total_physical_memory_mb is the database's memory limit and - available_physical_memory_mb the room left under it, not the host's RAM. */ - engine_edition = stats.EngineEdition - }, McpHelpers.JsonOptions); + return MemoryStatsPayload(resolved.ServerName, stats); } catch (Exception ex) { @@ -53,6 +36,39 @@ public static async Task GetMemoryStats( } } + /// + /// The get_memory_stats payload for one snapshot. On an Azure SQL Database (engine edition 5) + /// total_physical_memory_mb is the database's memory limit and available_physical_memory_mb the room left under + /// it, not the host's RAM, and a utilization near 100% is normal there. The keys keep their names on every edition, so the + /// payload gains a memory_note that says so. Every other edition keeps the payload it always had, key for key. + /// Darling's tool emits the same shape in the same words. + /// + internal static string MemoryStatsPayload(string serverName, MemoryStatsRow stats) + { + var payload = new + { + server = serverName, + /* #3541 A10: the one stamp every latest-snapshot read publishes, under the one name. */ + captured_at = stats.CollectionTime.ToString("o"), + total_physical_memory_mb = stats.TotalPhysicalMemoryMb, + available_physical_memory_mb = stats.AvailablePhysicalMemoryMb, + memory_utilization_pct = Math.Round(stats.MemoryUtilizationPercent, 1), + system_memory_state = stats.SystemMemoryState, + sql_memory_model = stats.SqlMemoryModel, + target_server_memory_mb = stats.TargetServerMemoryMb, + total_server_memory_mb = stats.TotalServerMemoryMb, + buffer_pool_mb = stats.BufferPoolMb, + plan_cache_mb = stats.PlanCacheMb, + engine_edition = stats.EngineEdition + }; + + if (!ServerHardwareScope.HardwareIsTheHosts(stats.EngineEdition)) + return JsonSerializer.Serialize(payload, McpHelpers.JsonOptions); + + var scoped = JsonSerializer.SerializeToNode(payload, McpHelpers.JsonOptions)!.AsObject(); + return ServerHardwareScope.WithMemoryNote(scoped).ToJsonString(McpHelpers.JsonOptions); + } + [McpServerTool(Name = "get_memory_trend"), Description("Gets memory usage over time in time buckets: total server, target, buffer pool and plan cache memory, with granted memory from the memory-grant series joined per bucket. total_granted_mb is null where that series has no snapshot (granted_note says why); use get_memory_grants for grant detail." + BaselineDiscontinuities.DescriptionSentence)] public static async Task GetMemoryTrend( LocalDataService dataService, diff --git a/Lite/Services/LocalDataService.FinOps.Recommendations.cs b/Lite/Services/LocalDataService.FinOps.Recommendations.cs index 6cde84b17..5a73c7df7 100644 --- a/Lite/Services/LocalDataService.FinOps.Recommendations.cs +++ b/Lite/Services/LocalDataService.FinOps.Recommendations.cs @@ -13,6 +13,7 @@ using DuckDB.NET.Data; using Microsoft.Data.SqlClient; using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; namespace PerformanceMonitorLite.Services; @@ -295,14 +296,17 @@ IF @sql <> N'' { var targetCores = Math.Max(4, (int)(util.CpuCount * (util.P95CpuPct / 70m))); var savingsPct = 1m - ((decimal)targetCores / util.CpuCount); + /* The count is the vCores the service objective gives an Azure SQL Database, so it is named as the utilization card + names it; everywhere else it is the CPU count, and the word stays "cores". */ + var cpuNoun = ServerHardwareScope.CpuCoreNoun(util.EngineEdition); recommendations.Add(new RecommendationRow { Category = "Compute", Severity = util.P95CpuPct < 15 ? "High" : "Medium", Confidence = "Medium", - Finding = $"CPU over-provisioned ({util.CpuCount} cores, P95 = {util.P95CpuPct:N1}%)", - Detail = $"P95 CPU utilization is {util.P95CpuPct:N1}% (avg {util.AvgCpuPct:N1}%, max {util.MaxCpuPct}%) across {util.CpuCount} cores. " + - $"Consider reducing to ~{targetCores} cores.", + Finding = $"CPU over-provisioned ({util.CpuCount} {cpuNoun}, P95 = {util.P95CpuPct:N1}%)", + Detail = $"P95 CPU utilization is {util.P95CpuPct:N1}% (avg {util.AvgCpuPct:N1}%, max {util.MaxCpuPct}%) across {util.CpuCount} {cpuNoun}. " + + $"Consider reducing to ~{targetCores} {cpuNoun}.", EstMonthlySavings = monthlyCost > 0 ? monthlyCost * savingsPct * 0.60m : null }); } diff --git a/Lite/Services/LocalDataService.Memory.cs b/Lite/Services/LocalDataService.Memory.cs index 0ec118526..8467258e5 100644 --- a/Lite/Services/LocalDataService.Memory.cs +++ b/Lite/Services/LocalDataService.Memory.cs @@ -452,7 +452,8 @@ public class MemoryStatsRow /// The server's engine edition from its latest server_properties row, or null when none is stored. On an Azure /// SQL Database (5) is the database's memory limit and - /// the room left under it, so the Memory tab and the MCP payload name them that way. + /// the room left under it, so the Memory tab names them that way and the get_memory_stats payload, which keeps its key + /// names, carries a memory_note that says so. public int? EngineEdition { get; set; } public double UsedPhysicalMemoryMb => TotalPhysicalMemoryMb - AvailablePhysicalMemoryMb; diff --git a/PerformanceMonitor.Common/ServerHardwareScope.cs b/PerformanceMonitor.Common/ServerHardwareScope.cs index 0e8572fb7..e43e80d5c 100644 --- a/PerformanceMonitor.Common/ServerHardwareScope.cs +++ b/PerformanceMonitor.Common/ServerHardwareScope.cs @@ -38,7 +38,8 @@ namespace PerformanceMonitor.Common; /// limit (1,838 MB on a 1-vCore General Purpose database whose server_properties row holds about 912 GB), and its buffer /// pool and server-memory counters are the database's too. So what reads memory_stats (the FinOps utilization card's /// Physical Memory and Buffer Pool %, its verdict sentences and the health score's memory term) is shown and scored on every -/// edition alike. Only the words change: on an Azure SQL Database the figure is the database's memory limit, not physical RAM. +/// edition alike. Only the words change: on an Azure SQL Database the figure is the database's memory limit, not physical RAM. +/// get_memory_stats keeps its key names, so on an Azure SQL Database it adds a memory_note (see ). /// public static class ServerHardwareScope { @@ -79,6 +80,28 @@ public static JsonObject ScopeServerPropertiesPayload(JsonObject payload, int? v return payload; } + /// + /// The memory_note get_memory_stats returns on an Azure SQL Database, word for word in both apps. The tool keeps + /// the key names total_physical_memory_mb and available_physical_memory_mb on every edition, so on an Azure SQL + /// Database this note is what says they are the database's memory limit and the room left under it, and that a utilization near + /// 100% is the normal state of a database that has grown to its limit. Without it a client reads that figure as OS memory pressure. + /// + public const string McpMemoryNote = + "On an Azure SQL Database total_physical_memory_mb is the database's memory limit (its committed target), not the host's " + + "memory. available_physical_memory_mb is what is left under that limit. memory_utilization_pct is the share of that limit " + + "in use: a value near 100% is normal once the database has grown to its limit, and is not memory pressure by itself."; + + /// + /// Appends memory_note to a get_memory_stats payload for an Azure SQL Database. Every key the payload already + /// has stays as it is, and the note comes last, as hardware_note does on get_server_properties. Both apps call it, + /// so the key and the words cannot drift apart. A payload of any other edition never reaches it and carries no note. + /// + public static JsonObject WithMemoryNote(JsonObject payload) + { + payload["memory_note"] = McpMemoryNote; + return payload; + } + /// The Hardware Note a FinOps Server Inventory row carries for an Azure SQL Database whose collector read gave no reason of its own. public const string InventoryHardwareNote = "Azure SQL Database: memory, sockets, cores per socket and hyperthread ratio are the host's, not this database's. " + @@ -118,6 +141,15 @@ public static string CpuCountText(int? engineEdition, int cpuCount) => public static string CpuCountUnit(int? engineEdition) => HardwareIsTheHosts(engineEdition) ? " vCores," : " CPUs,"; + /// + /// What a sentence calls the CPU count it prints beside a number: "vCores" on an Azure SQL Database, where that count is the + /// vCores the service objective gives the database (the same rule as ), and "cores" everywhere else, + /// which is the word the FinOps CPU right-sizing recommendation has always used. The recommendation takes the count from the + /// FinOps utilization read, so it names it the way the utilization card does. + /// + public static string CpuCoreNoun(int? engineEdition) => + HardwareIsTheHosts(engineEdition) ? "vCores" : "cores"; + /// /// What the FinOps utilization card measures the buffer pool against: physical RAM on SQL Server and Managed Instance, and /// on an Azure SQL Database the database's memory limit. Both are memory_stats.total_physical_memory_mb, which on an From 6fae165d3ecca4418d3b8e7ff9b3ab252239ef64 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:49:18 -0400 Subject: [PATCH 14/17] Point the CPU right-sizing stand-down pin at the moved guard The CPU rule's guard moved into ViewerDataService.BuildCpuRightSizingRecommendation, so the pin read a method body that no longer held it and failed on every run. The CPU half now reads the builder's guard (util == null || !util.HasCpuSample || util.P95CpuPct >= 30) and the call to the builder in GetRecommendationsAsync. The VM half still reads GetRecommendationsAsync. Dropping !util.HasCpuSample from the guard fails the pin, and so does replacing the call with null. --- .../ViewerFinOpsRecommendationsTests.cs | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/Darling/Darling.Tests/ViewerFinOpsRecommendationsTests.cs b/Darling/Darling.Tests/ViewerFinOpsRecommendationsTests.cs index 2641574bc..469b4e44a 100644 --- a/Darling/Darling.Tests/ViewerFinOpsRecommendationsTests.cs +++ b/Darling/Darling.Tests/ViewerFinOpsRecommendationsTests.cs @@ -458,8 +458,11 @@ public void EngineEditionSql_ReadsTheNewestCollectedServerPropertiesRow() /// /// The three right-sizing rules run inside GetRecommendationsAsync over the Postgres store, which this - /// suite does not stand up, so the gates are pinned on the rule source: the CPU rule and the VM rule read - /// HasCpuSample, and the memory rule and the VM rule stand down on Azure SQL Database (edition 5). + /// suite does not stand up, so the gates are pinned on the rule source. The CPU rule's guard lives in + /// BuildCpuRightSizingRecommendation, which GetRecommendationsAsync calls, so that half is pinned on + /// the builder's guard and on the call. The VM rule keeps its guard in GetRecommendationsAsync itself. The + /// CPU rule and the VM rule read HasCpuSample, and the memory rule and the VM rule stand down on Azure SQL + /// Database (edition 5). /// [Fact] public void CpuAndVmRightSizing_StandDownWithNoCpuSample() @@ -467,7 +470,10 @@ public void CpuAndVmRightSizing_StandDownWithNoCpuSample() var body = RightSizingRulesSource(); Assert.Matches( - new Regex(@"util\s*!=\s*null\s*&&\s*util\s*\.\s*HasCpuSample\s*&&\s*util\s*\.\s*P95CpuPct\s*<\s*30"), + new Regex(@"util\s*==\s*null\s*\|\|\s*!\s*util\s*\.\s*HasCpuSample\s*\|\|\s*util\s*\.\s*P95CpuPct\s*>=\s*30"), + CpuRightSizingBuilderSource()); + Assert.Matches( + new Regex(@"BuildCpuRightSizingRecommendation\s*\(\s*util\s*,\s*monthlyCost\s*\)"), body); Assert.Matches( new Regex(@"vmUtil\s*!=\s*null\s*&&\s*vmUtil\s*\.\s*HasCpuSample\s*&&"), @@ -486,12 +492,18 @@ public void MemoryAndVmRightSizing_StandDownOnAzureSqlDatabase() Assert.Equal(5, CollectorEngineCapability.AzureSqlDatabaseEngineEdition); } - private static string RightSizingRulesSource() + private static string RightSizingRulesSource() => + RecommendationsMethodSource("Task> GetRecommendationsAsync("); + + private static string CpuRightSizingBuilderSource() => + RecommendationsMethodSource("RecommendationRow? BuildCpuRightSizingRecommendation("); + + private static string RecommendationsMethodSource(string signatureText) { var source = CSharpSourceWalker.StripCommentsAndStrings( RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Recommendations.cs")); - var signature = source.IndexOf("Task> GetRecommendationsAsync(", StringComparison.Ordinal); - Assert.True(signature >= 0, "GetRecommendationsAsync is gone, so this pin would read nothing."); + var signature = source.IndexOf(signatureText, StringComparison.Ordinal); + Assert.True(signature >= 0, $"{signatureText} is gone, so this pin would read nothing."); return CSharpSourceWalker.BraceBalanced(source, source.IndexOf('{', signature)); } From 337e562b692d9070cec684fa821b522d19751060 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:27:48 -0400 Subject: [PATCH 15/17] Memory > Overview names its first two figures from the edition its other lines read --- .../AzureSqlDatabaseOwnFiguresTests.cs | 4 ++-- .../ViewerAzureSqlDatabaseCallSiteTests.cs | 19 ++++++++++++++++ .../ViewerServerTab.Memory.cs | 8 ++++--- Lite.Tests/AzureSqlDatabaseCallSiteTests.cs | 22 +++++++++++++++++++ Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs | 4 ++-- Lite/Controls/ServerTab.Charts.cs | 8 ++++--- 6 files changed, 55 insertions(+), 10 deletions(-) diff --git a/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs index 26c1447c0..cd4583e74 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs @@ -547,8 +547,8 @@ public void MemoryTab_AsksTheSharedRule_ForTheNamesOfItsFirstTwoFigures_InTheVie { var memory = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerServerTab.Memory.cs"); var xaml = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerServerTab.xaml"); - Assert.Contains("PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(stats?.EngineEdition);", memory, StringComparison.Ordinal); - Assert.Contains("AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(stats?.EngineEdition);", memory, StringComparison.Ordinal); + Assert.Contains("PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(_server.EngineEdition);", memory, StringComparison.Ordinal); + Assert.Contains("AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(_server.EngineEdition);", memory, StringComparison.Ordinal); Assert.Contains("x:Name=\"PhysicalMemoryLabel\"", xaml, StringComparison.Ordinal); Assert.Contains("x:Name=\"AvailablePhysicalMemoryLabel\"", xaml, StringComparison.Ordinal); diff --git a/Darling/Darling.Tests/ViewerAzureSqlDatabaseCallSiteTests.cs b/Darling/Darling.Tests/ViewerAzureSqlDatabaseCallSiteTests.cs index e9aa701f2..c83f2e14d 100644 --- a/Darling/Darling.Tests/ViewerAzureSqlDatabaseCallSiteTests.cs +++ b/Darling/Darling.Tests/ViewerAzureSqlDatabaseCallSiteTests.cs @@ -94,6 +94,25 @@ public void TheMemoryOverview_ReadsNotApplicable_ThroughTheHelpers() Assert.Contains("MemoryStateText.Text = SystemMemoryStateText(stats.SystemMemoryState, _server.EngineEdition);", body, StringComparison.Ordinal); } + /// + /// One edition for the whole Memory Overview panel. The two captions over its first figures, the two page-file figures and the + /// memory state all take the registry's edition (_server.EngineEdition), as every other edition-dependent line on the + /// tab does. So the panel cannot name a figure "Physical Memory" above a page file of "n/a". Nothing in the method reads an + /// edition off the memory row. + /// + [Fact] + public void TheMemoryOverview_ReadsTheRegistrysEdition_ForItsCaptionsAndForItsOtherLines() + { + var body = MethodBody(["Darling", "PerformanceMonitor.Darling.Viewer", "ViewerServerTab.Memory.cs"], "void RenderMemorySummary("); + + Assert.Contains("PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(_server.EngineEdition);", body, StringComparison.Ordinal); + Assert.Contains("AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(_server.EngineEdition);", body, StringComparison.Ordinal); + + /* Five lines read an edition (two captions, two page-file figures, the state) and every one of them reads _server.EngineEdition. */ + Assert.Equal(5, CountOf(body, "EngineEdition")); + Assert.Equal(5, CountOf(body, "_server.EngineEdition")); + } + private static readonly string[] McpDataToolsFile = ["Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"]; private static readonly string[] EngineCapabilityFile = ["Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingEngineCapability.cs"]; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs index 80d78430f..11af599bd 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs @@ -126,9 +126,11 @@ width and the nested one multiplies against a contention count that no longer ex private void RenderMemorySummary(MemoryStatsRow? stats) { /* On an Azure SQL Database the first two figures are the database's memory limit and the room left under it, not the - host's RAM, so they are named that way (the same words the FinOps utilization card uses). */ - PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(stats?.EngineEdition); - AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(stats?.EngineEdition); + host's RAM, so they are named that way (the same words the FinOps utilization card uses). The names follow the + registry's edition (_server.EngineEdition), the value the page-file and memory-state lines below read, so one panel + never names a figure one way and shows its neighbor the other. */ + PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(_server.EngineEdition); + AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(_server.EngineEdition); if (stats == null) { diff --git a/Lite.Tests/AzureSqlDatabaseCallSiteTests.cs b/Lite.Tests/AzureSqlDatabaseCallSiteTests.cs index 64e0b6b5d..ad5fdd059 100644 --- a/Lite.Tests/AzureSqlDatabaseCallSiteTests.cs +++ b/Lite.Tests/AzureSqlDatabaseCallSiteTests.cs @@ -89,6 +89,28 @@ public void TheMemoryOverview_ReadsNotApplicable_ThroughTheHelpers() Assert.Contains("MemoryStateText.Text = SystemMemoryStateText(stats.SystemMemoryState, _isAzureSqlDatabase);", body, StringComparison.Ordinal); } + /// + /// One edition for the whole Memory Overview panel. The two captions over its first figures take _engineEdition, and the + /// page-file and memory-state lines beside them take _isAzureSqlDatabase, which is that same field compared with edition + /// 5. So the panel cannot name a figure "Physical Memory" above a page file of "n/a". Nothing in the method reads an edition + /// off the memory row. + /// + [Fact] + public void TheMemoryOverview_ReadsTheTabsOwnEdition_ForItsCaptionsAndForItsOtherLines() + { + var body = MethodBody("Lite/Controls/ServerTab.Charts.cs", "void UpdateMemorySummary("); + + Assert.Contains("PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(_engineEdition);", body, StringComparison.Ordinal); + Assert.Contains("AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(_engineEdition);", body, StringComparison.Ordinal); + Assert.Contains("private bool _isAzureSqlDatabase => _engineEdition == ServerHardwareScope.AzureSqlDatabaseEngineEdition;", Code("Lite/Controls/ServerTab.xaml.cs"), StringComparison.Ordinal); + + /* Five lines read an edition: two spell it _engineEdition and three spell it _isAzureSqlDatabase. No other source is + read in the method, and a property read off the row (stats.EngineEdition and the like) would show up as EngineEdition. */ + Assert.Equal(2, CountOf(body, "_engineEdition")); + Assert.Equal(3, CountOf(body, "_isAzureSqlDatabase")); + Assert.DoesNotContain("EngineEdition", body, StringComparison.Ordinal); + } + /// /// The tab holds the connection check's edition as a number, so a failed check (0) is told apart from a box, and /// every tab load fills it from the store before any loader reads it. diff --git a/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs b/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs index 178059487..fa688677d 100644 --- a/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs +++ b/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs @@ -532,8 +532,8 @@ public void MemoryTab_AsksTheSharedRule_ForTheNamesOfItsFirstTwoFigures() var charts = ReadRepoFile("Lite/Controls/ServerTab.Charts.cs"); var xaml = ReadRepoFile("Lite/Controls/ServerTab.xaml"); - Assert.Contains("PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(stats?.EngineEdition);", charts, StringComparison.Ordinal); - Assert.Contains("AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(stats?.EngineEdition);", charts, StringComparison.Ordinal); + Assert.Contains("PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(_engineEdition);", charts, StringComparison.Ordinal); + Assert.Contains("AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(_engineEdition);", charts, StringComparison.Ordinal); Assert.Contains("x:Name=\"PhysicalMemoryLabel\"", xaml, StringComparison.Ordinal); Assert.Contains("x:Name=\"AvailablePhysicalMemoryLabel\"", xaml, StringComparison.Ordinal); diff --git a/Lite/Controls/ServerTab.Charts.cs b/Lite/Controls/ServerTab.Charts.cs index f98acc070..6132697b6 100644 --- a/Lite/Controls/ServerTab.Charts.cs +++ b/Lite/Controls/ServerTab.Charts.cs @@ -31,9 +31,11 @@ public partial class ServerTab : UserControl private void UpdateMemorySummary(MemoryStatsRow? stats) { /* On an Azure SQL Database the first two figures are the database's memory limit and the room left under it, not the - host's RAM, so they are named that way (the same words the FinOps utilization card uses). */ - PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(stats?.EngineEdition); - AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(stats?.EngineEdition); + host's RAM, so they are named that way (the same words the FinOps utilization card uses). The names follow the tab's + own edition, the value the page-file and memory-state lines below read (_isAzureSqlDatabase is derived from it), so + one panel never names a figure one way and shows its neighbor the other. */ + PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(_engineEdition); + AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(_engineEdition); if (stats == null) { From 24cfd0a35b7aca25207618fa4903c06dd7f82b1e Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:35:58 -0400 Subject: [PATCH 16/17] The latest-memory reads no longer carry an edition: nothing reads the row's, and the Memory tab follows the tab's own --- .../AzureSqlDatabaseMemoryScopeTests.cs | 22 ++++++---- .../AzureSqlDatabaseOwnFiguresTests.cs | 17 +++++--- .../ViewerDataService.Memory.cs | 20 ++------- Lite.Tests/AzureSqlDatabaseCallSiteTests.cs | 8 ++-- .../AzureSqlDatabaseMemoryScopeTests.cs | 43 +++++++++++++------ Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs | 9 ++-- Lite/Mcp/McpMemoryTools.cs | 5 +-- Lite/Services/LocalDataService.Memory.cs | 19 +------- 8 files changed, 69 insertions(+), 74 deletions(-) diff --git a/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs index 1e2ac1adb..7fb112600 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs @@ -343,17 +343,21 @@ any other edition gives none of them and the stored state. */ } [Fact] - public void GetMemoryStats_TheMemoryRead_CarriesNoEngineEdition_SoNoSecondSourceCanDisagreeWithTheRegistry() + public void TheLatestMemoryReads_CarryNoEngineEdition_SoNoSecondSourceCanDisagreeWithTheRegistry() { - /* This tool's one edition is the registry's (DarlingEngineCapability), the same value every Darling MCP not_collected - answer reads. A server_properties subselect beside the memory figures would be a second source for the same answer, - one that could say 3 while the registry says 5. The row has no edition member and the statement reads no - server_properties. The Viewer's own read (ViewerDataService.LatestMemoryStatsSql) keeps its subselect, because - server_properties is the Viewer's established source. */ - Assert.DoesNotContain("server_properties", DarlingDataReader.LatestMemoryStatsSql, StringComparison.Ordinal); - Assert.DoesNotContain("engine_edition", DarlingDataReader.LatestMemoryStatsSql, StringComparison.Ordinal); + /* Every surface that names the memory figures reads the REGISTRY's edition: the tool through DarlingEngineCapability (the + same value every Darling MCP not_collected answer reads) and the viewer through its server entry, the value its + page-file and memory-state lines read. A server_properties subselect beside the memory figures would be a second + source for the same answer, one that could say 3 while the registry says 5. So neither statement reads + server_properties, and neither row has an edition member. */ + foreach (var sql in new[] { DarlingDataReader.LatestMemoryStatsSql, ViewerDataService.LatestMemoryStatsSql }) + { + Assert.DoesNotContain("server_properties", sql, StringComparison.Ordinal); + Assert.DoesNotContain("engine_edition", sql, StringComparison.Ordinal); + } + Assert.Null(typeof(DarlingDataReader.MemoryStatsRow).GetProperty("EngineEdition")); - Assert.Contains("SELECT sp.engine_edition", ViewerDataService.LatestMemoryStatsSql, StringComparison.Ordinal); + Assert.Null(typeof(PerformanceMonitor.Darling.Viewer.MemoryStatsRow).GetProperty("EngineEdition")); } [Fact] diff --git a/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs index cd4583e74..f04cf18ca 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs @@ -522,19 +522,16 @@ public void WorkerReads_KeepANullInUseCountNull_InAllThreePlaces() public void MemoryUtilization_IsStillComputedFromMemoryStats_NotFromTheHostsPhysicalMemory() { /* The read is the memory_stats snapshot, which the collector fills from the database's own committed target on an - Azure SQL Database (1,838 MB for a 1-vCore General Purpose database), so dividing by it is database-scoped. The one - thing the Viewer's statement takes from server_properties is the engine edition, which names the figures on the - Memory tab. The MCP statement takes nothing from it: the tool reads its edition from the registry. */ + Azure SQL Database (1,838 MB for a 1-vCore General Purpose database), so dividing by it is database-scoped. Neither + statement takes anything from server_properties: both the tool and the viewer read their edition from the registry. */ foreach (var sql in new[] { DarlingDataReader.LatestMemoryStatsSql, ViewerDataService.LatestMemoryStatsSql }) { Assert.Contains("FROM v_memory_stats", sql, StringComparison.Ordinal); Assert.DoesNotContain("sp.physical_memory_mb", sql, StringComparison.Ordinal); Assert.DoesNotContain("sp.cpu_count", sql, StringComparison.Ordinal); + Assert.DoesNotContain("server_properties", sql, StringComparison.Ordinal); } - Assert.Contains("SELECT sp.engine_edition", ViewerDataService.LatestMemoryStatsSql, StringComparison.Ordinal); - Assert.DoesNotContain("server_properties", DarlingDataReader.LatestMemoryStatsSql, StringComparison.Ordinal); - var tool = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"); Assert.Contains( "? (stats.TotalPhysicalMemoryMb - stats.AvailablePhysicalMemoryMb) / stats.TotalPhysicalMemoryMb * 100", @@ -552,8 +549,14 @@ public void MemoryTab_AsksTheSharedRule_ForTheNamesOfItsFirstTwoFigures_InTheVie Assert.Contains("x:Name=\"PhysicalMemoryLabel\"", xaml, StringComparison.Ordinal); Assert.Contains("x:Name=\"AvailablePhysicalMemoryLabel\"", xaml, StringComparison.Ordinal); + /* The viewer's memory read carries no edition: the strip's names follow the registry's, the value its page-file lines read. + The row is built from the eleven memory columns and nothing else. */ var dataService = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.Memory.cs"); - Assert.Contains("reader.IsDBNull(11) ? null : Convert.ToInt32(reader.GetValue(11)));", dataService, StringComparison.Ordinal); + var viewerRowStart = dataService.IndexOf("return new MemoryStatsRow(", StringComparison.Ordinal); + Assert.True(viewerRowStart > 0, "the viewer's MemoryStatsRow construction is missing"); + var viewerRow = dataService[viewerRowStart..dataService.IndexOf(");", viewerRowStart, StringComparison.Ordinal)]; + Assert.Contains("reader.IsDBNull(10) ? 0 : reader.GetDouble(10)", viewerRow, StringComparison.Ordinal); + Assert.DoesNotContain("IsDBNull(11)", viewerRow, StringComparison.Ordinal); /* The MCP tool's payload names its figures from the one registry edition it read, not from a column of the memory read. */ var serviceReader = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingDataReader.cs"); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs index 03002f3bf..7924b74b9 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs @@ -21,10 +21,7 @@ namespace PerformanceMonitor.Darling.Viewer; /// MemoryStatsRow (LocalDataService.Memory.cs). The eight MB metrics are /// numeric(18,2) in the store and CAST to double precision in the read for the typed GetDouble /// reader; the two state strings are text. Lite's computed UsedPhysicalMemoryMb / -/// MemoryUtilizationPercent helpers are dropped — the summary strip displays only these fields. -/// EngineEdition is the server's engine edition from its latest server_properties row, null when none is stored. On an -/// Azure SQL Database (5) TotalPhysicalMemoryMb is the database's memory limit and AvailablePhysicalMemoryMb the room -/// left under it, so the strip names them that way. +/// MemoryUtilizationPercent helpers are dropped — the summary strip displays only these fields. public sealed record MemoryStatsRow( DateTime CollectionTime, double TotalPhysicalMemoryMb, @@ -36,8 +33,7 @@ public sealed record MemoryStatsRow( double TargetServerMemoryMb, double TotalServerMemoryMb, double BufferPoolMb, - double PlanCacheMb, - int? EngineEdition = null); + double PlanCacheMb); /// One point of the Memory Overview's memory-grant overlay line: total granted MB across all /// pools at a collection (or, for a singleton bucket, a raw collection). Lite crammed this into its @@ -135,14 +131,7 @@ public sealed partial class ViewerDataService CAST(target_server_memory_mb AS double precision) AS target_server_memory_mb, CAST(total_server_memory_mb AS double precision) AS total_server_memory_mb, CAST(buffer_pool_mb AS double precision) AS buffer_pool_mb, - CAST(plan_cache_mb AS double precision) AS plan_cache_mb, - ( - SELECT sp.engine_edition - FROM server_properties AS sp - WHERE sp.server_id = $1 - ORDER BY sp.collection_time DESC - LIMIT 1 - ) AS engine_edition + CAST(plan_cache_mb AS double precision) AS plan_cache_mb FROM v_memory_stats WHERE server_id = $1 ORDER BY collection_time DESC @@ -364,8 +353,7 @@ ORDER BY sample_time reader.IsDBNull(7) ? 0 : reader.GetDouble(7), reader.IsDBNull(8) ? 0 : reader.GetDouble(8), reader.IsDBNull(9) ? 0 : reader.GetDouble(9), - reader.IsDBNull(10) ? 0 : reader.GetDouble(10), - reader.IsDBNull(11) ? null : Convert.ToInt32(reader.GetValue(11))); + reader.IsDBNull(10) ? 0 : reader.GetDouble(10)); } /// Total granted MB across all pools per collection over the window — the Overview memory diff --git a/Lite.Tests/AzureSqlDatabaseCallSiteTests.cs b/Lite.Tests/AzureSqlDatabaseCallSiteTests.cs index ad5fdd059..dcca429f4 100644 --- a/Lite.Tests/AzureSqlDatabaseCallSiteTests.cs +++ b/Lite.Tests/AzureSqlDatabaseCallSiteTests.cs @@ -137,9 +137,9 @@ public void TheTab_FallsBackToTheStoredEdition_BeforeEveryLoad() /// /// get_memory_stats has ONE edition, read ONCE, through McpEngineCapability.EngineEditionAsync: the newest collected /// server_properties row, which is also what NotCollectedStatusAsync and every other Lite MCP gate read. The - /// payload's engine_edition, memory_note and memory-state pair are all built from that value, never from the row's - /// own EngineEdition (the desktop Memory tab's), so the tool cannot disagree with its own gate. The payload's answers are - /// pinned in AzureSqlDatabaseMemoryScopeTests; this pins where the value comes from. + /// payload's engine_edition, memory_note and memory-state pair are all built from that value, so the tool cannot + /// disagree with its own gate. The memory row carries no edition at all. The payload's answers are pinned in + /// AzureSqlDatabaseMemoryScopeTests; this pins where the value comes from. /// [Fact] public void GetMemoryStats_ReadsTheEditionOnce_FromTheEngineCapabilitySource_AndEverythingEditionDependentFollowsIt() @@ -150,7 +150,7 @@ public void GetMemoryStats_ReadsTheEditionOnce_FromTheEngineCapabilitySource_And var body = MethodBody(ToolsFile, "Task GetMemoryStats("); var payload = MethodBody(ToolsFile, "string MemoryStatsPayload("); - /* One read in the tool. The row's own edition is not read by the tool or the payload. */ + /* One read in the tool, and no edition off the row (it has none). */ Assert.Equal(1, CountOf(body, "EngineEditionAsync(")); Assert.Contains("var engineEdition = await McpEngineCapability.EngineEditionAsync(dataService, resolved.ServerId);", body, StringComparison.Ordinal); Assert.Contains("return MemoryStatsPayload(resolved.ServerName, stats, engineEdition);", body, StringComparison.Ordinal); diff --git a/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs b/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs index a630f1eb7..677f579fb 100644 --- a/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs +++ b/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs @@ -310,9 +310,8 @@ public void MemoryAndVmRules_OnAzureSqlDatabase_SayWhyTheyStandDown_AndDoNotCall // ── get_memory_stats: the keys keep their names, so an Azure SQL Database's payload says what they mean ── - /// The row GetLatestMemoryStatsAsync returns. is the row's OWN edition, which the - /// desktop Memory tab reads and the tool's payload must not. - private static MemoryStatsRow StatsRow(double totalMb, double availableMb, int? rowEdition = null) => new() + /// The row GetLatestMemoryStatsAsync returns. It carries no edition: the payload takes the one the tool read. + private static MemoryStatsRow StatsRow(double totalMb, double availableMb) => new() { CollectionTime = new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), TotalPhysicalMemoryMb = totalMb, @@ -323,15 +322,14 @@ public void MemoryAndVmRules_OnAzureSqlDatabase_SayWhyTheyStandDown_AndDoNotCall TotalServerMemoryMb = totalMb - availableMb, BufferPoolMb = 1_100, PlanCacheMb = 200, - EngineEdition = rowEdition, }; /// The payload for the ONE edition the tool reads (). private static JsonElement MemoryPayload(MemoryStatsRow stats, int engineEdition) => JsonDocument.Parse(McpMemoryTools.MemoryStatsPayload("Srv", stats, engineEdition)).RootElement.Clone(); - private static JsonElement MemoryPayload(int engineEdition, double totalMb, double availableMb, int? rowEdition = null) => - MemoryPayload(StatsRow(totalMb, availableMb, rowEdition), engineEdition); + private static JsonElement MemoryPayload(int engineEdition, double totalMb, double availableMb) => + MemoryPayload(StatsRow(totalMb, availableMb), engineEdition); [Fact] public void GetMemoryStats_OnAzureSqlDatabase_CarriesAMemoryNote_ThatCallsTheTotalTheDatabasesLimit_AndNearFullNormal() @@ -389,16 +387,15 @@ public void GetMemoryStats_OffAzureSqlDatabase_KeepsTheStoredState_AndCarriesNoN } [Theory] - [InlineData(5, 3)] - [InlineData(3, 5)] - [InlineData(5, null)] - [InlineData(0, 5)] - public void GetMemoryStats_EngineEdition_MemoryNote_AndTheStateNote_AllFollowTheOneEditionTheToolReads_NeverTheRowsOwn(int toolEdition, int? rowEdition) + [InlineData(5)] + [InlineData(3)] + [InlineData(8)] + [InlineData(0)] + public void GetMemoryStats_EngineEdition_MemoryNote_AndTheStateNote_AllFollowTheOneEditionTheToolReads(int toolEdition) { - /* The row's own EngineEdition is a second read of server_properties (the desktop Memory tab's). Fed a value that - disagrees with the one the tool read from McpEngineCapability, in either direction, the payload follows the tool's: + /* The payload is built from the one edition the tool read from McpEngineCapability and from nothing else: engine_edition, the memory note, the state and the state's note all flip together with it. */ - var json = MemoryPayload(toolEdition, DatabaseMemoryLimitMb, 500, rowEdition); + var json = MemoryPayload(toolEdition, DatabaseMemoryLimitMb, 500); var azure = toolEdition == 5; Assert.Equal(azure, json.TryGetProperty("memory_note", out _)); @@ -409,6 +406,24 @@ public void GetMemoryStats_EngineEdition_MemoryNote_AndTheStateNote_AllFollowThe Assert.Equal(toolEdition, json.GetProperty("engine_edition").GetInt32()); } + [Fact] + public void TheLatestMemoryRead_CarriesNoEngineEdition_SoNoSecondSourceCanDisagreeWithTheTabsOrTheToolsOwn() + { + /* Each surface that names the memory figures reads ONE edition of its own: the Memory tab the tab's (the same one its + page-file and memory-state lines read), get_memory_stats the one McpEngineCapability reads. A server_properties + subselect beside the memory figures would be a second source for the same answer, so the read carries none and the + row has no member to hold one. */ + var service = ReadRepoFile("Lite/Services/LocalDataService.Memory.cs"); + var start = service.IndexOf("Task GetLatestMemoryStatsAsync(", StringComparison.Ordinal); + Assert.True(start > 0, "GetLatestMemoryStatsAsync is missing"); + var memoryRead = service[start..service.IndexOf("GetMemoryTrendAsync(", start, StringComparison.Ordinal)]; + + Assert.Contains("FROM v_memory_stats", memoryRead, StringComparison.Ordinal); + Assert.DoesNotContain("server_properties", memoryRead, StringComparison.Ordinal); + Assert.DoesNotContain("engine_edition", memoryRead, StringComparison.Ordinal); + Assert.Null(typeof(MemoryStatsRow).GetProperty("EngineEdition")); + } + [Theory] [InlineData(5, true)] [InlineData(3, false)] diff --git a/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs b/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs index fa688677d..adf291f5a 100644 --- a/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs +++ b/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs @@ -537,7 +537,7 @@ public void MemoryTab_AsksTheSharedRule_ForTheNamesOfItsFirstTwoFigures() Assert.Contains("x:Name=\"PhysicalMemoryLabel\"", xaml, StringComparison.Ordinal); Assert.Contains("x:Name=\"AvailablePhysicalMemoryLabel\"", xaml, StringComparison.Ordinal); - /* The MCP tool names its figures from the ONE edition it read through McpEngineCapability, not from the row's own. */ + /* The MCP tool names its figures from the ONE edition it read through McpEngineCapability. The row carries none. */ var tool = ReadRepoFile("Lite/Mcp/McpMemoryTools.cs"); Assert.Contains("engine_edition = engineEdition == CollectorEngineCapability.UnknownEngineEdition ? (int?)null : engineEdition", tool, StringComparison.Ordinal); Assert.DoesNotContain("stats.EngineEdition", tool, StringComparison.Ordinal); @@ -859,11 +859,10 @@ INSERT INTO memory_stats Assert.Equal(totalMb, stats!.TotalPhysicalMemoryMb); Assert.Equal((totalMb - availableMb) / totalMb * 100, stats.MemoryUtilizationPercent, precision: 6); Assert.InRange(stats.MemoryUtilizationPercent, 1, 99); - Assert.Equal(engineEdition, stats.EngineEdition); } [Fact] - public async Task MemoryStatsRead_CarriesNoEngineEdition_WhenNoServerPropertiesRowIsStored() + public async Task MemoryStatsRead_ReturnsTheSnapshot_WhenNoServerPropertiesRowIsStored() { using (var readLock = _fixture.DuckDb.AcquireReadLock()) { @@ -883,7 +882,9 @@ INSERT INTO memory_stats var stats = await new LocalDataService(_fixture.DuckDb).GetLatestMemoryStatsAsync(ServerId); + /* The read takes nothing from server_properties, so a server that has none still gets its memory figures. */ Assert.NotNull(stats); - Assert.Null(stats!.EngineEdition); + Assert.Equal(1_000, stats!.TotalPhysicalMemoryMb); + Assert.Equal(400, stats.AvailablePhysicalMemoryMb); } } diff --git a/Lite/Mcp/McpMemoryTools.cs b/Lite/Mcp/McpMemoryTools.cs index faf9f0568..2992d1fbf 100644 --- a/Lite/Mcp/McpMemoryTools.cs +++ b/Lite/Mcp/McpMemoryTools.cs @@ -31,8 +31,7 @@ public static async Task GetMemoryStats( /* ONE edition for the whole answer, read once from the source every Lite MCP engine gate reads (the newest collected server_properties row), the same one NotCollectedStatusAsync reads on this tool's miss path. engine_edition, memory_note and the memory-state pair all follow it, so the tool cannot disagree with its - own gate. The row's own EngineEdition (a second read of that table) is for the desktop Memory tab and is - not read here. */ + own gate. The memory read carries no edition of its own. */ var engineEdition = await McpEngineCapability.EngineEditionAsync(dataService, resolved.ServerId); return MemoryStatsPayload(resolved.ServerName, stats, engineEdition); @@ -47,7 +46,7 @@ not read here. */ /// The get_memory_stats payload for one snapshot, built from the row and the ONE engine edition the tool read for this /// answer (; /// when the store has none). engine_edition (null when the edition is unknown), memory_note and the memory-state - /// pair all follow that one value, never the row's own EngineEdition. + /// pair all follow that one value. The row carries no edition. /// /// On an Azure SQL Database (engine edition 5) total_physical_memory_mb is the database's memory limit and /// available_physical_memory_mb the room left under it, not the host's RAM, and a utilization near 100% is normal there. diff --git a/Lite/Services/LocalDataService.Memory.cs b/Lite/Services/LocalDataService.Memory.cs index 8467258e5..c4c7349c5 100644 --- a/Lite/Services/LocalDataService.Memory.cs +++ b/Lite/Services/LocalDataService.Memory.cs @@ -39,14 +39,7 @@ public partial class LocalDataService target_server_memory_mb, total_server_memory_mb, buffer_pool_mb, - plan_cache_mb, - ( - SELECT sp.engine_edition - FROM v_server_properties AS sp - WHERE sp.server_id = $1 - ORDER BY sp.collection_time DESC - LIMIT 1 - ) AS engine_edition + plan_cache_mb FROM v_memory_stats WHERE server_id = $1 ORDER BY collection_time DESC @@ -72,8 +65,7 @@ ORDER BY collection_time DESC TargetServerMemoryMb = reader.IsDBNull(7) ? 0 : ToDouble(reader.GetValue(7)), TotalServerMemoryMb = reader.IsDBNull(8) ? 0 : ToDouble(reader.GetValue(8)), BufferPoolMb = reader.IsDBNull(9) ? 0 : ToDouble(reader.GetValue(9)), - PlanCacheMb = reader.IsDBNull(10) ? 0 : ToDouble(reader.GetValue(10)), - EngineEdition = reader.IsDBNull(11) ? null : Convert.ToInt32(reader.GetValue(11)) + PlanCacheMb = reader.IsDBNull(10) ? 0 : ToDouble(reader.GetValue(10)) }; } @@ -449,13 +441,6 @@ public class MemoryStatsRow public double TotalServerMemoryMb { get; set; } public double BufferPoolMb { get; set; } public double PlanCacheMb { get; set; } - - /// The server's engine edition from its latest server_properties row, or null when none is stored. On an Azure - /// SQL Database (5) is the database's memory limit and - /// the room left under it, so the Memory tab names them that way and the get_memory_stats payload, which keeps its key - /// names, carries a memory_note that says so. - public int? EngineEdition { get; set; } - public double UsedPhysicalMemoryMb => TotalPhysicalMemoryMb - AvailablePhysicalMemoryMb; public double MemoryUtilizationPercent => TotalPhysicalMemoryMb > 0 ? UsedPhysicalMemoryMb / TotalPhysicalMemoryMb * 100 : 0; } From 15e1b1f7e8579c26940c08ef5933e4950bdb091f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:36:04 -0400 Subject: [PATCH 17/17] The store-views pin scans the analysis and storage projects too, and its doc names all four --- .../DarlingReadsUseOnlyStoreViewsTests.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Darling/Darling.Tests/DarlingReadsUseOnlyStoreViewsTests.cs b/Darling/Darling.Tests/DarlingReadsUseOnlyStoreViewsTests.cs index fff7fc4ac..784d72362 100644 --- a/Darling/Darling.Tests/DarlingReadsUseOnlyStoreViewsTests.cs +++ b/Darling/Darling.Tests/DarlingReadsUseOnlyStoreViewsTests.cs @@ -21,7 +21,9 @@ namespace Darling.Tests; /// Every v_ name that a Darling read uses as a FROM or JOIN target must be a view the Darling store creates. /// Lite's reads query v_server_properties, which has no Darling twin, and a read ported from Lite that keeps the name fails /// on PostgreSQL with 42P01 (the relation does not exist). Only the live tests, which need a database, see that failure. This -/// pin reads the source text of the service and the viewer, with the comments removed, so it fails without one. +/// pin reads the source text of every Darling project that holds reads, with the comments removed, so it fails without one. The +/// projects are PerformanceMonitor.Darling.Service, PerformanceMonitor.Darling.Viewer, +/// PerformanceMonitor.Darling.Analysis and PerformanceMonitor.Darling.Storage. /// The store's views are and . /// public sealed class DarlingReadsUseOnlyStoreViewsTests @@ -31,14 +33,18 @@ public sealed class DarlingReadsUseOnlyStoreViewsTests private static readonly Regex ViewTarget = new(@"\b(?:FROM|JOIN)\s+(v_[a-z0-9_]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); [Fact] - public void EveryViewNamedAsAFromOrJoinTarget_InTheServiceAndViewerSql_IsOneTheStoreCreates() + public void EveryViewNamedAsAFromOrJoinTarget_InTheServiceViewerAnalysisAndStorageSql_IsOneTheStoreCreates() { var storeViews = new HashSet( PgSchemaGenerator.AllPassthroughViews.Concat(PgSchemaGenerator.PayloadResolvingViews), StringComparer.OrdinalIgnoreCase); var unknown = new SortedSet(StringComparer.Ordinal); var scanned = 0; - foreach (var project in new[] { "PerformanceMonitor.Darling.Service", "PerformanceMonitor.Darling.Viewer" }) + foreach (var project in new[] + { + "PerformanceMonitor.Darling.Service", "PerformanceMonitor.Darling.Viewer", + "PerformanceMonitor.Darling.Analysis", "PerformanceMonitor.Darling.Storage", + }) { foreach (var file in Directory.EnumerateFiles(PathTo("Darling", project), "*.cs", SearchOption.AllDirectories)) {