Merge feat/668-modified-values: Part 11 modified-value history (IsReadModified) on SqlServer + QuestDB (#668)

This commit is contained in:
Joseph Doherty
2026-10-11 09:32:13 -04:00
51 changed files with 2173 additions and 195 deletions
@@ -72,6 +72,50 @@ public sealed class HistoryUpdateOverTheWireTests
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// Part 11 modified-value history over the wire (#668): two inserts and a delete by the key principal, then a
/// HistoryReadRaw with <c>IsReadModified = true</c> decodes as a <see cref="HistoryModifiedData"/> whose
/// <c>ModificationInfos</c> carry the update type and the principal, and whose values carry the PRIOR value.
/// </summary>
[Fact]
public async Task A_modified_read_returns_the_trail_with_principal_and_update_type_over_the_wire()
{
var ct = TestContext.Current.CancellationToken;
var store = new SharedStore();
var rig = await StartAsync("HuWireModified", encrypted: false, ct);
try
{
rig.Nm.HistorianDataUpdater = store;
rig.Nm.HistorianDataSource = store;
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-editor", Editor));
var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname);
using var session = await rig.ConnectAsync(anonymous: false, ct);
var node = RawNode(session, id);
await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.5), (T2, 2.5));
await DeleteAtTimeAsync(session, node, ct, T1);
var details = new ReadRawModifiedDetails
{
StartTime = T1.AddHours(-1), EndTime = T1.AddHours(1), NumValuesPerNode = 100, IsReadModified = true,
};
var response = await session.HistoryReadAsync(
null, new ExtensionObject(details), TimestampsToReturn.Both, false,
new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } }, ct);
var result = response.Results.ShouldHaveSingleItem();
result.StatusCode.Code.ShouldBe(StatusCodes.Good);
var data = ExtensionObject.ToEncodeable(result.HistoryData).ShouldBeOfType<HistoryModifiedData>();
data.DataValues.Select(v => v.SourceTimestamp).ShouldBe([T1, T1, T2]);
data.ModificationInfos.Select(m => m.UpdateType)
.ShouldBe([HistoryUpdateType.Insert, HistoryUpdateType.Delete, HistoryUpdateType.Insert]);
data.ModificationInfos.ShouldAllBe(m => m.UserName == KeyPrincipal);
data.DataValues[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData); // an insert had no prior value
((double)data.DataValues[1].Value).ShouldBe(1.5); // the deleted sample's prior value
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// ALLOW beside DENY: the same server, same node — the editor session is admitted, the session
/// without the HistoryUpdate grant gets BadUserAccessDenied and the updater is never called.
@@ -263,14 +307,21 @@ public sealed class HistoryUpdateOverTheWireTests
{
private readonly object _gate = new();
private readonly SortedDictionary<DateTime, double> _samples = [];
private readonly List<HistoryModifiedValue> _trail = [];
private int _updateCalls;
private void Record(DateTime t, double? prior, HistoryModificationType type, HistoryUpdateContext who) =>
_trail.Add(new HistoryModifiedValue(
new DataValueSnapshot(prior, prior is null ? StatusCodes.BadNoData : StatusCodes.Good, t, t),
DateTime.UtcNow, type, who.Principal, _trail.Count + 1));
public int UpdateCalls => Volatile.Read(ref _updateCalls);
public int Count { get { lock (_gate) return _samples.Count; } }
public async Task<IReadOnlyList<uint>> UpdateDataAsync(
string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, CancellationToken ct)
string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context,
CancellationToken ct)
{
Interlocked.Increment(ref _updateCalls);
await Task.Delay(1, ct).ConfigureAwait(false);
@@ -285,21 +336,43 @@ public sealed class HistoryUpdateOverTheWireTests
if (_samples.ContainsKey(t)) { codes.Add(StatusCodes.BadEntryExists); continue; }
v.Value.TryGetDouble(out var d).ShouldBeTrue();
_samples[t] = d;
Record(t, null, HistoryModificationType.Insert, context);
codes.Add(StatusCodes.GoodEntryInserted);
}
}
return codes;
}
public Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
public Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context,
CancellationToken ct) =>
throw new NotSupportedException();
public async Task<IReadOnlyList<uint>> DeleteAtTimeAsync(
string tag, IReadOnlyList<DateTime> timesUtc, CancellationToken ct)
string tag, IReadOnlyList<DateTime> timesUtc, HistoryUpdateContext context, CancellationToken ct)
{
await Task.Delay(1, ct).ConfigureAwait(false);
lock (_gate)
return timesUtc.Select(t => _samples.Remove(t) ? StatusCodes.Good : StatusCodes.BadNoEntryExists).ToList();
return timesUtc.Select(t =>
{
if (!_samples.Remove(t, out var prior)) return StatusCodes.BadNoEntryExists;
Record(t, prior, HistoryModificationType.Delete, context);
return StatusCodes.Good;
}).ToList();
}
public async Task<HistoryModifiedReadResult> ReadModifiedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValues, HistoryModifiedCursor? resumeAfter,
CancellationToken cancellationToken)
{
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
fullReference.ShouldBe(Tagname);
lock (_gate)
{
var rows = _trail
.Where(m => m.Value.SourceTimestampUtc >= startUtc && m.Value.SourceTimestampUtc < endUtc)
.OrderBy(m => m.Value.SourceTimestampUtc).ThenBy(m => m.ModifiedId).ToList();
return new HistoryModifiedReadResult(rows, false);
}
}
public async Task<CoreHistoryReadResult> ReadRawAsync(
@@ -463,6 +463,56 @@ public sealed class HistoryUpdateTests : IDisposable
finally { await host.DisposeAsync(); }
}
/// <summary>
/// #668: the session's API-key principal and key id reach EVERY updater call (they become the Part 11
/// ModificationInfo.UserName of the trail rows the backend records).
/// </summary>
[Fact]
public async Task The_key_principal_and_key_id_are_threaded_to_every_updater_call()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var updater = new FakeUpdater();
nm.HistorianDataUpdater = updater;
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate);
Update(nm, identity, nodeId, PerformUpdateType.Update, Value(T1, 1d));
Call(nm, identity, typeof(DeleteRawModifiedDetails),
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false });
Call(nm, identity, typeof(DeleteAtTimeDetails),
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1 } });
updater.Callers.Count.ShouldBe(3);
updater.Callers.ShouldAllBe(c => c == new HistoryUpdateContext(Principal, KeyId));
}
finally { await host.DisposeAsync(); }
}
/// <summary>#668: an admitted anonymous session is recorded as the reserved <c>anonymous</c> principal, no key id.</summary>
[Fact]
public async Task An_anonymous_session_is_recorded_as_the_anonymous_principal()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var updater = new FakeUpdater();
nm.HistorianDataUpdater = updater;
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
var (_, errors) = Insert(nm, AnonymousIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
updater.Callers.ShouldHaveSingleItem().ShouldBe(new HistoryUpdateContext(OpcUaPrincipals.Anonymous, null));
}
finally { await host.DisposeAsync(); }
}
/// <summary>DeleteAtTime returns the updater's per-time codes in order.</summary>
[Fact]
public async Task Delete_at_time_passes_per_time_codes_through()
@@ -593,6 +643,7 @@ public sealed class HistoryUpdateTests : IDisposable
private sealed class FakeUpdater : IHistorianDataUpdater
{
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; } = [];
public List<IReadOnlyList<DateTime>> DeleteAtTimeCalls { get; } = [];
public uint[]? UpdateCodes { get; init; }
@@ -601,26 +652,28 @@ public sealed class HistoryUpdateTests : IDisposable
public Exception? Throw { get; init; }
public async Task<IReadOnlyList<uint>> UpdateDataAsync(
string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, CancellationToken ct)
string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context,
CancellationToken ct)
{
await Task.Delay(1, ct).ConfigureAwait(false);
if (Throw is not null) throw Throw;
lock (UpdateCalls) UpdateCalls.Add((tag, mode, values.ToList()));
lock (UpdateCalls) { UpdateCalls.Add((tag, mode, values.ToList())); Callers.Add(context); }
return UpdateCodes ?? Enumerable.Repeat(StatusCodes.GoodEntryInserted, values.Count).ToArray();
}
public async Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken ct)
public async Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context,
CancellationToken ct)
{
await Task.Delay(1, ct).ConfigureAwait(false);
lock (DeleteRawCalls) DeleteRawCalls.Add((tag, startUtc, endUtc));
lock (DeleteRawCalls) { DeleteRawCalls.Add((tag, startUtc, endUtc)); lock (UpdateCalls) Callers.Add(context); }
return DeleteRawCode;
}
public async Task<IReadOnlyList<uint>> DeleteAtTimeAsync(
string tag, IReadOnlyList<DateTime> timesUtc, CancellationToken ct)
string tag, IReadOnlyList<DateTime> timesUtc, HistoryUpdateContext context, CancellationToken ct)
{
await Task.Delay(1, ct).ConfigureAwait(false);
lock (DeleteAtTimeCalls) DeleteAtTimeCalls.Add(timesUtc.ToList());
lock (DeleteAtTimeCalls) { DeleteAtTimeCalls.Add(timesUtc.ToList()); lock (UpdateCalls) Callers.Add(context); }
return DeleteAtTimeCodes ?? Enumerable.Repeat(StatusCodes.Good, timesUtc.Count).ToArray();
}
}
@@ -89,6 +89,33 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
finally { await host.DisposeAsync(); }
}
/// <summary>
/// #668: a modified-value read (<c>IsReadModified = true</c>) is gated by the SAME HistoryRead decision — no new
/// operation. Denied without the bit; the granted principal reaches the backend (Null ⇒ unsupported, which is
/// "allowed but no trail", a different fact from the denial).
/// </summary>
[Fact]
public async Task Modified_history_read_is_gated_by_the_history_read_grant()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.HistorianDataSource = NullHistorianDataSource.Instance;
var nodeId = Historized(nm, "Plant/Modbus/dev1/Speed");
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.Read));
var (deniedResults, deniedErrors) = ReadRaw(nm, Identity(), modified: true, nodeId);
deniedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
deniedResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.HistoryRead));
var (_, allowedErrors) = ReadRaw(nm, Identity(), modified: true, nodeId);
allowedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
}
finally { await host.DisposeAsync(); }
}
/// <summary>A granted principal reads normally — the gate is not simply denying everything.</summary>
[Fact]
public async Task History_read_succeeds_for_a_granted_principal()
@@ -308,7 +335,11 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
}
private static (List<SdkHistoryReadResult> Results, List<ServiceResult> Errors) ReadRaw(
OtOpcUaNodeManager nm, IUserIdentity? identity, params NodeId[] nodeIds)
OtOpcUaNodeManager nm, IUserIdentity? identity, params NodeId[] nodeIds) =>
ReadRaw(nm, identity, modified: false, nodeIds);
private static (List<SdkHistoryReadResult> Results, List<ServiceResult> Errors) ReadRaw(
OtOpcUaNodeManager nm, IUserIdentity? identity, bool modified, params NodeId[] nodeIds)
{
var context = new OperationContext(
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity);
@@ -324,7 +355,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
NumValuesPerNode = 10,
IsReadModified = false,
IsReadModified = modified,
},
TimestampsToReturn.Both,
releaseContinuationPoints: false,
@@ -0,0 +1,308 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Gitea #668 — OPC UA Part 11 modified-value history: HistoryReadRaw with <c>IsReadModified = true</c> dispatches
/// to <see cref="IHistorianDataSource.ReadModifiedAsync"/>, projects a <see cref="HistoryModifiedData"/> with one
/// <see cref="ModificationInfo"/> per value, and pages with continuation points keyed on
/// <c>(SourceTimeUtc, ModifiedId)</c>. Same harness as <see cref="NodeManagerHistoryReadEventsPagingTests"/>: a real
/// SDK server, a session-less in-process HistoryRead and an <see cref="InMemoryHistoryContinuationStore"/>, over a
/// fake that behaves like the SQL backends (ordered by key, half-open window, cap + exact truncation flag).
/// </summary>
public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
{
private const string Tag = "WW.Mod";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static readonly DateTime Epoch = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-modified-{Guid.NewGuid():N}");
[Fact]
public async Task A_modified_read_dispatches_to_the_trail_and_projects_modification_infos()
{
var modifiedAt = Epoch.AddDays(2);
var trail = new List<HistoryModifiedValue>
{
Mod(1, seconds: 10, prior: null, HistoryModificationType.Insert, "alice", modifiedAt),
Mod(2, seconds: 10, prior: 1.5, HistoryModificationType.Replace, "bob", modifiedAt.AddMinutes(1)),
Mod(3, seconds: 20, prior: 7.0, HistoryModificationType.Delete, "carol", modifiedAt.AddMinutes(2)),
};
var (host, nm, node, src) = await SetupAsync(trail);
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 100, cp: null);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
r.Result.StatusCode.Code.ShouldBe(StatusCodes.Good);
r.Cp.ShouldBeNull();
src.LastTag.ShouldBe(Tag);
src.LastStart.ShouldBe(Epoch);
src.LastEnd.ShouldBe(Epoch.AddHours(1));
src.LastMax.ShouldBe(100u);
src.LastCursor.ShouldBeNull();
var data = r.Data!;
data.DataValues.Count.ShouldBe(3);
data.ModificationInfos.Count.ShouldBe(3);
data.ModificationInfos.Select(m => m.UpdateType)
.ShouldBe([HistoryUpdateType.Insert, HistoryUpdateType.Replace, HistoryUpdateType.Delete]);
data.ModificationInfos.Select(m => m.UserName).ShouldBe(["alice", "bob", "carol"]);
data.ModificationInfos.Select(m => m.ModificationTime)
.ShouldBe([modifiedAt, modifiedAt.AddMinutes(1), modifiedAt.AddMinutes(2)]);
data.DataValues[0].Value.ShouldBeNull();
data.DataValues[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData);
data.DataValues[1].Value.ShouldBe(1.5);
data.DataValues[1].SourceTimestamp.ShouldBe(Epoch.AddSeconds(10));
data.DataValues[2].Value.ShouldBe(7.0);
await host.DisposeAsync();
}
[Fact]
public async Task An_empty_trail_is_GoodNoData()
{
var (host, nm, node, _) = await SetupAsync([]);
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
r.Result.StatusCode.Code.ShouldBe(StatusCodes.GoodNoData);
r.Data!.ModificationInfos.ShouldBeEmpty();
await host.DisposeAsync();
}
[Fact]
public async Task Modified_reads_page_exactly_once_in_order_even_through_same_time_runs()
{
// 30 modifications; ids 5..9 all share one source time, so a page boundary cuts through the run.
var trail = Enumerable.Range(1, 30)
.Select(i => Mod(i, seconds: i is >= 5 and <= 9 ? 5 : i, prior: i, HistoryModificationType.Replace, "u", Epoch))
.ToList();
var (host, nm, node, src) = await SetupAsync(trail);
var ids = Drain(nm, node, Epoch, Epoch.AddHours(1), max: 4);
ids.ShouldBe(src.Ordered(reverse: false).Select(m => m.ModifiedId));
ids.Count.ShouldBe(30);
src.Calls.ShouldBe(8); // 7 full pages + a 2-value tail; the exact HasMore flag means no trailing empty page
await host.DisposeAsync();
}
[Fact]
public async Task A_reverse_modified_read_pages_descending()
{
var trail = Enumerable.Range(1, 11).Select(i => Mod(i, seconds: i / 2, prior: i, HistoryModificationType.Delete, "u", Epoch)).ToList();
var (host, nm, node, src) = await SetupAsync(trail);
var ids = Drain(nm, node, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 3);
ids.ShouldBe(src.Ordered(reverse: true).Select(m => m.ModifiedId));
ids.First().ShouldBe(11);
await host.DisposeAsync();
}
[Fact]
public async Task An_open_ended_forward_modified_read_pages_to_the_end()
{
var trail = Enumerable.Range(1, 9).Select(i => Mod(i, seconds: i, prior: i, HistoryModificationType.Update, "u", Epoch)).ToList();
var (host, nm, node, src) = await SetupAsync(trail);
Drain(nm, node, Epoch, DateTime.MinValue, max: 4).ShouldBe(src.Ordered(reverse: false).Select(m => m.ModifiedId));
src.LastEnd.ShouldBe(new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc));
await host.DisposeAsync();
}
[Fact]
public async Task An_unbounded_modified_read_never_pages()
{
var trail = Enumerable.Range(1, 50).Select(i => Mod(i, seconds: i, prior: i, HistoryModificationType.Update, "u", Epoch)).ToList();
var (host, nm, node, _) = await SetupAsync(trail);
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 0, cp: null);
r.Data!.DataValues.Count.ShouldBe(50);
r.Cp.ShouldBeNull();
await host.DisposeAsync();
}
[Fact]
public async Task Cursors_are_bound_to_the_read_kind_and_the_node()
{
var trail = Enumerable.Range(1, 10).Select(i => Mod(i, seconds: i, prior: i, HistoryModificationType.Update, "u", Epoch)).ToList();
var (host, nm, node, _) = await SetupAsync(trail);
// A RAW cursor for the same tag is not a modified cursor.
var raw = nm.HistoryContinuationStore.Save(null, new HistoryContinuationState(Tag, Epoch, Epoch.AddDays(1), 0, 4));
Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: raw).Error.StatusCode.Code
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
// A MODIFIED cursor presented to a raw read is invalid too.
var p1 = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null);
p1.Cp.ShouldNotBeNull();
Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: p1.Cp, modified: false).Error.StatusCode.Code
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
// A modified cursor issued for one node is invalid on another node's tag.
var p2 = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null);
nm.EnsureVariable("eq-1/other", null, "Other", "Double", false, historianTagname: "WW.Other", realm: AddressSpaceRealm.Uns);
var other = nm.TryGetVariable("eq-1/other")!.NodeId;
Read(nm, other, Epoch, Epoch.AddHours(1), max: 4, cp: p2.Cp).Error.StatusCode.Code
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
await host.DisposeAsync();
}
[Fact]
public async Task A_backend_failure_is_isolated_to_its_node()
{
var (host, nm, node, src) = await SetupAsync([Mod(1, 1, 1, HistoryModificationType.Update, "u", Epoch)]);
src.Throw = new InvalidOperationException("boom");
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
await host.DisposeAsync();
}
// --- helpers ---------------------------------------------------------------------------------
private static HistoryModifiedValue Mod(
long id, int seconds, double? prior, HistoryModificationType type, string user, DateTime modifiedAt)
{
var t = Epoch.AddSeconds(seconds);
var value = prior is null
? new DataValueSnapshot(null, StatusCodes.BadNoData, t, modifiedAt)
: new DataValueSnapshot(prior.Value, StatusCodes.Good, t, t);
return new HistoryModifiedValue(value, modifiedAt, type, user, id);
}
private static List<long> Drain(OtOpcUaNodeManager nm, NodeId node, DateTime start, DateTime end, uint max)
{
var ids = new List<long>();
byte[]? cp = null;
var pages = 0;
do
{
var r = Read(nm, node, start, end, max, cp);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good, r.Error.ToString());
ids.AddRange(r.Ids);
cp = r.Cp;
(++pages).ShouldBeLessThan(60, "paging must terminate");
}
while (cp is not null);
return ids;
}
private static (ServiceResult Error, SdkHistoryReadResult Result, HistoryModifiedData? Data, byte[]? Cp, List<long> Ids) Read(
OtOpcUaNodeManager nm, NodeId node, DateTime start, DateTime end, uint max, byte[]? cp, bool modified = true)
{
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
var results = new List<SdkHistoryReadResult> { null! };
var errors = new List<ServiceResult> { null! };
nm.HistoryRead(context,
new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, IsReadModified = modified },
TimestampsToReturn.Both, false,
new List<HistoryReadValueId> { new() { NodeId = node, ContinuationPoint = cp } }, results, errors);
HistoryModifiedData? data = null;
var ids = new List<long>();
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)
&& ExtensionObject.ToEncodeable(ho) is HistoryModifiedData hm)
{
data = hm;
// The fake stores each modification's id in its (unique) server timestamp offset — see SqlLikeTrail.
ids.AddRange(hm.DataValues.Select(v => SqlLikeTrail.IdOf(v)));
}
return (errors[0], results[0], data, results[0]?.ContinuationPoint, ids);
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId Node, SqlLikeTrail Src)> SetupAsync(
List<HistoryModifiedValue> trail)
{
var started = await OpcUaHostStart.StartAsync(
port => new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.ModifiedHistoryTest",
ApplicationUri = $"urn:OtOpcUa.ModifiedHistoryTest:{Guid.NewGuid():N}",
OpcUaPort = port,
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance),
() => new OtOpcUaSdkServer(), Ct);
var nm = started.Server.NodeManager!;
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
var src = new SqlLikeTrail(trail);
nm.HistorianDataSource = src;
nm.EnsureVariable("eq-1/mod", null, "Mod", "Double", false, historianTagname: Tag, realm: AddressSpaceRealm.Uns);
return (started.Host, nm, nm.TryGetVariable("eq-1/mod")!.NodeId, src);
}
/// <summary>
/// Behaves like the SqlServer/QuestDB modified reads: ordered by <c>(SourceTimeUtc, ModifiedId)</c> (descending
/// when reversed), half-open window, exclusive cursor, cap + exact <see cref="HistoryModifiedReadResult.HasMore"/>.
/// To let the test recover each projected value's id, the server timestamp handed out is
/// <c>Epoch + id ticks</c> (an otherwise unused field in these tests).
/// </summary>
private sealed class SqlLikeTrail(List<HistoryModifiedValue> trail) : IHistorianDataSource
{
public string? LastTag { get; private set; }
public DateTime LastStart { get; private set; }
public DateTime LastEnd { get; private set; }
public uint LastMax { get; private set; }
public HistoryModifiedCursor? LastCursor { get; private set; }
public int Calls { get; private set; }
public Exception? Throw { get; set; }
public static long IdOf(DataValue v) => v.ServerTimestamp.Ticks - Epoch.Ticks;
public IEnumerable<HistoryModifiedValue> Ordered(bool reverse) => reverse
? trail.OrderByDescending(m => m.Value.SourceTimestampUtc).ThenByDescending(m => m.ModifiedId)
: trail.OrderBy(m => m.Value.SourceTimestampUtc).ThenBy(m => m.ModifiedId);
public async Task<HistoryModifiedReadResult> ReadModifiedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValues, HistoryModifiedCursor? resumeAfter,
CancellationToken cancellationToken)
{
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
if (Throw is not null) throw Throw;
Calls++;
(LastTag, LastStart, LastEnd, LastMax, LastCursor) = (fullReference, startUtc, endUtc, maxValues, resumeAfter);
var reverse = startUtc > endUtc;
var (lo, hi) = reverse ? (endUtc, startUtc) : (startUtc, endUtc);
var rows = Ordered(reverse).Where(m =>
{
var t = m.Value.SourceTimestampUtc!.Value;
var inWindow = startUtc == endUtc ? t == lo : reverse ? t > lo && t <= hi : t >= lo && t < hi;
if (!inWindow) return false;
if (resumeAfter is null) return true;
var c = t.CompareTo(resumeAfter.SourceTimeUtc);
return reverse ? c < 0 || (c == 0 && m.ModifiedId < resumeAfter.ModifiedId)
: c > 0 || (c == 0 && m.ModifiedId > resumeAfter.ModifiedId);
}).Select(m => m with { Value = m.Value with { ServerTimestampUtc = Epoch.AddTicks(m.ModifiedId) } }).ToList();
var take = maxValues == 0 ? rows.Count : (int)Math.Min(maxValues, (uint)rows.Count);
return new HistoryModifiedReadResult(rows.Take(take).ToList(), rows.Count > take);
}
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(
string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadEventsAsync(n, s, e, m, ct);
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
public void Dispose() { }
}
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best effort */ }
}
}
}
@@ -175,10 +175,13 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
await host.DisposeAsync();
}
/// <summary>Raw with IsReadModified=true ⇒ BadHistoryOperationUnsupported (we don't serve modified
/// history); the source is never invoked.</summary>
/// <summary>
/// #668: Raw with IsReadModified=true on a backend that keeps no modification trail (the recording fake has no
/// ReadModifiedAsync override, like Gateway) ⇒ BadHistoryOperationUnsupported, and the RAW read is never
/// invoked in its place.
/// </summary>
[Fact]
public async Task Raw_read_modified_yields_BadHistoryOperationUnsupported()
public async Task Raw_read_modified_on_a_backend_without_a_trail_yields_BadHistoryOperationUnsupported()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
@@ -197,10 +200,16 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
IsReadModified = true,
};
var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);
var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
fake.LastCall.ShouldBeNull();
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
fake.LastCall.ShouldBeNull(); // the raw read is never served in place of a modified one
// The Null default (no historian configured) answers the same.
nm.HistorianDataSource = NullHistorianDataSource.Instance;
InvokeHistoryRead(server, nm, details, nodeId).Item2[0].StatusCode.Code
.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
await host.DisposeAsync();
}