fix(historian): #688 clamp the Gateway per-sample row budget at the gateway's default ceiling

The default MaxTimeWeightedRawRows (1,000,000) + 18 exceeded the gateway's
default MaxValuesCeiling (1,000,000), so with AllowStreamingBeyondCeiling=false
every Gateway per-sample aggregate read was refused. The allowance is now clamped
at 1,000,000 while the cap is at or under it (RawAggregateReader.
ClampToGatewayCeiling); a cap above it keeps its allowance. Raw reads are not
clamped (rebased onto #686).
This commit is contained in:
Joseph Doherty
2026-10-12 14:37:35 -04:00
parent 9e47532283
commit 6796ba4126
4 changed files with 69 additions and 12 deletions
@@ -369,7 +369,7 @@ public sealed class GatewayRawAggregateTests
await Source(count).ReadProcessedAsync("A", At(0), At(80), TimeSpan.FromSeconds(20), HistoryAggregateType.Count, Ct);
// The window read ends AT the window end; probe + look-ahead are separate 2-row reads, all consumed to completion.
count.RawCalls.ShouldBe([
(At(0) - TimeSpan.FromMilliseconds(1), At(80), 1_000_000 + 2 + 16),
(At(0) - TimeSpan.FromMilliseconds(1), At(80), 1_000_000), // cap + 18, clamped at the gateway default ceiling (#688)
(DateTime.UnixEpoch, At(0).AddTicks(-1), 2),
(At(80), Now + TimeSpan.FromDays(1), 2),
]);
@@ -473,6 +473,36 @@ public sealed class GatewayRawAggregateTests
ds2.GetHealthSnapshot().TotalFailures.ShouldBe(1);
}
[Fact] // #688: the default cap's budget fits the gateway's default ceiling, so a strict gateway serves the default config
public async Task The_default_caps_row_budget_does_not_exceed_the_gateways_default_ceiling()
{
var fake = Store([D(-5, 1), D(5, 2)], avevaBoundary: true);
await Source(fake).ReadProcessedAsync("T", At(0), At(10), TimeSpan.Zero, HistoryAggregateType.Average, Ct);
TimeWeightedAggregator.DefaultMaxWindowRows.ShouldBe(1_000_000);
fake.RawCalls[0].MaxValues.ShouldBe(RawAggregateReader.GatewayDefaultMaxValuesCeiling);
}
[Theory] // #688: the + 18 allowance is clamped at the gateway's default ceiling, never below the cap itself
[InlineData(10, 28)]
[InlineData(999_982, 1_000_000)]
[InlineData(999_990, 1_000_000)]
[InlineData(1_000_000, 1_000_000)]
[InlineData(2_000_000, 2_000_018)] // a cap above the default ceiling is a deliberate choice for a streaming gateway
[InlineData(0, int.MaxValue)]
[InlineData(-1, int.MaxValue)]
public void The_window_row_budget_is_clamped_at_the_gateways_default_ceiling(int cap, int expected) =>
RawAggregateReader.MaxValuesFor(cap).ShouldBe(expected);
[Fact] // #688: near the ceiling the window is still served when it is not full
public async Task A_cap_at_the_ceiling_still_serves_a_window_below_it()
{
var data = Enumerable.Range(0, 10).Select(i => D(i, i)).ToArray();
var fake = Store(data, avevaBoundary: true);
var r = await Source(fake, maxRawRows: 1_000_000)
.ReadProcessedAsync("T", At(0), At(10), TimeSpan.Zero, HistoryAggregateType.Average, Ct);
r.Samples.Single().Value.ShouldBe(4.5);
}
[Fact]
public async Task A_caller_cancellation_is_not_counted_as_a_backend_failure()
{