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;
+ }
}