fix(historian): #694 R2 cap the crowding re-read room at MaxTieClusterOverfetch

The #687/F4 crowding re-read doubled its room up to 8 re-reads with no cap, so a pathological single-ms
cluster on a streaming gateway was streamed several times over for one page. The room is now capped at
ServerHistorian:MaxTieClusterOverfetch (plumbed into GatewayHistorianDataSource as maxPreWindowRows,
default 65536); past it, or once a read has already seen more, the read is BadTooManyOperations with
PreWindowRows set.
This commit is contained in:
Joseph Doherty
2026-10-13 01:53:14 -04:00
parent 3c3513ea3e
commit de8bc7a12c
5 changed files with 80 additions and 7 deletions
@@ -255,6 +255,40 @@ public sealed class GatewayRawBoundaryRowTests
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
}
[Fact]
public async Task The_crowding_re_reads_never_make_room_for_more_than_the_tie_cluster_limit()
{
// #694 R2: the room used to double up to 8 re-reads with no cap — on a streaming gateway a pathological 1M-row
// single-millisecond cluster streamed ~4M rows for one page where it used to fail at once. The room is capped at
// MaxTieClusterOverfetch (here 100); past it the read is a request limit, after a bounded number of bounded reads.
var margin = Enumerable.Range(0, 150).Select(i => (At(30).AddTicks(-1 - i), (double)i, false));
var fake = Aveva(margin.Append((At(35), 1d, false)).ToList());
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, maxPreWindowRows: 100);
var ex = await Should.ThrowAsync<HistoryReadLimitExceededException>(() => ds.ReadRawAsync("T", At(30), At(40), 5, Ct));
ex.PreWindowRows.ShouldBeGreaterThan(100);
// Room 21, 42, 84: that read already saw more than 100 rows before the window, so it stops — never asking past the cap.
fake.ReadRawCallCount.ShouldBe(4);
fake.RawCalls.Max(c => c.MaxValues).ShouldBeLessThanOrEqualTo(5 + 100 + RawAggregateReader.PreWindowAllowance);
fake.RawYieldedCount.ShouldBeLessThanOrEqualTo(21 + 42 + 63 + 105);
fake.RawStreamsCompleted.ShouldBe(4);
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
}
[Fact]
public async Task Crowding_rows_just_under_the_tie_cluster_limit_are_read_around()
{
var margin = Enumerable.Range(0, 90).Select(i => (At(30).AddTicks(-1 - i), (double)i, false));
var fake = Aveva(margin.Append((At(35), 1d, false)).ToList());
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, maxPreWindowRows: 100);
var r = await ds.ReadRawAsync("T", At(30), At(40), 5, Ct);
r.Samples.Select(s => s.Value).ShouldBe([1d]);
fake.RawCalls.Max(c => c.MaxValues).ShouldBeLessThanOrEqualTo(5 + 100 + RawAggregateReader.PreWindowAllowance);
}
[Fact]
public async Task A_short_read_is_complete_and_is_not_re_read()
{
@@ -131,4 +131,17 @@ public sealed class HistorianGatewayClientAdapterTests
var dataSource = Assert.IsType<GatewayHistorianDataSource>(GatewayHistorian.CreateDataSource(opts, services));
dataSource.AtTimeFromRaw.ShouldBe(expected);
}
[Theory] // #694 R2: ServerHistorian:MaxTieClusterOverfetch bounds the crowding re-reads (default 65536)
[InlineData(null, 65536)]
[InlineData(1000, 1000)]
public void Factory_passes_the_tie_cluster_limit_to_the_crowding_re_reads(int? configured, int expected)
{
var opts = configured is { } c
? new ServerHistorianOptions { Enabled = true, Endpoint = "https://localhost:5222", ApiKey = "histgw_x_y", MaxTieClusterOverfetch = c }
: new ServerHistorianOptions { Enabled = true, Endpoint = "https://localhost:5222", ApiKey = "histgw_x_y" };
using var services = new ServiceCollection().BuildServiceProvider();
var dataSource = Assert.IsType<GatewayHistorianDataSource>(GatewayHistorian.CreateDataSource(opts, services));
dataSource.MaxPreWindowRows.ShouldBe(expected);
}
}