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 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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.");
}