5f949a86e2
v8 Event session reuses across SendEvent (~10-16x amortization); register-once sufficient; session survived 25s idle; event reads stay gated (C2). Live-validated against wonder-sql-vd03. Gate decision: GREEN -> HistorianGateway Stage B1 (separate event-session pool for SendEvent) warranted. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
112 lines
6.0 KiB
Markdown
112 lines
6.0 KiB
Markdown
# Event-session reuse spike — live results
|
||
|
||
> **Question:** does the 2023 R2 historian honor REUSING one authenticated **v8 Event**
|
||
> session (ECDH `ExchangeKey` → RC4 token → `ConnectionType=Event`, then `RegisterCmEventTag`)
|
||
> across multiple `SendEvent` ops, instead of the per-op open+register the SDK does today?
|
||
> This is the precondition for amortizing the EVENT path (HistorianGateway `pending.md` A1
|
||
> broadening, Stage B0 / B1).
|
||
>
|
||
> **Verdict: GREEN — a v8 Event session reuses across sends, register-once is sufficient,
|
||
> and the amortization is ~10–16×. Event READS stay gated (C2) and are not a reuse signal.**
|
||
|
||
**Date:** 2026-06-25
|
||
**Branch:** `feat/amortization-broadening`
|
||
**Server:** live 2023 R2 (`wonder-sql-vd03`), RemoteGrpc transport.
|
||
**Sandbox identity:** `HISTORIAN_EVENT_SANDBOX_TAG=HistGW.LiveTest.EventSpike` — the CM_EVENT send
|
||
buffer has **no per-tag routing field** (it registers against a fixed system tag), so the sandbox
|
||
value is stamped into the event `Type`/`SourceName`/`Namespace` + a `SpikeMarker` property as an
|
||
**identity marker**; no real tag is written or overwritten.
|
||
**Harness:** `tests/AVEVA.Historian.Client.Tests/EventSessionReuseSpikeTests.cs` driving the B0a
|
||
seams `HistorianGrpcEventWriteOrchestrator.OpenAndRegisterEventSession` (open v8 Event session +
|
||
`RegisterCmEventTag` ONCE) and `SendEventOnSession` (send only — no open/register).
|
||
|
||
---
|
||
|
||
## 1. Send reuse — GREEN
|
||
|
||
`ReusedEventSession_SendsTwice_SecondSkipsHandshake` **passed** (both runs): one
|
||
`OpenAndRegisterEventSession` then **two `SendEventOnSession` on the same v8 Event session** — both
|
||
accepted (`AddStreamValues` `BSuccess=true`).
|
||
|
||
```
|
||
open+register (ECDH handshake + RegisterCmEventTag) = 242 ms (run 1: 350 ms)
|
||
registration diag: RTag=True; EnsT=True
|
||
reused-send[0] = 23 ms, ok=True
|
||
reused-send[1] = 22 ms, ok=True
|
||
```
|
||
|
||
The server accepts the same v8 Event client handle across back-to-back sends. The session handle is
|
||
an immutable `readonly record struct (uint ClientHandle, Guid StorageSessionId)`; the send is
|
||
stateless on the client side (each call reserializes a fresh `"OS"` buffer), so nothing per-op is
|
||
baked into the handle.
|
||
|
||
## 2. Amortization — ~10–16×
|
||
|
||
The open+register (P-256 ECDH `ExchangeKey` → RC4 credential token → v8 `OpenConnection` →
|
||
`RegisterCmEventTag`) costs ~242–350 ms and is paid **once**; a reused send is ~22 ms. So over a
|
||
burst of N sends the per-send cost collapses from ~(265 ms open + 22 ms) to ~22 ms — a ~10–16× win
|
||
on the send path, same shape as the v6 read/write amortization (`handshake-reuse-spike-results.md`).
|
||
|
||
## 3. Register-once is sufficient — GREEN
|
||
|
||
`ReusedEventSession_RegisterOnce_ThenSendMany` **passed**: `RegisterCmEventTag` run **once** (inside
|
||
`OpenAndRegisterEventSession`), then **three** sends, all accepted.
|
||
|
||
```
|
||
register-once send[0] = 25 ms, ok=True
|
||
register-once send[1] = 22 ms, ok=True
|
||
register-once send[2] = 22 ms, ok=True
|
||
```
|
||
|
||
CM_EVENT registration is **session-scoped, not per-send** — the server holds the registration for the
|
||
session's lifetime. A reuse pool registers once per warm session, not per op.
|
||
|
||
## 4. Idle tolerance — survived ≥25 s (best-effort, single sample)
|
||
|
||
`ReusedEventSession_IdleSweep_BestEffort` (log-only): after a send, a **25 s idle gap**, then another
|
||
send — **the second send succeeded** (`session SURVIVED the idle gap`). Notable: the v6 read session
|
||
idle-expires at a ≥25 s gap (`handshake-reuse-spike-results.md` §3), but this v8 Event session
|
||
survived 25 s. This is a single-sample best-effort observation — a keepalive should still ping under
|
||
the ~20 s floor for safety margin until the v8 Event idle boundary is characterized more finely.
|
||
|
||
## 5. Read-after-send — GATED (C2), not a reuse signal
|
||
|
||
`ReusedEventSession_ServesReadAfterSend_BestEffort` (log-only, hard-bounded by a 5 s gRPC deadline +
|
||
an 8 s cancellation): the read-after-send on the same session **did not return data** — it cancels at
|
||
the bound:
|
||
|
||
```
|
||
read-after-send -> swallowed (RpcException Cancelled / OperationCanceled)
|
||
=> read gated/unverified over gRPC (expected)
|
||
```
|
||
|
||
This matches the pre-existing C2 gate: event **reads** over gRPC long-poll `GetNext` to a no-data
|
||
terminal and are unverified. So the spike did **not** prove a one-session-serves-both-kinds property
|
||
for reads — `SendEvent` is the only trustworthy reuse signal. (An unbounded read hung the first run;
|
||
the harness now bounds it so the spike is a clean, re-runnable record.)
|
||
|
||
---
|
||
|
||
## 6. Implications for Stage B1 (the event-pool build)
|
||
|
||
GREEN → a **separate event-session pool** (the approved B1 approach) is warranted and high-value:
|
||
|
||
1. **Amortize `SendEvent` through a bounded event-session pool.** Open+register a v8 Event session
|
||
once per warm session; lease it per send op (exclusive, like the v6 pool); reuse across a burst.
|
||
~10–16× on the send path.
|
||
2. **Keep the event pool SEPARATE from the v6 pool** (B1, as approved) — different auth (ECDH/v8),
|
||
heavier re-handshake on drop, and its own idle characteristics.
|
||
3. **`ReadEvents` stays PER-CALL / gated (C2).** Reads are unverified over gRPC regardless of reuse,
|
||
so the event pool amortizes **sends only**; `ReadEvents` is unaffected by B1 and stays on the
|
||
per-call path. (This refines the design's "route SendEvent + ReadEvents through the pool": only
|
||
`SendEvent` is routed; `ReadEvents` remains per-call because it is gated, not because of reuse.)
|
||
4. **Keepalive:** ping the warm event session under the idle floor. The cheap keepalive op for the
|
||
event channel is TBD in B1 (the v6 pool uses `GetSystemParameter`; the event session's equivalent
|
||
warm-touch needs picking — likely a no-op send or a lightweight event-channel status op).
|
||
5. **Reactive re-auth:** on an expiry-looking failure, evict + full v8 re-handshake (heavier than the
|
||
v6 re-auth — one ECDH + register penalty).
|
||
|
||
**Gate decision: GREEN → HistorianGateway A1 Stage B1 (a bounded `HistorianEventSessionPool` for
|
||
`SendEvent`, default-on, parallel to the v6 `HistorianSessionPool`) is warranted and earns its own
|
||
re-planned design + plan.**
|