fix(history): advertise HistoryServerCapabilities from the wired backend (#881)
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:
Joseph Doherty
2026-11-12 10:31:29 -05:00
parent ac4c4d40dc
commit b27b5b9cc1
17 changed files with 526 additions and 15 deletions
@@ -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)
{
@@ -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;
}
}
@@ -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) =>