Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- **Lite UI no longer freezes during archival** ([#979]) — archival held DuckDB's exclusive write lock across the entire export-to-Parquet step, blocking every UI query (tab switches showed the spinning wheel, worse with more monitored servers). Export-to-Parquet only reads the database, so it now runs under a shared read lock concurrently with the UI; only the brief `DELETE` takes the exclusive write lock
- **Lite FinOps no longer recommends an edition downgrade on an Availability Group secondary** ([#980]) — the licensing recommendations suggested "downgrade to Standard to save $X/mo" for any Enterprise instance, with no AG awareness. On a secondary replica that advice is misleading — every replica in an AG must run the same edition. FinOps now detects the AG replica role and, on a secondary, shows an informational note instead of the downgrade/savings estimate
- **Data Retention job no longer fails with `xp_delete_file` error 22049** ([#972]) — the trace-file cleanup added in v2.11.0 passed a wildcard path to `xp_delete_file`, raising an uncatchable `Msg 22049` that failed the entire `PerformanceMonitor - Data Retention` Agent job on every run once any `Monitor_LongQueries_*.trc` files existed. `xp_delete_file` also cannot delete `.trc` files at all — it only accepts SQL Server backup files and Maintenance Plan report files — so that cleanup step has been removed from `config.data_retention`

### Changed
Expand All @@ -22,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

[#972]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/972
[#979]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/979
[#980]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/980

## [2.11.0] - 2026-05-19

Expand Down
8 changes: 8 additions & 0 deletions Lite/Controls/FinOpsTab.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -2418,6 +2418,14 @@
</StackPanel>
</DataGridTextColumn.Header>
</DataGridTextColumn>
<DataGridTextColumn Binding="{Binding AgReplicaRoleDisplay}" Width="90">
<DataGridTextColumn.Header>
<StackPanel Orientation="Horizontal">
<Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AgReplicaRoleDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/>
<TextBlock Text="AG Role" FontWeight="Bold" VerticalAlignment="Center"/>
</StackPanel>
</DataGridTextColumn.Header>
</DataGridTextColumn>
<DataGridTextColumn Binding="{Binding ClusteredDisplay}" Width="80">
<DataGridTextColumn.Header>
<StackPanel Orientation="Horizontal">
Expand Down
66 changes: 65 additions & 1 deletion Lite/Services/LocalDataService.FinOps.Recommendations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,47 @@ public partial class LocalDataService
// FinOps Recommendations Engine
// ============================================

/// <summary>
/// Returns the Availability Group role of the connected instance: "Primary",
/// "Secondary", or "Standalone". "Standalone" is also returned for non-AG
/// instances and any platform where the AG state DMVs are unavailable
/// (e.g. Azure SQL Database), so callers can treat it as "not a secondary".
/// </summary>
private static async Task<string> GetAgReplicaRoleAsync(SqlConnection connection)
{
/* The DMV is referenced only inside dynamic SQL guarded by an OBJECT_ID
check, so the outer batch still compiles on platforms without it. */
const string sql = @"
DECLARE @role nvarchar(20) = N'Standalone';
IF CONVERT(int, ISNULL(SERVERPROPERTY('IsHadrEnabled'), 0)) = 1
AND OBJECT_ID(N'sys.dm_hadr_availability_replica_states') IS NOT NULL
BEGIN
DECLARE @detected nvarchar(20);
EXEC sys.sp_executesql N'
SELECT @r =
CASE
WHEN MAX(CASE WHEN ars.role = 1 THEN 1 ELSE 0 END) = 1 THEN N''Primary''
WHEN MAX(CASE WHEN ars.role = 2 THEN 1 ELSE 0 END) = 1 THEN N''Secondary''
ELSE N''Standalone''
END
FROM sys.dm_hadr_availability_replica_states AS ars
WHERE ars.is_local = 1;',
N'@r nvarchar(20) OUTPUT', @r = @detected OUTPUT;
SET @role = ISNULL(@detected, N'Standalone');
END;
SELECT @role;";
try
{
using var command = new SqlCommand(sql, connection) { CommandTimeout = 30 };
return await command.ExecuteScalarAsync() as string ?? "Standalone";
}
catch (Exception ex)
{
AppLogger.Error("FinOps", $"AG replica role detection failed: {ex.Message}");
return "Standalone";
}
}

/// <summary>
/// Runs all Phase 1 recommendation checks and returns a consolidated list.
/// Uses DuckDB for collected data and live SQL queries for server-specific checks.
Expand Down Expand Up @@ -50,11 +91,34 @@ public async Task<List<RecommendationRow>> GetRecommendationsAsync(int serverId,

if (edition.Contains("Enterprise", StringComparison.OrdinalIgnoreCase))
{
var agRole = await GetAgReplicaRoleAsync(sqlConn);

if (agRole == "Secondary")
{
/* On an Availability Group secondary, a "downgrade to Standard
to save money" recommendation is misleading: every replica in
an AG must run the same SQL Server edition, so the decision
belongs to the AG as a whole and must be evaluated on the
primary. Emit an informational note instead and skip the
savings estimates (#980). */
recommendations.Add(new RecommendationRow
{
Category = "Licensing",
Severity = "Low",
Confidence = "High",
Finding = "Enterprise Edition — Availability Group secondary replica",
Detail = "This instance is currently a secondary replica in an Availability Group. " +
"Every replica in an AG must run the same SQL Server edition, so edition and " +
"licensing decisions apply to the whole group and should be evaluated on the " +
"primary replica. A secondary used only for failover may also be covered by " +
"Software Assurance rather than separately licensed."
});
}
// SQL Server 2019 (major version 15) moved TDE to Standard Edition.
// On 2019+, dm_db_persisted_sku_features won't report TDE since it's
// no longer Enterprise-restricted — so we skip the TDE-specific check
// and give version-appropriate guidance instead.
if (majorVersion >= 15)
else if (majorVersion >= 15)
{
// 2019+: Most features that were Enterprise-only moved to Standard
// in 2016 SP1, and TDE moved in 2019. Very few Enterprise-only
Expand Down
25 changes: 23 additions & 2 deletions Lite/Services/LocalDataService.FinOps.ServerProperties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ WHEN CONVERT(int, SERVERPROPERTY('EngineEdition')) = 5
ELSE N'SELECT @gb = SUM(CAST(size AS bigint)) * 8.0 / 1024.0 / 1024.0 FROM sys.master_files'
END,
@storage_gb decimal(19,2),
@host_os nvarchar(256);
@host_os nvarchar(256),
@ag_role nvarchar(20) = N'Standalone';

EXEC sys.sp_executesql @storage_sql, N'@gb decimal(19,2) OUTPUT', @gb = @storage_gb OUTPUT;

Expand All @@ -51,6 +52,24 @@ IF @on_pos > 0
SET @host_os = LTRIM(SUBSTRING(@ver, @on_pos + 4, LEN(@ver)));
END;

/* Availability Group replica role. The DMV is referenced only inside
OBJECT_ID-guarded dynamic SQL, so this batch still compiles on Azure SQL
Database and non-AG instances — both leave @ag_role at 'Standalone' (#980). */
IF CONVERT(int, ISNULL(SERVERPROPERTY('IsHadrEnabled'), 0)) = 1
AND OBJECT_ID(N'sys.dm_hadr_availability_replica_states') IS NOT NULL
BEGIN
DECLARE @ag_detected nvarchar(20);
EXEC sys.sp_executesql N'
SELECT @r = CASE
WHEN MAX(CASE WHEN ars.role = 1 THEN 1 ELSE 0 END) = 1 THEN N''Primary''
WHEN MAX(CASE WHEN ars.role = 2 THEN 1 ELSE 0 END) = 1 THEN N''Secondary''
ELSE N''Standalone'' END
FROM sys.dm_hadr_availability_replica_states AS ars
WHERE ars.is_local = 1;',
N'@r nvarchar(20) OUTPUT', @r = @ag_detected OUTPUT;
SET @ag_role = ISNULL(@ag_detected, N'Standalone');
END;

SELECT
CONVERT(nvarchar(256), SERVERPROPERTY('Edition')),
CONVERT(nvarchar(128), SERVERPROPERTY('ProductVersion')),
Expand All @@ -65,7 +84,8 @@ IF @on_pos > 0
CONVERT(int, SERVERPROPERTY('EngineEdition')),
CONVERT(int, SERVERPROPERTY('IsHadrEnabled')),
CONVERT(int, SERVERPROPERTY('IsClustered')),
@host_os
@host_os,
@ag_role
FROM sys.dm_os_sys_info AS si;";

using var command = new SqlCommand(query, connection) { CommandTimeout = 30 };
Expand Down Expand Up @@ -93,6 +113,7 @@ IF @on_pos > 0
IsHadrEnabled = reader.IsDBNull(11) ? null : Convert.ToInt32(reader.GetValue(11)) == 1,
IsClustered = reader.IsDBNull(12) ? null : Convert.ToInt32(reader.GetValue(12)) == 1,
HostOsVersion = reader.IsDBNull(13) ? "" : reader.GetString(13),
AgReplicaRole = reader.IsDBNull(14) ? "Standalone" : reader.GetString(14),
LastUpdated = DateTime.Now
};
}
Expand Down
2 changes: 2 additions & 0 deletions Lite/Services/LocalDataService.FinOps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ public class ServerPropertyRow
public DateTime? LastUpdated { get; set; }
public bool? IsHadrEnabled { get; set; }
public bool? IsClustered { get; set; }
public string AgReplicaRole { get; set; } = "Standalone";

public decimal? AvgCpuPct { get; set; }
public decimal? StorageTotalGb { get; set; }
Expand All @@ -195,6 +196,7 @@ public string UptimeDisplay
}
public string HadrDisplay => IsHadrEnabled.HasValue ? (IsHadrEnabled.Value ? "Yes" : "No") : "";
public string ClusteredDisplay => IsClustered.HasValue ? (IsClustered.Value ? "Yes" : "No") : "";
public string AgReplicaRoleDisplay => string.Equals(AgReplicaRole, "Standalone", StringComparison.OrdinalIgnoreCase) ? "—" : AgReplicaRole;
public string ProvisioningDisplay => ProvisioningStatus?.Replace("_", " ") ?? "";

// FinOps cost — from server config
Expand Down
Loading