feat(grpc-events): v6 StartEventQuery request + capture-event harness scenario

Captured the stock 2023 R2 client doing a gRPC event read (50 rows flowed) to
resolve the open "gRPC event ROW retrieval returns zero rows" item. Two captured
differences from our SDK's path; this lands the first (necessary) one plus the
capture tooling.

- HistorianEventQueryProtocol.CreateStartEventQueryAttempts: add a `version`
  parameter (default 5 = the 2020 WCF format, unchanged). The gRPC event
  orchestrator now opts into version 6 — the leading `06` plus a 5-byte trailing
  zero pad — which is the envelope the stock 2023 R2 client sends. The two
  buffers are otherwise byte-identical (filter block, UTC string, metadata
  namespace). Golden test Version6EmptyFilterMatchesCapturedGrpcEnvelope pins it.
- Grpc2023CaptureHarness: new `capture-event` scenario drives HistorianAccess
  over an Event-type gRPC connection (CreateEventQuery -> EventQueryArgs ->
  StartQuery -> MoveNext) so the wide-net instrument-grpc-nonstream rewrite dumps
  StartEventQuery.requestBuffer + the row result. Hostname defaults sanitized to
  HISTORIAN_GRPC_HOST / "localhost" (removed hardcoded server name).

NECESSARY BUT NOT SUFFICIENT: live validation shows v6 alone does not make rows
flow — the read also requires an Event-type connection, which our SDK's v6 Open2
format cannot express (see the companion docs commit). The gated
ReadEventsAsync_OverGrpc_* test correctly still pins the no-row throw. 322/322
offline tests pass; WCF event path unchanged.

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-22 10:41:15 -04:00
parent d9051ba890
commit dbb5c99c53
4 changed files with 238 additions and 13 deletions
@@ -266,11 +266,17 @@ internal sealed class HistorianGrpcEventOrchestrator
new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion.UiVersion, _options);
// Version 6 envelope: the stock 2023 R2 client sends v6 (the WCF path's v5 request is accepted
// here but is the legacy format). NECESSARY but not alone sufficient — live validation 2026-06-22
// showed rows still don't flow on v6 because the read also requires an EVENT-type connection
// (the stock client opens ConnectionType=Event; our OpenSession opens a Process-style 0x402
// session). See docs/reverse-engineering/grpc-event-query-capture.md "remaining gate".
IReadOnlyList<HistorianEventQueryAttempt> attempts = HistorianEventQueryProtocol.CreateStartEventQueryAttempts(
startUtc.ToUniversalTime(),
endUtc.ToUniversalTime(),
eventCount: 5,
filter);
filter,
version: 6);
byte[] requestBuffer = attempts[0].RequestBuffer;
GrpcRetrieval.StartEventQueryResponse startResponse = retrievalClient.StartEventQuery(
@@ -8,22 +8,31 @@ internal static class HistorianEventQueryProtocol
{
public const ushort QueryRequestTypeEvent = 3;
/// <summary>
/// Builds the <c>StartEventQuery</c> <c>pRequestBuff</c>. <paramref name="version"/> selects the
/// envelope revision: <b>5</b> (default) is the native 2020 WCF format used by the WCF event
/// orchestrator; <b>6</b> is the 2023 R2 gRPC format. The two envelopes are byte-identical except
/// the leading version word and a 5-byte trailing zero pad — captured 2026-06-22 from the stock
/// 2023 R2 client (see <c>docs/reverse-engineering/grpc-event-query-capture.md</c>). The 2023 R2
/// server returns rows only for v6; v5 is accepted (StartEventQuery succeeds) but matches no rows.
/// The filter block in the middle is unchanged across versions.
/// </summary>
public static IReadOnlyList<HistorianEventQueryAttempt> CreateStartEventQueryAttempts(
DateTime startUtc, DateTime endUtc, uint eventCount, HistorianEventFilter? filter = null)
DateTime startUtc, DateTime endUtc, uint eventCount, HistorianEventFilter? filter = null, ushort version = 5)
{
List<HistorianEventQueryAttempt> attempts = [];
attempts.Add(CreateNativeFilterAttempt(startUtc, endUtc, eventCount, filter));
attempts.Add(CreateNativeFilterAttempt(startUtc, endUtc, eventCount, filter, version));
return attempts;
}
private static HistorianEventQueryAttempt CreateNativeFilterAttempt(
DateTime startUtc, DateTime endUtc, uint eventCount, HistorianEventFilter? filter)
DateTime startUtc, DateTime endUtc, uint eventCount, HistorianEventFilter? filter, ushort version)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write((ushort)5);
writer.Write(version);
writer.Write(startUtc.ToFileTimeUtc());
writer.Write(endUtc.ToFileTimeUtc());
writer.Write(eventCount);
@@ -43,10 +52,19 @@ internal static class HistorianEventQueryProtocol
WriteMetadataNamespace(writer);
writer.Write(0u);
// Version 6 (2023 R2 gRPC) appends a 5-byte trailing zero pad after the v5 terminal — the only
// envelope delta from v5 besides the version word. Captured live: the v6 buffer is the v5 buffer
// (byte 0 = 6) plus these 5 bytes, and is the form the 2023 R2 server returns event rows for.
if (version >= 6)
{
writer.Write(0u);
writer.Write((byte)0);
}
byte[] request = stream.ToArray();
return new HistorianEventQueryAttempt(
filter is null ? "native-empty-filter-version5" : "native-filter-version5",
5,
filter is null ? $"native-empty-filter-version{version}" : $"native-filter-version{version}",
version,
request,
Convert.ToHexString(SHA256.HashData(request)).ToLowerInvariant());
}