Merge master into feat/677-aggregates
# Conflicts: # docs/Historian.md
This commit is contained in:
@@ -108,6 +108,9 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
/// </summary>
|
||||
public Dictionary<string, IReadOnlyList<BrowseResult>> BrowseResultsByParent { get; } = new();
|
||||
|
||||
/// <summary>Gets or sets an exception thrown by HistoryReadRawAsync (simulates a refused read).</summary>
|
||||
public Exception? HistoryReadException { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the history read result returned by HistoryReadRawAsync and HistoryReadAggregateAsync.</summary>
|
||||
public IReadOnlyList<DataValue> HistoryReadResult { get; set; } = new List<DataValue>
|
||||
{
|
||||
@@ -323,6 +326,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
|
||||
{
|
||||
HistoryReadRawCalls.Add((nodeId, startTime, endTime, maxValues));
|
||||
if (HistoryReadException != null) throw HistoryReadException;
|
||||
return Task.FromResult(HistoryReadResult);
|
||||
}
|
||||
|
||||
|
||||
@@ -197,4 +197,53 @@ public class HistoryReadCommandTests
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await Should.ThrowAsync<CliFx.Exceptions.CommandException>(async () => await command.ExecuteAsync(console));
|
||||
}
|
||||
|
||||
/// <summary>#677.5: the new aggregate names parse (and the aliases).</summary>
|
||||
[Theory]
|
||||
[InlineData("TimeAverage", AggregateType.TimeAverage)]
|
||||
[InlineData("timeaverage2", AggregateType.TimeAverage2)]
|
||||
[InlineData("Total", AggregateType.Total)]
|
||||
[InlineData("total2", AggregateType.Total2)]
|
||||
[InlineData("Interpolative", AggregateType.Interpolative)]
|
||||
[InlineData("interp", AggregateType.Interpolative)]
|
||||
public async Task Execute_NewAggregates_Parse(string name, AggregateType expected)
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=HistNode",
|
||||
Aggregate = name,
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
fakeService.HistoryReadAggregateCalls.ShouldHaveSingleItem().Aggregate.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>#674.6: a refused read is a one-line CommandException (non-zero exit), not a stack trace.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_RefusedRead_ThrowsCommandException_WithOneLineMessage()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService
|
||||
{
|
||||
HistoryReadException = new ServiceResultException(StatusCodes.BadHistoryOperationUnsupported,
|
||||
"HistoryRead of ns=2;s=HistNode failed: BadHistoryOperationUnsupported (0x80B40000)."),
|
||||
};
|
||||
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=HistNode",
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CliFx.Exceptions.CommandException>(async () => await command.ExecuteAsync(console));
|
||||
|
||||
ex.Message.ShouldContain("BadHistoryOperationUnsupported");
|
||||
ex.Message.ShouldNotContain("\n");
|
||||
ex.Message.ShouldNotContain(" at ");
|
||||
ex.ExitCode.ShouldNotBe(0);
|
||||
fakeService.DisconnectCalled.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,4 +160,67 @@ public class EndpointSelectorTests
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
EndpointSelector.SelectBest(Array.Empty<EndpointDescription>(), "", MessageSecurityMode.None));
|
||||
}
|
||||
|
||||
/// <summary>#678.5: a server advertising its internal port must not redirect the session off the mapped port.</summary>
|
||||
[Fact]
|
||||
public void SelectBest_RewritesHostAndPort_WhenSchemeMatches()
|
||||
{
|
||||
var endpoints = new[] { Ep(MessageSecurityMode.None, SecurityPolicies.None, "opc.tcp://internal-host:4840/OtOpcUa") };
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost:4841", MessageSecurityMode.None);
|
||||
|
||||
var uri = new Uri(best.EndpointUrl);
|
||||
uri.Host.ShouldBe("localhost");
|
||||
uri.Port.ShouldBe(4841);
|
||||
uri.AbsolutePath.ShouldBe("/OtOpcUa");
|
||||
}
|
||||
|
||||
/// <summary>A different scheme (e.g. https endpoint advertised to an opc.tcp caller) is left untouched.</summary>
|
||||
[Fact]
|
||||
public void SelectBest_LeavesEndpointUntouched_WhenSchemeDiffers()
|
||||
{
|
||||
var endpoints = new[] { Ep(MessageSecurityMode.None, SecurityPolicies.None, "https://internal-host:4843/x") };
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost:4841", MessageSecurityMode.None);
|
||||
|
||||
new Uri(best.EndpointUrl).Host.ShouldBe("internal-host");
|
||||
new Uri(best.EndpointUrl).Port.ShouldBe(4843);
|
||||
}
|
||||
|
||||
/// <summary>Identical host, port and scheme: nothing to rewrite.</summary>
|
||||
[Fact]
|
||||
public void SelectBest_Unchanged_WhenHostPortAndSchemeMatch()
|
||||
{
|
||||
var endpoints = new[] { Ep(MessageSecurityMode.None, SecurityPolicies.None, "opc.tcp://server:4840") };
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://server:4840", MessageSecurityMode.None);
|
||||
|
||||
best.EndpointUrl.ShouldBe("opc.tcp://server:4840");
|
||||
}
|
||||
|
||||
/// <summary>A URL typed without a port keeps the advertised port (opc.tcp has no default; Uri.Port is -1).</summary>
|
||||
[Fact]
|
||||
public void SelectBest_KeepsAdvertisedPort_WhenRequestedUrlHasNoPort()
|
||||
{
|
||||
var endpoints = new[] { Ep(MessageSecurityMode.None, SecurityPolicies.None, "opc.tcp://internal-host:4840/OtOpcUa") };
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost", MessageSecurityMode.None);
|
||||
|
||||
var uri = new Uri(best.EndpointUrl);
|
||||
uri.Host.ShouldBe("localhost");
|
||||
uri.Port.ShouldBe(4840);
|
||||
}
|
||||
|
||||
/// <summary>An IPv6 host is rewritten with its brackets intact.</summary>
|
||||
[Fact]
|
||||
public void SelectBest_RewritesIpv6Host()
|
||||
{
|
||||
var endpoints = new[] { Ep(MessageSecurityMode.None, SecurityPolicies.None, "opc.tcp://internal-host:4840/OtOpcUa") };
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://[::1]:4841", MessageSecurityMode.None);
|
||||
|
||||
var uri = new Uri(best.EndpointUrl);
|
||||
uri.Host.ShouldBe("[::1]");
|
||||
uri.Port.ShouldBe(4841);
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -18,6 +18,11 @@ public class AggregateTypeMapperTests
|
||||
[InlineData(AggregateType.Start)]
|
||||
[InlineData(AggregateType.End)]
|
||||
[InlineData(AggregateType.StandardDeviation)]
|
||||
[InlineData(AggregateType.TimeAverage)]
|
||||
[InlineData(AggregateType.TimeAverage2)]
|
||||
[InlineData(AggregateType.Total)]
|
||||
[InlineData(AggregateType.Total2)]
|
||||
[InlineData(AggregateType.Interpolative)]
|
||||
public void ToNodeId_ReturnsNonNullForAllValues(AggregateType aggregate)
|
||||
{
|
||||
var nodeId = AggregateTypeMapper.ToNodeId(aggregate);
|
||||
@@ -82,4 +87,15 @@ public class AggregateTypeMapperTests
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
AggregateTypeMapper.ToNodeId((AggregateType)99));
|
||||
}
|
||||
|
||||
/// <summary>#677.5: the time-weighted Part 13 aggregates map to their ObjectIds.</summary>
|
||||
[Fact]
|
||||
public void ToNodeId_TimeWeightedAggregates_MapCorrectly()
|
||||
{
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.TimeAverage).ShouldBe(ObjectIds.AggregateFunction_TimeAverage);
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.TimeAverage2).ShouldBe(ObjectIds.AggregateFunction_TimeAverage2);
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Total).ShouldBe(ObjectIds.AggregateFunction_Total);
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Total2).ShouldBe(ObjectIds.AggregateFunction_Total2);
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Interpolative).ShouldBe(ObjectIds.AggregateFunction_Interpolative);
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,6 @@ public class ModelConstructionTests
|
||||
[Fact]
|
||||
public void AggregateType_Enum_HasExpectedValues()
|
||||
{
|
||||
Enum.GetValues<AggregateType>().Length.ShouldBe(7);
|
||||
Enum.GetValues<AggregateType>().Length.ShouldBe(12);
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,7 @@ public class HistoryViewModelTests
|
||||
public void AggregateTypes_ContainsNullForRaw()
|
||||
{
|
||||
_vm.AggregateTypes.ShouldContain((AggregateType?)null);
|
||||
_vm.AggregateTypes.Count.ShouldBe(8); // null + 7 enum values
|
||||
_vm.AggregateTypes.Count.ShouldBe(13); // null + 12 enum values
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Clear method resets the view model state.</summary>
|
||||
|
||||
+25
-14
@@ -45,9 +45,10 @@ public sealed class IHistorianDataSourceContractTests
|
||||
[InlineData("ReadEventsAsync", typeof(Task<HistoricalEventsResult>))]
|
||||
public void ReadMethods_ReturnExpectedTaskShape(string methodName, Type expectedReturnType)
|
||||
{
|
||||
var method = typeof(IHistorianDataSource).GetMethod(methodName);
|
||||
method.ShouldNotBeNull();
|
||||
method!.ReturnType.ShouldBe(expectedReturnType);
|
||||
// ReadEventsAsync is overloaded (the #675 direction-aware overload) — every overload must keep the shape.
|
||||
var methods = Overloads(methodName);
|
||||
methods.ShouldNotBeEmpty();
|
||||
foreach (var method in methods) method.ReturnType.ShouldBe(expectedReturnType);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetHealthSnapshot is synchronous.</summary>
|
||||
@@ -146,11 +147,14 @@ public sealed class IHistorianDataSourceContractTests
|
||||
[InlineData("ReadEventsAsync", "maxEvents", typeof(int))]
|
||||
public void HistoryRead_MaxParameter_TypePinned(string methodName, string parameterName, Type expectedType)
|
||||
{
|
||||
var method = typeof(IHistorianDataSource).GetMethod(methodName);
|
||||
method.ShouldNotBeNull();
|
||||
var parameter = method!.GetParameters().FirstOrDefault(p => p.Name == parameterName);
|
||||
parameter.ShouldNotBeNull($"Method {methodName} should expose a '{parameterName}' parameter.");
|
||||
parameter!.ParameterType.ShouldBe(expectedType);
|
||||
var methods = Overloads(methodName);
|
||||
methods.ShouldNotBeEmpty();
|
||||
foreach (var method in methods)
|
||||
{
|
||||
var parameter = method.GetParameters().FirstOrDefault(p => p.Name == parameterName);
|
||||
parameter.ShouldNotBeNull($"Method {methodName} should expose a '{parameterName}' parameter.");
|
||||
parameter!.ParameterType.ShouldBe(expectedType);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -163,13 +167,17 @@ public sealed class IHistorianDataSourceContractTests
|
||||
[Fact]
|
||||
public void ReadEventsAsync_sentinel_and_continuation_contract_types_pinned()
|
||||
{
|
||||
var method = typeof(IHistorianDataSource).GetMethod("ReadEventsAsync");
|
||||
method.ShouldNotBeNull();
|
||||
var methods = Overloads("ReadEventsAsync");
|
||||
methods.Count.ShouldBe(2, "the five-argument read plus the #675 direction-aware overload");
|
||||
methods.ShouldContain(m => m.GetParameters().Any(p => p.Name == "reverse" && p.ParameterType == typeof(bool)));
|
||||
|
||||
var maxEventsParam = method!.GetParameters().FirstOrDefault(p => p.Name == "maxEvents");
|
||||
maxEventsParam.ShouldNotBeNull("maxEvents parameter must exist for the sentinel contract");
|
||||
maxEventsParam!.ParameterType.ShouldBe(typeof(int),
|
||||
"maxEvents is int (not uint) so callers can pass <=0 as a 'use backend default cap' sentinel");
|
||||
foreach (var method in methods)
|
||||
{
|
||||
var maxEventsParam = method.GetParameters().FirstOrDefault(p => p.Name == "maxEvents");
|
||||
maxEventsParam.ShouldNotBeNull("maxEvents parameter must exist for the sentinel contract");
|
||||
maxEventsParam!.ParameterType.ShouldBe(typeof(int),
|
||||
"maxEvents is int (not uint) so callers can pass <=0 as a 'use backend default cap' sentinel");
|
||||
}
|
||||
|
||||
// HistoricalEventsResult must carry a nullable ContinuationPoint so implementations
|
||||
// can signal 'more results exist' (non-null) vs 'all results returned' (null).
|
||||
@@ -179,4 +187,7 @@ public sealed class IHistorianDataSourceContractTests
|
||||
continuationProp!.PropertyType.ShouldBe(typeof(byte[]),
|
||||
"ContinuationPoint is byte[]? — null means no more pages, non-null means caller must page");
|
||||
}
|
||||
|
||||
private static List<System.Reflection.MethodInfo> Overloads(string name) =>
|
||||
typeof(IHistorianDataSource).GetMethods().Where(m => m.Name == name).ToList();
|
||||
}
|
||||
|
||||
+14
@@ -67,6 +67,20 @@ public sealed class NullHistorianDataSourceTests
|
||||
result.ContinuationPoint.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #675: the direction-aware overload's default — forward is the five-argument read, reverse is refused
|
||||
/// (the server answers <c>BadHistoryOperationUnsupported</c>).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Direction_aware_ReadEventsAsync_forwards_forward_and_refuses_reverse()
|
||||
{
|
||||
IHistorianDataSource source = Source;
|
||||
(await source.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 0, reverse: false, Ct))
|
||||
.Events.ShouldBeEmpty();
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
source.ReadEventsAsync(null, DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 0, reverse: true, Ct));
|
||||
}
|
||||
|
||||
/// <summary>GetHealthSnapshot reports a fully-disabled (zeroed, disconnected, no-node) shape.</summary>
|
||||
[Fact]
|
||||
public void GetHealthSnapshot_is_disabled()
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
||||
|
||||
/// <summary>
|
||||
/// The direction-aware event read (Gitea #675): forward is <c>[lo, hi)</c> ascending by <c>(EventTimeUtc, EventId)</c>;
|
||||
/// reverse is the Part 11 reverse window <c>(lo, hi]</c> DESCENDING by the same key, so a capped reverse read returns the
|
||||
/// LATEST events and flags truncation. Paging a reverse read the way the server does — resume at the last returned
|
||||
/// time inclusive and drop the boundary ties already emitted — must visit every event exactly once, newest-first,
|
||||
/// through a same-timestamp tie cluster.
|
||||
/// </summary>
|
||||
public abstract class EventReadConformance : ConformanceBase
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
|
||||
/// <summary>e0@0, e1@1, then the ties t0..t4@2, then e5@3 and e6@4 (all source "s"), plus o0@2 from another source.</summary>
|
||||
private async Task<IHistorianDataSource> SeedAsync()
|
||||
{
|
||||
await B.SeedEventsAsync(
|
||||
(At(0), "e0", "s"), (At(1), "e1", "s"),
|
||||
(At(2), "t0", "s"), (At(2), "t1", "s"), (At(2), "t2", "s"), (At(2), "t3", "s"), (At(2), "t4", "s"),
|
||||
(At(3), "e5", "s"), (At(4), "e6", "s"),
|
||||
(At(2), "o0", "other"));
|
||||
return B.CreateDataSource();
|
||||
}
|
||||
|
||||
private static readonly string[] NewestFirst = ["e6", "e5", "t4", "t3", "t2", "t1", "t0", "e1", "e0"];
|
||||
|
||||
[Fact]
|
||||
public async Task Reverse_read_is_newest_first_by_time_then_EventId_descending()
|
||||
{
|
||||
using var src = await SeedAsync();
|
||||
var r = await src.ReadEventsAsync("s", At(-1), At(10), 0, reverse: true, Ct);
|
||||
r.Events.Select(e => e.EventId).ShouldBe(NewestFirst);
|
||||
r.ContinuationPoint.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reverse_window_is_end_exclusive_start_inclusive_whichever_order_the_bounds_come_in()
|
||||
{
|
||||
using var src = await SeedAsync();
|
||||
// (At(1), At(3)]: e1 at the lower bound is excluded, e5 at the upper bound included.
|
||||
(await src.ReadEventsAsync("s", At(3), At(1), 0, reverse: true, Ct)).Events.Select(e => e.EventId)
|
||||
.ShouldBe(["e5", "t4", "t3", "t2", "t1", "t0"]);
|
||||
(await src.ReadEventsAsync("s", At(1), At(3), 0, reverse: true, Ct)).Events.Select(e => e.EventId)
|
||||
.ShouldBe(["e5", "t4", "t3", "t2", "t1", "t0"]);
|
||||
// Forward is unchanged: [At(1), At(3)) ascending.
|
||||
(await src.ReadEventsAsync("s", At(1), At(3), 0, reverse: false, Ct)).Events.Select(e => e.EventId)
|
||||
.ShouldBe(["e1", "t0", "t1", "t2", "t3", "t4"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_capped_reverse_read_keeps_the_LATEST_events_and_flags_truncation()
|
||||
{
|
||||
using var src = await SeedAsync();
|
||||
var r = await src.ReadEventsAsync("s", At(10), At(-1), 3, reverse: true, Ct);
|
||||
r.Events.Select(e => e.EventId).ShouldBe(["e6", "e5", "t4"]);
|
||||
r.ContinuationPoint.ShouldNotBeNull();
|
||||
// An exact fit is complete.
|
||||
(await src.ReadEventsAsync("s", At(10), At(-1), 9, reverse: true, Ct)).ContinuationPoint.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Paging_a_reverse_read_through_a_tie_cluster_visits_every_event_once_newest_first()
|
||||
{
|
||||
using var src = await SeedAsync();
|
||||
foreach (var cap in new[] { 1, 2, 3, 4 })
|
||||
{
|
||||
var all = new List<string>();
|
||||
var lo = At(-1);
|
||||
var from = At(10);
|
||||
var skip = 0;
|
||||
for (var page = 0; ; page++)
|
||||
{
|
||||
page.ShouldBeLessThan(40, "paging must terminate");
|
||||
var r = await src.ReadEventsAsync("s", from, lo, cap + skip, reverse: true, Ct);
|
||||
var events = r.Events.ToList();
|
||||
var drop = 0;
|
||||
while (drop < skip && drop < events.Count && events[drop].EventTimeUtc == from) drop++;
|
||||
events = events.Skip(drop).ToList();
|
||||
// The server asks for cap + skip rows (it re-reads the boundary ties it then drops), so a page is never
|
||||
// emptied by the trim while more remains.
|
||||
if (r.ContinuationPoint is not null) events.Count.ShouldBe(cap);
|
||||
|
||||
all.AddRange(events.Select(e => e.EventId));
|
||||
if (r.ContinuationPoint is null) break;
|
||||
var boundary = events[^1].EventTimeUtc;
|
||||
var ties = events.AsEnumerable().Reverse().TakeWhile(e => e.EventTimeUtc == boundary).Count();
|
||||
skip = boundary == from ? skip + ties : ties;
|
||||
from = boundary;
|
||||
}
|
||||
|
||||
all.SequenceEqual(NewestFirst).ShouldBeTrue($"cap {cap}: {string.Join(",", all)}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reverse_read_with_a_null_source_spans_every_source()
|
||||
{
|
||||
using var src = await SeedAsync();
|
||||
var r = await src.ReadEventsAsync(null, At(2.5), At(1.5), 0, reverse: true, Ct);
|
||||
r.Events.Select(e => e.EventId).ShouldBe(["t4", "t3", "t2", "t1", "t0", "o0"]);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,13 @@ public interface IConformanceBackend : IAsyncDisposable
|
||||
/// <param name="samples">The rows to store.</param>
|
||||
Task SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples);
|
||||
|
||||
/// <summary>
|
||||
/// Writes historical events directly in the backend's event table (never through the alarm writer): event id,
|
||||
/// source and time as given (rounded to the storage resolution), severity 500, received time = event time.
|
||||
/// </summary>
|
||||
/// <param name="events">The events to store.</param>
|
||||
Task SeedEventsAsync(params (DateTime T, string Id, string Source)[] events);
|
||||
|
||||
/// <summary>
|
||||
/// Live (non-deleted) rows for a tag, ascending by time, read directly from storage. The status is returned
|
||||
/// VERBATIM as stored — there is no read-side clamp (an Empty row stored non-Bad reads back non-Bad here).
|
||||
|
||||
+15
-6
@@ -71,7 +71,7 @@ public sealed class WireBackendTests
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
await WaitFor(() =>
|
||||
driver.GetDeviceState(DeviceHost) is { FixedTreeCache: not null }, TimeSpan.FromSeconds(5));
|
||||
driver.GetDeviceState(DeviceHost) is { FixedTreeCache: not null }, WaitTimeout);
|
||||
|
||||
var state = driver.GetDeviceState(DeviceHost);
|
||||
state.ShouldNotBeNull();
|
||||
@@ -82,7 +82,7 @@ public sealed class WireBackendTests
|
||||
|
||||
await WaitFor(() =>
|
||||
state.LastFixedSnapshots.ContainsKey($"{DeviceHost}/Axes/X/AbsolutePosition"),
|
||||
TimeSpan.FromSeconds(3));
|
||||
WaitTimeout);
|
||||
state.LastFixedSnapshots[$"{DeviceHost}/Axes/X/AbsolutePosition"].ShouldBe(123456);
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ public sealed class WireBackendTests
|
||||
|
||||
await WaitFor(() =>
|
||||
driver.GetDeviceState(DeviceHost) is { LastProgramInfo: not null },
|
||||
TimeSpan.FromSeconds(5));
|
||||
WaitTimeout);
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
[$"{DeviceHost}/Program/Name",
|
||||
@@ -194,7 +194,7 @@ public sealed class WireBackendTests
|
||||
{
|
||||
var state = driver.GetDeviceState(DeviceHost);
|
||||
return state is not null && state.LastTimers.Count == 4;
|
||||
}, TimeSpan.FromSeconds(5));
|
||||
}, WaitTimeout);
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
[$"{DeviceHost}/Timers/PowerOnSeconds",
|
||||
@@ -265,8 +265,11 @@ public sealed class WireBackendTests
|
||||
{
|
||||
var state = driver.GetDeviceState(DeviceHost);
|
||||
return state?.FixedTreeCache is { Capabilities.SpindleLoad: true, Capabilities.SpindleMotorSpeed: true }
|
||||
&& state.LastSpindleLoads.Count >= 2;
|
||||
}, TimeSpan.FromSeconds(5));
|
||||
&& state.LastSpindleLoads.Count >= 2
|
||||
// Motor speeds are cached after a SECOND wire round-trip (FocasDriver spindle poll), so the loads
|
||||
// arriving first does not mean the speeds have — waiting on loads alone raced under load (#678.3).
|
||||
&& state.LastSpindleMotorSpeeds.Count >= 2;
|
||||
}, WaitTimeout);
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
[$"{DeviceHost}/Spindle/S1/Load",
|
||||
@@ -282,6 +285,10 @@ public sealed class WireBackendTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Generous: the sim fixture answers fast, but a loaded CI host (e.g. a concurrent docker build) does not.</summary>
|
||||
private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>Polls <paramref name="pred"/>; FAILS the test with a clear message on timeout rather than returning silently.</summary>
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
@@ -290,5 +297,7 @@ public sealed class WireBackendTests
|
||||
if (pred()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
if (pred()) return;
|
||||
Assert.Fail($"Condition not met within {timeout.TotalSeconds:0.#}s waiting on the FOCAS wire backend.");
|
||||
}
|
||||
}
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
using System.Net;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #675: the Gateway backend's event-read bounds and direction, offline.
|
||||
/// <list type="bullet">
|
||||
/// <item>A reverse (newest-first) read is refused before anything is sent — the gateway streams ascending only.</item>
|
||||
/// <item>The open upper bound a start-only forward read uses (<c>DateTime.MaxValue</c>, UTC kind) survives the
|
||||
/// PUBLISHED client package's proto conversion — captured on the wire by an in-process gRPC server — and the
|
||||
/// Windows FILETIME encoding the gateway converts it to. The live AVEVA leg (what the historian does with a
|
||||
/// year-9999 bound) stays open: it needs the VPN.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class GatewayEventBoundsTests
|
||||
{
|
||||
private static readonly DateTime UtcMax = new(DateTime.MaxValue.Ticks, DateTimeKind.Utc);
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task A_reverse_events_read_is_refused_and_sends_nothing()
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient();
|
||||
IHistorianDataSource ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
ds.ReadEventsAsync("Pump1", DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 10, reverse: true, Ct));
|
||||
fake.ReadEventsCallCount.ShouldBe(0);
|
||||
|
||||
// Forward through the same overload is the five-argument read, unchanged.
|
||||
await ds.ReadEventsAsync("Pump1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10, reverse: false, Ct);
|
||||
fake.ReadEventsCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UtcMax_round_trips_through_the_proto_Timestamp_and_FILETIME_encodings()
|
||||
{
|
||||
var ts = Timestamp.FromDateTime(UtcMax);
|
||||
ts.ToDateTime().ShouldBe(UtcMax);
|
||||
ts.ToDateTime().Kind.ShouldBe(DateTimeKind.Utc);
|
||||
|
||||
// The gateway hands its bounds to the historian as FILETIME (100 ns since 1601) — in range up to year 30828.
|
||||
var fileTime = ts.ToDateTime().ToFileTimeUtc();
|
||||
DateTime.FromFileTimeUtc(fileTime).ShouldBe(UtcMax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_start_only_bound_of_UtcMax_reaches_the_wire_intact_through_the_published_client()
|
||||
{
|
||||
var capture = new CapturingReadService();
|
||||
await using var server = await StartServerAsync(capture);
|
||||
using var adapter = HistorianGatewayClientAdapter.Create(
|
||||
new ServerHistorianOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Endpoint = $"http://127.0.0.1:{server.Port}",
|
||||
ApiKey = "histgw_test_key",
|
||||
UseTls = false,
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
IHistorianDataSource ds = new GatewayHistorianDataSource(adapter, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||
|
||||
var start = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var r = await ds.ReadEventsAsync("Pump1", start, UtcMax, 5, Ct);
|
||||
|
||||
r.Events.ShouldBeEmpty();
|
||||
capture.Request.ShouldNotBeNull();
|
||||
capture.Request!.StartTime.ToDateTime().ShouldBe(start);
|
||||
capture.Request.EndTime.ToDateTime().ShouldBe(UtcMax);
|
||||
DateTime.FromFileTimeUtc(capture.Request.EndTime.ToDateTime().ToFileTimeUtc()).ShouldBe(UtcMax);
|
||||
}
|
||||
|
||||
private sealed class CapturingReadService : HistorianRead.HistorianReadBase
|
||||
{
|
||||
public ReadEventsRequest? Request { get; private set; }
|
||||
|
||||
public override Task ReadEvents(
|
||||
ReadEventsRequest request, IServerStreamWriter<HistorianEvent> responseStream, ServerCallContext context)
|
||||
{
|
||||
Request = request;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Server(WebApplication app, int port) : IAsyncDisposable
|
||||
{
|
||||
public int Port { get; } = port;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await app.StopAsync();
|
||||
await app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Server> StartServerAsync(CapturingReadService service)
|
||||
{
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.WebHost.ConfigureKestrel(k =>
|
||||
k.Listen(IPAddress.Loopback, 0, o => o.Protocols = HttpProtocols.Http2));
|
||||
builder.Services.AddGrpc();
|
||||
builder.Services.AddSingleton(service);
|
||||
var app = builder.Build();
|
||||
app.MapGrpcService<CapturingReadService>();
|
||||
await app.StartAsync();
|
||||
var port = new Uri(app.Urls.First()).Port;
|
||||
return new Server(app, port);
|
||||
}
|
||||
}
|
||||
+6
@@ -15,12 +15,18 @@
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<!-- ServiceCollection + BuildServiceProvider for the CreateDataSource factory test. -->
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection"/>
|
||||
<!-- In-process gRPC server capturing the published client's ReadEvents request (#675 bounds pin). -->
|
||||
<PackageReference Include="Grpc.AspNetCore"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
+7
@@ -60,3 +60,10 @@ public sealed class QuestDbModifiedValueConformance(QuestDbFixture fx) : Modifie
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class QuestDbEventReadConformance(QuestDbFixture fx) : EventReadConformance
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
+21
@@ -143,6 +143,27 @@ internal sealed class QuestDbConformanceBackend : IConformanceBackend
|
||||
await Wal.WaitAsync(Conn.SampleTable, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task SeedEventsAsync(params (DateTime T, string Id, string Source)[] events)
|
||||
{
|
||||
await using (var c = await Conn.OpenAsync(CancellationToken.None))
|
||||
{
|
||||
foreach (var (t, id, source) in events)
|
||||
{
|
||||
await using var cmd = Conn.CreateCommand(c,
|
||||
$"INSERT INTO {Conn.EventTable} (ts, event_id, source, severity, message, received_utc) VALUES ($1, $2, $3, $4, $5, $6)");
|
||||
QuestDbConnection.BindTimestamp(cmd, t);
|
||||
QuestDbConnection.BindText(cmd, id);
|
||||
QuestDbConnection.BindText(cmd, source);
|
||||
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Integer, Value = 500 });
|
||||
QuestDbConnection.BindText(cmd, null);
|
||||
QuestDbConnection.BindTimestamp(cmd, t);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
await Wal.WaitAsync(Conn.EventTable, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Runs a statement against this table set (test-only escape hatch for hand-made rows), then waits for WAL visibility of the sample table.</summary>
|
||||
/// <param name="sql">The statement; parameters are bound positionally by <paramref name="bind"/>.</param>
|
||||
/// <param name="bind">Binds the parameters.</param>
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674.1 — pins the QuestDB TTL behaviour the historian's retention relies on: a table TTL is judged against
|
||||
/// the wall clock (<c>min(max timestamp, now)</c>), so one far-future row — a 2066 typo that reached the backend
|
||||
/// without going through the node manager's <c>HistoryUpdateMaxFutureSkew</c> bound — cannot age out every older
|
||||
/// partition, including the Part 11 modification trail (the record of who changed history).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Positive control.</b> A partition genuinely older than the TTL (40 days, against a 30-day TTL) is kept until
|
||||
/// the far-future commit and evicted BY it — proving TTL housekeeping ran on exactly the commit that could have
|
||||
/// dropped the in-TTL partitions. Without it the "nothing evicted" assertion would also pass against a table
|
||||
/// whose TTL never ran.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ Observed on QuestDB 10.0.1 (2026-10-11): the in-TTL partitions also survived with
|
||||
/// <c>cairo.ttl.use.wall.clock=false</c> (verified effective via <c>SHOW PARAMETERS</c>), so this test does not
|
||||
/// isolate that flag — it pins the outcome. Keep the flag at its default (<c>true</c>) regardless; the server-side
|
||||
/// future-skew bound is the primary guard.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class TtlWallClockTests(QuestDbFixture fx)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
private static readonly HistoryUpdateContext Who = new("ttl-test", null);
|
||||
|
||||
[Fact]
|
||||
public async Task A_far_future_row_evicts_only_partitions_older_than_the_ttl_by_the_wall_clock()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var o = fx.Options(fx.UniquePrefix(), retentionDays: 30);
|
||||
var c = new QuestDbConnection(o);
|
||||
var wal = new QuestDbWalVisibility(c, o.VisibilityTimeout);
|
||||
var tags = new QuestDbTagCatalog(c, new QuestDbSchemaBootstrapper(c, o), wal);
|
||||
var updater = new QuestDbHistorianDataUpdater(c, tags, wal);
|
||||
|
||||
// Whole seconds (µs-aligned), one row per DAY partition. With the newest row 10 days old, the 40-day partition is
|
||||
// still inside the TTL measured from the table's max timestamp, so this commit evicts nothing.
|
||||
var now = DateTime.UtcNow;
|
||||
var today = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, DateTimeKind.Utc);
|
||||
DateTime[] older = [today.AddDays(-40), today.AddDays(-20), today.AddDays(-10)];
|
||||
(await updater.UpdateDataAsync("ttl.tag", HistoryUpdateMode.Insert, older.Select((t, i) => V(t, i)).ToList(), Who, Ct))
|
||||
.ShouldAllBe(code => code == HistorianStatusCodes.GoodEntryInserted);
|
||||
(await DaysAsync(c, c.SampleTable)).ShouldBe(older.Select(t => t.Date));
|
||||
(await DaysAsync(c, c.SampleModifiedTable)).ShouldBe(older.Select(t => t.Date));
|
||||
|
||||
// The far-future row, then one more ordinary commit (TTL housekeeping runs on commits).
|
||||
(await updater.UpdateDataAsync("ttl.tag", HistoryUpdateMode.Insert,
|
||||
[V(new DateTime(2066, 1, 1, 0, 0, 0, DateTimeKind.Utc), 9)], Who, Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
|
||||
(await updater.UpdateDataAsync("ttl.tag", HistoryUpdateMode.Insert, [V(today, 10)], Who, Ct))
|
||||
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
|
||||
(await wal.WaitAsync(c.SampleTable, Ct)).ShouldBeTrue();
|
||||
(await wal.WaitAsync(c.SampleModifiedTable, Ct)).ShouldBeTrue();
|
||||
|
||||
// Only the genuinely expired 40-day partition is gone; the 20- and 10-day partitions survive in both tables.
|
||||
DateTime[] expected = [today.AddDays(-20).Date, today.AddDays(-10).Date, today.Date, new(2066, 1, 1)];
|
||||
(await DaysAsync(c, c.SampleTable)).ShouldBe(expected);
|
||||
(await DaysAsync(c, c.SampleModifiedTable)).ShouldBe(expected);
|
||||
}
|
||||
|
||||
private static HistorizationValue V(DateTime t, double v) => new(t, HistorianValue.FromDouble(v), HistorianStatusCodes.Good);
|
||||
|
||||
/// <summary>The distinct UTC days holding rows, ascending.</summary>
|
||||
private static async Task<List<DateTime>> DaysAsync(QuestDbConnection c, string table)
|
||||
{
|
||||
await using var conn = await c.OpenAsync(Ct);
|
||||
await using var cmd = c.CreateCommand(conn, $"SELECT ts FROM {table} ORDER BY ts");
|
||||
await using var r = await cmd.ExecuteReaderAsync(Ct);
|
||||
var days = new SortedSet<DateTime>();
|
||||
while (await r.ReadAsync(Ct)) days.Add(QuestDbConnection.ReadUtc(r, 0).Date);
|
||||
return days.ToList();
|
||||
}
|
||||
}
|
||||
+5
@@ -14,6 +14,11 @@ public sealed class StatusCodeOracleTests
|
||||
((uint)Opc.Ua.AggregateBits.Calculated).ShouldBe(HistorianInfoBits.HistorianCalculated);
|
||||
}
|
||||
|
||||
/// <summary>#674: the v1-schema refusal code is the SDK's BadHistoryOperationUnsupported.</summary>
|
||||
[Fact]
|
||||
public void History_operation_unsupported_matches_the_SDK() =>
|
||||
HistorianStatusCodes.BadHistoryOperationUnsupported.ShouldBe(Opc.Ua.StatusCodes.BadHistoryOperationUnsupported);
|
||||
|
||||
[Fact]
|
||||
public void Interpolated_bits_match_the_SDK()
|
||||
{
|
||||
|
||||
+3
-3
@@ -54,7 +54,7 @@ public sealed class ModifiedHistoryAtomicityTests(SqlHistorianFixture fx)
|
||||
public async Task A_replace_whose_trail_insert_fails_changes_nothing()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
var u = new SqlServerHistorianDataUpdater(db.Conn, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
var u = new SqlServerHistorianDataUpdater(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
(await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T1, 1)], Who, CancellationToken.None))
|
||||
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
|
||||
(await CountTrailAsync(db)).ShouldBe(1);
|
||||
@@ -78,7 +78,7 @@ public sealed class ModifiedHistoryAtomicityTests(SqlHistorianFixture fx)
|
||||
public async Task Deletes_whose_trail_insert_fails_delete_nothing()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
var u = new SqlServerHistorianDataUpdater(db.Conn, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
var u = new SqlServerHistorianDataUpdater(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T1, 1), V(T2, 2)], Who, CancellationToken.None);
|
||||
|
||||
await BreakTheTrailAsync(db);
|
||||
@@ -99,7 +99,7 @@ public sealed class ModifiedHistoryAtomicityTests(SqlHistorianFixture fx)
|
||||
public async Task An_over_long_principal_is_truncated_never_refused()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
var u = new SqlServerHistorianDataUpdater(db.Conn, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
var u = new SqlServerHistorianDataUpdater(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
var who = new HistoryUpdateContext(new string('p', 500), new string('k', 500));
|
||||
|
||||
(await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T1, 1)], who, CancellationToken.None))
|
||||
|
||||
+91
@@ -178,6 +178,97 @@ public sealed class RetentionTests(SqlHistorianFixture fx)
|
||||
(await LockIsFreeAsync(s)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancelling_mid_command_while_the_delete_is_blocked_still_releases_the_applock()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (c, b, _, s) = Make();
|
||||
await SeedAsync(b, s);
|
||||
|
||||
// A second session holds an exclusive table lock, so the purger's first DELETE blocks server-side.
|
||||
await using var holder = new SqlConnection(fx.ConnectionString);
|
||||
await holder.OpenAsync();
|
||||
await using var tx = (SqlTransaction)await holder.BeginTransactionAsync();
|
||||
await using (var cmd = holder.CreateCommand())
|
||||
{
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = $"SELECT COUNT(*) FROM [{s}].Sample WITH (TABLOCKX, HOLDLOCK)";
|
||||
await cmd.ExecuteScalarAsync();
|
||||
}
|
||||
short holderSpid;
|
||||
await using (var cmd = holder.CreateCommand())
|
||||
{
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = "SELECT CAST(@@SPID AS smallint)";
|
||||
holderSpid = (short)(await cmd.ExecuteScalarAsync())!;
|
||||
}
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var purger = new SqlServerHistorianRetention(c, b, TimeSpan.FromHours(1), 30, 4000, time: new FixedTime(Now));
|
||||
var purge = purger.PurgeAsync(cts.Token);
|
||||
|
||||
// Cancel only once the server reports a DELETE actually blocked behind the holder — a fixed delay could
|
||||
// cancel during bootstrap/applock acquisition on a slow host and never exercise the mid-command path.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(15);
|
||||
while (await CountAsync(
|
||||
$"SELECT COUNT(*) FROM sys.dm_exec_requests WHERE blocking_session_id = {holderSpid} AND command = 'DELETE'") == 0)
|
||||
{
|
||||
purge.IsCompleted.ShouldBeFalse("the purge finished without ever blocking on the holder's table lock");
|
||||
DateTime.UtcNow.ShouldBeLessThan(deadline, "the purge's DELETE never blocked behind the holder");
|
||||
await Task.Delay(50);
|
||||
}
|
||||
cts.Cancel();
|
||||
|
||||
// SqlClient surfaces the attention as OperationCanceledException or as a SqlException.
|
||||
var ex = await Should.ThrowAsync<Exception>(() => purge);
|
||||
(ex is OperationCanceledException or SqlException).ShouldBeTrue($"unexpected {ex.GetType().Name}: {ex.Message}");
|
||||
|
||||
await tx.RollbackAsync();
|
||||
(await LockIsFreeAsync(s)).ShouldBeTrue();
|
||||
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Sample")).ShouldBe(21);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_release_falls_back_to_clearing_the_pool_so_the_applock_is_freed()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (c, b, _, s) = Make();
|
||||
await SeedAsync(b, s);
|
||||
var purger = new SqlServerHistorianRetention(c, b, TimeSpan.FromHours(1), 30, 4000, time: new FixedTime(Now))
|
||||
{
|
||||
ReleaseSql = "EXEC sp_this_procedure_does_not_exist;",
|
||||
};
|
||||
|
||||
await purger.PurgeAsync(CancellationToken.None);
|
||||
|
||||
(await LockIsFreeAsync(s)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Falsification_control_a_failing_release_with_a_no_op_ClearPool_leaves_the_applock_held()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (c, b, _, s) = Make();
|
||||
await SeedAsync(b, s);
|
||||
var purger = new SqlServerHistorianRetention(c, b, TimeSpan.FromHours(1), 30, 4000, time: new FixedTime(Now))
|
||||
{
|
||||
ReleaseSql = "EXEC sp_this_procedure_does_not_exist;",
|
||||
ClearPool = _ => { },
|
||||
};
|
||||
|
||||
await purger.PurgeAsync(CancellationToken.None);
|
||||
|
||||
try
|
||||
{
|
||||
// The pooled session still owns the Session-scoped lock: proves the fallback test above is not vacuous.
|
||||
(await LockIsFreeAsync(s)).ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqlConnection.ClearAllPools(); // drop the leaked session so the lock does not outlive the test
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_throwing_purge_releases_the_applock()
|
||||
{
|
||||
|
||||
+14
-9
@@ -32,7 +32,7 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None);
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None); // fresh instance => re-runs the guarded path
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.RequiredVersion);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.ModifiedTrailVersion);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM sys.indexes WHERE name='CX_Event_Time' AND object_id=OBJECT_ID('[{s}].Event')")).ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -51,13 +51,14 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
cmd.CommandText = b;
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.RequiredVersion);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.ModifiedTrailVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #668 upgrade path: a schema left at version 1 (only <c>001_initial.sql</c> applied, as every pre-#668 install is)
|
||||
/// is upgraded in place by an auto-creating bootstrap — <c>SampleModified</c> appears, both version rows exist, and
|
||||
/// existing samples are untouched. With <c>AutoCreateSchema=false</c> the same v1 schema is refused.
|
||||
/// existing samples are untouched. With <c>AutoCreateSchema=false</c> the same v1 schema is accepted with the
|
||||
/// modification trail switched off and left at v1 (#674).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_version_1_schema_is_upgraded_to_version_2_in_place()
|
||||
@@ -81,11 +82,15 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
}
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM sys.tables WHERE name = 'SampleModified' AND schema_id = SCHEMA_ID('{s}')")).ShouldBe(0);
|
||||
|
||||
var refused = await Should.ThrowAsync<HistorianSchemaMissingException>(
|
||||
() => Boot(Opts(s, auto: false)).EnsureAsync(CancellationToken.None));
|
||||
refused.Message.ShouldContain("missing version 2 (found 1)");
|
||||
// #674: verify-only accepts v1 — with the modification trail switched off — and does not upgrade it.
|
||||
var verifyOnly = Boot(Opts(s, auto: false));
|
||||
await verifyOnly.EnsureAsync(CancellationToken.None);
|
||||
verifyOnly.SupportsModifiedTrail.ShouldBeFalse();
|
||||
(await ScalarAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(1);
|
||||
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None);
|
||||
var upgrading = Boot(Opts(s));
|
||||
await upgrading.EnsureAsync(CancellationToken.None);
|
||||
upgrading.SupportsModifiedTrail.ShouldBeTrue();
|
||||
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(2);
|
||||
(await ScalarAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(2);
|
||||
@@ -101,7 +106,7 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
var tasks = Enumerable.Range(0, 4).Select(_ => Task.Run(() => Boot(Opts(s)).EnsureAsync(CancellationToken.None))).ToArray();
|
||||
await Task.WhenAll(tasks);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.RequiredVersion);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.ModifiedTrailVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -110,7 +115,7 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
fx.SkipIfUnavailable();
|
||||
var ex = await Should.ThrowAsync<HistorianSchemaMissingException>(
|
||||
() => Boot(Opts(SqlHistorianFixture.UniqueSchema(), auto: false)).EnsureAsync(CancellationToken.None));
|
||||
ex.Message.ShouldContain("missing version 2");
|
||||
ex.Message.ShouldContain("missing version 1 (found 0)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674: a database left at schema version 1 (no Part 11 <c>SampleModified</c> trail) with
|
||||
/// <c>AutoCreateSchema=false</c> keeps historizing. Before #674 the bootstrapper required v2 and refused EVERY call —
|
||||
/// live writes, raw reads, alarm events, retention — until script 002 was applied by hand. Now only the features
|
||||
/// that need the trail are refused (<c>BadHistoryOperationUnsupported</c>, nothing sent to SQL), and the
|
||||
/// bootstrapper logs ONE warning for the whole provider. Built through <see cref="SqlServerHistorianProvider"/>, the
|
||||
/// production wiring, so every seam shares the one bootstrapper the way a node does.
|
||||
/// </summary>
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class SchemaV1PostureTests(SqlHistorianFixture fx)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
private static readonly DateTime Recent = DateTime.UtcNow.AddHours(-1);
|
||||
private static readonly HistoryUpdateContext Who = new("editor", "k1");
|
||||
|
||||
[Fact]
|
||||
public async Task A_v1_schema_keeps_historizing_and_refuses_only_the_modified_trail_features()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
await ApplyV1Async(s);
|
||||
var old = DateTime.UtcNow.AddDays(-40);
|
||||
var loggers = new CapturingLoggerFactory();
|
||||
var provider = new SqlServerHistorianProvider(new SqlServerHistorianOptions
|
||||
{
|
||||
ConnectionString = fx.ConnectionString, Schema = s, AutoCreateSchema = false, RetentionDays = 30,
|
||||
});
|
||||
var sp = new LoggerServices(loggers);
|
||||
|
||||
// Live writes (the continuous-historization drain) and raw reads work.
|
||||
var writer = provider.CreateValueWriter(sp);
|
||||
(await writer.WriteLiveValuesAsync("Plant.Speed",
|
||||
[new HistorizationValue(old, HistorianValue.FromDouble(1), 0), new HistorizationValue(Recent, HistorianValue.FromDouble(2), 0)],
|
||||
Ct)).ShouldBeTrue();
|
||||
var source = provider.CreateDataSource(sp);
|
||||
var raw = await source.ReadRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), 10, Ct);
|
||||
raw.Samples.Select(v => v.Value).ShouldBe([2d]);
|
||||
|
||||
// Alarm events are written.
|
||||
var alarms = provider.CreateAlarmWriter(sp);
|
||||
(await alarms.WriteBatchAsync(
|
||||
[new AlarmHistorianEvent("alarm-1", "Plant/Line/Eq", "HiTemp", "LimitAlarm", AlarmSeverity.High, "Activated", "m", "u", null, Recent)],
|
||||
Ct)).ShouldBe([HistorianWriteOutcome.Ack]);
|
||||
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Event")).ShouldBe(1);
|
||||
|
||||
// Modified reads: the interface's "no trail" signal (the node manager reports BadHistoryOperationUnsupported),
|
||||
// not a backend failure — health stays clean.
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
source.ReadModifiedAsync("Plant.Speed", Recent.AddDays(-1), Recent.AddDays(1), 10, null, Ct));
|
||||
source.GetHealthSnapshot().LastError.ShouldBeNull();
|
||||
|
||||
// HistoryUpdate: every item refused, and NOTHING reaches SQL — a sent MERGE/DELETE would either change the
|
||||
// samples or fail on the missing SampleModified target; an Insert on an unknown tag would create its Tag row.
|
||||
var updater = provider.CreateUpdater(sp)!;
|
||||
var tagsBefore = await CountAsync($"SELECT COUNT(*) FROM [{s}].Tag");
|
||||
(await updater.UpdateDataAsync("Plant.Speed", HistoryUpdateMode.Update,
|
||||
[new HistorizationValue(Recent, HistorianValue.FromDouble(99), 0), new HistorizationValue(Recent.AddSeconds(1), HistorianValue.FromDouble(98), 0)],
|
||||
Who, Ct)).ShouldBe([HistorianStatusCodes.BadHistoryOperationUnsupported, HistorianStatusCodes.BadHistoryOperationUnsupported]);
|
||||
(await updater.UpdateDataAsync("Plant.Brand.New", HistoryUpdateMode.Insert,
|
||||
[new HistorizationValue(Recent, HistorianValue.FromDouble(1), 0)], Who, Ct))
|
||||
.ShouldBe([HistorianStatusCodes.BadHistoryOperationUnsupported]);
|
||||
(await updater.DeleteRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), Who, Ct))
|
||||
.ShouldBe(HistorianStatusCodes.BadHistoryOperationUnsupported);
|
||||
(await updater.DeleteAtTimeAsync("Plant.Speed", [Recent], Who, Ct))
|
||||
.ShouldBe([HistorianStatusCodes.BadHistoryOperationUnsupported]);
|
||||
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Tag")).ShouldBe(tagsBefore);
|
||||
(await source.ReadRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), 10, Ct))
|
||||
.Samples.Select(v => v.Value).ShouldBe([2d]);
|
||||
|
||||
// Retention purges samples and skips the trail it does not have.
|
||||
var purge = await provider.CreateRetention(sp)!.PurgeAsync(Ct);
|
||||
purge.Select(r => r.Table).ShouldBe(["Sample", "Event"]);
|
||||
purge.Single(r => r.Table == "Sample").Rows.ShouldBe(1);
|
||||
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Sample")).ShouldBe(1);
|
||||
|
||||
// One warning for the whole provider (= per boot), however many seams and calls touched the bootstrapper.
|
||||
loggers.Warnings.Count(w => w.Contains("Part 11 modification trail is unavailable")).ShouldBe(1);
|
||||
(await CountAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(1); // verify-only: never upgraded
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #674: <c>AutoCreateSchema=true</c> still upgrades a v1 schema to v2 — the modification trail appears and a
|
||||
/// HistoryUpdate is recorded in it — and logs no v1 warning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Auto_create_still_upgrades_a_v1_schema_and_enables_the_trail()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
await ApplyV1Async(s);
|
||||
var loggers = new CapturingLoggerFactory();
|
||||
var provider = new SqlServerHistorianProvider(new SqlServerHistorianOptions
|
||||
{
|
||||
ConnectionString = fx.ConnectionString, Schema = s, AutoCreateSchema = true,
|
||||
});
|
||||
var sp = new LoggerServices(loggers);
|
||||
|
||||
(await provider.CreateUpdater(sp)!.UpdateDataAsync("Plant.Speed", HistoryUpdateMode.Insert,
|
||||
[new HistorizationValue(Recent, HistorianValue.FromDouble(5), 0)], Who, Ct))
|
||||
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
|
||||
|
||||
(await CountAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.ModifiedTrailVersion);
|
||||
var trail = await provider.CreateDataSource(sp)
|
||||
.ReadModifiedAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), 10, null, Ct);
|
||||
trail.Values.ShouldHaveSingleItem().UpdateType.ShouldBe(HistoryModificationType.Insert);
|
||||
loggers.Warnings.ShouldNotContain(w => w.Contains("Part 11 modification trail is unavailable"));
|
||||
}
|
||||
|
||||
private async Task ApplyV1Async(string schema)
|
||||
{
|
||||
var v1 = new StreamReader(typeof(SqlServerSchemaBootstrapper).Assembly.GetManifestResourceStream("Schema.001_initial.sql")!).ReadToEnd();
|
||||
await using var c = new SqlConnection(fx.ConnectionString);
|
||||
await c.OpenAsync(Ct);
|
||||
foreach (var b in SqlServerSchemaBootstrapper.SplitBatches(v1.Replace("$(Schema)", schema)))
|
||||
{
|
||||
await using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = b;
|
||||
await cmd.ExecuteNonQueryAsync(Ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<long> CountAsync(string sql)
|
||||
{
|
||||
await using var c = new SqlConnection(fx.ConnectionString);
|
||||
await c.OpenAsync(Ct);
|
||||
await using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
return Convert.ToInt64(await cmd.ExecuteScalarAsync(Ct));
|
||||
}
|
||||
|
||||
/// <summary>The only service the provider resolves is <see cref="ILoggerFactory"/>.</summary>
|
||||
private sealed class LoggerServices(ILoggerFactory loggers) : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => serviceType == typeof(ILoggerFactory) ? loggers : null;
|
||||
}
|
||||
|
||||
/// <summary>Captures every Warning-or-above message from every logger it creates.</summary>
|
||||
private sealed class CapturingLoggerFactory : ILoggerFactory
|
||||
{
|
||||
private readonly List<string> _warnings = [];
|
||||
|
||||
public IReadOnlyList<string> Warnings
|
||||
{
|
||||
get { lock (_warnings) return _warnings.ToList(); }
|
||||
}
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new Capturing(this);
|
||||
|
||||
public void AddProvider(ILoggerProvider provider)
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class Capturing(CapturingLoggerFactory owner) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (logLevel < LogLevel.Warning) return;
|
||||
lock (owner._warnings) owner._warnings.Add(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -60,3 +60,10 @@ public sealed class SqlServerModifiedValueConformance(SqlHistorianFixture fx) :
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class SqlServerEventReadConformance(SqlHistorianFixture fx) : EventReadConformance
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
+17
-1
@@ -34,12 +34,28 @@ internal sealed class SqlServerConformanceBackend : IConformanceBackend
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IHistorianDataUpdater CreateUpdater() =>
|
||||
new SqlServerHistorianDataUpdater(_db.Conn, new SqlServerTagCatalog(_db.Conn, _db.Boot));
|
||||
new SqlServerHistorianDataUpdater(_db.Conn, _db.Boot, new SqlServerTagCatalog(_db.Conn, _db.Boot));
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples) =>
|
||||
await _db.SeedAsync(tag, samples);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task SeedEventsAsync(params (DateTime T, string Id, string Source)[] events)
|
||||
{
|
||||
await using var c = await _db.Conn.OpenAsync(CancellationToken.None);
|
||||
foreach (var (t, id, source) in events)
|
||||
{
|
||||
await using var cmd = _db.Conn.CreateCommand(c,
|
||||
$"INSERT {_db.Conn.Q("Event")} (EventId, SourceName, AlarmId, AlarmName, AlarmTypeName, Severity, EventKind, Message, " +
|
||||
"UserName, Comment, EventTimeUtc, ReceivedTimeUtc) VALUES (@id, @src, 'a', 'n', 'T', 500, 'Activated', NULL, NULL, NULL, @t, @t)");
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
cmd.Parameters.AddWithValue("@src", source);
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@t", t);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<(DateTime T, HistorianValue V, uint Status)>> ReadBackAsync(string tag)
|
||||
{
|
||||
|
||||
+3
-2
@@ -32,7 +32,7 @@ public sealed class SchemaScriptTests
|
||||
|
||||
/// <summary>
|
||||
/// #668: schema v2 (the Part 11 modification trail) is embedded, matches its docs copy, records version 2 (which
|
||||
/// must equal <see cref="SqlServerSchemaBootstrapper.RequiredVersion"/>), and carries none of the constructs an
|
||||
/// must equal <see cref="SqlServerSchemaBootstrapper.ModifiedTrailVersion"/>), and carries none of the constructs an
|
||||
/// <c>OUTPUT … INTO</c> target forbids (FOREIGN KEY / CHECK / trigger) — any of them would fail every HistoryUpdate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -45,7 +45,8 @@ public sealed class SchemaScriptTests
|
||||
text.ShouldContain("[SampleModified]");
|
||||
text.ShouldContain("PRIMARY KEY CLUSTERED (TagId, SourceTimeUtc, ModifiedId)");
|
||||
text.ShouldContain("VALUES (2, SYSUTCDATETIME())");
|
||||
SqlServerSchemaBootstrapper.RequiredVersion.ShouldBe(2);
|
||||
SqlServerSchemaBootstrapper.ModifiedTrailVersion.ShouldBe(2);
|
||||
SqlServerSchemaBootstrapper.RequiredVersion.ShouldBe(1); // #674: v1 is still served, without the trail
|
||||
var ddl = string.Join('\n', text.Split('\n').Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
|
||||
ddl.ShouldNotContain("FOREIGN KEY", Case.Insensitive);
|
||||
ddl.ShouldNotContain("CHECK", Case.Insensitive);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// #678.6 — the per-role appsettings overlay name is built from plain roles only. A <c>cluster-*</c> role in the
|
||||
/// name produced a file that never exists, so no overlay ever loaded on a node with a cluster role.
|
||||
/// </summary>
|
||||
public sealed class RoleOverlayTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_driver_site_node_loads_the_driver_overlay()
|
||||
=> RoleOverlay.Suffix(["driver", "cluster-SITE-A"]).ShouldBe("driver");
|
||||
|
||||
[Fact]
|
||||
public void A_central_node_loads_the_admin_driver_overlay()
|
||||
=> RoleOverlay.Suffix(["admin", "driver", "cluster-MAIN"]).ShouldBe("admin-driver");
|
||||
|
||||
[Fact]
|
||||
public void Plain_roles_are_ordinal_sorted_regardless_of_input_order()
|
||||
=> RoleOverlay.Suffix(["driver", "cluster-MAIN", "admin"]).ShouldBe("admin-driver");
|
||||
|
||||
[Fact]
|
||||
public void Roles_without_a_cluster_role_keep_the_existing_names()
|
||||
{
|
||||
RoleOverlay.Suffix(["admin"]).ShouldBe("admin");
|
||||
RoleOverlay.Suffix(["driver"]).ShouldBe("driver");
|
||||
RoleOverlay.Suffix(["admin", "driver"]).ShouldBe("admin-driver");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_cluster_role_prefix_is_matched_case_insensitively()
|
||||
=> RoleOverlay.Suffix(["driver", "CLUSTER-SITE-A"]).ShouldBe("driver");
|
||||
|
||||
[Fact]
|
||||
public void No_plain_role_means_no_overlay()
|
||||
{
|
||||
RoleOverlay.Suffix([]).ShouldBeNull();
|
||||
RoleOverlay.Suffix(["cluster-SITE-A"]).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- The shipped overlays: activating them on cluster-role nodes must not change production semantics. ----
|
||||
|
||||
private static string HostDir()
|
||||
{
|
||||
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.Host");
|
||||
if (File.Exists(Path.Combine(candidate, "appsettings.json"))) return candidate;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Host project directory not found above " + AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("admin")]
|
||||
[InlineData("driver")]
|
||||
[InlineData("admin-driver")]
|
||||
public void Shipped_overlays_carry_only_the_audited_keys(string suffix)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(HostDir(), $"appsettings.{suffix}.json")));
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Only Serilog + Security:Ldap, and in Security:Ldap only the two audited keys.
|
||||
root.EnumerateObject().Select(p => p.Name).Order().ShouldBe(["Security", "Serilog"]);
|
||||
var ldap = root.GetProperty("Security").GetProperty("Ldap");
|
||||
ldap.EnumerateObject().Select(p => p.Name).Order().ShouldBe(["DevStubMode", "Transport"]);
|
||||
ldap.GetProperty("DevStubMode").GetBoolean().ShouldBeFalse();
|
||||
|
||||
// No Debug-level (or lower) category override: activating an overlay must not raise log volume on a node.
|
||||
foreach (var o in root.GetProperty("Serilog").GetProperty("MinimumLevel").GetProperty("Override").EnumerateObject())
|
||||
o.Value.GetString().ShouldNotBeOneOf("Debug", "Verbose");
|
||||
root.GetProperty("Serilog").GetProperty("MinimumLevel").GetProperty("Default").GetString().ShouldBe("Information");
|
||||
}
|
||||
}
|
||||
+55
-3
@@ -54,8 +54,48 @@ public sealed class EventHistoryPagingOverTheWireTests
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_capped_REVERSE_event_read_pages_newest_first_through_the_session_store_exactly_once()
|
||||
{
|
||||
// Gitea #675: StartTime later than EndTime. Before the fix the cap kept the EARLIEST 10, ascending, with no
|
||||
// continuation point.
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("EvtPagingWireRev", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
var events = Enumerable.Range(0, 25)
|
||||
.Select(i => new HistoricalEvent($"e{i:00}", "src", Epoch.AddSeconds(i / 2), Epoch, "m", 500))
|
||||
.ToList(); // pairs share a timestamp, so page boundaries cut through ties
|
||||
rig.Nm.HistorianDataSource = new SqlLikeEventSource(events);
|
||||
rig.MaterialiseNative();
|
||||
rig.Enforce(false, [Key()]);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var node = new NodeId(RawDeviceFolder, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
||||
|
||||
var collected = new List<string>();
|
||||
var sizes = new List<int>();
|
||||
byte[]? cp = null;
|
||||
do
|
||||
{
|
||||
var (ids, next) = await ReadPageAsync(session, node, cp, ct, reverse: true);
|
||||
collected.AddRange(ids);
|
||||
sizes.Add(ids.Count);
|
||||
cp = next;
|
||||
sizes.Count.ShouldBeLessThan(30, "paging must terminate");
|
||||
}
|
||||
while (cp is not null);
|
||||
|
||||
sizes.ShouldBe([10, 10, 5]);
|
||||
// Newest-first: time descending, ties by EventId descending.
|
||||
collected.ShouldBe(events.OrderByDescending(e => e.EventTimeUtc)
|
||||
.ThenByDescending(e => e.EventId, StringComparer.Ordinal).Select(e => e.EventId));
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
private static async Task<(List<string> Ids, byte[]? Cp)> ReadPageAsync(
|
||||
ISession session, NodeId node, byte[]? cp, CancellationToken ct)
|
||||
ISession session, NodeId node, byte[]? cp, CancellationToken ct, bool reverse = false)
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId"))
|
||||
@@ -64,8 +104,8 @@ public sealed class EventHistoryPagingOverTheWireTests
|
||||
});
|
||||
var details = new ReadEventDetails
|
||||
{
|
||||
StartTime = Epoch.AddDays(-1),
|
||||
EndTime = Epoch.AddDays(1),
|
||||
StartTime = reverse ? Epoch.AddDays(1) : Epoch.AddDays(-1),
|
||||
EndTime = reverse ? Epoch.AddDays(-1) : Epoch.AddDays(1),
|
||||
NumValuesPerNode = 10,
|
||||
Filter = filter,
|
||||
};
|
||||
@@ -94,6 +134,18 @@ public sealed class EventHistoryPagingOverTheWireTests
|
||||
return new HistoricalEventsResult(window.Take(cap).ToList(), window.Count > cap ? new byte[] { 1 } : null);
|
||||
}
|
||||
|
||||
/// <summary>The #675 overload: reverse is <c>(lo, hi]</c> by (time, id) descending.</summary>
|
||||
public async Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse, CancellationToken ct)
|
||||
{
|
||||
if (!reverse) return await ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, ct).ConfigureAwait(false);
|
||||
await Task.Delay(1, ct).ConfigureAwait(false);
|
||||
var (lo, hi) = startUtc <= endUtc ? (startUtc, endUtc) : (endUtc, startUtc);
|
||||
var window = _all.Where(e => e.EventTimeUtc > lo && e.EventTimeUtc <= hi).Reverse().ToList();
|
||||
var cap = maxEvents <= 0 ? 1000 : maxEvents;
|
||||
return new HistoricalEventsResult(window.Take(cap).ToList(), window.Count > cap ? new byte[] { 1 } : null);
|
||||
}
|
||||
|
||||
public Task<CoreHistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
|
||||
public Task<CoreHistoryReadResult> ReadProcessedAsync(
|
||||
|
||||
@@ -558,6 +558,190 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── #674 future-skew bound ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// #674: an item whose SourceTimestamp lies beyond <c>now + HistoryUpdateMaxFutureSkew</c> is refused
|
||||
/// <c>BadOutOfRange</c> by the node manager and never reaches the backend; an item exactly AT the bound and one in
|
||||
/// the past are sent. Judged against the injected clock, not the wall clock.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_source_time_beyond_the_future_skew_is_bad_out_of_range_and_never_sent()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater();
|
||||
nm.HistorianDataUpdater = updater;
|
||||
nm.Clock = new FakeClock(Start);
|
||||
nm.HistoryUpdateMaxFutureSkew = TimeSpan.FromHours(1);
|
||||
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
var atBound = Start.AddHours(1);
|
||||
|
||||
var (results, errors) = Update(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId,
|
||||
PerformUpdateType.Update, Value(T1, 1d), Value(atBound.AddTicks(1), 2d), Value(atBound, 3d),
|
||||
Value(new DateTime(2062, 1, 1, 0, 0, 0, DateTimeKind.Utc), 4d));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].OperationResults.Select(c => c.Code).ShouldBe(
|
||||
[
|
||||
StatusCodes.GoodEntryInserted, StatusCodes.BadOutOfRange, StatusCodes.GoodEntryInserted,
|
||||
StatusCodes.BadOutOfRange,
|
||||
]);
|
||||
updater.UpdateCalls.ShouldHaveSingleItem().Values.Select(v => v.TimestampUtc).ShouldBe([T1, atBound]);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>#674: every mode is bounded, and a request whose items are ALL out of range sends nothing.</summary>
|
||||
[Theory]
|
||||
[InlineData(PerformUpdateType.Insert)]
|
||||
[InlineData(PerformUpdateType.Replace)]
|
||||
[InlineData(PerformUpdateType.Update)]
|
||||
public async Task Every_update_mode_is_bounded_and_an_all_refused_request_sends_nothing(PerformUpdateType type)
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater();
|
||||
nm.HistorianDataUpdater = updater;
|
||||
nm.Clock = new FakeClock(Start);
|
||||
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
|
||||
// Default skew: one day.
|
||||
var (results, _) = Update(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, type,
|
||||
Value(Start.AddDays(1).AddSeconds(1), 1d));
|
||||
|
||||
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.BadOutOfRange]);
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>#674: a non-positive skew disables the bound — a far-future item reaches the backend.</summary>
|
||||
[Fact]
|
||||
public async Task A_non_positive_skew_disables_the_bound()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater();
|
||||
nm.HistorianDataUpdater = updater;
|
||||
nm.Clock = new FakeClock(Start);
|
||||
nm.HistoryUpdateMaxFutureSkew = TimeSpan.Zero;
|
||||
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
var farFuture = new DateTime(2062, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var (results, _) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(farFuture, 1d));
|
||||
|
||||
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
|
||||
updater.UpdateCalls.ShouldHaveSingleItem().Values.ShouldHaveSingleItem().TimestampUtc.ShouldBe(farFuture);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── #674 Primary gate ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// #674: off-Primary, an authorized HistoryUpdate is refused <c>BadInvalidState</c> per node — on every arm
|
||||
/// (update data, delete raw, delete at time) — and NOTHING reaches the updater.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Off_primary_every_arm_is_bad_invalid_state_and_nothing_is_sent()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater();
|
||||
nm.HistorianDataUpdater = updater;
|
||||
var consulted = 0;
|
||||
nm.HistoryUpdatePrimaryGate = () => { Interlocked.Increment(ref consulted); return false; };
|
||||
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate);
|
||||
|
||||
var (results, errors) = Insert(nm, identity, nodeId, Value(T1, 1d));
|
||||
var (rawResults, rawErrors) = Call(nm, identity, typeof(DeleteRawModifiedDetails),
|
||||
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2 });
|
||||
var (atResults, atErrors) = Call(nm, identity, typeof(DeleteAtTimeDetails),
|
||||
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1 } });
|
||||
|
||||
foreach (var (r, e) in new[] { (results, errors), (rawResults, rawErrors), (atResults, atErrors) })
|
||||
{
|
||||
e[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState);
|
||||
r[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState);
|
||||
}
|
||||
|
||||
consulted.ShouldBe(3);
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
updater.DeleteRawCalls.ShouldBeEmpty();
|
||||
updater.DeleteAtTimeCalls.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #674: on the Primary the gate allows and the update REACHES the updater — the allow leg that makes the deny
|
||||
/// leg meaningful (a gate that refused everything, or one never consulted, fails one of the two).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task On_primary_the_update_reaches_the_updater()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater();
|
||||
nm.HistorianDataUpdater = updater;
|
||||
var consulted = 0;
|
||||
nm.HistoryUpdatePrimaryGate = () => { Interlocked.Increment(ref consulted); return true; };
|
||||
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
|
||||
var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
|
||||
updater.UpdateCalls.ShouldHaveSingleItem().Tag.ShouldBe(Tagname);
|
||||
consulted.ShouldBe(1);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #674: the Primary gate runs AFTER authorization — an unauthorized caller is told <c>BadUserAccessDenied</c>
|
||||
/// (not the node's role) and the gate is never consulted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_primary_gate_runs_after_authorization()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater();
|
||||
nm.HistorianDataUpdater = updater;
|
||||
var consulted = 0;
|
||||
nm.HistoryUpdatePrimaryGate = () => { Interlocked.Increment(ref consulted); return false; };
|
||||
WireAcl(nm, enabled: false, NodePermissions.WriteOperate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
|
||||
var (results, _) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.WriteOperate), nodeId, Value(T1, 1d));
|
||||
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
consulted.ShouldBe(0);
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
private static DataValue Value(DateTime timestamp, object value) =>
|
||||
|
||||
+186
-7
@@ -199,11 +199,168 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
|
||||
var mixed = ReadBatch(nm, 4, null, (node, good1[0].Cp), (other, Guid.NewGuid().ToByteArray()));
|
||||
mixed[0].Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
mixed[0].Ids.ShouldBe(src.All.Skip(4).Take(3).Select(e => e.EventId));
|
||||
// A resumed page is FULL (#675): it over-fetches by the boundary skip it then trims.
|
||||
mixed[0].Ids.ShouldBe(src.All.Skip(4).Take(4).Select(e => e.EventId));
|
||||
mixed[1].Error.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
// --- Gitea #675: reverse (newest-first) event paging ------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Reverse_events_page_newest_first_exactly_once_with_exact_page_counts()
|
||||
{
|
||||
var (host, nm, node, src) = await SetupAsync(Distinct(250));
|
||||
var pages = new List<int>();
|
||||
var ids = DrainAsync(nm, node, max: 100, start: Late, end: Early, pageSizes: pages);
|
||||
ids.ShouldBe(src.All.Reverse().Select(e => e.EventId));
|
||||
pages.ShouldBe([100, 100, 50]);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reverse_first_page_is_the_LATEST_events_with_a_continuation_point()
|
||||
{
|
||||
// The pre-#675 shape: the cap kept the EARLIEST N, ascending, Good, and no continuation point.
|
||||
var (host, nm, node, _) = await SetupAsync(Distinct(10));
|
||||
var p1 = ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late);
|
||||
p1.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
p1.Ids.ShouldBe(["e0009", "e0008", "e0007"]);
|
||||
p1.Cp.ShouldNotBeNull("a capped reverse read has more behind it and must page");
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reverse_tie_at_page_boundary_is_neither_duplicated_nor_skipped()
|
||||
{
|
||||
var events = new List<HistoricalEvent>
|
||||
{
|
||||
Ev("e0", 0), Ev("e1", 1), Ev("e2", 2), Ev("e3", 2), Ev("e4", 2), Ev("e5", 3),
|
||||
};
|
||||
var (host, nm, node, _) = await SetupAsync(events);
|
||||
// Newest-first, ties in descending EventId: cap 2 cuts the cluster at e4|e3.
|
||||
DrainAsync(nm, node, max: 2, start: Late, end: Early).ShouldBe(["e5", "e4", "e3", "e2", "e1", "e0"]);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reverse_tie_cluster_larger_than_the_cap_pages_within_the_cluster()
|
||||
{
|
||||
var events = new List<HistoricalEvent> { Ev("a0", 0) };
|
||||
for (var i = 0; i < 7; i++) events.Add(Ev($"t{i}", 5));
|
||||
events.Add(Ev("z0", 9));
|
||||
var (host, nm, node, _) = await SetupAsync(events);
|
||||
var pages = new List<int>();
|
||||
DrainAsync(nm, node, max: 3, start: Late, end: Early, pageSizes: pages).ShouldBe(
|
||||
["z0", "t6", "t5", "t4", "t3", "t2", "t1", "t0", "a0"]);
|
||||
pages.Sum().ShouldBe(9);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reverse_tie_cluster_pages_on_a_coarse_resolution_backend()
|
||||
{
|
||||
const long res = 10;
|
||||
var events = new List<HistoricalEvent>();
|
||||
DateTime At(int slot) => Epoch.AddTicks(slot * res);
|
||||
events.Add(new("a0", "s", At(0), Epoch, "m", 500));
|
||||
for (var i = 0; i < 7; i++) events.Add(new($"t{i}", "s", At(3), Epoch, "m", 500));
|
||||
events.Add(new("z0", "s", At(8), Epoch, "m", 500));
|
||||
var (host, nm, node, _) = await SetupAsync(events, res);
|
||||
DrainAsync(nm, node, max: 3, start: Late, end: Early).ShouldBe(
|
||||
["z0", "t6", "t5", "t4", "t3", "t2", "t1", "t0", "a0"]);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reverse_tie_cluster_beyond_the_overfetch_bound_fails_loudly()
|
||||
{
|
||||
var events = new List<HistoricalEvent>();
|
||||
for (var i = 0; i < 10; i++) events.Add(Ev($"t{i:00}", 5));
|
||||
var (host, nm, node, _) = await SetupAsync(events);
|
||||
nm.MaxTieClusterOverfetch = 4;
|
||||
var p1 = ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late);
|
||||
p1.Cp.ShouldNotBeNull();
|
||||
ReadEvents(nm, node, max: 3, inboundCp: p1.Cp, end: Early, start: Late).Error.StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reverse_cursor_resumes_reverse_whatever_the_resume_request_says()
|
||||
{
|
||||
// The direction lives in the cursor: a resume carrying FORWARD details still continues newest-first.
|
||||
var (host, nm, node, _) = await SetupAsync(Distinct(10));
|
||||
var p1 = ReadEvents(nm, node, max: 4, inboundCp: null, end: Early, start: Late);
|
||||
p1.Ids.ShouldBe(["e0009", "e0008", "e0007", "e0006"]);
|
||||
var p2 = ReadEvents(nm, node, max: 4, inboundCp: p1.Cp); // default details: Early -> Late (forward)
|
||||
p2.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
p2.Ids.ShouldBe(["e0005", "e0004", "e0003", "e0002"]);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_forward_cursor_resumes_forward_whatever_the_resume_request_says()
|
||||
{
|
||||
var (host, nm, node, _) = await SetupAsync(Distinct(10));
|
||||
var p1 = ReadEvents(nm, node, max: 4, inboundCp: null);
|
||||
p1.Ids.ShouldBe(["e0000", "e0001", "e0002", "e0003"]);
|
||||
var p2 = ReadEvents(nm, node, max: 4, inboundCp: p1.Cp, end: Early, start: Late);
|
||||
p2.Ids.ShouldBe(["e0004", "e0005", "e0006", "e0007"]);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reverse_unbounded_read_returns_everything_newest_first_without_paging()
|
||||
{
|
||||
var (host, nm, node, src) = await SetupAsync(Distinct(30));
|
||||
var r = ReadEvents(nm, node, max: 0, inboundCp: null, end: Early, start: Late);
|
||||
r.Ids.ShouldBe(src.All.Reverse().Select(e => e.EventId));
|
||||
r.Cp.ShouldBeNull();
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task End_only_events_read_pages_backward_from_EndTime()
|
||||
{
|
||||
// StartTime unset + EndTime + NumValuesPerNode > 0 => backward from EndTime (Part 11; mirrors raw). The reverse
|
||||
// window is (.., EndTime], so an event exactly AT EndTime is the first one returned.
|
||||
var (host, nm, node, _) = await SetupAsync(Distinct(25));
|
||||
var ids = DrainAsync(nm, node, max: 10, start: DateTime.MinValue, end: Epoch.AddSeconds(20));
|
||||
ids.ShouldBe(Enumerable.Range(0, 21).Reverse().Select(i => $"e{i:0000}"));
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_backend_without_reverse_support_answers_BadHistoryOperationUnsupported_not_the_earliest_events()
|
||||
{
|
||||
var (host, nm, node, src) = await SetupAsync(Distinct(10));
|
||||
nm.HistorianDataSource = new ForwardOnlyEventSource(src);
|
||||
ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late).Error.StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
// Forward reads on the same backend are unaffected.
|
||||
DrainAsync(nm, node, max: 4).ShouldBe(src.All.Select(e => e.EventId));
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
private static readonly DateTime Early = Epoch.AddDays(-1);
|
||||
private static readonly DateTime Late = Epoch.AddDays(1);
|
||||
|
||||
/// <summary>A backend that implements only the five-argument (forward) event read - Gateway / Null shaped.</summary>
|
||||
private sealed class ForwardOnlyEventSource(IHistorianDataSource inner) : IHistorianDataSource
|
||||
{
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) =>
|
||||
inner.ReadEventsAsync(n, s, e, m, ct);
|
||||
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
|
||||
inner.ReadRawAsync(r, s, e, m, ct);
|
||||
public Task<HistorianRead> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
|
||||
inner.ReadProcessedAsync(r, s, e, i, a, ct);
|
||||
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
|
||||
inner.ReadAtTimeAsync(r, t, ct);
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => inner.GetHealthSnapshot();
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class RawSeriesSource : IHistorianDataSource
|
||||
{
|
||||
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct)
|
||||
@@ -265,16 +422,18 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
return (started.Host, nm, nm.TryGetFolder(equipmentId)!.NodeId, src);
|
||||
}
|
||||
|
||||
private static List<string> DrainAsync(OtOpcUaNodeManager nm, NodeId node, uint max, DateTime? end = null)
|
||||
private static List<string> DrainAsync(
|
||||
OtOpcUaNodeManager nm, NodeId node, uint max, DateTime? end = null, DateTime? start = null, List<int>? pageSizes = null)
|
||||
{
|
||||
var all = new List<string>();
|
||||
byte[]? cp = null;
|
||||
var pages = 0;
|
||||
do
|
||||
{
|
||||
var r = ReadEvents(nm, node, max, cp, end);
|
||||
var r = ReadEvents(nm, node, max, cp, end, start);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
all.AddRange(r.Ids);
|
||||
pageSizes?.Add(r.Ids.Count);
|
||||
cp = r.Cp;
|
||||
(++pages).ShouldBeLessThan(60, "paging must terminate");
|
||||
}
|
||||
@@ -283,11 +442,15 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
}
|
||||
|
||||
private static (List<string> Ids, ServiceResult Error, byte[]? Cp) ReadEvents(
|
||||
OtOpcUaNodeManager nm, NodeId node, uint max, byte[]? inboundCp, DateTime? end = null) =>
|
||||
ReadBatch(nm, max, end, (node, inboundCp))[0];
|
||||
OtOpcUaNodeManager nm, NodeId node, uint max, byte[]? inboundCp, DateTime? end = null, DateTime? start = null) =>
|
||||
ReadBatch(nm, max, end, start, (node, inboundCp))[0];
|
||||
|
||||
private static List<(List<string> Ids, ServiceResult Error, byte[]? Cp)> ReadBatch(
|
||||
OtOpcUaNodeManager nm, uint max, DateTime? end, params (NodeId Node, byte[]? Cp)[] reads)
|
||||
OtOpcUaNodeManager nm, uint max, DateTime? end, params (NodeId Node, byte[]? Cp)[] reads) =>
|
||||
ReadBatch(nm, max, end, null, reads);
|
||||
|
||||
private static List<(List<string> Ids, ServiceResult Error, byte[]? Cp)> ReadBatch(
|
||||
OtOpcUaNodeManager nm, uint max, DateTime? end, DateTime? start, params (NodeId Node, byte[]? Cp)[] reads)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
@@ -298,7 +461,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
});
|
||||
var details = new ReadEventDetails
|
||||
{
|
||||
StartTime = Epoch.AddDays(-1),
|
||||
StartTime = start ?? Epoch.AddDays(-1),
|
||||
EndTime = end ?? Epoch.AddDays(1),
|
||||
NumValuesPerNode = max,
|
||||
Filter = filter,
|
||||
@@ -361,6 +524,22 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
window.Take(cap).ToList(), truncated ? new byte[] { 1 } : null));
|
||||
}
|
||||
|
||||
/// <summary>Like the SQL backends' #675 overload: reverse => <c>(lo, hi]</c> newest-first by (time, id) descending.</summary>
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse, CancellationToken ct)
|
||||
{
|
||||
if (!reverse) return ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, ct);
|
||||
Calls++;
|
||||
var (lo, hi) = startUtc <= endUtc ? (startUtc, endUtc) : (endUtc, startUtc);
|
||||
lo = Trunc(lo);
|
||||
hi = Trunc(hi);
|
||||
var window = All.Where(e => e.EventTimeUtc > lo && e.EventTimeUtc <= hi).Reverse().ToList();
|
||||
var cap = maxEvents <= 0 ? 1000 : maxEvents;
|
||||
var truncated = window.Count > cap;
|
||||
return Task.FromResult(new HistoricalEventsResult(
|
||||
window.Take(cap).ToList(), truncated ? new byte[] { 1 } : null));
|
||||
}
|
||||
|
||||
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
|
||||
public Task<HistorianRead> ReadProcessedAsync(
|
||||
|
||||
+22
@@ -203,6 +203,28 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #674: the HistoryUpdate Primary gate does not reach HistoryRead — a closed gate neither refuses a read nor is
|
||||
/// consulted by one (reads are served from any redundancy node), for modified and raw reads alike.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_history_update_primary_gate_is_not_consulted_for_reads()
|
||||
{
|
||||
var (host, nm, node, src) = await SetupAsync([Mod(1, 1, 1, HistoryModificationType.Update, "u", Epoch)]);
|
||||
var consulted = 0;
|
||||
nm.HistoryUpdatePrimaryGate = () => { Interlocked.Increment(ref consulted); return false; };
|
||||
|
||||
var modified = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null);
|
||||
var raw = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null, modified: false);
|
||||
|
||||
modified.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
modified.Data!.DataValues.Count.ShouldBe(1);
|
||||
raw.Error.StatusCode.Code.ShouldNotBe(StatusCodes.BadInvalidState);
|
||||
src.Calls.ShouldBe(1);
|
||||
consulted.ShouldBe(0);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_backend_failure_is_isolated_to_its_node()
|
||||
{
|
||||
|
||||
@@ -165,6 +165,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
NumValuesPerNode = 10,
|
||||
IsReadModified = false, // SDK default is true; this test is about the raw path
|
||||
};
|
||||
|
||||
var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);
|
||||
|
||||
+22
-2
@@ -223,10 +223,30 @@ public sealed class DriverHostActorHistorizationRoleViewTests : RuntimeActorTest
|
||||
host.Tell(new ClusterEvent.UnreachableMember(MemberAt(Peer672)));
|
||||
AwaitAssert(() => hist.Published.ShouldBe([false, true]), duration: Timeout);
|
||||
|
||||
// And self "returning" does not close what the peer's loss opened.
|
||||
// Self "returning" is ignored too, and it does not close what the peer's loss opened. Events are
|
||||
// processed in order, so the peer's return (which MUST close the gate) is the only thing that may
|
||||
// appear next: had the self event published anything it would sit between `true` and `false`.
|
||||
host.Tell(new ClusterEvent.ReachableMember(MemberAt(Self672)));
|
||||
host.Tell(new ClusterEvent.ReachableMember(MemberAt(Peer672)));
|
||||
AwaitAssert(() => hist.Published.ShouldBe([false, true, false]), duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A host that starts while its peer is ALREADY unreachable (the <c>InitialStateAsEvents</c> replay
|
||||
/// delivers the <c>UnreachableMember</c> before the first redundancy snapshot) must still treat that
|
||||
/// peer as unreachable when the snapshot naming it Primary finally arrives.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_peer_unreachable_before_the_first_snapshot_still_opens_capture()
|
||||
{
|
||||
var hist = new RecordingHistorizationView();
|
||||
IActorRef host = SpawnHost672(hist, others: null, captureWhilePrimaryUnreachable: true);
|
||||
|
||||
host.Tell(new ClusterEvent.UnreachableMember(MemberAt(Peer672)));
|
||||
host.Tell(Snapshot((Self672, RedundancyRole.Secondary), (Peer672, RedundancyRole.Primary)));
|
||||
AwaitAssert(() => hist.Published.ShouldBe([false, true, true]), duration: Timeout);
|
||||
|
||||
// Exactly one publication, and it is `true`: no spurious `false` from treating the peer as reachable.
|
||||
AwaitAssert(() => hist.Published.ShouldBe([true]), duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary><c>CaptureWhilePrimaryUnreachable = false</c> restores the pre-#672 behaviour.</summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
@@ -99,6 +100,29 @@ public sealed class ServerHistorianOptionsTests
|
||||
Assert.Contains(w, m => m.Contains("MaxTimeWeightedRawRows"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #674: the HistoryUpdate future-skew bound defaults to one day and binds from configuration as a TimeSpan;
|
||||
/// a non-positive value is a deliberate "disabled", not a misconfiguration, so it does not warn.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HistoryUpdateMaxFutureSkew_defaults_to_one_day_binds_and_zero_is_silent()
|
||||
{
|
||||
Assert.Equal(TimeSpan.FromDays(1), new ServerHistorianOptions().HistoryUpdateMaxFutureSkew);
|
||||
var bound = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ServerHistorian:HistoryUpdateMaxFutureSkew"] = "00:05:00",
|
||||
})
|
||||
.Build()
|
||||
.GetSection(ServerHistorianOptions.SectionName)
|
||||
.Get<ServerHistorianOptions>();
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), bound!.HistoryUpdateMaxFutureSkew);
|
||||
var w = new ServerHistorianOptions
|
||||
{ Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y", HistoryUpdateMaxFutureSkew = TimeSpan.Zero }
|
||||
.Validate();
|
||||
Assert.Empty(w);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabled_with_nonpositive_HistoryRead_knobs_is_silent()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user