diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs index c09a80ec3..1c9e5fb46 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs @@ -20,11 +20,12 @@ namespace Darling.Tests; /// -/// On an Azure SQL Database (engine edition 5) the collected server_properties hardware columns are the -/// HOST's: a 1-vCore serverless General Purpose database read 2 logical CPUs, 0 sockets, 32 cores per socket, a -/// hyperthread ratio of 64 and 911.9 GB of physical memory. Nothing may present them as the database's; the service -/// objective and the vcore_count parsed from it describe the database. The memory_stats table is a different -/// source: its memory figures are the database's own and are pinned in . +/// On an Azure SQL Database (engine edition 5) four of the collected server_properties hardware columns are the +/// HOST's: a 1-vCore serverless General Purpose database read 0 sockets, 32 cores per socket, a hyperthread ratio of 64 and +/// 911.9 GB of physical memory. Nothing may present those as the database's. The fifth column, cpu_count, is the +/// database's own scheduler count (that database read 2), so it is shown as read. The service objective and the +/// vcore_count parsed from it describe the allocation. The memory_stats table is a different source: its memory +/// figures are the database's own and are pinned in . /// /// Pinned where the rule is applied: the get_server_properties payload (which the web Server Properties /// list reads through /api/read), the web list's own tiles, the FinOps Server Inventory row the grid binds, and @@ -33,8 +34,9 @@ namespace Darling.Tests; /// public sealed class AzureSqlDatabaseHardwareTests { + /// The four columns that describe the host on an Azure SQL Database. cpu_count is not among them. private static readonly string[] s_hostKeys = - ["cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; + ["hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; private static DarlingDataReader.ServerPropertiesReadRow HostRow(int engineEdition, string edition, string? objective, int? vcores) => new( new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), edition, "12.0.2000.8", "RTM", null, @@ -46,20 +48,24 @@ private static JsonElement Payload(DarlingDataReader.ServerPropertiesReadRow row // ── get_server_properties ── [Fact] - public void GetServerProperties_OnAzureSqlDatabase_ReturnsTheHostsFiveAsNull_AndTheDatabasesVcores_WithANote() + public void GetServerProperties_OnAzureSqlDatabase_ReturnsTheHostsFourAsNull_PassesItsOwnCpuCountThrough_AndReturnsTheVcores_WithANote() { var json = Payload(HostRow(5, "SQL Azure", "GP_S_Gen5_1", 1)); foreach (var key in s_hostKeys) Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + /* The stored count is the database's own scheduler count (2 for this 1-vCore database), not the host's, and not the vCores. */ + Assert.Equal(2, json.GetProperty("cpu_count").GetInt32()); + Assert.Equal("GP_S_Gen5_1", json.GetProperty("service_objective").GetString()); Assert.Equal(1, json.GetProperty("vcore_count").GetInt32()); var note = json.GetProperty("hardware_note").GetString(); Assert.Equal(ServerHardwareScope.McpHardwareNote, note); Assert.Contains("service_objective", note, StringComparison.Ordinal); Assert.Contains("vcore_count", note, StringComparison.Ordinal); - Assert.Contains("not this database's allocation", note, StringComparison.Ordinal); + Assert.Contains("describe the host machine, not this database", note, StringComparison.Ordinal); + Assert.Contains("cpu_count is the database's own scheduler count", note, StringComparison.Ordinal); var names = json.EnumerateObject().Select(p => p.Name).ToList(); Assert.Equal(names.IndexOf("service_objective") + 1, names.IndexOf("vcore_count")); @@ -67,12 +73,13 @@ public void GetServerProperties_OnAzureSqlDatabase_ReturnsTheHostsFiveAsNull_And } [Fact] - public void GetServerProperties_OnAzureSqlDatabase_WithNoVcores_StillHidesTheHost_AndReturnsANullVcoreCount() + public void GetServerProperties_OnAzureSqlDatabase_WithNoVcores_StillHidesTheHost_KeepsItsCpuCount_AndReturnsANullVcoreCount() { var json = Payload(HostRow(5, "SQL Azure", "S0", null)); foreach (var key in s_hostKeys) Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + Assert.Equal(2, json.GetProperty("cpu_count").GetInt32()); Assert.Equal(JsonValueKind.Null, json.GetProperty("vcore_count").ValueKind); Assert.True(json.TryGetProperty("hardware_note", out _)); } @@ -109,7 +116,7 @@ public void ServerPropertiesRead_CarriesTheStoredVcoreCount() // ── the web Server Properties list ── [Fact] - public void WebServerProperties_DescriptorHidesTheHostFive_OnEdition5_AndShowsTheServiceObjectiveAndVcores() + public void WebServerProperties_DescriptorHidesTheHostFour_OnEdition5_KeepsLogicalCpus_AndShowsTheServiceObjectiveAndVcores() { var js = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "server-tabs.js"); var start = js.IndexOf("const PROPERTY_STATS = [", StringComparison.Ordinal); @@ -123,6 +130,12 @@ public void WebServerProperties_DescriptorHidesTheHostFive_OnEdition5_AndShowsTh Assert.Contains("hideWhen: AZURE_SQL_DATABASE", line, StringComparison.Ordinal); } + /* Logical CPUs is the database's own scheduler count on edition 5, so its tile is drawn there like any other. */ + var cpuCount = list.Split('\n').Single(l => l.Contains("key: \"cpu_count\"", StringComparison.Ordinal)); + Assert.Contains("label: \"Logical CPUs\"", cpuCount, StringComparison.Ordinal); + Assert.DoesNotContain("hideWhen", cpuCount, StringComparison.Ordinal); + Assert.DoesNotContain("showWhen", cpuCount, StringComparison.Ordinal); + Assert.Contains("{ key: \"vcore_count\", label: \"vCores\", format: \"int\", showWhen: AZURE_SQL_DATABASE }", list, StringComparison.Ordinal); var objective = list.Split('\n').Single(l => l.Contains("key: \"service_objective\"", StringComparison.Ordinal)); Assert.DoesNotContain("hideWhen", objective, StringComparison.Ordinal); @@ -139,7 +152,7 @@ public void WebStatRenderer_DropsTilesByTheirCondition_OnEdition5_AndKeepsEveryT const m = await import(process.argv[1]); const H = { key: "engine_edition", equals: 5 }; const stats = [ - { key: "cpu_count", hideWhen: H }, { key: "physical_memory_mb", hideWhen: H }, + { key: "cpu_count" }, { key: "socket_count", hideWhen: H }, { key: "physical_memory_mb", hideWhen: H }, { key: "service_objective" }, { key: "vcore_count", showWhen: H }, ]; const names = (d) => m.visibleStats(stats, d).map((s) => s.key).join(","); @@ -161,39 +174,42 @@ public void WebStatRenderer_DropsTilesByTheirCondition_OnEdition5_AndKeepsEveryT var error = proc.StandardError.ReadToEnd(); Assert.True(proc.WaitForExit(20000) && proc.ExitCode == 0, "node failed: " + error); using var doc = JsonDocument.Parse(output); - Assert.Equal("service_objective,vcore_count", doc.RootElement.GetProperty("e5").GetString()); - Assert.Equal("cpu_count,physical_memory_mb,service_objective", doc.RootElement.GetProperty("e3").GetString()); - Assert.Equal("cpu_count,physical_memory_mb,service_objective", doc.RootElement.GetProperty("none").GetString()); + Assert.Equal("cpu_count,service_objective,vcore_count", doc.RootElement.GetProperty("e5").GetString()); + Assert.Equal("cpu_count,socket_count,physical_memory_mb,service_objective", doc.RootElement.GetProperty("e3").GetString()); + Assert.Equal("cpu_count,socket_count,physical_memory_mb,service_objective", doc.RootElement.GetProperty("none").GetString()); } } // ── FinOps Server Inventory row ── [Fact] - public void InventoryRow_OnAzureSqlDatabase_LeavesTheMemoryAndCoreCellsBlank_AndSaysWhy() + public void InventoryRow_OnAzureSqlDatabase_LeavesTheHostCellsBlank_ShowsItsOwnCpuCount_AndSaysWhy() { var row = new ServerPropertyRow { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, PhysicalMemoryMb = 933_888, SocketCount = 0, CoresPerSocket = 32, }; - Assert.Null(row.CpuCount); + Assert.Equal(2, row.CpuCount); Assert.Null(row.PhysicalMemoryMb); Assert.Null(row.SocketCount); Assert.Null(row.CoresPerSocket); Assert.Equal(ServerHardwareScope.InventoryHardwareNote, row.HardwareUnavailableReason); + Assert.Contains("memory, sockets, cores per socket and hyperthread ratio are the host's", row.HardwareUnavailableReason, StringComparison.Ordinal); + Assert.Contains("Logical CPUs is the database's own scheduler count", row.HardwareUnavailableReason, StringComparison.Ordinal); } [Fact] public void InventoryRow_OnAzureSqlDatabase_DoesNotDependOnTheOrderTheLoaderAssignsInAndKeepsAReadsOwnReason() { var row = new ServerPropertyRow { CpuCount = 2, PhysicalMemoryMb = 933_888, SocketCount = 0, CoresPerSocket = 32 }; - Assert.Equal(2, row.CpuCount); + Assert.Equal(933_888L, row.PhysicalMemoryMb); row.EngineEdition = 5; row.HardwareUnavailableReason = "Hardware read denied"; - Assert.Null(row.CpuCount); + Assert.Null(row.PhysicalMemoryMb); + Assert.Equal(2, row.CpuCount); Assert.Equal("Hardware read denied", row.HardwareUnavailableReason); } diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs index bb31cbdf5..bb0fc0d31 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs @@ -17,15 +17,16 @@ namespace Darling.Tests; /// -/// On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info describes the HOST: a 1-vCore serverless -/// General Purpose database read 2 logical CPUs and 911.9 GB of physical memory. -/// pins that nothing SHOWS those server_properties values as the database's. These pins are the calculations that USED -/// the host's CPU count: the attributed-CPU denominator and the FinOps utilization card's CPU count. The memory figures come -/// from a different table and are pinned in . +/// On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info reports the HOST's memory, sockets and cores per +/// socket, while its cpu_count is the database's own scheduler count, which is not the CPU the database is given: a +/// 1-vCore serverless General Purpose database read 2. pins what is SHOWN. These +/// pins are the calculations that must count the CPU the database is GIVEN (its vCores) and not the schedulers it can see: +/// the attributed-CPU denominator and the FinOps utilization card's CPU count. The memory figures come from a different +/// table and are pinned in . /// /// The rule: on an Azure SQL Database each of those uses the database's own figure where one is collected (the /// vcore_count parsed from the service objective) and is otherwise NOT APPLICABLE. A DTU-model objective or an elastic -/// pool has no vCore count, so its CPU count is not applicable and nothing is computed from the host's. SQL Server +/// pool has no vCore count, so its CPU count is not applicable and nothing is computed from the stored scheduler count. SQL Server /// (editions 1 to 4) and Managed Instance (8) behave exactly as before, and every test has that twin. Lite.Tests pins the /// same table for the other app, in the same words. /// @@ -36,7 +37,7 @@ public sealed class AzureSqlDatabaseHostMathTests // ── CPU attribution ── - /// Half of one CPU for an hour is 1,800 CPU-seconds; half of the host's two would be 3,600. + /// Half of one CPU for an hour is 1,800 CPU-seconds; half of the two schedulers the database can see would be 3,600. private static CpuAttribution.Result Attribute(int? engineEdition, int storedCpuCount, int? vcoreCount) => CpuAttribution.Compute( rankedCpuSeconds: 900, s_start, s_end, @@ -44,7 +45,7 @@ private static CpuAttribution.Result Attribute(int? engineEdition, int storedCpu engineEdition, storedCpuCount, vcoreCount); [Fact] - public void Attribution_OnAzureSqlDatabase_WithVcores_DividesByTheVcores_NotTheHostsCpus() + public void Attribution_OnAzureSqlDatabase_WithVcores_DividesByTheVcores_NotTheStoredSchedulerCount() { var result = Attribute(5, storedCpuCount: 2, vcoreCount: 1); @@ -56,7 +57,7 @@ public void Attribution_OnAzureSqlDatabase_WithVcores_DividesByTheVcores_NotTheH [Theory] [InlineData(null)] [InlineData(0)] - public void Attribution_OnAzureSqlDatabase_WithNoVcores_IsNotApplicable_AndComputesNothingFromTheHost(int? vcoreCount) + public void Attribution_OnAzureSqlDatabase_WithNoVcores_IsNotApplicable_AndComputesNothingFromTheStoredCount(int? vcoreCount) { var result = Attribute(5, storedCpuCount: 2, vcoreCount); @@ -145,12 +146,12 @@ public void CpuCountText_OnEveryOtherEdition_IsTheCountAsItAlwaysWas(int? engine $"SELECT CASE WHEN engine_edition = {ServerHardwareScope.AzureSqlDatabaseEngineEdition} THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, engine_edition"; [Fact] - public void UtilizationRead_ResolvesTheCpuCountThroughTheEdition_NeverFallingBackToTheHostsCount() + public void UtilizationRead_ResolvesTheCpuCountThroughTheEdition_NeverFallingBackToTheStoredSchedulerCount() { var sql = ViewerDataService.UtilizationEfficiencySql; Assert.Contains(s_cpuCountCase, sql, StringComparison.Ordinal); - /* Only edition 5 lacks the fall-back: the bare COALESCE that took the host's count is gone. */ + /* Only edition 5 lacks the fall-back: the bare COALESCE that took the stored scheduler count is gone. */ Assert.DoesNotContain("SELECT COALESCE(vcore_count, cpu_count) AS cpu_count", sql, StringComparison.Ordinal); Assert.Equal(1, CountOf(sql, "COALESCE(vcore_count, cpu_count)")); } @@ -171,6 +172,7 @@ public void UtilizationRead_ResolvesTheCpuCountTheSameWayLiteDoes() private static UtilizationEfficiencyRow Utilization(int engineEdition, int bufferPoolMb, int physicalMemoryMb) => new() { EngineEdition = engineEdition, + ProvisioningStatus = ProvisioningVerdict.RightSized, // a measured window: a window with no CPU sample has no CPU term P95CpuPct = 7m, BufferPoolMb = bufferPoolMb, PhysicalMemoryMb = physicalMemoryMb, diff --git a/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs index 42ddf9e31..7fb112600 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs @@ -28,7 +28,7 @@ namespace Darling.Tests; /// So the FinOps utilization card's Physical Memory and Buffer Pool %, its verdict sentences and the health score's memory /// term, which all read memory_stats, are shown and scored on an Azure SQL Database exactly as on SQL Server. What reads /// server_properties (get_server_properties, the web Server Properties tiles, the Server Inventory hardware cells) -/// still hides the host's values. The figures here are the two tables' different values (1,838 MB and 933,836 MB), so a read that +/// still hides the host's memory, sockets, cores per socket and hyperthread ratio. The figures here are the two tables' different values (1,838 MB and 933,836 MB), so a read that /// takes the wrong table shows up as the wrong number. The Viewer's reads run against PostgreSQL, which this suite does not stand /// up, so they are pinned as SQL text. Lite.Tests pins the same table for the other app, in the same words. /// @@ -40,8 +40,9 @@ public sealed class AzureSqlDatabaseMemoryScopeTests /// What server_properties holds for the same database: the host's physical memory. private const long HostPhysicalMemoryMb = 933_836; + /// The four columns that describe the host on an Azure SQL Database. cpu_count is not among them. private static readonly string[] s_hostKeys = - ["cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; + ["hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; /// Comments wrap, so a pin on their words reads them with every run of whitespace as one space. private static string Flatten(string text) => Regex.Replace(text, @"\s+", " "); @@ -75,6 +76,7 @@ public void UtilizationRead_TakesTheMemoryFiguresFromMemoryStats_NeverFromServer private static UtilizationEfficiencyRow Scored(int engineEdition, int physicalMemoryMb) => new() { EngineEdition = engineEdition, + ProvisioningStatus = ProvisioningVerdict.RightSized, // a measured window: a window with no CPU sample has no CPU term P95CpuPct = 7m, BufferPoolMb = 1_100, PhysicalMemoryMb = physicalMemoryMb, @@ -140,7 +142,7 @@ public void OverProvisionedSentence_OnAzureSqlDatabase_CitesTheBufferPoolShare_O onBox); } - // ── what reads server_properties stays the host's, and stays hidden ── + // ── what reads server_properties hides the host's four figures ── private static DarlingDataReader.ServerPropertiesReadRow StoredRow(int engineEdition) => new( new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), engineEdition == 5 ? "SQL Azure" : "Enterprise Edition (64-bit)", @@ -148,7 +150,7 @@ public void OverProvisionedSentence_OnAzureSqlDatabase_CitesTheBufferPoolShare_O engineEdition == 5 ? "GP_S_Gen5_1" : null, null, null, engineEdition == 5 ? 1 : null); [Fact] - public void ServerPropertiesReads_OnAzureSqlDatabase_StayTheHostsAndNull_WhateverMemoryStatsHolds() + public void ServerPropertiesReads_OnAzureSqlDatabase_HideTheHostsFourFigures_AndShowTheDatabasesOwnCpuCount() { var stored = StoredRow(5); /* The stored figure is the host's, which is why it is hidden: it is not memory_stats' 1,838. */ @@ -158,6 +160,7 @@ public void ServerPropertiesReads_OnAzureSqlDatabase_StayTheHostsAndNull_Whateve var json = JsonDocument.Parse(DarlingMcpDataTools.ServerPropertiesPayload("Srv", stored)).RootElement; foreach (var key in s_hostKeys) Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + Assert.Equal(2, json.GetProperty("cpu_count").GetInt32()); /* The FinOps Server Inventory row. */ var inventory = new ServerPropertyRow @@ -165,7 +168,7 @@ public void ServerPropertiesReads_OnAzureSqlDatabase_StayTheHostsAndNull_Whateve EngineEdition = stored.EngineEdition, CpuCount = stored.CpuCount, PhysicalMemoryMb = stored.PhysicalMemoryMb, SocketCount = stored.SocketCount, CoresPerSocket = stored.CoresPerSocket, }; - Assert.Null(inventory.CpuCount); + Assert.Equal(2, inventory.CpuCount); Assert.Null(inventory.PhysicalMemoryMb); Assert.Null(inventory.SocketCount); Assert.Null(inventory.CoresPerSocket); @@ -201,9 +204,10 @@ public void FinOpsUtilizationCard_ShowsPhysicalMemoryAndTheBufferPoolShare_OnEve 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. */ + /* The health score has its memory term everywhere, so there is no tooltip explaining an absent one. The only tooltip on it + explains an absent CPU term (a window with no CPU sample), and it is not keyed on the edition. */ Assert.DoesNotContain("HealthScoreWithoutMemoryNote", tab, StringComparison.Ordinal); - Assert.DoesNotContain("FinOpsHealthScoreBorder.ToolTip", tab, StringComparison.Ordinal); + Assert.DoesNotContain("FinOpsHealthScoreBorder.ToolTip = azureSqlDb", tab, StringComparison.Ordinal); Assert.Contains("data.HealthScore = data.ComputeHealthScore();", tab, StringComparison.Ordinal); } @@ -252,4 +256,173 @@ public void MemoryAndVmRules_OnAzureSqlDatabase_SayWhyTheyStandDown_AndDoNotCall Assert.Contains("its memory comes with its service objective and cannot be resized on its own", rules, StringComparison.Ordinal); Assert.Contains("its cores and memory come with its service objective", rules, StringComparison.Ordinal); } + + // ── get_memory_stats: the keys keep their names, so an Azure SQL Database's payload says what they mean ── + + /* The row carries NO engine edition. The tool reads the ONE edition from the registry and builds the payload from the row + and that value, so nothing in the row can disagree with it. */ + private static DarlingDataReader.MemoryStatsRow StatsRow(double totalMb, double availableMb) => new( + new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), totalMb, availableMb, 0, 0, + "Available physical memory is high", "CONVENTIONAL", totalMb, totalMb - availableMb, 1_100, 200); + + private static JsonElement MemoryPayload(int engineEdition, double totalMb, double availableMb) => + JsonDocument.Parse(DarlingMcpDataTools.MemoryStatsPayload("Srv", StatsRow(totalMb, availableMb), engineEdition)).RootElement.Clone(); + + [Fact] + public void GetMemoryStats_OnAzureSqlDatabase_CarriesAMemoryNote_ThatCallsTheTotalTheDatabasesLimit_AndNearFullNormal() + { + /* A database that has grown to its limit has nothing left under it, so it reads 100% in use. On this edition that is the + normal state, and the note says so, because the same figure on SQL Server is an operating system short of memory. */ + var json = MemoryPayload(5, DatabaseMemoryLimitMb, 0); + + var note = json.GetProperty("memory_note").GetString(); + Assert.Equal(ServerHardwareScope.McpMemoryNote, note); + Assert.Contains("total_physical_memory_mb is the database's memory limit (its committed target), not the host's memory", note, StringComparison.Ordinal); + Assert.Contains("available_physical_memory_mb is what is left under that limit", note, StringComparison.Ordinal); + Assert.Contains("a value near 100% is normal once the database has grown to its limit", note, StringComparison.Ordinal); + Assert.Contains("not memory pressure by itself", note, StringComparison.Ordinal); + + /* The figures and their names are as they were, and the note comes last. */ + Assert.Equal(DatabaseMemoryLimitMb, json.GetProperty("total_physical_memory_mb").GetDouble()); + Assert.Equal(0, json.GetProperty("available_physical_memory_mb").GetDouble()); + Assert.Equal(100, json.GetProperty("memory_utilization_pct").GetDouble()); + Assert.Equal(5, json.GetProperty("engine_edition").GetInt32()); + Assert.Equal("memory_note", json.EnumerateObject().Last().Name); + + /* The memory state is the constant "Available" the collector stores there, which is not a reading: null, with its note. */ + Assert.Equal(JsonValueKind.Null, json.GetProperty("system_memory_state").ValueKind); + Assert.Equal(ServerHardwareScope.MemoryStateNote, json.GetProperty("system_memory_state_note").GetString()); + } + + [Theory] + [InlineData(3)] + [InlineData(8)] + [InlineData(0)] + public void GetMemoryStats_OffAzureSqlDatabase_KeepsTheStoredState_AndCarriesNoNotes(int engineEdition) + { + var json = MemoryPayload(engineEdition, 65_536, 16_384); + + Assert.False(json.TryGetProperty("memory_note", out _), "an engine that is not an Azure SQL Database gets no memory note"); + Assert.Equal("Available physical memory is high", json.GetProperty("system_memory_state").GetString()); + Assert.Equal(JsonValueKind.Null, json.GetProperty("system_memory_state_note").ValueKind); + Assert.Equal(75, json.GetProperty("memory_utilization_pct").GetDouble()); + + /* An unknown edition (0) publishes no edition at all rather than the number 0. */ + if (engineEdition == 0) + Assert.Equal(JsonValueKind.Null, json.GetProperty("engine_edition").ValueKind); + else + Assert.Equal(engineEdition, json.GetProperty("engine_edition").GetInt32()); + + Assert.Equal( + new[] + { + "server", "captured_at", "total_physical_memory_mb", "available_physical_memory_mb", "memory_utilization_pct", + "system_memory_state", "system_memory_state_note", "sql_memory_model", "target_server_memory_mb", + "total_server_memory_mb", "buffer_pool_mb", "plan_cache_mb", "engine_edition", + }, + json.EnumerateObject().Select(p => p.Name).ToArray()); + } + + [Theory] + [InlineData(5, true)] + [InlineData(3, false)] + [InlineData(0, false)] + public void GetMemoryStats_EngineEdition_MemoryNote_AndTheStateNote_AllFollowTheOneEditionTheToolReads(int registryEdition, bool azure) + { + /* The same row is built every time, so the only thing that can change the three fields is the edition the tool read from + the registry. They flip together: edition 5 gives engine_edition 5, the memory note, a null state and the state's note; + any other edition gives none of them and the stored state. */ + var json = MemoryPayload(registryEdition, DatabaseMemoryLimitMb, 500); + + Assert.Equal(azure, json.TryGetProperty("memory_note", out _)); + Assert.Equal(azure, json.GetProperty("system_memory_state").ValueKind == JsonValueKind.Null); + Assert.Equal(azure, json.GetProperty("system_memory_state_note").ValueKind == JsonValueKind.String); + Assert.Equal(registryEdition == 0 ? JsonValueKind.Null : JsonValueKind.Number, json.GetProperty("engine_edition").ValueKind); + if (registryEdition != 0) + Assert.Equal(registryEdition, json.GetProperty("engine_edition").GetInt32()); + } + + [Fact] + public void TheLatestMemoryReads_CarryNoEngineEdition_SoNoSecondSourceCanDisagreeWithTheRegistry() + { + /* Every surface that names the memory figures reads the REGISTRY's edition: the tool through DarlingEngineCapability (the + same value every Darling MCP not_collected answer reads) and the viewer through its server entry, the value its + page-file and memory-state lines read. A server_properties subselect beside the memory figures would be a second + source for the same answer, one that could say 3 while the registry says 5. So neither statement reads + server_properties, and neither row has an edition member. */ + foreach (var sql in new[] { DarlingDataReader.LatestMemoryStatsSql, ViewerDataService.LatestMemoryStatsSql }) + { + Assert.DoesNotContain("server_properties", sql, StringComparison.Ordinal); + Assert.DoesNotContain("engine_edition", sql, StringComparison.Ordinal); + } + + Assert.Null(typeof(DarlingDataReader.MemoryStatsRow).GetProperty("EngineEdition")); + Assert.Null(typeof(PerformanceMonitor.Darling.Viewer.MemoryStatsRow).GetProperty("EngineEdition")); + } + + [Fact] + public void GetMemoryStatsTool_BuildsItsPayloadThroughTheSharedNote() + { + var tool = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"); + + Assert.Contains("return MemoryStatsPayload(resolved.ServerName, stats, engineEdition);", tool, StringComparison.Ordinal); + Assert.Contains("ServerHardwareScope.WithMemoryNote(", tool, StringComparison.Ordinal); + } + + // ── the FinOps CPU right-sizing recommendation: vCores on an Azure SQL Database, cores everywhere else ── + + private static UtilizationEfficiencyRow CpuRow(int engineEdition) => new() + { + ProvisioningStatus = "OVER_PROVISIONED", + AvgCpuPct = 4m, + MaxCpuPct = 20, + P95CpuPct = 9m, + CpuCount = 32, + EngineEdition = engineEdition, + }; + + [Fact] + public void CpuRightSizingRecommendation_OnAzureSqlDatabase_NamesTheVcores_InTheFindingAndTheDetail() + { + /* The count is the vCores the service objective gives the database, which the utilization card already calls vCores. */ + var rec = ViewerDataService.BuildCpuRightSizingRecommendation(CpuRow(5), monthlyCost: 0m); + + Assert.NotNull(rec); + Assert.StartsWith("CPU over-provisioned (32 vCores, P95 = ", rec!.Finding, StringComparison.Ordinal); + Assert.Contains("across 32 vCores. Consider reducing to ~", rec.Detail, StringComparison.Ordinal); + Assert.EndsWith(" vCores.", rec.Detail, StringComparison.Ordinal); + Assert.DoesNotContain(" cores", rec.Finding + rec.Detail, StringComparison.Ordinal); + } + + [Theory] + [InlineData(3)] + [InlineData(8)] + [InlineData(0)] + public void CpuRightSizingRecommendation_OffAzureSqlDatabase_KeepsTheWordCores(int engineEdition) + { + var rec = ViewerDataService.BuildCpuRightSizingRecommendation(CpuRow(engineEdition), monthlyCost: 1_000m); + + Assert.NotNull(rec); + Assert.StartsWith("CPU over-provisioned (32 cores, P95 = ", rec!.Finding, StringComparison.Ordinal); + Assert.Contains("across 32 cores. Consider reducing to ~", rec.Detail, StringComparison.Ordinal); + Assert.EndsWith(" cores.", rec.Detail, StringComparison.Ordinal); + Assert.DoesNotContain("vCores", rec.Finding + rec.Detail, StringComparison.Ordinal); + Assert.True(rec.EstMonthlySavings > 0, "a server with a budget still gets its savings estimate"); + } + + [Fact] + public void CpuRightSizingRecommendation_AdvisesNothing_WithoutACpuSample_OnABusyServer_OrWithFourOrFewerCpus() + { + var noSample = CpuRow(3); + noSample.ProvisioningStatus = ""; + var busy = CpuRow(3); + busy.P95CpuPct = 30m; + var small = CpuRow(5); + small.CpuCount = 4; + + Assert.Null(ViewerDataService.BuildCpuRightSizingRecommendation(null, 0m)); + Assert.Null(ViewerDataService.BuildCpuRightSizingRecommendation(noSample, 0m)); + Assert.Null(ViewerDataService.BuildCpuRightSizingRecommendation(busy, 0m)); + Assert.Null(ViewerDataService.BuildCpuRightSizingRecommendation(small, 0m)); + } } diff --git a/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs new file mode 100644 index 000000000..f04cf18ca --- /dev/null +++ b/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs @@ -0,0 +1,603 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Analysis; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.PlanAnalysis; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// What left. On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info +/// returns four figures that describe the HOST machine and not the database: physical_memory_mb (about 912 GB), +/// socket_count, cores_per_socket and hyperthread_ratio. Nothing may compute from them there. The other two +/// columns it fills are the database's own: cpu_count is the number of schedulers the database can see (a 1-vCore General +/// Purpose database reads 2, and the count can be higher than the vCores) and max_workers_count is the database's own worker +/// ceiling, so both are shown as stored. What a CPU percent, a CPU count shown beside one, and a recommended MAXDOP are taken from +/// is NOT cpu_count: it is the vCores the service objective gives the database (vcore_count), and an objective that +/// names none (a DTU-model objective or an elastic pool) has no such count, so those answers read n/a. +/// +/// Pinned here, each with an edition 5 case that has vCores, an edition 5 case without them, and the SQL Server / Managed +/// Instance twin that must not move: +/// +/// the SERVER_HARDWARE analysis fact (the vCores and none of the host's figures), the MAXDOP recommendation read from it, the +/// LPIM advisory that reads its memory, and the plan Server Context card; +/// the FinOps Worker Threads card: the in-use count is n/a where the collector could not read it (NULL), never 0, and the +/// ceiling is shown as stored; +/// the memory utilization percentage, which is NOT changed: it divides memory_stats columns, which the collector +/// fills from the database's own committed target, not from server_properties.physical_memory_mb; +/// the FinOps health score's CPU term, which is left out when the window holds no CPU sample (any edition); +/// the words over the Memory tab's first two figures (the viewer, and the web tiles over get_memory_stats), which on an +/// Azure SQL Database are the database's memory limit and the room left under it. +/// +/// +/// The pure rules are run here; the PostgreSQL reads are pinned as text (their statements are public constants, and the +/// readers that consume them are pinned by the lines that route them through the shared rule), and Lite.Tests runs the same +/// table against a real DuckDB in the same words. +/// +public sealed class AzureSqlDatabaseOwnFiguresTests +{ + private static AnalysisContext Context() => new() + { + ServerId = 7, + ServerName = "OwnFiguresSrv", + TimeRangeStart = new DateTime(2026, 9, 30, 0, 0, 0, DateTimeKind.Utc), + TimeRangeEnd = new DateTime(2026, 9, 30, 1, 0, 0, DateTimeKind.Utc), + }; + + // ── 1. SERVER_HARDWARE fact, MAXDOP recommendation, LPIM advisory, Server Context card ── + + private static Fact HardwareFact(bool azureSqlDatabase, int cpuCount, int coresPerSocket) => + FactCollectorHelpers.BuildServerHardwareFact( + Context(), hardwareIsTheHosts: azureSqlDatabase, + cpuCount: cpuCount, hyperthreadRatio: 64, physicalMemoryMb: 933_836, socketCount: 0, coresPerSocket: coresPerSocket, + hadrEnabled: false)!; + + private static Dictionary Facts(params Fact[] facts) => facts.ToDictionary(f => f.Key, f => f); + + private static Fact Config(string key, double value) => new() { Source = "config", Key = key, Value = value }; + + [Fact] + public void ServerHardwareFact_OnAzureSqlDatabase_CarriesTheVcoresAndHadrOnly_NotTheHostsTopologyOrMemory() + { + /* The host's four figures go in (hyperthread ratio 64, 933,836 MB, 0 sockets, 32 cores per socket); none may come out. */ + var fact = FactCollectorHelpers.BuildServerHardwareFact( + Context(), hardwareIsTheHosts: true, + cpuCount: 4, hyperthreadRatio: 64, physicalMemoryMb: 933_836, socketCount: 0, coresPerSocket: 32, hadrEnabled: false); + + Assert.NotNull(fact); + Assert.Equal("SERVER_HARDWARE", fact!.Key); + Assert.Equal("config", fact.Source); + Assert.Equal(4, fact.Value); + Assert.Equal( + new[] { "cpu_count", "hadr_enabled", "vcore_count" }, + fact.Metadata.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray()); + Assert.Equal(4, fact.Metadata["cpu_count"]); + Assert.Equal(4, fact.Metadata["vcore_count"]); + Assert.Equal(0, fact.Metadata["hadr_enabled"]); + } + + [Fact] + public void ServerHardwareFact_OnAzureSqlDatabaseWithNoVcores_IsNotEmitted() + { + Assert.Null(FactCollectorHelpers.BuildServerHardwareFact( + Context(), hardwareIsTheHosts: true, + cpuCount: 0, hyperthreadRatio: 64, physicalMemoryMb: 933_836, socketCount: 0, coresPerSocket: 32, hadrEnabled: false)); + } + + [Fact] + public void ServerHardwareFact_OffAzureSqlDatabase_IsTheStoredTopologyAsItAlwaysWas() + { + var fact = FactCollectorHelpers.BuildServerHardwareFact( + Context(), hardwareIsTheHosts: false, + cpuCount: 16, hyperthreadRatio: 2, physicalMemoryMb: 65_536, socketCount: 2, coresPerSocket: 4, hadrEnabled: true); + + Assert.NotNull(fact); + Assert.Equal(16, fact!.Value); + Assert.Equal( + new[] { "cpu_count", "hyperthread_ratio", "physical_memory_mb", "socket_count", "cores_per_socket", "hadr_enabled" }, + fact.Metadata.Keys.ToArray()); + Assert.Equal(16, fact.Metadata["cpu_count"]); + Assert.Equal(2, fact.Metadata["hyperthread_ratio"]); + Assert.Equal(65_536, fact.Metadata["physical_memory_mb"]); + Assert.Equal(2, fact.Metadata["socket_count"]); + Assert.Equal(4, fact.Metadata["cores_per_socket"]); + Assert.Equal(1, fact.Metadata["hadr_enabled"]); + + /* No CPU count at all is no fact, on any edition, as before. */ + Assert.Null(FactCollectorHelpers.BuildServerHardwareFact( + Context(), hardwareIsTheHosts: false, + cpuCount: 0, hyperthreadRatio: 2, physicalMemoryMb: 65_536, socketCount: 2, coresPerSocket: 4, hadrEnabled: false)); + } + + /// The recommended MAXDOP on an Azure SQL Database follows its vCores. The host's 32 cores per socket go into the + /// fact builder every time, so a fact that carried them would make every row here read 8. + [Theory] + [InlineData(1, 1)] + [InlineData(2, 2)] + [InlineData(4, 4)] + [InlineData(8, 8)] + [InlineData(16, 8)] + [InlineData(80, 8)] + public void MaxdopBasis_OnAzureSqlDatabase_FollowsTheVcores_NeverTheHostsCoresPerSocket(int vcores, int expectedMaxdop) + { + var basis = FactRemediation.MaxdopBasisFrom(Facts(HardwareFact(azureSqlDatabase: true, cpuCount: vcores, coresPerSocket: 32))); + + Assert.True(basis.FromVcores); + Assert.Equal(vcores, basis.Cores); + Assert.Equal($"({vcores} vCores)", basis.Note); + Assert.Equal(expectedMaxdop, FactRemediation.RecommendedMaxdop(basis.Cores)); + } + + [Fact] + public void MaxdopBasis_OffAzureSqlDatabase_IsTheCoresPerSocketAsItAlwaysWas() + { + var basis = FactRemediation.MaxdopBasisFrom(Facts(HardwareFact(azureSqlDatabase: false, cpuCount: 16, coresPerSocket: 4))); + + Assert.False(basis.FromVcores); + Assert.Equal(4, basis.Cores); + Assert.Equal("(cores per socket 4)", basis.Note); + Assert.Equal(4, FactRemediation.RecommendedMaxdop(basis.Cores)); + Assert.Equal(8, FactRemediation.RecommendedMaxdop(FactRemediation.MaxdopBasisFrom( + Facts(HardwareFact(azureSqlDatabase: false, cpuCount: 64, coresPerSocket: 32))).Cores)); + } + + /// A DTU-model objective or an elastic pool gives the fact builder no vCores, so there is no fact: no basis, no figure + /// to state, and the recommendation is the long-standing cap of 8 with nothing said about cores. + [Fact] + public void MaxdopBasis_OnAzureSqlDatabaseWithNoVcores_IsNotApplicable_AndTheAdviceIsTheStaticCap() + { + var basis = FactRemediation.MaxdopBasisFrom(Facts()); + + Assert.Equal(0, basis.Cores); + Assert.False(basis.FromVcores); + Assert.Equal(string.Empty, basis.Note); + Assert.Equal(8, FactRemediation.RecommendedMaxdop(basis.Cores)); + + var advice = FactAdvice.Compose("CONFIG_MAXDOP", Facts(Config("CONFIG_MAXDOP", 0)))!; + Assert.Contains("Set MAXDOP to 8", advice.Remediation, StringComparison.Ordinal); + Assert.DoesNotContain("vCores", advice.Remediation, StringComparison.Ordinal); + Assert.DoesNotContain("cores per socket", advice.Remediation, StringComparison.Ordinal); + } + + /// The words an Azure SQL Database's advice uses are its vCores, never "cores per socket", and its MAXDOP is set with + /// the database-scoped statement (it has no instance option to configure). + [Fact] + public void MaxdopAdvice_OnAzureSqlDatabase_NamesTheVcores_NeverCoresPerSocket_AndTheDatabaseScopedStatement() + { + var hardware = HardwareFact(azureSqlDatabase: true, cpuCount: 4, coresPerSocket: 32); + + var zero = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 0)))!; + Assert.Contains("Set MAXDOP to 4", zero.Remediation, StringComparison.Ordinal); + Assert.Contains("this database's vCores capped at 8 (4 vCores)", zero.Remediation, StringComparison.Ordinal); + Assert.Contains("ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = 4", zero.Remediation, StringComparison.Ordinal); + + var one = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 1)))!; + Assert.Contains("set MAXDOP to 4 (vCores capped at 8 (4 vCores)) with ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = 4", one.Remediation, StringComparison.Ordinal); + + var above = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 16)))!; + Assert.Contains("above this database's topology-based guidance of 4", above.Headline, StringComparison.Ordinal); + Assert.Contains("Lower MAXDOP from 16 to 4 (vCores capped at 8 (4 vCores)", above.Remediation, StringComparison.Ordinal); + + var parallel = FactAdvice.Compose("THREADPOOL_PARALLEL", Facts(hardware, Config("CONFIG_MAXDOP", 0), Config("CONFIG_CTFP", 5)))!; + Assert.Contains("(4 vCores)", parallel.Remediation, StringComparison.Ordinal); + Assert.Contains("cap MAXDOP at 4 (this database's vCores, capped at 8)", parallel.Remediation, StringComparison.Ordinal); + + var clause = FactAdvice.Compose("CXPACKET", Facts(hardware, Config("CONFIG_MAXDOP", 0), Config("CONFIG_CTFP", 50)))!; + Assert.Contains("cap MAXDOP at 4 (the database's vCores, ≤ 8)", clause.Remediation, StringComparison.Ordinal); + + foreach (var text in new[] { zero.Remediation, one.Remediation, above.Remediation, above.Headline, parallel.Remediation, clause.Remediation }) + { + Assert.DoesNotContain("cores per socket", text, StringComparison.Ordinal); + Assert.DoesNotContain("cores-per-socket", text, StringComparison.Ordinal); + Assert.DoesNotContain("sp_configure", text, StringComparison.Ordinal); + } + } + + /// The same advice off an Azure SQL Database, word for word as it always was. + [Fact] + public void MaxdopAdvice_OffAzureSqlDatabase_IsTheLongStandingText() + { + var hardware = HardwareFact(azureSqlDatabase: false, cpuCount: 16, coresPerSocket: 4); + + var zero = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 0)))!; + Assert.Contains( + "Set MAXDOP to 4 — this server's cores-per-socket capped at 8 (cores per socket 4), the per-NUMA-node proxy; " + + "the SKU is irrelevant to the right value. The Apply button runs sp_configure + RECONFIGURE, an online metadata change.", + zero.Remediation, StringComparison.Ordinal); + + var one = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 1)))!; + Assert.Contains("set MAXDOP to 4 (cores-per-socket capped at 8 (cores per socket 4)) via sp_configure + RECONFIGURE, an online change.", + one.Remediation, StringComparison.Ordinal); + + var above = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 16)))!; + Assert.Contains("above this server's topology-based guidance of 4", above.Headline, StringComparison.Ordinal); + + var parallel = FactAdvice.Compose("THREADPOOL_PARALLEL", Facts(hardware, Config("CONFIG_MAXDOP", 0), Config("CONFIG_CTFP", 5)))!; + Assert.Contains("cost threshold for parallelism is 5 (cores per socket 4).", parallel.Remediation, StringComparison.Ordinal); + Assert.Contains("cap MAXDOP at 4 (this server's per-NUMA-node processor count, capped at 8)", parallel.Remediation, StringComparison.Ordinal); + } + + [Fact] + public void LpimAdvisory_OnAzureSqlDatabase_IsNotRaisedFromTheHostsMemory_AndIsUnchangedElsewhere() + { + var context = Context(); + + var onAzure = new List(); + FactCollectorHelpers.EmitServerHealthFacts( + context, onAzure, "SQL Azure", physicalMemMb: 933_836, + lockPagesInMemory: false, instantFileInit: null, memoryDumpCount: null, hardwareIsTheHosts: true); + Assert.DoesNotContain(onAzure, f => f.Key == "CONFIG_LPIM_DISABLED"); + + var onServer = new List(); + FactCollectorHelpers.EmitServerHealthFacts( + context, onServer, "Enterprise Edition (64-bit)", physicalMemMb: 933_836, + lockPagesInMemory: false, instantFileInit: null, memoryDumpCount: null); + var lpim = Assert.Single(onServer, f => f.Key == "CONFIG_LPIM_DISABLED"); + Assert.Equal(933_836, lpim.Metadata["physical_memory_mb"]); + } + + [Fact] + public void ServerContextCard_OnAzureSqlDatabase_ShowsTheVcores_NeverTheSchedulerCountOrTheHostsRam_AndNaWhereThereAreNone() + { + static string? Hardware(ServerMetadata metadata) => + ServerContextCard.Rows(metadata).SingleOrDefault(r => r.Label == "Hardware").Value; + + /* The stored count (2) is the schedulers the database can see; its service objective gives it 1 vCore. The host's RAM, + had a reader let it through, is not printed either. */ + Assert.Equal("1 vCores", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = 1, PhysicalMemoryMB = 0 })); + Assert.Equal("1 vCores", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = 1, PhysicalMemoryMB = 933_836 })); + Assert.Equal("4 vCores", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = 4 })); + + /* A DTU-model objective or an elastic pool names no vCores: the row is still there, and it reads n/a, never the stored 2. */ + Assert.Equal("n/a", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = null })); + Assert.Equal("n/a", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = 0 })); + Assert.Equal("n/a", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 0, VcoreCount = null })); + } + + [Fact] + public void ServerContextCard_OffAzureSqlDatabase_IsTheLongStandingRow() + { + static string? Hardware(ServerMetadata metadata) => + ServerContextCard.Rows(metadata).SingleOrDefault(r => r.Label == "Hardware").Value; + + var expected = string.Format(CultureInfo.CurrentCulture, "8 CPUs, {0:N0} MB RAM", 65_536L); + Assert.Equal(expected, Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = 3, CpuCount = 8, PhysicalMemoryMB = 65_536 })); + Assert.Equal(expected, Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = 8, CpuCount = 8, PhysicalMemoryMB = 65_536 })); + Assert.Equal(expected, Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = null, CpuCount = 8, PhysicalMemoryMB = 65_536 })); + + /* A VcoreCount a non-Azure server somehow carries changes nothing, and no CPU count drops the row as it always did. */ + Assert.Equal(expected, Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = 3, CpuCount = 8, VcoreCount = 2, PhysicalMemoryMB = 65_536 })); + Assert.Null(Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = 3, CpuCount = 0, PhysicalMemoryMB = 0 })); + } + + // ── 2. Worker Threads card and the verdict's worker term ── + + [Fact] + public void OwnPhysicalMemoryMb_IsNotApplicableOnAzureSqlDatabase_AndTheStoredFigureEverywhereElse() + { + Assert.Null(ServerHardwareScope.OwnPhysicalMemoryMb(5, 933_836)); + Assert.Null(ServerHardwareScope.OwnPhysicalMemoryMb(5, null)); + Assert.Equal(65_536L, ServerHardwareScope.OwnPhysicalMemoryMb(3, 65_536)); + Assert.Equal(65_536L, ServerHardwareScope.OwnPhysicalMemoryMb(8, 65_536)); + Assert.Equal(65_536L, ServerHardwareScope.OwnPhysicalMemoryMb(null, 65_536)); + Assert.Null(ServerHardwareScope.OwnPhysicalMemoryMb(3, null)); + } + + [Fact] + public void WorkerThreadsText_ReadsNotApplicableForAnInUseCountThatWasNotCollected_AndNeverZero() + { + /* NULL in use is "not collected" (an Azure SQL Database stores NULL), and it is not 0: the ceiling beside it is the + database's own and is shown as stored. */ + Assert.Equal("n/a / 512", ServerHardwareScope.WorkerThreadsText(null, 512)); + Assert.Equal("n/a / 479", ServerHardwareScope.WorkerThreadsText(null, 479)); + + /* A genuine zero in use reads 0. */ + Assert.Equal("0 / 512", ServerHardwareScope.WorkerThreadsText(0, 512)); + + Assert.Equal( + string.Format(CultureInfo.CurrentCulture, "{0:N0} / {1:N0}", 1_200, 2_560), + ServerHardwareScope.WorkerThreadsText(1_200, 2_560)); + } + + [Fact] + public void Verdict_NeverReadsAnUnknownInUseWorkerCountAsSaturation() + { + /* Quiet CPU (50%) and no memory pressure: nothing but the worker term can decide this window. */ + static string Verdict(int maxWorkers, int? currentWorkers) => ProvisioningVerdict.Evaluate( + avgCpuPercent: 50m, maxCpuPercent: 50m, p95CpuPercent: 50m, maxGrantWaiters: 0, grantTimeouts: 0, forcedGrants: 0, + grantUtilizationPercent: 50m, maxWorkers, currentWorkers); + + Assert.Equal(ProvisioningVerdict.RightSized, Verdict(512, null)); + Assert.Equal(ProvisioningVerdict.RightSized, Verdict(512, 0)); + Assert.Equal(ProvisioningVerdict.RightSized, Verdict(512, 40)); + Assert.Equal(ProvisioningVerdict.UnderProvisioned, Verdict(512, 450)); + Assert.Equal(ProvisioningVerdict.RightSized, Verdict(0, 450)); // no ceiling known is not saturation + + Assert.Equal( + "No under-provisioning condition is currently met.", + ProvisioningVerdict.UnderProvisionedReason(50m, 0, 0, 0, 512, null)); + Assert.Contains( + "Worker threads are near the limit: 450 of 512 in use", + ProvisioningVerdict.UnderProvisionedReason(50m, 0, 0, 0, 512, 450), StringComparison.Ordinal); + } + + // ── 3. Health score: the CPU term ── + + [Fact] + public void Overall_WithNoCpuScore_WeighsMemoryAndStorageOverTheirOwnSixtyPercent() + { + Assert.Equal(100, FinOpsHealthCalculator.Overall(null, 100, 100)); + Assert.Equal(0, FinOpsHealthCalculator.Overall(null, 0, 0)); + Assert.Equal(80, FinOpsHealthCalculator.Overall(null, 60, 100)); + Assert.Equal(60, FinOpsHealthCalculator.Overall(null, 80, 40)); + } + + [Fact] + public void Overall_WithEveryTerm_IsTheLongStandingArithmetic() + { + Assert.Equal(86, FinOpsHealthCalculator.Overall(95, 60, 100)); + Assert.Equal(62, FinOpsHealthCalculator.Overall(80, 60, 40)); + Assert.Equal(100, FinOpsHealthCalculator.Overall(100, 100, 100)); + Assert.Equal(0, FinOpsHealthCalculator.Overall(0, 0, 0)); + } + + /// Buffer pool 10% of physical memory scores 60 and 50% free storage scores 100, so the CPU term is the only one + /// that can tell a measured window from an empty one. + private static UtilizationEfficiencyRow Window(int engineEdition, string provisioningStatus, decimal p95CpuPct) => new() + { + EngineEdition = engineEdition, + ProvisioningStatus = provisioningStatus, + P95CpuPct = p95CpuPct, + PhysicalMemoryMb = 100_000, + BufferPoolMb = 10_000, + FreeSpacePct = 50m, + }; + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(8)] + public void HealthScore_WithNoCpuSample_LeavesTheCpuTermOut_AndDoesNotScoreTheZeroItReadsAs(int engineEdition) + { + /* With the CPU term: 100 * 0.4 + 60 * 0.3 + 100 * 0.3 = 88. Without it: memory and storage over their own 60%. */ + var noSample = Window(engineEdition, provisioningStatus: "", p95CpuPct: 0m); + + Assert.False(noSample.HasCpuSample); + Assert.Equal(80, noSample.ComputeHealthScore()); + Assert.Equal(FinOpsHealthCalculator.Overall(null, 60, 100), noSample.ComputeHealthScore()); + + /* Whatever the P95 field holds, an empty window's CPU is not scored. */ + Assert.Equal(80, Window(engineEdition, "", 95m).ComputeHealthScore()); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(8)] + public void HealthScore_WithACpuSample_IsTheLongStandingScore(int engineEdition) + { + /* 95 * 0.4 + 60 * 0.3 + 100 * 0.3 = 86, whatever verdict the window earned. */ + foreach (var status in new[] { ProvisioningVerdict.RightSized, ProvisioningVerdict.OverProvisioned, ProvisioningVerdict.UnderProvisioned }) + { + var measured = Window(engineEdition, status, 7m); + Assert.True(measured.HasCpuSample); + Assert.Equal(86, measured.ComputeHealthScore()); + } + + /* A genuinely idle measured window (p95 of 0) still scores its 100, which is the point of telling it from no sample. */ + Assert.Equal(88, Window(engineEdition, ProvisioningVerdict.OverProvisioned, 0m).ComputeHealthScore()); + } + + // ── 4. Words: the CPU unit and the Memory tab ── + + [Fact] + public void CpuCountUnit_IsVcoresOnAzureSqlDatabase_AndCpusEverywhereElse() + { + Assert.Equal(" vCores,", ServerHardwareScope.CpuCountUnit(5)); + foreach (var engineEdition in new int?[] { 1, 2, 3, 4, 8, null }) + Assert.Equal(" CPUs,", ServerHardwareScope.CpuCountUnit(engineEdition)); + } + + [Fact] + public void CpuCoreNoun_IsVcoresOnAzureSqlDatabase_AndCoresEverywhereElse() + { + /* The FinOps CPU right-sizing text prints the utilization read's count: the vCores on an Azure SQL Database, which it + names the way the utilization card does, and a CPU count everywhere else, in the word it has always used. */ + Assert.Equal("vCores", ServerHardwareScope.CpuCoreNoun(5)); + foreach (var engineEdition in new int?[] { 1, 2, 3, 4, 8, null }) + Assert.Equal("cores", ServerHardwareScope.CpuCoreNoun(engineEdition)); + } + + [Fact] + public void MemoryTabLabels_NameTheDatabasesLimitOnAzureSqlDatabase_AndPhysicalMemoryEverywhereElse() + { + /* On an Azure SQL Database the collector stores the database's committed target as the first figure and the target + minus what is committed as the second, so neither is physical memory. */ + Assert.Equal("Memory limit", ServerHardwareScope.MemoryTabTotalLabel(5)); + Assert.Equal("Available under limit", ServerHardwareScope.MemoryTabAvailableLabel(5)); + + foreach (var engineEdition in new int?[] { 1, 2, 3, 4, 8, null }) + { + Assert.Equal("Physical Memory", ServerHardwareScope.MemoryTabTotalLabel(engineEdition)); + Assert.Equal("Available Physical", ServerHardwareScope.MemoryTabAvailableLabel(engineEdition)); + } + + /* The utilization card's caption and the Memory tab's label are the same words. */ + Assert.Equal(ServerHardwareScope.MemoryTabTotalLabel(5) + ": ", ServerHardwareScope.PhysicalMemoryCaption(5)); + Assert.Equal("Physical: ", ServerHardwareScope.PhysicalMemoryCaption(3)); + } + + // ── the wiring, pinned at the source ── + + [Fact] + public void FactCollectorAndServerMetadataReader_AskTheSharedRule_ForTheEditionsHardware() + { + /* The PostgreSQL statement reads the edition beside the count and scopes the count in SQL, identically to Lite's DuckDB read. */ + Assert.Contains( + "SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, hyperthread_ratio", + PgFactCollector.ServerPropertiesSql, StringComparison.Ordinal); + Assert.Contains("memory_dump_count, engine_edition", PgFactCollector.ServerPropertiesSql, StringComparison.Ordinal); + + var config = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Analysis", "PgFactCollector.Config.cs"); + Assert.Contains("FactCollectorHelpers.BuildServerHardwareFact(", config, StringComparison.Ordinal); + Assert.Contains("lpim, ifi, dumpCount, hardwareIsTheHosts);", config, StringComparison.Ordinal); + Assert.DoesNotContain("[\"hyperthread_ratio\"] = htRatio", config, StringComparison.Ordinal); + + Assert.Contains("engine_edition, vcore_count", DarlingServerMetadataReader.ServerMetadataSql, StringComparison.Ordinal); + Assert.Contains("props.engine_edition, props.vcore_count", DarlingServerMetadataReader.ServerMetadataSql, StringComparison.Ordinal); + var reader = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "DarlingServerMetadataReader.cs"); + Assert.Contains("PhysicalMemoryMB = ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L,", reader, StringComparison.Ordinal); + Assert.Contains("EngineEdition = engineEdition,", reader, StringComparison.Ordinal); + Assert.Contains("VcoreCount = vcoreCount,", reader, StringComparison.Ordinal); + } + + [Fact] + public void FinOpsTab_AsksTheSharedRules_ForTheCpuUnit_TheWorkerThreadsCard_TheHealthTooltip_AndTheInventoryCpuTerm() + { + var tab = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "FinOpsTab.Loaders.cs"); + var xaml = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "FinOpsTab.xaml"); + + Assert.Contains("FinOpsCpuCountUnitText.Text = ServerHardwareScope.CpuCountUnit(data.EngineEdition);", tab, StringComparison.Ordinal); + Assert.Contains("x:Name=\"FinOpsCpuCountUnitText\"", xaml, StringComparison.Ordinal); + + Assert.Contains( + "FinOpsWorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.CurrentWorkersCount, data.MaxWorkersCount);", + tab, StringComparison.Ordinal); + Assert.DoesNotContain("$\"{data.CurrentWorkersCount:N0} / {data.MaxWorkersCount:N0}\"", tab, StringComparison.Ordinal); + + Assert.Contains( + "FinOpsHealthScoreBorder.ToolTip = data.HasCpuSample ? null : ServerHardwareScope.HealthScoreWithoutCpuNote;", + tab, StringComparison.Ordinal); + + Assert.Contains( + "int? cpuScore = item.AvgCpuPct is decimal avgCpu ? FinOpsHealthCalculator.CpuScore(avgCpu) : null;", + tab, StringComparison.Ordinal); + Assert.DoesNotContain("item.AvgCpuPct ?? 0m", tab, StringComparison.Ordinal); + } + + [Fact] + public void WorkerReads_KeepANullInUseCountNull_InAllThreePlaces() + { + var utilization = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Utilization.cs"); + var inventory = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Inventory.cs"); + + /* Point-in-time read, 7-day trend and fleet read: the in-use count is read as NULL, not coalesced to 0. */ + Assert.Contains("int? currentWorkers = reader.IsDBNull(10) ? null : Convert.ToInt32(reader.GetValue(10));", utilization, StringComparison.Ordinal); + Assert.Contains("currentWorkers: reader.IsDBNull(10) ? (int?)null : Convert.ToInt32(reader.GetValue(10)));", utilization, StringComparison.Ordinal); + Assert.Contains("currentWorkers: reader.IsDBNull(7) ? (int?)null : Convert.ToInt32(reader.GetValue(7)));", inventory, StringComparison.Ordinal); + Assert.DoesNotContain("COALESCE(m.current_workers_count", utilization, StringComparison.Ordinal); + Assert.DoesNotContain("COALESCE(m.current_workers_count", inventory, StringComparison.Ordinal); + Assert.DoesNotContain("COALESCE(m.current_workers_count", ViewerDataService.ProvisioningTrendSql, StringComparison.Ordinal); + Assert.DoesNotContain("COALESCE(m.current_workers_count", ViewerDataService.ServerMetricsSql, StringComparison.Ordinal); + Assert.Contains("m.current_workers_count", ViewerDataService.ProvisioningTrendSql, StringComparison.Ordinal); + + /* The ceiling is the engine's own figure on every edition: no CASE zeroes it on an Azure SQL Database. */ + Assert.DoesNotContain("CASE WHEN s.engine_edition = 5 THEN 0 ELSE COALESCE(m.max_workers_count, 0) END", ViewerDataService.ProvisioningTrendSql, StringComparison.Ordinal); + Assert.DoesNotContain("CASE WHEN props.engine_edition = 5 THEN NULL ELSE latest.max_workers_count END", ViewerDataService.ServerMetricsSql, StringComparison.Ordinal); + } + + [Fact] + public void MemoryUtilization_IsStillComputedFromMemoryStats_NotFromTheHostsPhysicalMemory() + { + /* The read is the memory_stats snapshot, which the collector fills from the database's own committed target on an + Azure SQL Database (1,838 MB for a 1-vCore General Purpose database), so dividing by it is database-scoped. Neither + statement takes anything from server_properties: both the tool and the viewer read their edition from the registry. */ + foreach (var sql in new[] { DarlingDataReader.LatestMemoryStatsSql, ViewerDataService.LatestMemoryStatsSql }) + { + Assert.Contains("FROM v_memory_stats", sql, StringComparison.Ordinal); + Assert.DoesNotContain("sp.physical_memory_mb", sql, StringComparison.Ordinal); + Assert.DoesNotContain("sp.cpu_count", sql, StringComparison.Ordinal); + Assert.DoesNotContain("server_properties", sql, StringComparison.Ordinal); + } + + var tool = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"); + Assert.Contains( + "? (stats.TotalPhysicalMemoryMb - stats.AvailablePhysicalMemoryMb) / stats.TotalPhysicalMemoryMb * 100", + tool, StringComparison.Ordinal); + Assert.Contains("memory_utilization_pct = Math.Round(utilization, 1),", tool, StringComparison.Ordinal); + } + + [Fact] + public void MemoryTab_AsksTheSharedRule_ForTheNamesOfItsFirstTwoFigures_InTheViewer_TheMcpPayload_AndTheWebTiles() + { + var memory = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerServerTab.Memory.cs"); + var xaml = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerServerTab.xaml"); + Assert.Contains("PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(_server.EngineEdition);", memory, StringComparison.Ordinal); + Assert.Contains("AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(_server.EngineEdition);", memory, StringComparison.Ordinal); + Assert.Contains("x:Name=\"PhysicalMemoryLabel\"", xaml, StringComparison.Ordinal); + Assert.Contains("x:Name=\"AvailablePhysicalMemoryLabel\"", xaml, StringComparison.Ordinal); + + /* The viewer's memory read carries no edition: the strip's names follow the registry's, the value its page-file lines read. + The row is built from the eleven memory columns and nothing else. */ + var dataService = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.Memory.cs"); + var viewerRowStart = dataService.IndexOf("return new MemoryStatsRow(", StringComparison.Ordinal); + Assert.True(viewerRowStart > 0, "the viewer's MemoryStatsRow construction is missing"); + var viewerRow = dataService[viewerRowStart..dataService.IndexOf(");", viewerRowStart, StringComparison.Ordinal)]; + Assert.Contains("reader.IsDBNull(10) ? 0 : reader.GetDouble(10)", viewerRow, StringComparison.Ordinal); + Assert.DoesNotContain("IsDBNull(11)", viewerRow, StringComparison.Ordinal); + + /* The MCP tool's payload names its figures from the one registry edition it read, not from a column of the memory read. */ + var serviceReader = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingDataReader.cs"); + var memoryReadStart = serviceReader.IndexOf("Task GetLatestMemoryStatsAsync(", StringComparison.Ordinal); + Assert.True(memoryReadStart > 0, "GetLatestMemoryStatsAsync is missing"); + var memoryRead = serviceReader[memoryReadStart..serviceReader.IndexOf("LatestMemoryClerksSql", memoryReadStart, StringComparison.Ordinal)]; + Assert.DoesNotContain("IsDBNull(11)", memoryRead, StringComparison.Ordinal); + + var tool = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"); + Assert.Contains("engine_edition = engineEdition == CollectorEngineCapability.UnknownEngineEdition ? (int?)null : engineEdition", tool, StringComparison.Ordinal); + Assert.DoesNotContain("stats.EngineEdition", tool, StringComparison.Ordinal); + + /* The web tiles read get_memory_stats: the same two figures appear twice, each drawn only on its own side of the + engine_edition 5 condition (panels.js visibleStats), under the words the viewer uses. */ + var tabs = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "server-tabs.js"); + var templates = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "view-templates.js"); + var tabsStart = tabs.IndexOf("const MEMORY_STATS = [", StringComparison.Ordinal); + Assert.True(tabsStart > 0, "MEMORY_STATS is missing"); + var list = tabs[tabsStart..tabs.IndexOf("];", tabsStart, StringComparison.Ordinal)]; + /* The built-in page names the condition once; the ready-made dashboard is a literal the template tests read without + running it, so it writes the condition out in each tile. */ + foreach (var (source, condition) in new[] { (list, "AZURE_SQL_DATABASE"), (templates, "{ key: \"engine_edition\", equals: 5 }") }) + { + Assert.Contains($"{{ key: \"total_physical_memory_mb\", label: \"Physical\", format: \"mb\", hideWhen: {condition} }}", source, StringComparison.Ordinal); + Assert.Contains($"{{ key: \"total_physical_memory_mb\", label: \"Memory limit\", format: \"mb\", showWhen: {condition} }}", source, StringComparison.Ordinal); + Assert.Contains($"{{ key: \"available_physical_memory_mb\", label: \"Available\", format: \"mb\", hideWhen: {condition} }}", source, StringComparison.Ordinal); + Assert.Contains($"{{ key: \"available_physical_memory_mb\", label: \"Available under limit\", format: \"mb\", showWhen: {condition} }}", source, StringComparison.Ordinal); + } + Assert.Contains("const AZURE_SQL_DATABASE = { key: \"engine_edition\", equals: 5 };", tabs, StringComparison.Ordinal); + } + + /// audit_config reads PostgreSQL, which this suite does not stand up, so the tool is pinned at the source: its MAXDOP + /// recommendation comes from the shared basis (the vCores on an Azure SQL Database, cores per socket elsewhere) and says which. + /// Lite.Tests runs the same tool end to end over a seeded store. + [Fact] + public void AuditConfig_TakesItsMaxdopRecommendationFromTheSharedBasis_AndNamesTheDatabasesVcores() + { + var tool = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpTools.cs"); + + Assert.Contains("var maxdopBasis = FactRemediation.MaxdopBasisFrom(factsByKey);", tool, StringComparison.Ordinal); + Assert.Contains("var recommended = (int)FactRemediation.RecommendedMaxdop(maxdopBasis.Cores);", tool, StringComparison.Ordinal); + Assert.Contains("(maxdopBasis.FromVcores ? \"this database's vCores\" : \"this server's cores-per-socket\")", tool, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/DarlingReadsUseOnlyStoreViewsTests.cs b/Darling/Darling.Tests/DarlingReadsUseOnlyStoreViewsTests.cs new file mode 100644 index 000000000..784d72362 --- /dev/null +++ b/Darling/Darling.Tests/DarlingReadsUseOnlyStoreViewsTests.cs @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using PerformanceMonitor.Darling.Storage; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// Every v_ name that a Darling read uses as a FROM or JOIN target must be a view the Darling store creates. +/// Lite's reads query v_server_properties, which has no Darling twin, and a read ported from Lite that keeps the name fails +/// on PostgreSQL with 42P01 (the relation does not exist). Only the live tests, which need a database, see that failure. This +/// pin reads the source text of every Darling project that holds reads, with the comments removed, so it fails without one. The +/// projects are PerformanceMonitor.Darling.Service, PerformanceMonitor.Darling.Viewer, +/// PerformanceMonitor.Darling.Analysis and PerformanceMonitor.Darling.Storage. +/// The store's views are and . +/// +public sealed class DarlingReadsUseOnlyStoreViewsTests +{ + private static readonly Regex BlockComment = new(@"/\*.*?\*/", RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex LineComment = new(@"//[^\r\n]*", RegexOptions.Compiled); + private static readonly Regex ViewTarget = new(@"\b(?:FROM|JOIN)\s+(v_[a-z0-9_]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + [Fact] + public void EveryViewNamedAsAFromOrJoinTarget_InTheServiceViewerAnalysisAndStorageSql_IsOneTheStoreCreates() + { + var storeViews = new HashSet( + PgSchemaGenerator.AllPassthroughViews.Concat(PgSchemaGenerator.PayloadResolvingViews), StringComparer.OrdinalIgnoreCase); + var unknown = new SortedSet(StringComparer.Ordinal); + var scanned = 0; + + foreach (var project in new[] + { + "PerformanceMonitor.Darling.Service", "PerformanceMonitor.Darling.Viewer", + "PerformanceMonitor.Darling.Analysis", "PerformanceMonitor.Darling.Storage", + }) + { + foreach (var file in Directory.EnumerateFiles(PathTo("Darling", project), "*.cs", SearchOption.AllDirectories)) + { + var segments = file.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (segments.Contains("bin") || segments.Contains("obj")) + continue; + + var sqlAndCode = LineComment.Replace(BlockComment.Replace(File.ReadAllText(file), " "), string.Empty); + scanned++; + foreach (Match target in ViewTarget.Matches(sqlAndCode)) + { + if (!storeViews.Contains(target.Groups[1].Value)) + unknown.Add($"{target.Groups[1].Value} in {Path.GetFileName(file)}"); + } + } + } + + Assert.True(scanned > 20, $"Only {scanned} source files were scanned, so this pin is not reading the code it guards."); + Assert.True( + unknown.Count == 0, + "These views are not views the Darling store creates (read the base table instead): " + string.Join(", ", unknown)); + } +} diff --git a/Darling/Darling.Tests/TimeHonestyRungTests.cs b/Darling/Darling.Tests/TimeHonestyRungTests.cs index 57e9bdf2e..45f35ba55 100644 --- a/Darling/Darling.Tests/TimeHonestyRungTests.cs +++ b/Darling/Darling.Tests/TimeHonestyRungTests.cs @@ -394,8 +394,8 @@ ReadLatestCpuCoreAsync sibling holding the read byte-identical — the columns a public void TheServerPropertiesRead_CarriesTheClockPair_AndThePayloadPublishesIt() { var sql = DarlingDataReader.LatestServerPropertiesSql.Replace("\r\n", "\n", StringComparison.Ordinal); - /* The clock pair still follows the pre-rung columns; vcore_count (what describes an Azure SQL Database where - cpu_count describes its host) is projected after it, so the pair's position is unchanged. */ + /* The clock pair still follows the pre-rung columns; vcore_count (what an Azure SQL Database is given, where + cpu_count is the schedulers it can see) is projected after it, so the pair's position is unchanged. */ Assert.Contains("service_objective,\n utc_offset_minutes,\n time_zone_id,\n vcore_count\nFROM server_properties", Dedent(sql), StringComparison.Ordinal); Assert.Equal(typeof(int?), typeof(DarlingDataReader.ServerPropertiesReadRow).GetProperty("UtcOffsetMinutes")!.PropertyType); diff --git a/Darling/Darling.Tests/ViewerAzureSqlDatabaseCallSiteTests.cs b/Darling/Darling.Tests/ViewerAzureSqlDatabaseCallSiteTests.cs index d924b00b6..c83f2e14d 100644 --- a/Darling/Darling.Tests/ViewerAzureSqlDatabaseCallSiteTests.cs +++ b/Darling/Darling.Tests/ViewerAzureSqlDatabaseCallSiteTests.cs @@ -7,6 +7,7 @@ */ using System; +using PerformanceMonitor.Darling.Service.Mcp; using Xunit; using static Darling.Tests.RepoFile; @@ -94,19 +95,83 @@ public void TheMemoryOverview_ReadsNotApplicable_ThroughTheHelpers() } /// - /// get_memory_stats reads the edition on its success path and publishes the state through the shared rule. The tool - /// needs a live store to run, so its use of the rule is pinned here and the rule itself in + /// One edition for the whole Memory Overview panel. The two captions over its first figures, the two page-file figures and the + /// memory state all take the registry's edition (_server.EngineEdition), as every other edition-dependent line on the + /// tab does. So the panel cannot name a figure "Physical Memory" above a page file of "n/a". Nothing in the method reads an + /// edition off the memory row. + /// + [Fact] + public void TheMemoryOverview_ReadsTheRegistrysEdition_ForItsCaptionsAndForItsOtherLines() + { + var body = MethodBody(["Darling", "PerformanceMonitor.Darling.Viewer", "ViewerServerTab.Memory.cs"], "void RenderMemorySummary("); + + Assert.Contains("PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(_server.EngineEdition);", body, StringComparison.Ordinal); + Assert.Contains("AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(_server.EngineEdition);", body, StringComparison.Ordinal); + + /* Five lines read an edition (two captions, two page-file figures, the state) and every one of them reads _server.EngineEdition. */ + Assert.Equal(5, CountOf(body, "EngineEdition")); + Assert.Equal(5, CountOf(body, "_server.EngineEdition")); + } + + private static readonly string[] McpDataToolsFile = ["Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"]; + + private static readonly string[] EngineCapabilityFile = ["Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingEngineCapability.cs"]; + + /// + /// get_memory_stats reads the edition on its success path and publishes the state through the shared rule, in the payload + /// function it hands the row and that edition to. The tool needs a live store to run, so its use of the rule is pinned here + /// and the rule itself in /// . /// [Fact] public void GetMemoryStats_PublishesTheStateThroughTheSharedRule() { - var body = MethodBody(["Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"], "Task GetMemoryStats("); + var body = MethodBody(McpDataToolsFile, "Task GetMemoryStats("); + var payload = MethodBody(McpDataToolsFile, "string MemoryStatsPayload("); Assert.Contains("var engineEdition = await DarlingEngineCapability.EngineEditionAsync(postgres, resolved.ServerId, cancellationToken);", body, StringComparison.Ordinal); - Assert.Contains("system_memory_state = ServerHardwareScope.MemoryStateOrNull(engineEdition, stats.SystemMemoryState),", body, StringComparison.Ordinal); - Assert.Contains("system_memory_state_note = ServerHardwareScope.MemoryStateNoteFor(engineEdition),", body, StringComparison.Ordinal); - Assert.DoesNotContain("system_memory_state = stats.SystemMemoryState", body, StringComparison.Ordinal); + Assert.Contains("return MemoryStatsPayload(resolved.ServerName, stats, engineEdition);", body, StringComparison.Ordinal); + Assert.Contains("system_memory_state = ServerHardwareScope.MemoryStateOrNull(engineEdition, stats.SystemMemoryState),", payload, StringComparison.Ordinal); + Assert.Contains("system_memory_state_note = ServerHardwareScope.MemoryStateNoteFor(engineEdition),", payload, StringComparison.Ordinal); + Assert.DoesNotContain("system_memory_state = stats.SystemMemoryState", payload, StringComparison.Ordinal); + } + + /// + /// get_memory_stats has ONE edition, read ONCE, from the registry: DarlingEngineCapability.EngineEditionAsync, which + /// reads the same servers row as NotCollectedStatusAsync, the answer every other Darling MCP gate gives. The + /// payload's engine_edition, memory_note and memory-state pair are all built from that value, so the tool + /// cannot say one thing in its figures and another in its own not_collected answers. The payload's answers are pinned in + /// ; this pins where the value comes from. + /// + [Fact] + public void GetMemoryStats_ReadsTheEditionOnce_FromTheRegistry_AndEverythingEditionDependentFollowsIt() + { + var body = MethodBody(McpDataToolsFile, "Task GetMemoryStats("); + var payload = MethodBody(McpDataToolsFile, "string MemoryStatsPayload("); + + /* One read in the tool, and it is the registry reader. No second source (the memory row's, server_properties) is read. */ + Assert.Equal(1, CountOf(body, "EngineEditionAsync(")); + Assert.Contains("DarlingEngineCapability.EngineEditionAsync(", body, StringComparison.Ordinal); + Assert.DoesNotContain("GetLatestServerPropertiesAsync", body, StringComparison.Ordinal); + Assert.DoesNotContain("stats.EngineEdition", body + payload, StringComparison.Ordinal); + + /* Every edition-dependent line of the payload reads the one value it was handed. */ + Assert.Contains("engine_edition = engineEdition == CollectorEngineCapability.UnknownEngineEdition ? (int?)null : engineEdition", payload, StringComparison.Ordinal); + Assert.Contains("if (!ServerHardwareScope.HardwareIsTheHosts(engineEdition))", payload, StringComparison.Ordinal); + + /* The registry reader answers both the success path and the not_collected gate, from the same read of the servers row. */ + Assert.Contains("(await ReadServerEngineAsync(postgres, serverId, cancellationToken)).EngineEdition", MethodBody(EngineCapabilityFile, "Task EngineEditionAsync("), StringComparison.Ordinal); + Assert.Contains("(engineEdition, engineKind) = await ReadServerEngineAsync(postgres, serverId, cancellationToken);", MethodBody(EngineCapabilityFile, "Task NotCollectedStatusAsync("), StringComparison.Ordinal); + Assert.Contains("sql_engine_edition", DarlingEngineCapability.ServerEngineSql, StringComparison.Ordinal); + Assert.Contains("FROM servers", DarlingEngineCapability.ServerEngineSql, StringComparison.Ordinal); + } + + 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; } /// diff --git a/Darling/Darling.Tests/ViewerFinOpsRecommendationsTests.cs b/Darling/Darling.Tests/ViewerFinOpsRecommendationsTests.cs index 2641574bc..469b4e44a 100644 --- a/Darling/Darling.Tests/ViewerFinOpsRecommendationsTests.cs +++ b/Darling/Darling.Tests/ViewerFinOpsRecommendationsTests.cs @@ -458,8 +458,11 @@ public void EngineEditionSql_ReadsTheNewestCollectedServerPropertiesRow() /// /// The three right-sizing rules run inside GetRecommendationsAsync over the Postgres store, which this - /// suite does not stand up, so the gates are pinned on the rule source: the CPU rule and the VM rule read - /// HasCpuSample, and the memory rule and the VM rule stand down on Azure SQL Database (edition 5). + /// suite does not stand up, so the gates are pinned on the rule source. The CPU rule's guard lives in + /// BuildCpuRightSizingRecommendation, which GetRecommendationsAsync calls, so that half is pinned on + /// the builder's guard and on the call. The VM rule keeps its guard in GetRecommendationsAsync itself. The + /// CPU rule and the VM rule read HasCpuSample, and the memory rule and the VM rule stand down on Azure SQL + /// Database (edition 5). /// [Fact] public void CpuAndVmRightSizing_StandDownWithNoCpuSample() @@ -467,7 +470,10 @@ public void CpuAndVmRightSizing_StandDownWithNoCpuSample() var body = RightSizingRulesSource(); Assert.Matches( - new Regex(@"util\s*!=\s*null\s*&&\s*util\s*\.\s*HasCpuSample\s*&&\s*util\s*\.\s*P95CpuPct\s*<\s*30"), + new Regex(@"util\s*==\s*null\s*\|\|\s*!\s*util\s*\.\s*HasCpuSample\s*\|\|\s*util\s*\.\s*P95CpuPct\s*>=\s*30"), + CpuRightSizingBuilderSource()); + Assert.Matches( + new Regex(@"BuildCpuRightSizingRecommendation\s*\(\s*util\s*,\s*monthlyCost\s*\)"), body); Assert.Matches( new Regex(@"vmUtil\s*!=\s*null\s*&&\s*vmUtil\s*\.\s*HasCpuSample\s*&&"), @@ -486,12 +492,18 @@ public void MemoryAndVmRightSizing_StandDownOnAzureSqlDatabase() Assert.Equal(5, CollectorEngineCapability.AzureSqlDatabaseEngineEdition); } - private static string RightSizingRulesSource() + private static string RightSizingRulesSource() => + RecommendationsMethodSource("Task> GetRecommendationsAsync("); + + private static string CpuRightSizingBuilderSource() => + RecommendationsMethodSource("RecommendationRow? BuildCpuRightSizingRecommendation("); + + private static string RecommendationsMethodSource(string signatureText) { var source = CSharpSourceWalker.StripCommentsAndStrings( RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Recommendations.cs")); - var signature = source.IndexOf("Task> GetRecommendationsAsync(", StringComparison.Ordinal); - Assert.True(signature >= 0, "GetRecommendationsAsync is gone, so this pin would read nothing."); + var signature = source.IndexOf(signatureText, StringComparison.Ordinal); + Assert.True(signature >= 0, $"{signatureText} is gone, so this pin would read nothing."); return CSharpSourceWalker.BraceBalanced(source, source.IndexOf('{', signature)); } diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs index 9a45f3a00..740395bc4 100644 --- a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using Npgsql; using PerformanceMonitor.Analysis; +using PerformanceMonitor.Common; namespace PerformanceMonitor.Darling.Analysis; @@ -308,10 +309,18 @@ private async Task CollectTraceFlagFactsAsync(AnalysisContext context, List + /// The newest server_properties row for the SERVER_HARDWARE fact. On an Azure SQL Database (engine_edition 5) the CPU + /// count is the vcore_count parsed from the service objective (NULL for a DTU objective or an elastic pool, which leaves no + /// fact), not the stored cpu_count: that is the number of schedulers the database can see, which can be higher than its vCores + /// (a 1-vCore database reads 2). There hyperthread_ratio, physical_memory_mb, socket_count and cores_per_socket describe the + /// HOST, and carries none of them; the recommended MAXDOP is taken + /// from the vCores. Every other edition reads as it always did. Lite's DuckDbFactCollector carries the same CASE. + /// public const string ServerPropertiesSql = @" -SELECT COALESCE(vcore_count, cpu_count) AS cpu_count, hyperthread_ratio, physical_memory_mb, +SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, hyperthread_ratio, physical_memory_mb, socket_count, cores_per_socket, is_hadr_enabled, edition, product_version, - lock_pages_in_memory, instant_file_initialization_enabled, memory_dump_count + lock_pages_in_memory, instant_file_initialization_enabled, memory_dump_count, engine_edition FROM server_properties WHERE server_id = $1 ORDER BY collection_time DESC @@ -343,30 +352,19 @@ private async Task CollectServerPropertiesFactsAsync(AnalysisContext context, Li bool? lpim = reader.IsDBNull(8) ? (bool?)null : Convert.ToBoolean(reader.GetValue(8)); bool? ifi = reader.IsDBNull(9) ? (bool?)null : Convert.ToBoolean(reader.GetValue(9)); int? dumpCount = reader.IsDBNull(10) ? (int?)null : Convert.ToInt32(reader.GetValue(10)); + int? engineEdition = reader.IsDBNull(11) ? (int?)null : Convert.ToInt32(reader.GetValue(11)); + var hardwareIsTheHosts = ServerHardwareScope.HardwareIsTheHosts(engineEdition); - if (cpuCount == 0) return; + var hardwareFact = FactCollectorHelpers.BuildServerHardwareFact( + context, hardwareIsTheHosts, cpuCount, htRatio, physicalMemMb, socketCount, coresPerSocket, hadrEnabled); + if (hardwareFact is null) return; - facts.Add(new Fact - { - Source = "config", - Key = "SERVER_HARDWARE", - Value = cpuCount, - ServerId = context.ServerId, - Metadata = new Dictionary - { - ["cpu_count"] = cpuCount, - ["hyperthread_ratio"] = htRatio, - ["physical_memory_mb"] = physicalMemMb, - ["socket_count"] = socketCount, - ["cores_per_socket"] = coresPerSocket, - ["hadr_enabled"] = hadrEnabled ? 1 : 0 - } - }); + facts.Add(hardwareFact); // WS5 server-health advisories (advise-only). Gating mirrors the Lite/Dashboard // collectors so all consumers agree on what is worth flagging; a fact that would // score 0 is simply never emitted (noise control). - FactCollectorHelpers.EmitServerHealthFacts(context, facts, edition, physicalMemMb, lpim, ifi, dumpCount); + FactCollectorHelpers.EmitServerHealthFacts(context, facts, edition, physicalMemMb, lpim, ifi, dumpCount, hardwareIsTheHosts); } catch (Exception ex) when (!AnalysisShutdown.IsExpectedAbandon(ex, context.CancellationToken)) { diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs index 5ad13965e..43a861b6f 100644 --- a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs @@ -34,8 +34,9 @@ ORDER BY collection_time DESC /// Collects memory stats: total physical RAM, buffer pool size, target memory — the newest sample /// within its collector's lookback of the window's end (#3896). /// These facts enable RESOURCE-based memory recommendations in the config audit: max server memory is - /// sized against the host's physical RAM, and no check in that audit branches on the edition (which the - /// payload reports for context only). + /// sized against the server's physical RAM (on an Azure SQL Database the collector stores the database's own memory limit in + /// that column, not the host's RAM), and no check in that audit branches on the edition (which the payload reports for + /// context only). /// private async Task CollectMemoryFactsAsync(AnalysisContext context, List facts) { diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs index 2696bbc94..457eb4753 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs @@ -61,7 +61,10 @@ public sealed record WaitStatRow(string WaitType, long TotalWaitingTasks, long T public sealed record WaitTrendPoint(DateTime CollectionTime, double WaitTimeMsPerSecond, double SignalWaitTimeMsPerSecond); /// The latest memory_stats snapshot (Lite's MemoryStatsRow); utilization is - /// computed by the tool. + /// computed by the tool. The row carries NO engine edition: the tool reads the edition once from the registry + /// (DarlingEngineCapability.EngineEditionAsync), the one every Darling MCP engine gate reads, and builds the payload + /// from this row and that value. On an Azure SQL Database (5) TotalPhysicalMemoryMb is the database's memory limit and + /// AvailablePhysicalMemoryMb the room left under it. public sealed record MemoryStatsRow( DateTime CollectionTime, double TotalPhysicalMemoryMb, double AvailablePhysicalMemoryMb, double TotalPageFileMb, double AvailablePageFileMb, string SystemMemoryState, string SqlMemoryModel, @@ -274,7 +277,8 @@ public sealed record ServerListRow( /// where the engine cannot say, which is every SQL Server before 2022 and a real, common value rather than /// a miss. is the vCore count the collector parses from an Azure SQL Database's /// service objective (null off Azure SQL Database, and for a DTU-model objective or an elastic pool, which name no vCores) — what - /// describes the database there, where and its neighbours describe the HOST. + /// the database is given there, where is the schedulers it can see (possibly more than its vCores) and + /// the memory, socket, cores-per-socket and hyperthread figures beside it describe the HOST. public sealed record ServerPropertiesReadRow( DateTime CollectionTime, string Edition, string ProductVersion, string ProductLevel, string? ProductUpdateLevel, int EngineEdition, int CpuCount, int HyperthreadRatio, long PhysicalMemoryMb, int SocketCount, int CoresPerSocket, diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index 68e41f157..c1cc6189c 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -309,30 +309,13 @@ public static async Task GetMemoryStats( return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "memory_stats", cancellationToken) ?? McpHelpers.Status("unavailable", "No memory stats available."); - var utilization = stats.TotalPhysicalMemoryMb > 0 - ? (stats.TotalPhysicalMemoryMb - stats.AvailablePhysicalMemoryMb) / stats.TotalPhysicalMemoryMb * 100 - : 0; - - /* On an Azure SQL Database the collector stores the constant "Available" as the memory state. It is not a - reading, so the state is null there and the note beside it says why (ServerHardwareScope). */ + /* ONE edition for the whole answer: the REGISTRY's (servers.sql_engine_edition), read once through the reader every + other Darling MCP engine gate uses (DarlingEngineCapability.NotCollectedStatusAsync, which this tool also calls on + its miss path). engine_edition, memory_note and the memory-state pair all follow it, so the tool cannot disagree + with its own not_collected answers. The memory read carries no edition of its own. */ var engineEdition = await DarlingEngineCapability.EngineEditionAsync(postgres, resolved.ServerId, cancellationToken); - return JsonSerializer.Serialize(new - { - server = resolved.ServerName, - /* #3541 A10: the one stamp every latest-snapshot read publishes, under the one name. */ - captured_at = stats.CollectionTime.ToString("o"), - total_physical_memory_mb = stats.TotalPhysicalMemoryMb, - available_physical_memory_mb = stats.AvailablePhysicalMemoryMb, - memory_utilization_pct = Math.Round(utilization, 1), - system_memory_state = ServerHardwareScope.MemoryStateOrNull(engineEdition, stats.SystemMemoryState), - system_memory_state_note = ServerHardwareScope.MemoryStateNoteFor(engineEdition), - sql_memory_model = stats.SqlMemoryModel, - target_server_memory_mb = stats.TargetServerMemoryMb, - total_server_memory_mb = stats.TotalServerMemoryMb, - buffer_pool_mb = stats.BufferPoolMb, - plan_cache_mb = stats.PlanCacheMb - }, McpHelpers.JsonOptions); + return MemoryStatsPayload(resolved.ServerName, stats, engineEdition); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -340,6 +323,51 @@ public static async Task GetMemoryStats( } } + /// + /// The get_memory_stats payload for one snapshot, built from the row and the ONE engine edition the tool read for this + /// answer from the registry (; + /// when the registry has none). engine_edition (null when + /// the edition is unknown), memory_note and the memory-state pair all follow that one value. + /// + /// On an Azure SQL Database (engine edition 5) total_physical_memory_mb is the database's memory limit and + /// available_physical_memory_mb the room left under it, not the host's RAM, and a utilization near 100% is normal there. + /// The keys keep their names on every edition, so the payload gains a memory_note, last, that says so. The collector + /// stores the constant "Available" as the memory state there, which is not a reading, so system_memory_state is null and + /// system_memory_state_note says why (). Every other edition keeps the + /// stored state and no memory_note, and its system_memory_state_note is null. Lite's tool emits the same shape in + /// the same words. + /// + internal static string MemoryStatsPayload(string serverName, DarlingDataReader.MemoryStatsRow stats, int engineEdition) + { + var utilization = stats.TotalPhysicalMemoryMb > 0 + ? (stats.TotalPhysicalMemoryMb - stats.AvailablePhysicalMemoryMb) / stats.TotalPhysicalMemoryMb * 100 + : 0; + + var payload = new + { + server = serverName, + /* #3541 A10: the one stamp every latest-snapshot read publishes, under the one name. */ + captured_at = stats.CollectionTime.ToString("o"), + total_physical_memory_mb = stats.TotalPhysicalMemoryMb, + available_physical_memory_mb = stats.AvailablePhysicalMemoryMb, + memory_utilization_pct = Math.Round(utilization, 1), + system_memory_state = ServerHardwareScope.MemoryStateOrNull(engineEdition, stats.SystemMemoryState), + system_memory_state_note = ServerHardwareScope.MemoryStateNoteFor(engineEdition), + sql_memory_model = stats.SqlMemoryModel, + target_server_memory_mb = stats.TargetServerMemoryMb, + total_server_memory_mb = stats.TotalServerMemoryMb, + buffer_pool_mb = stats.BufferPoolMb, + plan_cache_mb = stats.PlanCacheMb, + engine_edition = engineEdition == CollectorEngineCapability.UnknownEngineEdition ? (int?)null : engineEdition + }; + + if (!ServerHardwareScope.HardwareIsTheHosts(engineEdition)) + return JsonSerializer.Serialize(payload, McpHelpers.JsonOptions); + + var scoped = JsonSerializer.SerializeToNode(payload, McpHelpers.JsonOptions)!.AsObject(); + return ServerHardwareScope.WithMemoryNote(scoped).ToJsonString(McpHelpers.JsonOptions); + } + [McpServerTool(Name = "get_memory_clerks"), Description("Gets the top memory consumers by memory clerk type — shows which SQL Server components are using the most memory. LATEST IS A TIME: this reads the newest clerk snapshot, not a window, and captured_at is the instant it was collected.")] public static async Task GetMemoryClerks( NpgsqlDataSource postgres, @@ -672,8 +700,9 @@ ratio is omitted rather than invented when a denominator piece is missing. The t await Task.WhenAll(cpuAggregateTask, propertiesTask); var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; - /* The core count is the server's own: on an Azure SQL Database the stored cpu_count is the HOST's, so this divides by its - vcore_count, or omits the ratio for a DTU-model objective or an elastic pool (see CpuAttribution). */ + /* The core count is what the server is given: on an Azure SQL Database the stored cpu_count is the schedulers it can see, + which can be more than its vCores, so this divides by its vcore_count, or omits the ratio for a DTU-model objective or + an elastic pool (see CpuAttribution). */ var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuUs) / 1_000_000.0, attrStart, attrEnd, @@ -839,8 +868,9 @@ same concurrent independent reads. */ await Task.WhenAll(cpuAggregateTask, propertiesTask); var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; - /* The core count is the server's own: on an Azure SQL Database the stored cpu_count is the HOST's, so this divides by its - vcore_count, or omits the ratio for a DTU-model objective or an elastic pool (see CpuAttribution). */ + /* The core count is what the server is given: on an Azure SQL Database the stored cpu_count is the schedulers it can see, + which can be more than its vCores, so this divides by its vcore_count, or omits the ratio for a DTU-model objective or + an elastic pool (see CpuAttribution). */ var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuUs) / 1_000_000.0, attrStart, attrEnd, @@ -1767,7 +1797,7 @@ a status word (about 200 characters at most), and only a regressed row carries o }; } - [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, memory, socket/core topology, HADR, clustering, and the clock (utc_offset_minutes, time_zone_id). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected, and on a stalled collector it is the only sign of staleness. time_zone_id is CURRENT_TIMEZONE_ID() (SQL Server 2022+/Azure SQL only); null means a pre-2022 engine, so only the offset in force at captured_at is known, and an instant across a DST transition from it can read an hour off. <> Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, clustering, and the server's clock: utc_offset_minutes is the UTC offset in force when the snapshot was collected, and time_zone_id is the engine's own time-zone name (CURRENT_TIMEZONE_ID(), SQL Server 2022+ and Azure SQL only) - a null time_zone_id means a pre-2022 engine, where only the offset is known and any instant on the far side of a DST transition from the snapshot is placed an hour off by that offset. Use for capacity planning and edition-aware recommendations. LATEST IS A TIME: this reads the newest properties snapshot, not a window, and captured_at is the instant it was collected - a core count or memory figure here is what the server reported AT that stamp, and on a server whose collector has stalled the stamp is the only thing that says how stale it is. ON AN AZURE SQL DATABASE (engine_edition 5) the host's hardware is not the database's allocation: cpu_count, hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb come back null with a hardware_note, and service_objective with vcore_count says what the database is given.")] + [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, memory, socket/core topology, HADR, clustering, and the clock (utc_offset_minutes, time_zone_id). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected, and on a stalled collector it is the only sign of staleness. time_zone_id is CURRENT_TIMEZONE_ID() (SQL Server 2022+/Azure SQL only); null means a pre-2022 engine, so only the offset in force at captured_at is known, and an instant across a DST transition from it can read an hour off. <> Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, clustering, and the server's clock: utc_offset_minutes is the UTC offset in force when the snapshot was collected, and time_zone_id is the engine's own time-zone name (CURRENT_TIMEZONE_ID(), SQL Server 2022+ and Azure SQL only) - a null time_zone_id means a pre-2022 engine, where only the offset is known and any instant on the far side of a DST transition from the snapshot is placed an hour off by that offset. Use for capacity planning and edition-aware recommendations. LATEST IS A TIME: this reads the newest properties snapshot, not a window, and captured_at is the instant it was collected - a core count or memory figure here is what the server reported AT that stamp, and on a server whose collector has stalled the stamp is the only thing that says how stale it is. ON AN AZURE SQL DATABASE (engine_edition 5) the host's hardware is not the database's allocation: hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb come back null with a hardware_note, cpu_count is the database's own scheduler count (it can be higher than its vCores), and service_objective with vcore_count says what the database is given.")] public static async Task GetServerProperties( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, @@ -1793,10 +1823,10 @@ public static async Task GetServerProperties( /// /// The get_server_properties payload for one snapshot. On an Azure SQL Database (engine edition 5) the stored - /// cpu_count, hyperthread_ratio, socket_count, cores_per_socket and - /// physical_memory_mb are the HOST's, so they come back null, vcore_count (what the service objective - /// gives the database) rides beside service_objective, and a hardware_note says why. Every other - /// edition keeps the payload it always had, key for key. Lite's tool emits the same shape in the same words. + /// hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb are the HOST's, so + /// they come back null, cpu_count (the database's own scheduler count) passes through, vcore_count (what the + /// service objective gives the database) rides beside service_objective, and a hardware_note says why. Every + /// other edition keeps the payload it always had, key for key. Lite's tool emits the same shape in the same words. /// internal static string ServerPropertiesPayload(string serverName, DarlingDataReader.ServerPropertiesReadRow row) { diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs index 4405090c5..4236a1a9e 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs @@ -776,8 +776,9 @@ the collector missed changes nothing about what the server is configured to. (Ph var totalDbSizeMb = factsByKey.TryGetValue("DATABASE_TOTAL_SIZE_MB", out var dbFact) ? dbFact.Value : 0; var editionName = AuditEditionName(edition); - var coresPerSocket = factsByKey.TryGetValue("SERVER_HARDWARE", out var hwFact) - && hwFact.Metadata.TryGetValue("cores_per_socket", out var cps) ? (int)cps : 0; + /* The recommended MAXDOP follows the cores per socket the SERVER_HARDWARE fact carries. On an Azure SQL Database the fact carries its vCores instead, because the stored + cores_per_socket is the host's (see FactRemediation.MaxdopBasisFrom). */ + var maxdopBasis = FactRemediation.MaxdopBasisFrom(factsByKey); var recommendations = new List(); @@ -810,11 +811,11 @@ the collector missed changes nothing about what the server is configured to. (Ph } } - // MAXDOP audit — topology-based (min(cores-per-socket, 8)), NOT edition-based. + // MAXDOP audit — topology-based (min(cores-per-socket, 8); min(vCores, 8) on an Azure SQL Database), NOT edition-based. if (factsByKey.TryGetValue("CONFIG_MAXDOP", out var maxdopFact)) { var maxdop = (int)maxdopFact.Value; - var recommended = (int)FactRemediation.RecommendedMaxdop(coresPerSocket); + var recommended = (int)FactRemediation.RecommendedMaxdop(maxdopBasis.Cores); if (maxdop == 0) { @@ -822,7 +823,7 @@ the collector missed changes nothing about what the server is configured to. (Ph $"MAXDOP is 0 (unlimited). This lets one query fan out across all schedulers, " + $"leading to CXPACKET waits and thread exhaustion under load. Microsoft's guidance is " + $"topology-based: keep MAXDOP at or under the logical processors in a single NUMA node, capped at 8. " + - $"Start with {recommended} (this server's cores-per-socket, capped at 8) and adjust to the workload.")); + $"Start with {recommended} ({(maxdopBasis.FromVcores ? "this database's vCores" : "this server's cores-per-socket")}, capped at 8) and adjust to the workload.")); } else if (maxdop == 1 && recommended > 1) { diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js index 74e940877..846c8beb7 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js @@ -2490,17 +2490,18 @@ const OVERVIEW_STATS = [ { key: "last_collection", label: "Last collection", format: "reltime", small: true }, ]; -/* An Azure SQL Database (engine_edition 5) reports the HOST's logical CPUs, sockets, cores per socket, hyperthread - ratio and physical memory, none of which is the database's allocation (a 1-vCore serverless database read "2 CPUs, - 0 sockets, 32 cores/socket, HT ratio 64, 911.9 GB"). get_server_properties returns those five as null there, and - these tiles are not drawn; the service objective and its vCores say what the database is given. */ +/* An Azure SQL Database (engine_edition 5) reports the HOST's sockets, cores per socket, hyperthread ratio and physical + memory, none of which is the database's allocation (a 1-vCore database read "0 sockets, 32 cores/socket, HT ratio 64, + about 912 GB"). get_server_properties returns those four as null there, and these tiles are not drawn. Logical CPUs + is the database's own scheduler count (a 1-vCore database reads 2), so that tile is drawn; the service objective and its + vCores say what the database is given. */ const AZURE_SQL_DATABASE = { key: "engine_edition", equals: 5 }; const PROPERTY_STATS = [ { key: "product_version", label: "Version", format: "text", small: true }, { key: "edition", label: "Edition", format: "text", small: true }, { key: "product_level", label: "Level", format: "text", small: true }, - { key: "cpu_count", label: "Logical CPUs", format: "int", hideWhen: AZURE_SQL_DATABASE }, + { key: "cpu_count", label: "Logical CPUs", format: "int" }, { key: "socket_count", label: "Sockets", format: "int", hideWhen: AZURE_SQL_DATABASE }, { key: "cores_per_socket", label: "Cores/socket", format: "int", hideWhen: AZURE_SQL_DATABASE }, { key: "hyperthread_ratio", label: "HT ratio", format: "int", hideWhen: AZURE_SQL_DATABASE }, @@ -2568,9 +2569,14 @@ const SCHEDULER_STATS = [ { key: "recommendation", label: "Recommendation", format: "text", small: true }, ]; +/* On an Azure SQL Database (engine_edition 5) the collector fills total_physical_memory_mb from the database's own committed + target and available_physical_memory_mb as that target minus what is committed, so the first two tiles are the database's + memory limit and the room left under it, not the host's RAM, and are named that way. */ const MEMORY_STATS = [ - { key: "total_physical_memory_mb", label: "Physical", format: "mb" }, - { key: "available_physical_memory_mb", label: "Available", format: "mb" }, + { key: "total_physical_memory_mb", label: "Physical", format: "mb", hideWhen: AZURE_SQL_DATABASE }, + { key: "total_physical_memory_mb", label: "Memory limit", format: "mb", showWhen: AZURE_SQL_DATABASE }, + { key: "available_physical_memory_mb", label: "Available", format: "mb", hideWhen: AZURE_SQL_DATABASE }, + { key: "available_physical_memory_mb", label: "Available under limit", format: "mb", showWhen: AZURE_SQL_DATABASE }, { key: "memory_utilization_pct", label: "Utilization", format: "pct" }, { key: "total_server_memory_mb", label: "Total server", format: "mb" }, { key: "target_server_memory_mb", label: "Target server", format: "mb" }, diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js index eba7c481a..0a12365bb 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js @@ -220,8 +220,9 @@ function cell(row, c) { /* A stat tile may depend on another value in the same payload: `hideWhen: { key, equals }` drops it when that value equals `equals`, `showWhen: { key, equals }` keeps it only then. The Server Properties list uses the pair on - `engine_edition`: an Azure SQL Database (5) reports the HOST's CPUs, sockets and memory, which are not the - database's, so those tiles are not drawn and its vCores tile is (a tile with neither field is always drawn). */ + `engine_edition`: an Azure SQL Database (5) reports the HOST's sockets, cores per socket, hyperthread ratio and + physical memory, which are not the database's, so those tiles are not drawn and its vCores tile is (a tile with + neither field is always drawn, so its own logical CPU count still is). */ export function visibleStats(stats, data) { return stats.filter((s) => { if (s.hideWhen && getPath(data, s.hideWhen.key) === s.hideWhen.equals) return false; diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js index b08b810e3..96b930920 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js @@ -307,8 +307,13 @@ export const DASHBOARD_TEMPLATES = [ viz: "stat", span: 2, stats: [ - { key: "total_physical_memory_mb", label: "Physical", format: "mb" }, - { key: "available_physical_memory_mb", label: "Available", format: "mb" }, + /* On an Azure SQL Database (engine_edition 5) the memory figures are the database's memory limit and the room left + under it, so those tiles are captioned that way; panels.js reads `hideWhen` and `showWhen`. The condition is written + out in each tile because this file is a literal that the template tests read without running it. */ + { key: "total_physical_memory_mb", label: "Physical", format: "mb", hideWhen: { key: "engine_edition", equals: 5 } }, + { key: "total_physical_memory_mb", label: "Memory limit", format: "mb", showWhen: { key: "engine_edition", equals: 5 } }, + { key: "available_physical_memory_mb", label: "Available", format: "mb", hideWhen: { key: "engine_edition", equals: 5 } }, + { key: "available_physical_memory_mb", label: "Available under limit", format: "mb", showWhen: { key: "engine_edition", equals: 5 } }, { key: "memory_utilization_pct", label: "Utilization", format: "pct" }, { key: "total_server_memory_mb", label: "Total server", format: "mb" }, { key: "target_server_memory_mb", label: "Target server", format: "mb" }, diff --git a/Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs b/Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs index 9fdafda92..59256d1e0 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs @@ -10,6 +10,7 @@ using System.Threading; using System.Threading.Tasks; using Npgsql; +using PerformanceMonitor.Common; using PerformanceMonitor.PlanAnalysis; namespace PerformanceMonitor.Darling.Storage; @@ -40,7 +41,7 @@ public static class DarlingServerMetadataReader /// public const string ServerMetadataSql = @" WITH props AS ( - SELECT server_name, edition, product_version, product_level, cpu_count, physical_memory_mb + SELECT server_name, edition, product_version, product_level, cpu_count, physical_memory_mb, engine_edition, vcore_count FROM server_properties WHERE server_id = $1 ORDER BY collection_time DESC @@ -85,7 +86,7 @@ LIMIT 1 ctfp.value_in_use, maxmem.value_in_use, dbconfig.database_name, dbconfig.compatibility_level, dbconfig.collation_name, dbconfig.is_read_committed_snapshot_on, dbconfig.is_auto_create_stats_on, dbconfig.is_auto_update_stats_on, dbconfig.is_auto_update_stats_async_on, - dbconfig.is_parameterization_forced + dbconfig.is_parameterization_forced, props.engine_edition, props.vcore_count FROM props LEFT JOIN maxdop ON true LEFT JOIN ctfp ON true @@ -115,14 +116,25 @@ LEFT JOIN maxmem ON true if (!await reader.ReadAsync(cancellationToken)) return null; + /* On an Azure SQL Database the server context's Hardware row names the vCores (n/a for a DTU objective or an elastic + pool) and no RAM: the stored physical_memory_mb is the HOST's. The stored cpu_count there is the database's own + scheduler count and is not what the card shows. Every other edition reads as it always did. Lite's plan-metadata + readers apply the same rule. */ + int? engineEdition = reader.IsDBNull(17) ? null : Convert.ToInt32(reader.GetValue(17)); + int? vcoreCount = reader.IsDBNull(18) ? null : Convert.ToInt32(reader.GetValue(18)); + int? storedCpuCount = reader.IsDBNull(4) ? null : Convert.ToInt32(reader.GetValue(4)); + long? storedPhysicalMemoryMb = reader.IsDBNull(5) ? null : Convert.ToInt64(reader.GetValue(5)); + return new ServerMetadata { ServerName = reader.IsDBNull(0) ? null : reader.GetString(0), Edition = reader.IsDBNull(1) ? null : reader.GetString(1), ProductVersion = reader.IsDBNull(2) ? null : reader.GetString(2), ProductLevel = reader.IsDBNull(3) ? null : reader.GetString(3), - CpuCount = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)), - PhysicalMemoryMB = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)), + CpuCount = storedCpuCount ?? 0, + PhysicalMemoryMB = ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L, + EngineEdition = engineEdition, + VcoreCount = vcoreCount, MaxDop = reader.IsDBNull(6) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(6))), CostThresholdForParallelism = reader.IsDBNull(7) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(7))), MaxServerMemoryMB = reader.IsDBNull(8) ? 0L : Convert.ToInt64(Convert.ToDouble(reader.GetValue(8))), diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs index fa7af0d16..e00a69e22 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs @@ -230,6 +230,7 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) FinOpsProvisioningStatusBorder.Background = new SolidColorBrush(Colors.Gray); FinOpsAvgCpuText.Text = FinOpsP95CpuText.Text = FinOpsMaxCpuText.Text = FinOpsCpuSamplesText.Text = "-"; FinOpsCpuCountText.Text = "-"; + FinOpsCpuCountUnitText.Text = ServerHardwareScope.CpuCountUnit(null); FinOpsWorkerThreadsText.Text = "-"; FinOpsAvgCpuBar.Width = FinOpsP95CpuBar.Width = FinOpsMaxCpuBar.Width = 0; FinOpsMemoryUtilBar.Width = FinOpsMemoryRatioBar.Width = 0; @@ -272,9 +273,12 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) FinOpsP95CpuText.Text = $"{data.P95CpuPct:N2}%"; FinOpsMaxCpuText.Text = $"{data.MaxCpuPct}%"; FinOpsCpuSamplesText.Text = data.CpuSamples.ToString("N0"); - /* n/a on an Azure SQL Database whose service objective names no vCores: the host's count is never shown as the database's. */ + /* On an Azure SQL Database the count is its vCores, named as vCores, and n/a where its service objective names none: the + scheduler count it can see is never shown as the CPU it is given. */ FinOpsCpuCountText.Text = ServerHardwareScope.CpuCountText(data.EngineEdition, data.CpuCount); - FinOpsWorkerThreadsText.Text = $"{data.CurrentWorkersCount:N0} / {data.MaxWorkersCount:N0}"; + FinOpsCpuCountUnitText.Text = ServerHardwareScope.CpuCountUnit(data.EngineEdition); + /* The in-use count is n/a where it was not collected (NULL on an Azure SQL Database), never 0; the maximum shows as stored. */ + FinOpsWorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.CurrentWorkersCount, data.MaxWorkersCount); SetBar(FinOpsAvgCpuBar, FinOpsAvgCpuFilled, FinOpsAvgCpuEmpty, (double)data.AvgCpuPct); SetBar(FinOpsP95CpuBar, FinOpsP95CpuFilled, FinOpsP95CpuEmpty, (double)data.P95CpuPct); @@ -336,6 +340,8 @@ pressure or worker saturation would have been explained as a memory ratio that n /* 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(); + /* A window with no CPU sample has no CPU term (ComputeHealthScore leaves it out), and the tooltip says so. */ + FinOpsHealthScoreBorder.ToolTip = data.HasCpuSample ? null : ServerHardwareScope.HealthScoreWithoutCpuNote; FinOpsHealthScoreText.Text = $"Health: {data.HealthScore}"; FinOpsHealthScoreBorder.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(data.HealthScoreColor)); FinOpsHealthScoreBorder.Visibility = Visibility.Visible; @@ -586,7 +592,9 @@ and there is now exactly one read total. */ if (row.ProvisioningStatus != null) item.ProvisioningStatus = row.ProvisioningStatus; } - var cpuScore = FinOpsHealthCalculator.CpuScore(item.AvgCpuPct ?? 0m); + /* A server with no CPU sample in the window has a null average: its CPU term is left out, because scoring it + as 0% CPU would hand it a full 100 made from nothing. */ + int? cpuScore = item.AvgCpuPct is decimal avgCpu ? FinOpsHealthCalculator.CpuScore(avgCpu) : null; var memScore = 80; var storScore = FinOpsHealthCalculator.StorageScore(50); item.HealthScore = FinOpsHealthCalculator.Overall(cpuScore, memScore, storScore); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml index 3336c37cb..b443775ca 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml @@ -350,7 +350,7 @@ - + diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs index fb10fa584..c7cbaac4f 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs @@ -155,7 +155,7 @@ GROUP BY s.server_id c.max_cpu_pct, c.p95_cpu_pct, COALESCE(m.max_workers_count, 0), - COALESCE(m.current_workers_count, 0), + m.current_workers_count, COALESCE(g.max_grant_waiters, 0), COALESCE(g.grant_timeouts, 0), COALESCE(g.forced_grants, 0), @@ -318,7 +318,7 @@ cannot disagree — they now call the same predicate. The old inline CASE was co forcedGrants: reader.IsDBNull(10) ? 0L : Convert.ToInt64(reader.GetValue(10)), grantUtilizationPercent: reader.IsDBNull(11) ? 0m : Convert.ToDecimal(reader.GetValue(11)), maxWorkers: reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6)), - currentWorkers: reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7))); + currentWorkers: reader.IsDBNull(7) ? (int?)null : Convert.ToInt32(reader.GetValue(7))); } /// diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs index 21d649464..0a9f21d2c 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs @@ -397,6 +397,33 @@ the savings estimates (#980). */ }; } + /// + /// The CPU right-sizing recommendation for one utilization row, or null when it has nothing to say. A window with no CPU + /// sample reads a P95 of 0, which is "idle" only because nothing was measured. The utilization row gives that window no verdict + /// (HasCpuSample is false), and the advice follows it. The count the text prints is the vCores the service objective + /// gives an Azure SQL Database, so there it is named "vCores", as the utilization card names it; on every other edition it + /// is the CPU count and the word stays "cores". Lite's recommendation reads the same rule. + /// + internal static RecommendationRow? BuildCpuRightSizingRecommendation(UtilizationEfficiencyRow? util, decimal monthlyCost) + { + if (util == null || !util.HasCpuSample || util.P95CpuPct >= 30 || util.CpuCount <= 4) + return null; + + var targetCores = Math.Max(4, (int)(util.CpuCount * (util.P95CpuPct / 70m))); + var savingsPct = 1m - ((decimal)targetCores / util.CpuCount); + var cpuNoun = ServerHardwareScope.CpuCoreNoun(util.EngineEdition); + return new RecommendationRow + { + Category = "Compute", + Severity = util.P95CpuPct < 15 ? "High" : "Medium", + Confidence = "Medium", + Finding = $"CPU over-provisioned ({util.CpuCount} {cpuNoun}, P95 = {util.P95CpuPct:N1}%)", + Detail = $"P95 CPU utilization is {util.P95CpuPct:N1}% (avg {util.AvgCpuPct:N1}%, max {util.MaxCpuPct}%) across {util.CpuCount} {cpuNoun}. " + + $"Consider reducing to ~{targetCores} {cpuNoun}.", + EstMonthlySavings = monthlyCost > 0 ? monthlyCost * savingsPct * 0.60m : null + }; + } + /// /// Runs every monitor-side FinOps recommendation check over the collected store and returns the consolidated /// list sorted by severity. All reads are async I/O (they don't block the UI thread), and each check is @@ -439,23 +466,9 @@ public async Task> GetRecommendationsAsync(int serverId, try { var util = await GetUtilizationEfficiencyAsync(serverId, cancellationToken); - /* A window with no CPU sample reads a P95 of 0, which is "idle" only because nothing was measured. - The utilization row gives that window no verdict (HasCpuSample is false); the advice follows it. */ - if (util != null && util.HasCpuSample && util.P95CpuPct < 30 && util.CpuCount > 4) - { - var targetCores = Math.Max(4, (int)(util.CpuCount * (util.P95CpuPct / 70m))); - var savingsPct = 1m - ((decimal)targetCores / util.CpuCount); - recommendations.Add(new RecommendationRow - { - Category = "Compute", - Severity = util.P95CpuPct < 15 ? "High" : "Medium", - Confidence = "Medium", - Finding = $"CPU over-provisioned ({util.CpuCount} cores, P95 = {util.P95CpuPct:N1}%)", - Detail = $"P95 CPU utilization is {util.P95CpuPct:N1}% (avg {util.AvgCpuPct:N1}%, max {util.MaxCpuPct}%) across {util.CpuCount} cores. " + - $"Consider reducing to ~{targetCores} cores.", - EstMonthlySavings = monthlyCost > 0 ? monthlyCost * savingsPct * 0.60m : null - }); - } + var cpuRecommendation = BuildCpuRightSizingRecommendation(util, monthlyCost); + if (cpuRecommendation != null) + recommendations.Add(cpuRecommendation); } catch (Exception ex) { diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs index 4280f70de..1313f271b 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs @@ -54,10 +54,10 @@ FROM v_memory_stats ORDER BY collection_time DESC LIMIT 1 ), -/* cpu_count is the count the server itself has. On an Azure SQL Database (engine_edition 5) the stored cpu_count - describes the HOST (a 1-vCore serverless database read 2), so there it is the vcore_count parsed from the service - objective, and NULL for an objective that names no vCores (a DTU-model objective or an elastic pool): never the - host count. Every other edition reads as it always did. The same CASE is in the Lite read. */ +/* cpu_count is the count CPU percent is measured against. On an Azure SQL Database (engine_edition 5) the stored cpu_count + is the number of schedulers the database can see (a 1-vCore serverless database read 2), not what it is given, so there it + is the vcore_count parsed from the service objective, and NULL for an objective that names no vCores (a DTU-model objective + or an elastic pool): never the scheduler count. Every other edition reads as it always did. The same CASE is in the Lite read. */ server_info AS ( SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, engine_edition FROM server_properties @@ -120,7 +120,8 @@ LEFT JOIN server_info s ON true var memRatio = reader.IsDBNull(8) ? 0m : Convert.ToDecimal(reader.GetValue(8)); var maxWorkers = reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)); - var currentWorkers = reader.IsDBNull(10) ? 0 : Convert.ToInt32(reader.GetValue(10)); + /* NULL is "not collected" (an Azure SQL Database), not 0 workers in use: it shows as n/a and the verdict reads it as unknown. */ + int? currentWorkers = reader.IsDBNull(10) ? null : Convert.ToInt32(reader.GetValue(10)); /* memory_ratio is still SELECTed and still displayed — it is a real fact about the instance — but it is no longer part of the verdict: Total over Target Server Memory converges at 1.0 on any warmed @@ -209,7 +210,7 @@ GROUP BY CAST(collection_time AS DATE) COALESCE(g.forced_grants, 0), COALESCE(g.grant_utilization_pct, 0), COALESCE(m.max_workers_count, 0), - COALESCE(m.current_workers_count, 0) + m.current_workers_count FROM daily_cpu c LEFT JOIN daily_mem m ON m.day = c.day LEFT JOIN daily_grants g ON g.day = c.day @@ -240,7 +241,7 @@ public async Task> GetProvisioningTrendAsync(int serv forcedGrants: reader.IsDBNull(7) ? 0L : Convert.ToInt64(reader.GetValue(7)), grantUtilizationPercent: reader.IsDBNull(8) ? 0m : Convert.ToDecimal(reader.GetValue(8)), maxWorkers: reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)), - currentWorkers: reader.IsDBNull(10) ? 0 : Convert.ToInt32(reader.GetValue(10))); + currentWorkers: reader.IsDBNull(10) ? (int?)null : Convert.ToInt32(reader.GetValue(10))); items.Add(new ProvisioningTrendRow { diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs index db79874e2..daf329e1d 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs @@ -118,12 +118,15 @@ public sealed class UtilizationEfficiencyRow public decimal GrantUtilizationPct { get; set; } public int MaxWorkersCount { get; set; } - public int CurrentWorkersCount { get; set; } - /// The server's OWN CPU count, 0 when there is none. On an Azure SQL Database ( 5) the - /// stored cpu_count is the HOST's, so this is the vcore_count parsed from the service objective and 0 for an - /// objective that names none (a DTU-model objective or an elastic pool), which the card shows as n/a. It is never the host's - /// count. + /// Workers in use at the latest sample. null where the collector cannot read it (an Azure SQL Database stores + /// NULL), which the card shows as n/a: it is never 0, and the verdict treats it as unknown. + public int? CurrentWorkersCount { get; set; } + + /// The CPU count CPU percent is measured against, 0 when there is none. On an Azure SQL Database + /// ( 5) that is the vcore_count parsed from the service objective, not the stored + /// cpu_count (the schedulers the database can see, which can be higher than its vCores), and it is 0 for an objective + /// that names none (a DTU-model objective or an elastic pool), which the card shows as n/a. public int CpuCount { get; set; } /// The engine edition of the server these figures describe (SERVERPROPERTY('EngineEdition'), 0 when unread). @@ -152,14 +155,16 @@ public sealed class UtilizationEfficiencyRow /// /// 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. + /// SQL Database those are the database's own (its memory limit, not the host's RAM), so the memory term is worked the same way + /// on every edition. A window with no CPU sample ( false) leaves the CPU term out: its p95 is a 0 + /// that came from nothing, and scoring that 0 would hand the server a full 100. /// public int ComputeHealthScore() { var bpRatio = PhysicalMemoryMb > 0 ? (decimal)BufferPoolMb / PhysicalMemoryMb : 0m; + int? cpuScore = HasCpuSample ? FinOpsHealthCalculator.CpuScore(P95CpuPct) : null; return FinOpsHealthCalculator.Overall( - FinOpsHealthCalculator.CpuScore(P95CpuPct), FinOpsHealthCalculator.MemoryScore(bpRatio), FinOpsHealthCalculator.StorageScore(FreeSpacePct)); + cpuScore, FinOpsHealthCalculator.MemoryScore(bpRatio), FinOpsHealthCalculator.StorageScore(FreeSpacePct)); } } @@ -305,9 +310,10 @@ public sealed class ServerPropertyRow public string HostOsVersion { get; set; } = ""; public int EngineEdition { get; set; } - /* The four hardware cells below read as ABSENT for an Azure SQL Database (engine edition 5): its collected - sys.dm_os_sys_info values are the HOST's, not the database's allocation (a 1-vCore serverless database read 2 CPUs, - 0 sockets, 32 cores per socket and 911.9 GB), and the grid draws an absent value as a blank cell. The stored values + /* Three of the four hardware cells below read as ABSENT for an Azure SQL Database (engine edition 5): its collected + sys.dm_os_sys_info memory, socket count and cores per socket are the HOST's, not the database's allocation (a 1-vCore + database read 0 sockets, 32 cores per socket and about 912 GB), and the grid draws an absent value as a blank cell. The + CPU count is the database's own scheduler count (a 1-vCore database reads 2), so it is shown as stored. The stored values are kept behind the properties, so the order the loader assigns them in does not matter and no calculation loses its input. */ private int _cpuCount; @@ -317,7 +323,7 @@ its input. */ private string? _hardwareUnavailableReason; private bool HostHardware => ServerHardwareScope.HardwareIsTheHosts(EngineEdition); - public int? CpuCount { get => HostHardware ? null : _cpuCount; set => _cpuCount = value ?? 0; } + public int? CpuCount { get => _cpuCount; set => _cpuCount = value ?? 0; } public long? PhysicalMemoryMb { get => HostHardware ? null : _physicalMemoryMb; set => _physicalMemoryMb = value ?? 0L; } public int? SocketCount { get => HostHardware ? null : _socketCount; set => _socketCount = value; } public int? CoresPerSocket { get => HostHardware ? null : _coresPerSocket; set => _coresPerSocket = value; } @@ -544,8 +550,20 @@ public static int StorageScore(decimal freeSpacePct) return (int)(freeSpacePct * 5); } - public static int Overall(int cpu, int memory, int storage) => - (int)(cpu * 0.40 + memory * 0.30 + storage * 0.30); + /// + /// The overall score: CPU 40%, memory 30%, storage 30%. A null means the window held no CPU + /// sample: there is nothing to score, and scoring the 0 it reads as would be a full 100 made from nothing. The term is + /// then left out, not scored as zero and not scored as a default, and memory and storage keep their weights over their + /// own total (30:30 over 60). + /// + public static int Overall(int? cpu, int memory, int storage) + { + if (cpu is int cpuScore) + return (int)(cpuScore * 0.40 + memory * 0.30 + storage * 0.30); + + /* integer weights, so no floating-point error can truncate 100 to 99 */ + return (memory * 30 + storage * 30) / 60; + } public static string ScoreColor(int score) => score switch { diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs index 57a17dcd1..11af599bd 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs @@ -125,6 +125,13 @@ width and the nested one multiplies against a contention count that no longer ex /// The Overview summary strip — Lite's UpdateMemorySummary verbatim. private void RenderMemorySummary(MemoryStatsRow? stats) { + /* On an Azure SQL Database the first two figures are the database's memory limit and the room left under it, not the + host's RAM, so they are named that way (the same words the FinOps utilization card uses). The names follow the + registry's edition (_server.EngineEdition), the value the page-file and memory-state lines below read, so one panel + never names a figure one way and shows its neighbor the other. */ + PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(_server.EngineEdition); + AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(_server.EngineEdition); + if (stats == null) { PhysicalMemoryText.Text = "--"; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml index 6e0404915..80442f194 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml @@ -2144,7 +2144,7 @@ - + @@ -2164,7 +2164,7 @@ - + diff --git a/Lite.Tests/AuditConfigEditionTests.cs b/Lite.Tests/AuditConfigEditionTests.cs index afa9aaada..d80d5e60a 100644 --- a/Lite.Tests/AuditConfigEditionTests.cs +++ b/Lite.Tests/AuditConfigEditionTests.cs @@ -118,6 +118,50 @@ public async Task AuditConfig_WithNoConfigRows_AnswersNotCollectedOnAzureSqlDb_A Assert.Contains("may not have run yet", box, StringComparison.Ordinal); } + /// The MAXDOP audit follows the vCores on an Azure SQL Database. The seeded row holds the host's 32 cores per socket + /// beside 4 vCores, so a recommendation taken from cores per socket would read 8 and one taken from the vCores reads 4. An + /// Azure SQL Database collects no server_config in production; the rows are seeded here to reach the recommendation. + [Fact] + public async Task AuditConfig_OnAzureSqlDatabase_RecommendsMaxdopFromTheVcores_NotTheHostsCoresPerSocket() + { + await SeedServerPropertiesAsync(_azureServerId, AzureServerName, CollectorEngineCapability.AzureSqlDatabaseEngineEdition, vcoreCount: 4, coresPerSocket: 32); + await SeedServerConfigAsync(_azureServerId, AzureServerName); + + var json = await McpAnalysisTools.AuditConfig(new AnalysisService(_duckDb), new LocalDataService(_duckDb), _serverManager, AzureServerName); + + var maxdop = MaxdopRecommendation(json); + Assert.Equal(4, maxdop.GetProperty("suggested_value").GetInt32()); + var text = maxdop.GetProperty("recommendation").GetString()!; + Assert.Contains("Start with 4 (this database's vCores, capped at 8)", text, StringComparison.Ordinal); + Assert.DoesNotContain("cores-per-socket", text, StringComparison.Ordinal); + } + + /// The same rows on SQL Server recommend from cores per socket, as they always did. + [Fact] + public async Task AuditConfig_OffAzureSqlDatabase_RecommendsMaxdopFromCoresPerSocket_AsItAlwaysDid() + { + await SeedServerPropertiesAsync(_boxServerId, BoxServerName, engineEdition: 3, vcoreCount: null, coresPerSocket: 6); + await SeedServerConfigAsync(_boxServerId, BoxServerName); + + var json = await McpAnalysisTools.AuditConfig(new AnalysisService(_duckDb), new LocalDataService(_duckDb), _serverManager, BoxServerName); + + var maxdop = MaxdopRecommendation(json); + Assert.Equal(6, maxdop.GetProperty("suggested_value").GetInt32()); + Assert.Contains("Start with 6 (this server's cores-per-socket, capped at 8)", maxdop.GetProperty("recommendation").GetString(), StringComparison.Ordinal); + } + + private static JsonElement MaxdopRecommendation(string json) + { + using var doc = JsonDocument.Parse(json); + foreach (var r in doc.RootElement.GetProperty("recommendations").EnumerateArray()) + { + if (r.GetProperty("setting").GetString() == "max degree of parallelism") + return r.Clone(); + } + + throw new Xunit.Sdk.XunitException("audit_config returned no MAXDOP recommendation: " + json); + } + private static string StatusOf(string json) => JsonDocument.Parse(json).RootElement.GetProperty("status").GetString()!; @@ -134,7 +178,7 @@ private async Task SeedConnectionAsync() /// One collected server_properties row: the NOT NULL edition and hardware columns are /// filled with values nothing here reads, plus the one column these tests vary. - private async Task SeedServerPropertiesAsync(int serverId, string serverName, int engineEdition) + private async Task SeedServerPropertiesAsync(int serverId, string serverName, int engineEdition, int? vcoreCount = null, int? coresPerSocket = null) { using var readLock = _duckDb.AcquireReadLock(); var connection = await SeedConnectionAsync(); @@ -144,13 +188,15 @@ private async Task SeedServerPropertiesAsync(int serverId, string serverName, in INSERT INTO server_properties (collection_id, collection_time, server_id, server_name, edition, product_version, product_level, engine_edition, - cpu_count, hyperthread_ratio, physical_memory_mb) -VALUES ($1, $2, $3, $4, 'Test Edition', '16.0.4150.1', 'RTM', $5, 8, 1, 16384)"; + cpu_count, hyperthread_ratio, physical_memory_mb, cores_per_socket, vcore_count) +VALUES ($1, $2, $3, $4, 'Test Edition', '16.0.4150.1', 'RTM', $5, 8, 1, 16384, $6, $7)"; cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); cmd.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow }); cmd.Parameters.Add(new DuckDBParameter { Value = serverId }); cmd.Parameters.Add(new DuckDBParameter { Value = serverName }); cmd.Parameters.Add(new DuckDBParameter { Value = engineEdition }); + cmd.Parameters.Add(new DuckDBParameter { Value = (object?)coresPerSocket ?? DBNull.Value }); + cmd.Parameters.Add(new DuckDBParameter { Value = (object?)vcoreCount ?? DBNull.Value }); await cmd.ExecuteNonQueryAsync(); } diff --git a/Lite.Tests/AzureSqlDatabaseCallSiteTests.cs b/Lite.Tests/AzureSqlDatabaseCallSiteTests.cs index cd3e0b18f..dcca429f4 100644 --- a/Lite.Tests/AzureSqlDatabaseCallSiteTests.cs +++ b/Lite.Tests/AzureSqlDatabaseCallSiteTests.cs @@ -89,6 +89,28 @@ public void TheMemoryOverview_ReadsNotApplicable_ThroughTheHelpers() Assert.Contains("MemoryStateText.Text = SystemMemoryStateText(stats.SystemMemoryState, _isAzureSqlDatabase);", body, StringComparison.Ordinal); } + /// + /// One edition for the whole Memory Overview panel. The two captions over its first figures take _engineEdition, and the + /// page-file and memory-state lines beside them take _isAzureSqlDatabase, which is that same field compared with edition + /// 5. So the panel cannot name a figure "Physical Memory" above a page file of "n/a". Nothing in the method reads an edition + /// off the memory row. + /// + [Fact] + public void TheMemoryOverview_ReadsTheTabsOwnEdition_ForItsCaptionsAndForItsOtherLines() + { + var body = MethodBody("Lite/Controls/ServerTab.Charts.cs", "void UpdateMemorySummary("); + + Assert.Contains("PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(_engineEdition);", body, StringComparison.Ordinal); + Assert.Contains("AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(_engineEdition);", body, StringComparison.Ordinal); + Assert.Contains("private bool _isAzureSqlDatabase => _engineEdition == ServerHardwareScope.AzureSqlDatabaseEngineEdition;", Code("Lite/Controls/ServerTab.xaml.cs"), StringComparison.Ordinal); + + /* Five lines read an edition: two spell it _engineEdition and three spell it _isAzureSqlDatabase. No other source is + read in the method, and a property read off the row (stats.EngineEdition and the like) would show up as EngineEdition. */ + Assert.Equal(2, CountOf(body, "_engineEdition")); + Assert.Equal(3, CountOf(body, "_isAzureSqlDatabase")); + Assert.DoesNotContain("EngineEdition", body, StringComparison.Ordinal); + } + /// /// The tab holds the connection check's edition as a number, so a failed check (0) is told apart from a box, and /// every tab load fills it from the store before any loader reads it. @@ -112,6 +134,47 @@ public void TheTab_FallsBackToTheStoredEdition_BeforeEveryLoad() Assert.Contains("ResolveEngineEditionAsync(_engineEdition, () => Task.Run(() => _dataService.GetSqlEngineEditionAsync(_serverId)))", refresh, StringComparison.Ordinal); } + /// + /// get_memory_stats has ONE edition, read ONCE, through McpEngineCapability.EngineEditionAsync: the newest collected + /// server_properties row, which is also what NotCollectedStatusAsync and every other Lite MCP gate read. The + /// payload's engine_edition, memory_note and memory-state pair are all built from that value, so the tool cannot + /// disagree with its own gate. The memory row carries no edition at all. The payload's answers are pinned in + /// AzureSqlDatabaseMemoryScopeTests; this pins where the value comes from. + /// + [Fact] + public void GetMemoryStats_ReadsTheEditionOnce_FromTheEngineCapabilitySource_AndEverythingEditionDependentFollowsIt() + { + const string ToolsFile = "Lite/Mcp/McpMemoryTools.cs"; + const string CapabilityFile = "Lite/Mcp/McpEngineCapability.cs"; + + var body = MethodBody(ToolsFile, "Task GetMemoryStats("); + var payload = MethodBody(ToolsFile, "string MemoryStatsPayload("); + + /* One read in the tool, and no edition off the row (it has none). */ + Assert.Equal(1, CountOf(body, "EngineEditionAsync(")); + Assert.Contains("var engineEdition = await McpEngineCapability.EngineEditionAsync(dataService, resolved.ServerId);", body, StringComparison.Ordinal); + Assert.Contains("return MemoryStatsPayload(resolved.ServerName, stats, engineEdition);", body, StringComparison.Ordinal); + Assert.DoesNotContain("stats.EngineEdition", body + payload, StringComparison.Ordinal); + + /* Every edition-dependent line of the payload reads the one value it was handed. */ + Assert.Contains("system_memory_state = ServerHardwareScope.MemoryStateOrNull(engineEdition, stats.SystemMemoryState),", payload, StringComparison.Ordinal); + Assert.Contains("system_memory_state_note = ServerHardwareScope.MemoryStateNoteFor(engineEdition),", payload, StringComparison.Ordinal); + Assert.Contains("engine_edition = engineEdition == CollectorEngineCapability.UnknownEngineEdition ? (int?)null : engineEdition", payload, StringComparison.Ordinal); + Assert.Contains("if (!ServerHardwareScope.HardwareIsTheHosts(engineEdition))", payload, StringComparison.Ordinal); + + /* That source is the one the not_collected gate reads: the newest collected server_properties row. */ + Assert.Contains("return await dataService.GetSqlEngineEditionAsync(serverId);", MethodBody(CapabilityFile, "Task EngineEditionAsync("), StringComparison.Ordinal); + Assert.Contains("var engineEdition = await EngineEditionAsync(dataService, serverId);", MethodBody(CapabilityFile, "Task NotCollectedStatusAsync("), StringComparison.Ordinal); + } + + 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; + } + /// The body of the method whose declaration contains , from its opening brace. private static string MethodBody(string file, string anchor) { diff --git a/Lite.Tests/AzureSqlDatabaseHardwareTests.cs b/Lite.Tests/AzureSqlDatabaseHardwareTests.cs index b4f23fc71..26caeec03 100644 --- a/Lite.Tests/AzureSqlDatabaseHardwareTests.cs +++ b/Lite.Tests/AzureSqlDatabaseHardwareTests.cs @@ -21,11 +21,12 @@ namespace PerformanceMonitorLite.Tests; /// -/// On an Azure SQL Database (engine edition 5) the collected server_properties hardware columns are the -/// HOST's: a 1-vCore serverless General Purpose database read 2 logical CPUs, 0 sockets, 32 cores per socket, a -/// hyperthread ratio of 64 and 911.9 GB of physical memory. Nothing may present them as the database's. What does -/// describe the database is the service objective and the vcore_count parsed from it. The memory_stats table is -/// a different source: its memory figures are the database's own and are pinned in . +/// On an Azure SQL Database (engine edition 5) four of the collected server_properties hardware columns are the +/// HOST's: a 1-vCore serverless General Purpose database read 0 sockets, 32 cores per socket, a hyperthread ratio of 64 and +/// 911.9 GB of physical memory. Nothing may present those as the database's. The fifth column, cpu_count, is the database's +/// own scheduler count (that database read 2), so it is shown as read. What describes the allocation is the service objective and +/// the vcore_count parsed from it. The memory_stats table is a different source: its memory figures are the database's +/// own and are pinned in . /// /// The rule is pinned where it is applied: get_server_properties (the payload the web Server Properties /// list reads too), the FinOps Server Inventory row the grid binds, and the words the FinOps utilization card takes from @@ -65,26 +66,31 @@ public AzureSqlDatabaseHardwareTests(SharedDuckDbFixture fixture) private static JsonElement Payload(ServerPropertiesRow row) => JsonDocument.Parse(McpServerInfoTools.ServerPropertiesPayload("Srv", row)).RootElement.Clone(); + /// The four columns that describe the host on an Azure SQL Database. cpu_count is not among them. private static readonly string[] s_hostKeys = - ["cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; + ["hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; // ── get_server_properties ── [Fact] - public void GetServerProperties_OnAzureSqlDatabase_ReturnsTheHostsFiveAsNull_AndTheDatabasesVcores_WithANote() + public void GetServerProperties_OnAzureSqlDatabase_ReturnsTheHostsFourAsNull_PassesItsOwnCpuCountThrough_AndReturnsTheVcores_WithANote() { var json = Payload(HostRow(5, "SQL Azure", "GP_S_Gen5_1", 1)); foreach (var key in s_hostKeys) Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + /* The stored count is the database's own scheduler count (2 for this 1-vCore database), not the host's, and not the vCores. */ + Assert.Equal(2, json.GetProperty("cpu_count").GetInt32()); + Assert.Equal("GP_S_Gen5_1", json.GetProperty("service_objective").GetString()); Assert.Equal(1, json.GetProperty("vcore_count").GetInt32()); var note = json.GetProperty("hardware_note").GetString(); Assert.Equal(ServerHardwareScope.McpHardwareNote, note); Assert.Contains("service_objective", note, StringComparison.Ordinal); Assert.Contains("vcore_count", note, StringComparison.Ordinal); - Assert.Contains("not this database's allocation", note, StringComparison.Ordinal); + Assert.Contains("describe the host machine, not this database", note, StringComparison.Ordinal); + Assert.Contains("cpu_count is the database's own scheduler count", note, StringComparison.Ordinal); /* vcore_count sits beside service_objective, and the rest of the payload is still there. */ var names = json.EnumerateObject().Select(p => p.Name).ToList(); @@ -94,12 +100,13 @@ public void GetServerProperties_OnAzureSqlDatabase_ReturnsTheHostsFiveAsNull_And } [Fact] - public void GetServerProperties_OnAzureSqlDatabase_WithNoVcores_StillHidesTheHost_AndReturnsANullVcoreCount() + public void GetServerProperties_OnAzureSqlDatabase_WithNoVcores_StillHidesTheHost_KeepsItsCpuCount_AndReturnsANullVcoreCount() { var json = Payload(HostRow(5, "SQL Azure", "S0", null)); foreach (var key in s_hostKeys) Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + Assert.Equal(2, json.GetProperty("cpu_count").GetInt32()); Assert.Equal(JsonValueKind.Null, json.GetProperty("vcore_count").ValueKind); Assert.True(json.TryGetProperty("hardware_note", out _)); } @@ -165,30 +172,33 @@ INSERT INTO server_properties // ── FinOps Server Inventory row ── [Fact] - public void InventoryRow_OnAzureSqlDatabase_LeavesTheMemoryAndCoreCellsBlank_AndSaysWhy() + public void InventoryRow_OnAzureSqlDatabase_LeavesTheHostCellsBlank_ShowsItsOwnCpuCount_AndSaysWhy() { var row = new ServerPropertyRow { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, PhysicalMemoryMb = 933_888, SocketCount = 0, CoresPerSocket = 32, }; - Assert.Null(row.CpuCount); + Assert.Equal(2, row.CpuCount); Assert.Null(row.PhysicalMemoryMb); Assert.Null(row.SocketCount); Assert.Null(row.CoresPerSocket); Assert.Equal(ServerHardwareScope.InventoryHardwareNote, row.HardwareUnavailableReason); + Assert.Contains("memory, sockets, cores per socket and hyperthread ratio are the host's", row.HardwareUnavailableReason, StringComparison.Ordinal); + Assert.Contains("Logical CPUs is the database's own scheduler count", row.HardwareUnavailableReason, StringComparison.Ordinal); } [Fact] public void InventoryRow_OnAzureSqlDatabase_DoesNotDependOnTheOrderTheLoaderAssignsInAndKeepsAReadsOwnReason() { var row = new ServerPropertyRow { CpuCount = 2, PhysicalMemoryMb = 933_888, SocketCount = 0, CoresPerSocket = 32 }; - Assert.Equal(2, row.CpuCount); + Assert.Equal(933_888L, row.PhysicalMemoryMb); row.EngineEdition = 5; row.HardwareUnavailableReason = "Hardware read denied"; - Assert.Null(row.CpuCount); + Assert.Null(row.PhysicalMemoryMb); + Assert.Equal(2, row.CpuCount); Assert.Equal("Hardware read denied", row.HardwareUnavailableReason); } diff --git a/Lite.Tests/AzureSqlDatabaseHostMathTests.cs b/Lite.Tests/AzureSqlDatabaseHostMathTests.cs index 9a2951daa..393cfec8f 100644 --- a/Lite.Tests/AzureSqlDatabaseHostMathTests.cs +++ b/Lite.Tests/AzureSqlDatabaseHostMathTests.cs @@ -19,15 +19,16 @@ namespace PerformanceMonitorLite.Tests; /// -/// On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info describes the HOST: a 1-vCore serverless -/// General Purpose database read 2 logical CPUs and 911.9 GB of physical memory. -/// pins that nothing SHOWS those server_properties values as the database's. These pins are the calculations that USED -/// the host's CPU count: the attributed-CPU denominator and the FinOps utilization card's CPU count. The memory figures come -/// from a different table and are pinned in . +/// On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info reports the HOST's memory, sockets and cores per +/// socket, while its cpu_count is the database's own scheduler count, which is not the CPU the database is given: a +/// 1-vCore serverless General Purpose database read 2. pins what is SHOWN. These +/// pins are the calculations that must count the CPU the database is GIVEN (its vCores) and not the schedulers it can see: +/// the attributed-CPU denominator and the FinOps utilization card's CPU count. The memory figures come from a different +/// table and are pinned in . /// /// The rule: on an Azure SQL Database each of those uses the database's own figure where one is collected (the /// vcore_count parsed from the service objective) and is otherwise NOT APPLICABLE. A DTU-model objective or an elastic -/// pool has no vCore count, so its CPU count is not applicable and nothing is computed from the host's. SQL Server +/// pool has no vCore count, so its CPU count is not applicable and nothing is computed from the stored scheduler count. SQL Server /// (editions 1 to 4) and Managed Instance (8) behave exactly as before, and every test has that twin. The Darling.Tests twin /// pins the same table for the other app, in the same words. /// @@ -50,7 +51,7 @@ public AzureSqlDatabaseHostMathTests(SharedDuckDbFixture fixture) // ── CPU attribution ── - /// Half of one CPU for an hour is 1,800 CPU-seconds; half of the host's two would be 3,600. + /// Half of one CPU for an hour is 1,800 CPU-seconds; half of the two schedulers the database can see would be 3,600. private static CpuAttribution.Result Attribute(int? engineEdition, int storedCpuCount, int? vcoreCount) => CpuAttribution.Compute( rankedCpuSeconds: 900, s_start, s_end, @@ -58,7 +59,7 @@ private static CpuAttribution.Result Attribute(int? engineEdition, int storedCpu engineEdition, storedCpuCount, vcoreCount); [Fact] - public void Attribution_OnAzureSqlDatabase_WithVcores_DividesByTheVcores_NotTheHostsCpus() + public void Attribution_OnAzureSqlDatabase_WithVcores_DividesByTheVcores_NotTheStoredSchedulerCount() { var result = Attribute(5, storedCpuCount: 2, vcoreCount: 1); @@ -70,7 +71,7 @@ public void Attribution_OnAzureSqlDatabase_WithVcores_DividesByTheVcores_NotTheH [Theory] [InlineData(null)] [InlineData(0)] - public void Attribution_OnAzureSqlDatabase_WithNoVcores_IsNotApplicable_AndComputesNothingFromTheHost(int? vcoreCount) + public void Attribution_OnAzureSqlDatabase_WithNoVcores_IsNotApplicable_AndComputesNothingFromTheStoredCount(int? vcoreCount) { var result = Attribute(5, storedCpuCount: 2, vcoreCount); @@ -154,13 +155,13 @@ public void CpuCountText_OnEveryOtherEdition_IsTheCountAsItAlwaysWas(int? engine } [Theory] - [InlineData(5, 1, 1)] // Azure SQL Database, vCore objective: its vCores, not the host's 2 - [InlineData(5, null, 0)] // Azure SQL Database, DTU-model objective or elastic pool: no count, never the host's 2 + [InlineData(5, 1, 1)] // Azure SQL Database, vCore objective: its vCores, not the 2 schedulers it can see + [InlineData(5, null, 0)] // Azure SQL Database, DTU-model objective or elastic pool: no count, never the stored 2 [InlineData(3, null, 2)] // SQL Server: the stored count [InlineData(8, null, 2)] // Managed Instance: the stored count public async Task UtilizationRead_ResolvesTheCpuCountThroughTheEdition(int engineEdition, int? vcoreCount, int expectedCpuCount) { - await SeedAsync(engineEdition, hostCpuCount: 2, vcoreCount); + await SeedAsync(engineEdition, storedCpuCount: 2, vcoreCount); var row = await new LocalDataService(_fixture.DuckDb).GetUtilizationEfficiencyAsync(ServerId); @@ -177,6 +178,7 @@ public async Task UtilizationRead_ResolvesTheCpuCountThroughTheEdition(int engin private static UtilizationEfficiencyRow Utilization(int engineEdition, int bufferPoolMb, int physicalMemoryMb) => new() { EngineEdition = engineEdition, + ProvisioningStatus = ProvisioningVerdict.RightSized, // a measured window: a window with no CPU sample has no CPU term P95CpuPct = 7m, BufferPoolMb = bufferPoolMb, PhysicalMemoryMb = physicalMemoryMb, @@ -235,7 +237,7 @@ public void FinOpsUtilizationCard_AsksTheSharedRule_ForTheCpuCountAndTheHealthSc // ── seeding ── - private async Task SeedAsync(int engineEdition, int hostCpuCount, int? vcoreCount) + private async Task SeedAsync(int engineEdition, int storedCpuCount, int? vcoreCount) { using var readLock = _fixture.DuckDb.AcquireReadLock(); if (_seedConn is null) @@ -253,11 +255,11 @@ INSERT INTO server_properties VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)"; void P(object? v) => cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); P(-487_002L); P(DateTime.UtcNow); P(ServerId); P("AzureHostMathSrv"); P("SQL Azure"); P("12.0.2000.8"); P("RTM"); - P(engineEdition); P(hostCpuCount); P(64); P(933_888L); P(0); P(32); P(vcoreCount.HasValue ? "GP_S_Gen5_" + vcoreCount : "S0"); P(vcoreCount); + P(engineEdition); P(storedCpuCount); P(64); P(933_888L); P(0); P(32); P(vcoreCount.HasValue ? "GP_S_Gen5_" + vcoreCount : "S0"); P(vcoreCount); await cmd.ExecuteNonQueryAsync(); } - /* memory_stats is not the host's table: on an Azure SQL Database its total_physical_memory_mb is the database's own + /* memory_stats is not the table that holds the host's memory: on an Azure SQL Database its total_physical_memory_mb is the database's own memory limit (committed_target_kb), 1,838 MB for a 1-vCore General Purpose database. */ using (var cmd = _seedConn.CreateCommand()) { diff --git a/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs b/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs index 9b6a072ab..677f579fb 100644 --- a/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs +++ b/Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs @@ -30,7 +30,8 @@ namespace PerformanceMonitorLite.Tests; /// /// So the FinOps utilization card's Physical Memory and Buffer Pool %, its verdict sentences and the health score's memory /// term, which all read memory_stats, are shown and scored on an Azure SQL Database exactly as on SQL Server. What reads -/// server_properties (get_server_properties, the Server Inventory hardware cells) still hides the host's values. +/// server_properties (get_server_properties, the Server Inventory hardware cells) still hides the host's memory, +/// sockets, cores per socket and hyperthread ratio. /// Every test seeds BOTH tables with different values (1,838 MB and 933,836 MB), so a read that takes the wrong table shows up /// as the wrong number. The Darling.Tests twin pins the same table for the other app, in the same words. /// @@ -82,6 +83,7 @@ public async Task UtilizationRead_TakesTheMemoryFiguresFromMemoryStats_OnEveryEd /// would be 0.1%, which scores 60 and gives 86. Left out altogether it gives 97. private static UtilizationEfficiencyRow Scored(UtilizationEfficiencyRow row) { + row.ProvisioningStatus = ProvisioningVerdict.RightSized; // a measured window: a window with no CPU sample has no CPU term row.P95CpuPct = 7m; row.FreeSpacePct = 50m; return row; @@ -166,13 +168,14 @@ public void OverProvisionedSentence_OnAzureSqlDatabase_CitesTheBufferPoolShare_O onBox); } - // ── what reads server_properties stays the host's, and stays hidden ── + // ── what reads server_properties hides the host's four figures ── + /// The four columns that describe the host on an Azure SQL Database. cpu_count is not among them. private static readonly string[] s_hostKeys = - ["cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; + ["hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; [Fact] - public async Task ServerPropertiesReads_OnAzureSqlDatabase_StayTheHostsAndNull_WhateverMemoryStatsHolds() + public async Task ServerPropertiesReads_OnAzureSqlDatabase_HideTheHostsFourFigures_AndShowTheDatabasesOwnCpuCount() { await SeedAsync(engineEdition: 5); @@ -186,6 +189,7 @@ public async Task ServerPropertiesReads_OnAzureSqlDatabase_StayTheHostsAndNull_W var json = JsonDocument.Parse(McpServerInfoTools.ServerPropertiesPayload("Srv", stored)).RootElement; foreach (var key in s_hostKeys) Assert.Equal(JsonValueKind.Null, json.GetProperty(key).ValueKind); + Assert.Equal(2, json.GetProperty("cpu_count").GetInt32()); /* The FinOps Server Inventory row. */ var inventory = new ServerPropertyRow @@ -193,7 +197,7 @@ public async Task ServerPropertiesReads_OnAzureSqlDatabase_StayTheHostsAndNull_W EngineEdition = stored.EngineEdition, CpuCount = stored.CpuCount, PhysicalMemoryMb = stored.PhysicalMemoryMb, SocketCount = stored.SocketCount, CoresPerSocket = stored.CoresPerSocket, }; - Assert.Null(inventory.CpuCount); + Assert.Equal(2, inventory.CpuCount); Assert.Null(inventory.PhysicalMemoryMb); Assert.Null(inventory.SocketCount); Assert.Null(inventory.CoresPerSocket); @@ -236,9 +240,10 @@ public void FinOpsUtilizationCard_ShowsPhysicalMemoryAndTheBufferPoolShare_OnEve 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. */ + /* The health score has its memory term everywhere, so there is no tooltip explaining an absent one. The only tooltip on it + explains an absent CPU term (a window with no CPU sample), and it is not keyed on the edition. */ Assert.DoesNotContain("HealthScoreWithoutMemoryNote", tab, StringComparison.Ordinal); - Assert.DoesNotContain("HealthScoreBorder.ToolTip", tab, StringComparison.Ordinal); + Assert.DoesNotContain("HealthScoreBorder.ToolTip = azureSqlDb", tab, StringComparison.Ordinal); Assert.Contains("data.HealthScore = data.ComputeHealthScore();", tab, StringComparison.Ordinal); } @@ -303,6 +308,154 @@ public void MemoryAndVmRules_OnAzureSqlDatabase_SayWhyTheyStandDown_AndDoNotCall Assert.Contains("its cores and memory come with its service objective", rules, StringComparison.Ordinal); } + // ── get_memory_stats: the keys keep their names, so an Azure SQL Database's payload says what they mean ── + + /// The row GetLatestMemoryStatsAsync returns. It carries no edition: the payload takes the one the tool read. + private static MemoryStatsRow StatsRow(double totalMb, double availableMb) => new() + { + CollectionTime = new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), + TotalPhysicalMemoryMb = totalMb, + AvailablePhysicalMemoryMb = availableMb, + SystemMemoryState = "Available physical memory is high", + SqlMemoryModel = "CONVENTIONAL", + TargetServerMemoryMb = totalMb, + TotalServerMemoryMb = totalMb - availableMb, + BufferPoolMb = 1_100, + PlanCacheMb = 200, + }; + + /// The payload for the ONE edition the tool reads (). + private static JsonElement MemoryPayload(MemoryStatsRow stats, int engineEdition) => + JsonDocument.Parse(McpMemoryTools.MemoryStatsPayload("Srv", stats, engineEdition)).RootElement.Clone(); + + private static JsonElement MemoryPayload(int engineEdition, double totalMb, double availableMb) => + MemoryPayload(StatsRow(totalMb, availableMb), engineEdition); + + [Fact] + public void GetMemoryStats_OnAzureSqlDatabase_CarriesAMemoryNote_ThatCallsTheTotalTheDatabasesLimit_AndNearFullNormal() + { + /* A database that has grown to its limit has nothing left under it, so it reads 100% in use. On this edition that is the + normal state, and the note says so, because the same figure on SQL Server is an operating system short of memory. */ + var json = MemoryPayload(5, DatabaseMemoryLimitMb, 0); + + var note = json.GetProperty("memory_note").GetString(); + Assert.Equal(ServerHardwareScope.McpMemoryNote, note); + Assert.Contains("total_physical_memory_mb is the database's memory limit (its committed target), not the host's memory", note, StringComparison.Ordinal); + Assert.Contains("available_physical_memory_mb is what is left under that limit", note, StringComparison.Ordinal); + Assert.Contains("a value near 100% is normal once the database has grown to its limit", note, StringComparison.Ordinal); + Assert.Contains("not memory pressure by itself", note, StringComparison.Ordinal); + + /* The figures and their names are as they were, and the note comes last. */ + Assert.Equal(DatabaseMemoryLimitMb, json.GetProperty("total_physical_memory_mb").GetDouble()); + Assert.Equal(0, json.GetProperty("available_physical_memory_mb").GetDouble()); + Assert.Equal(100, json.GetProperty("memory_utilization_pct").GetDouble()); + Assert.Equal(5, json.GetProperty("engine_edition").GetInt32()); + Assert.Equal("memory_note", json.EnumerateObject().Last().Name); + + /* The memory state is the constant "Available" the collector stores there, which is not a reading: null, with its note. */ + Assert.Equal(JsonValueKind.Null, json.GetProperty("system_memory_state").ValueKind); + Assert.Equal(ServerHardwareScope.MemoryStateNote, json.GetProperty("system_memory_state_note").GetString()); + } + + [Theory] + [InlineData(3)] + [InlineData(8)] + [InlineData(0)] + public void GetMemoryStats_OffAzureSqlDatabase_KeepsTheStoredState_AndCarriesNoNotes(int engineEdition) + { + var json = MemoryPayload(engineEdition, 65_536, 16_384); + + Assert.False(json.TryGetProperty("memory_note", out _), "an engine that is not an Azure SQL Database gets no memory note"); + Assert.Equal("Available physical memory is high", json.GetProperty("system_memory_state").GetString()); + Assert.Equal(JsonValueKind.Null, json.GetProperty("system_memory_state_note").ValueKind); + Assert.Equal(75, json.GetProperty("memory_utilization_pct").GetDouble()); + + /* An unknown edition (0) publishes no edition at all rather than the number 0. */ + if (engineEdition == 0) + Assert.Equal(JsonValueKind.Null, json.GetProperty("engine_edition").ValueKind); + else + Assert.Equal(engineEdition, json.GetProperty("engine_edition").GetInt32()); + + Assert.Equal( + new[] + { + "server", "captured_at", "total_physical_memory_mb", "available_physical_memory_mb", "memory_utilization_pct", + "system_memory_state", "system_memory_state_note", "sql_memory_model", "target_server_memory_mb", + "total_server_memory_mb", "buffer_pool_mb", "plan_cache_mb", "engine_edition", + }, + json.EnumerateObject().Select(p => p.Name).ToArray()); + } + + [Theory] + [InlineData(5)] + [InlineData(3)] + [InlineData(8)] + [InlineData(0)] + public void GetMemoryStats_EngineEdition_MemoryNote_AndTheStateNote_AllFollowTheOneEditionTheToolReads(int toolEdition) + { + /* The payload is built from the one edition the tool read from McpEngineCapability and from nothing else: + engine_edition, the memory note, the state and the state's note all flip together with it. */ + var json = MemoryPayload(toolEdition, DatabaseMemoryLimitMb, 500); + + var azure = toolEdition == 5; + Assert.Equal(azure, json.TryGetProperty("memory_note", out _)); + Assert.Equal(azure, json.GetProperty("system_memory_state").ValueKind == JsonValueKind.Null); + Assert.Equal(azure, json.GetProperty("system_memory_state_note").ValueKind == JsonValueKind.String); + Assert.Equal(toolEdition == 0 ? JsonValueKind.Null : JsonValueKind.Number, json.GetProperty("engine_edition").ValueKind); + if (toolEdition != 0) + Assert.Equal(toolEdition, json.GetProperty("engine_edition").GetInt32()); + } + + [Fact] + public void TheLatestMemoryRead_CarriesNoEngineEdition_SoNoSecondSourceCanDisagreeWithTheTabsOrTheToolsOwn() + { + /* Each surface that names the memory figures reads ONE edition of its own: the Memory tab the tab's (the same one its + page-file and memory-state lines read), get_memory_stats the one McpEngineCapability reads. A server_properties + subselect beside the memory figures would be a second source for the same answer, so the read carries none and the + row has no member to hold one. */ + var service = ReadRepoFile("Lite/Services/LocalDataService.Memory.cs"); + var start = service.IndexOf("Task GetLatestMemoryStatsAsync(", StringComparison.Ordinal); + Assert.True(start > 0, "GetLatestMemoryStatsAsync is missing"); + var memoryRead = service[start..service.IndexOf("GetMemoryTrendAsync(", start, StringComparison.Ordinal)]; + + Assert.Contains("FROM v_memory_stats", memoryRead, StringComparison.Ordinal); + Assert.DoesNotContain("server_properties", memoryRead, StringComparison.Ordinal); + Assert.DoesNotContain("engine_edition", memoryRead, StringComparison.Ordinal); + Assert.Null(typeof(MemoryStatsRow).GetProperty("EngineEdition")); + } + + [Theory] + [InlineData(5, true)] + [InlineData(3, false)] + public async Task GetMemoryStats_ReadFromTheStore_CarriesTheNoteOnlyWhenTheStoredEngineEditionIsAnAzureSqlDatabase(int engineEdition, bool carriesNote) + { + /* The tool's one edition is McpEngineCapability.EngineEditionAsync: the newest collected server_properties row, which is + what every Lite MCP engine gate reads, so the note follows what the collector stored and not anything the tool is told. */ + await SeedAsync(engineEdition); + + var service = new LocalDataService(_fixture.DuckDb); + var stats = await service.GetLatestMemoryStatsAsync(ServerId); + Assert.NotNull(stats); + var storedEdition = await McpEngineCapability.EngineEditionAsync(service, ServerId); + Assert.Equal(engineEdition, storedEdition); + + var json = MemoryPayload(stats!, storedEdition); + + Assert.Equal(carriesNote, json.TryGetProperty("memory_note", out _)); + Assert.Equal(carriesNote, json.GetProperty("system_memory_state").ValueKind == JsonValueKind.Null); + Assert.Equal(DatabaseMemoryLimitMb, json.GetProperty("total_physical_memory_mb").GetDouble()); + Assert.Equal(engineEdition, json.GetProperty("engine_edition").GetInt32()); + } + + [Fact] + public void GetMemoryStatsTool_BuildsItsPayloadThroughTheSharedNote() + { + var tool = ReadRepoFile("Lite/Mcp/McpMemoryTools.cs"); + + Assert.Contains("return MemoryStatsPayload(resolved.ServerName, stats, engineEdition);", tool, StringComparison.Ordinal); + Assert.Contains("ServerHardwareScope.WithMemoryNote(", tool, StringComparison.Ordinal); + } + // ── seeding ── private async Task SeedAsync(int engineEdition) diff --git a/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs b/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs new file mode 100644 index 000000000..adf291f5a --- /dev/null +++ b/Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs @@ -0,0 +1,890 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Common; +using PerformanceMonitor.PlanAnalysis; +using PerformanceMonitorLite.Analysis; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// What left. On an Azure SQL Database (engine edition 5) sys.dm_os_sys_info +/// returns four figures that describe the HOST machine and not the database: physical_memory_mb (about 912 GB), +/// socket_count, cores_per_socket and hyperthread_ratio. Nothing may compute from them there. The other two +/// columns it fills are the database's own: cpu_count is the number of schedulers the database can see (a 1-vCore General +/// Purpose database reads 2, and the count can be higher than the vCores) and max_workers_count is the database's own worker +/// ceiling, so both are shown as stored. What a CPU percent, a CPU count shown beside one, and a recommended MAXDOP are taken from +/// is NOT cpu_count: it is the vCores the service objective gives the database (vcore_count), and an objective that +/// names none (a DTU-model objective or an elastic pool) has no such count, so those answers read n/a. +/// +/// Pinned here, each with an edition 5 case that has vCores, an edition 5 case without them, and the SQL Server / Managed +/// Instance twin that must not move: +/// +/// the SERVER_HARDWARE analysis fact (the vCores and none of the host's figures), the MAXDOP recommendation read from it, the +/// LPIM advisory that reads its memory, and the plan Server Context card; +/// the FinOps Worker Threads card: the in-use count is n/a where the collector could not read it (NULL), never 0, and the +/// ceiling is shown as stored; +/// the memory utilization percentage, which is NOT changed: it divides memory_stats columns, which the collector +/// fills from the database's own committed target, not from server_properties.physical_memory_mb; +/// the FinOps health score's CPU term, which is left out when the window holds no CPU sample (any edition); +/// the words over the Memory tab's first two figures, which on an Azure SQL Database are the database's memory limit and the +/// room left under it. +/// +/// +/// memory_stats seeds on edition 5 use what the collector stores there: the database's own memory limit +/// (1,838 MB for a 1-vCore General Purpose database), never the host's 911.9 GB. The Darling.Tests twin pins the same table for +/// the other app, in the same words. +/// +public sealed class AzureSqlDatabaseOwnFiguresTests +{ + // ── 1. SERVER_HARDWARE fact, MAXDOP recommendation, LPIM advisory, Server Context card ── + + private static Fact HardwareFact(bool azureSqlDatabase, int cpuCount, int coresPerSocket) => + FactCollectorHelpers.BuildServerHardwareFact( + TestDataSeeder.CreateTestContext(), hardwareIsTheHosts: azureSqlDatabase, + cpuCount: cpuCount, hyperthreadRatio: 64, physicalMemoryMb: 933_836, socketCount: 0, coresPerSocket: coresPerSocket, + hadrEnabled: false)!; + + private static Dictionary Facts(params Fact[] facts) => facts.ToDictionary(f => f.Key, f => f); + + private static Fact Config(string key, double value) => new() { Source = "config", Key = key, Value = value }; + + [Fact] + public void ServerHardwareFact_OnAzureSqlDatabase_CarriesTheVcoresAndHadrOnly_NotTheHostsTopologyOrMemory() + { + /* The host's four figures go in (hyperthread ratio 64, 933,836 MB, 0 sockets, 32 cores per socket); none may come out. */ + var fact = FactCollectorHelpers.BuildServerHardwareFact( + TestDataSeeder.CreateTestContext(), hardwareIsTheHosts: true, + cpuCount: 4, hyperthreadRatio: 64, physicalMemoryMb: 933_836, socketCount: 0, coresPerSocket: 32, hadrEnabled: false); + + Assert.NotNull(fact); + Assert.Equal("SERVER_HARDWARE", fact!.Key); + Assert.Equal("config", fact.Source); + Assert.Equal(4, fact.Value); + Assert.Equal( + new[] { "cpu_count", "hadr_enabled", "vcore_count" }, + fact.Metadata.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray()); + Assert.Equal(4, fact.Metadata["cpu_count"]); + Assert.Equal(4, fact.Metadata["vcore_count"]); + Assert.Equal(0, fact.Metadata["hadr_enabled"]); + } + + [Fact] + public void ServerHardwareFact_OnAzureSqlDatabaseWithNoVcores_IsNotEmitted() + { + Assert.Null(FactCollectorHelpers.BuildServerHardwareFact( + TestDataSeeder.CreateTestContext(), hardwareIsTheHosts: true, + cpuCount: 0, hyperthreadRatio: 64, physicalMemoryMb: 933_836, socketCount: 0, coresPerSocket: 32, hadrEnabled: false)); + } + + [Fact] + public void ServerHardwareFact_OffAzureSqlDatabase_IsTheStoredTopologyAsItAlwaysWas() + { + var fact = FactCollectorHelpers.BuildServerHardwareFact( + TestDataSeeder.CreateTestContext(), hardwareIsTheHosts: false, + cpuCount: 16, hyperthreadRatio: 2, physicalMemoryMb: 65_536, socketCount: 2, coresPerSocket: 4, hadrEnabled: true); + + Assert.NotNull(fact); + Assert.Equal(16, fact!.Value); + Assert.Equal( + new[] { "cpu_count", "hyperthread_ratio", "physical_memory_mb", "socket_count", "cores_per_socket", "hadr_enabled" }, + fact.Metadata.Keys.ToArray()); + Assert.Equal(16, fact.Metadata["cpu_count"]); + Assert.Equal(2, fact.Metadata["hyperthread_ratio"]); + Assert.Equal(65_536, fact.Metadata["physical_memory_mb"]); + Assert.Equal(2, fact.Metadata["socket_count"]); + Assert.Equal(4, fact.Metadata["cores_per_socket"]); + Assert.Equal(1, fact.Metadata["hadr_enabled"]); + + /* No CPU count at all is no fact, on any edition, as before. */ + Assert.Null(FactCollectorHelpers.BuildServerHardwareFact( + TestDataSeeder.CreateTestContext(), hardwareIsTheHosts: false, + cpuCount: 0, hyperthreadRatio: 2, physicalMemoryMb: 65_536, socketCount: 2, coresPerSocket: 4, hadrEnabled: false)); + } + + /// The recommended MAXDOP on an Azure SQL Database follows its vCores. The host's 32 cores per socket go into the + /// fact builder every time, so a fact that carried them would make every row here read 8. + [Theory] + [InlineData(1, 1)] + [InlineData(2, 2)] + [InlineData(4, 4)] + [InlineData(8, 8)] + [InlineData(16, 8)] + [InlineData(80, 8)] + public void MaxdopBasis_OnAzureSqlDatabase_FollowsTheVcores_NeverTheHostsCoresPerSocket(int vcores, int expectedMaxdop) + { + var basis = FactRemediation.MaxdopBasisFrom(Facts(HardwareFact(azureSqlDatabase: true, cpuCount: vcores, coresPerSocket: 32))); + + Assert.True(basis.FromVcores); + Assert.Equal(vcores, basis.Cores); + Assert.Equal($"({vcores} vCores)", basis.Note); + Assert.Equal(expectedMaxdop, FactRemediation.RecommendedMaxdop(basis.Cores)); + } + + [Fact] + public void MaxdopBasis_OffAzureSqlDatabase_IsTheCoresPerSocketAsItAlwaysWas() + { + var basis = FactRemediation.MaxdopBasisFrom(Facts(HardwareFact(azureSqlDatabase: false, cpuCount: 16, coresPerSocket: 4))); + + Assert.False(basis.FromVcores); + Assert.Equal(4, basis.Cores); + Assert.Equal("(cores per socket 4)", basis.Note); + Assert.Equal(4, FactRemediation.RecommendedMaxdop(basis.Cores)); + Assert.Equal(8, FactRemediation.RecommendedMaxdop(FactRemediation.MaxdopBasisFrom( + Facts(HardwareFact(azureSqlDatabase: false, cpuCount: 64, coresPerSocket: 32))).Cores)); + } + + /// A DTU-model objective or an elastic pool gives the fact builder no vCores, so there is no fact: no basis, no figure + /// to state, and the recommendation is the long-standing cap of 8 with nothing said about cores. + [Fact] + public void MaxdopBasis_OnAzureSqlDatabaseWithNoVcores_IsNotApplicable_AndTheAdviceIsTheStaticCap() + { + var basis = FactRemediation.MaxdopBasisFrom(Facts()); + + Assert.Equal(0, basis.Cores); + Assert.False(basis.FromVcores); + Assert.Equal(string.Empty, basis.Note); + Assert.Equal(8, FactRemediation.RecommendedMaxdop(basis.Cores)); + + var advice = FactAdvice.Compose("CONFIG_MAXDOP", Facts(Config("CONFIG_MAXDOP", 0)))!; + Assert.Contains("Set MAXDOP to 8", advice.Remediation, StringComparison.Ordinal); + Assert.DoesNotContain("vCores", advice.Remediation, StringComparison.Ordinal); + Assert.DoesNotContain("cores per socket", advice.Remediation, StringComparison.Ordinal); + } + + /// The words an Azure SQL Database's advice uses are its vCores, never "cores per socket", and its MAXDOP is set with + /// the database-scoped statement (it has no instance option to configure). + [Fact] + public void MaxdopAdvice_OnAzureSqlDatabase_NamesTheVcores_NeverCoresPerSocket_AndTheDatabaseScopedStatement() + { + var hardware = HardwareFact(azureSqlDatabase: true, cpuCount: 4, coresPerSocket: 32); + + var zero = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 0)))!; + Assert.Contains("Set MAXDOP to 4", zero.Remediation, StringComparison.Ordinal); + Assert.Contains("this database's vCores capped at 8 (4 vCores)", zero.Remediation, StringComparison.Ordinal); + Assert.Contains("ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = 4", zero.Remediation, StringComparison.Ordinal); + + var one = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 1)))!; + Assert.Contains("set MAXDOP to 4 (vCores capped at 8 (4 vCores)) with ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = 4", one.Remediation, StringComparison.Ordinal); + + var above = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 16)))!; + Assert.Contains("above this database's topology-based guidance of 4", above.Headline, StringComparison.Ordinal); + Assert.Contains("Lower MAXDOP from 16 to 4 (vCores capped at 8 (4 vCores)", above.Remediation, StringComparison.Ordinal); + + var parallel = FactAdvice.Compose("THREADPOOL_PARALLEL", Facts(hardware, Config("CONFIG_MAXDOP", 0), Config("CONFIG_CTFP", 5)))!; + Assert.Contains("(4 vCores)", parallel.Remediation, StringComparison.Ordinal); + Assert.Contains("cap MAXDOP at 4 (this database's vCores, capped at 8)", parallel.Remediation, StringComparison.Ordinal); + + var clause = FactAdvice.Compose("CXPACKET", Facts(hardware, Config("CONFIG_MAXDOP", 0), Config("CONFIG_CTFP", 50)))!; + Assert.Contains("cap MAXDOP at 4 (the database's vCores, ≤ 8)", clause.Remediation, StringComparison.Ordinal); + + foreach (var text in new[] { zero.Remediation, one.Remediation, above.Remediation, above.Headline, parallel.Remediation, clause.Remediation }) + { + Assert.DoesNotContain("cores per socket", text, StringComparison.Ordinal); + Assert.DoesNotContain("cores-per-socket", text, StringComparison.Ordinal); + Assert.DoesNotContain("sp_configure", text, StringComparison.Ordinal); + } + } + + /// The same advice off an Azure SQL Database, word for word as it always was. + [Fact] + public void MaxdopAdvice_OffAzureSqlDatabase_IsTheLongStandingText() + { + var hardware = HardwareFact(azureSqlDatabase: false, cpuCount: 16, coresPerSocket: 4); + + var zero = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 0)))!; + Assert.Contains( + "Set MAXDOP to 4 — this server's cores-per-socket capped at 8 (cores per socket 4), the per-NUMA-node proxy; " + + "the SKU is irrelevant to the right value. The Apply button runs sp_configure + RECONFIGURE, an online metadata change.", + zero.Remediation, StringComparison.Ordinal); + + var one = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 1)))!; + Assert.Contains("set MAXDOP to 4 (cores-per-socket capped at 8 (cores per socket 4)) via sp_configure + RECONFIGURE, an online change.", + one.Remediation, StringComparison.Ordinal); + + var above = FactAdvice.Compose("CONFIG_MAXDOP", Facts(hardware, Config("CONFIG_MAXDOP", 16)))!; + Assert.Contains("above this server's topology-based guidance of 4", above.Headline, StringComparison.Ordinal); + + var parallel = FactAdvice.Compose("THREADPOOL_PARALLEL", Facts(hardware, Config("CONFIG_MAXDOP", 0), Config("CONFIG_CTFP", 5)))!; + Assert.Contains("cost threshold for parallelism is 5 (cores per socket 4).", parallel.Remediation, StringComparison.Ordinal); + Assert.Contains("cap MAXDOP at 4 (this server's per-NUMA-node processor count, capped at 8)", parallel.Remediation, StringComparison.Ordinal); + } + + [Fact] + public void LpimAdvisory_OnAzureSqlDatabase_IsNotRaisedFromTheHostsMemory_AndIsUnchangedElsewhere() + { + var context = TestDataSeeder.CreateTestContext(); + + var onAzure = new List(); + FactCollectorHelpers.EmitServerHealthFacts( + context, onAzure, "SQL Azure", physicalMemMb: 933_836, + lockPagesInMemory: false, instantFileInit: null, memoryDumpCount: null, hardwareIsTheHosts: true); + Assert.DoesNotContain(onAzure, f => f.Key == "CONFIG_LPIM_DISABLED"); + + var onServer = new List(); + FactCollectorHelpers.EmitServerHealthFacts( + context, onServer, "Enterprise Edition (64-bit)", physicalMemMb: 933_836, + lockPagesInMemory: false, instantFileInit: null, memoryDumpCount: null); + var lpim = Assert.Single(onServer, f => f.Key == "CONFIG_LPIM_DISABLED"); + Assert.Equal(933_836, lpim.Metadata["physical_memory_mb"]); + } + + [Fact] + public void ServerContextCard_OnAzureSqlDatabase_ShowsTheVcores_NeverTheSchedulerCountOrTheHostsRam_AndNaWhereThereAreNone() + { + static string? Hardware(ServerMetadata metadata) => + ServerContextCard.Rows(metadata).SingleOrDefault(r => r.Label == "Hardware").Value; + + /* The stored count (2) is the schedulers the database can see; its service objective gives it 1 vCore. The host's RAM, + had a reader let it through, is not printed either. */ + Assert.Equal("1 vCores", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = 1, PhysicalMemoryMB = 0 })); + Assert.Equal("1 vCores", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = 1, PhysicalMemoryMB = 933_836 })); + Assert.Equal("4 vCores", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = 4 })); + + /* A DTU-model objective or an elastic pool names no vCores: the row is still there, and it reads n/a, never the stored 2. */ + Assert.Equal("n/a", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = null })); + Assert.Equal("n/a", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 2, VcoreCount = 0 })); + Assert.Equal("n/a", Hardware(new ServerMetadata { Edition = "SQL Azure", EngineEdition = 5, CpuCount = 0, VcoreCount = null })); + } + + [Fact] + public void ServerContextCard_OffAzureSqlDatabase_IsTheLongStandingRow() + { + static string? Hardware(ServerMetadata metadata) => + ServerContextCard.Rows(metadata).SingleOrDefault(r => r.Label == "Hardware").Value; + + var expected = string.Format(CultureInfo.CurrentCulture, "8 CPUs, {0:N0} MB RAM", 65_536L); + Assert.Equal(expected, Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = 3, CpuCount = 8, PhysicalMemoryMB = 65_536 })); + Assert.Equal(expected, Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = 8, CpuCount = 8, PhysicalMemoryMB = 65_536 })); + Assert.Equal(expected, Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = null, CpuCount = 8, PhysicalMemoryMB = 65_536 })); + + /* A VcoreCount a non-Azure server somehow carries changes nothing, and no CPU count drops the row as it always did. */ + Assert.Equal(expected, Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = 3, CpuCount = 8, VcoreCount = 2, PhysicalMemoryMB = 65_536 })); + Assert.Null(Hardware(new ServerMetadata { Edition = "Enterprise Edition (64-bit)", EngineEdition = 3, CpuCount = 0, PhysicalMemoryMB = 0 })); + } + + // ── 2. Worker Threads card and the verdict's worker term ── + + [Fact] + public void OwnPhysicalMemoryMb_IsNotApplicableOnAzureSqlDatabase_AndTheStoredFigureEverywhereElse() + { + Assert.Null(ServerHardwareScope.OwnPhysicalMemoryMb(5, 933_836)); + Assert.Null(ServerHardwareScope.OwnPhysicalMemoryMb(5, null)); + Assert.Equal(65_536L, ServerHardwareScope.OwnPhysicalMemoryMb(3, 65_536)); + Assert.Equal(65_536L, ServerHardwareScope.OwnPhysicalMemoryMb(8, 65_536)); + Assert.Equal(65_536L, ServerHardwareScope.OwnPhysicalMemoryMb(null, 65_536)); + Assert.Null(ServerHardwareScope.OwnPhysicalMemoryMb(3, null)); + } + + [Fact] + public void WorkerThreadsText_ReadsNotApplicableForAnInUseCountThatWasNotCollected_AndNeverZero() + { + /* NULL in use is "not collected" (an Azure SQL Database stores NULL), and it is not 0: the ceiling beside it is the + database's own and is shown as stored. */ + Assert.Equal("n/a / 512", ServerHardwareScope.WorkerThreadsText(null, 512)); + Assert.Equal("n/a / 479", ServerHardwareScope.WorkerThreadsText(null, 479)); + + /* A genuine zero in use reads 0. */ + Assert.Equal("0 / 512", ServerHardwareScope.WorkerThreadsText(0, 512)); + + Assert.Equal( + string.Format(CultureInfo.CurrentCulture, "{0:N0} / {1:N0}", 1_200, 2_560), + ServerHardwareScope.WorkerThreadsText(1_200, 2_560)); + } + + [Fact] + public void Verdict_NeverReadsAnUnknownInUseWorkerCountAsSaturation() + { + /* Quiet CPU (50%) and no memory pressure: nothing but the worker term can decide this window. */ + static string Verdict(int maxWorkers, int? currentWorkers) => ProvisioningVerdict.Evaluate( + avgCpuPercent: 50m, maxCpuPercent: 50m, p95CpuPercent: 50m, maxGrantWaiters: 0, grantTimeouts: 0, forcedGrants: 0, + grantUtilizationPercent: 50m, maxWorkers, currentWorkers); + + Assert.Equal(ProvisioningVerdict.RightSized, Verdict(512, null)); + Assert.Equal(ProvisioningVerdict.RightSized, Verdict(512, 0)); + Assert.Equal(ProvisioningVerdict.RightSized, Verdict(512, 40)); + Assert.Equal(ProvisioningVerdict.UnderProvisioned, Verdict(512, 450)); + Assert.Equal(ProvisioningVerdict.RightSized, Verdict(0, 450)); // no ceiling known is not saturation + + Assert.Equal( + "No under-provisioning condition is currently met.", + ProvisioningVerdict.UnderProvisionedReason(50m, 0, 0, 0, 512, null)); + Assert.Contains( + "Worker threads are near the limit: 450 of 512 in use", + ProvisioningVerdict.UnderProvisionedReason(50m, 0, 0, 0, 512, 450), StringComparison.Ordinal); + } + + // ── 3. Health score: the CPU term ── + + [Fact] + public void Overall_WithNoCpuScore_WeighsMemoryAndStorageOverTheirOwnSixtyPercent() + { + Assert.Equal(100, FinOpsHealthCalculator.Overall(null, 100, 100)); + Assert.Equal(0, FinOpsHealthCalculator.Overall(null, 0, 0)); + Assert.Equal(80, FinOpsHealthCalculator.Overall(null, 60, 100)); + Assert.Equal(60, FinOpsHealthCalculator.Overall(null, 80, 40)); + } + + [Fact] + public void Overall_WithEveryTerm_IsTheLongStandingArithmetic() + { + Assert.Equal(86, FinOpsHealthCalculator.Overall(95, 60, 100)); + Assert.Equal(62, FinOpsHealthCalculator.Overall(80, 60, 40)); + Assert.Equal(100, FinOpsHealthCalculator.Overall(100, 100, 100)); + Assert.Equal(0, FinOpsHealthCalculator.Overall(0, 0, 0)); + } + + /// Buffer pool 10% of physical memory scores 60 and 50% free storage scores 100, so the CPU term is the only one + /// that can tell a measured window from an empty one. + private static UtilizationEfficiencyRow Window(int engineEdition, string provisioningStatus, decimal p95CpuPct) => new() + { + EngineEdition = engineEdition, + ProvisioningStatus = provisioningStatus, + P95CpuPct = p95CpuPct, + PhysicalMemoryMb = 100_000, + BufferPoolMb = 10_000, + FreeSpacePct = 50m, + }; + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(8)] + public void HealthScore_WithNoCpuSample_LeavesTheCpuTermOut_AndDoesNotScoreTheZeroItReadsAs(int engineEdition) + { + /* With the CPU term: 100 * 0.4 + 60 * 0.3 + 100 * 0.3 = 88. Without it: memory and storage over their own 60%. */ + var noSample = Window(engineEdition, provisioningStatus: "", p95CpuPct: 0m); + + Assert.False(noSample.HasCpuSample); + Assert.Equal(80, noSample.ComputeHealthScore()); + Assert.Equal(FinOpsHealthCalculator.Overall(null, 60, 100), noSample.ComputeHealthScore()); + + /* Whatever the P95 field holds, an empty window's CPU is not scored. */ + Assert.Equal(80, Window(engineEdition, "", 95m).ComputeHealthScore()); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(8)] + public void HealthScore_WithACpuSample_IsTheLongStandingScore(int engineEdition) + { + /* 95 * 0.4 + 60 * 0.3 + 100 * 0.3 = 86, whatever verdict the window earned. */ + foreach (var status in new[] { ProvisioningVerdict.RightSized, ProvisioningVerdict.OverProvisioned, ProvisioningVerdict.UnderProvisioned }) + { + var measured = Window(engineEdition, status, 7m); + Assert.True(measured.HasCpuSample); + Assert.Equal(86, measured.ComputeHealthScore()); + } + + /* A genuinely idle measured window (p95 of 0) still scores its 100, which is the point of telling it from no sample. */ + Assert.Equal(88, Window(engineEdition, ProvisioningVerdict.OverProvisioned, 0m).ComputeHealthScore()); + } + + // ── 4. Words: the CPU unit and the Memory tab ── + + [Fact] + public void CpuCountUnit_IsVcoresOnAzureSqlDatabase_AndCpusEverywhereElse() + { + Assert.Equal(" vCores,", ServerHardwareScope.CpuCountUnit(5)); + foreach (var engineEdition in new int?[] { 1, 2, 3, 4, 8, null }) + Assert.Equal(" CPUs,", ServerHardwareScope.CpuCountUnit(engineEdition)); + } + + [Fact] + public void CpuCoreNoun_IsVcoresOnAzureSqlDatabase_AndCoresEverywhereElse() + { + /* The FinOps CPU right-sizing text prints the utilization read's count: the vCores on an Azure SQL Database, which it + names the way the utilization card does, and a CPU count everywhere else, in the word it has always used. */ + Assert.Equal("vCores", ServerHardwareScope.CpuCoreNoun(5)); + foreach (var engineEdition in new int?[] { 1, 2, 3, 4, 8, null }) + Assert.Equal("cores", ServerHardwareScope.CpuCoreNoun(engineEdition)); + } + + [Fact] + public void MemoryTabLabels_NameTheDatabasesLimitOnAzureSqlDatabase_AndPhysicalMemoryEverywhereElse() + { + /* On an Azure SQL Database the collector stores the database's committed target as the first figure and the target + minus what is committed as the second, so neither is physical memory. */ + Assert.Equal("Memory limit", ServerHardwareScope.MemoryTabTotalLabel(5)); + Assert.Equal("Available under limit", ServerHardwareScope.MemoryTabAvailableLabel(5)); + + foreach (var engineEdition in new int?[] { 1, 2, 3, 4, 8, null }) + { + Assert.Equal("Physical Memory", ServerHardwareScope.MemoryTabTotalLabel(engineEdition)); + Assert.Equal("Available Physical", ServerHardwareScope.MemoryTabAvailableLabel(engineEdition)); + } + + /* The utilization card's caption and the Memory tab's label are the same words. */ + Assert.Equal(ServerHardwareScope.MemoryTabTotalLabel(5) + ": ", ServerHardwareScope.PhysicalMemoryCaption(5)); + Assert.Equal("Physical: ", ServerHardwareScope.PhysicalMemoryCaption(3)); + } + + // ── the wiring, pinned at the source ── + + private static string ReadRepoFile(string relativePath, [CallerFilePath] string thisFile = "") + { + var dir = Path.GetDirectoryName(thisFile)!; + var parts = relativePath.Split('/'); + while (dir is not null && !File.Exists(Path.Combine(new[] { dir }.Concat(parts).ToArray()))) + dir = Path.GetDirectoryName(dir); + + Assert.NotNull(dir); + return File.ReadAllText(Path.Combine(new[] { dir! }.Concat(parts).ToArray())); + } + + [Fact] + public void FinOpsTab_AsksTheSharedRules_ForTheCpuUnit_TheWorkerThreadsCard_TheHealthTooltip_AndTheInventoryCpuTerm() + { + var tab = ReadRepoFile("Lite/Controls/FinOpsTab.xaml.cs"); + var xaml = ReadRepoFile("Lite/Controls/FinOpsTab.xaml"); + + Assert.Contains("CpuCountUnitText.Text = ServerHardwareScope.CpuCountUnit(data.EngineEdition);", tab, StringComparison.Ordinal); + Assert.Contains("x:Name=\"CpuCountUnitText\"", xaml, StringComparison.Ordinal); + + Assert.Contains( + "WorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.CurrentWorkersCount, data.MaxWorkersCount);", + tab, StringComparison.Ordinal); + Assert.DoesNotContain("$\"{data.CurrentWorkersCount:N0} / {data.MaxWorkersCount:N0}\"", tab, StringComparison.Ordinal); + + Assert.Contains( + "HealthScoreBorder.ToolTip = data.HasCpuSample ? null : ServerHardwareScope.HealthScoreWithoutCpuNote;", + tab, StringComparison.Ordinal); + + Assert.Contains( + "int? cpuScore = item.AvgCpuPct is decimal avgCpu ? FinOpsHealthCalculator.CpuScore(avgCpu) : null;", + tab, StringComparison.Ordinal); + Assert.DoesNotContain("item.AvgCpuPct ?? 0m", tab, StringComparison.Ordinal); + } + + [Fact] + public void WorkerReads_KeepANullInUseCountNull_InAllThreePlaces() + { + var utilization = ReadRepoFile("Lite/Services/LocalDataService.FinOps.Utilization.cs"); + var fleet = ReadRepoFile("Lite/Services/LocalDataService.FinOps.ServerProperties.cs"); + + Assert.Contains("int? currentWorkers = reader.IsDBNull(10) ? null : Convert.ToInt32(reader.GetValue(10));", utilization, StringComparison.Ordinal); + Assert.Contains("currentWorkers: reader.IsDBNull(10) ? (int?)null : Convert.ToInt32(reader.GetValue(10)));", utilization, StringComparison.Ordinal); + Assert.Contains("currentWorkers: reader.IsDBNull(7) ? (int?)null : Convert.ToInt32(reader.GetValue(7)));", fleet, StringComparison.Ordinal); + Assert.DoesNotContain("COALESCE(m.current_workers_count, 0)", utilization, StringComparison.Ordinal); + Assert.DoesNotContain("COALESCE(m.current_workers_count, 0)", fleet, StringComparison.Ordinal); + } + + [Fact] + public void FactCollectorAndPlanReaders_AskTheSharedRule_ForTheEditionsHardware() + { + var config = ReadRepoFile("Lite/Analysis/DuckDbFactCollector.Config.cs"); + Assert.Contains("lock_pages_in_memory, instant_file_initialization_enabled, memory_dump_count, engine_edition", config, StringComparison.Ordinal); + Assert.Contains("FactCollectorHelpers.BuildServerHardwareFact(", config, StringComparison.Ordinal); + Assert.Contains("lpim, ifi, dumpCount, hardwareIsTheHosts);", config, StringComparison.Ordinal); + Assert.DoesNotContain("[\"hyperthread_ratio\"] = htRatio", config, StringComparison.Ordinal); + + var planMetadata = ReadRepoFile("Lite/Services/LocalDataService.PlanServerMetadata.cs"); + var drillDown = ReadRepoFile("Lite/Analysis/DrillDownCollector.Plans.cs"); + foreach (var source in new[] { planMetadata, drillDown }) + { + Assert.Contains("PhysicalMemoryMB = ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L,", source, StringComparison.Ordinal); + Assert.Contains("EngineEdition = engineEdition,", source, StringComparison.Ordinal); + Assert.Contains("VcoreCount = vcoreCount,", source, StringComparison.Ordinal); + } + } + + [Fact] + public void MemoryUtilization_IsStillComputedFromMemoryStats_NotFromTheHostsPhysicalMemory() + { + var read = ReadRepoFile("Lite/Services/LocalDataService.Memory.cs"); + Assert.Contains("FROM v_memory_stats", read, StringComparison.Ordinal); + Assert.Contains( + "public double MemoryUtilizationPercent => TotalPhysicalMemoryMb > 0 ? UsedPhysicalMemoryMb / TotalPhysicalMemoryMb * 100 : 0;", + read, StringComparison.Ordinal); + + var tool = ReadRepoFile("Lite/Mcp/McpMemoryTools.cs"); + Assert.Contains("memory_utilization_pct = Math.Round(stats.MemoryUtilizationPercent, 1),", tool, StringComparison.Ordinal); + } + + [Fact] + public void MemoryTab_AsksTheSharedRule_ForTheNamesOfItsFirstTwoFigures() + { + var charts = ReadRepoFile("Lite/Controls/ServerTab.Charts.cs"); + var xaml = ReadRepoFile("Lite/Controls/ServerTab.xaml"); + + Assert.Contains("PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(_engineEdition);", charts, StringComparison.Ordinal); + Assert.Contains("AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(_engineEdition);", charts, StringComparison.Ordinal); + Assert.Contains("x:Name=\"PhysicalMemoryLabel\"", xaml, StringComparison.Ordinal); + Assert.Contains("x:Name=\"AvailablePhysicalMemoryLabel\"", xaml, StringComparison.Ordinal); + + /* The MCP tool names its figures from the ONE edition it read through McpEngineCapability. The row carries none. */ + var tool = ReadRepoFile("Lite/Mcp/McpMemoryTools.cs"); + Assert.Contains("engine_edition = engineEdition == CollectorEngineCapability.UnknownEngineEdition ? (int?)null : engineEdition", tool, StringComparison.Ordinal); + Assert.DoesNotContain("stats.EngineEdition", tool, StringComparison.Ordinal); + } +} + +/// +/// The seeded half of : real DuckDB reads against rows seeded the way the +/// collectors store them. +/// +public sealed class AzureSqlDatabaseOwnFiguresReadTests : IClassFixture, IDisposable +{ + private const int ServerId = -488_101; + private readonly SharedDuckDbFixture _fixture; + private readonly DateTime _now = DateTime.UtcNow; + private DuckDBConnection? _seedConn; + private long _nextId = -488_101_000; + + public AzureSqlDatabaseOwnFiguresReadTests(SharedDuckDbFixture fixture) + { + fixture.ResetData(); + _fixture = fixture; + } + + public void Dispose() => _seedConn?.Dispose(); + + // ── 1. SERVER_HARDWARE fact, end to end through the collector ── + + private async Task> CollectFactsAsync(int engineEdition, int? vcoreCount) + { + using var seeder = new TestDataSeeder(_fixture.DuckDb); + /* An Azure SQL Database as the collector stores it: the database's own 2 schedulers in cpu_count, the host's hyperthread + ratio 64, 0 sockets, 32 cores per socket and 911.9 GB, plus the service objective's vCores. */ + await seeder.SeedServerPropertiesAsync( + cpuCount: 2, htRatio: 64, physicalMemMb: 933_836, socketCount: 0, coresPerSocket: 32, + edition: "SQL Azure", engineEdition: engineEdition, + serviceObjective: vcoreCount.HasValue ? "GP_S_Gen5_" + vcoreCount : "S0", vcoreCount: vcoreCount); + + var facts = await new DuckDbFactCollector(_fixture.DuckDb).CollectFactsAsync(TestDataSeeder.CreateTestContext()); + return facts.ToDictionary(f => f.Key, f => f); + } + + [Fact] + public async Task Collector_OnAzureSqlDatabaseWithVcores_EmitsTheVcoresAndNoHostTopologyOrMemory() + { + var facts = await CollectFactsAsync(engineEdition: 5, vcoreCount: 4); + + var hardware = Assert.Contains("SERVER_HARDWARE", facts); + Assert.Equal(4, hardware.Value); + Assert.Equal( + new[] { "cpu_count", "hadr_enabled", "vcore_count" }, + hardware.Metadata.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray()); + Assert.Equal(4, hardware.Metadata["cpu_count"]); + Assert.Equal(4, hardware.Metadata["vcore_count"]); + Assert.DoesNotContain(facts.Values, f => f.Metadata.ContainsKey("physical_memory_mb")); + Assert.DoesNotContain(facts.Values, f => f.Metadata.ContainsKey("cores_per_socket")); + + /* The recommended MAXDOP read off the collected fact follows the 4 vCores, not the 32 cores per socket stored beside them. */ + var basis = FactRemediation.MaxdopBasisFrom(facts); + Assert.True(basis.FromVcores); + Assert.Equal(4, basis.Cores); + Assert.Equal(4, FactRemediation.RecommendedMaxdop(basis.Cores)); + } + + [Fact] + public async Task Collector_OnAzureSqlDatabaseWithNoVcores_EmitsNoServerHardwareFact_AndNothingFromTheHost() + { + var facts = await CollectFactsAsync(engineEdition: 5, vcoreCount: null); + + Assert.DoesNotContain("SERVER_HARDWARE", facts.Keys); + Assert.DoesNotContain(facts.Values, f => f.Metadata.ContainsKey("physical_memory_mb")); + Assert.DoesNotContain(facts.Values, f => f.Metadata.ContainsKey("cores_per_socket")); + Assert.Equal(0, FactRemediation.MaxdopBasisFrom(facts).Cores); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(8)] + public async Task Collector_OnEveryOtherEdition_EmitsTheStoredTopologyAsItAlwaysDid(int engineEdition) + { + using var seeder = new TestDataSeeder(_fixture.DuckDb); + await seeder.SeedServerPropertiesAsync( + cpuCount: 16, htRatio: 2, physicalMemMb: 65_536, socketCount: 2, coresPerSocket: 4, engineEdition: engineEdition); + + var facts = await new DuckDbFactCollector(_fixture.DuckDb).CollectFactsAsync(TestDataSeeder.CreateTestContext()); + var hardware = Assert.Single(facts, f => f.Key == "SERVER_HARDWARE"); + + Assert.Equal(16, hardware.Value); + Assert.Equal(2, hardware.Metadata["hyperthread_ratio"]); + Assert.Equal(65_536, hardware.Metadata["physical_memory_mb"]); + Assert.Equal(2, hardware.Metadata["socket_count"]); + Assert.Equal(4, hardware.Metadata["cores_per_socket"]); + Assert.Equal(0, hardware.Metadata["hadr_enabled"]); + Assert.False(hardware.Metadata.ContainsKey("vcore_count")); + } + + // ── 1. plan Server Context metadata: the two DuckDB readers ── + + private async Task SeedConnectionAsync() + { + if (_seedConn is null) + { + _seedConn = _fixture.DuckDb.CreateConnection(); + await _seedConn.OpenAsync(); + } + return _seedConn; + } + + private async Task SeedServerPropertiesAsync(int serverId, int engineEdition, int storedCpuCount, long storedPhysicalMemoryMb, int? vcoreCount) + { + using var readLock = _fixture.DuckDb.AcquireReadLock(); + var conn = await SeedConnectionAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @" +INSERT INTO server_properties + (collection_id, collection_time, server_id, server_name, edition, product_version, product_level, engine_edition, + cpu_count, hyperthread_ratio, physical_memory_mb, socket_count, cores_per_socket, service_objective, vcore_count) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)"; + void P(object? v) => cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); + P(_nextId--); P(_now); P(serverId); P("OwnFiguresSrv" + serverId); + P(engineEdition == 5 ? "SQL Azure" : "Enterprise Edition (64-bit)"); P("12.0.2000.8"); P("RTM"); + P(engineEdition); P(storedCpuCount); P(64); P(storedPhysicalMemoryMb); P(0); P(32); + P(vcoreCount.HasValue ? "GP_S_Gen5_" + vcoreCount : (engineEdition == 5 ? "S0" : null)); P(vcoreCount); + await cmd.ExecuteNonQueryAsync(); + } + + /// (edition, stored cpu_count, stored physical memory, vCores, expected CpuCount, expected RAM, expected Hardware row). + /// The stored count is carried as read (on an Azure SQL Database it is the database's own scheduler count, 2 for a 1-vCore + /// database), the host's RAM never, and the card shows the vCores. + public static IEnumerable PlanMetadataCases() => + [ + [5, 2, 933_836L, 1, 2, 0L, "1 vCores"], // Azure SQL Database with vCores: the vCores, never the 2 it can see, no RAM + [5, 2, 933_836L, null, 2, 0L, "n/a"], // Azure SQL Database, DTU objective: the row is there and reads n/a + [3, 8, 65_536L, null, 8, 65_536L, "8 CPUs, {RAM} MB RAM"], // SQL Server: as stored + [8, 8, 65_536L, null, 8, 65_536L, "8 CPUs, {RAM} MB RAM"], // Managed Instance: as stored + ]; + + private static string HardwareRow(ServerMetadata metadata) => + ServerContextCard.Rows(metadata).Single(r => r.Label == "Hardware").Value; + + [Theory] + [MemberData(nameof(PlanMetadataCases))] + public async Task PlanMetadata_LocalDataServiceRead_CarriesTheDatabasesOwnFigures( + int engineEdition, int storedCpuCount, long storedPhysicalMemoryMb, int? vcoreCount, int expectedCpuCount, + long expectedPhysicalMemoryMb, string expectedHardwareRow) + { + await SeedServerPropertiesAsync(ServerId, engineEdition, storedCpuCount, storedPhysicalMemoryMb, vcoreCount); + + var metadata = await new LocalDataService(_fixture.DuckDb).GetServerMetadataForPlanAnalysisAsync(ServerId); + + Assert.NotNull(metadata); + Assert.Equal(expectedCpuCount, metadata!.CpuCount); + Assert.Equal(expectedPhysicalMemoryMb, metadata.PhysicalMemoryMB); + Assert.Equal(engineEdition, metadata.EngineEdition); + Assert.Equal(vcoreCount, metadata.VcoreCount); + Assert.Equal(expectedHardwareRow.Replace("{RAM}", 65_536L.ToString("N0", CultureInfo.CurrentCulture)), HardwareRow(metadata)); + } + + [Theory] + [MemberData(nameof(PlanMetadataCases))] + public async Task PlanMetadata_DrillDownRead_CarriesTheDatabasesOwnFigures( + int engineEdition, int storedCpuCount, long storedPhysicalMemoryMb, int? vcoreCount, int expectedCpuCount, + long expectedPhysicalMemoryMb, string expectedHardwareRow) + { + await SeedServerPropertiesAsync(ServerId, engineEdition, storedCpuCount, storedPhysicalMemoryMb, vcoreCount); + + var read = typeof(DrillDownCollector).GetMethod("ReadServerMetadataForPlanAnalysisAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + var metadata = await (Task)read.Invoke(new DrillDownCollector(_fixture.DuckDb), [ServerId, CancellationToken.None])!; + + Assert.NotNull(metadata); + Assert.Equal(expectedCpuCount, metadata!.CpuCount); + Assert.Equal(expectedPhysicalMemoryMb, metadata.PhysicalMemoryMB); + Assert.Equal(engineEdition, metadata.EngineEdition); + Assert.Equal(vcoreCount, metadata.VcoreCount); + Assert.Equal(expectedHardwareRow.Replace("{RAM}", 65_536L.ToString("N0", CultureInfo.CurrentCulture)), HardwareRow(metadata)); + } + + // ── 2. in-use worker count: point-in-time read, 7-day trend, fleet read ── + + /// One server with a measured CPU window (average and p95 50%, so the verdict is RIGHT_SIZED unless something else + /// decides it) and the memory_stats the collector stores: on an Azure SQL Database the database's own memory limit (1,838 MB + /// for a 1-vCore General Purpose database), the database's own ceiling of 512 workers, and NO in-use count (the collector + /// stores NULL there). + private async Task SeedServerWithMemoryAsync(int serverId, int engineEdition, int? vcoreCount, int? currentWorkers) + { + await SeedServerPropertiesAsync(serverId, engineEdition, storedCpuCount: 2, storedPhysicalMemoryMb: engineEdition == 5 ? 933_836 : 65_536, vcoreCount); + + using var readLock = _fixture.DuckDb.AcquireReadLock(); + var conn = await SeedConnectionAsync(); + + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = @" +INSERT INTO cpu_utilization_stats + (collection_id, collection_time, server_id, server_name, sample_time, sqlserver_cpu_utilization, other_process_cpu_utilization) +VALUES ($1, $2, $3, $4, $2, 50, 2)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); + cmd.Parameters.Add(new DuckDBParameter { Value = _now }); + cmd.Parameters.Add(new DuckDBParameter { Value = serverId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "OwnFiguresSrv" + serverId }); + await cmd.ExecuteNonQueryAsync(); + } + + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = @" +INSERT INTO memory_stats + (collection_id, collection_time, server_id, server_name, total_physical_memory_mb, available_physical_memory_mb, + target_server_memory_mb, total_server_memory_mb, buffer_pool_mb, max_workers_count, current_workers_count) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 512, $10)"; + void P(object? v) => cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); + var physical = engineEdition == 5 ? 1_838d : 65_536d; + P(_nextId--); P(_now); P(serverId); P("OwnFiguresSrv" + serverId); + P(physical); P(physical / 2); P(physical * 0.9); P(physical * 0.8); P(physical * 0.5); P(currentWorkers); + await cmd.ExecuteNonQueryAsync(); + } + } + + /// (edition, vCores, in-use workers stored, expected verdict, expected Worker Threads text). The ceiling is the stored + /// 512 on every edition. + public static IEnumerable WorkerCases() => + [ + [5, 1, null, "RIGHT_SIZED", "n/a / 512"], // Azure SQL Database with vCores, as stored: no in-use count + [5, null, null, "RIGHT_SIZED", "n/a / 512"], // Azure SQL Database, DTU objective + [3, null, 450, "UNDER_PROVISIONED", "450 / 512"], // SQL Server: 450 of 512 is worker saturation, as it always was + [8, null, 450, "UNDER_PROVISIONED", "450 / 512"], // Managed Instance: the same + [3, null, 40, "RIGHT_SIZED", "40 / 512"], + [3, null, 0, "RIGHT_SIZED", "0 / 512"], // a genuine zero reads 0 + [3, null, null, "RIGHT_SIZED", "n/a / 512"], // a NULL on any edition reads n/a, never 0 + ]; + + [Theory] + [MemberData(nameof(WorkerCases))] + public async Task UtilizationRead_KeepsANullInUseCountNull_AndTheCeilingAsStored( + int engineEdition, int? vcoreCount, int? currentWorkers, string expectedVerdict, string expectedText) + { + await SeedServerWithMemoryAsync(ServerId, engineEdition, vcoreCount, currentWorkers); + + var row = await new LocalDataService(_fixture.DuckDb).GetUtilizationEfficiencyAsync(ServerId); + + Assert.NotNull(row); + Assert.Equal(engineEdition, row!.EngineEdition); + Assert.Equal(512, row.MaxWorkersCount); + Assert.Equal(currentWorkers, row.CurrentWorkersCount); + Assert.Equal(expectedVerdict, row.ProvisioningStatus); + Assert.Equal(expectedText, ServerHardwareScope.WorkerThreadsText(row.CurrentWorkersCount, row.MaxWorkersCount)); + } + + [Theory] + [MemberData(nameof(WorkerCases))] + public async Task TrendRead_KeepsANullInUseCountNull_AndNeverCallsADaySaturated( + int engineEdition, int? vcoreCount, int? currentWorkers, string expectedVerdict, string expectedText) + { + _ = expectedText; + await SeedServerWithMemoryAsync(ServerId, engineEdition, vcoreCount, currentWorkers); + + var days = await new LocalDataService(_fixture.DuckDb).GetProvisioningTrendAsync(ServerId); + + var day = Assert.Single(days); + Assert.Equal(expectedVerdict, day.Status); + } + + [Fact] + public async Task FleetRead_KeepsANullInUseCountNull_AndNeverCallsAServerSaturated() + { + const int azureVcores = ServerId - 1; + const int azureDtu = ServerId - 2; + const int sqlServer = ServerId - 3; + const int managedInstance = ServerId - 4; + const int sqlServerNoCount = ServerId - 5; + await SeedServerWithMemoryAsync(azureVcores, engineEdition: 5, vcoreCount: 1, currentWorkers: null); + await SeedServerWithMemoryAsync(azureDtu, engineEdition: 5, vcoreCount: null, currentWorkers: null); + await SeedServerWithMemoryAsync(sqlServer, engineEdition: 3, vcoreCount: null, currentWorkers: 450); + await SeedServerWithMemoryAsync(managedInstance, engineEdition: 8, vcoreCount: null, currentWorkers: 450); + await SeedServerWithMemoryAsync(sqlServerNoCount, engineEdition: 3, vcoreCount: null, currentWorkers: null); + + var metrics = await new LocalDataService(_fixture.DuckDb).GetServerMetricsAsync(); + + Assert.Equal("RIGHT_SIZED", metrics[azureVcores].ProvisioningStatus); + Assert.Equal("RIGHT_SIZED", metrics[azureDtu].ProvisioningStatus); + Assert.Equal("UNDER_PROVISIONED", metrics[sqlServer].ProvisioningStatus); + Assert.Equal("UNDER_PROVISIONED", metrics[managedInstance].ProvisioningStatus); + Assert.Equal("RIGHT_SIZED", metrics[sqlServerNoCount].ProvisioningStatus); + } + + // ── 3. memory utilization: unchanged, it is memory_stats ── + + [Theory] + [InlineData(5, 1_838, 1_000)] // Azure SQL Database: the database's own memory limit, as the collector stores it + [InlineData(3, 65_536, 16_384)] // SQL Server + [InlineData(8, 65_536, 16_384)] // Managed Instance + public async Task MemoryUtilization_IsComputedFromMemoryStats_OnEveryEdition_AndNeverFromTheHostsPhysicalMemory( + int engineEdition, double totalMb, double availableMb) + { + /* server_properties carries the HOST's 911.9 GB on an Azure SQL Database. Were the percentage divided by it, the + figure would be ~100%; it is divided by the memory_stats total, so it is what it always was. */ + await SeedServerPropertiesAsync(ServerId, engineEdition, storedCpuCount: 2, storedPhysicalMemoryMb: 933_836, vcoreCount: engineEdition == 5 ? 1 : null); + + using (var readLock = _fixture.DuckDb.AcquireReadLock()) + { + var conn = await SeedConnectionAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @" +INSERT INTO memory_stats + (collection_id, collection_time, server_id, server_name, total_physical_memory_mb, available_physical_memory_mb, + target_server_memory_mb, total_server_memory_mb, buffer_pool_mb) +VALUES ($1, $2, $3, $4, $5, $6, $5, $5, $6)"; + void P(object? v) => cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); + P(_nextId--); P(_now); P(ServerId); P("OwnFiguresSrv"); P(totalMb); P(availableMb); + await cmd.ExecuteNonQueryAsync(); + } + + var stats = await new LocalDataService(_fixture.DuckDb).GetLatestMemoryStatsAsync(ServerId); + + Assert.NotNull(stats); + Assert.Equal(totalMb, stats!.TotalPhysicalMemoryMb); + Assert.Equal((totalMb - availableMb) / totalMb * 100, stats.MemoryUtilizationPercent, precision: 6); + Assert.InRange(stats.MemoryUtilizationPercent, 1, 99); + } + + [Fact] + public async Task MemoryStatsRead_ReturnsTheSnapshot_WhenNoServerPropertiesRowIsStored() + { + using (var readLock = _fixture.DuckDb.AcquireReadLock()) + { + var conn = await SeedConnectionAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @" +INSERT INTO memory_stats + (collection_id, collection_time, server_id, server_name, total_physical_memory_mb, available_physical_memory_mb, + target_server_memory_mb, total_server_memory_mb, buffer_pool_mb) +VALUES ($1, $2, $3, $4, 1000, 400, 1000, 1000, 400)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); + cmd.Parameters.Add(new DuckDBParameter { Value = _now }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "OwnFiguresSrv" }); + await cmd.ExecuteNonQueryAsync(); + } + + var stats = await new LocalDataService(_fixture.DuckDb).GetLatestMemoryStatsAsync(ServerId); + + /* The read takes nothing from server_properties, so a server that has none still gets its memory figures. */ + Assert.NotNull(stats); + Assert.Equal(1_000, stats!.TotalPhysicalMemoryMb); + Assert.Equal(400, stats.AvailablePhysicalMemoryMb); + } +} diff --git a/Lite.Tests/FinOpsTests.cs b/Lite.Tests/FinOpsTests.cs index e2c6b9920..61dfa6c88 100644 --- a/Lite.Tests/FinOpsTests.cs +++ b/Lite.Tests/FinOpsTests.cs @@ -75,22 +75,27 @@ public async Task NoCpuSamples_CpuAndVmRightSizingAdviseNothing() public async Task AzureSqlDatabase_MemoryAndVmRightSizingAdviseNothing_BecauseItsMemoryComesWithItsServiceObjective() { // The database uses 40,960 MB of its own 167,117 MB memory limit, a share that advises on any other edition. Its - // service objective names 32 vCores, which are the database's own CPU count. Its memory cannot be resized on its own, - // so the memory and VM rules have nothing to recommend, and no text can carry the host's 911 GB from server_properties. + // service objective names 32 vCores, which is the CPU it is given. Its memory cannot be resized on its own, + // so the memory and VM rules have nothing to recommend. var recs = await RunRecommendationsAsync(s => s.SeedRightSizingScenarioAsync(engineEdition: 5, withCpuSamples: true, vcoreCount: 32)); PrintRecommendations("AZURE SQL DATABASE MEMORY AND VM RULES", recs); Assert.DoesNotContain(recs, r => r.Finding.StartsWith("Memory over-provisioned", StringComparison.Ordinal)); Assert.DoesNotContain(recs, r => r.Category == "Hardware"); - 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)); + // The CPU rule is not one of the two that stand down on a database: it reads the vCores the objective names, and it + // calls them vCores, as the utilization card does, in the finding and in the detail. + var cpu = Assert.Single(recs, r => r.Finding.StartsWith("CPU over-provisioned", StringComparison.Ordinal)); + Assert.StartsWith("CPU over-provisioned (32 vCores, P95 = ", cpu.Finding, StringComparison.Ordinal); + Assert.Contains("across 32 vCores. Consider reducing to ~", cpu.Detail, StringComparison.Ordinal); + Assert.EndsWith(" vCores.", cpu.Detail, StringComparison.Ordinal); + Assert.DoesNotContain(" cores", cpu.Finding + cpu.Detail, StringComparison.Ordinal); } [Fact] - public async Task AzureSqlDatabaseWithNoVcores_CpuRightSizingAdvisesNothing_BecauseTheHostsCpuCountIsNotTheDatabases() + public async Task AzureSqlDatabaseWithNoVcores_CpuRightSizingAdvisesNothing_BecauseTheStoredSchedulerCountIsNotTheCpuItIsGiven() { - // A DTU-model objective names no vCores. The stored cpu_count is the HOST's 32, so the CPU rule has no count to work from. + // A DTU-model objective names no vCores. The stored cpu_count (32 here) is the schedulers the database can see, not the + // CPU it is given, so the CPU rule has no count to work from. var recs = await RunRecommendationsAsync(s => s.SeedRightSizingScenarioAsync(engineEdition: 5, withCpuSamples: true)); PrintRecommendations("AZURE SQL DATABASE, NO VCORES", recs); @@ -107,7 +112,12 @@ public async Task WithCpuSamplesOffAzureSqlDatabase_CpuMemoryAndVmRightSizingSti var recs = await RunRecommendationsAsync(s => s.SeedRightSizingScenarioAsync(engineEdition, withCpuSamples: true)); PrintRecommendations($"RIGHT-SIZING UNCHANGED (edition {engineEdition})", recs); - Assert.Contains(recs, r => r.Finding.StartsWith("CPU over-provisioned", StringComparison.Ordinal)); + // Off an Azure SQL Database the count is a CPU count and the word is the one it always was. + var cpu = Assert.Single(recs, r => r.Finding.StartsWith("CPU over-provisioned", StringComparison.Ordinal)); + Assert.StartsWith("CPU over-provisioned (32 cores, P95 = ", cpu.Finding, StringComparison.Ordinal); + Assert.Contains("across 32 cores. Consider reducing to ~", cpu.Detail, StringComparison.Ordinal); + Assert.EndsWith(" cores.", cpu.Detail, StringComparison.Ordinal); + Assert.DoesNotContain("vCores", cpu.Finding + cpu.Detail, StringComparison.Ordinal); Assert.Contains(recs, r => r.Finding.StartsWith("Memory over-provisioned", StringComparison.Ordinal)); Assert.Contains(recs, r => r.Category == "Hardware" && r.Finding.StartsWith("CPU: reduce from 32", StringComparison.Ordinal)); Assert.Contains(recs, r => r.Category == "Hardware" && r.Finding.StartsWith("Memory: reduce from 256GB", StringComparison.Ordinal)); diff --git a/Lite.Tests/MemoryCollectorsDefinitionTests.cs b/Lite.Tests/MemoryCollectorsDefinitionTests.cs index 6b0b7e9f5..132ad1a17 100644 --- a/Lite.Tests/MemoryCollectorsDefinitionTests.cs +++ b/Lite.Tests/MemoryCollectorsDefinitionTests.cs @@ -58,8 +58,10 @@ public void NamesAndColumns_MatchDispatchAndSchema() MemoryStatsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray()); } + /// An Azure SQL Database's query returns NULL for the in-use worker count (it cannot read it). That NULL is stored as NULL, + /// not as 0, so a reader can tell "not collected" from "no workers in use". [Fact] - public async Task ReadAsync_MapsSingleRow_WithNullDefaults() + public async Task ReadAsync_KeepsANullInUseWorkerCountNull_NotZero() { using var reader = new FakeCollectorDataReader( new object[] { 64000m, 12000m, 8000m, 6000m, "Available", "Conventional", 48000m, 40000m, 30000m, 2000m, 704, DBNull.Value }); @@ -67,14 +69,35 @@ public async Task ReadAsync_MapsSingleRow_WithNullDefaults() var rows = await MemoryStatsCollector.Instance.ReadAsync(reader, CollectorTestContext.Make(s_deltas), CancellationToken.None); var row = Assert.Single(rows); - Assert.Equal(0, row.CurrentWorkersCount); + Assert.Null(row.CurrentWorkersCount); + Assert.Equal(704, row.MaxWorkersCount); Assert.Equal("Available", row.SystemMemoryState); var writer = new RecordingCollectorRowWriter(); MemoryStatsCollector.Instance.WritePayload(row, writer, CollectorTestContext.Make(s_deltas)); Assert.Equal(12, writer.Values.Count); Assert.Equal(64000m, writer.Values[0]); - Assert.Equal(0, writer.Values[11]); + Assert.Equal(704, writer.Values[10]); + Assert.Null(writer.Values[11]); + } + + [Fact] + public async Task ReadAsync_KeepsAnInUseWorkerCountAsRead_IncludingAGenuineZero() + { + foreach (var inUse in new[] { 0, 37 }) + { + using var reader = new FakeCollectorDataReader( + new object[] { 64000m, 12000m, 8000m, 6000m, "Available", "Conventional", 48000m, 40000m, 30000m, 2000m, 704, inUse }); + + var rows = await MemoryStatsCollector.Instance.ReadAsync(reader, CollectorTestContext.Make(s_deltas), CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Equal(inUse, row.CurrentWorkersCount); + + var writer = new RecordingCollectorRowWriter(); + MemoryStatsCollector.Instance.WritePayload(row, writer, CollectorTestContext.Make(s_deltas)); + Assert.Equal(inUse, writer.Values[11]); + } } [Fact] diff --git a/Lite.Tests/TestDataSeeder.cs b/Lite.Tests/TestDataSeeder.cs index 9065e5690..afbd2496f 100644 --- a/Lite.Tests/TestDataSeeder.cs +++ b/Lite.Tests/TestDataSeeder.cs @@ -2143,11 +2143,12 @@ await SeedServerPropertiesAsync(cpuCount: 32, htRatio: 2, physicalMemMb: 262_144 /// is not a measurement), and the memory and VM rules stand down on Azure SQL Database (edition 5), whose memory comes /// with its service objective and cannot be resized on its own. /// - /// The two tables differ on edition 5. server_properties holds the HOST's 32 CPUs and 933,836 MB. memory_stats holds + /// The two tables differ on edition 5. server_properties holds the host's 933,836 MB and a cpu_count of 32, which is the + /// schedulers the database can see, not the CPU it is given. memory_stats holds /// the database's own memory limit and counters: 167,117 MB (about 163 GB, what a 32-vCore Gen5 database is given) with the /// same 40,960 MB buffer pool. Pass to give it the vCore count its service objective names, or - /// leave it null for a DTU-model objective or an elastic pool, which has no CPU count of its own. Every other edition - /// has 256 GB in both tables. + /// leave it null for a DTU-model objective or an elastic pool, whose objective names no vCore count (its cpu_count is still its + /// own scheduler count). Every other edition has 256 GB in both tables. /// public async Task SeedRightSizingScenarioAsync(int engineEdition, bool withCpuSamples, int? vcoreCount = null) { diff --git a/Lite/Analysis/DrillDownCollector.Plans.cs b/Lite/Analysis/DrillDownCollector.Plans.cs index a0554cd5f..c04a4bce6 100644 --- a/Lite/Analysis/DrillDownCollector.Plans.cs +++ b/Lite/Analysis/DrillDownCollector.Plans.cs @@ -152,7 +152,9 @@ FROM v_server_config AS c WHERE c.server_id = $1 AND c.configuration_name = 'max degree of parallelism' AND c.capture_time = (SELECT MAX(capture_time) FROM v_server_config WHERE server_id = $1) - LIMIT 1) AS max_dop + LIMIT 1) AS max_dop, + p.engine_edition, + p.vcore_count FROM v_server_properties AS p WHERE p.server_id = $1 ORDER BY p.collection_time DESC @@ -162,13 +164,23 @@ ORDER BY p.collection_time DESC using var reader = await cmd.ExecuteReaderAsync(cancellationToken); if (!await reader.ReadAsync(cancellationToken)) return null; + /* Same rule as LocalDataService.GetServerMetadataForPlanAnalysisAsync: an Azure SQL Database's stored + physical_memory_mb is the HOST's, so the drill-down's server context carries no RAM figure, and its Hardware row + names the vCores. */ + int? engineEdition = reader.IsDBNull(6) ? null : Convert.ToInt32(reader.GetValue(6)); + int? vcoreCount = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)); + int? storedCpuCount = reader.IsDBNull(3) ? null : reader.GetInt32(3); + long? storedPhysicalMemoryMb = reader.IsDBNull(4) ? null : Convert.ToInt64(reader.GetValue(4)); + return new PerformanceMonitor.PlanAnalysis.ServerMetadata { Edition = reader.IsDBNull(0) ? null : reader.GetString(0), ProductVersion = reader.IsDBNull(1) ? null : reader.GetString(1), ProductLevel = reader.IsDBNull(2) ? null : reader.GetString(2), - CpuCount = reader.IsDBNull(3) ? 0 : reader.GetInt32(3), - PhysicalMemoryMB = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)), + CpuCount = storedCpuCount ?? 0, + PhysicalMemoryMB = ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L, + EngineEdition = engineEdition, + VcoreCount = vcoreCount, MaxDop = reader.IsDBNull(5) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(5))), }; } diff --git a/Lite/Analysis/DuckDbFactCollector.Config.cs b/Lite/Analysis/DuckDbFactCollector.Config.cs index 518aa0872..1bfa7f3d5 100644 --- a/Lite/Analysis/DuckDbFactCollector.Config.cs +++ b/Lite/Analysis/DuckDbFactCollector.Config.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using DuckDB.NET.Data; using PerformanceMonitor.Analysis; +using PerformanceMonitor.Common; using PerformanceMonitor.PlanAnalysis; using PerformanceMonitorLite.Database; @@ -324,10 +325,16 @@ private async Task CollectServerPropertiesFactsAsync(AnalysisContext context, Li await connection.OpenAsync(context.CancellationToken); using var cmd = connection.CreateCommand(); + /* On an Azure SQL Database (engine_edition 5) the CPU count is the vcore_count parsed from the service objective + (NULL for a DTU objective or an elastic pool, which leaves no fact), not the stored cpu_count: that is the number + of schedulers the database can see, which can be higher than its vCores (a 1-vCore database reads 2). There + hyperthread_ratio, physical_memory_mb, socket_count and cores_per_socket describe the HOST, and + FactCollectorHelpers.BuildServerHardwareFact carries none of them; the recommended MAXDOP is taken from the + vCores. Every other edition reads as it always did. The same CASE is in Darling's PgFactCollector. */ cmd.CommandText = @" -SELECT COALESCE(vcore_count, cpu_count) AS cpu_count, hyperthread_ratio, physical_memory_mb, +SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, hyperthread_ratio, physical_memory_mb, socket_count, cores_per_socket, is_hadr_enabled, edition, product_version, - lock_pages_in_memory, instant_file_initialization_enabled, memory_dump_count + lock_pages_in_memory, instant_file_initialization_enabled, memory_dump_count, engine_edition FROM v_server_properties WHERE server_id = $1 ORDER BY collection_time DESC @@ -348,30 +355,19 @@ ORDER BY collection_time DESC bool? lpim = reader.IsDBNull(8) ? (bool?)null : Convert.ToBoolean(reader.GetValue(8)); bool? ifi = reader.IsDBNull(9) ? (bool?)null : Convert.ToBoolean(reader.GetValue(9)); int? dumpCount = reader.IsDBNull(10) ? (int?)null : Convert.ToInt32(reader.GetValue(10)); + int? engineEdition = reader.IsDBNull(11) ? (int?)null : Convert.ToInt32(reader.GetValue(11)); + var hardwareIsTheHosts = ServerHardwareScope.HardwareIsTheHosts(engineEdition); - if (cpuCount == 0) return; + var hardwareFact = FactCollectorHelpers.BuildServerHardwareFact( + context, hardwareIsTheHosts, cpuCount, htRatio, physicalMemMb, socketCount, coresPerSocket, hadrEnabled); + if (hardwareFact is null) return; - facts.Add(new Fact - { - Source = "config", - Key = "SERVER_HARDWARE", - Value = cpuCount, - ServerId = context.ServerId, - Metadata = new Dictionary - { - ["cpu_count"] = cpuCount, - ["hyperthread_ratio"] = htRatio, - ["physical_memory_mb"] = physicalMemMb, - ["socket_count"] = socketCount, - ["cores_per_socket"] = coresPerSocket, - ["hadr_enabled"] = hadrEnabled ? 1 : 0 - } - }); + facts.Add(hardwareFact); // WS5 server-health advisories (advise-only). Gating mirrors the Dashboard collector so // both apps agree on what is worth flagging; a fact that would score 0 is simply never // emitted (noise control). - FactCollectorHelpers.EmitServerHealthFacts(context, facts, edition, physicalMemMb, lpim, ifi, dumpCount); + FactCollectorHelpers.EmitServerHealthFacts(context, facts, edition, physicalMemMb, lpim, ifi, dumpCount, hardwareIsTheHosts); } catch (Exception ex) when (!AnalysisAbandon.IsExpected(ex, context.CancellationToken)) { diff --git a/Lite/Analysis/DuckDbFactCollector.Resources.cs b/Lite/Analysis/DuckDbFactCollector.Resources.cs index e9cbb9f20..518cd5ea6 100644 --- a/Lite/Analysis/DuckDbFactCollector.Resources.cs +++ b/Lite/Analysis/DuckDbFactCollector.Resources.cs @@ -17,8 +17,9 @@ public partial class DuckDbFactCollector /// Collects memory stats: total physical RAM, buffer pool size, target memory — the newest sample /// within its collector's lookback of the window's end (#3896). /// These facts enable RESOURCE-based memory recommendations in the config audit: max server memory is - /// sized against the host's physical RAM, and no check in that audit branches on the edition (which the - /// payload reports for context only). + /// sized against the server's physical RAM (on an Azure SQL Database the collector stores the database's own memory limit in + /// that column, not the host's RAM), and no check in that audit branches on the edition (which the payload reports for + /// context only). /// private async Task CollectMemoryFactsAsync(AnalysisContext context, List facts) { diff --git a/Lite/Controls/FinOpsTab.xaml b/Lite/Controls/FinOpsTab.xaml index fa9446317..15e8b9314 100644 --- a/Lite/Controls/FinOpsTab.xaml +++ b/Lite/Controls/FinOpsTab.xaml @@ -298,7 +298,7 @@ - + diff --git a/Lite/Controls/FinOpsTab.xaml.cs b/Lite/Controls/FinOpsTab.xaml.cs index 155a319e7..83f868a15 100644 --- a/Lite/Controls/FinOpsTab.xaml.cs +++ b/Lite/Controls/FinOpsTab.xaml.cs @@ -363,6 +363,7 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) ProvisioningStatusBorder.Background = new SolidColorBrush(Colors.Gray); AvgCpuText.Text = P95CpuText.Text = MaxCpuText.Text = CpuSamplesText.Text = "-"; CpuCountText.Text = "-"; + CpuCountUnitText.Text = ServerHardwareScope.CpuCountUnit(null); WorkerThreadsText.Text = "-"; AvgCpuBar.Width = P95CpuBar.Width = MaxCpuBar.Width = 0; MemoryUtilBar.Width = MemoryRatioBar.Width = 0; @@ -402,9 +403,12 @@ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) P95CpuText.Text = $"{data.P95CpuPct:N2}%"; MaxCpuText.Text = $"{data.MaxCpuPct}%"; CpuSamplesText.Text = data.CpuSamples.ToString("N0"); - /* n/a on an Azure SQL Database whose service objective names no vCores: the host's count is never shown as the database's. */ + /* On an Azure SQL Database the count is its vCores, named as vCores, and n/a where its service objective names none: the + scheduler count it can see is never shown as the CPU it is given. */ CpuCountText.Text = ServerHardwareScope.CpuCountText(data.EngineEdition, data.CpuCount); - WorkerThreadsText.Text = $"{data.CurrentWorkersCount:N0} / {data.MaxWorkersCount:N0}"; + CpuCountUnitText.Text = ServerHardwareScope.CpuCountUnit(data.EngineEdition); + /* The in-use count is n/a where it was not collected (NULL on an Azure SQL Database), never 0; the maximum shows as stored. */ + WorkerThreadsText.Text = ServerHardwareScope.WorkerThreadsText(data.CurrentWorkersCount, data.MaxWorkersCount); SetBar(AvgCpuBar, AvgCpuFilled, AvgCpuEmpty, (double)data.AvgCpuPct); SetBar(P95CpuBar, P95CpuFilled, P95CpuEmpty, (double)data.P95CpuPct); @@ -468,6 +472,8 @@ pressure or worker saturation would have been explained as a memory ratio that n /* 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(); + /* A window with no CPU sample has no CPU term (ComputeHealthScore leaves it out), and the tooltip says so. */ + HealthScoreBorder.ToolTip = data.HasCpuSample ? null : ServerHardwareScope.HealthScoreWithoutCpuNote; HealthScoreText.Text = $"Health: {data.HealthScore}"; HealthScoreBorder.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(data.HealthScoreColor)); HealthScoreBorder.Visibility = Visibility.Visible; @@ -737,7 +743,9 @@ private async System.Threading.Tasks.Task LoadServerInventoryAsync(bool forceRef // Compute health scores for each server foreach (var item in data) { - var cpuScore = FinOpsHealthCalculator.CpuScore(item.AvgCpuPct ?? 0m); + /* A server with no CPU sample in the window has a null average: its CPU term is left out, because scoring it + as 0% CPU would hand it a full 100 made from nothing. */ + int? cpuScore = item.AvgCpuPct is decimal avgCpu ? FinOpsHealthCalculator.CpuScore(avgCpu) : null; var memScore = 80; // Default — we don't have buffer pool ratio in inventory var storScore = FinOpsHealthCalculator.StorageScore(50); // Default — no file-level free space in inventory item.HealthScore = FinOpsHealthCalculator.Overall(cpuScore, memScore, storScore); diff --git a/Lite/Controls/ServerTab.Charts.cs b/Lite/Controls/ServerTab.Charts.cs index 93726c687..6132697b6 100644 --- a/Lite/Controls/ServerTab.Charts.cs +++ b/Lite/Controls/ServerTab.Charts.cs @@ -30,6 +30,13 @@ public partial class ServerTab : UserControl private void UpdateMemorySummary(MemoryStatsRow? stats) { + /* On an Azure SQL Database the first two figures are the database's memory limit and the room left under it, not the + host's RAM, so they are named that way (the same words the FinOps utilization card uses). The names follow the tab's + own edition, the value the page-file and memory-state lines below read (_isAzureSqlDatabase is derived from it), so + one panel never names a figure one way and shows its neighbor the other. */ + PhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabTotalLabel(_engineEdition); + AvailablePhysicalMemoryLabel.Text = ServerHardwareScope.MemoryTabAvailableLabel(_engineEdition); + if (stats == null) { PhysicalMemoryText.Text = "--"; diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml index ae0383ea3..79d74be30 100644 --- a/Lite/Controls/ServerTab.xaml +++ b/Lite/Controls/ServerTab.xaml @@ -1491,7 +1491,7 @@ - + @@ -1511,7 +1511,7 @@ - + diff --git a/Lite/Mcp/McpAnalysisTools.cs b/Lite/Mcp/McpAnalysisTools.cs index 562049968..ae149ded4 100644 --- a/Lite/Mcp/McpAnalysisTools.cs +++ b/Lite/Mcp/McpAnalysisTools.cs @@ -595,8 +595,9 @@ the collector missed changes nothing about what the server is configured to. (Ph var totalDbSizeMb = factsByKey.TryGetValue("DATABASE_TOTAL_SIZE_MB", out var dbFact) ? dbFact.Value : 0; var editionName = AuditEditionName(edition); - var coresPerSocket = factsByKey.TryGetValue("SERVER_HARDWARE", out var hwFact) - && hwFact.Metadata.TryGetValue("cores_per_socket", out var cps) ? (int)cps : 0; + /* The recommended MAXDOP follows the cores per socket the SERVER_HARDWARE fact carries. On an Azure SQL Database the fact carries its vCores instead, because the stored + cores_per_socket is the host's (see FactRemediation.MaxdopBasisFrom). */ + var maxdopBasis = FactRemediation.MaxdopBasisFrom(factsByKey); var recommendations = new List(); @@ -629,11 +630,11 @@ the collector missed changes nothing about what the server is configured to. (Ph } } - // MAXDOP audit — topology-based (min(cores-per-socket, 8)), NOT edition-based. + // MAXDOP audit — topology-based (min(cores-per-socket, 8); min(vCores, 8) on an Azure SQL Database), NOT edition-based. if (factsByKey.TryGetValue("CONFIG_MAXDOP", out var maxdopFact)) { var maxdop = (int)maxdopFact.Value; - var recommended = (int)FactRemediation.RecommendedMaxdop(coresPerSocket); + var recommended = (int)FactRemediation.RecommendedMaxdop(maxdopBasis.Cores); if (maxdop == 0) { @@ -641,7 +642,7 @@ the collector missed changes nothing about what the server is configured to. (Ph $"MAXDOP is 0 (unlimited). This lets one query fan out across all schedulers, " + $"leading to CXPACKET waits and thread exhaustion under load. Microsoft's guidance is " + $"topology-based: keep MAXDOP at or under the logical processors in a single NUMA node, capped at 8. " + - $"Start with {recommended} (this server's cores-per-socket, capped at 8) and adjust to the workload.")); + $"Start with {recommended} ({(maxdopBasis.FromVcores ? "this database's vCores" : "this server's cores-per-socket")}, capped at 8) and adjust to the workload.")); } else if (maxdop == 1 && recommended > 1) { diff --git a/Lite/Mcp/McpMemoryTools.cs b/Lite/Mcp/McpMemoryTools.cs index 8c36be65c..2992d1fbf 100644 --- a/Lite/Mcp/McpMemoryTools.cs +++ b/Lite/Mcp/McpMemoryTools.cs @@ -28,26 +28,13 @@ public static async Task GetMemoryStats( ?? McpHelpers.Status("unavailable", "No memory stats available."); } - /* On an Azure SQL Database the collector stores the constant "Available" as the memory state. It is not a - reading, so the state is null there and the note beside it says why (ServerHardwareScope). */ + /* ONE edition for the whole answer, read once from the source every Lite MCP engine gate reads (the newest + collected server_properties row), the same one NotCollectedStatusAsync reads on this tool's miss path. + engine_edition, memory_note and the memory-state pair all follow it, so the tool cannot disagree with its + own gate. The memory read carries no edition of its own. */ var engineEdition = await McpEngineCapability.EngineEditionAsync(dataService, resolved.ServerId); - return JsonSerializer.Serialize(new - { - server = resolved.ServerName, - /* #3541 A10: the one stamp every latest-snapshot read publishes, under the one name. */ - captured_at = stats.CollectionTime.ToString("o"), - total_physical_memory_mb = stats.TotalPhysicalMemoryMb, - available_physical_memory_mb = stats.AvailablePhysicalMemoryMb, - memory_utilization_pct = Math.Round(stats.MemoryUtilizationPercent, 1), - system_memory_state = ServerHardwareScope.MemoryStateOrNull(engineEdition, stats.SystemMemoryState), - system_memory_state_note = ServerHardwareScope.MemoryStateNoteFor(engineEdition), - sql_memory_model = stats.SqlMemoryModel, - target_server_memory_mb = stats.TargetServerMemoryMb, - total_server_memory_mb = stats.TotalServerMemoryMb, - buffer_pool_mb = stats.BufferPoolMb, - plan_cache_mb = stats.PlanCacheMb - }, McpHelpers.JsonOptions); + return MemoryStatsPayload(resolved.ServerName, stats, engineEdition); } catch (Exception ex) { @@ -55,6 +42,47 @@ public static async Task GetMemoryStats( } } + /// + /// The get_memory_stats payload for one snapshot, built from the row and the ONE engine edition the tool read for this + /// answer (; + /// when the store has none). engine_edition (null when the edition is unknown), memory_note and the memory-state + /// pair all follow that one value. The row carries no edition. + /// + /// On an Azure SQL Database (engine edition 5) total_physical_memory_mb is the database's memory limit and + /// available_physical_memory_mb the room left under it, not the host's RAM, and a utilization near 100% is normal there. + /// The keys keep their names on every edition, so the payload gains a memory_note, last, that says so. The collector + /// stores the constant "Available" as the memory state there, which is not a reading, so system_memory_state is null and + /// system_memory_state_note says why (). Every other edition keeps the + /// stored state and no memory_note, and its system_memory_state_note is null. Darling's tool emits the same + /// shape in the same words. + /// + internal static string MemoryStatsPayload(string serverName, MemoryStatsRow stats, int engineEdition) + { + var payload = new + { + server = serverName, + /* #3541 A10: the one stamp every latest-snapshot read publishes, under the one name. */ + captured_at = stats.CollectionTime.ToString("o"), + total_physical_memory_mb = stats.TotalPhysicalMemoryMb, + available_physical_memory_mb = stats.AvailablePhysicalMemoryMb, + memory_utilization_pct = Math.Round(stats.MemoryUtilizationPercent, 1), + system_memory_state = ServerHardwareScope.MemoryStateOrNull(engineEdition, stats.SystemMemoryState), + system_memory_state_note = ServerHardwareScope.MemoryStateNoteFor(engineEdition), + sql_memory_model = stats.SqlMemoryModel, + target_server_memory_mb = stats.TargetServerMemoryMb, + total_server_memory_mb = stats.TotalServerMemoryMb, + buffer_pool_mb = stats.BufferPoolMb, + plan_cache_mb = stats.PlanCacheMb, + engine_edition = engineEdition == CollectorEngineCapability.UnknownEngineEdition ? (int?)null : engineEdition + }; + + if (!ServerHardwareScope.HardwareIsTheHosts(engineEdition)) + return JsonSerializer.Serialize(payload, McpHelpers.JsonOptions); + + var scoped = JsonSerializer.SerializeToNode(payload, McpHelpers.JsonOptions)!.AsObject(); + return ServerHardwareScope.WithMemoryNote(scoped).ToJsonString(McpHelpers.JsonOptions); + } + [McpServerTool(Name = "get_memory_trend"), Description("Gets memory usage over time in time buckets: total server, target, buffer pool and plan cache memory, with granted memory from the memory-grant series joined per bucket. total_granted_mb is null where that series has no snapshot (granted_note says why); use get_memory_grants for grant detail." + BaselineDiscontinuities.DescriptionSentence)] public static async Task GetMemoryTrend( LocalDataService dataService, diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index 6e02f50c7..84c9ac1be 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -92,8 +92,9 @@ ratio is omitted rather than invented when a denominator piece is missing. One n await Task.WhenAll(cpuAggregateTask, propertiesTask); var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; - /* The core count is the server's own: on an Azure SQL Database the stored cpu_count is the HOST's, so this divides by its - vcore_count, or omits the ratio for a DTU-model objective or an elastic pool (see CpuAttribution). */ + /* The core count is what the server is given: on an Azure SQL Database the stored cpu_count is the schedulers it can see, + which can be more than its vCores, so this divides by its vcore_count, or omits the ratio for a DTU-model objective or + an elastic pool (see CpuAttribution). */ var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuMs) / 1000.0, requestedStart, nowUtc, @@ -221,8 +222,9 @@ public static async Task GetTopProceduresByCpu( await Task.WhenAll(cpuAggregateTask, propertiesTask); var cpuAggregate = await cpuAggregateTask; var properties = await propertiesTask; - /* The core count is the server's own: on an Azure SQL Database the stored cpu_count is the HOST's, so this divides by its - vcore_count, or omits the ratio for a DTU-model objective or an elastic pool (see CpuAttribution). */ + /* The core count is what the server is given: on an Azure SQL Database the stored cpu_count is the schedulers it can see, + which can be more than its vCores, so this divides by its vcore_count, or omits the ratio for a DTU-model objective or + an elastic pool (see CpuAttribution). */ var attribution = CpuAttribution.Compute( rows.Sum(r => r.TotalCpuMs) / 1000.0, requestedStart, nowUtc, diff --git a/Lite/Mcp/McpServerInfoTools.cs b/Lite/Mcp/McpServerInfoTools.cs index a11183d70..101321da9 100644 --- a/Lite/Mcp/McpServerInfoTools.cs +++ b/Lite/Mcp/McpServerInfoTools.cs @@ -9,7 +9,7 @@ namespace PerformanceMonitorLite.Mcp; [McpServerToolType] public sealed class McpServerInfoTools { - [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, memory, socket/core topology, HADR, clustering, and the clock (utc_offset_minutes, time_zone_id). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected, and on a stalled collector it is the only sign of staleness. time_zone_id is CURRENT_TIMEZONE_ID() (SQL Server 2022+/Azure SQL only); null means a pre-2022 engine, so only the offset in force at captured_at is known, and an instant across a DST transition from it can read an hour off. <> Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, clustering, and the server's clock: utc_offset_minutes is the UTC offset in force when the snapshot was collected, and time_zone_id is the engine's own time-zone name (CURRENT_TIMEZONE_ID(), SQL Server 2022+ and Azure SQL only) - a null time_zone_id means a pre-2022 engine, where only the offset is known and any instant on the far side of a DST transition from the snapshot is placed an hour off by that offset. Use for capacity planning and edition-aware recommendations. LATEST IS A TIME: this reads the newest properties snapshot, not a window, and captured_at is the instant it was collected - a core count or memory figure here is what the server reported AT that stamp, and on a server whose collector has stalled the stamp is the only thing that says how stale it is. ON AN AZURE SQL DATABASE (engine_edition 5) the host's hardware is not the database's allocation: cpu_count, hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb come back null with a hardware_note, and service_objective with vcore_count says what the database is given.")] + [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, memory, socket/core topology, HADR, clustering, and the clock (utc_offset_minutes, time_zone_id). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected, and on a stalled collector it is the only sign of staleness. time_zone_id is CURRENT_TIMEZONE_ID() (SQL Server 2022+/Azure SQL only); null means a pre-2022 engine, so only the offset in force at captured_at is known, and an instant across a DST transition from it can read an hour off. <> Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, clustering, and the server's clock: utc_offset_minutes is the UTC offset in force when the snapshot was collected, and time_zone_id is the engine's own time-zone name (CURRENT_TIMEZONE_ID(), SQL Server 2022+ and Azure SQL only) - a null time_zone_id means a pre-2022 engine, where only the offset is known and any instant on the far side of a DST transition from the snapshot is placed an hour off by that offset. Use for capacity planning and edition-aware recommendations. LATEST IS A TIME: this reads the newest properties snapshot, not a window, and captured_at is the instant it was collected - a core count or memory figure here is what the server reported AT that stamp, and on a server whose collector has stalled the stamp is the only thing that says how stale it is. ON AN AZURE SQL DATABASE (engine_edition 5) the host's hardware is not the database's allocation: hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb come back null with a hardware_note, cpu_count is the database's own scheduler count (it can be higher than its vCores), and service_objective with vcore_count says what the database is given.")] public static async Task GetServerProperties( LocalDataService dataService, ServerManager serverManager, @@ -35,10 +35,10 @@ public static async Task GetServerProperties( /// /// The get_server_properties payload for one snapshot. On an Azure SQL Database (engine edition 5) the stored - /// cpu_count, hyperthread_ratio, socket_count, cores_per_socket and - /// physical_memory_mb are the HOST's, so they come back null, vcore_count (what the service objective - /// gives the database) rides beside service_objective, and a hardware_note says why. Every other - /// edition keeps the payload it always had, key for key. Lite's tool emits the same shape in the same words. + /// hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb are the HOST's, so + /// they come back null, cpu_count (the database's own scheduler count) passes through, vcore_count (what the + /// service objective gives the database) rides beside service_objective, and a hardware_note says why. Every + /// other edition keeps the payload it always had, key for key. Darling's tool emits the same shape in the same words. /// internal static string ServerPropertiesPayload(string serverName, ServerPropertiesRow row) { diff --git a/Lite/Services/LocalDataService.FinOps.Recommendations.cs b/Lite/Services/LocalDataService.FinOps.Recommendations.cs index 8d32ab0a9..8c3511afb 100644 --- a/Lite/Services/LocalDataService.FinOps.Recommendations.cs +++ b/Lite/Services/LocalDataService.FinOps.Recommendations.cs @@ -13,6 +13,7 @@ using DuckDB.NET.Data; using Microsoft.Data.SqlClient; using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; namespace PerformanceMonitorLite.Services; @@ -295,14 +296,17 @@ IF @sql <> N'' { var targetCores = Math.Max(4, (int)(util.CpuCount * (util.P95CpuPct / 70m))); var savingsPct = 1m - ((decimal)targetCores / util.CpuCount); + /* The count is the vCores the service objective gives an Azure SQL Database, so it is named as the utilization card + names it; everywhere else it is the CPU count, and the word stays "cores". */ + var cpuNoun = ServerHardwareScope.CpuCoreNoun(util.EngineEdition); recommendations.Add(new RecommendationRow { Category = "Compute", Severity = util.P95CpuPct < 15 ? "High" : "Medium", Confidence = "Medium", - Finding = $"CPU over-provisioned ({util.CpuCount} cores, P95 = {util.P95CpuPct:N1}%)", - Detail = $"P95 CPU utilization is {util.P95CpuPct:N1}% (avg {util.AvgCpuPct:N1}%, max {util.MaxCpuPct}%) across {util.CpuCount} cores. " + - $"Consider reducing to ~{targetCores} cores.", + Finding = $"CPU over-provisioned ({util.CpuCount} {cpuNoun}, P95 = {util.P95CpuPct:N1}%)", + Detail = $"P95 CPU utilization is {util.P95CpuPct:N1}% (avg {util.AvgCpuPct:N1}%, max {util.MaxCpuPct}%) across {util.CpuCount} {cpuNoun}. " + + $"Consider reducing to ~{targetCores} {cpuNoun}.", EstMonthlySavings = monthlyCost > 0 ? monthlyCost * savingsPct * 0.60m : null }); } diff --git a/Lite/Services/LocalDataService.FinOps.ServerProperties.cs b/Lite/Services/LocalDataService.FinOps.ServerProperties.cs index c18a35348..3c96878cf 100644 --- a/Lite/Services/LocalDataService.FinOps.ServerProperties.cs +++ b/Lite/Services/LocalDataService.FinOps.ServerProperties.cs @@ -321,7 +321,7 @@ GROUP BY server_id c.max_cpu_pct, c.p95_cpu_pct, COALESCE(m.max_workers_count, 0), - COALESCE(m.current_workers_count, 0), + m.current_workers_count, COALESCE(g.max_grant_waiters, 0), COALESCE(g.grant_timeouts, 0), COALESCE(g.forced_grants, 0), @@ -380,6 +380,6 @@ FROM known_servers s forcedGrants: reader.IsDBNull(10) ? 0L : ToInt64(reader.GetValue(10)), grantUtilizationPercent: reader.IsDBNull(11) ? 0m : Convert.ToDecimal(reader.GetValue(11)), maxWorkers: reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6)), - currentWorkers: reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7))); + currentWorkers: reader.IsDBNull(7) ? (int?)null : Convert.ToInt32(reader.GetValue(7))); } } diff --git a/Lite/Services/LocalDataService.FinOps.Utilization.cs b/Lite/Services/LocalDataService.FinOps.Utilization.cs index 6bfc52cbb..39132facb 100644 --- a/Lite/Services/LocalDataService.FinOps.Utilization.cs +++ b/Lite/Services/LocalDataService.FinOps.Utilization.cs @@ -52,10 +52,10 @@ FROM v_memory_stats ORDER BY collection_time DESC LIMIT 1 ), -/* cpu_count is the count the server itself has. On an Azure SQL Database (engine_edition 5) the stored cpu_count - describes the HOST (a 1-vCore serverless database read 2), so there it is the vcore_count parsed from the service - objective, and NULL for an objective that names no vCores (a DTU-model objective or an elastic pool): never the - host count. Every other edition reads as it always did. The same CASE is in the Darling read. */ +/* cpu_count is the count CPU percent is measured against. On an Azure SQL Database (engine_edition 5) the stored cpu_count + is the number of schedulers the database can see (a 1-vCore serverless database read 2), not what it is given, so there it + is the vcore_count parsed from the service objective, and NULL for an objective that names no vCores (a DTU-model objective + or an elastic pool): never the scheduler count. Every other edition reads as it always did. The same CASE is in the Darling read. */ server_info AS ( SELECT CASE WHEN engine_edition = 5 THEN vcore_count ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, engine_edition FROM v_server_properties @@ -112,7 +112,8 @@ LEFT JOIN server_info s ON true var memRatio = reader.IsDBNull(8) ? 0m : Convert.ToDecimal(reader.GetValue(8)); var maxWorkers = reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)); - var currentWorkers = reader.IsDBNull(10) ? 0 : Convert.ToInt32(reader.GetValue(10)); + /* NULL is "not collected" (an Azure SQL Database), not 0 workers in use: it shows as n/a and the verdict reads it as unknown. */ + int? currentWorkers = reader.IsDBNull(10) ? null : Convert.ToInt32(reader.GetValue(10)); /* memory_ratio is still SELECTed and still displayed — it is a real fact about the instance — but it is no longer part of the verdict: Total over Target Server Memory converges at 1.0 on any warmed @@ -210,7 +211,7 @@ GROUP BY CAST(collection_time AS DATE) COALESCE(g.forced_grants, 0), COALESCE(g.grant_utilization_pct, 0), COALESCE(m.max_workers_count, 0), - COALESCE(m.current_workers_count, 0) + m.current_workers_count FROM daily_cpu c LEFT JOIN daily_mem m ON m.day = c.day LEFT JOIN daily_grants g ON g.day = c.day @@ -235,7 +236,7 @@ FROM daily_cpu c forcedGrants: reader.IsDBNull(7) ? 0L : ToInt64(reader.GetValue(7)), grantUtilizationPercent: reader.IsDBNull(8) ? 0m : Convert.ToDecimal(reader.GetValue(8)), maxWorkers: reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)), - currentWorkers: reader.IsDBNull(10) ? 0 : Convert.ToInt32(reader.GetValue(10))); + currentWorkers: reader.IsDBNull(10) ? (int?)null : Convert.ToInt32(reader.GetValue(10))); items.Add(new ProvisioningTrendRow { diff --git a/Lite/Services/LocalDataService.FinOps.cs b/Lite/Services/LocalDataService.FinOps.cs index 7dae1b330..e19bdc187 100644 --- a/Lite/Services/LocalDataService.FinOps.cs +++ b/Lite/Services/LocalDataService.FinOps.cs @@ -101,12 +101,15 @@ public class UtilizationEfficiencyRow public decimal GrantUtilizationPct { get; set; } public int MaxWorkersCount { get; set; } - public int CurrentWorkersCount { get; set; } - /// The server's OWN CPU count, 0 when there is none. On an Azure SQL Database ( 5) the - /// stored cpu_count is the HOST's, so this is the vcore_count parsed from the service objective and 0 for an - /// objective that names none (a DTU-model objective or an elastic pool), which the card shows as n/a. It is never the host's - /// count. + /// Workers in use at the latest sample. null where the collector cannot read it (an Azure SQL Database stores + /// NULL), which the card shows as n/a: it is never 0, and the verdict treats it as unknown. + public int? CurrentWorkersCount { get; set; } + + /// The CPU count CPU percent is measured against, 0 when there is none. On an Azure SQL Database + /// ( 5) that is the vcore_count parsed from the service objective, not the stored + /// cpu_count (the schedulers the database can see, which can be higher than its vCores), and it is 0 for an objective + /// that names none (a DTU-model objective or an elastic pool), which the card shows as n/a. public int CpuCount { get; set; } /// The engine edition of the server these figures describe (SERVERPROPERTY('EngineEdition'), 0 when unread). @@ -135,14 +138,16 @@ public class UtilizationEfficiencyRow /// /// 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. + /// SQL Database those are the database's own (its memory limit, not the host's RAM), so the memory term is worked the same way + /// on every edition. A window with no CPU sample ( false) leaves the CPU term out: its p95 is a 0 + /// that came from nothing, and scoring that 0 would hand the server a full 100. /// public int ComputeHealthScore() { var bpRatio = PhysicalMemoryMb > 0 ? (decimal)BufferPoolMb / PhysicalMemoryMb : 0m; + int? cpuScore = HasCpuSample ? FinOpsHealthCalculator.CpuScore(P95CpuPct) : null; return FinOpsHealthCalculator.Overall( - FinOpsHealthCalculator.CpuScore(P95CpuPct), FinOpsHealthCalculator.MemoryScore(bpRatio), FinOpsHealthCalculator.StorageScore(FreeSpacePct)); + cpuScore, FinOpsHealthCalculator.MemoryScore(bpRatio), FinOpsHealthCalculator.StorageScore(FreeSpacePct)); } } @@ -287,10 +292,11 @@ public class ServerPropertyRow public string? ProductUpdateLevel { get; set; } public int EngineEdition { get; set; } - /* The four hardware cells below read as ABSENT for an Azure SQL Database (engine edition 5): its sys.dm_os_sys_info - values are the HOST's, not the database's allocation (a 1-vCore serverless database read 2 CPUs, 0 sockets, 32 - cores per socket and 911.9 GB), and the grid draws an absent value as a blank cell. The read values are kept - behind the properties, so the order the loader assigns them in does not matter and no calculation loses its input. */ + /* Three of the four hardware cells below read as ABSENT for an Azure SQL Database (engine edition 5): its + sys.dm_os_sys_info memory, socket count and cores per socket are the HOST's, not the database's allocation (a 1-vCore + database read 0 sockets, 32 cores per socket and about 912 GB), and the grid draws an absent value as a blank cell. The + CPU count is the database's own scheduler count (a 1-vCore database reads 2), so it is shown as read. The read values are + kept behind the properties, so the order the loader assigns them in does not matter and no calculation loses its input. */ private int _cpuCount; private long _physicalMemoryMb; private int? _socketCount; @@ -298,7 +304,7 @@ public class ServerPropertyRow private string? _hardwareUnavailableReason; private bool HostHardware => ServerHardwareScope.HardwareIsTheHosts(EngineEdition); - public int? CpuCount { get => HostHardware ? null : _cpuCount; set => _cpuCount = value ?? 0; } + public int? CpuCount { get => _cpuCount; set => _cpuCount = value ?? 0; } public long? PhysicalMemoryMb { get => HostHardware ? null : _physicalMemoryMb; set => _physicalMemoryMb = value ?? 0L; } public int? SocketCount { get => HostHardware ? null : _socketCount; set => _socketCount = value; } public int? CoresPerSocket { get => HostHardware ? null : _coresPerSocket; set => _coresPerSocket = value; } @@ -440,8 +446,20 @@ public static int StorageScore(decimal freeSpacePct) return (int)(freeSpacePct * 5); } - public static int Overall(int cpu, int memory, int storage) => - (int)(cpu * 0.40 + memory * 0.30 + storage * 0.30); + /// + /// The overall score: CPU 40%, memory 30%, storage 30%. A null means the window held no CPU + /// sample: there is nothing to score, and scoring the 0 it reads as would be a full 100 made from nothing. The term is + /// then left out, not scored as zero and not scored as a default, and memory and storage keep their weights over their + /// own total (30:30 over 60). + /// + public static int Overall(int? cpu, int memory, int storage) + { + if (cpu is int cpuScore) + return (int)(cpuScore * 0.40 + memory * 0.30 + storage * 0.30); + + /* integer weights, so no floating-point error can truncate 100 to 99 */ + return (memory * 30 + storage * 30) / 60; + } public static string ScoreColor(int score) => score switch { diff --git a/Lite/Services/LocalDataService.PlanServerMetadata.cs b/Lite/Services/LocalDataService.PlanServerMetadata.cs index 909bb83cc..30474d9dd 100644 --- a/Lite/Services/LocalDataService.PlanServerMetadata.cs +++ b/Lite/Services/LocalDataService.PlanServerMetadata.cs @@ -9,6 +9,7 @@ using System; using System.Threading.Tasks; using DuckDB.NET.Data; +using PerformanceMonitor.Common; using PerformanceMonitor.PlanAnalysis; namespace PerformanceMonitorLite.Services; @@ -92,7 +93,9 @@ FROM v_database_config AS d WHERE d.server_id = $1 AND d.database_name = $2 AND d.capture_time = (SELECT MAX(capture_time) FROM v_database_config WHERE server_id = $1 AND database_name = $2) - LIMIT 1) AS is_parameterization_forced + LIMIT 1) AS is_parameterization_forced, + p.engine_edition, + p.vcore_count FROM v_server_properties AS p WHERE p.server_id = $1 ORDER BY p.collection_time DESC @@ -119,13 +122,23 @@ ORDER BY p.collection_time DESC using var reader = await command.ExecuteReaderAsync(); if (!await reader.ReadAsync()) return null; + /* On an Azure SQL Database the Server Context card's Hardware row names the vCores (n/a for a DTU objective or an + elastic pool) and no RAM: the stored physical_memory_mb is the HOST's. The stored cpu_count there is the + database's own scheduler count and is not what the card shows. Every other edition reads as it always did. */ + int? engineEdition = reader.IsDBNull(16) ? null : Convert.ToInt32(reader.GetValue(16)); + int? vcoreCount = reader.IsDBNull(17) ? null : Convert.ToInt32(reader.GetValue(17)); + int? storedCpuCount = reader.IsDBNull(3) ? null : reader.GetInt32(3); + long? storedPhysicalMemoryMb = reader.IsDBNull(4) ? null : ToInt64(reader.GetValue(4)); + return new ServerMetadata { Edition = reader.IsDBNull(0) ? null : reader.GetString(0), ProductVersion = reader.IsDBNull(1) ? null : reader.GetString(1), ProductLevel = reader.IsDBNull(2) ? null : reader.GetString(2), - CpuCount = reader.IsDBNull(3) ? 0 : reader.GetInt32(3), - PhysicalMemoryMB = reader.IsDBNull(4) ? 0L : ToInt64(reader.GetValue(4)), + CpuCount = storedCpuCount ?? 0, + PhysicalMemoryMB = ServerHardwareScope.OwnPhysicalMemoryMb(engineEdition, storedPhysicalMemoryMb) ?? 0L, + EngineEdition = engineEdition, + VcoreCount = vcoreCount, MaxDop = reader.IsDBNull(5) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(5))), CostThresholdForParallelism = reader.IsDBNull(6) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(6))), MaxServerMemoryMB = reader.IsDBNull(7) ? 0L : ToInt64(reader.GetValue(7)), diff --git a/Lite/Services/LocalDataService.ServerInfo.cs b/Lite/Services/LocalDataService.ServerInfo.cs index 0d0f3f3a1..efc0de8dd 100644 --- a/Lite/Services/LocalDataService.ServerInfo.cs +++ b/Lite/Services/LocalDataService.ServerInfo.cs @@ -61,7 +61,7 @@ ORDER BY collection_time DESC UtcOffsetMinutes = reader.IsDBNull(15) ? null : reader.GetInt32(15), TimeZoneId = reader.IsDBNull(16) ? null : reader.GetString(16), /* The vCore count parsed from an Azure SQL Database's service objective; null elsewhere and for a - DTU-model objective or an elastic pool. It is what describes the database where cpu_count describes the host. */ + DTU-model objective or an elastic pool. It is what the database is given, where cpu_count is the schedulers it can see. */ VcoreCount = reader.IsDBNull(17) ? null : reader.GetInt32(17) }; } diff --git a/PerformanceMonitor.Analysis/FactAdvice.cs b/PerformanceMonitor.Analysis/FactAdvice.cs index 9954596f9..c23d42d2d 100644 --- a/PerformanceMonitor.Analysis/FactAdvice.cs +++ b/PerformanceMonitor.Analysis/FactAdvice.cs @@ -449,14 +449,6 @@ private sealed record StoryAdvice(string? h, string? i, string? r); private static long? FactValue(IReadOnlyDictionary facts, string key) => facts.TryGetValue(key, out var f) ? (long)Math.Round(f.Value) : (long?)null; - /// - /// Cores-per-socket from SERVER_HARDWARE metadata — the per-NUMA-node proxy MAXDOP guidance keys - /// on (NUMA node count itself is not collected). 0 when absent. - /// - private static int CoresPerSocket(IReadOnlyDictionary facts) => - facts.TryGetValue("SERVER_HARDWARE", out var hw) - && hw.Metadata.TryGetValue("cores_per_socket", out var c) ? (int)c : 0; - /// /// The collection-gap caveat appended to every THREADPOOL-family block: under live thread /// exhaustion the collector is itself a query waiting for a worker, so a gap in Collection Health @@ -482,9 +474,9 @@ private static AdviceBlock ComposeThreadpoolParallel(IReadOnlyDictionary @@ -500,14 +492,14 @@ private static AdviceBlock ComposeThreadpoolMixed(IReadOnlyDictionary - private static string ParallelGuardCore(long? maxdop, long? ctfp, int cores, long rec) + private static string ParallelGuardCore(long? maxdop, long? ctfp, FactRemediation.MaxdopBasis basis, long rec) { var sb = new StringBuilder(); @@ -528,7 +520,7 @@ private static string ParallelGuardCore(long? maxdop, long? ctfp, int cores, lon .Append(maxdop?.ToString() ?? "not readable this window") .Append(" and cost threshold for parallelism is ") .Append(ctfp?.ToString() ?? "not readable this window") - .Append(cores > 0 ? $" (cores per socket {cores})." : "."); + .Append(basis.Cores > 0 ? $" {basis.Note}." : "."); var ctfpGuarded = ctfp is >= 50; var maxdopGuarded = maxdop is > 0 && maxdop <= rec; @@ -554,9 +546,9 @@ private static string ParallelGuardCore(long? maxdop, long? ctfp, int cores, lon sb.Append(" cost threshold for parallelism is already past the trivial-query cutoff"); if (maxdop is 0) - sb.Append($", and cap MAXDOP at {rec} (this server's per-NUMA-node processor count, capped at 8) instead of unlimited"); + sb.Append($", and cap MAXDOP at {rec} ({basis.Source}, capped at 8) instead of unlimited"); else if (maxdop > rec) - sb.Append($", and lower MAXDOP from {maxdop} to {rec} (the per-NUMA-node processor count, capped at 8)"); + sb.Append($", and lower MAXDOP from {maxdop} to {rec} ({basis.Bare}, capped at 8)"); else sb.Append($"; MAXDOP at {maxdop} is already within the ≤ {rec} guidance"); sb.Append(". Then go after the specific high-DOP offenders"); @@ -587,18 +579,27 @@ private static AdviceBlock ComposeConfigMaxdop(IReadOnlyDictionary if (maxdop is null) return fallback; - var cores = CoresPerSocket(facts); - var rec = FactRemediation.RecommendedMaxdop(cores); - var coresNote = cores > 0 ? $" (cores per socket {cores})" : string.Empty; + var basis = FactRemediation.MaxdopBasisFrom(facts); + var rec = FactRemediation.RecommendedMaxdop(basis.Cores); + var coresNote = basis.Cores > 0 ? $" {basis.Note}" : string.Empty; + var cappedFrom = basis.FromVcores ? "vCores" : "cores-per-socket"; + /* MAXDOP is an instance option (sp_configure) everywhere except an Azure SQL Database, which has no instance setting for it: + there it is the database-scoped MAXDOP, set with ALTER DATABASE SCOPED CONFIGURATION. Only that engine carries vCores + instead of cores per socket, so the vCores basis is the one that names the database-scoped statement. */ + var applySentence = basis.FromVcores + ? $"An Azure SQL Database has no instance setting for it; set it per database with ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = {rec}, an online change." + : "The Apply button runs sp_configure + RECONFIGURE, an online metadata change."; + var viaClause = basis.FromVcores + ? $"with ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = {rec}" + : "via sp_configure + RECONFIGURE"; string headline, remediation; if (maxdop == 0) { headline = "MAXDOP is 0 — a single query can fan out across every scheduler (up to 64)"; remediation = - $"Set MAXDOP to {rec} — this server's cores-per-socket capped at 8{coresNote}, the per-NUMA-node " + - "proxy; the SKU is irrelevant to the right value. The Apply button runs sp_configure + " + - "RECONFIGURE, an online metadata change. On hardware with more than 16 logical processors " + + $"Set MAXDOP to {rec} — this {(basis.FromVcores ? "database" : "server")}'s {cappedFrom} capped at 8{coresNote}, the per-NUMA-node " + + $"proxy; the SKU is irrelevant to the right value. {applySentence} On hardware with more than 16 logical processors " + "per NUMA node you can raise it by hand. Raise Cost Threshold for Parallelism in the same pass " + "if its companion finding fired."; } @@ -608,15 +609,14 @@ private static AdviceBlock ComposeConfigMaxdop(IReadOnlyDictionary remediation = $"MAXDOP 1 forces every query serial: large analytical queries, index rebuilds, and DBCC run " + $"far slower. Unless this was set deliberately to fix a specific parallelism problem, set MAXDOP " + - $"to {rec} (cores-per-socket capped at 8{coresNote}) via sp_configure + RECONFIGURE, an online change."; + $"to {rec} ({cappedFrom} capped at 8{coresNote}) {viaClause}, an online change."; } else { - headline = $"MAXDOP is {maxdop} — above this server's topology-based guidance of {rec}"; + headline = $"MAXDOP is {maxdop} — above this {(basis.FromVcores ? "database" : "server")}'s topology-based guidance of {rec}"; remediation = - $"Lower MAXDOP from {maxdop} to {rec} (cores-per-socket capped at 8{coresNote}, the per-NUMA-node " + - "proxy; the SKU is irrelevant). The Apply button runs sp_configure + RECONFIGURE, an online " + - "metadata change. On hardware with more than 16 logical processors per NUMA node a higher value " + + $"Lower MAXDOP from {maxdop} to {rec} ({cappedFrom} capped at 8{coresNote}, the per-NUMA-node " + + $"proxy; the SKU is irrelevant). {applySentence} On hardware with more than 16 logical processors per NUMA node a higher value " + "can be justified by hand. Pair it with a sane Cost Threshold for Parallelism if that finding fired."; } @@ -661,8 +661,8 @@ private static string MaxdopCtfpClause(IReadOnlyDictionary facts) if (maxdop is null && ctfp is null) return string.Empty; - var cores = CoresPerSocket(facts); - var rec = FactRemediation.RecommendedMaxdop(cores); + var basis = FactRemediation.MaxdopBasisFrom(facts); + var rec = FactRemediation.RecommendedMaxdop(basis.Cores); var sb = new StringBuilder("This server's MAXDOP is ") .Append(maxdop?.ToString() ?? "not readable this window") .Append(" and cost threshold for parallelism is ") @@ -675,7 +675,7 @@ private static string MaxdopCtfpClause(IReadOnlyDictionary facts) else if (ctfp is not null && ctfp < 50) recs.Add($"raise cost threshold for parallelism from {ctfp} toward 50"); if (maxdop is 0) - recs.Add($"cap MAXDOP at {rec} (the per-NUMA-node processor count, ≤ 8)"); + recs.Add($"cap MAXDOP at {rec} ({basis.Bare}, ≤ 8)"); else if (maxdop is not null && maxdop > rec) recs.Add($"lower MAXDOP from {maxdop} to {rec}"); diff --git a/PerformanceMonitor.Analysis/FactCollectorHelpers.cs b/PerformanceMonitor.Analysis/FactCollectorHelpers.cs index dc75e9852..6c74bea41 100644 --- a/PerformanceMonitor.Analysis/FactCollectorHelpers.cs +++ b/PerformanceMonitor.Analysis/FactCollectorHelpers.cs @@ -21,6 +21,53 @@ namespace PerformanceMonitor.Analysis; /// public static class FactCollectorHelpers { + /// + /// Builds the SERVER_HARDWARE fact from the newest server_properties row, or returns null when there is no CPU count to + /// carry (no fact at all: every reader of it takes it by key and treats an absent fact as "not collected"). + /// + /// Off an Azure SQL Database the fact is what it always was. ON one (), the stored + /// hyperthread_ratio, physical_memory_mb, socket_count and cores_per_socket describe the HOST, not + /// what the database is given (a 1-vCore database read 0 sockets, 32 cores per socket and about 912 GB), so the fact carries none + /// of them. It carries the vCores instead: is then the vcore_count parsed from the service + /// objective (not the stored cpu_count, the schedulers the database can see, which can be higher), and it is 0 for an + /// objective that names none (a DTU model or an elastic pool), which leaves no fact. The vCores ride twice: as cpu_count, + /// and as vcore_count, the figure the recommended MAXDOP is taken from where an Azure SQL Database has no + /// cores_per_socket to give (see FactRemediation.MaxdopBasisFrom). Nothing that reads the fact (the MAXDOP advice, + /// the config audit, the LPIM advisory) can compute from the host's topology or memory. + /// + /// Both apps call this, so the two collectors cannot drift in what the fact carries. + /// + public static Fact? BuildServerHardwareFact( + AnalysisContext context, bool hardwareIsTheHosts, int cpuCount, int hyperthreadRatio, long physicalMemoryMb, + int socketCount, int coresPerSocket, bool hadrEnabled) + { + if (cpuCount == 0) + return null; + + var metadata = new Dictionary { ["cpu_count"] = cpuCount }; + if (hardwareIsTheHosts) + { + metadata["vcore_count"] = cpuCount; + } + else + { + metadata["hyperthread_ratio"] = hyperthreadRatio; + metadata["physical_memory_mb"] = physicalMemoryMb; + metadata["socket_count"] = socketCount; + metadata["cores_per_socket"] = coresPerSocket; + } + metadata["hadr_enabled"] = hadrEnabled ? 1 : 0; + + return new Fact + { + Source = "config", + Key = "SERVER_HARDWARE", + Value = cpuCount, + ServerId = context.ServerId, + Metadata = metadata + }; + } + /// /// RAM floor below which LPIM-off is not worth flagging — on a small buffer pool the OS paging /// SQL out is not the practical risk it is on a large dedicated host. @@ -32,12 +79,16 @@ public static class FactCollectorHelpers /// latest server_properties values, applying the noise-control gating both apps share: /// • IFI: emit whenever the value is known (Value = enabled bit) — universally good advice. /// • LPIM: emit only on non-Express editions with meaningful RAM (Value = enabled bit) — so a - /// tiny instance never flags. When LPIM is ON the emitted Value scores 0 (harmless). + /// tiny instance never flags. When LPIM is ON the emitted Value scores 0 (harmless). The RAM + /// gate reads server_properties.physical_memory_mb, which on an Azure SQL Database is the + /// HOST's, so leaves the advisory out there: the gate has no + /// figure of the database's own to read. /// • Dumps: emit whenever the count is known (Value = count) — the scorer flags count > 0. /// public static void EmitServerHealthFacts( AnalysisContext context, List facts, string edition, long physicalMemMb, - bool? lockPagesInMemory, bool? instantFileInit, int? memoryDumpCount) + bool? lockPagesInMemory, bool? instantFileInit, int? memoryDumpCount, + bool hardwareIsTheHosts = false) { var isExpress = edition.Contains("Express", StringComparison.OrdinalIgnoreCase); @@ -56,7 +107,7 @@ public static void EmitServerHealthFacts( }); } - if (lockPagesInMemory.HasValue && !isExpress && physicalMemMb >= LpimAdvisoryMinPhysicalMemoryMb) + if (lockPagesInMemory.HasValue && !isExpress && !hardwareIsTheHosts && physicalMemMb >= LpimAdvisoryMinPhysicalMemoryMb) { facts.Add(new Fact { diff --git a/PerformanceMonitor.Analysis/FactRemediation.cs b/PerformanceMonitor.Analysis/FactRemediation.cs index e57235414..4b27c58d7 100644 --- a/PerformanceMonitor.Analysis/FactRemediation.cs +++ b/PerformanceMonitor.Analysis/FactRemediation.cs @@ -170,6 +170,37 @@ public static long RecommendedMaxdop(int coresPerSocket) return Math.Min(coresPerSocket, MaxdopCap); } + /// + /// The core count the recommended MAXDOP is taken from, and what to call it. Off an Azure SQL Database it is the + /// SERVER_HARDWARE fact's cores_per_socket, the per-NUMA-node proxy. On one the fact carries no host topology (the + /// stored cores_per_socket is the machine's, not the database's), so the proxy comes from its vcore_count instead, + /// and every sentence that names it says "vCores", never "cores per socket". is 0 when the fact carries + /// neither (no fact, or a DTU objective or an elastic pool, which have no vCores), which leaves the safe general cap of 8. + /// + public readonly record struct MaxdopBasis(int Cores, bool FromVcores) + { + /// The parenthetical that states the figure, such as "(cores per socket 4)" or "(4 vCores)"; empty when unknown. + public string Note => Cores <= 0 ? string.Empty : FromVcores ? $"({Cores} vCores)" : $"(cores per socket {Cores})"; + + /// What the recommended MAXDOP is capped from, as a noun phrase: "this server's per-NUMA-node processor count" or "this database's vCores". + public string Source => FromVcores ? "this database's vCores" : "this server's per-NUMA-node processor count"; + + /// The same figure named without "this server's" / "this database's": "the per-NUMA-node processor count" or "the database's vCores". + public string Bare => FromVcores ? "the database's vCores" : "the per-NUMA-node processor count"; + } + + /// Reads the off the SERVER_HARDWARE fact; cores_per_socket when it carries one, else vcore_count. + public static MaxdopBasis MaxdopBasisFrom(IReadOnlyDictionary facts) + { + if (!facts.TryGetValue("SERVER_HARDWARE", out var hardware)) + return default; + if (hardware.Metadata.TryGetValue("cores_per_socket", out var perSocket)) + return new MaxdopBasis((int)perSocket, FromVcores: false); + return hardware.Metadata.TryGetValue("vcore_count", out var vcores) + ? new MaxdopBasis((int)vcores, FromVcores: true) + : default; + } + /// /// Builds the server-level config action for a CONFIG_* finding (WS3), or null when the /// drill-down carries no bad server-config setting. Parallel to — diff --git a/PerformanceMonitor.Collectors/MemoryStatsCollector.cs b/PerformanceMonitor.Collectors/MemoryStatsCollector.cs index e2e82b06b..151551785 100644 --- a/PerformanceMonitor.Collectors/MemoryStatsCollector.cs +++ b/PerformanceMonitor.Collectors/MemoryStatsCollector.cs @@ -17,7 +17,8 @@ namespace PerformanceMonitor.Collectors; /// Server memory statistics from sys.dm_os_sys_memory + performance counters, with an Azure SQL /// DB variant (edition 5) that approximates from sys.dm_os_sys_info committed targets and reports /// current_workers_count as NULL — elastic pools enforce VIEW SERVER PERFORMANCE STATE on -/// sys.dm_os_schedulers regardless of DB-scoped grants (#857). Azure MI (edition 8) behaves like +/// sys.dm_os_schedulers regardless of DB-scoped grants (#857). The NULL is stored as NULL, not as 0, so a reader can tell +/// "not collected" from "no workers in use". Azure MI (edition 8) behaves like /// on-prem. Extracted verbatim from Lite's RemoteCollectorService.Memory.cs. Single row per cycle; /// zero rows when the query returns none. /// @@ -41,7 +42,7 @@ public readonly record struct Row( decimal BufferPoolMb, decimal PlanCacheMb, int MaxWorkersCount, - int CurrentWorkersCount); + int? CurrentWorkersCount); private const string AzureSqlDbQueryText = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; @@ -188,7 +189,7 @@ public override async ValueTask> ReadAsync(DbDataReader reader, Collec reader.IsDBNull(8) ? 0m : reader.GetDecimal(8), reader.IsDBNull(9) ? 0m : reader.GetDecimal(9), reader.IsDBNull(10) ? 0 : reader.GetInt32(10), - reader.IsDBNull(11) ? 0 : reader.GetInt32(11))); + reader.IsDBNull(11) ? null : reader.GetInt32(11))); return rows; } diff --git a/PerformanceMonitor.Common/CpuAttribution.cs b/PerformanceMonitor.Common/CpuAttribution.cs index a916a3977..a0eb2351d 100644 --- a/PerformanceMonitor.Common/CpuAttribution.cs +++ b/PerformanceMonitor.Common/CpuAttribution.cs @@ -25,12 +25,13 @@ namespace PerformanceMonitor.Common /// seconds. When a piece is missing — no CPU samples, no properties snapshot, or the series covers too /// little of the window — the ratio is OMITTED, never invented (#2320's explicit degrade rule). /// - /// The core count is the server's OWN. On an Azure SQL Database sys.dm_os_sys_info reports the - /// HOST's CPUs (a 1-vCore serverless database read 2), and a denominator built from them is wrong by the ratio of - /// the two. There the count is the vcore_count parsed from the service objective, and a DTU-model objective or an - /// elastic pool, which name no vCores, has none: the ratio is omitted with instead of - /// being computed from the host. holds that rule; the overload that - /// takes the engine edition applies it, so a caller holding a server_properties row cannot forget it. + /// The core count is what the server is given. On an Azure SQL Database the stored cpu_count is the + /// number of schedulers the database can see (a 1-vCore serverless database read 2), not the CPU it is given, and a + /// denominator built from it is wrong by the ratio of the two. There the count is the vcore_count parsed from the + /// service objective, and a DTU-model objective or an elastic pool, which name no vCores, has none: the ratio is omitted + /// with instead of being computed from the scheduler count. + /// holds that rule; the overload that takes the engine edition applies it, so + /// a caller holding a server_properties row cannot forget it. /// public static class CpuAttribution { @@ -53,10 +54,11 @@ public static class CpuAttribution public const string CoreCountUnavailableNote = "core count unavailable (no server_properties snapshot), so measured CPU-seconds cannot be computed; ratio omitted rather than invented"; - /// The note on an Azure SQL Database whose service objective names no vCores: the host's core count is - /// not this database's allocation, so there is no core count to multiply by. The ratio is omitted, not estimated. + /// The note on an Azure SQL Database whose service objective names no vCores: the stored CPU count is the + /// schedulers the database can see, not the CPU it is given, so there is no core count to multiply by. The ratio is + /// omitted, not estimated. public const string CoreCountNotApplicableNote = - "core count not applicable: on an Azure SQL Database the host's core count is not this database's allocation and its service objective names no vCores (a DTU-model objective or an elastic pool), so measured CPU-seconds cannot be computed; ratio omitted rather than invented"; + "core count not applicable: on an Azure SQL Database the stored CPU count is the schedulers the database can see, not the CPU it is given, and its service objective names no vCores (a DTU-model objective or an elastic pool), so measured CPU-seconds cannot be computed; ratio omitted rather than invented"; /// /// A null always comes with a saying why. @@ -96,7 +98,7 @@ public static Result Compute( /// Azure SQL Database (or with no row, null) that is /// unchanged. On one it is , or no count at all for a DTU-model objective or an elastic pool, and /// then the ratio is omitted with rather than computed from the - /// host's CPUs. Both SKUs' top-queries and top-procedures tools call this one. + /// stored scheduler count. Both SKUs' top-queries and top-procedures tools call this one. /// public static Result Compute( double rankedCpuSeconds, diff --git a/PerformanceMonitor.Common/ProvisioningVerdict.cs b/PerformanceMonitor.Common/ProvisioningVerdict.cs index beb16aa4d..22c1684f2 100644 --- a/PerformanceMonitor.Common/ProvisioningVerdict.cs +++ b/PerformanceMonitor.Common/ProvisioningVerdict.cs @@ -90,7 +90,8 @@ public static class ProvisioningVerdict /// Peak granted-over-target workspace memory, as a percentage. /// The instance's worker-thread ceiling; 0 or negative means unknown, which /// cannot imply saturation. - /// Workers in use at the latest sample. + /// Workers in use at the latest sample; null where the collector cannot read it (an + /// Azure SQL Database), which is unknown: it never counts as zero in use and it cannot imply saturation. public static string Evaluate( decimal avgCpuPercent, decimal maxCpuPercent, @@ -100,7 +101,7 @@ public static string Evaluate( long forcedGrants, decimal grantUtilizationPercent, int maxWorkers, - int currentWorkers) + int? currentWorkers) { /* Any of these three is a query that asked for workspace memory and did not simply get it. They are counts of events, not levels, so there is no threshold to tune — and on a fleet with no memory @@ -111,7 +112,8 @@ public static string Evaluate( rule the collector gates follow, where an unclassified target must never be gated off by assumption. */ var workerPressure = maxWorkers > 0 - && currentWorkers / (double)maxWorkers > HighWorkerRatio; + && currentWorkers is int inUse + && inUse / (double)maxWorkers > HighWorkerRatio; if (p95CpuPercent > HighCpuP95Percent || memoryPressure || workerPressure) { @@ -146,7 +148,7 @@ public static string UnderProvisionedReason( long grantTimeouts, long forcedGrants, int maxWorkers, - int currentWorkers) + int? currentWorkers) { if (p95CpuPercent > HighCpuP95Percent) { @@ -161,9 +163,9 @@ public static string UnderProvisionedReason( + $"{forcedGrants} forced grant(s). This server may need more memory."; } - if (maxWorkers > 0 && currentWorkers / (double)maxWorkers > HighWorkerRatio) + if (maxWorkers > 0 && currentWorkers is int inUse && inUse / (double)maxWorkers > HighWorkerRatio) { - return $"Worker threads are near the limit: {currentWorkers} of {maxWorkers} in use " + return $"Worker threads are near the limit: {inUse} of {maxWorkers} in use " + $"(threshold: {HighWorkerRatio:P0}). This server may need more CPU capacity."; } diff --git a/PerformanceMonitor.Common/ServerHardwareScope.cs b/PerformanceMonitor.Common/ServerHardwareScope.cs index 9610027d3..b8d684e75 100644 --- a/PerformanceMonitor.Common/ServerHardwareScope.cs +++ b/PerformanceMonitor.Common/ServerHardwareScope.cs @@ -15,28 +15,31 @@ namespace PerformanceMonitor.Common; /// /// Whose hardware the collected server_properties hardware columns describe. /// -/// On an Azure SQL Database (SERVERPROPERTY('EngineEdition') 5) sys.dm_os_sys_info reports the -/// HOST the database runs on, not what the database is given: a 1-vCore serverless General Purpose database showed -/// 2 logical CPUs, 0 sockets, 32 cores per socket, a hyperthread ratio of 64 and 911.9 GB of physical memory. What -/// does describe the database is the service objective (DATABASEPROPERTYEX('ServiceObjective'), for example +/// On an Azure SQL Database (SERVERPROPERTY('EngineEdition') 5) sys.dm_os_sys_info mixes two kinds of +/// figure. cpu_count and max_workers_count are the database's own: cpu_count is the number of +/// schedulers the database can see, which can be higher than its vCores (a 1-vCore General Purpose database reads 2, a +/// 2-vCore Hyperscale database reads 2), and the worker ceiling is the database's own too. Four figures are the HOST's: +/// physical_memory_mb (about 912 GB), socket_count, cores_per_socket and hyperthread_ratio. +/// What the database is GIVEN is its service objective (DATABASEPROPERTYEX('ServiceObjective'), for example /// GP_S_Gen5_1) and the vcore_count the collector parses out of it. /// -/// Every surface that SHOWS cpu_count, socket_count, cores_per_socket, -/// hyperthread_ratio or physical_memory_mb asks this class first, so the rule and the words live in -/// one place for both apps. The collector keeps storing the values as read. +/// Every surface that SHOWS socket_count, cores_per_socket, hyperthread_ratio or +/// physical_memory_mb asks this class first, so the rule and the words live in one place for both apps. The +/// collector keeps storing the values as read. cpu_count is shown as stored, as the database's own scheduler count. /// -/// Every calculation that would DIVIDE BY one of the host's CPU figures asks this class too: the attributed-CPU -/// denominator () and the FinOps utilization card's CPU count. On an Azure SQL Database each of -/// those uses the database's own figure where one is collected (vcore_count) and is otherwise NOT APPLICABLE. Neither -/// falls back to the host's count, because a number built from the host reads as the database's and is wrong. A DTU-model -/// objective or an elastic pool names no vCores, so its CPU count is not applicable. +/// Every calculation that would DIVIDE BY a CPU count asks this class too: the attributed-CPU denominator +/// () and the FinOps utilization card's CPU count. On an Azure SQL Database CPU percent is +/// measured against the vCores the service objective gives the database, not against the schedulers it can see, so those use +/// the database's vcore_count and are NOT APPLICABLE where the objective names none (a DTU-model objective or an +/// elastic pool). Neither falls back to cpu_count, because a percentage spread over a count above the vCores reads low. /// /// The host's memory is only what server_properties holds. memory_stats is a different table: on an /// Azure SQL Database its total_physical_memory_mb is filled from committed_target_kb, the database's own memory -/// limit (1,838 MB on a 1-vCore General Purpose database whose server_properties row holds 911.9 GB), and its buffer +/// limit (1,838 MB on a 1-vCore General Purpose database whose server_properties row holds about 912 GB), and its buffer /// pool and server-memory counters are the database's too. So what reads memory_stats (the FinOps utilization card's /// Physical Memory and Buffer Pool %, its verdict sentences and the health score's memory term) is shown and scored on every -/// edition alike. Only the words change: on an Azure SQL Database the figure is the database's memory limit, not physical RAM. +/// edition alike. Only the words change: on an Azure SQL Database the figure is the database's memory limit, not physical RAM. +/// get_memory_stats keeps its key names, so on an Azure SQL Database it adds a memory_note (see ). /// /// Two memory_stats facts have no source at all on an Azure SQL Database. Its memory collector stores 0 for /// both page-file columns and the constant "Available" for the memory state. Neither is a reading, so every surface that shows @@ -56,18 +59,20 @@ public static class ServerHardwareScope /// apps (the web Server Properties list reads the same payload). /// public const string McpHardwareNote = - "cpu_count, hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb are null on an Azure SQL Database: " + - "the host's hardware is not this database's allocation. Read service_objective and vcore_count for what the database is given."; + "hyperthread_ratio, socket_count, cores_per_socket and physical_memory_mb are null on an Azure SQL Database: " + + "they describe the host machine, not this database. cpu_count is the database's own scheduler count and can be higher " + + "than its vCores. Read service_objective and vcore_count for what the database is given."; - /// The five columns that describe the host on an Azure SQL Database, by their get_server_properties key. + /// The four columns that describe the host on an Azure SQL Database, by their get_server_properties key. public static readonly IReadOnlyList HostHardwareKeys = - ["cpu_count", "hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; + ["hyperthread_ratio", "socket_count", "cores_per_socket", "physical_memory_mb"]; /// - /// Rewrites a get_server_properties payload for an Azure SQL Database: the five host-hardware keys become - /// null where they stand, vcore_count is inserted right after service_objective (null for a DTU-model - /// objective or an elastic pool, which name no vCores), and hardware_note is appended. Both apps call it, so the shape and the - /// words cannot drift apart. A payload of any other edition never reaches it. + /// Rewrites a get_server_properties payload for an Azure SQL Database: the four host-hardware keys become + /// null where they stand, cpu_count passes through (it is the database's own scheduler count), vcore_count + /// is inserted right after service_objective (null for a DTU-model objective or an elastic pool, which name no + /// vCores), and hardware_note is appended. Both apps call it, so the shape and the words cannot drift apart. A + /// payload of any other edition never reaches it. /// public static JsonObject ScopeServerPropertiesPayload(JsonObject payload, int? vcoreCount) { @@ -80,9 +85,32 @@ public static JsonObject ScopeServerPropertiesPayload(JsonObject payload, int? v return payload; } + /// + /// The memory_note get_memory_stats returns on an Azure SQL Database, word for word in both apps. The tool keeps + /// the key names total_physical_memory_mb and available_physical_memory_mb on every edition, so on an Azure SQL + /// Database this note is what says they are the database's memory limit and the room left under it, and that a utilization near + /// 100% is the normal state of a database that has grown to its limit. Without it a client reads that figure as OS memory pressure. + /// + public const string McpMemoryNote = + "On an Azure SQL Database total_physical_memory_mb is the database's memory limit (its committed target), not the host's " + + "memory. available_physical_memory_mb is what is left under that limit. memory_utilization_pct is the share of that limit " + + "in use: a value near 100% is normal once the database has grown to its limit, and is not memory pressure by itself."; + + /// + /// Appends memory_note to a get_memory_stats payload for an Azure SQL Database. Every key the payload already + /// has stays as it is, and the note comes last, as hardware_note does on get_server_properties. Both apps call it, + /// so the key and the words cannot drift apart. A payload of any other edition never reaches it and carries no note. + /// + public static JsonObject WithMemoryNote(JsonObject payload) + { + payload["memory_note"] = McpMemoryNote; + return payload; + } + /// The Hardware Note a FinOps Server Inventory row carries for an Azure SQL Database whose collector read gave no reason of its own. public const string InventoryHardwareNote = - "Azure SQL Database: the host's hardware is not this database's allocation; see its service objective."; + "Azure SQL Database: memory, sockets, cores per socket and hyperthread ratio are the host's, not this database's. " + + "Logical CPUs is the database's own scheduler count; see its service objective for its vCores."; /// What a figure that is not applicable shows: the FinOps utilization card's CPU count (see ), /// and the Memory Overview's page-file figures and memory state on an Azure SQL Database, whose memory collector stores 0 @@ -110,10 +138,12 @@ public static JsonObject ScopeServerPropertiesPayload(JsonObject payload, int? v engineEdition == AzureSqlDatabaseEngineEdition ? MemoryStateNote : null; /// - /// The CPU count a calculation may treat as the server's OWN. Off an Azure SQL Database it is the stored - /// , as it always was. On one the stored count is the HOST's, so the answer is the - /// the collector parsed from the service objective, and null (not applicable) - /// when the objective names none (a DTU-model objective, or an elastic pool). It is never the host's count. + /// The CPU count a calculation that divides CPU percent by it may use. Off an Azure SQL Database it is the stored + /// , as it always was. On one the stored count is the number of schedulers the database can see, + /// which can be higher than its vCores (a 1-vCore General Purpose database reads 2), and CPU percent there is measured + /// against the vCores. So the answer is the the collector parsed from the service objective, + /// and null (not applicable) when the objective names none (a DTU-model objective, or an elastic pool). It is never + /// the scheduler count. /// public static int? OwnCpuCount(int? engineEdition, int? cpuCount, int? vcoreCount) => HardwareIsTheHosts(engineEdition) @@ -130,6 +160,23 @@ public static string CpuCountText(int? engineEdition, int cpuCount) => ? NotApplicable : cpuCount.ToString("N0", CultureInfo.CurrentCulture); + /// + /// The unit that follows the FinOps utilization card's CPU count: " vCores," on an Azure SQL Database, where the count is the + /// vCores its service objective gives it (see ) and so is not a count of CPUs, and " CPUs," + /// everywhere else. The leading space and trailing comma are part of the text, as the card draws it between two other figures. + /// + public static string CpuCountUnit(int? engineEdition) => + HardwareIsTheHosts(engineEdition) ? " vCores," : " CPUs,"; + + /// + /// What a sentence calls the CPU count it prints beside a number: "vCores" on an Azure SQL Database, where that count is the + /// vCores the service objective gives the database (the same rule as ), and "cores" everywhere else, + /// which is the word the FinOps CPU right-sizing recommendation has always used. The recommendation takes the count from the + /// FinOps utilization read, so it names it the way the utilization card does. + /// + public static string CpuCoreNoun(int? engineEdition) => + HardwareIsTheHosts(engineEdition) ? "vCores" : "cores"; + /// /// What the FinOps utilization card measures the buffer pool against: physical RAM on SQL Server and Managed Instance, and /// on an Azure SQL Database the database's memory limit. Both are memory_stats.total_physical_memory_mb, which on an @@ -137,12 +184,31 @@ public static string CpuCountText(int? engineEdition, int cpuCount) => /// private static string MemoryBasis(bool azureSqlDatabase) => azureSqlDatabase ? "the database's memory limit" : "physical RAM"; + private const string MemoryLimitLabel = "Memory limit"; + /// /// The caption beside the utilization card's memory figure: "Physical: " on SQL Server and Managed Instance, "Memory limit: " /// on an Azure SQL Database, where the figure is the database's own limit and not the host's RAM. /// public static string PhysicalMemoryCaption(int? engineEdition) => - HardwareIsTheHosts(engineEdition) ? "Memory limit: " : "Physical: "; + HardwareIsTheHosts(engineEdition) ? MemoryLimitLabel + ": " : "Physical: "; + + /// + /// The label over the Memory tab's first figure (memory_stats.total_physical_memory_mb): "Physical Memory" on SQL Server + /// and Managed Instance, "Memory limit" on an Azure SQL Database, where the collector fills the column from the database's + /// own committed target and not from the host's RAM. The same word uses. + /// + public static string MemoryTabTotalLabel(int? engineEdition) => + HardwareIsTheHosts(engineEdition) ? MemoryLimitLabel : "Physical Memory"; + + /// + /// The label over the Memory tab's second figure (memory_stats.available_physical_memory_mb): "Available Physical" on SQL + /// Server and Managed Instance, where it is the operating system's available physical memory, and "Available under limit" on + /// an Azure SQL Database, where the collector computes it as the committed target minus what is committed, which is the room + /// left under the database's memory limit and not a count of free RAM. + /// + public static string MemoryTabAvailableLabel(int? engineEdition) => + HardwareIsTheHosts(engineEdition) ? "Available under limit" : "Available Physical"; /// /// The FinOps utilization verdict sentence for a server whose provisioning is RIGHT_SIZED. Off an Azure SQL Database it is @@ -159,4 +225,30 @@ public static string RightSizedExplanation(decimal avgCpuPct, decimal p95CpuPct, /// 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."); + + /// + /// The physical memory (server_properties.physical_memory_mb) a calculation may treat as the server's OWN. Off an + /// Azure SQL Database it is the stored figure, as it always was. On one the stored figure is the HOST's and no + /// database-scoped figure is collected to stand in for it, so the answer is null (not applicable). This is about the + /// server_properties column only: the memory_stats memory figures come from the engine's own memory counters and + /// its committed target, which describe the database, and they are not scoped here. + /// + public static long? OwnPhysicalMemoryMb(int? engineEdition, long? physicalMemoryMb) => + HardwareIsTheHosts(engineEdition) ? null : physicalMemoryMb; + + /// + /// The FinOps utilization card's worker-thread text: "in use / maximum". The in-use count is NULL where the collector cannot + /// read it (an Azure SQL Database: its query hard-codes NULL), and then it reads , never 0. The + /// maximum is the engine's own figure on every edition and is shown as stored. + /// + public static string WorkerThreadsText(int? currentWorkers, int maxWorkers) => + string.Create(CultureInfo.CurrentCulture, $"{(currentWorkers is int inUse ? inUse.ToString("N0", CultureInfo.CurrentCulture) : NotApplicable)} / {maxWorkers:N0}"); + + /// + /// The tooltip on the FinOps health score when it has no CPU term: the 24-hour window held no CPU sample, so CPU is left + /// out, not scored as zero and not scored as a default. It is not specific to an Azure SQL Database, so both apps read the same + /// words from here. + /// + public const string HealthScoreWithoutCpuNote = + "CPU is not part of this score: the last 24 hours hold no CPU sample."; } diff --git a/PerformanceMonitor.PlanAnalysis/ServerContextCard.cs b/PerformanceMonitor.PlanAnalysis/ServerContextCard.cs index 12587f8b7..c5fc59f40 100644 --- a/PerformanceMonitor.PlanAnalysis/ServerContextCard.cs +++ b/PerformanceMonitor.PlanAnalysis/ServerContextCard.cs @@ -7,6 +7,7 @@ */ using System.Collections.Generic; +using PerformanceMonitor.Common; namespace PerformanceMonitor.PlanAnalysis; @@ -57,8 +58,15 @@ public static List Rows(ServerMetadata? metadata) serverLine += $", {metadata.ProductVersion}"; rows.Add(new ServerContextRow("Server", serverLine)); - // Hardware — dropped entirely when CpuCount is 0 (no hardware facts captured), matching PS. - if (metadata.CpuCount > 0) + // Hardware. An Azure SQL Database (engine edition 5) names the vCores its service objective gives it, labeled + // "vCores", and reads n/a where the objective names none (a DTU model or an elastic pool): its stored CPU count is the + // schedulers it can see, which can be higher than its vCores, and its stored memory is the HOST's, so neither is shown. + // Any other engine drops the row when CpuCount is 0 (no hardware facts captured), matching PS. + if (ServerHardwareScope.HardwareIsTheHosts(metadata.EngineEdition)) + rows.Add(new ServerContextRow("Hardware", metadata.VcoreCount is > 0 + ? $"{metadata.VcoreCount} vCores" + : ServerHardwareScope.NotApplicable)); + else if (metadata.CpuCount > 0) rows.Add(new ServerContextRow("Hardware", $"{metadata.CpuCount} CPUs, {metadata.PhysicalMemoryMB:N0} MB RAM")); // Instance settings — PS always shows these three rows, even when the value is 0 (a real diff --git a/PerformanceMonitor.PlanAnalysis/ServerMetadata.cs b/PerformanceMonitor.PlanAnalysis/ServerMetadata.cs index 75773d2ed..ef74978b4 100644 --- a/PerformanceMonitor.PlanAnalysis/ServerMetadata.cs +++ b/PerformanceMonitor.PlanAnalysis/ServerMetadata.cs @@ -28,6 +28,18 @@ public class ServerMetadata public int CpuCount { get; set; } public long PhysicalMemoryMB { get; set; } + /// + /// SERVERPROPERTY('EngineEdition') of the stored properties row; null when it was not read. 5 is an Azure SQL + /// Database, whose Hardware row names its vCores and no RAM (see ). + /// + public int? EngineEdition { get; set; } + + /// + /// The vCores parsed from an Azure SQL Database's service objective; null for any other engine, and for an objective that + /// names none (a DTU-model objective or an elastic pool). + /// + public int? VcoreCount { get; set; } + // Instance settings public int MaxDop { get; set; } public int CostThresholdForParallelism { get; set; }