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
@@ -73,6 +73,33 @@ public sealed class WcfEventQueryProtocolTests
Assert.Equal("6b955b02087047a3199a8c74f3eee85c3b49aaa29b05de12eff2dd536f2da0d5", attempt.RequestSha256);
}
[Fact]
public void Version6EmptyFilterMatchesCapturedGrpcEnvelope()
{
// Captured 2026-06-22 from the stock 2023 R2 client (docs/reverse-engineering/grpc-event-query-capture.md):
// the v6 StartEventQuery request is byte-identical to the v5 buffer except byte 0 (version 6) and a
// 5-byte trailing zero pad (70 vs 65 bytes). The 2023 R2 server returns event rows only for v6.
DateTime start = new DateTime(2026, 4, 25, 14, 39, 36, 800, DateTimeKind.Utc).AddTicks(1646);
DateTime end = new DateTime(2026, 5, 2, 14, 39, 36, 800, DateTimeKind.Utc).AddTicks(1646);
byte[] v5 = HistorianEventQueryProtocol.CreateStartEventQueryAttempts(start, end, 3)[0].RequestBuffer;
HistorianEventQueryAttempt v6Attempt = Assert.Single(
HistorianEventQueryProtocol.CreateStartEventQueryAttempts(start, end, 3, filter: null, version: 6));
byte[] v6 = v6Attempt.RequestBuffer;
Assert.Equal("native-empty-filter-version6", v6Attempt.Name);
Assert.Equal(6, v6Attempt.Version);
Assert.Equal(70, v6.Length);
Assert.Equal([0x06, 0x00], v6[..2]);
// v6 == v5 with byte 0 -> 6 and 5 trailing zero bytes appended.
byte[] expected = new byte[70];
Array.Copy(v5, expected, v5.Length);
expected[0] = 0x06;
Assert.Equal(expected, v6);
Assert.Equal([0x00, 0x00, 0x00, 0x00, 0x00], v6[^5..]);
}
[Fact]
public void NativeEmptyFilterAttemptMatchesDecompiledSaveOrder()
{