fix(historian): #691.4 re-review — never narrow a walk segment below the 1 ms request granularity

A tie cluster's truncated read left the width at one tick; once the gap
above it was under two milliseconds nothing floored it, so segHi
stepped down one tick per read: 30 rows at one ms 0.3 s below hi with a
carried 1 s width took 10,006 identical reads (S8). (a) The top-anchored
width is floored at the granularity, Min kept; (b) a segment no wider
than the granularity is the unit that escalates to
MaxTieClusterOverfetch and then refuses - per millisecond, exact for the
ms-resolution Gateway (documented). Both are needed: the floor alone
loops on crowded reads with a small tie budget. S8 is a repo test
(4 shapes, reads <= 30, 0 repeated requests); random-data tests now
measure the refusal unit as rows in any 1 ms window. Reviewer harness,
5,000 seeds x default/MSONLY/CROWD: worst page 70 reads (default/MSONLY),
63 (CROWD) at M=65536, p99.9 27-37; no page over 500 reads at any tie
budget.
This commit is contained in:
Joseph Doherty
2026-10-12 17:50:39 -04:00
parent 574c3aa1b0
commit 9bb1363cd6
8 changed files with 109 additions and 20 deletions
@@ -731,9 +731,38 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
var max = (uint)rng.Next(1, 12);
var end = new DateTime(t + rng.Next(0, 3) * TimeSpan.TicksPerSecond, DateTimeKind.Utc);
var collected = await DrainAsync(nm, nodeId, DateTime.MinValue, end, max, maxPages: 2000);
// The walk refuses more than MaxTieClusterOverfetch samples within ONE MILLISECOND (#691.4 re-review), so with
// the small budget a dense sub-millisecond burst is a legitimate BadTooManyOperations — and nothing else is.
var msMax = 0;
for (int i = 0, j = 0; i < series.Count; i++)
{
while (series[i].SourceTimestampUtc!.Value.Ticks - series[j].SourceTimestampUtc!.Value.Ticks >= TimeSpan.TicksPerMillisecond) j++;
msMax = Math.Max(msMax, i - j + 1);
}
collected.ShouldBe(Enumerable.Range(0, series.Count).Reverse().Select(i => (double)i), $"seed {seed}, N = {max}");
var collected = new List<double>();
byte[]? cp = null;
var pages = 0;
var refused = false;
do
{
var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, end, max, inboundCp: cp);
if (error.StatusCode.Code == StatusCodes.BadTooManyOperations && msMax > nm.MaxTieClusterOverfetch)
{
refused = true;
break;
}
error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"seed {seed}, N = {max}, ms max {msMax}");
collected.AddRange(values);
cp = next;
(++pages).ShouldBeLessThan(2000, "paging must terminate, not loop");
}
while (cp is not null);
var expected = Enumerable.Range(0, series.Count).Reverse().Select(i => (double)i).ToList();
if (refused) collected.ShouldBe(expected.Take(collected.Count), $"seed {seed}: what came before the refusal");
else collected.ShouldBe(expected, $"seed {seed}, N = {max}");
}
await host.DisposeAsync();