diff --git a/Lite.Tests/DismissedArchiveSidecarTests.cs b/Lite.Tests/DismissedArchiveSidecarTests.cs index 53c61d5..9ef4ba4 100644 --- a/Lite.Tests/DismissedArchiveSidecarTests.cs +++ b/Lite.Tests/DismissedArchiveSidecarTests.cs @@ -269,7 +269,7 @@ SELECT 1 FROM dismissed_archive_alerts d } [Fact] - public async Task SchemaVersion_IsUpdatedTo23() + public async Task SchemaVersion_IsUpdatedTo24() { using var connection = await InitializeDatabaseAsync(); @@ -277,6 +277,6 @@ public async Task SchemaVersion_IsUpdatedTo23() cmd.CommandText = "SELECT MAX(version) FROM schema_version"; var version = Convert.ToInt32(await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken)); - Assert.Equal(23, version); + Assert.Equal(DuckDbInitializer.CurrentSchemaVersion, version); } } diff --git a/Lite/Analysis/DuckDbFactCollector.cs b/Lite/Analysis/DuckDbFactCollector.cs index b6b12b9..c366270 100644 --- a/Lite/Analysis/DuckDbFactCollector.cs +++ b/Lite/Analysis/DuckDbFactCollector.cs @@ -1363,8 +1363,8 @@ private async Task CollectServerPropertiesFactsAsync(AnalysisContext context, Li using var cmd = connection.CreateCommand(); cmd.CommandText = @" -SELECT cpu_count, hyperthread_ratio, physical_memory_mb, socket_count, cores_per_socket, - is_hadr_enabled, edition, product_version +SELECT COALESCE(vcore_count, cpu_count) AS cpu_count, hyperthread_ratio, physical_memory_mb, + socket_count, cores_per_socket, is_hadr_enabled, edition, product_version FROM server_properties WHERE server_id = $1 ORDER BY collection_time DESC diff --git a/Lite/Analysis/TestDataSeeder.cs b/Lite/Analysis/TestDataSeeder.cs index 35ea6c2..0102c89 100644 --- a/Lite/Analysis/TestDataSeeder.cs +++ b/Lite/Analysis/TestDataSeeder.cs @@ -1525,7 +1525,8 @@ INSERT INTO trace_flags /// internal async Task SeedServerPropertiesAsync(int cpuCount, int htRatio, long physicalMemMb, int socketCount = 2, int coresPerSocket = 0, - bool hadrEnabled = false, string edition = "Standard Edition") + bool hadrEnabled = false, string edition = "Standard Edition", + int engineEdition = 2, string? serviceObjective = null, int? vcoreCount = null) { if (coresPerSocket == 0) coresPerSocket = cpuCount / (socketCount * 2); // assume HT @@ -1539,9 +1540,10 @@ 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, is_hadr_enabled) -VALUES ($1, $2, $3, $4, $5, '16.0.4150.1', 'RTM', 2, - $6, $7, $8, $9, $10, $11)"; + socket_count, cores_per_socket, is_hadr_enabled, + service_objective, vcore_count) +VALUES ($1, $2, $3, $4, $5, '16.0.4150.1', 'RTM', $12, + $6, $7, $8, $9, $10, $11, $13, $14)"; cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); cmd.Parameters.Add(new DuckDBParameter { Value = TestPeriodEnd }); @@ -1554,6 +1556,9 @@ INSERT INTO server_properties cmd.Parameters.Add(new DuckDBParameter { Value = socketCount }); cmd.Parameters.Add(new DuckDBParameter { Value = coresPerSocket }); cmd.Parameters.Add(new DuckDBParameter { Value = hadrEnabled }); + cmd.Parameters.Add(new DuckDBParameter { Value = engineEdition }); + cmd.Parameters.Add(new DuckDBParameter { Value = (object?)serviceObjective ?? DBNull.Value }); + cmd.Parameters.Add(new DuckDBParameter { Value = (object?)vcoreCount ?? DBNull.Value }); await cmd.ExecuteNonQueryAsync(); } @@ -2034,6 +2039,28 @@ public async Task SeedBurstyCpuAsync() await SeedCpuUtilizationAlternatingAsync(low: 5, high: 85); } + /// + /// Scenario 11: Azure SQL DB vCore tier. + /// sys.dm_os_sys_info reports 20 cores (compute node total), but the database + /// is provisioned as HS_Gen5_14 (14 vCores). FinOps should use 14, not 20. + /// + /// Expected: CPU right-sizing uses vcore_count (14) instead of cpu_count (20). + /// + public async Task SeedAzureSqlDbVcoreAsync() + { + await ClearTestDataAsync(); + await SeedTestServerAsync(); + + // Azure SQL DB: node has 20 cores, but this DB has HS_Gen5_14 (14 vCores) + // CPU at 8% avg — overprovisioned relative to 14 vCores + await SeedCpuUtilizationAsync(8, 2); + await SeedMemoryStatsAsync(totalPhysicalMb: 65_536, bufferPoolMb: 40_960, targetMb: 57_344); + await SeedServerPropertiesAsync(cpuCount: 20, htRatio: 1, physicalMemMb: 65_536, + edition: "SQL Azure", engineEdition: 5, + serviceObjective: "HS_Gen5_14", vcoreCount: 14); + await SeedFileSizeAsync(totalDataSizeMb: 51_200); + } + // ============================================ // Phase 3 Seed Helpers // ============================================ diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs index e7291e7..6a6e62f 100644 --- a/Lite/Database/DuckDbInitializer.cs +++ b/Lite/Database/DuckDbInitializer.cs @@ -86,7 +86,7 @@ public void Dispose() /// /// Current schema version. Increment this when schema changes require table rebuilds. /// - internal const int CurrentSchemaVersion = 23; + internal const int CurrentSchemaVersion = 24; private readonly string _archivePath; @@ -614,6 +614,20 @@ New tables only — no existing table changes needed. Tables created by throw; } } + + if (fromVersion < 24) + { + _logger?.LogInformation("Running migration to v24: adding vcore_count column to server_properties for Azure SQL DB vCore tracking"); + try + { + await ExecuteNonQueryAsync(connection, "ALTER TABLE server_properties ADD COLUMN IF NOT EXISTS vcore_count INTEGER"); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Migration to v24 failed"); + throw; + } + } } /// diff --git a/Lite/Database/Schema.cs b/Lite/Database/Schema.cs index 09ccc1a..be4b356 100644 --- a/Lite/Database/Schema.cs +++ b/Lite/Database/Schema.cs @@ -642,7 +642,8 @@ CREATE TABLE IF NOT EXISTS server_properties ( is_hadr_enabled BOOLEAN, is_clustered BOOLEAN, enterprise_features VARCHAR, - service_objective VARCHAR + service_objective VARCHAR, + vcore_count INTEGER )"; public const string CreateServerPropertiesIndex = @" diff --git a/Lite/Services/LocalDataService.FinOps.cs b/Lite/Services/LocalDataService.FinOps.cs index c546498..1dc7101 100644 --- a/Lite/Services/LocalDataService.FinOps.cs +++ b/Lite/Services/LocalDataService.FinOps.cs @@ -345,7 +345,7 @@ GROUP BY server_id sp.product_level, sp.product_update_level, sp.engine_edition, - sp.cpu_count, + COALESCE(sp.vcore_count, sp.cpu_count) AS cpu_count, sp.physical_memory_mb, sp.socket_count, sp.cores_per_socket, @@ -476,7 +476,7 @@ ORDER BY collection_time DESC LIMIT 1 ), server_info AS ( - SELECT cpu_count + SELECT COALESCE(vcore_count, cpu_count) AS cpu_count FROM v_server_properties WHERE server_id = $1 ORDER BY collection_time DESC diff --git a/Lite/Services/RemoteCollectorService.ServerProperties.cs b/Lite/Services/RemoteCollectorService.ServerProperties.cs index 40bfd94..5a9012d 100644 --- a/Lite/Services/RemoteCollectorService.ServerProperties.cs +++ b/Lite/Services/RemoteCollectorService.ServerProperties.cs @@ -96,6 +96,15 @@ FROM sys.dm_os_sys_info AS osi bool? isClustered = reader.IsDBNull(12) ? null : reader.GetBoolean(12); var serviceObjective = reader.IsDBNull(13) ? null : reader.GetString(13); + /* For Azure SQL DB, sys.dm_os_sys_info.cpu_count returns the compute node's + total cores, not the per-database vCore allocation. Parse the actual vCore + count from the service_objective string (e.g. "HS_Gen5_14" → 14). */ + int? vcoreCount = null; + if (isAzureSqlDb && !string.IsNullOrEmpty(serviceObjective)) + { + vcoreCount = ParseVcoreFromServiceObjective(serviceObjective); + } + sqlSw.Stop(); var duckSw = Stopwatch.StartNew(); @@ -125,6 +134,7 @@ FROM sys.dm_os_sys_info AS osi .AppendValue(isClustered) .AppendValue((string?)null) // enterprise_features — not collected in Lite (requires cross-database cursor) .AppendValue(serviceObjective) + .AppendValue(vcoreCount) .EndRow(); rowsCollected++; } @@ -143,4 +153,21 @@ FROM sys.dm_os_sys_info AS osi _logger?.LogDebug("Collected {RowCount} server properties row(s) for server '{Server}'", rowsCollected, server.DisplayName); return rowsCollected; } + + /// + /// Parses the vCore count from an Azure SQL DB service objective string. + /// vCore tiers follow the pattern {Tier}_{Gen}_{VcoreCount} + /// (e.g. "HS_Gen5_14", "GP_Gen5_6", "BC_Gen5_8", "GP_S_Gen5_2"). + /// DTU tiers (e.g. "P1", "S0") and elastic pools return null. + /// + internal static int? ParseVcoreFromServiceObjective(string serviceObjective) + { + var parts = serviceObjective.Split('_'); + if (parts.Length >= 3 && int.TryParse(parts[^1], out var vcores) && vcores > 0) + { + return vcores; + } + + return null; + } }