R1.7: server-side event filters — ReadEventsAsync(HistorianEventFilter), live-honored
Roadmap M1 R1.7. Filters are set on the native EventQuery object via AddEventFilter(property, HistorianComparisionType, value) — NOT EventQueryArgs (time/count/order only). Found via a new harness --dump-type-members command. Captured the native filtered StartEventQuery pRequestBuff (Capture-EventFilter.ps1 + harness --event-filter knob) and diffed Equal(0) vs Contains(12) to isolate the operator field. Filter block (decoded byte-for-byte): ushort 0 + uint filterCount + uint condCount + uint nameLen + name(UTF-16) + uint 1 + ushort op + uint 1 + value(0x09-LEN-0x00 compact-ASCII) + byte 0 The filter is REAL, not inert (unlike the analog-summary knobs): a non-matching predicate returns 0 events; Type=Equal=User.Write returns only User.Write events. Verified live via both the native harness and the SDK. - HistorianClient.ReadEventsAsync(start, end, HistorianEventFilter, ct) overload - HistorianEventFilter + HistorianEventComparison (18 ops, ordinals = native) - Filter encoding in HistorianEventQueryProtocol (empty-filter path unchanged) - Golden-byte tests (block match, op field, empty-filter regression) + gated live test Single string-valued predicate only; multi-filter (OR) / multi-condition (AND via AddEventFilterCondition) framing is partially captured and not shipped. 216 unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
@@ -90,7 +90,25 @@ public sealed class HistorianClient : IAsyncDisposable
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateTimeRange(startUtc, endUtc);
|
||||
return _protocol.ReadEventsAsync(startUtc, endUtc, cancellationToken);
|
||||
return _protocol.ReadEventsAsync(startUtc, endUtc, filter: null, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads events in the time window, server-filtered by a single predicate
|
||||
/// (<paramref name="filter"/>) — e.g. <c>Type Equal "User.Write"</c> or
|
||||
/// <c>Area Contains "Tank"</c>. The historian applies the filter and returns only matching
|
||||
/// events. Filtering is a real server-side operation (live-verified: a non-matching predicate
|
||||
/// returns zero events). Single string-valued predicates only; see <see cref="HistorianEventFilter"/>.
|
||||
/// </summary>
|
||||
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
HistorianEventFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
ValidateTimeRange(startUtc, endUtc);
|
||||
return _protocol.ReadEventsAsync(startUtc, endUtc, filter, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace AVEVA.Historian.Client.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Comparison operator for a <see cref="HistorianEventFilter"/>. Values mirror the native
|
||||
/// <c>ArchestrA.HistorianComparisionType</c> ordinals and travel on the wire as a UInt16.
|
||||
/// </summary>
|
||||
public enum HistorianEventComparison : ushort
|
||||
{
|
||||
Equal = 0,
|
||||
NotEqual = 1,
|
||||
LessThan = 2,
|
||||
NotLessThan = 3,
|
||||
GreaterThan = 4,
|
||||
NotGreaterThan = 5,
|
||||
LessThanEqual = 6,
|
||||
NotLessThanEqual = 7,
|
||||
GreaterThanEqual = 8,
|
||||
NotGreaterThanEqual = 9,
|
||||
Begins = 10,
|
||||
NotBegins = 11,
|
||||
Contains = 12,
|
||||
NotContains = 13,
|
||||
Exists = 14,
|
||||
NotExists = 15,
|
||||
EndWith = 16,
|
||||
NotEndWith = 17,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single server-side event filter predicate: <c>PropertyName Comparison Value</c>
|
||||
/// (e.g. <c>Type Equal "User.Write"</c>, <c>Area Contains "Tank"</c>). Applied to
|
||||
/// <c>ReadEventsAsync</c>; the server returns only events whose named property satisfies the
|
||||
/// comparison. For <see cref="HistorianEventComparison.Exists"/> /
|
||||
/// <see cref="HistorianEventComparison.NotExists"/> the value is ignored but still required by
|
||||
/// the wire format (pass any non-null string).
|
||||
/// </summary>
|
||||
public sealed record HistorianEventFilter(
|
||||
string PropertyName,
|
||||
HistorianEventComparison Comparison,
|
||||
string Value);
|
||||
@@ -42,10 +42,10 @@ internal sealed class Historian2020ProtocolDialect
|
||||
return Missing<HistorianBlock>("StartBlockRetrievalQuery", cancellationToken);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken)
|
||||
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(DateTime startUtc, DateTime endUtc, HistorianEventFilter? filter, CancellationToken cancellationToken)
|
||||
{
|
||||
HistorianWcfEventOrchestrator orchestrator = new(_options);
|
||||
return orchestrator.ReadEventsAsync(startUtc, endUtc, cancellationToken);
|
||||
return orchestrator.ReadEventsAsync(startUtc, endUtc, filter, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<HistorianConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
|
||||
namespace AVEVA.Historian.Client.Wcf;
|
||||
|
||||
@@ -7,15 +8,17 @@ internal static class HistorianEventQueryProtocol
|
||||
{
|
||||
public const ushort QueryRequestTypeEvent = 3;
|
||||
|
||||
public static IReadOnlyList<HistorianEventQueryAttempt> CreateStartEventQueryAttempts(DateTime startUtc, DateTime endUtc, uint eventCount)
|
||||
public static IReadOnlyList<HistorianEventQueryAttempt> CreateStartEventQueryAttempts(
|
||||
DateTime startUtc, DateTime endUtc, uint eventCount, HistorianEventFilter? filter = null)
|
||||
{
|
||||
List<HistorianEventQueryAttempt> attempts = [];
|
||||
attempts.Add(CreateNativeEmptyFilterAttempt(startUtc, endUtc, eventCount));
|
||||
attempts.Add(CreateNativeFilterAttempt(startUtc, endUtc, eventCount, filter));
|
||||
|
||||
return attempts;
|
||||
}
|
||||
|
||||
private static HistorianEventQueryAttempt CreateNativeEmptyFilterAttempt(DateTime startUtc, DateTime endUtc, uint eventCount)
|
||||
private static HistorianEventQueryAttempt CreateNativeFilterAttempt(
|
||||
DateTime startUtc, DateTime endUtc, uint eventCount, HistorianEventFilter? filter)
|
||||
{
|
||||
using MemoryStream stream = new();
|
||||
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
|
||||
@@ -27,7 +30,14 @@ internal static class HistorianEventQueryProtocol
|
||||
writer.Write(0u);
|
||||
writer.Write((ushort)0);
|
||||
writer.Write((ushort)1);
|
||||
WriteNativeEmptyFilterBlock(writer);
|
||||
if (filter is null)
|
||||
{
|
||||
WriteNativeEmptyFilterBlock(writer);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteFilterBlock(writer, filter);
|
||||
}
|
||||
writer.Write(65_536u);
|
||||
WriteHistorianString(writer, "UTC");
|
||||
WriteMetadataNamespace(writer);
|
||||
@@ -35,12 +45,64 @@ internal static class HistorianEventQueryProtocol
|
||||
|
||||
byte[] request = stream.ToArray();
|
||||
return new HistorianEventQueryAttempt(
|
||||
"native-empty-filter-version5",
|
||||
filter is null ? "native-empty-filter-version5" : "native-filter-version5",
|
||||
5,
|
||||
request,
|
||||
Convert.ToHexString(SHA256.HashData(request)).ToLowerInvariant());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter block carrying one predicate (property / comparison / value), decoded byte-for-byte
|
||||
/// from instrument-wcf-writemessage captures of <c>EventQuery.AddEventFilter</c> (Equal vs
|
||||
/// Contains diffed to isolate the operator field). Layout:
|
||||
/// <code>
|
||||
/// UInt16 0 // header word (also written by the empty-filter block)
|
||||
/// UInt32 filterCount = 1
|
||||
/// UInt32 conditionCount = 1
|
||||
/// UInt32 propertyNameCharCount
|
||||
/// propertyName (UTF-16LE)
|
||||
/// UInt32 1 // constant (per-condition marker; observed = 1)
|
||||
/// UInt16 comparison // HistorianComparisionType ordinal
|
||||
/// UInt32 1 // constant (value marker; observed = 1)
|
||||
/// value: 0x09 LEN 0x00 LEN×ASCII // compact-ASCII string (same as event/CTagMetadata)
|
||||
/// Byte 0 // block terminator
|
||||
/// </code>
|
||||
/// Single-predicate only — multi-filter (OR) / multi-condition (AND) framing is not yet
|
||||
/// fully captured. String values only; the value is always emitted (even for Exists).
|
||||
/// </summary>
|
||||
private static void WriteFilterBlock(BinaryWriter writer, HistorianEventFilter filter)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(filter.PropertyName);
|
||||
ArgumentNullException.ThrowIfNull(filter.Value);
|
||||
|
||||
writer.Write((ushort)0);
|
||||
writer.Write(1u); // filterCount
|
||||
writer.Write(1u); // conditionCount
|
||||
writer.Write((uint)filter.PropertyName.Length); // char count
|
||||
writer.Write(Encoding.Unicode.GetBytes(filter.PropertyName));
|
||||
writer.Write(1u); // constant marker
|
||||
writer.Write((ushort)filter.Comparison);
|
||||
writer.Write(1u); // constant marker
|
||||
WriteCompactAsciiString(writer, filter.Value);
|
||||
writer.Write((byte)0); // block terminator
|
||||
}
|
||||
|
||||
/// <summary>Compact ASCII string: <c>0x09 LEN 0x00 LEN×ASCII bytes</c>.</summary>
|
||||
private static void WriteCompactAsciiString(BinaryWriter writer, string value)
|
||||
{
|
||||
byte[] ascii = Encoding.ASCII.GetBytes(value);
|
||||
if (ascii.Length > byte.MaxValue)
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
$"Event filter value '{value}' exceeds the single-byte length captured for the compact-string encoding.");
|
||||
}
|
||||
|
||||
writer.Write((byte)0x09);
|
||||
writer.Write((byte)ascii.Length);
|
||||
writer.Write((byte)0);
|
||||
writer.Write(ascii);
|
||||
}
|
||||
|
||||
private static HistorianEventQueryAttempt CreateAttempt(
|
||||
string shape,
|
||||
ushort version,
|
||||
|
||||
@@ -66,6 +66,7 @@ internal sealed class HistorianWcfEventOrchestrator
|
||||
public async IAsyncEnumerable<HistorianEvent> ReadEventsAsync(
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
HistorianEventFilter? filter,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.IntegratedSecurity && string.IsNullOrEmpty(_options.UserName))
|
||||
@@ -77,7 +78,7 @@ internal sealed class HistorianWcfEventOrchestrator
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
IReadOnlyList<HistorianEvent> events = await Task.Run(
|
||||
() => RunEventChain(startUtc, endUtc, cancellationToken),
|
||||
() => RunEventChain(startUtc, endUtc, filter, cancellationToken),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (HistorianEvent evt in events)
|
||||
@@ -143,7 +144,7 @@ internal sealed class HistorianWcfEventOrchestrator
|
||||
return sent;
|
||||
}
|
||||
|
||||
private List<HistorianEvent> RunEventChain(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken)
|
||||
private List<HistorianEvent> RunEventChain(DateTime startUtc, DateTime endUtc, HistorianEventFilter? filter, CancellationToken cancellationToken)
|
||||
{
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(_options);
|
||||
@@ -155,7 +156,7 @@ internal sealed class HistorianWcfEventOrchestrator
|
||||
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode,
|
||||
additionalSetup: (historyChannel, context) =>
|
||||
AddCmEventTagViaAddT(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrBinding, retrEndpoint));
|
||||
return RunEventQuery(retrBinding, retrEndpoint, clientHandle, startUtc, endUtc, cancellationToken);
|
||||
return RunEventQuery(retrBinding, retrEndpoint, clientHandle, startUtc, endUtc, filter, cancellationToken);
|
||||
}
|
||||
|
||||
private List<HistorianEvent> RunEventQuery(
|
||||
@@ -164,6 +165,7 @@ internal sealed class HistorianWcfEventOrchestrator
|
||||
uint clientHandle,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
HistorianEventFilter? filter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ChannelFactory<IRetrievalServiceContract4> factory = new(binding, retrievalEndpoint);
|
||||
@@ -187,7 +189,8 @@ internal sealed class HistorianWcfEventOrchestrator
|
||||
IReadOnlyList<HistorianEventQueryAttempt> attempts = HistorianEventQueryProtocol.CreateStartEventQueryAttempts(
|
||||
startUtc.ToUniversalTime(),
|
||||
endUtc.ToUniversalTime(),
|
||||
eventCount: 5);
|
||||
eventCount: 5,
|
||||
filter);
|
||||
byte[] requestBuffer = attempts[0].RequestBuffer;
|
||||
|
||||
uint queryHandle = 0;
|
||||
|
||||
Reference in New Issue
Block a user