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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Recommendations warm-up now survives the 512 MB archive/reset, and analysis reads the archive tier everywhere** ([#1811], closes [#1809]) - the 24-hour sufficiency check measured MIN..MAX(collection_time) on the RAW hot wait_stats table, while `ArchiveAllAndResetAsync` copies everything to Parquet and empties the hot store. On a multi-server install that trips the size threshold more often than daily, the measured span restarted with every reset and warm-up never completed - even though the archived history was sitting in Parquet and every other tab could read it through the `v_` union views. The check now reads `v_wait_stats`, so archived plus hot history counts, and a genuinely young install still gates (both pinned, watched red by reverting the query to the raw table).

The subtlety that made this a 24-file-line sweep rather than a one-word fix: the broken gate was accidentally SHIELDING a second instance of the same defect. The analysis fact collector read raw tables in 22 more places - the window reads (query stats, snapshots, memory, perfmon, file IO, blocking, deadlocks, storage) and the on-load config snapshots (server/database config, trace flags, server properties), which after a reset are EMPTY until the next app start. Fixing only the gate would have run analysis over a thin post-reset hot window, reintroducing the exact fraction-of-period distortion the 24-hour floor exists to prevent ("5 seconds of THREADPOOL looks alarming in a 16-minute window"). All 23 sites now read their `v_` archive views - column-identical by construction (only `config_alert_log` carries a view-only column, and analysis does not read it), dedup-safe where re-collection could duplicate rows (the QUALIFY views), and a catalog-driven sweep guard holds the whole pipeline there: for every table in `ArchiveService.ArchivableTables`, a raw `FROM` anywhere in Lite/Analysis fails the build's tests naming the file - so a new collector's table is guarded the day it exists.
- **Live-Postgres test cleanup no longer depends on the connection the failure just destroyed** ([#1810], closes [#1794]) - three serialized live-store classes (`PayloadDimensionLiveTests`, `DarlingSelfAlertTests`, `DarlingMcpJobToolsLivePostgresTests`) intermittently failed with `Connection is not open` on reused databases, and the issue's diagnosis held: not contention, not the #1776 parallelism class - a lifecycle defect, in every case the same shape. Each test's `finally` ran its restore/delete statements on the BODY's connection, which is the one object the failure being reported may have destroyed (a broken socket, a taint from a failed COPY, a cancellation mid-command all leave it closed). That did three bad things at once: the cleanup threw `InvalidOperationException` from a helper frame - the exact reported signature, with the nightly's occurrence landing in `DeleteDimRowAsync` - the throw REPLACED the body's in-flight exception (a finally-throw wins), so the real failure was never seen, and every cleanup statement after the first was abandoned, leaving renamed tables, aggregate snapshots, and dim rows behind in the reused store to manufacture the next run's "unrelated" flakes. One test's finally restores `collect.query_stats` from a mid-test rename; abandoning THAT leaves the store without its central table for everything after.

All twelve cleanup sites now run through a shared `LiveStoreCleanup` helper: a freshly-opened connection (with `search_path` SET explicitly, because a pooled physical session opened before the store's first migration keeps the pre-`ALTER DATABASE` default for its whole life - the 42P01 trap the atomic-visibility test already documents), `CancellationToken.None` (a cancelled run must still restore the store; the body's token is by definition already signalled on that path), and body-aware masking: when the body already failed, a cleanup exception is swallowed so the REAL failure surfaces; when the body succeeded, cleanup failures propagate loudly. Deliberately no retry anywhere - the standing house rule for this flake class, and the issue's own remedy note. Verified against a live store: seven consecutive green runs of all three classes, including two immediately after a cluster restart, and the gated tests still skip cleanly without `DARLING_TEST_PG`.
Expand Down Expand Up @@ -1899,6 +1902,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#1803]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1803
[#1805]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1805
[#1808]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1808
[#1809]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1809
[#1811]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1811
[#1794]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1794
[#1810]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1810
[#1787]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1787
Expand Down
181 changes: 181 additions & 0 deletions Lite.Tests/AnalysisDataSpanTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor Lite.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DuckDB.NET.Data;
using PerformanceMonitorLite.Analysis;
using PerformanceMonitorLite.Database;
using PerformanceMonitorLite.Services;
using Xunit;

namespace PerformanceMonitorLite.Tests;

/// <summary>
/// #1809: the Recommendations 24-hour warm-up must survive the 512 MB archive/reset. The sufficiency
/// check measured MIN..MAX(collection_time) on the RAW hot table, so every
/// <c>ArchiveAllAndResetAsync</c> restarted the measured span — on a multi-server install that resets
/// more often than daily, warm-up never completed, even though the archived history was sitting right
/// there in parquet and every other surface could read it through the <c>v_</c> views.
///
/// <para>Two layers: the behavioral fixture proves the span reads hot + archived parquet through
/// <c>v_wait_stats</c> (and that a genuinely young install still gates), and the sweep guard holds the
/// WHOLE analysis pipeline to the view tier — the fact collector's window reads had the same defect,
/// masked until now by the broken gate blocking analysis entirely after each reset. Fixing the gate
/// without the reads would have analyzed a thin post-reset hot window, reintroducing the exact
/// fraction-of-period distortion the 24-hour floor exists to prevent.</para>
/// </summary>
public sealed class AnalysisDataSpanTests : IDisposable
{
private readonly string _tempDir;
private readonly string _dbPath;
private readonly string _archivePath;

public AnalysisDataSpanTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "LiteTests_" + Guid.NewGuid().ToString("N")[..8]);
Directory.CreateDirectory(_tempDir);
_dbPath = Path.Combine(_tempDir, "test.duckdb");
_archivePath = Path.Combine(_tempDir, "archive");
Directory.CreateDirectory(_archivePath);
}

public void Dispose()
{
try
{
if (Directory.Exists(_tempDir))
Directory.Delete(_tempDir, recursive: true);
}
catch
{
/* Best-effort cleanup */
}
}

[Fact]
public async Task DataSpan_CountsArchivedParquetHistory_AcrossAnArchiveReset()
{
var initializer = new DuckDbInitializer(_dbPath);
await initializer.InitializeAsync();

using (var connection = new DuckDBConnection($"Data Source={_dbPath}"))
{
await connection.OpenAsync(TestContext.Current.CancellationToken);

/* 28 hours of history collected BEFORE the reset... */
await ExecuteAsync(connection, @"
INSERT INTO wait_stats (collection_id, collection_time, server_id, server_name, wait_type)
VALUES
(1, now() - INTERVAL 30 HOUR, 1, 'S1', 'SOS_SCHEDULER_YIELD'),
(2, now() - INTERVAL 3 HOUR, 1, 'S1', 'SOS_SCHEDULER_YIELD')");

/* ...archived to parquet and wiped from the hot store, the way ArchiveAllAndResetAsync
does it (COPY whole table, then reset)... */
var parquetPath = Path.Combine(_archivePath, "20260101_0000_wait_stats.parquet").Replace("\\", "/");
await ExecuteAsync(connection, $"COPY wait_stats TO '{parquetPath}' (FORMAT PARQUET)");
await ExecuteAsync(connection, "DELETE FROM wait_stats");

/* ...and only 2 hours of post-reset hot data. The raw-table read this test guards against
sees 2 hours here and blocks analysis forever. */
await ExecuteAsync(connection, @"
INSERT INTO wait_stats (collection_id, collection_time, server_id, server_name, wait_type)
VALUES
(3, now() - INTERVAL 2 HOUR, 1, 'S1', 'SOS_SCHEDULER_YIELD'),
(4, now(), 1, 'S1', 'SOS_SCHEDULER_YIELD')");
}

await initializer.CreateArchiveViewsAsync();

var analysis = new AnalysisService(initializer);
var spanHours = await analysis.GetTotalDataSpanHoursAsync(serverId: 1);

Assert.True(spanHours >= 29,
$"span must count hot + archived history (~30h), got {spanHours:F1}h — the check is reading the raw table again");
}

[Fact]
public async Task DataSpan_StillGatesAGenuinelyYoungInstall()
{
var initializer = new DuckDbInitializer(_dbPath);
await initializer.InitializeAsync();

using (var connection = new DuckDBConnection($"Data Source={_dbPath}"))
{
await connection.OpenAsync(TestContext.Current.CancellationToken);
await ExecuteAsync(connection, @"
INSERT INTO wait_stats (collection_id, collection_time, server_id, server_name, wait_type)
VALUES
(1, now() - INTERVAL 2 HOUR, 1, 'S1', 'SOS_SCHEDULER_YIELD'),
(2, now(), 1, 'S1', 'SOS_SCHEDULER_YIELD')");
}

await initializer.CreateArchiveViewsAsync();

var analysis = new AnalysisService(initializer);
var spanHours = await analysis.GetTotalDataSpanHoursAsync(serverId: 1);

Assert.InRange(spanHours, 1.5, 3);
}

/// <summary>
/// The sweep guard: NO archivable table may be read raw anywhere in the analysis pipeline — every
/// read goes through its <c>v_</c> archive view, or the #1809 class of defect returns one query at
/// a time. Driven by <see cref="ArchiveService.ArchivableTables"/>, the same catalog-derived list
/// the archiver itself uses, so a new collector's table is guarded the day it exists.
/// </summary>
[Fact]
public void AnalysisPipeline_NeverReadsAnArchivableTableRaw()
{
var analysisDir = Path.Combine(FindRepoDirectory(Path.Combine("Lite", "Analysis")));
var sources = Directory.GetFiles(analysisDir, "*.cs", SearchOption.AllDirectories);
Assert.NotEmpty(sources);

var offenders = new System.Collections.Generic.List<string>();
foreach (var (table, _) in ArchiveService.ArchivableTables)
{
var raw = new Regex($@"\bFROM\s+{Regex.Escape(table)}\b");
foreach (var file in sources)
{
foreach (Match match in raw.Matches(File.ReadAllText(file)))
{
offenders.Add($"{Path.GetFileName(file)}: {match.Value}");
}
}
}

Assert.True(offenders.Count == 0,
"Analysis reads archivable tables raw (use the v_ archive view so history survives the 512 MB reset):\n"
+ string.Join("\n", offenders.Distinct()));
}

private static async Task ExecuteAsync(DuckDBConnection connection, string sql)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = sql;
await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
}

private static string FindRepoDirectory(string relativePath)
{
var dir = AppContext.BaseDirectory;
for (int i = 0; i < 8 && dir is not null; i++)
{
var candidate = Path.Combine(dir, relativePath);
if (Directory.Exists(candidate))
{
return candidate;
}
dir = Path.GetDirectoryName(dir);
}
throw new DirectoryNotFoundException($"Could not locate {relativePath} walking up from {AppContext.BaseDirectory}");
}
}
6 changes: 4 additions & 2 deletions Lite/Analysis/AnalysisService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,9 @@ public async Task CleanupAsync(int retentionDays = 30)
/// the analysis window. A server with 100 hours of total history can safely
/// be analyzed over a 4-hour window without dilution.
/// </summary>
private async Task<double> GetTotalDataSpanHoursAsync(int serverId)
/* Internal for AnalysisDataSpanTests (#1809): the span must survive an archive/reset, which is
only observable with a real DuckDB + parquet fixture. */
internal async Task<double> GetTotalDataSpanHoursAsync(int serverId)
{
try
{
Expand All @@ -346,7 +348,7 @@ private async Task<double> GetTotalDataSpanHoursAsync(int serverId)
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
SELECT EXTRACT(EPOCH FROM (MAX(collection_time) - MIN(collection_time))) / 3600.0
FROM wait_stats
FROM v_wait_stats
WHERE server_id = $1";

cmd.Parameters.Add(new DuckDBParameter { Value = serverId });
Expand Down
2 changes: 1 addition & 1 deletion Lite/Analysis/DrillDownCollector.Plans.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ private async Task CollectPlanAdvisoryDetail(AnalysisFinding finding, AnalysisCo
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
SELECT query_plan_xml
FROM query_stats
FROM v_query_stats
WHERE server_id = $1
AND collection_time >= $2
AND collection_time <= $3
Expand Down
6 changes: 3 additions & 3 deletions Lite/Analysis/DuckDbFactCollector.Activity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ private async Task CollectActiveQueryFactsAsync(AnalysisContext context, List<Fa
COUNT(CASE WHEN dop > 1 THEN 1 END) AS parallel_count,
MAX(dop) AS max_dop,
COUNT(DISTINCT session_id) AS distinct_sessions
FROM query_snapshots
FROM v_query_snapshots
WHERE server_id = $1
AND collection_time >= $2
AND collection_time <= $3";
Expand Down Expand Up @@ -185,7 +185,7 @@ private async Task CollectRunningJobFactsAsync(AnalysisContext context, List<Fac
COUNT(CASE WHEN is_running_long THEN 1 END) AS running_long_count,
MAX(percent_of_average) AS max_percent_of_avg,
MAX(current_duration_seconds) AS max_duration_seconds
FROM running_jobs
FROM v_running_jobs
WHERE server_id = $1
AND collection_time >= $2
AND collection_time <= $3";
Expand Down Expand Up @@ -238,7 +238,7 @@ private async Task CollectSessionFactsAsync(AnalysisContext context, List<Fact>
WITH latest AS (
SELECT program_name, connection_count, running_count, sleeping_count, dormant_count,
ROW_NUMBER() OVER (PARTITION BY program_name ORDER BY collection_time DESC) AS rn
FROM session_stats
FROM v_session_stats
WHERE server_id = $1
AND collection_time >= $2
AND collection_time <= $3
Expand Down
10 changes: 5 additions & 5 deletions Lite/Analysis/DuckDbFactCollector.Config.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ WITH latest AS (
configuration_name,
value_in_use,
ROW_NUMBER() OVER (PARTITION BY configuration_name ORDER BY capture_time DESC) AS rn
FROM server_config
FROM v_server_config
WHERE server_id = $1
AND configuration_name IN (
'cost threshold for parallelism',
Expand Down Expand Up @@ -118,7 +118,7 @@ private async Task CollectServerMetadataFactsAsync(AnalysisContext context, List
cmd.CommandText = @"
SELECT engine_edition,
CAST(SPLIT_PART(product_version, '.', 1) AS INTEGER) AS major_version
FROM server_properties
FROM v_server_properties
WHERE server_id = $1
ORDER BY collection_time DESC
LIMIT 1";
Expand Down Expand Up @@ -159,7 +159,7 @@ WITH latest AS (
is_query_store_on, compatibility_level, page_verify_option,
is_accelerated_database_recovery_on,
ROW_NUMBER() OVER (PARTITION BY database_name ORDER BY capture_time DESC) AS rn
FROM database_config
FROM v_database_config
WHERE server_id = $1
)
SELECT
Expand Down Expand Up @@ -234,7 +234,7 @@ private async Task CollectTraceFlagFactsAsync(AnalysisContext context, List<Fact
WITH latest AS (
SELECT trace_flag, status,
ROW_NUMBER() OVER (PARTITION BY trace_flag ORDER BY capture_time DESC) AS rn
FROM trace_flags
FROM v_trace_flags
WHERE server_id = $1
AND is_global = true
)
Expand Down Expand Up @@ -288,7 +288,7 @@ private async Task CollectServerPropertiesFactsAsync(AnalysisContext context, Li
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,
lock_pages_in_memory, instant_file_initialization_enabled, memory_dump_count
FROM server_properties
FROM v_server_properties
WHERE server_id = $1
ORDER BY collection_time DESC
LIMIT 1";
Expand Down
4 changes: 2 additions & 2 deletions Lite/Analysis/DuckDbFactCollector.QueryPerf.cs
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ private async Task CollectProcedureStatsFactsAsync(AnalysisContext context, List
SUM(delta_worker_time) AS total_cpu_time_us,
SUM(delta_elapsed_time) AS total_elapsed_time_us,
SUM(delta_logical_reads) AS total_logical_reads
FROM procedure_stats
FROM v_procedure_stats
WHERE server_id = $1
AND collection_time >= $2
AND collection_time <= $3
Expand Down Expand Up @@ -461,7 +461,7 @@ private async Task CollectPlanAdvisoryFactsAsync(AnalysisContext context, List<F
using var command = connection.CreateCommand();
command.CommandText = @"
SELECT query_plan_xml
FROM query_stats
FROM v_query_stats
WHERE server_id = $1
AND collection_time >= $2
AND collection_time <= $3
Expand Down
12 changes: 6 additions & 6 deletions Lite/Analysis/DuckDbFactCollector.Resources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ private async Task CollectMemoryFactsAsync(AnalysisContext context, List<Fact> f
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
SELECT total_physical_memory_mb, buffer_pool_mb, target_server_memory_mb
FROM memory_stats
FROM v_memory_stats
WHERE server_id = $1
AND collection_time <= $2
ORDER BY collection_time DESC
Expand Down Expand Up @@ -77,7 +77,7 @@ private async Task CollectRunnableTaskFactsAsync(AnalysisContext context, List<F
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
SELECT total_runnable_tasks_count, runnable_tasks_warning
FROM cpu_scheduler_stats
FROM v_cpu_scheduler_stats
WHERE server_id = $1
AND collection_time >= $2
AND collection_time <= $3
Expand Down Expand Up @@ -186,7 +186,7 @@ private async Task CollectMemoryClerkFactsAsync(AnalysisContext context, List<Fa
WITH latest AS (
SELECT clerk_type, memory_mb,
ROW_NUMBER() OVER (PARTITION BY clerk_type ORDER BY collection_time DESC) AS rn
FROM memory_clerks
FROM v_memory_clerks
WHERE server_id = $1
AND collection_time <= $2
)
Expand Down Expand Up @@ -323,7 +323,7 @@ private async Task CollectPerfmonFactsAsync(AnalysisContext context, List<Fact>
WITH latest AS (
SELECT counter_name, cntr_value, delta_cntr_value,
ROW_NUMBER() OVER (PARTITION BY counter_name ORDER BY collection_time DESC) AS rn
FROM perfmon_stats
FROM v_perfmon_stats
WHERE server_id = $1
AND collection_time >= $2
AND collection_time <= $3
Expand Down Expand Up @@ -397,7 +397,7 @@ private async Task CollectPlanCacheFactsAsync(AnalysisContext context, List<Fact
WITH ranked AS (
SELECT total_plans, single_use_plans, total_size_mb, single_use_size_mb,
DENSE_RANK() OVER (ORDER BY collection_time DESC) AS rnk
FROM plan_cache_stats
FROM v_plan_cache_stats
WHERE server_id = $1
AND collection_time <= $2
)
Expand Down Expand Up @@ -473,7 +473,7 @@ private async Task CollectMemoryPressureEventFactsAsync(AnalysisContext context,
SUM(CASE WHEN memory_indicators_process >= 2 OR memory_indicators_system >= 2 THEN 1 ELSE 0 END) AS pressure_event_count,
MAX(memory_indicators_process) AS max_process,
MAX(memory_indicators_system) AS max_system
FROM memory_pressure_events
FROM v_memory_pressure_events
WHERE server_id = $1
AND collection_time >= $2
AND collection_time <= $3";
Expand Down
Loading
Loading