e790938f94
QuestDB modified-history reads dropped every still-pending trail row that was not the latest at its source time, so V0->V1 (applied marker never written) followed by V1->V2 hid the V0->V1 change for good. A pending row below the latest is now served when the NEXT trail row at that time recorded a different prior value (kind, value, status or the prior's server_ts) - the next change saw what this one wrote; an orphan of a change that never landed leaves the same prior for the next row and stays hidden. QuestDB window functions reject VARCHAR, so the next row's prior comes from a LEFT JOIN on (ts, lead(id)). The updater's comment and marker-failure log no longer claim modified reads "still serve it" unconditionally. Regression tests (QuestDB 10.0.1 throwaway container): marker lost then a replace / delete at the same time; an orphan followed by a marker-lost retry then a later change (orphan still dropped); the random marker-mix fuzz now varies the recorded priors. All four fail on the old filter. NITs: - SqlServerHistorianValueWriter: a SqlException raised while the token is cancelled (SqlClient's "Operation cancelled by user") is rethrown as OperationCanceledException instead of booked as a failed write. Integration test holds a TABLOCKX on Sample and cancels mid-INSERT; it returned false before. - SqlServerHistorianDataUpdater.DeleteRawBatches is an Interlocked counter. - HistoryReadEvents: an event filter whose SDK validation throws (unknown operator) failed with the generic BadUnexpectedError; the node manager now re-throws any failed event-filter validation as BadEventFilterInvalid. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
220 lines
11 KiB
C#
220 lines
11 KiB
C#
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>
|
|
/// HistoryReadEvents projects every field a historical alarm event can supply — <c>EventType</c> (the stored alarm
|
|
/// type name mapped to its Part 9 type, an unmodelled one to <c>AlarmConditionType</c>), <c>ConditionName</c>, and
|
|
/// <c>SourceNode</c> when the event's alarm id resolves to a materialised condition — and honours the request's
|
|
/// where-clause (an <c>OfType</c> by the type tree; any other operator against the projected fields). These used to
|
|
/// project <see cref="Variant.Null"/> and the where-clause was ignored.
|
|
/// </summary>
|
|
public sealed class NodeManagerHistoryReadEventsProjectionTests : IDisposable
|
|
{
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
private static readonly DateTime T0 = new(2026, 10, 22, 8, 0, 0, DateTimeKind.Utc);
|
|
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-evtproj-{Guid.NewGuid():N}");
|
|
|
|
private const string Equipment = "eq-proj";
|
|
|
|
private static readonly HistoricalEvent[] Events =
|
|
[
|
|
new("e-limit", Equipment, T0, T0, "hi", 700, EventTypeName: "LimitAlarm", ConditionName: "HighTemp", AlarmId: "alarm-1"),
|
|
new("e-offnormal", Equipment, T0.AddSeconds(1), T0, "off", 500, EventTypeName: "OffNormalAlarm", ConditionName: "Door", AlarmId: "alarm-gone"),
|
|
new("e-untyped", Equipment, T0.AddSeconds(2), T0, "x", 300),
|
|
new("e-bogus", Equipment, T0.AddSeconds(3), T0, "y", 900, EventTypeName: "Folder", ConditionName: "Odd"),
|
|
];
|
|
|
|
[Fact]
|
|
public async Task EventType_ConditionName_and_a_resolvable_SourceNode_are_projected()
|
|
{
|
|
var (host, server, nm, notifier) = await BootAsync();
|
|
var condition = nm.TryGetAlarmCondition("alarm-1")!.NodeId;
|
|
|
|
var history = Read(server, nm, notifier, Select("EventId", "EventType", "ConditionName", "SourceNode"), where: null);
|
|
|
|
var rows = history.Events.ToDictionary(e => System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value));
|
|
rows["e-limit"].EventFields[1].Value.ShouldBe(ObjectTypeIds.LimitAlarmType);
|
|
rows["e-limit"].EventFields[2].Value.ShouldBe("HighTemp");
|
|
rows["e-limit"].EventFields[3].Value.ShouldBe(condition, "the alarm id resolves to the materialised condition — the live events' SourceNode");
|
|
|
|
rows["e-offnormal"].EventFields[1].Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
|
|
rows["e-offnormal"].EventFields[3].Value.ShouldBeNull("no condition is materialised for that alarm id");
|
|
|
|
// No stored type: still an alarm condition (every historical row is alarm history); an unrecognised or non-event
|
|
// type name is not passed through as a non-event type.
|
|
rows["e-untyped"].EventFields[1].Value.ShouldBe(ObjectTypeIds.AlarmConditionType);
|
|
rows["e-untyped"].EventFields[2].Value.ShouldBeNull();
|
|
rows["e-bogus"].EventFields[1].Value.ShouldBe(ObjectTypeIds.AlarmConditionType);
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_OfType_where_clause_keeps_only_events_of_that_type_or_a_subtype()
|
|
{
|
|
var (host, server, nm, notifier) = await BootAsync();
|
|
|
|
Ids(Read(server, nm, notifier, Select("EventId"), OfType(ObjectTypeIds.LimitAlarmType))).ShouldBe(["e-limit"]);
|
|
Ids(Read(server, nm, notifier, Select("EventId"), OfType(ObjectTypeIds.OffNormalAlarmType))).ShouldBe(["e-offnormal"]);
|
|
// A supertype keeps its subtypes: every row is an AlarmConditionType (positive control — the filter is not
|
|
// simply dropping everything typed).
|
|
Ids(Read(server, nm, notifier, Select("EventId"), OfType(ObjectTypeIds.AlarmConditionType)))
|
|
.ShouldBe(["e-limit", "e-offnormal", "e-untyped", "e-bogus"]);
|
|
// An unrelated event type keeps nothing (GoodNoData).
|
|
Ids(Read(server, nm, notifier, Select("EventId"), OfType(ObjectTypeIds.AuditEventType))).ShouldBeEmpty();
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_field_where_clause_is_evaluated_against_the_projected_fields()
|
|
{
|
|
var (host, server, nm, notifier) = await BootAsync();
|
|
var where = new ContentFilter();
|
|
where.Push(FilterOperator.GreaterThan,
|
|
new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("Severity")) { AttributeId = Attributes.Value },
|
|
new LiteralOperand(new Variant((ushort)600)));
|
|
|
|
Ids(Read(server, nm, notifier, Select("EventId"), where)).ShouldBe(["e-limit", "e-bogus"]);
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("unknown operator")]
|
|
[InlineData("self-referencing element")]
|
|
[InlineData("operand-count mismatch")]
|
|
public async Task An_invalid_where_clause_is_BadEventFilterInvalid(string fault)
|
|
{
|
|
// lmxopcua#778: an unknown operator made the SDK's pre-validation itself throw, failing the read with the generic
|
|
// BadUnexpectedError. The structural faults were already BadEventFilterInvalid and must stay so.
|
|
var (host, server, nm, notifier) = await BootAsync();
|
|
var where = new ContentFilter();
|
|
switch (fault)
|
|
{
|
|
case "unknown operator":
|
|
where.Push((FilterOperator)99, new LiteralOperand(new Variant(1)), new LiteralOperand(new Variant(1)));
|
|
break;
|
|
case "self-referencing element":
|
|
where.Push(FilterOperator.Not, new ElementOperand(0));
|
|
break;
|
|
default:
|
|
where.Push(FilterOperator.Equals, new LiteralOperand(new Variant(1)));
|
|
break;
|
|
}
|
|
|
|
var ex = Should.Throw<ServiceResultException>(() => ReadStatus(server, nm, notifier, Select("EventId"), where));
|
|
ex.StatusCode.ShouldBe(StatusCodes.BadEventFilterInvalid);
|
|
|
|
// Positive control: the same read with a valid where-clause is served.
|
|
var valid = new ContentFilter();
|
|
valid.Push(FilterOperator.Equals, new LiteralOperand(new Variant(1)), new LiteralOperand(new Variant(1)));
|
|
StatusCode.IsGood(ReadStatus(server, nm, notifier, Select("EventId"), valid).Status).ShouldBeTrue();
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
private static string[] Ids(HistoryEvent h) =>
|
|
h.Events.Select(e => System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value)).ToArray();
|
|
|
|
private static ContentFilter OfType(NodeId type)
|
|
{
|
|
var f = new ContentFilter();
|
|
f.Push(FilterOperator.OfType, new LiteralOperand(new Variant(type)));
|
|
return f;
|
|
}
|
|
|
|
private static SimpleAttributeOperandCollection Select(params string[] leaves)
|
|
{
|
|
var c = new SimpleAttributeOperandCollection();
|
|
foreach (var leaf in leaves)
|
|
c.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(leaf)) { AttributeId = Attributes.Value });
|
|
return c;
|
|
}
|
|
|
|
private static HistoryEvent Read(OtOpcUaSdkServer server, OtOpcUaNodeManager nm, NodeId notifier,
|
|
SimpleAttributeOperandCollection select, ContentFilter? where)
|
|
{
|
|
var (status, result) = ReadStatus(server, nm, notifier, select, where);
|
|
StatusCode.IsGood(status).ShouldBeTrue($"HistoryRead failed: {status}");
|
|
return (HistoryEvent)ExtensionObject.ToEncodeable(result.HistoryData);
|
|
}
|
|
|
|
private static (StatusCode Status, SdkHistoryReadResult Result) ReadStatus(OtOpcUaSdkServer server, OtOpcUaNodeManager nm,
|
|
NodeId notifier, SimpleAttributeOperandCollection select, ContentFilter? where)
|
|
{
|
|
var details = new ReadEventDetails
|
|
{
|
|
StartTime = T0.AddHours(-1),
|
|
EndTime = T0.AddHours(1),
|
|
NumValuesPerNode = 100,
|
|
Filter = new EventFilter { SelectClauses = select, WhereClause = where ?? new ContentFilter() },
|
|
};
|
|
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, details, TimestampsToReturn.Both, releaseContinuationPoints: false,
|
|
new List<HistoryReadValueId> { new() { NodeId = notifier } }, results, errors);
|
|
return (errors[0].StatusCode, results[0]);
|
|
}
|
|
|
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server, OtOpcUaNodeManager Nm, NodeId Notifier)> BootAsync()
|
|
{
|
|
var started = await OpcUaHostStart.StartAsync(
|
|
port => new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.HistoryReadEventsProjectionTest",
|
|
ApplicationUri = $"urn:OtOpcUa.HistoryReadEventsProjectionTest:{Guid.NewGuid():N}",
|
|
OpcUaPort = port,
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance),
|
|
() => new OtOpcUaSdkServer(), Ct);
|
|
var nm = started.Server.NodeManager!;
|
|
nm.HistorianDataSource = new FixedEventsSource(Events);
|
|
nm.EnsureFolder(Equipment, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
|
nm.MaterialiseAlarmCondition("alarm-1", Equipment, "HighTemp", "LimitAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
|
return (started.Host, started.Server, nm, nm.TryGetFolder(Equipment)!.NodeId);
|
|
}
|
|
|
|
private sealed class FixedEventsSource(IReadOnlyList<HistoricalEvent> events) : IHistorianDataSource
|
|
{
|
|
public Task<HistorianRead> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
|
CancellationToken cancellationToken) =>
|
|
NullHistorianDataSource.Instance.ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, cancellationToken);
|
|
|
|
public Task<HistorianRead> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
|
HistoryAggregateType aggregate, CancellationToken cancellationToken) =>
|
|
NullHistorianDataSource.Instance.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);
|
|
|
|
public Task<HistorianRead> ReadAtTimeAsync(string fullReference, IReadOnlyList<DateTime> timestampsUtc,
|
|
CancellationToken cancellationToken) =>
|
|
NullHistorianDataSource.Instance.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);
|
|
|
|
public Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
|
CancellationToken cancellationToken) =>
|
|
Task.FromResult(new HistoricalEventsResult(events.Where(e => e.EventTimeUtc >= startUtc && e.EventTimeUtc < endUtc).ToList(), null));
|
|
|
|
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 cleanup */ }
|
|
}
|
|
}
|
|
}
|