diff --git a/Darling/Darling.Tests/PgStatementStatsRowCoherentResetTests.cs b/Darling/Darling.Tests/PgStatementStatsRowCoherentResetTests.cs new file mode 100644 index 0000000000..f7d9fb2a5a --- /dev/null +++ b/Darling/Darling.Tests/PgStatementStatsRowCoherentResetTests.cs @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4428: the same row-coherent restart decision QueryStatsRowCoherentResetTests pins for +/// query_stats, applied to — with one difference the +/// PostgreSQL side forces: the series-age signal (stats_since, pg_stat_statements 1.11+) is a +/// TIMESTAMP on the monitored server's own clock, not an age relative to the collector host's clock, so +/// placement compares it ONLY against the previous pass's target_now — captured in the SAME read +/// — never against the collector's own . +/// +public sealed class PgStatementStatsRowCoherentResetTests +{ + private const int ServerId = 1; + private static DateTime T0 => new(2026, 9, 1, 12, 0, 0, DateTimeKind.Unspecified); + + /// One row, ordinals matching PgStatementStatsCollector's BuildQuery, 0-29. + private static object[] Row( + long queryId, long dbid, long userid, bool toplevel, long calls, double totalExecTimeMs, + long rowsReturned, DateTime? statsReset, DateTime? statsSince, DateTime targetNow) + => new object[] + { + queryId, dbid, userid, toplevel, calls, totalExecTimeMs, + 0.0, 0.0, 0.0, rowsReturned, + 0L, 0L, 0L, 0L, 0L, 0L, 0.0, 0.0, + (object?)null!, (object?)null!, (object?)null!, (object?)null!, + 0L, 0L, 0L, + (object?)null!, (object?)null!, + (object?)statsReset ?? DBNull.Value, + (object?)statsSince ?? DBNull.Value, + targetNow, + }; + + private static async Task> RunReadAsync( + ICollectorDeltaCalculator deltas, DateTime collectionTime, object[] row) + { + var reader = new FakeReader(row); + var context = new CollectorContext + { + ServerId = ServerId, + ServerName = "target-a", + CollectionTime = collectionTime, + Deltas = deltas, + }; + + return await PgStatementStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + } + + /// + /// #4431's shape, adapted: calls re-grown past the old value, total time DROPPED (the counter that a + /// per-family delta alone would call a reset), stats_since inside the gap on the TARGET clock. The + /// whole row credits current values over the real interval. + /// + [Fact] + public async Task Reset_StatsSinceInsideGap_CreditsCurrentValues() + { + var deltas = new CollectorDeltaCalculator(); + var t0Target = T0; + var t1Target = T0.AddSeconds(60); + + await RunReadAsync(deltas, T0, + Row(1, 1, 1, true, calls: 1, totalExecTimeMs: 57_695_259, rowsReturned: 1, + statsReset: null, statsSince: null, targetNow: t0Target)); + + /* Restart between t0Target and t1Target: total time FELL (57,695,259 -> 703,943), while calls and + rows RE-GREW past their own old values (1 -> 16) — the #4431 field shape. A per-family delta + alone would read this as "total time reset, calls +15"; row-coherent placement instead credits + every counter as its current value over the real interval. stats_since sits inside the gap on + the TARGET clock. */ + var restartedAt = t0Target.AddSeconds(30); + var rows = await RunReadAsync(deltas, T0.AddSeconds(60), + Row(1, 1, 1, true, calls: 16, totalExecTimeMs: 703_943, rowsReturned: 16, + statsReset: null, statsSince: restartedAt, targetNow: t1Target)); + + var restarted = Assert.Single(rows); + Assert.Equal(16, restarted.DeltaCalls); + Assert.Equal(703_943, restarted.DeltaTotalExecTimeMs); + Assert.Equal(16, restarted.DeltaRows); + Assert.Equal(60, restarted.SampleIntervalSeconds); + } + + /// + /// stats_since BEFORE the previous pass's target_now: the restart happened before we last looked, so + /// it is not placeable inside the gap. A decreasing row reports (0, 0). + /// + [Fact] + public async Task Reset_StatsSinceBeforePreviousTargetNow_ReportsZeroZero() + { + var deltas = new CollectorDeltaCalculator(); + var t0Target = T0; + var t1Target = T0.AddSeconds(60); + + await RunReadAsync(deltas, T0, + Row(1, 1, 1, true, calls: 100, totalExecTimeMs: 5000, rowsReturned: 100, + statsReset: null, statsSince: null, targetNow: t0Target)); + + /* stats_since is BEFORE t0Target — the entry existed before our previous look, so a decrease here + cannot be a "credit the current values" restart. */ + var beforePreviousLook = t0Target.AddSeconds(-10); + var rows = await RunReadAsync(deltas, T0.AddSeconds(60), + Row(1, 1, 1, true, calls: 5, totalExecTimeMs: 900, rowsReturned: 5, + statsReset: null, statsSince: beforePreviousLook, targetNow: t1Target)); + + var reset = Assert.Single(rows); + Assert.Equal(0, reset.DeltaCalls); + Assert.Equal(0, reset.DeltaTotalExecTimeMs); + Assert.Equal(0, reset.DeltaRows); + Assert.Equal(0, reset.SampleIntervalSeconds); + } + + /// stats_since absent (PostgreSQL 16, or Aurora without it): strict (0, 0) for a decreasing row. + [Fact] + public async Task Reset_StatsSinceAbsent_ReportsZeroZero() + { + var deltas = new CollectorDeltaCalculator(); + + await RunReadAsync(deltas, T0, + Row(1, 1, 1, true, calls: 100, totalExecTimeMs: 5000, rowsReturned: 100, + statsReset: null, statsSince: null, targetNow: T0)); + + var rows = await RunReadAsync(deltas, T0.AddSeconds(60), + Row(1, 1, 1, true, calls: 5, totalExecTimeMs: 900, rowsReturned: 5, + statsReset: null, statsSince: null, targetNow: T0.AddSeconds(60))); + + var reset = Assert.Single(rows); + Assert.Equal(0, reset.DeltaCalls); + Assert.Equal(0, reset.DeltaTotalExecTimeMs); + Assert.Equal(0, reset.DeltaRows); + Assert.Equal(0, reset.SampleIntervalSeconds); + } + + /// + /// Clock skew: the target's clock runs several minutes AHEAD of the collector host's clock. An entry + /// created BEFORE the previous pass on the TARGET's clock must not be credited, even though its + /// stats_since reads "after" the previous pass's context.CollectionTime on the collector's clock — + /// proving placement never compares against the collector's own clock. + /// + [Fact] + public async Task Reset_TargetClockSkew_NeverComparedAgainstCollectorClock() + { + var deltas = new CollectorDeltaCalculator(); + + /* Target clock runs 5 minutes ahead of the collector host: collector sees T0, target's now() is + T0 + 5 minutes. */ + var skew = TimeSpan.FromMinutes(5); + var t0Target = T0 + skew; + var t1Target = T0.AddSeconds(60) + skew; + + await RunReadAsync(deltas, T0, + Row(1, 1, 1, true, calls: 100, totalExecTimeMs: 5000, rowsReturned: 100, + statsReset: null, statsSince: null, targetNow: t0Target)); + + /* The restart's stats_since is T0 + 90s on the COLLECTOR's clock — later than the collector's own + T0 (context.CollectionTime for pass one) — but on the TARGET's clock it is BEFORE t0Target + (T0 + 300s), i.e. before the previous pass. A calculator that compared stats_since against the + collector's clock would wrongly credit this; comparing against target_now correctly refuses it. */ + var statsSinceOnCollectorClock = T0.AddSeconds(90); + var rows = await RunReadAsync(deltas, T0.AddSeconds(60), + Row(1, 1, 1, true, calls: 5, totalExecTimeMs: 900, rowsReturned: 5, + statsReset: null, statsSince: statsSinceOnCollectorClock, targetNow: t1Target)); + + var reset = Assert.Single(rows); + Assert.Equal(0, reset.DeltaCalls); + Assert.Equal(0, reset.DeltaTotalExecTimeMs); + Assert.Equal(0, reset.DeltaRows); + Assert.Equal(0, reset.SampleIntervalSeconds); + } + + /// No reset — every counter only ever increases — takes the ordinary per-family path unchanged. + [Fact] + public async Task NoReset_OrdinaryIncrease_Unchanged() + { + var deltas = new CollectorDeltaCalculator(); + + await RunReadAsync(deltas, T0, + Row(1, 1, 1, true, calls: 100, totalExecTimeMs: 5000, rowsReturned: 100, + statsReset: null, statsSince: null, targetNow: T0)); + + var rows = await RunReadAsync(deltas, T0.AddSeconds(60), + Row(1, 1, 1, true, calls: 120, totalExecTimeMs: 5200, rowsReturned: 110, + statsReset: null, statsSince: null, targetNow: T0.AddSeconds(60))); + + var ordinary = Assert.Single(rows); + Assert.Equal(20, ordinary.DeltaCalls); + Assert.Equal(200, ordinary.DeltaTotalExecTimeMs); + Assert.Equal(10, ordinary.DeltaRows); + Assert.Equal(60, ordinary.SampleIntervalSeconds); + } + + /// A minimal multi-call over one boxed row, ordinal access only. + private sealed class FakeReader : DbDataReader + { + private readonly object[] _values; + private int _row = -1; + + public FakeReader(object[] values) => _values = values; + + private object Raw(int ordinal) => _values[ordinal]; + + public override bool IsDBNull(int ordinal) => Raw(ordinal) is DBNull; + public override DateTime GetDateTime(int ordinal) => (DateTime)Raw(ordinal); + public override long GetInt64(int ordinal) => Convert.ToInt64(Raw(ordinal)); + public override int GetInt32(int ordinal) => Convert.ToInt32(Raw(ordinal)); + public override bool GetBoolean(int ordinal) => (bool)Raw(ordinal); + public override double GetDouble(int ordinal) => Convert.ToDouble(Raw(ordinal)); + public override string GetString(int ordinal) => (string)Raw(ordinal); + public override T GetFieldValue(int ordinal) => (T)Raw(ordinal); + public override object GetValue(int ordinal) => Raw(ordinal); + public override int FieldCount => _values.Length; + public override bool Read() => ++_row == 0; + public override Task ReadAsync(CancellationToken cancellationToken) => Task.FromResult(Read()); + public override bool HasRows => true; + + public override int Depth => 0; + public override bool IsClosed => false; + public override int RecordsAffected => 0; + public override object this[int ordinal] => Raw(ordinal); + public override object this[string name] => throw new NotSupportedException("ordinal access only"); + public override int GetOrdinal(string name) => throw new NotSupportedException("ordinal access only"); + public override string GetName(int ordinal) => throw new NotSupportedException("ordinal access only"); + public override bool NextResult() => false; + public override IEnumerator GetEnumerator() => throw new NotSupportedException(); + public override int GetValues(object[] values) => throw new NotSupportedException(); + public override string GetDataTypeName(int ordinal) => throw new NotSupportedException(); + public override Type GetFieldType(int ordinal) => Raw(ordinal).GetType(); + public override byte GetByte(int ordinal) => throw new NotSupportedException(); + public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length) => throw new NotSupportedException(); + public override char GetChar(int ordinal) => throw new NotSupportedException(); + public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length) => throw new NotSupportedException(); + public override decimal GetDecimal(int ordinal) => throw new NotSupportedException(); + public override float GetFloat(int ordinal) => throw new NotSupportedException(); + public override Guid GetGuid(int ordinal) => throw new NotSupportedException(); + public override short GetInt16(int ordinal) => throw new NotSupportedException(); + } +} diff --git a/Lite.Tests/PgStatementStatsDeltaSkipTests.cs b/Lite.Tests/PgStatementStatsDeltaSkipTests.cs index c20c094d4c..7d66f019e8 100644 --- a/Lite.Tests/PgStatementStatsDeltaSkipTests.cs +++ b/Lite.Tests/PgStatementStatsDeltaSkipTests.cs @@ -56,8 +56,11 @@ public class PgStatementStatsDeltaSkipTests /// on the Aurora-only six, and — since #3653 A5 — a NULL statements_stats_reset at ordinal 27, /// which the epoch check reads as "unknown" and so never as a change; these tests are about the skip, /// not the epoch, and a NULL keeps the epoch inert. ServerEpochTests drives the epoch itself.) + /// Since #4428, ordinal 28 is stats_since (NULL by default here — the row-coherent restart + /// placement is tested separately in Darling.Tests) and ordinal 29 is target_now, the + /// target's own clock, defaulted to the collection time a scenario is about to pass to ReadAsync. /// - private static object[] Row(long queryId, long calls, double totalExecTimeMs, long rowsReturned = 0, long databaseId = 1, long userId = 1) => new object[] + private static object[] Row(long queryId, long calls, double totalExecTimeMs, long rowsReturned = 0, long databaseId = 1, long userId = 1, DateTime? statsSince = null, DateTime? targetNow = null) => new object[] { queryId, databaseId, userId, true, calls, totalExecTimeMs, 0d, 0d, 0d, rowsReturned, @@ -67,6 +70,8 @@ public class PgStatementStatsDeltaSkipTests 0L, 0L, 0L, DBNull.Value, DBNull.Value, DBNull.Value, + statsSince.HasValue ? (object)statsSince.Value : DBNull.Value, + targetNow ?? T0, }; private static async Task> ReadAsync( diff --git a/Lite.Tests/PgStatementStatsFlavorTests.cs b/Lite.Tests/PgStatementStatsFlavorTests.cs index 9a1a51442c..90d125f712 100644 --- a/Lite.Tests/PgStatementStatsFlavorTests.cs +++ b/Lite.Tests/PgStatementStatsFlavorTests.cs @@ -30,8 +30,9 @@ namespace Lite.Tests; /// /// /// -/// The ordinals are the load-bearing detail. Both queries select the same 28 columns in the same order (the -/// 28th, appended by #3653 A5, is the statements epoch stats_reset, read and not stored) — the +/// The ordinals are the load-bearing detail. Both queries select the same 30 columns in the same order (the +/// 28th, appended by #3653 A5, is the statements epoch stats_reset, read and not stored; the 29th and +/// 30th, appended by #4428, are stats_since and target_now, also read and not stored) — the /// vanilla one fills Aurora's six with typed NULL literals — so ReadAsync, PayloadColumns and /// WritePayload stay single implementations. A shorter vanilla SELECT would have meant a second reader /// whose ordinals could drift from this one, which is exactly the failure the per-major column naming in this @@ -110,10 +111,13 @@ public void BothFlavorsSelectTheSameColumnsInTheSameOrder() Assert.Equal(aurora, vanilla); /* The SELECT list is the payload minus the four columns computed on the client (the three deltas - and, since V128 (#3540), the interval they accrued over) PLUS the one column read and not stored: - the statements epoch stats_reset (#3653 A5), last, so every stored ordinal is where it was. */ - Assert.Equal(PgStatementStatsCollector.Instance.PayloadColumns.Count - 4 + 1, aurora.Count); - Assert.Equal("statements_stats_reset", aurora[^1]); + and, since V128 (#3540), the interval they accrued over) PLUS the three columns read and not + stored: the statements epoch stats_reset (#3653 A5), and — since #4428 — stats_since and + target_now, last, so every stored ordinal is where it was. */ + Assert.Equal(PgStatementStatsCollector.Instance.PayloadColumns.Count - 4 + 3, aurora.Count); + Assert.Equal("statements_stats_reset", aurora[^3]); + Assert.Equal("stats_since", aurora[^2]); + Assert.Equal("target_now", aurora[^1]); } /// diff --git a/Lite.Tests/ServerEpochTests.cs b/Lite.Tests/ServerEpochTests.cs index b2e2c303cd..52ecd59198 100644 --- a/Lite.Tests/ServerEpochTests.cs +++ b/Lite.Tests/ServerEpochTests.cs @@ -835,7 +835,12 @@ public void DrainDiscontinuities_ReturnsInOrder_ThenEmpty() /* ---------------- helpers ---------------- */ - /// A pg_statement_stats reader row in ordinal order, the statements epoch at 27. + /// + /// A pg_statement_stats reader row in ordinal order, the statements epoch at 27. Since #4428, ordinal + /// 28 is stats_since (NULL here — these tests are about the epoch forget, not the row-coherent + /// restart placement, which Darling.Tests covers separately) and ordinal 29 is target_now, + /// defaulted to the same moment as when it is a . + /// private static object[] StatementRow(long queryId, long calls, object statsReset) => new object[] { queryId, 1L, 1L, true, calls, 100d, @@ -846,6 +851,8 @@ public void DrainDiscontinuities_ReturnsInOrder_ThenEmpty() 0L, 0L, 0L, DBNull.Value, DBNull.Value, statsReset, + DBNull.Value, + statsReset is DateTime resetTime ? resetTime : new DateTime(2026, 9, 19, 3, 59, 0, DateTimeKind.Utc), }; /// Reaches the protected restart-seed hook, to stage the host-restart case. diff --git a/PerformanceMonitor.Collectors/CollectorDeltaCalculator.cs b/PerformanceMonitor.Collectors/CollectorDeltaCalculator.cs index 19f3c79e09..4960697fa3 100644 --- a/PerformanceMonitor.Collectors/CollectorDeltaCalculator.cs +++ b/PerformanceMonitor.Collectors/CollectorDeltaCalculator.cs @@ -187,6 +187,17 @@ public static DateTime SeedCutoff() return updated.Previous; } + /// + /// + /// #4428: the same window-rolling machinery + /// (private, above) already keeps for a collector-clock caller, exposed under the interface's own + /// name for a caller that tracks a DIFFERENT clock. shares the private + /// method's dictionary rather than a second one, on the contract stated on the interface member: a + /// caller's group name never collides with any collectorName an ordinary delta call passes, so + /// one dictionary safely serves both without either clock's window disturbing the other's. + public DateTime? PreviousPass(int serverId, string group, DateTime observedTime) + => PreviousPass(serverId, group, (DateTime?)observedTime); + /// /// The discontinuity accounts (#3653 A5) a definition handed to or /// and no host has logged yet: serverId -> the lines, in the order they were diff --git a/PerformanceMonitor.Collectors/ICollectorDeltaCalculator.cs b/PerformanceMonitor.Collectors/ICollectorDeltaCalculator.cs index f1fa338da7..0339162560 100644 --- a/PerformanceMonitor.Collectors/ICollectorDeltaCalculator.cs +++ b/PerformanceMonitor.Collectors/ICollectorDeltaCalculator.cs @@ -115,6 +115,25 @@ RowResetDecision DecideRow(int serverId, IReadOnlyList<(string Family, long Curr int? seriesAgeSeconds, DateTime? collectionTime, int maxGapSeconds) => default; + /// + /// #4428: rolls a caller-named pass window forward when is new for + /// (, ), and returns the PREVIOUS value of that + /// window — the same bookkeeping already keeps on the collector's own clock + /// (collectionTime), exposed generically so a definition that must place a restart on a + /// DIFFERENT clock — a source's own now(), captured in the same read, rather than the collector + /// host's — can track that clock's own previous pass without a second cache of its own. + /// + /// is a caller-chosen namespace. It shares nothing with any + /// collectorName a delta family uses elsewhere — the point of the method is that a target-clock + /// window and a collector-clock window never mix, so a caller MUST pick a name no ordinary delta call + /// also passes as its collectorName. + /// + /// Default-implemented to return null, like every other member here, so an implementer that + /// tracks no such window — every test double in this repo until it opts in — keeps compiling. + /// + DateTime? PreviousPass(int serverId, string group, DateTime observedTime) + => null; + /// /// Forgets every baseline and every pass window cached for , because the /// counters behind that id are no longer the counters the baselines were read from (#3653 A5, the diff --git a/PerformanceMonitor.Collectors/PgStatementStatsCollector.cs b/PerformanceMonitor.Collectors/PgStatementStatsCollector.cs index edf70ca292..7496c5edfe 100644 --- a/PerformanceMonitor.Collectors/PgStatementStatsCollector.cs +++ b/PerformanceMonitor.Collectors/PgStatementStatsCollector.cs @@ -182,6 +182,25 @@ reads it unguarded. */ with the measurement behind its shape (#3818). */ var statsReset = StatementsEpochSql; + /* #4428: stats_since arrived in pg_stat_statements 1.11, bundled with PostgreSQL 17 — the moment + THIS entry was (re-)created in the extension's hashtable, the same series-age signal #2235 + already uses for query_stats' compile_age, reported as a timestamp rather than an age. It is a + COLUMN of the base view, exactly like toplevel above, so it takes the SAME proxy toplevel + already takes and for the same accepted reason (#3818's remarks on toplevel): the server major + is a stand-in for the extension's catalog version, which is usually right and loudly wrong + (42703, unclassified) on the cluster it misses — a 17+ engine whose extension predates the + in-place major upgrade. aurora_stat_statements() is documented to carry every pg_stat_statements + column, stats_since included, wherever the function itself exists, so the Aurora flavor reads + it unguarded the same way it already reads toplevel unguarded. */ + var statsSince = postgresMajorVersion >= 17 ? "stats_since" : "NULL::timestamp with time zone"; + + /* #4428: the TARGET's own clock, captured in the SAME read as stats_since, once per row (cheap — + PostgreSQL evaluates now() once per statement, not per row, so this is one clock read per pass + either way). The restart placement below compares stats_since ONLY against this column's value + from the PREVIOUS pass, never against the collector host's own clock — the two clocks can be + minutes apart (#4428's skew pin), and the collector's clock is not even the same MACHINE. */ + var targetNow = "now()"; + if (!isAurora) { return $@" @@ -213,7 +232,9 @@ with the measurement behind its shape (#3818). */ wal_bytes::bigint AS wal_bytes, NULL::bigint AS total_exec_peakmem, NULL::bigint AS max_exec_peakmem, - {statsReset} AS statements_stats_reset + {statsReset} AS statements_stats_reset, + {statsSince} AS stats_since, + {targetNow} AS target_now FROM public.pg_stat_statements WHERE calls > 0"; } @@ -247,7 +268,9 @@ FROM public.pg_stat_statements wal_bytes::bigint AS wal_bytes, total_exec_peakmem::bigint AS total_exec_peakmem, max_exec_peakmem::bigint AS max_exec_peakmem, - {statsReset} AS statements_stats_reset + {statsReset} AS statements_stats_reset, + {statsSince} AS stats_since, + {targetNow} AS target_now FROM aurora_stat_statements(false) WHERE calls > 0"; } @@ -478,6 +501,42 @@ it the same way it covers a restart. */ var key = string.Create(CultureInfo.InvariantCulture, $"{queryId}|{databaseId}|{userId}|{(topLevel ? 1 : 0)}"); + /* #4428: stats_since (ordinal 28, NULL below PostgreSQL 17 or when the column does not exist + on this cluster's extension catalog) and target_now (ordinal 29, always present — now() never + returns NULL) are read TOGETHER, in this same row, before any delta call below can touch a + baseline. Both travel on the TARGET's own clock; neither is ever compared against anything + measured on the collector host's clock — see the class remarks and #4428's skew pin. */ + var statsSince = reader.IsDBNull(28) ? (DateTime?)null : reader.GetDateTime(28); + var targetNow = reader.GetDateTime(29); + + /* #4428: peeked BEFORE any of the three per-family calls below mutate a baseline — the same + ordering DecideRow's own doc comment requires and QueryStatsCollector already follows for + query_stats. seriesAgeSeconds is passed null deliberately: DecideRow's OWN gap-placement + branch (reached only when seriesAgeSeconds.HasValue) measures the gap on collectionTime, + the COLLECTOR's clock, which this definition must never use for placement. Passing null + skips that branch entirely and leaves this call a pure AnyReset peek; placement is decided + below, on the target's own clock, using stats_since and the target-clock pass window. */ + var rowReset = context.Deltas.DecideRow( + context.ServerId, + new (string Family, long Current)[] + { + ("pg_statement_stats_calls", calls), + ("pg_statement_stats_time", (long)totalExecTimeMs), + ("pg_statement_stats_rows", rowsReturned), + }, + key, + seriesAgeSeconds: null, + collectionTime: context.CollectionTime, + maxGapSeconds: CollectorDeltaCalculator.DefaultMaxGapSeconds); + + /* #4428: the TARGET-clock pass window, tracked under a group name no ordinary delta call ever + passes as its own collectorName (see ICollectorDeltaCalculator.PreviousPass's contract), so + it never collides with the collector-clock windows the three CalculateDeltaWithInterval + calls below keep for themselves. Rolled every pass — target_now moves forward on every + genuine pass, so this always advances — and returns the PREVIOUS pass's target_now, the + only clock a restart may be placed against. */ + var previousTargetNow = context.Deltas.PreviousPass(context.ServerId, "pg_statement_stats_target_clock", targetNow); + var deltaCalls = context.Deltas.CalculateDeltaWithInterval( context.ServerId, "pg_statement_stats_calls", key, calls, out var callsIntervalSeconds, collectionTime: context.CollectionTime, maxGapSeconds: CollectorDeltaCalculator.DefaultMaxGapSeconds); @@ -496,11 +555,55 @@ false counter-reset on a real accrual. See the class remarks. */ var deltaRows = context.Deltas.CalculateDeltaWithInterval( context.ServerId, "pg_statement_stats_rows", key, rowsReturned, out var rowsIntervalSeconds, collectionTime: context.CollectionTime, maxGapSeconds: CollectorDeltaCalculator.DefaultMaxGapSeconds); + + /* #4428: the row-coherent decision, applied AFTER every per-family call above has already run + and stored its own new baseline — exactly QueryStatsCollector's ordering, so the row is + ready for an ordinary delta on the NEXT pass regardless of which branch this row takes now. + Any family decreasing makes the WHOLE row a restart (rowReset.AnyReset, decided above on the + unmutated baseline). Placement: stats_since strictly AFTER the previous pass's target-clock + now() means the restart happened inside the gap, so every counter's delta becomes its + CURRENT value over the real target-clock gap; anything else — stats_since absent (rule 3), + previousTargetNow unknown (first pass), or stats_since at or before the previous target_now + — makes the whole row unknowable, (0, 0), same as an ordinary single-family reset already + reports. NEVER compared against context.CollectionTime — the collector host's own clock — + which is the whole point of carrying target_now beside stats_since in the same read. */ + if (rowReset.AnyReset) + { + var gapSeconds = previousTargetNow.HasValue + ? (int)(targetNow - previousTargetNow.Value).TotalSeconds + : 0; + + var creditedInGap = statsSince.HasValue && previousTargetNow.HasValue + && statsSince.Value > previousTargetNow.Value + && gapSeconds > 0; + + if (creditedInGap) + { + deltaCalls = calls; + deltaTotalTime = (long)totalExecTimeMs; + deltaRows = rowsReturned; + callsIntervalSeconds = gapSeconds; + timeIntervalSeconds = gapSeconds; + rowsIntervalSeconds = gapSeconds; + } + else + { + deltaCalls = 0; + deltaTotalTime = 0; + deltaRows = 0; + callsIntervalSeconds = 0; + timeIntervalSeconds = 0; + rowsIntervalSeconds = 0; + } + } + /* #3540 (V128): the stored interval is the MINIMUM over the row's three groups — the V127 rule (WaitStatsCollector). The groups share a key and a collection time, so they agree in every case but an independent single-counter reset, and pg_stat_statements resets an entry's counters together; the minimum makes the stored pair mean "every delta in this row is - knowable", so a reader never divides one group's reset 0 by a sibling's real span. */ + knowable", so a reader never divides one group's reset 0 by a sibling's real span. After the + #4428 override above the three intervals already agree, so this is a no-op for a restarted + row and unchanged behaviour for every other one. */ var sampleIntervalSeconds = Math.Min(callsIntervalSeconds, Math.Min(timeIntervalSeconds, rowsIntervalSeconds)); /* The skip: a REAL interval (this is not a first sighting, a counter reset, or a gap this