diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs
index ec60ef85d..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,30 +215,21 @@ 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);
- 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..bb31cbdf5
--- /dev/null
+++ b/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs
@@ -0,0 +1,212 @@
+/*
+ * 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 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 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
+{
+ 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("a DTU-model objective or an elastic pool", 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 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,
+ P95CpuPct = 7m,
+ BufferPoolMb = bufferPoolMb,
+ PhysicalMemoryMb = physicalMemoryMb,
+ FreeSpacePct = 50m,
+ };
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ [InlineData(8)]
+ 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());
+ }
+
+ // ── 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.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 b83b166e8..ac318f6f5 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs
@@ -271,7 +271,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 72b388a5b..f01fb0b22 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
@@ -668,11 +668,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-model objective or an elastic pool (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
{
@@ -833,11 +835,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-model objective or an elastic pool (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 1c79a3185..4f814f0cb 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs
@@ -272,7 +272,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,13 +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; the verdict and the health score below still read the stored value. */
+ /* 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";
@@ -330,12 +333,9 @@ 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: 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();
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 51804e176..4280f70de 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 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 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 20e6de077..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; }
@@ -114,11 +119,16 @@ 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 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 and its
- /// verdict sentence cites no share of it; the verdict itself is unchanged.
+ /// 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; } = "";
@@ -138,6 +148,19 @@ 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. 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;
+ return FinOpsHealthCalculator.Overall(
+ FinOpsHealthCalculator.CpuScore(P95CpuPct), FinOpsHealthCalculator.MemoryScore(bpRatio), FinOpsHealthCalculator.StorageScore(FreeSpacePct));
+ }
}
/// Per-database resource usage (Database Resources sub-tab).
diff --git a/Lite.Tests/AzureSqlDatabaseHardwareTests.cs b/Lite.Tests/AzureSqlDatabaseHardwareTests.cs
index 9839cf34e..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,17 +221,21 @@ 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);
- 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..9a2951daa
--- /dev/null
+++ b/Lite.Tests/AzureSqlDatabaseHostMathTests.cs
@@ -0,0 +1,282 @@
+/*
+ * 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 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 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
+{
+ 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("a DTU-model objective or an elastic pool", 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-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)
+ {
+ 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 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,
+ P95CpuPct = 7m,
+ BufferPoolMb = bufferPoolMb,
+ PhysicalMemoryMb = physicalMemoryMb,
+ FreeSpacePct = 50m,
+ };
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ [InlineData(8)]
+ 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());
+ }
+
+ // ── 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.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();
+ }
+
+ /* 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, $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 45a62dc37..e2c6b9920 100644
--- a/Lite.Tests/FinOpsTests.cs
+++ b/Lite.Tests/FinOpsTests.cs
@@ -72,16 +72,31 @@ 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.
- var recs = await RunRecommendationsAsync(s => s.SeedRightSizingScenarioAsync(engineEdition: 5, withCpuSamples: true));
- PrintRecommendations("AZURE SQL DATABASE HOST MEMORY", recs);
+ // 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 MEMORY AND VM RULES", recs);
Assert.DoesNotContain(recs, r => r.Finding.StartsWith("Memory over-provisioned", StringComparison.Ordinal));
Assert.DoesNotContain(recs, r => r.Category == "Hardware");
- // The CPU rule is not one of the two that stand down on a database.
- Assert.Contains(recs, r => r.Finding.StartsWith("CPU over-provisioned", StringComparison.Ordinal));
+ Assert.DoesNotContain(recs, r => r.Finding.Contains("911GB", StringComparison.Ordinal));
+ // The CPU rule is not one of the two that stand down on a database: it reads the vCores the objective names.
+ Assert.Contains(recs, r => r.Finding.StartsWith("CPU over-provisioned (32 cores", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task AzureSqlDatabaseWithNoVcores_CpuRightSizingAdvisesNothing_BecauseTheHostsCpuCountIsNotTheDatabases()
+ {
+ // 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);
+
+ 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..9065e5690 100644
--- a/Lite.Tests/TestDataSeeder.cs
+++ b/Lite.Tests/TestDataSeeder.cs
@@ -2140,10 +2140,16 @@ 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.
+ /// 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)
+ public async Task SeedRightSizingScenarioAsync(int engineEdition, bool withCpuSamples, int? vcoreCount = null)
{
await ClearTestDataAsync();
await SeedTestServerAsync();
@@ -2153,9 +2159,12 @@ 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 84a20385a..92c8a7f00 100644
--- a/Lite/Controls/FinOpsTab.xaml.cs
+++ b/Lite/Controls/FinOpsTab.xaml.cs
@@ -402,7 +402,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,13 +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; the verdict and the health score below still read the stored value. */
+ /* 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";
@@ -462,12 +465,9 @@ 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: 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();
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..6e02f50c7 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-model objective or an elastic pool (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-model objective or an elastic pool (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.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 85a40849d..6bfc52cbb 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 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 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 79913e810..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; }
@@ -97,11 +102,16 @@ 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 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 and its
- /// verdict sentence cites no share of it; the verdict itself is unchanged.
+ /// 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; } = "";
@@ -121,6 +131,19 @@ 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. 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;
+ return FinOpsHealthCalculator.Overall(
+ FinOpsHealthCalculator.CpuScore(P95CpuPct), FinOpsHealthCalculator.MemoryScore(bpRatio), FinOpsHealthCalculator.StorageScore(FreeSpacePct));
+ }
}
public class DatabaseResourceUsageRow
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 64b74fe90..a916a3977 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 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.
///
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 (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.
/// 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 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.
+ ///
+ 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..c1c71e9d7 100644
--- a/PerformanceMonitor.Common/ServerHardwareScope.cs
+++ b/PerformanceMonitor.Common/ServerHardwareScope.cs
@@ -23,8 +23,20 @@ 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 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
{
@@ -49,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)
@@ -67,25 +79,57 @@ 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";
///
- /// 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 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);
+
+ ///
+ /// 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 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 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.");
}