M2: implement SendEventAsync — event-send rides WCF AddS2, not the storage pipe
Roadmap Milestone 2 (event sending). Capture disproved the assumption that event delivery uses the non-WCF storage-engine pipe (which would block it like revision writes): a native AddStreamedValue(HistorianEvent) leaves over WCF as AddS2 (IHistoryServiceContract2.AddStreamValues2). CM_EVENT is a built-in registered tag, so the 129 TagNotFoundInCache gate that blocks AddS2 for user tags does not apply. - R2.1: NativeTraceHarness "event-send" scenario + Capture-EventSend.ps1; two captures diffed to separate constant framing from value-dependent fields. - R2.2: HistorianEventWriteProtocol serializes the AddS2 pBuf (storage sample buffer wrapping the event VTQ) — golden-byte tested. Decoded "OS" sig + length fields + CM_EVENT tag id + EventTime/ReceivedTime FILETIMEs + Opc 192 + 0x118D descriptor + event Id + Namespace + EventType + version 5 + typed property bag. - R2.3/R2.4: HistorianWcfEventOrchestrator.SendEventAsync (Open2 event-mode 0x501 -> reuse CM_EVENT RTag2/EnsT2 -> AddStreamValues2) + HistorianClient.SendEventAsync. - R2.5: gated live test; server accepts the AddS2 (success, empty error buffer). Server requires delivered byte[].Length == declared packet length (uint32@0x04); the native relies on the MDAS encoder adding a pad byte, so the SDK emits an explicit trailing 0x00 (else AddS2 rejects with "CValuStream buffer size vs packet length mismatch"). Original events only (RevisionVersion=0) with string properties; other property types + revision/update/delete throw ProtocolEvidenceMissingException. Caveat (documented): accepted events are not persisted on the local dev box; the native client behaves identically (event ingestion pipeline inactive) — not an SDK gap. 212 unit tests pass; 16/16 event tests pass live. 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:
@@ -744,4 +744,68 @@ public sealed class HistorianClientIntegrationTests
|
||||
Assert.True(metadata.MaxRaw is > 0 and <= 1e15);
|
||||
Assert.False(string.IsNullOrWhiteSpace(metadata.EngineeringUnit));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEventAsync_AgainstLocalHistorian_AcceptedByServer()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
Guid eventId = Guid.NewGuid();
|
||||
DateTime eventTime = DateTime.UtcNow;
|
||||
AVEVA.Historian.Client.Models.HistorianEvent evt = new(
|
||||
Id: eventId,
|
||||
EventTimeUtc: eventTime,
|
||||
ReceivedTimeUtc: eventTime,
|
||||
Type: "User.Write",
|
||||
SourceName: string.Empty,
|
||||
Namespace: "RetestSdkEventSend",
|
||||
RevisionVersion: 0,
|
||||
Properties: new Dictionary<string, object?>
|
||||
{
|
||||
["Source"] = "RetestSdkEventSend",
|
||||
["TestMarker"] = "histsdk-R2.5-roundtrip",
|
||||
});
|
||||
|
||||
// The full managed event-send chain (Open2 event-mode 0x501 → CM_EVENT RTag2/EnsT2 →
|
||||
// AddS2) reaches the server and the server accepts the AddS2 delivery. NOTE: whether the
|
||||
// event is then persisted to the queryable store depends on the historian's event
|
||||
// ingestion pipeline being active — on this dev box new events are accepted but not
|
||||
// persisted (the native client behaves identically), so this asserts acceptance, which
|
||||
// is the SDK-level signal. Round-trip read-back is best-effort below.
|
||||
bool accepted = await client.SendEventAsync(evt, CancellationToken.None);
|
||||
Assert.True(accepted);
|
||||
|
||||
// Best-effort round-trip: if the event store persisted it, it should be readable in a
|
||||
// tight time window. Not asserted hard because event persistence is environment-gated.
|
||||
try
|
||||
{
|
||||
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
|
||||
await sql.OpenAsync();
|
||||
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"SELECT COUNT(*) FROM v_AlarmEventHistory2 " +
|
||||
"WHERE EventStampUTC BETWEEN @s AND @e AND Type = @t";
|
||||
cmd.Parameters.AddWithValue("@s", eventTime.AddMinutes(-2));
|
||||
cmd.Parameters.AddWithValue("@e", eventTime.AddMinutes(2));
|
||||
cmd.Parameters.AddWithValue("@t", "User.Write");
|
||||
int count = Convert.ToInt32(await cmd.ExecuteScalarAsync());
|
||||
// If persistence is active the event is present; if not, count is 0 (env limitation).
|
||||
Assert.True(count >= 0);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// SQL read-back is diagnostic only; never fail the send test on a query issue.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
|
||||
namespace AVEVA.Historian.Client.Tests;
|
||||
|
||||
public sealed class WcfEventWriteProtocolTests
|
||||
{
|
||||
// Golden bytes captured from a native AddStreamedValue(HistorianEvent) over WCF (AddS2.pBuf),
|
||||
// instrument-wcf-writemessage, event-send capture A (Type="User.Write").
|
||||
private const string CaptureAddS2PBufBase64 =
|
||||
"T1MBANMAAADJAEWBOzXwXUZNolOHGu9JsyHwZ9L7+QDdAcAAwACNEbJ2OdoXacNJkFutCzR+krSAst37" +
|
||||
"+QDdAQkSAFJldGVzdFNka0V2ZW50U2VuZAkKAFVzZXIuV3JpdGUFAAIACQYAU291cmNlQyYAEgBSAGUA" +
|
||||
"dABlAHMAdABTAGQAawBFAHYAZQBuAHQAUwBlAG4AZAAJCgBUZXN0TWFya2VyQyoAFABoAGkAcwB0AHMA" +
|
||||
"ZABrAC0AUgAyAC4AMQAtAGMAYQBwAHQAdQByAGUA";
|
||||
|
||||
[Fact]
|
||||
public void SerializeAddStreamValuesBufferMatchesInstrumentedNativeEventSend()
|
||||
{
|
||||
// Exact field values recovered from capture A's pBuf.
|
||||
Guid eventId = new("da3976b2-6917-49c3-905b-ad0b347e92b4");
|
||||
DateTime eventTimeUtc = DateTime.FromFileTimeUtc(134264637562710000L);
|
||||
DateTime receivedTimeUtc = DateTime.FromFileTimeUtc(134264637563449984L);
|
||||
|
||||
HistorianEvent evt = new(
|
||||
Id: eventId,
|
||||
EventTimeUtc: eventTimeUtc,
|
||||
ReceivedTimeUtc: receivedTimeUtc,
|
||||
Type: "User.Write",
|
||||
SourceName: string.Empty,
|
||||
Namespace: "RetestSdkEventSend",
|
||||
RevisionVersion: 0,
|
||||
Properties: new Dictionary<string, object?>
|
||||
{
|
||||
["Source"] = "RetestSdkEventSend",
|
||||
["TestMarker"] = "histsdk-R2.1-capture",
|
||||
});
|
||||
|
||||
// The capture's MDAS length marker excluded the final trailing pad byte, so the golden
|
||||
// bytes are the 210-byte deterministic content; the serializer appends one pad byte
|
||||
// (total 211) to satisfy the server's packet-length == buffer-size check.
|
||||
byte[] expectedContent = Convert.FromBase64String(CaptureAddS2PBufBase64);
|
||||
byte[] actual = HistorianEventWriteProtocol.SerializeAddStreamValuesBuffer(evt, receivedTimeUtc);
|
||||
|
||||
Assert.Equal(expectedContent.Length + 1, actual.Length);
|
||||
Assert.Equal(expectedContent, actual[..expectedContent.Length]);
|
||||
Assert.Equal(0, actual[^1]);
|
||||
// The declared packet length (UInt32 @0x04) equals the delivered buffer length.
|
||||
Assert.Equal((uint)actual.Length, BitConverter.ToUInt32(actual, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BufferFramingFieldsAreDerivedFromValueBlobLength()
|
||||
{
|
||||
HistorianEvent evt = new(
|
||||
Id: new("da3976b2-6917-49c3-905b-ad0b347e92b4"),
|
||||
EventTimeUtc: DateTime.FromFileTimeUtc(134264637562710000L),
|
||||
ReceivedTimeUtc: DateTime.FromFileTimeUtc(134264637563449984L),
|
||||
Type: "User.Write",
|
||||
SourceName: string.Empty,
|
||||
Namespace: "RetestSdkEventSend",
|
||||
RevisionVersion: 0,
|
||||
Properties: new Dictionary<string, object?> { ["Source"] = "RetestSdkEventSend", ["TestMarker"] = "histsdk-R2.1-capture" });
|
||||
|
||||
byte[] buf = HistorianEventWriteProtocol.SerializeAddStreamValuesBuffer(evt, evt.ReceivedTimeUtc);
|
||||
|
||||
Assert.Equal(0x534F, BitConverter.ToUInt16(buf, 0)); // "OS"
|
||||
Assert.Equal(1, BitConverter.ToUInt16(buf, 2)); // sampleCount
|
||||
// Declared packet length (UInt32 @0x04) equals the delivered buffer length (incl. pad).
|
||||
Assert.Equal((uint)buf.Length, BitConverter.ToUInt32(buf, 4));
|
||||
// Inner length (UInt16 @0x08) = buffer length - 10.
|
||||
Assert.Equal((ushort)(buf.Length - 10), BitConverter.ToUInt16(buf, 8));
|
||||
// CM_EVENT tag id at the head of the value blob.
|
||||
Assert.Equal(HistorianEventWriteProtocol.CmEventTagId, new Guid(buf.AsSpan(10, 16).ToArray()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonStringPropertyValueThrowsProtocolEvidenceMissing()
|
||||
{
|
||||
HistorianEvent evt = new(
|
||||
Id: Guid.NewGuid(),
|
||||
EventTimeUtc: new DateTime(2026, 6, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||
ReceivedTimeUtc: new DateTime(2026, 6, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||
Type: "User.Write",
|
||||
SourceName: string.Empty,
|
||||
Namespace: "ns",
|
||||
RevisionVersion: 0,
|
||||
Properties: new Dictionary<string, object?> { ["Count"] = 5 });
|
||||
|
||||
Assert.Throws<ProtocolEvidenceMissingException>(
|
||||
() => HistorianEventWriteProtocol.SerializeAddStreamValuesBuffer(evt, evt.ReceivedTimeUtc));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user