fix(history): advertise HistoryServerCapabilities from the wired backend (#881)
v2-ci / unit-tests (push) Has been cancelled
v2-ci / unit-tests (push) Has been cancelled
Every Boolean under Server/ServerCapabilities/HistoryServerCapabilities read false (the SDK default) even though data and event history were served, so a spec-following client skipped HistoryRead entirely. Each backend now declares what it serves through its own interface: - IHistorianDataSource.ReadCapabilities (Data / Events / ServerTimestamps): Gateway Data|Events; SqlServer + QuestDb add ServerTimestamps (they store ServerTimeUtc, the Gateway stamps read time); Null declares None. A reflection guard requires every production source to declare it itself. - IHistorianDataUpdater.Capabilities (required member): SqlServer and QuestDb declare Insert/Replace/Update/DeleteRaw/DeleteAtTime; the Gateway provider has no updater. The node manager writes the flags on every source/updater assignment (AdvertisedHistoryCapabilities), and the same declarations drive dispatch: an update the updater does not declare is BadHistoryOperationUnsupported without reaching it, the HistoryWrite AccessLevel bit follows a non-empty declaration, and event-history notifier promotion follows the Events flag. Event and annotation update flags stay false (nothing dispatches them). Tests: booted-server wire test per backend + Null + a SqlServer->Gateway rewire pairing on one session; dispatch gating unit test; the #801 live gate now asserts the Gateway flags (not run here). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
+11
@@ -159,6 +159,17 @@ public sealed class AvevaGatewayHistoryLiveGateTests
|
||||
report.Add(new Case("metadata.historyServerCapabilities", null, null, "wire", "Good", null, caps));
|
||||
Check(failures, caps["AggregateFunctions"] == string.Join(",", Served.Select(a => a.Name).OrderBy(n => n, StringComparer.Ordinal)),
|
||||
$"advertised history aggregates must be exactly the Gateway's ten, got {caps["AggregateFunctions"]}");
|
||||
// lmxopcua#881: the Boolean capabilities follow the Gateway backend — data + event reads, no HistoryUpdate path, no
|
||||
// stored server timestamps, never event / annotation updates (this gate recorded all-false before the fix).
|
||||
foreach (var flag in new[] { "AccessHistoryDataCapability", "AccessHistoryEventsCapability" })
|
||||
Check(failures, caps.GetValueOrDefault(flag) == "Boolean:True", $"{flag} must be true on the Gateway backend, got {caps.GetValueOrDefault(flag)}");
|
||||
foreach (var flag in new[]
|
||||
{
|
||||
"ServerTimestampSupported", "InsertDataCapability", "ReplaceDataCapability", "UpdateDataCapability",
|
||||
"DeleteRawCapability", "DeleteAtTimeCapability", "InsertEventCapability", "ReplaceEventCapability",
|
||||
"UpdateEventCapability", "DeleteEventCapability", "InsertAnnotationCapability",
|
||||
})
|
||||
Check(failures, caps.GetValueOrDefault(flag) == "Boolean:False", $"{flag} must be false on the Gateway backend, got {caps.GetValueOrDefault(flag)}");
|
||||
|
||||
foreach (var (tag, id) in nodes)
|
||||
{
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#881 over a real SDK client session: the Boolean <c>Server/ServerCapabilities/HistoryServerCapabilities</c>
|
||||
/// properties follow the wired backend. The data source AND the updater are built through
|
||||
/// <see cref="HistorianBackendRegistry"/> exactly as the Host builds them (Program.cs wires
|
||||
/// <c>provider.CreateUpdater</c>), so each expectation is what that backend really serves:
|
||||
/// <list type="bullet">
|
||||
/// <item>Gateway — data + event reads; no HistoryUpdate path (its provider returns no updater); AVEVA keeps no
|
||||
/// per-sample server time.</item>
|
||||
/// <item>SqlServer / QuestDb — data + event reads; Insert / Replace / Update / DeleteRaw / DeleteAtTime
|
||||
/// (QuestDB deletes are tombstones); every sample stores its server time.</item>
|
||||
/// <item>None of them — event or annotation updates: the server dispatches none.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class HistoryServerCapabilitiesWireTests
|
||||
{
|
||||
private static readonly (string Name, NodeId Id)[] Flags =
|
||||
[
|
||||
("AccessHistoryDataCapability", VariableIds.HistoryServerCapabilities_AccessHistoryDataCapability),
|
||||
("AccessHistoryEventsCapability", VariableIds.HistoryServerCapabilities_AccessHistoryEventsCapability),
|
||||
("ServerTimestampSupported", VariableIds.HistoryServerCapabilities_ServerTimestampSupported),
|
||||
("InsertDataCapability", VariableIds.HistoryServerCapabilities_InsertDataCapability),
|
||||
("ReplaceDataCapability", VariableIds.HistoryServerCapabilities_ReplaceDataCapability),
|
||||
("UpdateDataCapability", VariableIds.HistoryServerCapabilities_UpdateDataCapability),
|
||||
("DeleteRawCapability", VariableIds.HistoryServerCapabilities_DeleteRawCapability),
|
||||
("DeleteAtTimeCapability", VariableIds.HistoryServerCapabilities_DeleteAtTimeCapability),
|
||||
("InsertEventCapability", VariableIds.HistoryServerCapabilities_InsertEventCapability),
|
||||
("ReplaceEventCapability", VariableIds.HistoryServerCapabilities_ReplaceEventCapability),
|
||||
("UpdateEventCapability", VariableIds.HistoryServerCapabilities_UpdateEventCapability),
|
||||
("DeleteEventCapability", VariableIds.HistoryServerCapabilities_DeleteEventCapability),
|
||||
("InsertAnnotationCapability", VariableIds.HistoryServerCapabilities_InsertAnnotationCapability),
|
||||
];
|
||||
|
||||
private static readonly string[] ReadOnlyHistory = ["AccessHistoryDataCapability", "AccessHistoryEventsCapability"];
|
||||
|
||||
private static readonly string[] UpdatableHistory =
|
||||
[
|
||||
"AccessHistoryDataCapability", "AccessHistoryEventsCapability", "ServerTimestampSupported",
|
||||
"InsertDataCapability", "ReplaceDataCapability", "UpdateDataCapability", "DeleteRawCapability", "DeleteAtTimeCapability",
|
||||
];
|
||||
|
||||
/// <summary>The flags expected TRUE per backend; every other flag in <see cref="Flags"/> must read false.</summary>
|
||||
private static string[] ExpectedTrue(string backend) => backend == "Gateway" ? ReadOnlyHistory : UpdatableHistory;
|
||||
|
||||
private static IHistorianBackendProvider Provider(string backend)
|
||||
{
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ServerHistorian:SqlServer:ConnectionString"] = "Server=localhost;User Id=sa;Password=x;TrustServerCertificate=True",
|
||||
["ServerHistorian:QuestDb:ConnectionString"] = "Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb",
|
||||
}).Build();
|
||||
var options = new ServerHistorianOptions
|
||||
{
|
||||
Backend = backend, Enabled = true, Endpoint = "http://localhost:5222", ApiKey = "histgw_id_secret", UseTls = false,
|
||||
};
|
||||
// Constructing the seams opens no connection; nothing here reaches a database or a gateway.
|
||||
return HistorianBackendRegistry.Resolve(options, config);
|
||||
}
|
||||
|
||||
private static void Wire(ServerEventRig rig, string backend)
|
||||
{
|
||||
var sp = new ServiceCollection().BuildServiceProvider();
|
||||
var provider = Provider(backend);
|
||||
rig.Nm.HistorianDataSource = provider.CreateDataSource(sp);
|
||||
rig.Nm.HistorianDataUpdater = provider.CreateUpdater(sp);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Gateway")]
|
||||
[InlineData("SqlServer")]
|
||||
[InlineData("QuestDb")]
|
||||
public async Task Each_backend_advertises_exactly_what_it_serves(string backend)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync($"HistCaps{backend}", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
Wire(rig, backend);
|
||||
rig.Enforce(false, [Key()]);
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
|
||||
var read = await ReadFlagsAsync(session, ct);
|
||||
var expected = ExpectedTrue(backend);
|
||||
foreach (var (name, _) in Flags)
|
||||
read[name].ShouldBe(expected.Contains(name), $"{backend}: {name}");
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task With_no_historian_wired_every_flag_stays_false()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("HistCapsNull", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
// The Host's disabled-historian wiring: the Null source and no updater, assigned explicitly.
|
||||
rig.Nm.HistorianDataSource = null!;
|
||||
rig.Nm.HistorianDataUpdater = null;
|
||||
rig.Enforce(false, [Key()]);
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
|
||||
(await ReadFlagsAsync(session, ct)).Values.ShouldAllBe(v => v == false);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rewiring_SqlServer_to_Gateway_retracts_the_update_flags_on_the_same_session()
|
||||
{
|
||||
// Positive/negative pairing on one live session: the same nodes read true under SqlServer and false under the
|
||||
// Gateway, so neither expectation can pass on a server that never writes the properties.
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("HistCapsRewire", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(false, [Key()]);
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
|
||||
Wire(rig, "SqlServer");
|
||||
var sql = await ReadFlagsAsync(session, ct);
|
||||
sql["InsertDataCapability"].ShouldBeTrue();
|
||||
sql["DeleteRawCapability"].ShouldBeTrue();
|
||||
sql["ServerTimestampSupported"].ShouldBeTrue();
|
||||
sql["AccessHistoryDataCapability"].ShouldBeTrue();
|
||||
|
||||
Wire(rig, "Gateway");
|
||||
var gateway = await ReadFlagsAsync(session, ct);
|
||||
gateway["InsertDataCapability"].ShouldBeFalse();
|
||||
gateway["DeleteRawCapability"].ShouldBeFalse();
|
||||
gateway["ServerTimestampSupported"].ShouldBeFalse();
|
||||
gateway["AccessHistoryDataCapability"].ShouldBeTrue("read access is not an update flag");
|
||||
|
||||
rig.Nm.HistorianDataSource = null!;
|
||||
rig.Nm.HistorianDataUpdater = null;
|
||||
(await ReadFlagsAsync(session, ct))["AccessHistoryDataCapability"].ShouldBeFalse("unwiring retracts read access too");
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<string, bool>> ReadFlagsAsync(ISession session, CancellationToken ct)
|
||||
{
|
||||
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither,
|
||||
Flags.Select(f => new ReadValueId { NodeId = f.Id, AttributeId = Attributes.Value }).ToArray(), ct);
|
||||
var results = response.Results.ToList();
|
||||
var flags = new Dictionary<string, bool>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < Flags.Length; i++)
|
||||
{
|
||||
StatusCode.IsGood(results[i].StatusCode).ShouldBeTrue($"{Flags[i].Name}: {results[i].StatusCode}");
|
||||
flags[Flags[i].Name] = results[i].ClrValue() is true;
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
}
|
||||
+2
@@ -324,6 +324,8 @@ public sealed class HistoryUpdateOverTheWireTests
|
||||
private readonly List<HistoryModifiedValue> _trail = [];
|
||||
private int _updateCalls;
|
||||
|
||||
public HistoryUpdateCapabilities Capabilities => HistoryUpdateCapabilities.AllData;
|
||||
|
||||
private void Record(DateTime t, double? prior, HistoryModificationType type, HistoryUpdateContext who) =>
|
||||
_trail.Add(new HistoryModifiedValue(
|
||||
new DataValueSnapshot(prior, prior is null ? StatusCodes.BadNoData.Code : StatusCodes.Good.Code, t, t),
|
||||
|
||||
@@ -222,6 +222,51 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#881 — the updater's declared <see cref="IHistorianDataUpdater.Capabilities"/> gate dispatch as well as
|
||||
/// the advertisement: an updater that declares only InsertData serves an insert (positive control) but a Replace,
|
||||
/// a DeleteRaw and a DeleteAtTime are BadHistoryOperationUnsupported without reaching it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_operation_the_updater_does_not_declare_is_unsupported_and_never_reaches_it()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater { Capabilities = HistoryUpdateCapabilities.InsertData };
|
||||
nm.HistorianDataUpdater = updater;
|
||||
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
var who = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate);
|
||||
|
||||
var (inserted, _) = Insert(nm, who, nodeId, Value(T1, 1d));
|
||||
inserted[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted.Code]);
|
||||
updater.UpdateCalls.Count.ShouldBe(1, "the declared operation is served");
|
||||
|
||||
var (replaced, replaceErrors) = Update(nm, who, nodeId, PerformUpdateType.Replace, Value(T1, 2d));
|
||||
replaceErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
replaced[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
|
||||
var (_, rawErrors) = Call(nm, who, typeof(DeleteRawModifiedDetails), new DeleteRawModifiedDetails
|
||||
{
|
||||
NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false,
|
||||
});
|
||||
rawErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
|
||||
var (_, atTimeErrors) = Call(nm, who, typeof(DeleteAtTimeDetails), new DeleteAtTimeDetails
|
||||
{
|
||||
NodeId = nodeId, ReqTimes = new List<DateTimeUtc> { T1 },
|
||||
});
|
||||
atTimeErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
|
||||
updater.UpdateCalls.Count.ShouldBe(1);
|
||||
updater.DeleteRawCalls.ShouldBeEmpty();
|
||||
updater.DeleteAtTimeCalls.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An updater removed AFTER materialisation retracts the node's HistoryWrite bit (review 01 U3 / B7), so
|
||||
/// the SDK base refuses the insert with BadHistoryOperationUnsupported before our override runs and the
|
||||
@@ -982,6 +1027,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
/// <summary>Records every call; completes on a pool thread like a real backend round-trip.</summary>
|
||||
private sealed class FakeUpdater : IHistorianDataUpdater
|
||||
{
|
||||
public HistoryUpdateCapabilities Capabilities { get; init; } = HistoryUpdateCapabilities.AllData;
|
||||
public List<(string Tag, HistoryUpdateMode Mode, IReadOnlyList<HistorizationValue> Values)> UpdateCalls { get; } = [];
|
||||
public List<HistoryUpdateContext> Callers { get; } = [];
|
||||
public List<(string Tag, DateTime Start, DateTime End)> DeleteRawCalls { get; } = [];
|
||||
|
||||
@@ -115,6 +115,7 @@ public sealed class NodeManagerCapabilityRefreshTests : IDisposable
|
||||
|
||||
private sealed class StubUpdater : IHistorianDataUpdater
|
||||
{
|
||||
public HistoryUpdateCapabilities Capabilities => HistoryUpdateCapabilities.AllData;
|
||||
public Task<IReadOnlyList<uint>> UpdateDataAsync(string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context, CancellationToken ct) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context, CancellationToken ct) =>
|
||||
|
||||
Reference in New Issue
Block a user