fix(historian): #687 re-review — a row at/before the Unix epoch is never a stored sample

With StartTime at or before the epoch, RequestStartFor has no margin left, so
AVEVA's synthetic no-data/boundary row is stamped exactly at StartTime and was
kept: raw reads returned a fabricated Bad sample at 1970-01-01 and the per-sample
aggregates put it in bucket 0 (Start/End fabricated, Min/Max status Uncertain).
IsWindowSample and the aggregate loop now drop any row with t <= ProbeFloor
(RawAggregateReader.IsAfterFloor), matching the #686 walk-back's assumption.
This commit is contained in:
Joseph Doherty
2026-10-12 15:47:39 -04:00
parent 8114f13a28
commit a3ee917fb4
4 changed files with 43 additions and 2 deletions
@@ -473,6 +473,23 @@ public sealed class GatewayRawAggregateTests
ds2.GetHealthSnapshot().TotalFailures.ShouldBe(1);
}
[Theory] // a window starting AT the epoch gets no 1 ms margin, so AVEVA's no-data row is stamped at the window start:
// it must not become a stored Bad sample of bucket 0
[InlineData(HistoryAggregateType.Start)]
[InlineData(HistoryAggregateType.Count)]
[InlineData(HistoryAggregateType.Minimum)]
public async Task A_window_starting_at_the_epoch_does_not_evaluate_the_fabricated_row(HistoryAggregateType agg)
{
var epoch = DateTime.UnixEpoch;
var data = new[] { new StoredSample(epoch.AddSeconds(5), HistorianValue.FromDouble(7), Good) };
var fake = Store(data, avevaBoundary: true);
var s = (await Source(fake).ReadProcessedAsync("T", epoch, epoch.AddSeconds(20), TimeSpan.FromSeconds(20), agg, Ct))
.Samples.Single();
var expected = SqlBackendAnswer(agg, BucketPlan.Create(epoch, epoch.AddSeconds(20), TimeSpan.FromSeconds(20)), data).Single();
(s.Value, s.StatusCode).ShouldBe((expected.Value, expected.StatusCode));
fake.RawCalls[0].Start.ShouldBe(epoch); // the request start cannot go before the epoch
}
[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()
{
@@ -292,4 +292,16 @@ public sealed class GatewayRawBoundaryRowTests
RawAggregateReader.RequestStartFor(start).ShouldBe(DateTime.UnixEpoch);
RawAggregateReader.RequestStartFor(At(0)).ShouldBe(At(0) - RawAggregateReader.BoundaryMargin);
}
[Theory] // with no margin left before the epoch, AVEVA's no-data row lands AT StartTime: a row at/before the epoch is
// never a stored sample (AVEVA stores nothing there — the #686 walk-back's floor too)
[InlineData(0L)]
[InlineData(-10_000_000L)]
public async Task A_read_starting_at_or_before_the_epoch_does_not_return_the_fabricated_row(long startTicksFromEpoch)
{
var epoch = DateTime.UnixEpoch;
var fake = Aveva([(epoch.AddSeconds(10), 7d, false)]);
var r = await Source(fake).ReadRawAsync("T", epoch.AddTicks(startTicksFromEpoch), epoch.AddSeconds(60), 5, Ct);
r.Samples.Select(s => s.SourceTimestampUtc).ShouldBe(new DateTime?[] { epoch.AddSeconds(10) });
}
}