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:
+20
-2
@@ -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();
|
||||
|
||||
+31
-2
@@ -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();
|
||||
|
||||
@@ -32,7 +32,14 @@ public sealed class ReverseWalkTests
|
||||
for (var seed = 0; seed < 400; seed++)
|
||||
{
|
||||
var rng = new Random(seed);
|
||||
var series = RandomSeries(rng, out var maxCluster);
|
||||
var series = RandomSeries(rng, out _);
|
||||
// The refusal unit is one millisecond (#691.4 re-review): the most rows inside any 1 ms window.
|
||||
var maxCluster = 0;
|
||||
for (int i = 0, j = 0; i < series.Count; i++)
|
||||
{
|
||||
while (Ts(series[i]) - Ts(series[j]) >= TimeSpan.TicksPerMillisecond) j++;
|
||||
maxCluster = Math.Max(maxCluster, i - j + 1);
|
||||
}
|
||||
var source = new AvevaShapedSource(series, roundUp: rng.Next(2) == 0);
|
||||
var tieBudget = rng.Next(4) switch { 0 => 1, 1 => 3, 2 => 8, _ => 65536 };
|
||||
var want = (uint)rng.Next(1, 40);
|
||||
@@ -81,7 +88,7 @@ public sealed class ReverseWalkTests
|
||||
}
|
||||
catch (HistoryReadLimitExceededException)
|
||||
{
|
||||
if (maxCluster <= tieBudget) failures.Add($"seed {seed}: refused with no cluster over {tieBudget} (max {maxCluster})");
|
||||
if (maxCluster <= tieBudget) failures.Add($"seed {seed}: refused with no millisecond over {tieBudget} rows (max {maxCluster})");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -206,6 +213,33 @@ public sealed class ReverseWalkTests
|
||||
source.Reads.ShouldBeLessThanOrEqualTo(22, string.Join("\n", source.Log));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #691.4 re-review (S8): a tie cluster under a carried width. 30 rows at one whole-millisecond timestamp 0.3 s below
|
||||
/// <c>hi</c>, nothing above, N = 1, a carried 1 s width. The truncated read of the cluster 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 —
|
||||
/// 10,006 reads of the same millisecond (28 before the gallop).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(30, 1, 0.3, 1.0)]
|
||||
[InlineData(34, 1, 0.52, 0.48)]
|
||||
[InlineData(25, 1, 1.2, 1.0)]
|
||||
[InlineData(40, 3, 3.5, 2.0)]
|
||||
public async Task A_tie_cluster_under_a_carried_width_is_read_in_a_bounded_number_of_reads(
|
||||
int ties, int want, double secondsBelow, double hintSeconds)
|
||||
{
|
||||
var at = T0.Ticks - (long)(secondsBelow * TimeSpan.TicksPerSecond);
|
||||
at -= at % TimeSpan.TicksPerMillisecond;
|
||||
var series = Enumerable.Range(0, ties).Select(i => At(at, i)).ToList();
|
||||
var source = new AvevaShapedSource(series);
|
||||
|
||||
var page = await ReverseRawReader.ReadAsync(source, "T", T0, DateTime.MinValue, (uint)want, 65536, T0,
|
||||
new ReverseWalkHint((long)(hintSeconds * TimeSpan.TicksPerSecond)), Ct);
|
||||
|
||||
page.Samples.Select(s => (double)s.Value!).ShouldBe(Enumerable.Range(ties - want, want).Reverse().Select(i => (double)i));
|
||||
source.Reads.ShouldBeLessThanOrEqualTo(30, string.Join("\n", source.Log.Take(40)));
|
||||
source.RepeatedRequests.ShouldBe(0);
|
||||
}
|
||||
|
||||
private static long Ts(DataValueSnapshot s) => s.SourceTimestampUtc!.Value.Ticks;
|
||||
|
||||
/// <summary>Random ascending series: bursts, gaps, sub-millisecond stamps and tie clusters.</summary>
|
||||
|
||||
Reference in New Issue
Block a user