Merge master into feat/677-aggregates
# Conflicts: # docs/Historian.md
This commit is contained in:
@@ -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