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."); }