test(loadharness)+docs: wire-path stream probes — R2 batching benchmark-proven at target scale

The WP-4 runs measured the stream path only up to the bounded send channel;
the probe deliberately substituted the socket hop with a controllable reader,
which is exactly where the R2 SiteStreamEventBatcher lives — so batching had
never been in any benchmark's hot path. The harness now also attaches per-site
wire probes: a real Kestrel h2c listener hosting the production
SiteStreamGrpcServer, dialed by the production SiteStreamGrpcClient with
batching negotiated, batch sizes read from the production batch_size histogram
via a MeterListener (--wire-probes-per-site, default 10; 0 restores the old
shape). Full-scale run (10x500x75, 20-min window, results doc §8 + raw JSON):
899,807 events in 12,462 frames (mean 72.2 ev/frame, p50=p99=75), wire P99
43.4 ms vs the 250 ms budget — every batch pays the full 25 ms linger since a
75-event burst never hits the 100 cap — zero drops, zero faults, offered load
100.0% of nominal with fan-out doubled. CI smoke Fact now asserts the wire
path carries traffic and observes frames.
This commit is contained in:
Joseph Doherty
2026-08-18 10:32:46 -04:00
parent d3be5f0527
commit e9e44a9ef0
14 changed files with 3400 additions and 8 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
- **`ActiveNodeEvaluator.SelfIsOldestUp` is THE single definition of "active node"** (`Communication/ClusterState/ActiveNodeEvaluator.cs`) — the **oldest Up member** in a role scope, and explicitly **never `cluster.State.Leader`**: leadership (lowest address) is an Akka-internal concept that diverges from singleton placement permanently once the original first node restarts and rejoins, and both sides claim it during a partition. The equivalence *oldest-Up == where `ClusterSingletonManager` places singletons* **is** the design. `ClusterActivityEvaluator.SelfIsOldest`, the S&F delivery gate, `/health/active` and the heartbeat `IsActive` stamp all delegate here. - **`ActiveNodeEvaluator.SelfIsOldestUp` is THE single definition of "active node"** (`Communication/ClusterState/ActiveNodeEvaluator.cs`) — the **oldest Up member** in a role scope, and explicitly **never `cluster.State.Leader`**: leadership (lowest address) is an Akka-internal concept that diverges from singleton placement permanently once the original first node restarts and rejoins, and both sides claim it during a partition. The equivalence *oldest-Up == where `ClusterSingletonManager` places singletons* **is** the design. `ClusterActivityEvaluator.SelfIsOldest`, the S&F delivery gate, `/health/active` and the heartbeat `IsActive` stamp all delegate here.
- Site nodes carry **two Akka roles**: the base `Site` plus a site-specific `site-{SiteId}` (`AkkaHostedService.BuildRoles`). Singletons scope to the **site-specific** role. - Site nodes carry **two Akka roles**: the base `Site` plus a site-specific `site-{SiteId}` (`AkkaHostedService.BuildRoles`). Singletons scope to the **site-specific** role.
- **The gRPC boundary is authenticated (PSK) as of 2026-07-22; Akka remoting still is not, and nothing is encrypted.** Akka remoting sets no `enable-ssl`, no secure cookie, no `trusted-selection-paths` — so intra-cluster Akka remoting remains open to anyone who can reach the remoting port, and that boundary still assumes a trusted network. The gRPC listener stays **h2c**, but `SiteStreamService` is no longer open: `ControlPlaneAuthInterceptor` (`Host/ControlPlaneAuthInterceptor.cs`) gates `/sitestream.SiteStreamService/` — including the `PullAuditEvents`/`PullSiteCalls` RPCs that return audit rows — against a **per-site preshared key**, fail-closed, constant-time compared, alongside the separate `LocalDbSyncAuthInterceptor` on `/localdb_sync.v1.LocalDbSync/` with its own separate key. **Site side:** `ScadaBridge:Communication:GrpcPsk`, in production `${secret:SB-GRPC-PSK-<siteId>}`, and **`StartupValidator` refuses to boot a site node without it** (an unset key would leave the node healthy-looking but serving nothing). **Central side:** `SitePskProvider` resolves `SB-GRPC-PSK-{siteId}` from the secrets store at channel-build time (sites are added at runtime, so no boot-time expansion is possible), with `ScadaBridge:Communication:SitePsks:{siteId}` as an override for hosts running without a master key — the docker rig uses the latter. One key per site, never fleet-wide. A bearer token over h2c is readable and replayable on-path; TLS is the follow-on hardening and needs no change to this design. Introduced by Phase 0 of the ClusterClient→gRPC migration (`docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`). - **The gRPC boundary is authenticated (PSK) as of 2026-07-22; Akka remoting still is not, and nothing is encrypted.** Akka remoting sets no `enable-ssl`, no secure cookie, no `trusted-selection-paths` — so intra-cluster Akka remoting remains open to anyone who can reach the remoting port, and that boundary still assumes a trusted network. The gRPC listener stays **h2c**, but `SiteStreamService` is no longer open: `ControlPlaneAuthInterceptor` (`Host/ControlPlaneAuthInterceptor.cs`) gates `/sitestream.SiteStreamService/` — including the `PullAuditEvents`/`PullSiteCalls` RPCs that return audit rows — against a **per-site preshared key**, fail-closed, constant-time compared, alongside the separate `LocalDbSyncAuthInterceptor` on `/localdb_sync.v1.LocalDbSync/` with its own separate key. **Site side:** `ScadaBridge:Communication:GrpcPsk`, in production `${secret:SB-GRPC-PSK-<siteId>}`, and **`StartupValidator` refuses to boot a site node without it** (an unset key would leave the node healthy-looking but serving nothing). **Central side:** `SitePskProvider` resolves `SB-GRPC-PSK-{siteId}` from the secrets store at channel-build time (sites are added at runtime, so no boot-time expansion is possible), with `ScadaBridge:Communication:SitePsks:{siteId}` as an override for hosts running without a master key — the docker rig uses the latter. One key per site, never fleet-wide. A bearer token over h2c is readable and replayable on-path; TLS is the follow-on hardening and needs no change to this design. Introduced by Phase 0 of the ClusterClient→gRPC migration (`docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`).
- gRPC streaming channel — **note the direction is inverted from the data flow**: data moves site→central, but each **site node hosts the gRPC server** (`SiteStreamGrpcServer`, Kestrel h2c, port 8083, mapped **only in the Site branch** of `Program.cs`) and **central is the client**, dialling in. Central creates per-site `SiteStreamGrpcClient` via `SiteStreamGrpcClientFactory`, keyed **`(siteId, endpoint)`** — the key was widened from site-only to fix an arch-review High where one session's NodeA→NodeB flip disposed a channel another session was still using. Proto evolution is **additive only** and field numbers are never reused (`AlarmStateUpdate` grew 7→23 fields for the native-alarm mirror, then 24 for `AckTime`). Generated C# is **vendored** under `Communication/SiteStreamGrpc/` with the `<Protobuf>` include commented out — regeneration is a manual toggle-build-copy-untoggle, automated by `docker/regen-proto.sh [sitestream|centralcontrol|sitecommand|all]` (it always restores the csproj; an active `<Protobuf>` item must never be committed — it breaks the Docker build). **Stream events are BATCHED as of R2 (2026-08-15)** — a site emitting ~37.5k events/s used to pay one gRPC message per event. Additive wire shape, no new RPC: `batching_supported` on the request (`InstanceStreamRequest` field 3 / `SiteStreamRequest` field 2) plus a `SiteStreamEvent.batch` oneof case (**field 4**) carrying a new `SiteStreamEventBatch { repeated SiteStreamEvent events = 1 }`. **The proto3 default of that flag IS the negotiation, and it is load-bearing** — a batch frame reaches a pre-R2 central as `EventOneofCase.None`, whose `ConvertToDomainEvent` returns null, so the whole batch would vanish with no error anywhere; an old central cannot set the flag, so it never gets one, and an old site ignores the unknown request field and keeps sending per-event frames the new client's `ForEachEvent` unpack handles as the single-event case. Server side is `SiteStreamEventBatcher`, a per-subscriber pump **downstream of `StreamRelayActor`'s bounded DropOldest channel** — so it changes framing only and **does NOT move the burst ceiling** (deferred register row 31; that ceiling lives in the shared publish stage upstream of the BroadcastHub). It never delays a lone event: it drains the already-queued backlog for free and lingers only once a backlog is proven, then emits a single-event buffer as a plain frame. `GrpcStreamBatchMaxEvents` (100, `1` disables) and `GrpcStreamBatchWindow` (25 ms, validated **strictly under 250 ms** = the load test's end-to-end P99 threshold); measured worst case P99 25.4 ms. Histogram `scadabridge.site.stream.batch_size`, recorded only on negotiated streams (`ScadaBridgeTelemetry.MeterName` is already in the `ObservedMeters` allowlist). **The client reconnects on graceful (OK-status) stream completion, not just on fault (arch-review remediation, commit `34a3f4bb`)** — Kestrel's `MaxStreamLifetime`/`Grpc.AspNetCore.Server` max-connection-age periodically ends a healthy stream with a normal completion, which the client used to treat as terminal (no reconnect attempt), silently killing a site's live feed until the next process restart (observed up to ~4h on the rig); live-probed at a 2-minute forced lifetime, reconnect lands within one reconcile tick and `IsLive` reflects the gap in between. - gRPC streaming channel — **note the direction is inverted from the data flow**: data moves site→central, but each **site node hosts the gRPC server** (`SiteStreamGrpcServer`, Kestrel h2c, port 8083, mapped **only in the Site branch** of `Program.cs`) and **central is the client**, dialling in. Central creates per-site `SiteStreamGrpcClient` via `SiteStreamGrpcClientFactory`, keyed **`(siteId, endpoint)`** — the key was widened from site-only to fix an arch-review High where one session's NodeA→NodeB flip disposed a channel another session was still using. Proto evolution is **additive only** and field numbers are never reused (`AlarmStateUpdate` grew 7→23 fields for the native-alarm mirror, then 24 for `AckTime`). Generated C# is **vendored** under `Communication/SiteStreamGrpc/` with the `<Protobuf>` include commented out — regeneration is a manual toggle-build-copy-untoggle, automated by `docker/regen-proto.sh [sitestream|centralcontrol|sitecommand|all]` (it always restores the csproj; an active `<Protobuf>` item must never be committed — it breaks the Docker build). **Stream events are BATCHED as of R2 (2026-08-15)** — a site emitting ~37.5k events/s used to pay one gRPC message per event. Additive wire shape, no new RPC: `batching_supported` on the request (`InstanceStreamRequest` field 3 / `SiteStreamRequest` field 2) plus a `SiteStreamEvent.batch` oneof case (**field 4**) carrying a new `SiteStreamEventBatch { repeated SiteStreamEvent events = 1 }`. **The proto3 default of that flag IS the negotiation, and it is load-bearing** — a batch frame reaches a pre-R2 central as `EventOneofCase.None`, whose `ConvertToDomainEvent` returns null, so the whole batch would vanish with no error anywhere; an old central cannot set the flag, so it never gets one, and an old site ignores the unknown request field and keeps sending per-event frames the new client's `ForEachEvent` unpack handles as the single-event case. Server side is `SiteStreamEventBatcher`, a per-subscriber pump **downstream of `StreamRelayActor`'s bounded DropOldest channel** — so it changes framing only and **does NOT move the burst ceiling** (deferred register row 31; that ceiling lives in the shared publish stage upstream of the BroadcastHub). It never delays a lone event: it drains the already-queued backlog for free and lingers only once a backlog is proven, then emits a single-event buffer as a plain frame. `GrpcStreamBatchMaxEvents` (100, `1` disables) and `GrpcStreamBatchWindow` (25 ms, validated **strictly under 250 ms** = the load test's end-to-end P99 threshold); measured worst case P99 25.4 ms. **Benchmark-proven at target scale 2026-08-18** (the LoadHarness wire path — real Kestrel h2c + real `SiteStreamGrpcClient`, `--wire-probes-per-site`): 899,807 events in 12,462 frames (mean 72.2 ev/frame), wire P99 43.4 ms (5.8× inside the 250 ms budget — a 75-event burst never hits the 100 cap, so every batch pays the full 25 ms window: this workload's worst-case latency posture), zero drops/faults; results doc §8. Histogram `scadabridge.site.stream.batch_size`, recorded only on negotiated streams (`ScadaBridgeTelemetry.MeterName` is already in the `ObservedMeters` allowlist). **The client reconnects on graceful (OK-status) stream completion, not just on fault (arch-review remediation, commit `34a3f4bb`)** — Kestrel's `MaxStreamLifetime`/`Grpc.AspNetCore.Server` max-connection-age periodically ends a healthy stream with a normal completion, which the client used to treat as terminal (no reconnect attempt), silently killing a site's live feed until the next process restart (observed up to ~4h on the rig); live-probed at a 2-minute forced lifetime, reconnect lands within one reconcile tick and `IsLive` reflects the gap in between.
- Native alarms are a **read-only** mirror of OPC UA Alarms & Conditions and MxAccess Gateway alarms — **no ack-back, no central tables**; state lives in the site's `native_alarm_state`, survives failover, and is cleared on redeploy/undeploy (mirrors static overrides). Central's per-site live alarm cache (`ISiteAlarmLiveCache`) is **transient in-memory only** — there is deliberately no persisted central alarm store, so the 15s poll remains the NotReporting authority behind the live stream. See `Component-DataConnectionLayer.md` / `Component-CentralUI.md` for the model and the authoring surface. - Native alarms are a **read-only** mirror of OPC UA Alarms & Conditions and MxAccess Gateway alarms — **no ack-back, no central tables**; state lives in the site's `native_alarm_state`, survives failover, and is cleared on redeploy/undeploy (mirrors static overrides). Central's per-site live alarm cache (`ISiteAlarmLiveCache`) is **transient in-memory only** — there is deliberately no persisted central alarm store, so the 15s poll remains the NotReporting authority behind the live stream. See `Component-DataConnectionLayer.md` / `Component-CentralUI.md` for the model and the authoring surface.
- **`AckTime` mirror enrichment + the `Alarms` script accessor (MES alarm-status API Phase 1, 2026-08-01).** `AlarmStateChanged` carries an additive `AckTime` (`DateTimeOffset?`), mirrored on the vendored `AlarmStateUpdate` proto as **field 24** and persisted inside `native_alarm_state`'s `metadata_json` — deliberately NOT a new column, because that table is `RegisterReplicated` and LocalDb builds its CDC triggers from the column list at registration time. Set only while a condition is active AND acknowledged (so it is null while unacked and cleared on re-raise); the DCL stamps the source's own ack instant for OPC UA (new SelectClause **index 18** = `AckedState/TransitionTime`) and its observation time of the ack transition for MxGateway, which supplies none. Site `Call` scripts read alarms via the new **`Alarms.CurrentAsync()`** accessor (`ScriptRuntimeContext` + `ScriptGlobals`, local Ask on `GetAlarmSnapshotRequest`, returns `Commons.Types.Scripts.ScriptAlarm`), mirrored on `ScriptCompileSurface` AND the Central UI `SandboxScriptHost` editor surface. The trust model needed no change — it is a deny-list over API roots, not an allow-list of context members. Plan: `docs/plans/2026-06-30-mes-alarm-status-api.md` (Phases 2–4 are deployed config, not repo). - **`AckTime` mirror enrichment + the `Alarms` script accessor (MES alarm-status API Phase 1, 2026-08-01).** `AlarmStateChanged` carries an additive `AckTime` (`DateTimeOffset?`), mirrored on the vendored `AlarmStateUpdate` proto as **field 24** and persisted inside `native_alarm_state`'s `metadata_json` — deliberately NOT a new column, because that table is `RegisterReplicated` and LocalDb builds its CDC triggers from the column list at registration time. Set only while a condition is active AND acknowledged (so it is null while unacked and cleared on re-raise); the DCL stamps the source's own ack instant for OPC UA (new SelectClause **index 18** = `AckedState/TransitionTime`) and its observation time of the ack transition for MxGateway, which supplies none. Site `Call` scripts read alarms via the new **`Alarms.CurrentAsync()`** accessor (`ScriptRuntimeContext` + `ScriptGlobals`, local Ask on `GetAlarmSnapshotRequest`, returns `Commons.Types.Scripts.ScriptAlarm`), mirrored on `ScriptCompileSurface` AND the Central UI `SandboxScriptHost` editor surface. The trust model needed no change — it is a deny-list over API roots, not an allow-list of context members. Plan: `docs/plans/2026-06-30-mes-alarm-status-api.md` (Phases 2–4 are deployed config, not repo).
- OPC UA cert trust is **site-local and not persisted centrally** (follow-up): the verify-endpoint probe captures an untrusted server cert but **NEVER trusts it**, and DeploymentManager broadcasts `TrustServerCertCommand`/`RemoveServerCertCommand` to **BOTH** site nodes — `CertStoreActor` runs on every site node, not as a singleton, so PKI stores stay consistent across failover. - OPC UA cert trust is **site-local and not persisted centrally** (follow-up): the verify-endpoint probe captures an untrusted server cert but **NEVER trusts it**, and DeploymentManager broadcasts `TrustServerCertCommand`/`RemoveServerCertCommand` to **BOTH** site nodes — `CertStoreActor` runs on every site node, not as a singleton, so PKI stores stay consistent across failover.
@@ -191,7 +191,7 @@ Each is a deliberate, recorded trade — not an omission.
| D1 | **Sustained window shortened from 1 hour to 20 minutes** at full scale | Practicality on a single shared workstation. Memory growth is reported as a *slope* precisely so a shorter window still answers the leak question: a leak shows as a positive slope in 20 min just as in 60. The 1-hour run is a single flag — `--sustain-minutes 60` — and the exact command is recorded in the results doc. | | D1 | **Sustained window shortened from 1 hour to 20 minutes** at full scale | Practicality on a single shared workstation. Memory growth is reported as a *slope* precisely so a shorter window still answers the leak question: a leak shows as a positive slope in 20 min just as in 60. The 1-hour run is a single flag — `--sustain-minutes 60` — and the exact command is recorded in the results doc. |
| D2 | **In-process, non-clustered sites** rather than 10 real two-node Akka clusters | A single box cannot host 20 clustered nodes at this scale. Cluster membership/failover is already measured on a real two-node rig (`FailoverTimingTests`, `docker/failover-drill.sh`); WP-4's question is about the hierarchy under the singleton. | | D2 | **In-process, non-clustered sites** rather than 10 real two-node Akka clusters | A single box cannot host 20 clustered nodes at this scale. Cluster membership/failover is already measured on a real two-node rig (`FailoverTimingTests`, `docker/failover-drill.sh`); WP-4's question is about the hierarchy under the singleton. |
| D3 | **Simulated data source** instead of real OPC UA | No simulated adapter exists in the DCL, and 375k real monitored items would measure the OPC UA SDK. Injected at the documented `RegisterAdapter` seam, at the exact hand-off point the real adapter uses. | | D3 | **Simulated data source** instead of real OPC UA | No simulated adapter exists in the DCL, and 375k real monitored items would measure the OPC UA SDK. Injected at the documented `RegisterAdapter` seam, at the exact hand-off point the real adapter uses. |
| D4 | **gRPC socket replaced by a reader task** on the stream path | Required by the row-50 measurement itself: a stalled subscriber must be *held* stalled. All of `SiteStreamGrpcServer`'s per-subscription machinery above the socket is real. | | D4 | **gRPC socket replaced by a reader task** on the stream path | Required by the row-50 measurement itself: a stalled subscriber must be *held* stalled. All of `SiteStreamGrpcServer`'s per-subscription machinery above the socket is real. **Partially retired 2026-08-18:** the harness now ALSO attaches per-site wire probes — real Kestrel h2c + real `SiteStreamGrpcClient` with R2 batching negotiated (`--wire-probes-per-site`, default 10) — so the batcher/serialization/HTTP/2 hop is measured too; the substituted-reader probes remain because the slow-subscriber scenario still needs them. Results doc §8. |
| D5 | **Instance Actors are `/user` children**, not children of a `DeploymentManagerActor` | Constructing them directly is what lets the ramp be timed in isolation and keeps the harness independent of the deploy round-trip. Visible only as `InstanceActorInitialized` dead letters, which the harness suppresses. No measured path differs. | | D5 | **Instance Actors are `/user` children**, not children of a `DeploymentManagerActor` | Constructing them directly is what lets the ramp be timed in isolation and keeps the harness independent of the deploy round-trip. Visible only as `InstanceActorInitialized` dead letters, which the harness suppresses. No measured path differs. |
| D6 | **Akka logging at WARNING** | At 37,500 updates/s, INFO output would itself become a measured load. | | D6 | **Akka logging at WARNING** | At 37,500 updates/s, INFO output would itself become a measured load. |
| D7 | `[xc-3]` site event logging volume, `[xc-4]` audit-log degradation, `[xc-5]` template flattening, `[xc-8]` UI responsiveness **not covered** | Out of scope for this harness: the first three need central MS SQL and the Template Engine (a central-cluster fixture, not a site one), and `[xc-8]` needs a browser. Recorded as residual scope in the results doc rather than silently dropped. | | D7 | `[xc-3]` site event logging volume, `[xc-4]` audit-log degradation, `[xc-5]` template flattening, `[xc-8]` UI responsiveness **not covered** | Out of scope for this harness: the first three need central MS SQL and the Template Engine (a central-cluster fixture, not a site one), and `[xc-8]` needs a browser. Recorded as residual scope in the results doc rather than silently dropped. |
@@ -275,7 +275,10 @@ Stated plainly so the evidence is not over-read:
- **Not a real-network run.** The gRPC, health-transport and S&F-to-central hops are - **Not a real-network run.** The gRPC, health-transport and S&F-to-central hops are
in-process. Serialization cost, TLS, WAN latency and socket backpressure are out of in-process. Serialization cost, TLS, WAN latency and socket backpressure are out of
frame; the *stream* path's backpressure was measured deliberately by substituting a frame; the *stream* path's backpressure was measured deliberately by substituting a
controllable reader for the socket (D4). controllable reader for the socket (D4). **Partially superseded by §8 (2026-08-18):**
the site→central stream hop now also runs over a real loopback HTTP/2 socket with
the R2 batcher, protobuf serialization and Kestrel in the measured path. TLS and
WAN latency remain out of frame.
- **Not a real OPC UA run.** 375,000 monitored items were simulated at the adapter - **Not a real OPC UA run.** 375,000 monitored items were simulated at the adapter
callback (D3). The DCL above that callback is real and fully exercised. callback (D3). The DCL above that callback is real and fully exercised.
- **Not 1 hour.** 20 minutes (D1). See finding F1 for the one question this leaves - **Not 1 hour.** 20 minutes (D1). See finding F1 for the one question this leaves
@@ -323,6 +326,111 @@ dotnet test tests/ZB.MOM.WW.ScadaBridge.PerformanceTests \
`--help`-style knobs: `--sites`, `--instances-per-site`, `--tags-per-instance`, `--help`-style knobs: `--sites`, `--instances-per-site`, `--tags-per-instance`,
`--tag-update-period-seconds`, `--settle-minutes`, `--sustain-minutes`, `--tag-update-period-seconds`, `--settle-minutes`, `--sustain-minutes`,
`--sample-seconds`, `--health-interval-seconds`, `--debug-probe-interval-seconds`, `--sample-seconds`, `--health-interval-seconds`, `--debug-probe-interval-seconds`,
`--subscribe-settle-seconds`, `--stream-probes-per-site`, `--sf-drain-messages`, `--subscribe-settle-seconds`, `--stream-probes-per-site`, `--wire-probes-per-site`,
`--slow-subscriber-events`, `--data-dir`, `--results`. An unknown key is rejected `--sf-drain-messages`, `--slow-subscriber-events`, `--data-dir`, `--results`. An
rather than ignored, so a typo cannot silently change the measured scale. unknown key is rejected rather than ignored, so a typo cannot silently change the
measured scale.
Since 2026-08-18 the default protocol also attaches 10 **wire** stream subscribers
per site (real Kestrel + real `SiteStreamGrpcClient`, §8); `--wire-probes-per-site 0`
reproduces the original in-process-only shape.
---
## 8. Wire-path run — R2 stream batching in the hot path (2026-08-18)
**Raw metrics:** `docs/plans/2026-08-18-loadharness-results-wire.json`
### 8.1 Why this run exists
Both prior runs (§2 and the §4 F1 1-hour sustain) measured the stream path only up
to the bounded send channel: `StreamSubscriberProbe` deliberately substitutes the
final socket hop with a controllable reader (deviation D4 — required by the row-50
slow-subscriber measurement). The R2 gRPC event batcher (`SiteStreamEventBatcher`,
merged `c75c79f8` on 2026-08-15, about an hour *after* the §2 run) lives exactly in
that substituted hop — so no benchmark run had ever exercised it, only R2's own
component-level measurement (worst-case P99 25.4 ms) and trickle-rate live traffic
on the rig (register row 48).
This run adds a **wire path** to the harness without touching the existing probes:
each site hosts the production `SiteStreamGrpcServer` on a real Kestrel HTTP/2 (h2c)
loopback listener (production `CommunicationOptions` defaults — batch cap 100,
window 25 ms, channel capacity 1000), and the production `SiteStreamGrpcClient`
dials in with `BatchingSupported = true`, one channel per site with 10 streams
multiplexed on it — the exact production shape and the exact production code on both
ends. Latency is recorded in the client callback, i.e. after protobuf
deserialization, batch unpacking and domain mapping. The batch-size figures are the
production `scadabridge.site.stream.batch_size` histogram itself, captured by a
`MeterListener`. Deliberately not in the path: `ControlPlaneAuthInterceptor`
(authenticates once per stream lifetime, nothing per-event) and TLS (production is
h2c too).
The 100 wire subscribers are attached **in addition to** the 100 in-process probes,
watching the same instances (same stride), so one run yields both distributions and
their difference isolates the wire hop's cost. Total hub fan-out is therefore
double that of the §2 run.
### 8.2 Environment
Identical posture to §1: same host (M4 Pro, 14 cores, 48 GB), .NET 10.0.5, server
GC, Release; the 8-node `docker/` rig stayed running throughout (D8); 20-minute
window after a 2-minute settle; harness at the state committed with this section.
### 8.3 Measured
| | in-process probes (§2 path) | **wire probes (new)** |
|---|---|---|
| Subscribers | 100 | 100 |
| Events delivered (window) | 899,807 | **899,807** |
| Events dropped / faults | 0 / — | **0 / 0** |
| Latency mean | 1.79 ms | **28.00 ms** |
| Latency P50 | 1.36 ms | **26.97 ms** |
| Latency P95 | 4.19 ms | **32.07 ms** |
| Latency P99 | 6.74 ms | **43.44 ms** |
| Latency P99.9 / max | 25.83 / 32.17 ms | **53.94 / 62.42 ms** |
Batching (production histogram, window deltas):
```
events 899,807 frames 12,462 → mean 72.20 events/frame (72x frame reduction)
batch size p50/p95/p99/max = 75/75/75/75 batched frames 11,998 single-event frames 464
server channel drops 0 stream faults 0 graceful completions 0
```
Offered load held at **37,497/s = 100.0% of nominal** with the doubled fan-out
(driver overrun 0.0 s, skipped 0). Whole-run CPU mean **66% of one core (4.7% of
the box)** vs 41% in §2 — the added ~25% of one core buys 10 Kestrel listeners, 100
live HTTP/2 streams, protobuf serialization of ~900 k events and the client-side
unpack. Working-set slope **−116 MB/min** (a large mid-window release; peak
3,413 MB), heap sawtooth with 5 gen-2 collects and endpoints 1,876→1,943 MB —
consistent with the §4 F1 resolution (row 29: not a leak).
### 8.4 Reading the numbers
- **The batcher engaged exactly as designed, at its natural batch size.** The
driver emits each instance's 75 tags contiguously within a slice, so a subscriber
receives one 75-event burst per 10 s period. 75 < the 100-event cap, so the
size cap never closes a batch early — nearly every burst becomes exactly one
75-event frame (p50 = p99 = max = 75). The 464 single-event frames are burst
stragglers that arrived after their batch's window closed.
- **The ~26 ms wire P50 is dominated by the deliberate linger, and that is the
documented trade.** Because bursts never hit the cap, every batch pays the full
25 ms `GrpcStreamBatchWindow` — this workload is the batcher's *worst-case latency
posture and best-case framing win* simultaneously. Wire P50 26.97 ms ≈ in-process
P50 1.36 ms + the 25 ms window + sub-millisecond socket/serialize cost. Against
the 250 ms P99 threshold the wire path passes with a **5.8× margin** (43.44 ms),
and the `CommunicationOptionsValidator` bound (window strictly < 250 ms) is
confirmed to compose: end-to-end latency stayed threshold-compliant with the
window fully paid on every frame.
- **Framing efficiency at scale is now measured, not extrapolated:** 899,807 events
crossed the wire in 12,462 frames. Pre-R2, the same window would have cost
899,807 frames.
- **Nothing degraded elsewhere.** The in-process path under doubled fan-out reads
P99 6.74 ms vs 4.57 ms in §2 — same order, still a 37× margin — and every §2
pass criterion that this run re-exercises (offered load, zero drops at healthy
subscribers, health reports, debug snapshots, S&F drain, slow-subscriber
isolation) repeated its pass.
**Verdict: PASS.** R2's negotiated batching is now benchmark-proven at target scale
over a real socket: 72× fewer frames, zero loss, zero faults, and worst-case-posture
latency 5.8× inside the budget.
File diff suppressed because it is too large Load Diff
@@ -68,6 +68,19 @@ public sealed record HarnessConfig
/// </summary> /// </summary>
public int StreamProbesPerSite { get; init; } = 10; public int StreamProbesPerSite { get; init; } = 10;
/// <summary>
/// Instances per site additionally watched over a REAL gRPC hop: a per-site
/// Kestrel h2c listener hosts the production <c>SiteStreamGrpcServer</c> and the
/// production <c>SiteStreamGrpcClient</c> dials in with batching negotiated, so
/// the R2 <c>SiteStreamEventBatcher</c>, protobuf serialization and HTTP/2 flow
/// control are inside the measured path (they are NOT for
/// <see cref="StreamProbesPerSite"/>, whose final hop is deliberately an
/// in-process reader). 0 disables the wire path entirely. Like the in-process
/// probes, each wire subscriber's stream graph sees the full site event flow and
/// filters it, so this adds to the fan-out multiplier.
/// </summary>
public int WireProbesPerSite { get; init; } = 10;
/// <summary>Store-and-forward messages enqueued for the drain-rate measurement (register row 50).</summary> /// <summary>Store-and-forward messages enqueued for the drain-rate measurement (register row 50).</summary>
public int StoreAndForwardDrainMessages { get; init; } = 20_000; public int StoreAndForwardDrainMessages { get; init; } = 20_000;
@@ -131,6 +144,7 @@ public sealed record HarnessConfig
"health-interval-seconds" => config with { HealthReportInterval = TimeSpan.FromSeconds(double.Parse(value)) }, "health-interval-seconds" => config with { HealthReportInterval = TimeSpan.FromSeconds(double.Parse(value)) },
"debug-probe-interval-seconds" => config with { DebugProbeInterval = TimeSpan.FromSeconds(double.Parse(value)) }, "debug-probe-interval-seconds" => config with { DebugProbeInterval = TimeSpan.FromSeconds(double.Parse(value)) },
"stream-probes-per-site" => config with { StreamProbesPerSite = int.Parse(value) }, "stream-probes-per-site" => config with { StreamProbesPerSite = int.Parse(value) },
"wire-probes-per-site" => config with { WireProbesPerSite = int.Parse(value) },
"sf-drain-messages" => config with { StoreAndForwardDrainMessages = int.Parse(value) }, "sf-drain-messages" => config with { StoreAndForwardDrainMessages = int.Parse(value) },
"slow-subscriber-events" => config with { SlowSubscriberEvents = int.Parse(value) }, "slow-subscriber-events" => config with { SlowSubscriberEvents = int.Parse(value) },
"data-dir" => config with { DataDirectory = value }, "data-dir" => config with { DataDirectory = value },
@@ -40,6 +40,10 @@ namespace ZB.MOM.WW.ScadaBridge.LoadHarness;
/// <param name="StreamProbeDropped">Events evicted by live subscribers' bounded channels.</param> /// <param name="StreamProbeDropped">Events evicted by live subscribers' bounded channels.</param>
/// <param name="StoreAndForwardDrain">Store-and-forward drain measurement (register row 50).</param> /// <param name="StoreAndForwardDrain">Store-and-forward drain measurement (register row 50).</param>
/// <param name="SlowSubscriber">Slow-subscriber isolation measurement (register row 50).</param> /// <param name="SlowSubscriber">Slow-subscriber isolation measurement (register row 50).</param>
/// <param name="WireStream">
/// Wire-path stream measurement — real Kestrel + real <c>SiteStreamGrpcClient</c> with
/// R2 batching negotiated — or null when <see cref="HarnessConfig.WireProbesPerSite"/> is 0.
/// </param>
public sealed record HarnessRunResult( public sealed record HarnessRunResult(
HarnessConfig Config, HarnessConfig Config,
EnvironmentInfo Environment, EnvironmentInfo Environment,
@@ -67,7 +71,30 @@ public sealed record HarnessRunResult(
long StreamProbeReceived, long StreamProbeReceived,
long StreamProbeDropped, long StreamProbeDropped,
StoreAndForwardDrainResult? StoreAndForwardDrain, StoreAndForwardDrainResult? StoreAndForwardDrain,
SlowSubscriberResult? SlowSubscriber); SlowSubscriberResult? SlowSubscriber,
WireStreamResult? WireStream);
/// <summary>
/// Steady-state-window measurement of the wire-path stream probes: the production
/// gRPC server (Kestrel h2c) and client with the R2 batcher, protobuf serialization
/// and HTTP/2 flow control INSIDE the measured path. All figures are deltas over the
/// steady-state window only.
/// </summary>
/// <param name="ProbeCount">Wire subscribers attached across the fleet.</param>
/// <param name="Latency">End-to-end DCL-boundary → post-unpack client callback latency.</param>
/// <param name="EventsReceived">Domain events delivered by the client in the window.</param>
/// <param name="ServerDroppedEvents">Events evicted by the wire servers' bounded send channels in the window.</param>
/// <param name="StreamFaults">Stream faults reported by the client (expected 0).</param>
/// <param name="GracefulCompletions">Graceful server-side stream completions (expected 0 inside the 4 h lifetime).</param>
/// <param name="Batches">The frame/batch-size distribution the R2 batcher actually produced.</param>
public sealed record WireStreamResult(
int ProbeCount,
LatencySnapshot Latency,
long EventsReceived,
long ServerDroppedEvents,
long StreamFaults,
long GracefulCompletions,
BatchSizeWindow Batches);
/// <summary>Host and runtime facts recorded alongside the numbers.</summary> /// <summary>Host and runtime facts recorded alongside the numbers.</summary>
/// <param name="MachineName">Host name.</param> /// <param name="MachineName">Host name.</param>
@@ -120,6 +147,7 @@ public static class HarnessRun
ObservabilityProbes? probes = null; ObservabilityProbes? probes = null;
StoreAndForwardDrainResult? drainResult = null; StoreAndForwardDrainResult? drainResult = null;
SlowSubscriberResult? slowResult = null; SlowSubscriberResult? slowResult = null;
BatchSizeAggregator? batchAggregator = null;
var aggregator = new CentralHealthAggregator( var aggregator = new CentralHealthAggregator(
Options.Create(new HealthMonitoringOptions Options.Create(new HealthMonitoringOptions
@@ -154,6 +182,18 @@ public static class HarnessRun
site.AttachStreamProbes(latency); site.AttachStreamProbes(latency);
log($" {sites.Sum(s => s.Probes.Count)} live stream subscribers attached"); log($" {sites.Sum(s => s.Probes.Count)} live stream subscribers attached");
// Wire path (real gRPC): the aggregator must exist BEFORE the first stream
// opens or the MeterListener misses early frames.
var wireLatency = new LatencyHistogram();
if (config.WireProbesPerSite > 0)
{
batchAggregator = new BatchSizeAggregator();
foreach (var site in sites)
await site.AttachWireStreamProbesAsync(config.WireProbesPerSite, wireLatency, log);
log($" {sites.Sum(s => s.WireSubscribers.Count)} wire stream subscribers attached " +
"(real Kestrel + SiteStreamGrpcClient, batching negotiated)");
}
// Let Instance Actors complete their DCL subscribe round-trips before the // Let Instance Actors complete their DCL subscribe round-trips before the
// driver starts; an emit before SubscribeBatchAsync has captured the // driver starts; an emit before SubscribeBatchAsync has captured the
// callback would be silently discarded. // callback would be silently discarded.
@@ -184,6 +224,18 @@ public static class HarnessRun
probe.RetargetLatency(steadyLatency); probe.RetargetLatency(steadyLatency);
} }
// Wire path: same ramp/steady histogram separation, plus window-start
// baselines for the client/server counters and the batch distribution.
var steadyWireLatency = new LatencyHistogram();
var wireReceivedAtWindowStart = sites.Sum(s => s.WireSubscribers.Sum(p => p.ReceivedEvents));
var wireDroppedAtWindowStart = sites.Sum(s => s.WireHost?.Server.DroppedStreamEventCount ?? 0);
var batchesAtWindowStart = batchAggregator?.Snapshot();
foreach (var site in sites)
{
foreach (var wire in site.WireSubscribers)
wire.RetargetLatency(steadyWireLatency);
}
var windowWatch = Stopwatch.StartNew(); var windowWatch = Stopwatch.StartNew();
log($"Sustained measurement window: {config.SustainDuration.TotalMinutes:F1} min..."); log($"Sustained measurement window: {config.SustainDuration.TotalMinutes:F1} min...");
await Task.Delay(config.SustainDuration, cancellationToken); await Task.Delay(config.SustainDuration, cancellationToken);
@@ -199,6 +251,24 @@ public static class HarnessRun
var probeReceived = sites.Sum(s => s.Probes.Sum(p => p.ReceivedEvents)) - receivedAtWindowStart; var probeReceived = sites.Sum(s => s.Probes.Sum(p => p.ReceivedEvents)) - receivedAtWindowStart;
var probeDropped = sites.Sum(s => s.Probes.Sum(p => p.DroppedEvents)) - droppedAtWindowStart; var probeDropped = sites.Sum(s => s.Probes.Sum(p => p.DroppedEvents)) - droppedAtWindowStart;
// Wire-path window deltas, captured BEFORE the row-50 scenarios below so
// their publishing cannot contaminate the wire figures.
WireStreamResult? wireResult = null;
if (batchAggregator is not null)
{
wireResult = new WireStreamResult(
ProbeCount: sites.Sum(s => s.WireSubscribers.Count),
Latency: steadyWireLatency.Snapshot(),
EventsReceived: sites.Sum(s => s.WireSubscribers.Sum(p => p.ReceivedEvents)) - wireReceivedAtWindowStart,
ServerDroppedEvents: sites.Sum(s => s.WireHost?.Server.DroppedStreamEventCount ?? 0) - wireDroppedAtWindowStart,
StreamFaults: sites.Sum(s => s.WireSubscribers.Sum(p => p.Faults)),
GracefulCompletions: sites.Sum(s => s.WireSubscribers.Sum(p => p.GracefulCompletions)),
Batches: BatchSizeWindow.From(batchesAtWindowStart!, batchAggregator.Snapshot()));
log($" wire path: {wireResult.EventsReceived:N0} events in {wireResult.Batches.Frames:N0} frames " +
$"(mean {wireResult.Batches.MeanEventsPerFrame:F1} ev/frame, max {wireResult.Batches.Max}), " +
$"P99 {wireResult.Latency.P99Ms:F2}ms");
}
// Register row 50 — measured after the sustained window so the numbers are // Register row 50 — measured after the sustained window so the numbers are
// not competing with the full tag load for CPU, and reported separately for // not competing with the full tag load for CPU, and reported separately for
// the same reason. // the same reason.
@@ -250,12 +320,14 @@ public static class HarnessRun
StreamProbeReceived: probeReceived, StreamProbeReceived: probeReceived,
StreamProbeDropped: probeDropped, StreamProbeDropped: probeDropped,
StoreAndForwardDrain: drainResult, StoreAndForwardDrain: drainResult,
SlowSubscriber: slowResult); SlowSubscriber: slowResult,
WireStream: wireResult);
} }
finally finally
{ {
if (driver != null) await driver.DisposeAsync(); if (driver != null) await driver.DisposeAsync();
if (probes != null) await probes.DisposeAsync(); if (probes != null) await probes.DisposeAsync();
batchAggregator?.Dispose();
await sampler.DisposeAsync(); await sampler.DisposeAsync();
log("Tearing down sites..."); log("Tearing down sites...");
@@ -0,0 +1,136 @@
using System.Diagnostics.Metrics;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
namespace ZB.MOM.WW.ScadaBridge.LoadHarness.Metrics;
/// <summary>
/// Captures the production <c>scadabridge.site.stream.batch_size</c> histogram via a
/// <see cref="MeterListener"/>, so a wire-path run can report the actual frame/batch
/// distribution the R2 batcher produced — not a client-side reconstruction. The
/// instrument is recorded once per emitted gRPC frame (negotiated streams only), with
/// the frame's event count as the value, which makes <c>count = frames</c> and
/// <c>sum = events</c> exactly.
///
/// <para>
/// The harness hosts every site's <see cref="ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer"/>
/// in-process, so this listener sees every wire frame the run emits and nothing else —
/// no other component in the harness records this instrument.
/// </para>
/// </summary>
public sealed class BatchSizeAggregator : IDisposable
{
/// <summary>
/// Sizes above this are clamped into the last bucket. The production cap is
/// <c>GrpcStreamBatchMaxEvents</c> = 100, so 512 leaves generous headroom for a
/// configured-up run without an unbounded array.
/// </summary>
public const int MaxTrackedBatchSize = 512;
private readonly MeterListener _listener;
private readonly long[] _framesBySize = new long[MaxTrackedBatchSize + 1];
/// <summary>Starts listening. Must be created BEFORE the first stream opens.</summary>
public BatchSizeAggregator()
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == ScadaBridgeTelemetry.MeterName &&
instrument.Name == "scadabridge.site.stream.batch_size")
{
listener.EnableMeasurementEvents(instrument);
}
},
};
_listener.SetMeasurementEventCallback<int>((_, value, _, _) =>
{
var size = Math.Clamp(value, 1, MaxTrackedBatchSize);
Interlocked.Increment(ref _framesBySize[size]);
});
_listener.Start();
}
/// <summary>Copies the cumulative per-size frame counts.</summary>
/// <returns>Index = events-per-frame (clamped), value = frames emitted at that size.</returns>
public long[] Snapshot()
{
var copy = new long[_framesBySize.Length];
for (var i = 0; i < copy.Length; i++)
copy[i] = Interlocked.Read(ref _framesBySize[i]);
return copy;
}
/// <inheritdoc />
public void Dispose() => _listener.Dispose();
}
/// <summary>
/// Batch-size distribution over one measurement window, computed from two
/// <see cref="BatchSizeAggregator.Snapshot"/>s. Percentiles are exact (integer sizes,
/// full counts — nothing is bucketed away).
/// </summary>
/// <param name="Frames">gRPC frames written in the window.</param>
/// <param name="Events">Events those frames carried.</param>
/// <param name="BatchedFrames">Frames that carried more than one event.</param>
/// <param name="MeanEventsPerFrame">Events / Frames.</param>
/// <param name="P50">Median events-per-frame.</param>
/// <param name="P95">95th percentile events-per-frame.</param>
/// <param name="P99">99th percentile events-per-frame.</param>
/// <param name="Max">Largest frame observed in the window.</param>
public sealed record BatchSizeWindow(
long Frames,
long Events,
long BatchedFrames,
double MeanEventsPerFrame,
int P50,
int P95,
int P99,
int Max)
{
/// <summary>Computes the window distribution from cumulative start/end snapshots.</summary>
/// <param name="start">Snapshot at window start.</param>
/// <param name="end">Snapshot at window end.</param>
/// <returns>The distribution of frames emitted between the two snapshots.</returns>
public static BatchSizeWindow From(long[] start, long[] end)
{
long frames = 0, events = 0, batched = 0;
var max = 0;
var delta = new long[end.Length];
for (var size = 1; size < end.Length; size++)
{
var n = end[size] - start[size];
if (n <= 0) continue;
delta[size] = n;
frames += n;
events += n * size;
if (size > 1) batched += n;
max = size;
}
return new BatchSizeWindow(
frames, events, batched,
frames == 0 ? 0 : events / (double)frames,
Percentile(delta, frames, 50),
Percentile(delta, frames, 95),
Percentile(delta, frames, 99),
max);
}
private static int Percentile(long[] delta, long frames, double percentile)
{
if (frames == 0) return 0;
var target = (long)Math.Ceiling(frames * percentile / 100.0);
if (target < 1) target = 1;
long cumulative = 0;
for (var size = 1; size < delta.Length; size++)
{
cumulative += delta[size];
if (cumulative >= target)
return size;
}
return delta.Length - 1;
}
}
@@ -0,0 +1,96 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.LoadHarness.Probes;
/// <summary>
/// The site half of the wire-path stream probe: a real Kestrel HTTP/2 (h2c) listener on
/// a loopback ephemeral port, serving the PRODUCTION <see cref="SiteStreamGrpcServer"/>
/// over the site's real <see cref="ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming.SiteStreamManager"/>.
///
/// <para>
/// This is the hop <see cref="StreamSubscriberProbe"/> deliberately substitutes with a
/// controllable in-process reader (which the slow-subscriber scenario needs), and
/// therefore the hop the original WP-4 runs never exercised: the
/// <c>SiteStreamEventBatcher</c> pump (R2), protobuf serialization,
/// <c>IServerStreamWriter.WriteAsync</c>, and HTTP/2 flow control. The server is built
/// through its production DI constructor with default <see cref="CommunicationOptions"/>,
/// so the batch negotiation (100 events / 25 ms window), channel capacities and the
/// concurrency cap are exactly what ships.
/// </para>
///
/// <para>
/// Deliberately NOT in the path: <c>ControlPlaneAuthInterceptor</c> (it lives in the
/// Host project and authenticates once per CALL — one header compare per stream
/// lifetime, nothing on the per-event path) and TLS (production is h2c too).
/// </para>
/// </summary>
public sealed class SiteWireStreamHost : IAsyncDisposable
{
private readonly WebApplication _app;
/// <summary>The hosted production gRPC server (drop counters, active stream count).</summary>
public SiteStreamGrpcServer Server { get; }
/// <summary>The dialable endpoint, e.g. <c>http://127.0.0.1:54321</c>.</summary>
public string Endpoint { get; }
private SiteWireStreamHost(WebApplication app, SiteStreamGrpcServer server, string endpoint)
{
_app = app;
Server = server;
Endpoint = endpoint;
}
/// <summary>
/// Builds, starts and readies the listener for one site.
/// </summary>
/// <param name="site">The site whose stream manager and actor system back the server.</param>
/// <returns>The started host.</returns>
public static async Task<SiteWireStreamHost> StartAsync(SiteRuntimeFixture site)
{
var server = new SiteStreamGrpcServer(
site.StreamManager,
Microsoft.Extensions.Logging.Abstractions.NullLogger<SiteStreamGrpcServer>.Instance,
Options.Create(new CommunicationOptions()));
var builder = WebApplication.CreateSlimBuilder();
builder.Logging.ClearProviders();
builder.WebHost.ConfigureKestrel(options =>
options.Listen(System.Net.IPAddress.Loopback, 0, listenOptions =>
listenOptions.Protocols = HttpProtocols.Http2));
builder.Services.AddGrpc();
builder.Services.AddSingleton(server);
var app = builder.Build();
app.MapGrpcService<SiteStreamGrpcServer>();
await app.StartAsync();
// Same post-start readiness handoff the production host performs.
server.SetReady(site.System);
var address = app.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>()?.Addresses.FirstOrDefault()
?? throw new InvalidOperationException("Kestrel bound no address.");
return new SiteWireStreamHost(app, server, address);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
// Mirrors the production shutdown ordering: cancel active streams first so
// their pumps observe cancellation, then stop the listener.
Server.CancelAllStreams();
await _app.StopAsync();
await _app.DisposeAsync();
}
}
@@ -0,0 +1,108 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using ZB.MOM.WW.ScadaBridge.LoadHarness.Metrics;
namespace ZB.MOM.WW.ScadaBridge.LoadHarness.Probes;
/// <summary>
/// The central half of the wire-path stream probe: one instance subscription opened
/// through the PRODUCTION <see cref="SiteStreamGrpcClient"/> — the exact
/// <c>SubscribeAsync</c> path central's <c>DebugStreamBridgeActor</c> uses, including
/// the R2 batch negotiation (<c>BatchingSupported = true</c>) and the client-side
/// batch unpack + proto→domain conversion.
///
/// <para>
/// Latency is recorded in <c>onEvent</c>, i.e. AFTER wire deserialization, batch
/// unpacking and domain mapping — the same "DCL boundary → subscriber" measurement as
/// <see cref="StreamSubscriberProbe"/>, extended across the real socket. The emit
/// instant travels verbatim on the proto <c>Timestamp</c>, so the subtraction stays a
/// genuine end-to-end figure (same process, same clock).
/// </para>
/// </summary>
public sealed class WireStreamSubscriber
{
private Task _streamTask = Task.CompletedTask;
private volatile LatencyHistogram? _latency;
private long _received;
private long _faults;
private long _completions;
/// <summary>Probe name (also the stream's correlation id).</summary>
public string Name { get; }
/// <summary>Domain events delivered by the client callback.</summary>
public long ReceivedEvents => Interlocked.Read(ref _received);
/// <summary>Stream faults reported via <c>onError</c>. Expected 0 for a healthy run.</summary>
public long Faults => Interlocked.Read(ref _faults);
/// <summary>Graceful server-side completions via <c>onCompleted</c>. Expected 0 inside a run shorter than the 4 h stream lifetime.</summary>
public long GracefulCompletions => Interlocked.Read(ref _completions);
private WireStreamSubscriber(string name, LatencyHistogram? latency)
{
Name = name;
_latency = latency;
}
/// <summary>
/// Opens one instance stream on the shared per-site client. The subscription task
/// runs until <paramref name="ct"/> cancels — the harness deliberately does NOT
/// reconnect on fault (a fault inside a 25-minute loopback run is a finding, not
/// an operational event to paper over), it counts it.
/// </summary>
/// <param name="client">The site's shared production gRPC client (one channel per site, streams multiplexed).</param>
/// <param name="instanceUniqueName">Instance whose events this probe receives.</param>
/// <param name="name">Probe/correlation name (must be a valid Akka path element).</param>
/// <param name="latency">Histogram fed with end-to-end event latency.</param>
/// <param name="log">Progress sink for fault reporting.</param>
/// <param name="ct">Stops the subscription.</param>
/// <returns>The attached subscriber.</returns>
public static WireStreamSubscriber Attach(
SiteStreamGrpcClient client,
string instanceUniqueName,
string name,
LatencyHistogram? latency,
Action<string> log,
CancellationToken ct)
{
var self = new WireStreamSubscriber(name, latency);
self._streamTask = client.SubscribeAsync(
correlationId: name,
instanceUniqueName: instanceUniqueName,
onEvent: evt =>
{
Interlocked.Increment(ref self._received);
if (self._latency is { } histogram && evt is AttributeValueChanged changed)
histogram.Record(DateTimeOffset.UtcNow - changed.Timestamp);
},
onError: ex =>
{
Interlocked.Increment(ref self._faults);
log($" wire probe {name} stream fault: {ex.GetType().Name}: {ex.Message}");
},
onCompleted: () => Interlocked.Increment(ref self._completions),
ct);
return self;
}
/// <summary>Repoints the latency histogram (steady-state window separation) — mirrors <see cref="StreamSubscriberProbe.RetargetLatency"/>.</summary>
/// <param name="latency">The histogram to record into from now on, or null to stop recording.</param>
public void RetargetLatency(LatencyHistogram? latency) => _latency = latency;
/// <summary>Awaits the subscription task after its token has been cancelled.</summary>
/// <returns>A task that completes when the stream loop has exited.</returns>
public async Task WaitForShutdownAsync()
{
try
{
await _streamTask;
}
catch (OperationCanceledException)
{
// Normal teardown.
}
}
}
@@ -22,6 +22,7 @@ Console.WriteLine($" sites {config.Sites}");
Console.WriteLine($" instances/site {config.InstancesPerSite}"); Console.WriteLine($" instances/site {config.InstancesPerSite}");
Console.WriteLine($" tags/instance {config.TagsPerInstance}"); Console.WriteLine($" tags/instance {config.TagsPerInstance}");
Console.WriteLine($" total subscriptions {config.TotalSubscriptions:N0}"); Console.WriteLine($" total subscriptions {config.TotalSubscriptions:N0}");
Console.WriteLine($" stream probes/site {config.StreamProbesPerSite} in-process + {config.WireProbesPerSite} wire (real gRPC)");
Console.WriteLine($" nominal update rate {config.NominalUpdatesPerSecond:N0}/s"); Console.WriteLine($" nominal update rate {config.NominalUpdatesPerSecond:N0}/s");
Console.WriteLine($" settle / sustain {config.SettleDuration.TotalMinutes:F1} / {config.SustainDuration.TotalMinutes:F1} min"); Console.WriteLine($" settle / sustain {config.SettleDuration.TotalMinutes:F1} / {config.SustainDuration.TotalMinutes:F1} min");
Console.WriteLine(); Console.WriteLine();
@@ -47,6 +47,18 @@ public static class ResultsFormatter
builder.AppendLine(culture, $"events dropped {result.StreamProbeDropped:N0}"); builder.AppendLine(culture, $"events dropped {result.StreamProbeDropped:N0}");
builder.AppendLine(); builder.AppendLine();
if (result.WireStream is { } wire)
{
builder.AppendLine("-- wire stream subscribers (real gRPC, R2 batching in path) --");
builder.AppendLine(culture, $"probes {wire.ProbeCount} (Kestrel h2c + SiteStreamGrpcClient, batching negotiated)");
AppendLatency(builder, culture, wire.Latency);
builder.AppendLine(culture, $"events delivered {wire.EventsReceived:N0} in {wire.Batches.Frames:N0} frames");
builder.AppendLine(culture, $"batching mean {wire.Batches.MeanEventsPerFrame:F2} ev/frame p50 {wire.Batches.P50} p95 {wire.Batches.P95} p99 {wire.Batches.P99} max {wire.Batches.Max} ({wire.Batches.BatchedFrames:N0} frames >1 event)");
builder.AppendLine(culture, $"server channel drops {wire.ServerDroppedEvents:N0}");
builder.AppendLine(culture, $"faults / completions {wire.StreamFaults:N0} / {wire.GracefulCompletions:N0}");
builder.AppendLine();
}
builder.AppendLine("-- health report delivery --"); builder.AppendLine("-- health report delivery --");
AppendLatency(builder, culture, result.HealthReportLatency); AppendLatency(builder, culture, result.HealthReportLatency);
builder.AppendLine(culture, $"reports ingested {result.HealthReportsDelivered:N0}"); builder.AppendLine(culture, $"reports ingested {result.HealthReportsDelivered:N0}");
@@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.LocalDb; using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
@@ -43,7 +44,11 @@ public sealed class SiteRuntimeFixture : IAsyncDisposable
private readonly ILocalDb _localDb; private readonly ILocalDb _localDb;
private readonly List<IActorRef> _instanceActors = new(); private readonly List<IActorRef> _instanceActors = new();
private readonly List<StreamSubscriberProbe> _probes = new(); private readonly List<StreamSubscriberProbe> _probes = new();
private readonly List<WireStreamSubscriber> _wireSubscribers = new();
private readonly string _dataDirectory; private readonly string _dataDirectory;
private SiteWireStreamHost? _wireHost;
private SiteStreamGrpcClient? _wireClient;
private CancellationTokenSource? _wireCts;
/// <summary>Site identifier, e.g. <c>site-01</c>.</summary> /// <summary>Site identifier, e.g. <c>site-01</c>.</summary>
public string SiteId { get; } public string SiteId { get; }
@@ -72,6 +77,12 @@ public sealed class SiteRuntimeFixture : IAsyncDisposable
/// <summary>The live stream subscribers attached to this site.</summary> /// <summary>The live stream subscribers attached to this site.</summary>
public IReadOnlyList<StreamSubscriberProbe> Probes => _probes; public IReadOnlyList<StreamSubscriberProbe> Probes => _probes;
/// <summary>The wire-path (real gRPC) subscribers attached to this site, if any.</summary>
public IReadOnlyList<WireStreamSubscriber> WireSubscribers => _wireSubscribers;
/// <summary>The site's real gRPC listener, when wire probes are attached.</summary>
public SiteWireStreamHost? WireHost => _wireHost;
/// <summary>Instance Actors created on this site.</summary> /// <summary>Instance Actors created on this site.</summary>
public IReadOnlyList<IActorRef> InstanceActors => _instanceActors; public IReadOnlyList<IActorRef> InstanceActors => _instanceActors;
@@ -344,12 +355,63 @@ public sealed class SiteRuntimeFixture : IAsyncDisposable
} }
} }
/// <summary>
/// Starts the site's real Kestrel/gRPC listener and attaches
/// <paramref name="count"/> subscribers through the production
/// <see cref="SiteStreamGrpcClient"/> — one channel per site with the streams
/// multiplexed on it, exactly the production shape (central keys one client per
/// <c>(siteId, endpoint)</c>). Instance selection uses the same stride as
/// <see cref="AttachStreamProbes"/>, so the wire probes watch the SAME instances
/// as the in-process probes and the two latency distributions are directly
/// comparable within one run.
/// </summary>
/// <param name="count">Wire subscribers to attach.</param>
/// <param name="latency">Histogram that receives end-to-end (over-the-wire) latencies.</param>
/// <param name="log">Progress sink for fault reporting.</param>
/// <returns>
/// A task that completes when the listener is up and every stream has been
/// initiated. Stream ESTABLISHMENT finishes asynchronously — comfortably inside
/// the subscribe-settle + settle windows that separate attach from measurement.
/// </returns>
public async Task AttachWireStreamProbesAsync(int count, LatencyHistogram latency, Action<string> log)
{
_wireHost = await SiteWireStreamHost.StartAsync(this);
_wireClient = new SiteStreamGrpcClient(_wireHost.Endpoint, NullLogger.Instance);
_wireCts = new CancellationTokenSource();
var stride = Math.Max(1, _config.InstancesPerSite / Math.Max(1, count));
for (var p = 0; p < count; p++)
{
var instanceIndex = Math.Min(p * stride, _config.InstancesPerSite - 1);
var subscriber = WireStreamSubscriber.Attach(
_wireClient, InstanceName(instanceIndex), $"wire-{SiteId}-p{p:D2}",
latency, log, _wireCts.Token);
_wireSubscribers.Add(subscriber);
}
}
/// <summary>Total tag paths registered across this site's connections.</summary> /// <summary>Total tag paths registered across this site's connections.</summary>
public int TotalTagPaths => TagPathsByConnection.Sum(list => list.Count); public int TotalTagPaths => TagPathsByConnection.Sum(list => list.Count);
/// <inheritdoc /> /// <inheritdoc />
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
// Wire path first: cancel the client streams, wait for their loops to exit,
// then drop the channel and the listener — the same order production teardown
// takes (client goes away, server cancels, actor system last).
if (_wireCts is not null)
{
await _wireCts.CancelAsync();
foreach (var subscriber in _wireSubscribers)
await subscriber.WaitForShutdownAsync();
_wireCts.Dispose();
}
if (_wireClient is not null)
await _wireClient.DisposeAsync();
if (_wireHost is not null)
await _wireHost.DisposeAsync();
foreach (var probe in _probes) foreach (var probe in _probes)
await probe.DisposeAsync(); await probe.DisposeAsync();
@@ -27,6 +27,14 @@
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection> <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<!-- Wire-path probes: each site hosts the production SiteStreamGrpcServer on a
real Kestrel h2c listener so the R2 batcher + serialization + HTTP/2 flow
control sit inside the measured path (see Probes/SiteWireStreamHost.cs). -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Grpc.AspNetCore" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" /> <ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj" /> <ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj" />
@@ -49,6 +49,7 @@ public class TargetScaleHarnessSmokeTests
DebugProbeInterval = TimeSpan.FromSeconds(2), DebugProbeInterval = TimeSpan.FromSeconds(2),
SubscribeSettleDuration = TimeSpan.FromSeconds(5), SubscribeSettleDuration = TimeSpan.FromSeconds(5),
StreamProbesPerSite = 3, StreamProbesPerSite = 3,
WireProbesPerSite = 2,
StoreAndForwardDrainMessages = 200, StoreAndForwardDrainMessages = 200,
SlowSubscriberEvents = 2_000, SlowSubscriberEvents = 2_000,
ResultsPath = Path.Combine(Path.GetTempPath(), $"loadharness-smoke-{Guid.NewGuid():N}.json"), ResultsPath = Path.Combine(Path.GetTempPath(), $"loadharness-smoke-{Guid.NewGuid():N}.json"),
@@ -64,6 +65,18 @@ public class TargetScaleHarnessSmokeTests
Assert.True(result.StreamProbeReceived > 0, "Live stream subscribers received nothing."); Assert.True(result.StreamProbeReceived > 0, "Live stream subscribers received nothing.");
Assert.Equal(0, result.DriverSkippedNoCallback); Assert.Equal(0, result.DriverSkippedNoCallback);
// The wire path (real Kestrel + real SiteStreamGrpcClient, R2 batching
// negotiated) carried traffic end to end: events crossed a real HTTP/2
// socket, the batch-size histogram observed real frames, and nothing faulted.
Assert.NotNull(result.WireStream);
Assert.True(result.WireStream!.EventsReceived > 0,
"Wire subscribers received nothing — the Kestrel/gRPC hop did not carry traffic.");
Assert.True(result.WireStream.Latency.Count > 0,
"No wire latency samples — the over-the-wire measurement path is broken.");
Assert.True(result.WireStream.Batches.Frames > 0,
"The batch-size MeterListener observed no frames — the R2 histogram path is broken.");
Assert.Equal(0, result.WireStream.StreamFaults);
// Observability held up. // Observability held up.
Assert.Equal(config.Sites, result.SitesTrackedByAggregator); Assert.Equal(config.Sites, result.SitesTrackedByAggregator);
Assert.True(result.DebugSnapshotsCompleted > 0, "No debug snapshot completed."); Assert.True(result.DebugSnapshotsCompleted > 0, "No debug snapshot completed.");