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:
Joseph Doherty
2026-06-20 18:00:52 -04:00
parent 1a7519c803
commit f1e23a3a02
11 changed files with 920 additions and 16 deletions
@@ -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.
}
}
}