diff --git a/Darling/Darling.Tests/DarlingAnomalyBaselineTests.cs b/Darling/Darling.Tests/DarlingAnomalyBaselineTests.cs
index d4878b17f4..988687e28a 100644
--- a/Darling/Darling.Tests/DarlingAnomalyBaselineTests.cs
+++ b/Darling/Darling.Tests/DarlingAnomalyBaselineTests.cs
@@ -242,6 +242,170 @@ QUALIFY NOT (delta_cntr_value = 0 AND COALESCE(LAG(delta_cntr_value) OVER
/* The pre-window row filter is unchanged from Lite. */
Assert.Contains("counter_name = 'Batch Requests/sec'", TimescaleSupport.CreatePerfmonBaselineSql, StringComparison.Ordinal);
Assert.Contains("delta_cntr_value >= 0", TimescaleSupport.CreatePerfmonBaselineSql, StringComparison.Ordinal);
+
+ /* #3527: v is the PER-SECOND rate. The perfmon_baseline supply materializes only
+ (collection_time, delta_cntr_value) — no stored interval, and a continuous aggregate cannot
+ grow a column without forfeiting the history the 4-day raw tier can't refill — so the divisor
+ is derived from LAG(collection_time) over the collapsed series (the WaitMsPerSec idiom),
+ computed in the SAME windowed CTE (window-before-filter holds for it too), with the
+ interval-less first row filtered alongside the restart exclusion. The DOUBLE PRECISION cast
+ is the io-arm rule: STDDEV_SAMP over numeric can overflow System.Decimal. */
+ var intervalAt = sql.IndexOf("extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_sec", StringComparison.Ordinal);
+ Assert.True(intervalAt >= 0 && intervalAt < fromCteAt, "interval_sec must be derived inside the windowed CTE");
+ Assert.Contains("delta_cntr_value::DOUBLE PRECISION / interval_sec AS v", sql, StringComparison.Ordinal);
+ var intervalFilterAt = sql.IndexOf("interval_sec > 0", StringComparison.Ordinal);
+ Assert.True(intervalFilterAt > fromCteAt, "the interval filter must sit OUTSIDE the windowed CTE, with the exclusion");
+ }
+
+ ///
+ /// #3527: the batch-request WINDOW statistic must be requests/sec — the per-interval
+ /// delta_cntr_value divided by the row's measured sample_interval_seconds — or the
+ /// BatchRequestFloor/Fallback thresholds (defined in requests/sec) admit 60-300x-inflated
+ /// deltas and the comparison against the per-second baseline is cross-unit. Interval <= 0
+ /// rows carry NO knowable delta and must be filtered, never read as a rate of 0 or as the
+ /// raw delta.
+ ///
+ [Fact]
+ public void BatchRequestWindow_DividesByMeasuredInterval_AndSkipsUnknowableRows()
+ {
+ var sql = PgAnomalyDetector.BatchRequestWindowSql;
+
+ Assert.Contains("AVG(delta_cntr_value * 1.0 / NULLIF(sample_interval_seconds, 0))", sql, StringComparison.Ordinal);
+ Assert.Contains("MAX(delta_cntr_value * 1.0 / NULLIF(sample_interval_seconds, 0))", sql, StringComparison.Ordinal);
+ Assert.Contains("sample_interval_seconds > 0", sql, StringComparison.Ordinal);
+
+ /* A raw AVG/MAX of the delta is exactly the #3527 defect — pin its absence. */
+ Assert.DoesNotContain("AVG(delta_cntr_value)", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("MAX(delta_cntr_value)", sql, StringComparison.Ordinal);
+ }
+
+ ///
+ /// #3527 proven live, both halves in one place: the BatchRequests BASELINE arm derives its
+ /// per-second unit from LAG(collection_time) over the perfmon_baseline supply, and the DETECTOR's
+ /// window read divides by the stored measured interval — so the two sides meet in the same
+ /// requests/sec unit and the absolute bars judge honest rates.
+ ///
+ /// History (one Monday-10:00 bucket, 12 collections at 300s spacing, delta 30000 each —
+ /// 100 req/sec): c1 has no prior (interval NULL → dropped), c6 is a restart zero (prior 30000 >
+ /// 1000 → excluded), c7 is a genuine idle zero (prior 0 → kept at 0/sec). 10 samples, mean
+ /// (9x100 + 0)/10 = 90 — in requests/sec, where the raw-delta unit would read 27000.
+ ///
+ /// Window (the following Monday): three rows at stored interval 60, delta 600000 — 10000
+ /// req/sec — plus one interval-0 row with a wild delta that must be SKIPPED, not rated. The one
+ /// planted history day leaves the bucket untrustworthy (Full tier needs 3 distinct days), so the
+ /// detector fires on the absolute BatchRequestFallback bar (5000 req/sec): peak 10000 clears it
+ /// honestly. Pre-#3527 the raw deltas cleared every bar by orders of magnitude regardless of
+ /// workload; post-fix the emitted Value, peak/avg metadata, and baseline_mean are all per-second.
+ ///
+ [Fact]
+ public async Task EndToEnd_BatchRequestArm_PerSecondBaselineAndWindow_AgainstDevPostgres()
+ {
+ var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(connectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live batch-request test.");
+
+ var ct = TestContext.Current.CancellationToken;
+ const int batchServerId = TestServerId + 2; // own id — this test cleans its own rows
+ const string batchServerName = "batch-per-second-e2e";
+
+ using var connection = new NpgsqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+
+ await using (var cleanup = new NpgsqlCommand(
+ $"DELETE FROM perfmon_stats WHERE server_id = {batchServerId}; " +
+ $"DELETE FROM wait_stats WHERE server_id = {batchServerId};", connection))
+ {
+ await cleanup.ExecuteNonQueryAsync(ct);
+ }
+
+ await using var postgres = NpgsqlDataSource.Create(connectionString!);
+ var bodySucceeded = false;
+ try
+ {
+ var day = DateTime.UtcNow.Date.AddDays(-8);
+ while (day.DayOfWeek != DayOfWeek.Monday) day = day.AddDays(-1);
+ var historyStart = DateTime.SpecifyKind(day.AddHours(10), DateTimeKind.Unspecified);
+
+ for (var i = 0; i < 12; i++)
+ {
+ var delta = (i == 5 || i == 6) ? 0L : 30000L;
+ await InsertAsync(connection,
+ "INSERT INTO perfmon_stats (collection_id, collection_time, server_id, server_name, object_name, counter_name, instance_name, cntr_value, delta_cntr_value, sample_interval_seconds) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
+ (long)(200 + i), historyStart.AddMinutes(5 * i), batchServerId, batchServerName,
+ "SQLServer:SQL Statistics", "Batch Requests/sec", "", delta * 2, delta, 300);
+ }
+
+ /* The baseline supply must exist (see the wait test's note) — plain fallback views. */
+ await TimescaleSupport.EnsureBaselineFallbackViewsAsync(connection, null, ct);
+
+ var provider = new PgBaselineProvider(postgres);
+ var analysisTime = historyStart.AddDays(7);
+
+ var baseline = await provider.GetBaselineAsync(batchServerId, MetricNames.BatchRequests, analysisTime);
+ Assert.Equal(10L, baseline.SampleCount);
+ Assert.Equal(90.0, baseline.Mean, 0.001);
+ Assert.Equal(BaselineTier.Full, baseline.Tier);
+ Assert.Equal(10, baseline.HourOfDay);
+ Assert.Equal((int)DayOfWeek.Monday, baseline.DayOfWeek);
+
+ /* Canary for the HasBaselineData gate — OUTSIDE the analysis window so the wait
+ detector's own window read stays empty and it emits nothing. */
+ await InsertAsync(connection,
+ "INSERT INTO wait_stats (collection_id, collection_time, server_id, server_name, wait_type, delta_waiting_tasks, delta_wait_time_ms) VALUES ($1, $2, $3, $4, $5, $6, $7)",
+ 300L, historyStart, batchServerId, batchServerName, TestWaitType, 1L, 100L);
+
+ /* The anomalous current window: 10000 req/sec (delta 600000 over a measured 60s),
+ plus one interval-0 row whose wild delta must never be rated. */
+ for (var i = 0; i < 3; i++)
+ {
+ await InsertAsync(connection,
+ "INSERT INTO perfmon_stats (collection_id, collection_time, server_id, server_name, object_name, counter_name, instance_name, cntr_value, delta_cntr_value, sample_interval_seconds) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
+ (long)(400 + i), analysisTime.AddMinutes(5 * (i + 1)), batchServerId, batchServerName,
+ "SQLServer:SQL Statistics", "Batch Requests/sec", "", 1200000L, 600000L, 60);
+ }
+ await InsertAsync(connection,
+ "INSERT INTO perfmon_stats (collection_id, collection_time, server_id, server_name, object_name, counter_name, instance_name, cntr_value, delta_cntr_value, sample_interval_seconds) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
+ 403L, analysisTime.AddMinutes(20), batchServerId, batchServerName,
+ "SQLServer:SQL Statistics", "Batch Requests/sec", "", 0L, 999999999L, 0);
+
+ var detector = new PgAnomalyDetector(postgres, provider);
+ var context = new AnalysisContext
+ {
+ ServerId = batchServerId,
+ ServerName = batchServerName,
+ TimeRangeStart = analysisTime,
+ TimeRangeEnd = analysisTime.AddMinutes(30),
+ ServerUtcOffset = TimeSpan.Zero
+ };
+
+ var anomalies = await detector.DetectAnomaliesAsync(context);
+
+ var fact = Assert.Single(anomalies);
+ Assert.Equal("ANOMALY_BATCH_REQUESTS", fact.Key);
+ Assert.Equal(10000.0, fact.Value, 0.001); // per-second, not 600000
+ Assert.Equal(10000.0, fact.Metadata["peak_batch_requests"], 0.001);
+ Assert.Equal(10000.0, fact.Metadata["avg_batch_requests"], 0.001);
+ Assert.Equal(3.0, fact.Metadata["window_samples"]); // the interval-0 row is NOT a sample
+ Assert.Equal(90.0, fact.Metadata["baseline_mean"], 0.001); // same unit as the window
+ Assert.Equal(1.0, fact.Metadata["baseline_low_quality"]); // one distinct day → absolute bar
+ Assert.Equal(2.0, fact.Metadata["fallback_exceedance"], 0.001); // 10000 / the 5000 req/sec bar
+
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup, cleanupCt) =>
+ {
+ using (var command = new NpgsqlCommand(
+ $"DELETE FROM perfmon_stats WHERE server_id = {batchServerId}; " +
+ $"DELETE FROM wait_stats WHERE server_id = {batchServerId};", cleanup))
+ {
+ await command.ExecuteNonQueryAsync(cleanupCt);
+ }
+ await DropBaselineFallbackViewsAsync(cleanup, cleanupCt);
+ });
+ }
}
[Fact]
diff --git a/Darling/Darling.Tests/PgFactCollectorTests.cs b/Darling/Darling.Tests/PgFactCollectorTests.cs
index 5b5b60c096..f363944483 100644
--- a/Darling/Darling.Tests/PgFactCollectorTests.cs
+++ b/Darling/Darling.Tests/PgFactCollectorTests.cs
@@ -341,6 +341,126 @@ await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup
}
}
+ /* ---------------- #3527: perfmon facts are per-second rates ---------------- */
+
+ ///
+ /// #3527: delta_cntr_value spans one COLLECTION INTERVAL, not one second — read raw, the
+ /// PERFMON_*_SEC facts overstate by the cadence (60x at 60s, 300x at 5min). The query must
+ /// select the row's measured sample_interval_seconds (#2234) for the division and filter
+ /// interval <= 0 rows (no delta was knowable: first sighting, reset, gap) so rn = 1 lands on
+ /// the newest row a rate can honestly be derived from.
+ ///
+ [Fact]
+ public void PerfmonSql_SelectsTheMeasuredInterval_AndFiltersUnknowableRows()
+ {
+ var sql = PgFactCollector.PerfmonSql;
+
+ Assert.Contains("delta_cntr_value, sample_interval_seconds", sql, StringComparison.Ordinal);
+ Assert.Contains("sample_interval_seconds > 0", sql, StringComparison.Ordinal);
+ }
+
+ ///
+ /// #3527 live fixture: a 'Batch Requests/sec' row with delta 6000 over a measured 60s interval
+ /// must emit PERFMON_BATCH_REQ_SEC = 100 (not 6000), with the raw delta and the divisor in the
+ /// metadata. A NEWER interval-0 row (unknowable delta) must be skipped — the fact still comes
+ /// from the older usable row — and a counter with ONLY interval-0 rows emits no fact at all,
+ /// never a fact of 0.
+ ///
+ [Fact]
+ public async Task EndToEnd_PerfmonFacts_DivideDeltaByMeasuredInterval_AgainstDevPostgres()
+ {
+ var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(connectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live perfmon fact test.");
+
+ var ct = TestContext.Current.CancellationToken;
+ const int perfmonServerId = TestServerId - 2; // own id — this test cleans its own rows
+
+ using var connection = new NpgsqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+
+ await using (var cleanup = new NpgsqlCommand(
+ $"DELETE FROM perfmon_stats WHERE server_id = {perfmonServerId};", connection))
+ {
+ await cleanup.ExecuteNonQueryAsync(ct);
+ }
+
+ await using var postgres = NpgsqlDataSource.Create(connectionString!);
+ var collector = new PgFactCollector(postgres);
+
+ var bodySucceeded = false;
+ try
+ {
+ var windowEnd = TruncateToSeconds(DateTime.UtcNow);
+ var windowStart = windowEnd.AddHours(-1);
+
+ async Task PlantAsync(long id, DateTime time, string counter, long delta, int intervalSeconds)
+ {
+ using var plant = new NpgsqlCommand(@"
+INSERT INTO perfmon_stats
+ (collection_id, collection_time, server_id, server_name,
+ object_name, counter_name, instance_name, cntr_value, delta_cntr_value, sample_interval_seconds)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", connection);
+ plant.Parameters.AddWithValue(id);
+ plant.Parameters.AddWithValue(time);
+ plant.Parameters.AddWithValue(perfmonServerId);
+ plant.Parameters.AddWithValue("perfmon-per-second-e2e");
+ plant.Parameters.AddWithValue("SQLServer:SQL Statistics");
+ plant.Parameters.AddWithValue(counter);
+ plant.Parameters.AddWithValue("");
+ plant.Parameters.AddWithValue(delta * 2);
+ plant.Parameters.AddWithValue(delta);
+ plant.Parameters.AddWithValue(intervalSeconds);
+ await plant.ExecuteNonQueryAsync(ct);
+ }
+
+ /* Batch requests: an older USABLE row (delta 6000 / 60s = 100/sec), then a NEWER
+ interval-0 row that must not become the fact. */
+ await PlantAsync(1, windowStart.AddMinutes(20), "Batch Requests/sec", 6000, 60);
+ await PlantAsync(2, windowStart.AddMinutes(25), "Batch Requests/sec", 0, 0);
+
+ /* Compilations: one usable row, 300 / 60s = 5/sec. */
+ await PlantAsync(3, windowStart.AddMinutes(20), "SQL Compilations/sec", 300, 60);
+
+ /* Re-compilations: ONLY an interval-0 row — no rate is knowable, so no fact. */
+ await PlantAsync(4, windowStart.AddMinutes(20), "SQL Re-Compilations/sec", 0, 0);
+
+ var context = new AnalysisContext
+ {
+ ServerId = perfmonServerId,
+ ServerName = "perfmon-per-second-e2e",
+ TimeRangeStart = windowStart,
+ TimeRangeEnd = windowEnd,
+ ServerUtcOffset = TimeSpan.Zero
+ };
+
+ var facts = await collector.CollectFactsAsync(context);
+
+ var batch = Assert.Single(facts, f => f.Key == "PERFMON_BATCH_REQ_SEC");
+ Assert.Equal(100.0, batch.Value, precision: 10);
+ Assert.Equal(6000, batch.Metadata["delta_cntr_value"]);
+ Assert.Equal(60, batch.Metadata["sample_interval_seconds"]);
+
+ var compilations = Assert.Single(facts, f => f.Key == "PERFMON_COMPILATIONS_SEC");
+ Assert.Equal(5.0, compilations.Value, precision: 10);
+
+ Assert.DoesNotContain(facts, f => f.Key == "PERFMON_RECOMPILATIONS_SEC");
+ Assert.Equal(2, facts.Count);
+
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup, cleanupCt) =>
+ {
+ using var command = new NpgsqlCommand(
+ $"DELETE FROM perfmon_stats WHERE server_id = {perfmonServerId};", cleanup);
+ await command.ExecuteNonQueryAsync(cleanupCt);
+ });
+ }
+ }
+
private static DateTime TruncateToSeconds(DateTime value) =>
DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified);
diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs
index 776d66e4c4..4a4c0d85cf 100644
--- a/Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs
+++ b/Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs
@@ -207,14 +207,20 @@ FROM v_file_io_stats
WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3
AND (delta_reads > 0 OR delta_writes > 0)";
+ /* Batch-request window: per-second rate per sample (#3527) — delta_cntr_value spans one collection
+ interval, so divide by the row's MEASURED sample_interval_seconds (#2234). Interval <= 0 marks an
+ unknowable delta (first sighting/reset/gap) and the row is skipped, never read as 0. Keeps the
+ window statistic in the same requests/sec unit as the baseline and the BatchRequestFloor/Fallback
+ thresholds. */
public const string BatchRequestWindowSql = @"
-SELECT AVG(delta_cntr_value) AS avg_batch,
- MAX(delta_cntr_value) AS peak_batch,
+SELECT AVG(delta_cntr_value * 1.0 / NULLIF(sample_interval_seconds, 0)) AS avg_batch,
+ MAX(delta_cntr_value * 1.0 / NULLIF(sample_interval_seconds, 0)) AS peak_batch,
COUNT(*) AS sample_count
FROM v_perfmon_stats
WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3
AND counter_name = 'Batch Requests/sec'
-AND delta_cntr_value >= 0";
+AND delta_cntr_value >= 0
+AND sample_interval_seconds > 0";
public const string SessionWindowSql = @"
WITH per_collection AS (
diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs
index cc8e10ab5a..e987c868b3 100644
--- a/Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs
+++ b/Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs
@@ -366,23 +366,19 @@ FROM cpu_utilization_stats
/* QUALIFY rewrite 1 of 4 — cumulative counter, restart exclusion.
Excludes samples where the delta drops to 0 when the prior sample was > 1000
- (restart signature for cumulative counters). Lite's DuckDB original:
-
- SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day,
- EXTRACT(DOW FROM collection_time)::INT AS day_of_week,
- AVG(delta_cntr_value) AS mean_val,
- STDDEV_SAMP(delta_cntr_value) AS stddev_val,
- COUNT(*) AS sample_count
- FROM (
- SELECT collection_time, delta_cntr_value
+ (restart signature for cumulative counters). Lite's DuckDB original (#3527 unit:
+ v is delta / the row's measured sample_interval_seconds — a per-second rate):
+
+ WITH clean AS (
+ SELECT collection_time, delta_cntr_value * 1.0 / NULLIF(sample_interval_seconds, 0) AS v
FROM v_perfmon_stats
WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3
AND counter_name = 'Batch Requests/sec'
AND delta_cntr_value >= 0
+ AND sample_interval_seconds > 0
QUALIFY NOT (delta_cntr_value = 0
AND COALESCE(LAG(delta_cntr_value) OVER (ORDER BY collection_time), 0) > 1000)
)
- GROUP BY hour_of_day, day_of_week
QUALIFY evaluates AFTER window computation: LAG runs over every WHERE-surviving
row (including rows QUALIFY itself is about to drop), THEN the predicate prunes.
@@ -390,18 +386,32 @@ The rewrite computes the SAME LAG over the SAME WHERE-filtered rowset inside a
CTE and applies the IDENTICAL predicate in the outer WHERE — window-before-filter
is preserved, so only the FIRST zero after a >1000 sample is dropped, and a zero
following another zero keeps LAG = 0 and SURVIVES (genuine idle, not a restart).
- Row selection is exactly the original's. */
+ Row selection is exactly the original's.
+
+ #3527 divisor: this arm reads the perfmon_baseline supply (the #1757 CAGG / fallback
+ view), which materializes only (collection_time, delta_cntr_value) — it does NOT carry
+ sample_interval_seconds, and a continuous aggregate cannot grow a column without a
+ drop-and-rebuild that would forfeit the 31 days of baseline history the 4-day raw tier
+ can no longer refill. So the interval is DERIVED from the gap between consecutive
+ collections — LAG(collection_time) over the collapsed series, byte-for-byte the
+ WaitMsPerSec arm's idiom (see CreateWaitStatsBaselineSql's note: the provider computes
+ interval_sec, nothing extra is stored). Same requests/sec unit as Lite and as the
+ detector's window read; the first row of the window has no prior (interval NULL) and is
+ skipped, exactly like WaitMsPerSec. The ::DOUBLE PRECISION cast is the io-arm rule:
+ STDDEV_SAMP over numeric can overflow System.Decimal at materialization. */
MetricNames.BatchRequests => @"
WITH windowed AS (
SELECT collection_time, delta_cntr_value,
- COALESCE(LAG(delta_cntr_value) OVER (ORDER BY collection_time), 0) AS prior_delta
+ COALESCE(LAG(delta_cntr_value) OVER (ORDER BY collection_time), 0) AS prior_delta,
+ extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_sec
FROM perfmon_baseline
WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3
),
clean AS (
- SELECT collection_time, delta_cntr_value AS v
+ SELECT collection_time, delta_cntr_value::DOUBLE PRECISION / interval_sec AS v
FROM windowed
WHERE NOT (delta_cntr_value = 0 AND prior_delta > 1000)
+ AND interval_sec > 0
)," + RobustTierScaffold,
/* QUALIFY rewrite 2 of 4 — cumulative counter, multiple rows per collection (per
diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs
index 8b60fdd25e..8c126431b6 100644
--- a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs
+++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs
@@ -331,22 +331,30 @@ private async Task CollectCpuUtilizationFactsAsync(AnalysisContext context, List
}
}
+ /* #3527: delta_cntr_value spans one COLLECTION INTERVAL, not one second — at a 60s cadence the raw
+ delta is 60x the true rate. The honest rate divides by the row's MEASURED sample_interval_seconds
+ (#2234); interval <= 0 marks an unknowable delta (first sighting, counter reset, gap past the
+ policy), so those rows are filtered rather than emitted as 0 — rn = 1 lands on the newest row a
+ rate can honestly be derived from. */
public const string PerfmonSql = @"
WITH latest AS (
- SELECT counter_name, cntr_value, delta_cntr_value,
+ SELECT counter_name, cntr_value, delta_cntr_value, sample_interval_seconds,
ROW_NUMBER() OVER (PARTITION BY counter_name ORDER BY collection_time DESC) AS rn
FROM perfmon_stats
WHERE server_id = $1
AND collection_time >= $2
AND collection_time <= $3
AND counter_name IN ('Batch Requests/sec', 'SQL Compilations/sec', 'SQL Re-Compilations/sec')
+ AND sample_interval_seconds > 0
)
-SELECT counter_name, cntr_value, delta_cntr_value
+SELECT counter_name, cntr_value, delta_cntr_value, sample_interval_seconds
FROM latest WHERE rn = 1";
///
/// Collects key perfmon throughput counters: Batch Requests/sec, compilations, recompilations.
/// Unscored context that distinguishes a busy server from a sick one (used by the AI surfaces).
+ /// Fact values are per-second rates: the per-interval delta divided by the row's measured
+ /// sample_interval_seconds (#3527); the raw delta and the divisor ride the metadata.
///
private async Task CollectPerfmonFactsAsync(AnalysisContext context, List facts)
{
@@ -365,6 +373,7 @@ private async Task CollectPerfmonFactsAsync(AnalysisContext context, List
var counterName = reader.GetString(0);
var cntrValue = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1));
var deltaValue = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2));
+ var intervalSeconds = reader.IsDBNull(3) ? 0L : ToInt64(reader.GetValue(3));
var (factKey, source) = counterName switch
{
@@ -376,8 +385,11 @@ private async Task CollectPerfmonFactsAsync(AnalysisContext context, List
if (factKey == null) continue;
- // All remaining counters are per-second rates — use the delta.
- var value = (double)deltaValue;
+ /* The delta spans one collection interval — divide by the measured interval for the
+ per-second rate (#3527). The SQL already filters interval <= 0 (unknowable delta);
+ this guard keeps a raw or zero value from ever escaping if that filter regresses. */
+ if (intervalSeconds <= 0) continue;
+ var value = deltaValue / (double)intervalSeconds;
facts.Add(new Fact
{
@@ -388,7 +400,8 @@ private async Task CollectPerfmonFactsAsync(AnalysisContext context, List
Metadata = new Dictionary
{
["cntr_value"] = cntrValue,
- ["delta_cntr_value"] = deltaValue
+ ["delta_cntr_value"] = deltaValue,
+ ["sample_interval_seconds"] = intervalSeconds
}
});
}
diff --git a/Lite.Tests/AnomalyDetectorTests.cs b/Lite.Tests/AnomalyDetectorTests.cs
index 073cd6f808..198b5d14b2 100644
--- a/Lite.Tests/AnomalyDetectorTests.cs
+++ b/Lite.Tests/AnomalyDetectorTests.cs
@@ -100,10 +100,10 @@ private async Task ExecuteSeedAsync(string sql)
[Fact]
public async Task DetectBatchRequestAnomalies_Spike_DetectsAnomaly()
{
- // Baseline: normal batch requests (~5000)
+ // Baseline: normal batch requests (delta ~5000 per 10s interval = ~500/sec)
await SeedBaselinePerfmon("Batch Requests/sec", 5000, variance: 200);
- // Analysis window: spike to 15000
+ // Analysis window: spike to delta 15000 per 10s interval = 1500/sec
for (int i = 0; i < 16; i++)
await SeedPerfmonAsync(_analysisStart.AddMinutes(i * 15), "Batch Requests/sec", 15000);
@@ -139,11 +139,51 @@ public async Task DetectBatchRequestAnomalies_Normal_NoAnomaly()
[Fact]
public async Task DetectBatchRequestAnomalies_LowVolumeSpike_NoAnomaly()
{
- // Low-throughput server: a 6x relative spike but the peak stays at 300/sec
- // — below the 500/sec BatchRequestFloor — must NOT be flagged.
- await SeedBaselinePerfmon("Batch Requests/sec", 50, variance: 5);
+ // Low-throughput server: a 6x relative spike but the peak stays at 300/sec (delta 3000 over
+ // a 10s interval) — below the 500/sec BatchRequestFloor — must NOT be flagged. The RAW
+ // per-interval delta (3000) is past the floor: only the per-second division (#3527) keeps
+ // this quiet, so this test also pins that the floor compares requests/sec, not the delta.
+ await SeedBaselinePerfmon("Batch Requests/sec", 500, variance: 50);
for (int i = 0; i < 16; i++)
- await SeedPerfmonAsync(_analysisStart.AddMinutes(i * 15), "Batch Requests/sec", 300);
+ await SeedPerfmonAsync(_analysisStart.AddMinutes(i * 15), "Batch Requests/sec", 3000);
+
+ await SeedBaselineCpu(10, variance: 2);
+
+ var anomalies = await _detector.DetectAnomaliesAsync(CreateContext());
+
+ Assert.DoesNotContain(anomalies, f => f.Key == "ANOMALY_BATCH_REQUESTS");
+ }
+
+ [Fact]
+ public async Task DetectBatchRequestAnomalies_WindowValuesArePerSecond()
+ {
+ // #3527 fixture through the detector: window delta 15000 over a 10s interval = 1500/sec.
+ // The emitted Value and peak/avg metadata must be the per-second numbers, in the same unit
+ // as the baseline (delta ~5000 / 10s = ~500/sec) — not the raw per-interval deltas.
+ await SeedBaselinePerfmon("Batch Requests/sec", 5000, variance: 200);
+ for (int i = 0; i < 16; i++)
+ await SeedPerfmonAsync(_analysisStart.AddMinutes(i * 15), "Batch Requests/sec", 15000);
+
+ await SeedBaselineCpu(10, variance: 2);
+
+ var anomalies = await _detector.DetectAnomaliesAsync(CreateContext());
+
+ var fact = anomalies.First(f => f.Key == "ANOMALY_BATCH_REQUESTS");
+ Assert.Equal(1500.0, fact.Value);
+ Assert.Equal(1500.0, fact.Metadata["peak_batch_requests"]);
+ Assert.Equal(1500.0, fact.Metadata["avg_batch_requests"]);
+ Assert.InRange(fact.Metadata["baseline_mean"], 450, 550);
+ }
+
+ [Fact]
+ public async Task DetectBatchRequestAnomalies_IntervalZeroRowsAreSkipped_NotReadAsRates()
+ {
+ // #3527: interval-0 rows carry NO knowable delta. A window holding only interval-0 rows —
+ // however wild their deltas — has zero usable samples, so the detector stays silent instead
+ // of reading the raw deltas as a spike (or the rows as rate 0).
+ await SeedBaselinePerfmon("Batch Requests/sec", 5000, variance: 200);
+ for (int i = 0; i < 16; i++)
+ await SeedPerfmonAsync(_analysisStart.AddMinutes(i * 15), "Batch Requests/sec", 999_999, intervalSeconds: 0);
await SeedBaselineCpu(10, variance: 2);
@@ -600,7 +640,10 @@ private async Task SeedCpuAsync(DateTime time, int cpuValue)
await cmd.ExecuteNonQueryAsync();
}
- private async Task SeedPerfmonAsync(DateTime time, string counterName, long deltaValue)
+ /// Seeds one perfmon row. deltaValue is the PER-INTERVAL delta; the detector divides it by
+ /// intervalSeconds (#3527), so at the default 10s interval the per-second rate is deltaValue / 10.
+ /// intervalSeconds = 0 plants the unknowable-delta marker the reads must skip.
+ private async Task SeedPerfmonAsync(DateTime time, string counterName, long deltaValue, int intervalSeconds = 10)
{
using var readLock = _duckDb.AcquireReadLock();
var conn = await SeedConnectionAsync();
@@ -608,12 +651,13 @@ private async Task SeedPerfmonAsync(DateTime time, string counterName, long delt
cmd.CommandText = @"INSERT INTO perfmon_stats
(collection_id, collection_time, server_id, server_name,
object_name, counter_name, instance_name, cntr_value, delta_cntr_value, sample_interval_seconds)
- VALUES ($1, $2, $3, 'TestServer', 'SQLServer:SQL Statistics', $4, '', $5, $5, 10)";
+ VALUES ($1, $2, $3, 'TestServer', 'SQLServer:SQL Statistics', $4, '', $5, $5, $6)";
cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- });
cmd.Parameters.Add(new DuckDBParameter { Value = time });
cmd.Parameters.Add(new DuckDBParameter { Value = ServerId });
cmd.Parameters.Add(new DuckDBParameter { Value = counterName });
cmd.Parameters.Add(new DuckDBParameter { Value = deltaValue });
+ cmd.Parameters.Add(new DuckDBParameter { Value = intervalSeconds });
await cmd.ExecuteNonQueryAsync();
}
diff --git a/Lite.Tests/BaselineProviderTests.cs b/Lite.Tests/BaselineProviderTests.cs
index 1ce9f111ea..ad04f8bd9b 100644
--- a/Lite.Tests/BaselineProviderTests.cs
+++ b/Lite.Tests/BaselineProviderTests.cs
@@ -286,8 +286,34 @@ public async Task GetBaseline_BatchRequests_ExcludesRestartDrop()
_provider.ClearCache();
var baseline = await _provider.GetBaselineAsync(ServerId, MetricNames.BatchRequests, AnalysisTime);
- // The restart drop (0) should be excluded, so mean should be near 5000, not pulled toward 0
- Assert.True(baseline.Mean > 4000, $"Mean {baseline.Mean} should not be poisoned by restart drop");
+ // #3527: the baseline is per-second now — deltas of ~5000 over 10s intervals are ~500/sec.
+ // The restart drop (0) should be excluded, so the mean should sit near 500, not pulled toward 0.
+ Assert.True(baseline.Mean > 400, $"Mean {baseline.Mean} should not be poisoned by restart drop");
+ Assert.InRange(baseline.Mean, 400, 600);
+ }
+
+ [Fact]
+ public async Task GetBaseline_BatchRequests_IsPerSecond_AndSkipsIntervalZeroRows()
+ {
+ // #3527 fixture: deltas of 6000 over measured 60s intervals are 100 requests/sec — the
+ // baseline population must be that division, in the same unit as the detector's window read.
+ // Interspersed interval-0 rows (unknowable delta) must not enter the population at all:
+ // read as raw deltas they would inflate the mean; read as 0 they would drag it down.
+ for (int d = 1; d <= 4; d++)
+ {
+ var day = AnalysisTime.AddDays(-7 * d);
+ for (int i = 0; i < 5; i++)
+ await SeedPerfmonAsync(day.AddMinutes(i * 10), "Batch Requests/sec", 6000, intervalSeconds: 60);
+
+ // One unknowable-delta row per day, wedged between the usable samples.
+ await SeedPerfmonAsync(day.AddMinutes(55), "Batch Requests/sec", 0, intervalSeconds: 0);
+ }
+
+ _provider.ClearCache();
+ var baseline = await _provider.GetBaselineAsync(ServerId, MetricNames.BatchRequests, AnalysisTime);
+
+ Assert.Equal(20, baseline.SampleCount); // 5 usable rows x 4 days; interval-0 rows contribute nothing
+ Assert.Equal(100.0, baseline.Mean, 3);
}
// ── Wait stats: per-collection aggregation ──
@@ -442,7 +468,10 @@ private async Task SeedCpuAsync(DateTime time, int cpuValue, int serverId = Serv
await cmd.ExecuteNonQueryAsync();
}
- private async Task SeedPerfmonAsync(DateTime time, string counterName, long deltaValue)
+ /// Seeds one perfmon row. deltaValue is the PER-INTERVAL delta; the baseline divides it by
+ /// intervalSeconds (#3527), so at the default 10s interval the per-second value is deltaValue / 10.
+ /// intervalSeconds = 0 plants the unknowable-delta marker the baseline must skip.
+ private async Task SeedPerfmonAsync(DateTime time, string counterName, long deltaValue, int intervalSeconds = 10)
{
using var readLock = _duckDb.AcquireReadLock();
var conn = await SeedConnectionAsync();
@@ -450,12 +479,13 @@ private async Task SeedPerfmonAsync(DateTime time, string counterName, long delt
cmd.CommandText = @"INSERT INTO perfmon_stats
(collection_id, collection_time, server_id, server_name,
object_name, counter_name, instance_name, cntr_value, delta_cntr_value, sample_interval_seconds)
- VALUES ($1, $2, $3, 'TestServer', 'SQLServer:SQL Statistics', $4, '', $5, $5, 10)";
+ VALUES ($1, $2, $3, 'TestServer', 'SQLServer:SQL Statistics', $4, '', $5, $5, $6)";
cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- });
cmd.Parameters.Add(new DuckDBParameter { Value = time });
cmd.Parameters.Add(new DuckDBParameter { Value = ServerId });
cmd.Parameters.Add(new DuckDBParameter { Value = counterName });
cmd.Parameters.Add(new DuckDBParameter { Value = deltaValue });
+ cmd.Parameters.Add(new DuckDBParameter { Value = intervalSeconds });
await cmd.ExecuteNonQueryAsync();
}
diff --git a/Lite.Tests/FactCollectorTests.cs b/Lite.Tests/FactCollectorTests.cs
index b226f1f72b..915c635795 100644
--- a/Lite.Tests/FactCollectorTests.cs
+++ b/Lite.Tests/FactCollectorTests.cs
@@ -213,6 +213,64 @@ public async Task CollectFacts_Perfmon_ReturnsRateCounters()
var facts = await SeedAndCollectAsync(s => s.SeedEverythingOnFireServerAsync());
Assert.True(facts.ContainsKey("PERFMON_BATCH_REQ_SEC"), "PERFMON_BATCH_REQ_SEC should be collected");
+
+ /* #3527: the seeder plants delta 30000 over a measured 60s interval — the fact must be the
+ per-second rate 500, not the raw per-interval delta. */
+ var batch = facts["PERFMON_BATCH_REQ_SEC"];
+ Assert.Equal(500.0, batch.Value);
+ Assert.Equal(30000, batch.Metadata["delta_cntr_value"]);
+ Assert.Equal(60, batch.Metadata["sample_interval_seconds"]);
+ }
+
+ ///
+ /// #3527 fixture: delta 6000 over a measured 60s interval is 100 requests/sec — the fact value is
+ /// the division, with the raw delta and the divisor preserved in metadata. Before the fix the fact
+ /// carried the raw 6000 (60x truth at this cadence).
+ ///
+ [Fact]
+ public async Task CollectFacts_Perfmon_DividesDeltaByMeasuredInterval()
+ {
+ var facts = await SeedAndCollectAsync(async s =>
+ {
+ await s.ClearTestDataAsync();
+ await s.SeedTestServerAsync();
+ await s.SeedPerfmonRawAsync("Batch Requests/sec", deltaValue: 6000, sampleIntervalSeconds: 60);
+ });
+
+ var batch = facts["PERFMON_BATCH_REQ_SEC"];
+ Assert.Equal(100.0, batch.Value);
+ Assert.Equal(6000, batch.Metadata["delta_cntr_value"]);
+ Assert.Equal(60, batch.Metadata["sample_interval_seconds"]);
+ }
+
+ ///
+ /// #3527: sample_interval_seconds = 0 means NO delta was knowable (first sighting, counter reset,
+ /// gap past the delta policy) — such a row must never become a fact of 0 or of the raw delta. The
+ /// newest usable row wins instead, and a counter with ONLY unusable rows emits no fact at all.
+ ///
+ [Fact]
+ public async Task CollectFacts_Perfmon_SkipsIntervalZeroRows()
+ {
+ var facts = await SeedAndCollectAsync(async s =>
+ {
+ await s.ClearTestDataAsync();
+ await s.SeedTestServerAsync();
+
+ /* Older usable row, then a NEWER interval-0 row: the fact must come from the usable row. */
+ await s.SeedPerfmonRawAsync("Batch Requests/sec", deltaValue: 6000, sampleIntervalSeconds: 60,
+ collectionTime: TestDataSeeder.TestPeriodEnd.AddMinutes(-5));
+ await s.SeedPerfmonRawAsync("Batch Requests/sec", deltaValue: 0, sampleIntervalSeconds: 0);
+
+ /* A counter whose only row in the window is interval-0: no fact, not a fact of 0. */
+ await s.SeedPerfmonRawAsync("SQL Re-Compilations/sec", deltaValue: 0, sampleIntervalSeconds: 0);
+ });
+
+ var batch = facts["PERFMON_BATCH_REQ_SEC"];
+ Assert.Equal(100.0, batch.Value);
+ Assert.Equal(60, batch.Metadata["sample_interval_seconds"]);
+
+ Assert.False(facts.ContainsKey("PERFMON_RECOMPILATIONS_SEC"),
+ "a counter with only interval-0 rows must emit no fact — 0 is not a knowable rate");
}
[Fact]
diff --git a/Lite.Tests/TestDataSeeder.cs b/Lite.Tests/TestDataSeeder.cs
index cca1af50a7..00db2a4241 100644
--- a/Lite.Tests/TestDataSeeder.cs
+++ b/Lite.Tests/TestDataSeeder.cs
@@ -1518,7 +1518,9 @@ INSERT INTO query_stats
///
/// Seeds perfmon_stats with the collected rate counters (batch requests, compilations,
- /// recompilations); all use delta_cntr_value.
+ /// recompilations). Parameters are PER-SECOND rates; the rows carry the per-interval delta
+ /// (rate x the 60s interval) plus sample_interval_seconds = 60, so the fact collector's
+ /// delta / interval division (#3527) reproduces the parameter exactly.
///
internal async Task SeedPerfmonAsync(long batchReqSec = 500,
long compilationsSec = 50, long recompilationsSec = 5)
@@ -1529,9 +1531,9 @@ internal async Task SeedPerfmonAsync(long batchReqSec = 500,
var counters = new (string name, long cntrValue, long deltaValue)[]
{
- ("Batch Requests/sec", batchReqSec * 60, batchReqSec), // cntr = cumulative, delta = rate
- ("SQL Compilations/sec", compilationsSec * 60, compilationsSec),
- ("SQL Re-Compilations/sec", recompilationsSec * 60, recompilationsSec)
+ ("Batch Requests/sec", batchReqSec * 120, batchReqSec * 60), // cntr = cumulative, delta = rate x 60s interval
+ ("SQL Compilations/sec", compilationsSec * 120, compilationsSec * 60),
+ ("SQL Re-Compilations/sec", recompilationsSec * 120, recompilationsSec * 60)
};
foreach (var (name, cntr, delta) in counters)
@@ -1556,6 +1558,36 @@ INSERT INTO perfmon_stats
}
}
+ ///
+ /// Seeds one raw perfmon_stats row with explicit delta and interval — for pinning the #3527
+ /// delta / sample_interval_seconds division and the interval-0 (unknowable delta) skip.
+ ///
+ internal async Task SeedPerfmonRawAsync(string counterName, long deltaValue, int sampleIntervalSeconds,
+ DateTime? collectionTime = null)
+ {
+ using var readLock = _duckDb.AcquireReadLock();
+ var connection = await SeedConnectionAsync();
+
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+INSERT INTO perfmon_stats
+ (collection_id, collection_time, server_id, server_name,
+ object_name, counter_name, cntr_value, delta_cntr_value, sample_interval_seconds)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)";
+
+ cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- });
+ cmd.Parameters.Add(new DuckDBParameter { Value = collectionTime ?? TestPeriodEnd });
+ cmd.Parameters.Add(new DuckDBParameter { Value = TestServerId });
+ cmd.Parameters.Add(new DuckDBParameter { Value = TestServerName });
+ cmd.Parameters.Add(new DuckDBParameter { Value = "SQLServer:SQL Statistics" });
+ cmd.Parameters.Add(new DuckDBParameter { Value = counterName });
+ cmd.Parameters.Add(new DuckDBParameter { Value = deltaValue * 2 });
+ cmd.Parameters.Add(new DuckDBParameter { Value = deltaValue });
+ cmd.Parameters.Add(new DuckDBParameter { Value = sampleIntervalSeconds });
+
+ await cmd.ExecuteNonQueryAsync();
+ }
+
///
/// Seeds memory_clerks with clerk type → MB mappings.
///
diff --git a/Lite/Analysis/AnomalyDetector.cs b/Lite/Analysis/AnomalyDetector.cs
index 30e0ce0a10..c06096799d 100644
--- a/Lite/Analysis/AnomalyDetector.cs
+++ b/Lite/Analysis/AnomalyDetector.cs
@@ -716,14 +716,20 @@ private async Task DetectBatchRequestAnomalies(AnalysisContext context, List= $2 AND collection_time <= $3
AND counter_name = 'Batch Requests/sec'
-AND delta_cntr_value >= 0";
+AND delta_cntr_value >= 0
+AND sample_interval_seconds > 0";
cmd.Parameters.Add(new DuckDBParameter { Value = context.ServerId });
cmd.Parameters.Add(new DuckDBParameter { Value = context.TimeRangeStart });
diff --git a/Lite/Analysis/BaselineProvider.cs b/Lite/Analysis/BaselineProvider.cs
index a517765c8c..c558e9836d 100644
--- a/Lite/Analysis/BaselineProvider.cs
+++ b/Lite/Analysis/BaselineProvider.cs
@@ -294,13 +294,19 @@ FROM v_cpu_utilization_stats
// Cumulative counter — restart exclusion via subquery with QUALIFY.
// Excludes samples where delta drops to 0 when prior sample was > 1000
// (restart signature for cumulative counters).
+ // #3527: v is the per-second rate — the per-interval delta divided by the row's measured
+ // sample_interval_seconds — so the baseline population is in the same requests/sec unit as
+ // the detector's window statistic. Interval <= 0 rows (unknowable delta) are skipped, never
+ // read as 0. The restart signature stays on the RAW delta: its > 1000 bar predates the
+ // division and marks a counter reset regardless of cadence.
MetricNames.BatchRequests => @"
WITH clean AS (
- SELECT collection_time, delta_cntr_value AS v
+ SELECT collection_time, delta_cntr_value * 1.0 / NULLIF(sample_interval_seconds, 0) AS v
FROM v_perfmon_stats
WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3
AND counter_name = 'Batch Requests/sec'
AND delta_cntr_value >= 0
+ AND sample_interval_seconds > 0
QUALIFY NOT (delta_cntr_value = 0
AND COALESCE(LAG(delta_cntr_value) OVER (ORDER BY collection_time), 0) > 1000)
)," + RobustTierScaffold,
diff --git a/Lite/Analysis/DuckDbFactCollector.Resources.cs b/Lite/Analysis/DuckDbFactCollector.Resources.cs
index fbdf2975e4..32d272e438 100644
--- a/Lite/Analysis/DuckDbFactCollector.Resources.cs
+++ b/Lite/Analysis/DuckDbFactCollector.Resources.cs
@@ -339,6 +339,8 @@ FROM v_cpu_utilization_stats
///
/// Collects key perfmon throughput counters: Batch Requests/sec, compilations, recompilations.
/// Unscored context that distinguishes a busy server from a sick one (used by the AI surfaces).
+ /// Fact values are per-second rates: the per-interval delta divided by the row's measured
+ /// sample_interval_seconds (#3527); the raw delta and the divisor ride the metadata.
///
private async Task CollectPerfmonFactsAsync(AnalysisContext context, List facts)
{
@@ -349,17 +351,23 @@ private async Task CollectPerfmonFactsAsync(AnalysisContext context, List
await connection.OpenAsync(context.CancellationToken);
using var cmd = connection.CreateCommand();
+ /* #3527: delta_cntr_value spans one COLLECTION INTERVAL, not one second — at a 60s cadence the
+ raw delta is 60x the true rate. The honest rate divides by the row's MEASURED
+ sample_interval_seconds (#2234); interval <= 0 marks an unknowable delta (first sighting,
+ counter reset, gap past the policy), so those rows are filtered rather than emitted as 0 —
+ rn = 1 lands on the newest row a rate can honestly be derived from. */
cmd.CommandText = @"
WITH latest AS (
- SELECT counter_name, cntr_value, delta_cntr_value,
+ SELECT counter_name, cntr_value, delta_cntr_value, sample_interval_seconds,
ROW_NUMBER() OVER (PARTITION BY counter_name ORDER BY collection_time DESC) AS rn
FROM v_perfmon_stats
WHERE server_id = $1
AND collection_time >= $2
AND collection_time <= $3
AND counter_name IN ('Batch Requests/sec', 'SQL Compilations/sec', 'SQL Re-Compilations/sec')
+ AND sample_interval_seconds > 0
)
-SELECT counter_name, cntr_value, delta_cntr_value
+SELECT counter_name, cntr_value, delta_cntr_value, sample_interval_seconds
FROM latest WHERE rn = 1";
cmd.Parameters.Add(new DuckDBParameter { Value = context.ServerId });
@@ -372,6 +380,7 @@ AND counter_name IN ('Batch Requests/sec', 'SQL Compilations/sec', 'SQL Re-Com
var counterName = reader.GetString(0);
var cntrValue = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1));
var deltaValue = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2));
+ var intervalSeconds = reader.IsDBNull(3) ? 0L : ToInt64(reader.GetValue(3));
var (factKey, source) = counterName switch
{
@@ -383,8 +392,11 @@ AND counter_name IN ('Batch Requests/sec', 'SQL Compilations/sec', 'SQL Re-Com
if (factKey == null) continue;
- // All remaining counters are per-second rates — use the delta.
- var value = (double)deltaValue;
+ /* The delta spans one collection interval — divide by the measured interval for the
+ per-second rate (#3527). The SQL already filters interval <= 0 (unknowable delta);
+ this guard keeps a raw or zero value from ever escaping if that filter regresses. */
+ if (intervalSeconds <= 0) continue;
+ var value = deltaValue / (double)intervalSeconds;
facts.Add(new Fact
{
@@ -395,7 +407,8 @@ AND counter_name IN ('Batch Requests/sec', 'SQL Compilations/sec', 'SQL Re-Com
Metadata = new Dictionary
{
["cntr_value"] = cntrValue,
- ["delta_cntr_value"] = deltaValue
+ ["delta_cntr_value"] = deltaValue,
+ ["sample_interval_seconds"] = intervalSeconds
}
});
}