fix(historian): respect gateway ceilings and history value contracts
This commit is contained in:
@@ -128,6 +128,35 @@ public sealed class GatewayAtTimeTests
|
||||
return r.Samples;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_future_time_on_a_live_tag_finds_the_prior_sample_without_walking_the_empty_future()
|
||||
{
|
||||
var store = new AvevaStore(Enumerable.Range(0, 7201).Select(i => (Now.AddSeconds(i - 7200), (double)i, false)));
|
||||
var fake = store.Client();
|
||||
var result = (await AtTime(fake, Now.AddHours(1))).Single();
|
||||
result.Value.ShouldBe(7200d);
|
||||
result.StatusCode.ShouldBe(UncertainInterpolated);
|
||||
fake.ReadRawCallCount.ShouldBeLessThanOrEqualTo(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_future_time_still_uses_a_stored_future_prior_sample()
|
||||
{
|
||||
var fake = new AvevaStore([(Now.AddSeconds(-1), 1d, false), (Now.AddMinutes(30), 2d, false)]).Client();
|
||||
var result = (await AtTime(fake, Now.AddHours(1))).Single();
|
||||
result.Value.ShouldBe(2d);
|
||||
result.StatusCode.ShouldBe(UncertainInterpolated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_full_future_probe_walks_to_the_latest_stored_future_sample()
|
||||
{
|
||||
var fake = new AvevaStore(Enumerable.Range(0, 7201).Select(i => (Now.AddSeconds(i), (double)i, false))).Client();
|
||||
var result = (await AtTime(fake, Now.AddHours(3))).Single();
|
||||
result.Value.ShouldBe(7200d);
|
||||
result.StatusCode.ShouldBe(UncertainInterpolated);
|
||||
}
|
||||
|
||||
// ---- the four live findings ------------------------------------------------------------------------------------
|
||||
|
||||
[Fact] // live: 19:00:03 (a stored NULL) ⇒ 107 Good
|
||||
|
||||
+48
@@ -420,6 +420,54 @@ public sealed class GatewayCeilingPagingTests : IDisposable
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1709, 1366)] // #695 reverse-walk trace
|
||||
[InlineData(1369, 1292)] // #695 bounds trace
|
||||
public async Task A_warm_read_uses_the_last_room_below_the_ceiling_for_pre_window_ties(int ceiling, int cluster)
|
||||
{
|
||||
var start = At(1000);
|
||||
var stored = Enumerable.Range(0, cluster).Select(i => (start.AddMilliseconds(-0.5), (double)i))
|
||||
.Append((start, 9000d)).Append((start.AddSeconds(1), 9001d)).ToList();
|
||||
var fake = Gateway(stored, ceiling);
|
||||
using var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||
await ds.ReadRawAsync("T", At(0), At(1), 5000, Ct); // learn without crowding
|
||||
fake.RawCalls.Clear();
|
||||
var result = await ds.ReadRawAsync("T", start, start.AddSeconds(2), 1, Ct);
|
||||
result.Samples.Select(s => s.Value).ShouldBe([9000d]);
|
||||
fake.RawCalls.ShouldAllBe(c => c.MaxValues <= ceiling);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_warm_reverse_segment_can_shrink_again_when_the_pre_window_cluster_grows()
|
||||
{
|
||||
var start = At(1000);
|
||||
var stored = Enumerable.Range(0, 1366).Select(i => (start.AddMilliseconds(-0.5), (double)i))
|
||||
.Concat(Enumerable.Range(0, 500).Select(i => (start.AddMilliseconds(i), 9000d + i))).ToList();
|
||||
var fake = Gateway(stored, 1709);
|
||||
using var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||
await ds.ReadRawAsync("T", At(0), At(1), 5000, Ct);
|
||||
var page = await ReverseRawReader.ReadAsync(ds, "T", start.AddMilliseconds(499), start.AddTicks(-1),
|
||||
249, 65536, At(2000), new ReverseWalkHint(499 * TimeSpan.TicksPerMillisecond), Ct);
|
||||
page.Samples.Select(s => (double)s.Value!).ShouldBe(Enumerable.Range(9251, 249).Reverse().Select(i => (double)i));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Gateway_transport_faults_surface_as_BadNoCommunication(bool deadline)
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient { ThrowOnRead = deadline
|
||||
? new ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayException("deadline", new Grpc.Core.RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.DeadlineExceeded, "deadline")))
|
||||
: new ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayUnavailableException("unavailable") };
|
||||
var (host, nm, nodeId) = await BootAsync(fake);
|
||||
try
|
||||
{
|
||||
var r = ReadRaw(nm, nodeId, At(0), At(10), 10, null);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId NodeId)> BootAsync(
|
||||
|
||||
+27
@@ -11,6 +11,33 @@ public sealed class QuestDbHistorianProviderTests
|
||||
ConnectionString = "Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading",
|
||||
});
|
||||
|
||||
[Theory]
|
||||
[InlineData("Raw")]
|
||||
[InlineData("AtTime")]
|
||||
[InlineData("Processed")]
|
||||
[InlineData("Events")]
|
||||
[InlineData("Modified")]
|
||||
public async Task A_connection_failure_is_exposed_as_a_transport_failure(string kind)
|
||||
{
|
||||
using var provider = new QuestDbHistorianProvider(new QuestDbHistorianOptions {
|
||||
ConnectionString = "Host=127.0.0.1;Port=1;Username=admin;Password=test;Database=qdb;Timeout=1;Pooling=False;ServerCompatibilityMode=NoTypeLoading", AutoCreateSchema = false
|
||||
});
|
||||
using var source = provider.CreateDataSource(new NullSp());
|
||||
var t = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
Func<Task> read = kind switch
|
||||
{
|
||||
"Raw" => async () => await source.ReadRawAsync("T", t, t.AddHours(1), 10, TestContext.Current.CancellationToken),
|
||||
"AtTime" => async () => await source.ReadAtTimeAsync("T", [t], TestContext.Current.CancellationToken),
|
||||
"Processed" => async () => await source.ReadProcessedAsync("T", t, t.AddHours(1), TimeSpan.FromSeconds(10), HistoryAggregateType.Average, TestContext.Current.CancellationToken),
|
||||
"Events" => async () => await source.ReadEventsAsync("T", t, t.AddHours(1), 10, TestContext.Current.CancellationToken),
|
||||
_ => async () => await source.ReadModifiedAsync("T", t, t.AddHours(1), 10, null, TestContext.Current.CancellationToken)
|
||||
};
|
||||
var error = await Should.ThrowAsync<System.IO.IOException>(read);
|
||||
error.InnerException.ShouldNotBeNull();
|
||||
// A cached bootstrap outage must remain retryable on subsequent reads as well.
|
||||
(await Should.ThrowAsync<System.IO.IOException>(read)).InnerException.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_and_all_six_kinds()
|
||||
{
|
||||
|
||||
+27
@@ -11,6 +11,33 @@ public sealed class SqlServerHistorianProviderTests
|
||||
ConnectionString = "Server=localhost;User Id=sa;Password=x;TrustServerCertificate=True",
|
||||
});
|
||||
|
||||
[Theory]
|
||||
[InlineData("Raw")]
|
||||
[InlineData("AtTime")]
|
||||
[InlineData("Processed")]
|
||||
[InlineData("Events")]
|
||||
[InlineData("Modified")]
|
||||
public async Task A_connection_failure_is_exposed_as_a_transport_failure(string kind)
|
||||
{
|
||||
var provider = new SqlServerHistorianProvider(new SqlServerHistorianOptions {
|
||||
ConnectionString = "Server=127.0.0.1,1;User Id=sa;Password=test;TrustServerCertificate=True;Connect Timeout=1;ConnectRetryCount=0;Pooling=False", AutoCreateSchema = false
|
||||
});
|
||||
using var source = provider.CreateDataSource(new NullSp());
|
||||
var t = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
Func<Task> read = kind switch
|
||||
{
|
||||
"Raw" => async () => await source.ReadRawAsync("T", t, t.AddHours(1), 10, TestContext.Current.CancellationToken),
|
||||
"AtTime" => async () => await source.ReadAtTimeAsync("T", [t], TestContext.Current.CancellationToken),
|
||||
"Processed" => async () => await source.ReadProcessedAsync("T", t, t.AddHours(1), TimeSpan.FromSeconds(10), HistoryAggregateType.Average, TestContext.Current.CancellationToken),
|
||||
"Events" => async () => await source.ReadEventsAsync("T", t, t.AddHours(1), 10, TestContext.Current.CancellationToken),
|
||||
_ => async () => await source.ReadModifiedAsync("T", t, t.AddHours(1), 10, null, TestContext.Current.CancellationToken)
|
||||
};
|
||||
var error = await Should.ThrowAsync<System.IO.IOException>(read);
|
||||
error.InnerException.ShouldNotBeNull();
|
||||
// A cached bootstrap outage must remain retryable on subsequent reads as well.
|
||||
(await Should.ThrowAsync<System.IO.IOException>(read)).InnerException.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_and_all_six_kinds()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user