3a312ffdd1
v2-ci / unit-tests (push) Has been cancelled
A notifier folder read one event source, its NodeId identifier (a RawPath or an EquipmentId). AVEVA stores a Galaxy object's A&E history under the object's tag_name (Source_Object), so AVEVA's own alarm and event history reached no production folder. Its lower-case property keys were also missed, so every row read Severity 1 with no ConditionName. Sources. A folder's event history now reads its own source plus the Galaxy object of every native condition that fires on it live: those parented under it and those WireAlarmNotifiers wired to it. Live and history use the same membership. The object comes from the condition's Galaxy alarm reference, its raw tag's attributeRef, up to the first '.': - The composer reads it with the driver's own parser (GalaxyAttributeRef, shared with GalaxyDriver), only for a GalaxyMxGateway tag with an alarm (RawTagPlan.NativeAlarmReference, part of plan equality). - The applier binds it after materialising the condition (IOpcUaAddressSpaceSink.BindNativeAlarmHistory, forwarded by the deferred sink). A rebuild or the condition's removal forgets it. Each object is read with the #882 rule: its own events and <Object>.<Attribute> rows, never a prefix sibling. Fan-out. historiangw's ReadEvents filter is a single predicate, so there is one request per source; the wire contract is unchanged. EventSourceFanOut merges the reads as one source: - order is (time, source position, arrival), reversed for a reverse read; ties are never re-sorted by EventId, so the boundary skip stays stable; - duplicates by EventId are dropped and the merge is cut to the cap; - a truncated source bounds the page at the time it stopped. Paging, continuation points (bound to the whole source set), the walked-back reverse read and the tie-cluster over-fetch are unchanged. A folder with one source is read exactly as before. ACL. Every source is read inside the folder's own HistoryRead, after AuthorizeHistoryRead (#826). A denied folder reads nothing and fails closed. Mapping (EventMapper, property names now case-insensitive): - Severity: an AVEVA row's priority goes through GalaxyAlarmPriority, the table live Galaxy alarms use (MxAccessSeverityMapper now delegates to it), so history matches live. Otherwise severity (OtOpcUa's own rows, which AVEVA stores lower-cased), clamped. AVEVA's own 1-4 severity is ignored. The table ranks a higher priority as more severe, which inverts AVEVA's convention: filed as #887. - An AVEVA row whose source_conditionvariable names a native condition that fires on the folder takes that condition's SourceNode and live ConditionName. Otherwise ConditionName is alarmname or the condition variable's attribute. - alarm_id/alarmid map to AlarmId. Tests: - GalaxyEquipmentEventHistoryWireTests (4, real SDK session, production Gateway backend, AVEVA-shaped fake gateway): equipment EqA backed by objects Oven_007 + OvenDoor_07. Covers object and attribute events from both objects, the prefix sibling excluded, cross-object and in-object ties, forward paging at 1-4 a page, reverse whole and paged, SourceNode/ConditionName/Severity, the raw device folder, ACL deny with no backend call plus a granted positive control, and a rebuild forgetting the bindings. - EventSourceFanOutTests (8), EventMapper AVEVA cases (11), GalaxyAttributeRefTests (16), a composer test and an applier ordering test. - Mutation-checked, 17 mutants, all killed. Covered: parent-only membership, no scope, a case-sensitive condition match, no condition rewrite, no truncation bound, EventId tie order, no sort, no cap, no stop at the bound, unreversed source order, no dedupe, no '.' boundary, priority ignored, case-sensitive keys, no applier bind, no deferred forward, and a composer that ignores the driver type. The forget-on-rematerialise guard survives as belt-and-braces: the rebuild clear covers it. - Live, read-only, no WinRM, vd03 HistorianGateway ea9f21c: equipment folder EQ-886-ZipsenLeft (objects DevWinPlatform + Left_001). It read 0 events before and 92 now, equal to the unfiltered read's rows for both objects. 1/7-event pages and reverse whole/5-event pages agree, and 73 rows resolve to their live condition. diff_runs vs the #882 run: 221/226 identical. The 5 that differ are expected: DevWinPlatform Severity 1 -> 750/1000, and #881 capabilities. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
78 lines
5.2 KiB
C#
78 lines
5.2 KiB
C#
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
/// <summary>One walked-back reverse events page (<see cref="ReverseEventsReader"/>).</summary>
|
|
/// <param name="Events">At most the requested count, newest first by <c>(EventTimeUtc, EventId)</c>.</param>
|
|
/// <param name="Hint">What the next page of the same read resumes with.</param>
|
|
internal readonly record struct ReverseEventsPage(List<HistoricalEvent> Events, ReverseWalkHint Hint);
|
|
|
|
/// <summary>
|
|
/// Serves a Part 11 reverse events read — <c>(lo, hi]</c> newest-first — on a backend that reads events
|
|
/// forward only (<see cref="IHistorianDataSource.SupportsReverseEvents"/> = <c>false</c>: the Gateway) with the
|
|
/// <see cref="ReverseWalk"/>. A segment read is cut short when the source flags truncation (its continuation point); ties
|
|
/// are ordered by <see cref="EventIdTieOrder"/> because the gateway orders by event time only.
|
|
/// </summary>
|
|
internal static class ReverseEventsReader
|
|
{
|
|
/// <summary>Reads <c>(lo, hi]</c> newest-first from forward event reads.</summary>
|
|
/// <param name="source">A backend that reads events forward.</param>
|
|
/// <param name="sourceName">Event-source filter (null = all).</param>
|
|
/// <param name="hi">The later (inclusive) bound.</param>
|
|
/// <param name="lo">The earlier (exclusive) bound.</param>
|
|
/// <param name="want">How many events; <c>0</c> = all in the window (one forward read).</param>
|
|
/// <param name="maxTieCluster">The tie budget (<c>MaxTieClusterOverfetch</c>) — a ceiling only.</param>
|
|
/// <param name="nowUtc">Now, for the look-ahead clamp.</param>
|
|
/// <param name="hint">What the previous page carried (default = none).</param>
|
|
/// <param name="ct">Cancellation.</param>
|
|
/// <returns>The events, newest first, and the hint for the next page.</returns>
|
|
/// <exception cref="HistoryReadLimitExceededException">One timestamp holds more than the tie budget.</exception>
|
|
public static Task<ReverseEventsPage> ReadAsync(
|
|
IHistorianDataSource source, string? sourceName, DateTime hi, DateTime lo, uint want, int maxTieCluster,
|
|
DateTime nowUtc, ReverseWalkHint hint, CancellationToken ct) =>
|
|
ReadAsync(
|
|
(start, end, cap, token) => source.ReadEventsAsync(sourceName, start, end, cap, reverse: false, token),
|
|
source.TimestampResolutionTicks, sourceName, hi, lo, want, maxTieCluster, nowUtc, hint, ct);
|
|
|
|
/// <summary>
|
|
/// Reads <c>(lo, hi]</c> newest-first from a forward event reader — the form a folder with several event sources
|
|
/// uses (<see cref="EventSourceFanOut"/>, Gitea #886), whose merged reads stand in for one source's.
|
|
/// </summary>
|
|
/// <param name="readForward">Reads <c>[start, end]</c> forward with a cap (<c>int.MaxValue</c> = none).</param>
|
|
/// <param name="resolutionTicks">The backend's timestamp resolution.</param>
|
|
/// <param name="sourceName">The source (or source set) named in a limit message.</param>
|
|
/// <param name="hi">The later (inclusive) bound.</param>
|
|
/// <param name="lo">The earlier (exclusive) bound.</param>
|
|
/// <param name="want">How many events; <c>0</c> = all in the window (one forward read).</param>
|
|
/// <param name="maxTieCluster">The tie budget (<c>MaxTieClusterOverfetch</c>) — a ceiling only.</param>
|
|
/// <param name="nowUtc">Now, for the look-ahead clamp.</param>
|
|
/// <param name="hint">What the previous page carried (default = none).</param>
|
|
/// <param name="ct">Cancellation.</param>
|
|
/// <returns>The events, newest first, and the hint for the next page.</returns>
|
|
/// <exception cref="HistoryReadLimitExceededException">One timestamp holds more than the tie budget.</exception>
|
|
public static async Task<ReverseEventsPage> ReadAsync(
|
|
Func<DateTime, DateTime, int, CancellationToken, Task<HistoricalEventsResult>> readForward, long resolutionTicks,
|
|
string? sourceName, DateTime hi, DateTime lo, uint want, int maxTieCluster, DateTime nowUtc, ReverseWalkHint hint,
|
|
CancellationToken ct)
|
|
{
|
|
var budget = Math.Max(1, maxTieCluster);
|
|
var (rows, next) = await ReverseWalk.WalkAsync<HistoricalEvent>(
|
|
async (start, end, cap, token) =>
|
|
{
|
|
// cap 0 = the whole window: int.MaxValue is the events contract's "no cap" (a non-positive value would
|
|
// mean the backend's DEFAULT cap).
|
|
var r = await readForward(start, end, cap == 0 ? int.MaxValue : cap, token).ConfigureAwait(false);
|
|
return new ForwardSegmentRead<HistoricalEvent>(r.Events, r.ContinuationPoint is not null);
|
|
},
|
|
ev => ev.EventTimeUtc.Ticks, EventIdTieOrder.Instance, hi, lo, want, budget, resolutionTicks,
|
|
nowUtc, hint,
|
|
at => new HistoryReadLimitExceededException(budget,
|
|
$"A reverse events read of '{sourceName}' at {at:O} found more than {budget} events within one millisecond; the " +
|
|
"backend reads forward only, so the latest of them cannot be selected. Increase " +
|
|
"ServerHistorian:MaxTieClusterOverfetch."),
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
return new ReverseEventsPage(rows, next);
|
|
}
|
|
}
|