fix(history): a folder's event history includes its object's attribute alarms (#882)
v2-ci / unit-tests (push) Has been cancelled
v2-ci / unit-tests (push) Has been cancelled
HistorianGateway answers a per-source ReadEvents (Source_Object = DevWinPlatform) with the object's own events (SourceName DevWinPlatform) AND its attribute alarms, which it reports as <Object>.<Attribute> (Source_ProcessVariable, e.g. DevWinPlatform.CPULoadAvg). The Gateway backend's defensive client-side source filter compared SourceName for equality, so it dropped the attribute alarms: 70 of the 72 DevWinPlatform events on vd03. The filter (and the past-the-cap "more events" signal) now uses EventSourceMatch: the source itself, or <source>.<anything> on the '.' boundary, ordinal. DevWinPlatform never matches DevWinPlatform2 or DevWinPlatform2.X. This needs no wire change and still makes one request. Each event keeps its own SourceName. SourceNode stays the condition its stored alarm id names, which is null for AVEVA rows. The attribute events ride the notifier's own read, so the per-folder HistoryRead verdict (#826) governs them: a denied folder answers BadUserAccessDenied before any backend call. The SqlServer and QuestDB backends still match exactly; they hold only OtOpcUa's own events. Tests: - GatewayAttributeEventSourceTests (16): the boundary theory, attribute-in/sibling-out through the data source, the drain signal, and an exact attribute-named read. - AttributeEventHistoryWireTests (2, over a real SDK session with the production Gateway backend on an AVEVA-shaped deterministic gateway): object + attribute events with their own SourceName, a prefix sibling excluded, paging, and the ACL-on deny with no backend call plus a granted positive control. - Mutation-checked: exact match fails both wire tests; dropping the '.' boundary fails the wire sibling check and 6 unit tests. - Live AVEVA harness re-run against vd03 HistorianGateway ea9f21c (read-only, no WinRM): folder DevWinPlatform 2 -> 72 events (whole, 72 1-event pages, reverse); the new #882 check passes; 221 of 225 shared cases are identical to the #801 SDK2 baseline, and the 4 that differ are exactly the DevWinPlatform event cases. Recorded in aveva-history-801.md. Follow-up #886: production folders name RawPaths/EquipmentIds, not Galaxy objects, plus the AVEVA severity/condition property mapping. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
@@ -135,7 +135,11 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// This surface declares <see cref="ReadAtTimeAsync(string, IReadOnlyList{DateTime}, CancellationToken)"/> and <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>
|
||||
/// as required members — a server-side historian owns the full read surface.
|
||||
/// </remarks>
|
||||
/// <param name="sourceName">The source name to filter events, or null to return events from all sources.</param>
|
||||
/// <param name="sourceName">
|
||||
/// The source name to filter events, or null to return events from all sources. The Gateway backend also returns
|
||||
/// the events of the source's attributes, reported as <c><source>.<attribute></c> (AVEVA's attribute
|
||||
/// alarms, Gitea #882); each event keeps its own <see cref="HistoricalEvent.SourceName"/>.
|
||||
/// </param>
|
||||
/// <param name="startUtc">The start of the time range in UTC.</param>
|
||||
/// <param name="endUtc">The end of the time range in UTC.</param>
|
||||
/// <param name="maxEvents">
|
||||
|
||||
+5
-3
@@ -772,7 +772,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
// rows would make the server crawl it one resolution step per page).
|
||||
if (!truncated)
|
||||
{
|
||||
if (sourceName is not null && !string.Equals(wireEvent.SourceName, sourceName, StringComparison.Ordinal))
|
||||
if (!EventSourceMatch.Matches(sourceName, wireEvent.SourceName))
|
||||
continue;
|
||||
truncated = true;
|
||||
}
|
||||
@@ -787,8 +787,10 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
var mapped = EventMapper.ToHistoricalEvent(wireEvent);
|
||||
|
||||
// Defensive client-side source filter: the gateway's SQL ReadEvents source filter
|
||||
// may not be present, so drop any event whose source does not match the request.
|
||||
if (sourceName is not null && !string.Equals(mapped.SourceName, sourceName, StringComparison.Ordinal))
|
||||
// may not be present, so drop any event whose source does not match the request. The request's own
|
||||
// events and its attributes' (<source>.<attribute>, the name the gateway reports an AVEVA attribute
|
||||
// alarm under) match; a sibling that merely shares the prefix does not (#882, EventSourceMatch).
|
||||
if (!EventSourceMatch.Matches(sourceName, mapped.SourceName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,10 @@ public interface IHistorianGatewayClient : IAsyncDisposable
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>Streams historian events over a window, optionally filtered to a single source name.</summary>
|
||||
/// <param name="sourceName">Optional source-name filter; <c>null</c> returns events from all sources.</param>
|
||||
/// <param name="sourceName">
|
||||
/// Optional source-name filter, sent as <c>Source_Object = sourceName</c>; <c>null</c> returns events from all
|
||||
/// sources. The gateway reports an attribute alarm of that object as <c><sourceName>.<attribute></c>.
|
||||
/// </param>
|
||||
/// <param name="startUtc">Inclusive start of the time window (UTC).</param>
|
||||
/// <param name="endUtc">Exclusive end of the time window (UTC).</param>
|
||||
/// <param name="maxEvents">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// Which gateway events belong to a requested event source (Gitea #882): the source's own events, and the events of
|
||||
/// its attributes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The gateway filters a per-source <c>ReadEvents</c> on AVEVA's <c>Source_Object</c>, and reports each row's
|
||||
/// <c>SourceName</c> as <c>Source_ProcessVariable</c> (else <c>Source_Object</c>). An object-level event
|
||||
/// (<c>System.Stop</c> / <c>System.Start</c>) therefore comes back named after the object, <c>DevWinPlatform</c>,
|
||||
/// while an alarm on one of the object's attributes comes back named <c><Object>.<Attribute></c>,
|
||||
/// <c>DevWinPlatform.CPULoadAvg</c>, although the gateway returned it for <c>Source_Object = DevWinPlatform</c>.
|
||||
/// Both are the object's events; live, an attribute alarm's condition fires on the same folder as the object's own
|
||||
/// events.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The match is on the <c>.</c> boundary, ordinal: <c>DevWinPlatform</c> matches <c>DevWinPlatform</c> and
|
||||
/// <c>DevWinPlatform.CPULoadAvg</c>, never <c>DevWinPlatform2</c> or <c>DevWinPlatform2.CPULoadAvg</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class EventSourceMatch
|
||||
{
|
||||
/// <summary>Whether an event reported with <paramref name="eventSource"/> belongs to <paramref name="requested"/>.</summary>
|
||||
/// <param name="requested">The requested source; <c>null</c> requests every source.</param>
|
||||
/// <param name="eventSource">The event's reported source name.</param>
|
||||
/// <returns><c>true</c> when the event is the source's own or one of its attributes'.</returns>
|
||||
public static bool Matches(string? requested, string? eventSource)
|
||||
{
|
||||
if (requested is null) return true;
|
||||
if (eventSource is null) return false;
|
||||
return eventSource.Length == requested.Length
|
||||
? string.Equals(eventSource, requested, StringComparison.Ordinal)
|
||||
: eventSource.Length > requested.Length + 1
|
||||
&& eventSource[requested.Length] == '.'
|
||||
&& eventSource.StartsWith(requested, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user