From dc4141e7186d22b08a433aa72da115a3194fcc89 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 10:39:40 -0400 Subject: [PATCH] feat(grpc): event-on-session seam for the reuse spike (SendEvent[+ReadEvents]) Extract SendEventOnSession (and best-effort RunEventQueryOnSession) so the B0b spike can run multiple event ops on one already-opened v8 Event session. RegisterCmEventTag made independently callable. Behaviour-preserving (pending.md A1 broadening, Stage B0). Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../Grpc/HistorianGrpcEventOrchestrator.cs | 12 ++++- .../HistorianGrpcEventWriteOrchestrator.cs | 31 +++++++++++- .../EventOnSessionSeamTests.cs | 50 +++++++++++++++++++ 3 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 tests/AVEVA.Historian.Client.Tests/EventOnSessionSeamTests.cs diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs index 758b527..1720728 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs @@ -163,7 +163,7 @@ internal sealed class HistorianGrpcEventOrchestrator RegisterCmEventTag(connection, session, cancellationToken); - List events = RunEventQuery(connection, session, startUtc, endUtc, filter, cancellationToken); + List events = RunEventQueryOnSession(connection, session, startUtc, endUtc, filter, cancellationToken); // Honest no-data handling: when the query returns real rows, hand them back. When it instead // reaches the no-data terminal with ZERO rows (the gRPC server long-polls GetNext rather than @@ -273,7 +273,15 @@ internal sealed class HistorianGrpcEventOrchestrator /// Diagnostic: outcomes of the key CM_EVENT registration RPCs. public string RegistrationDiag { get; private set; } = string.Empty; - private List RunEventQuery( + // Spike seam (pending.md A1 broadening, Stage B0b): run ONLY the event query (StartEventQuery → + // GetNext loop → EndEventQuery) against an EXTERNALLY-supplied, already-opened + CM_EVENT-registered + // v8 Event connection + session — NO Create()/OpenSession/RegisterCmEventTag here. The per-call + // RunEventChain delegates to this so the per-call read and the B0b reuse spike share one query + // implementation (DRY). NOTE: event reads are otherwise GATED (C2) — the gRPC server long-polls + // GetNext to the no-data terminal and row-level retrieval is not yet verified over gRPC (see class + // remarks); the SEND seam is the spike's primary reuse signal. The split-channel opt-in + // (HISTORIAN_GRPC_EVENT_SPLIT_CHANNEL) is preserved inside, unchanged. + internal List RunEventQueryOnSession( HistorianGrpcConnection connection, HistorianGrpcHandshake.Session session, DateTime startUtc, diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventWriteOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventWriteOrchestrator.cs index 6971e60..f9f337c 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventWriteOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventWriteOrchestrator.cs @@ -67,12 +67,41 @@ internal sealed class HistorianGrpcEventWriteOrchestrator // The event SEND uses the same v8 Event connection as the event READ. The write-enabled // open buffer is byte-identical to the read-only one (verified live), so OpenSession's - // event path is reused unchanged. + // event path is reused unchanged. Per-call: open + register + send on a fresh session. + HistorianGrpcHandshake.Session session = OpenAndRegisterEventSession(connection, cancellationToken); + + return SendEventOnSession(connection, session, evt, cancellationToken); + } + + // Spike seam (pending.md A1 broadening, Stage B0b): open a v8 Event connection and drive the + // CM_EVENT registration ONCE, returning the warm (connection, session). The per-call Run() uses + // it for a single send; the B0b reuse spike calls it once and then issues MULTIPLE + // SendEventOnSession ops against the returned session to measure whether a v8 Event session can + // be reused across sends (it has NEVER been proven reusable — that is exactly what B0b measures). + // The caller owns the connection's lifetime (dispose it). + internal HistorianGrpcHandshake.Session OpenAndRegisterEventSession( + HistorianGrpcConnection connection, + CancellationToken cancellationToken) + { HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession( connection, _options, cancellationToken, eventConnection: true); RegisterCmEventTag(connection, session, cancellationToken); + return session; + } + // Spike seam (pending.md A1 broadening, Stage B0b): perform ONLY the event send against an + // EXTERNALLY-supplied, already-opened + CM_EVENT-registered v8 Event connection + session — + // i.e. NO Create(), NO OpenSession(eventConnection:true), NO RegisterCmEventTag inside it. The + // per-call Run() path delegates here so the per-call send and the B0b reuse-spike send share one + // implementation (DRY) and stay byte-identical. The spike drives this repeatedly on one warm + // session to measure whether the server honors a reused v8 Event session for multiple sends. + internal bool SendEventOnSession( + HistorianGrpcConnection connection, + HistorianGrpcHandshake.Session session, + HistorianEvent evt, + CancellationToken cancellationToken) + { var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel); byte[] pBuf = HistorianEventWriteProtocol.SerializeAddStreamValuesBuffer(evt, DateTime.UtcNow); diff --git a/tests/AVEVA.Historian.Client.Tests/EventOnSessionSeamTests.cs b/tests/AVEVA.Historian.Client.Tests/EventOnSessionSeamTests.cs new file mode 100644 index 0000000..05e8688 --- /dev/null +++ b/tests/AVEVA.Historian.Client.Tests/EventOnSessionSeamTests.cs @@ -0,0 +1,50 @@ +using System.Reflection; +using AVEVA.Historian.Client.Grpc; +using Xunit; + +namespace AVEVA.Historian.Client.Tests; + +/// +/// Reflection guard for the event-on-session seams the B0b reuse spike drives (pending.md A1 +/// broadening, Stage B0). Mirrors : the seam runs ONLY the +/// op against an externally-supplied (connection, session), so the spike can run MULTIPLE event ops +/// on one already-opened + registered v8 Event session to measure reuse. +/// +public class EventOnSessionSeamTests +{ + private static MethodInfo RequireMethod(Type owner, string name) + { + MethodInfo? m = owner.GetMethod( + name, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public | BindingFlags.Static); + Assert.NotNull(m); + return m!; + } + + [Fact] + public void SendEventOnSession_ExposesSeam_WithConnectionAndSessionFirst() + { + MethodInfo m = RequireMethod(typeof(HistorianGrpcEventWriteOrchestrator), "SendEventOnSession"); + ParameterInfo[] ps = m.GetParameters(); + Assert.Equal("HistorianGrpcConnection", ps[0].ParameterType.Name); + Assert.Equal("Session", ps[1].ParameterType.Name); + } + + [Fact] + public void OpenAndRegisterEventSession_ExposesRegisterOnceSeam() + { + // The spike registers CM_EVENT ONCE via this helper, then issues many SendEventOnSession ops. + MethodInfo m = RequireMethod(typeof(HistorianGrpcEventWriteOrchestrator), "OpenAndRegisterEventSession"); + ParameterInfo[] ps = m.GetParameters(); + Assert.Equal("HistorianGrpcConnection", ps[0].ParameterType.Name); + Assert.Equal("Session", m.ReturnType.Name); + } + + [Fact] + public void RunEventQueryOnSession_ExposesSeam_WithConnectionAndSessionFirst() + { + MethodInfo m = RequireMethod(typeof(HistorianGrpcEventOrchestrator), "RunEventQueryOnSession"); + ParameterInfo[] ps = m.GetParameters(); + Assert.Equal("HistorianGrpcConnection", ps[0].ParameterType.Name); + Assert.Equal("Session", ps[1].ParameterType.Name); + } +}