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
@@ -446,20 +446,38 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
var expected = events.OrderByDescending(e => e.EventTimeUtc).ThenByDescending(e => e.EventId, StringComparer.Ordinal)
.Select(e => e.EventId).ToList();
// The walk refuses more than MaxTieClusterOverfetch events within ONE MILLISECOND (#691.4 re-review): with the
// small budget a dense sub-millisecond burst is a legitimate BadTooManyOperations — and nothing else is.
var times = events.Select(e => e.EventTimeUtc.Ticks).Order().ToList();
var msMax = 0;
for (int i = 0, j = 0; i < times.Count; i++)
{
while (times[i] - times[j] >= TimeSpan.TicksPerMillisecond) j++;
msMax = Math.Max(msMax, i - j + 1);
}
var all = new List<string>();
byte[]? cp = null;
var pages = 0;
var refused = false;
do
{
var r = ReadEvents(nm, node, max, cp, end, DateTime.MinValue);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"seed {seed}");
if (r.Error.StatusCode.Code == StatusCodes.BadTooManyOperations && msMax > nm.MaxTieClusterOverfetch)
{
refused = true;
break;
}
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"seed {seed}, ms max {msMax}");
all.AddRange(r.Ids);
cp = r.Cp;
(++pages).ShouldBeLessThan(2000);
}
while (cp is not null);
all.ShouldBe(expected, $"seed {seed}, N = {max}");
if (refused) all.ShouldBe(expected.Take(all.Count), $"seed {seed}: what came before the refusal");
else all.ShouldBe(expected, $"seed {seed}, N = {max}");
}
await host.DisposeAsync();