Merge branch 'grpc-event-batching' — additive site-stream event batching, negotiated, 100ev/25ms window (residual #3 / R2)
This commit is contained in:
@@ -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.
|
||||
- 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`).
|
||||
- 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). Generated C# is **vendored** under `Communication/SiteStreamGrpc/` with the `<Protobuf>` include commented out — regeneration is a manual toggle-build-copy-untoggle. **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. 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.
|
||||
- **`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.
|
||||
|
||||
@@ -91,14 +91,14 @@ Delivered 2026-07-10 (`docs/plans/2026-07-10-aggregated-live-alarm-stream-plan.m
|
||||
|
||||
#### Site-Side gRPC Streaming Components
|
||||
|
||||
- **SiteStreamGrpcServer**: gRPC service (`SiteStreamService.SiteStreamServiceBase`) hosted on each site node via Kestrel HTTP/2 on a dedicated port (default 8083). Implements the `SubscribeInstance` RPC. For each subscription, creates a `StreamRelayActor` that subscribes to `SiteStreamManager`, bridges events through a `Channel<SiteStreamEvent>` to the gRPC response stream. Tracks active subscriptions by `correlation_id` — duplicate IDs cancel the old stream. Enforces a max concurrent stream limit (default 100). Rejects streams with `StatusCode.Unavailable` before the actor system is ready.
|
||||
- **SiteStreamGrpcServer**: gRPC service (`SiteStreamService.SiteStreamServiceBase`) hosted on each site node via Kestrel HTTP/2 on a dedicated port (default 8083). Implements the `SubscribeInstance` RPC. For each subscription, creates a `StreamRelayActor` that subscribes to `SiteStreamManager`, bridges events through a `Channel<SiteStreamEvent>` to the gRPC response stream — via `SiteStreamEventBatcher`, the per-subscriber coalescing pump that optionally packs several events into one frame (see Event Batching below). Tracks active subscriptions by `correlation_id` — duplicate IDs cancel the old stream. Enforces a max concurrent stream limit (default 100). Rejects streams with `StatusCode.Unavailable` before the actor system is ready.
|
||||
- **StreamRelayActor**: Short-lived actor created per gRPC subscription. Receives domain events (`AttributeValueChanged`, `AlarmStateChanged`) from `SiteStreamManager`, converts them to protobuf `SiteStreamEvent` messages, and writes to the `Channel<SiteStreamEvent>` writer. Stopped when the gRPC stream is cancelled or the client disconnects.
|
||||
|
||||
#### Central-Side Debug Stream Components
|
||||
|
||||
- **DebugStreamService**: Singleton service that manages debug stream sessions. Resolves instance ID to unique name and site, creates and tears down `DebugStreamBridgeActor` instances, and provides a clean API for both Blazor components and the SignalR hub. Injects `SiteStreamGrpcClientFactory` for gRPC stream creation.
|
||||
- **DebugStreamBridgeActor**: One per active debug session. Opens a gRPC streaming subscription via `SiteStreamGrpcClient` and receives real-time events via callback. Also receives the initial `DebugViewSnapshot` over gRPC command/control (`SiteCommandService`). Forwards all events to the consumer via callbacks. Handles gRPC stream errors with reconnection logic: tries the other site node endpoint, retries with backoff (max 3 retries), terminates the session if all retries fail.
|
||||
- **SiteStreamGrpcClient**: Per-site gRPC client that manages `GrpcChannel` instances and streaming subscriptions. Reads from the gRPC response stream in a background task, converts protobuf messages to domain events, and invokes the `onEvent` callback.
|
||||
- **SiteStreamGrpcClient**: Per-site gRPC client that manages `GrpcChannel` instances and streaming subscriptions. Reads from the gRPC response stream in a background task, unpacks each frame via `ForEachEvent` (a plain frame passes through; a batch frame is flattened in order), converts protobuf messages to domain events, and invokes the `onEvent` callback.
|
||||
- **SiteStreamGrpcClientFactory**: Caches per-site `SiteStreamGrpcClient` instances. Reads `GrpcNodeAAddress` / `GrpcNodeBAddress` from the `Site` entity (loaded by `CentralCommunicationActor`). Falls back to NodeB if NodeA connection fails. Disposes clients on site removal or address change.
|
||||
- **DebugStreamHub**: SignalR hub at `/hubs/debug-stream` for external consumers (e.g., CLI). Authenticates via Basic Auth + LDAP and requires the **Deployment** role. Server-to-client methods: `OnSnapshot`, `OnAttributeChanged`, `OnAlarmChanged`, `OnStreamTerminated`.
|
||||
|
||||
@@ -113,9 +113,77 @@ The streaming protocol is defined in `sitestream.proto` (`src/ZB.MOM.WW.ScadaBri
|
||||
- `IngestCachedTelemetry(CachedTelemetryBatch) returns (IngestAck)` — ingest receiving surface for the combined cached-call telemetry packet (audit row + `SiteCalls` operational upsert written in one transaction).
|
||||
- `PullAuditEvents(PullAuditEventsRequest) returns (PullAuditEventsResponse)` — central→site **reconciliation pull** for the Audit Log self-heal feed; the site serves `Pending`/`Forwarded` rows from its `ISiteAuditQueue`. **At-least-once (WP2.3):** rows are NOT retired when served. The site retires (`Reconciled`) everything at or before the cursor of the **next** pull, because that cursor is central's only proof of receipt — a fault between the response leaving the site and central committing it re-serves the rows instead of losing them (central dedups on `EventId`). The request carries an additive composite-keyset `after_id` (field 3) mirroring `PullSiteCallsRequest`; with it the read is a strict `(OccurredAtUtc, EventId)` keyset and the retirement is exact, without it the legacy inclusive `>=` read applies and only rows strictly older than the cursor instant are provably received.
|
||||
- `PullSiteCalls(PullSiteCallsRequest) returns (PullSiteCallsResponse)` — central→site reconciliation pull for the Site Call Audit (#22) self-heal feed; the site serves operation-tracking rows changed since a cursor from its `IOperationTrackingStore`. A separate RPC from `PullAuditEvents` because the tracking store is the operational source of truth, distinct from the site audit queue.
|
||||
- **Messages**: `InstanceStreamRequest` (correlation_id, instance_unique_name), `SiteStreamRequest` (correlation_id only — no instance name; drives the site-wide `SubscribeSite` stream), `SiteStreamEvent` (correlation_id, oneof event: `AttributeValueUpdate`, `AlarmStateUpdate`); `AuditEventDto`/`AuditEventBatch`/`IngestAck` for ingest; `CachedTelemetryPacket`/`CachedTelemetryBatch` (each packet pairing an `AuditEventDto` with a `SiteCallOperationalDto`); `PullAuditEventsRequest`/`PullAuditEventsResponse` and `PullSiteCallsRequest`/`PullSiteCallsResponse` (each request carries `since_utc` + `batch_size` + an optional composite-keyset `after_id`; each response carries `more_available` to signal a saturated batch).
|
||||
- **Messages**: `InstanceStreamRequest` (correlation_id, instance_unique_name, `batching_supported`), `SiteStreamRequest` (correlation_id + `batching_supported` — no instance name; drives the site-wide `SubscribeSite` stream), `SiteStreamEvent` (correlation_id, oneof event: `AttributeValueUpdate`, `AlarmStateUpdate`, `SiteStreamEventBatch`); `AuditEventDto`/`AuditEventBatch`/`IngestAck` for ingest; `CachedTelemetryPacket`/`CachedTelemetryBatch` (each packet pairing an `AuditEventDto` with a `SiteCallOperationalDto`); `PullAuditEventsRequest`/`PullAuditEventsResponse` and `PullSiteCallsRequest`/`PullSiteCallsResponse` (each request carries `since_utc` + `batch_size` + an optional composite-keyset `after_id`; each response carries `more_available` to signal a saturated batch).
|
||||
- The `oneof event` pattern is extensible — future event types (health metrics, connection state changes) are added as new fields without breaking existing consumers.
|
||||
- Proto field numbers are never reused; new RPCs and message fields are appended additively. Old clients ignore unknown `oneof` variants.
|
||||
- Proto field numbers are never reused; new RPCs and message fields are appended additively. Old clients ignore unknown `oneof` variants — **but "ignore" means the event vanishes silently**, which is why the batch frame below is gated behind an explicit negotiation flag rather than simply emitted.
|
||||
|
||||
##### Event Batching (R2, 2026-08-15)
|
||||
|
||||
At target scale a site emits ~37.5k stream events/second, and before R2 every one of them cost its
|
||||
own gRPC message. Batching amortises that framing overhead **additively**, with no new RPC:
|
||||
|
||||
| Element | Where | Field # |
|
||||
|---------|-------|---------|
|
||||
| `InstanceStreamRequest.batching_supported` | request | 3 |
|
||||
| `SiteStreamRequest.batching_supported` | request | 2 |
|
||||
| `SiteStreamEvent.batch` (`SiteStreamEventBatch`) | response `oneof event` | 4 |
|
||||
| `SiteStreamEventBatch.events` (`repeated SiteStreamEvent`) | new message | 1 |
|
||||
|
||||
**Negotiation is the proto3 default itself.** The server coalesces only when the subscription
|
||||
request set `batching_supported = true`; a central built before R2 cannot set it, so it keeps
|
||||
receiving exactly one event per frame. This is load-bearing rather than merely tidy: a batch frame
|
||||
rides field 4, which an older generated parser skips into unknown fields and reports as
|
||||
`EventOneofCase.None` — `ConvertToDomainEvent` then returns null and the **entire batch disappears
|
||||
with no error anywhere**. The two skew directions are therefore:
|
||||
|
||||
- **old central ↔ new site** — flag absent on the wire, site stays on the per-event path.
|
||||
- **new central ↔ old site** — the site ignores the unknown request field and keeps sending
|
||||
per-event frames, which the new client handles as the single-event case of the same unpack path.
|
||||
|
||||
**Server side.** `SiteStreamEventBatcher` is a per-subscriber coalescing pump that replaced the
|
||||
handler's `await foreach … WriteAsync(evt)` loop, and is byte-for-byte identical to it when the
|
||||
size cap is 1. Its latency contract: take the first event, drain whatever is **already queued**
|
||||
behind it (free), and linger for `GrpcStreamBatchWindow` only once a backlog has actually been
|
||||
observed — so a lone event on a quiet stream is never delayed, and a single-event frame is emitted
|
||||
as a plain `attribute_changed`/`alarm_changed` frame rather than a one-element batch. The batch's
|
||||
`correlation_id` is stamped once on the enclosing frame and blanked on the packed events. Ordering
|
||||
is preserved exactly; per-event `Timestamp`s are untouched (end-to-end latency measurement rides
|
||||
them). Buffered events are flushed when the send channel's writer completes; cancellation is not
|
||||
flushed (the client is already gone) and surfaces the same `OperationCanceledException` the old
|
||||
loop did.
|
||||
|
||||
**Client side.** `SiteStreamGrpcClient` sets `batching_supported = true` on both subscriptions and
|
||||
unpacks each frame through `ForEachEvent` into the existing per-event pipeline, so
|
||||
`SiteAlarmAggregatorActor`, `DebugStreamBridgeActor`, the consumer-keepalive/orphan logic,
|
||||
reconnect-on-graceful-completion, generation fencing, the `(siteId, endpoint)` factory key and
|
||||
`IsLive` semantics all see no difference. Unpacking is deliberately non-recursive: the server never
|
||||
nests batches, and a nested or unknown inner case is skipped rather than followed.
|
||||
|
||||
**This does NOT change the site's burst ceiling.** The pump sits strictly *downstream* of
|
||||
`StreamRelayActor`'s bounded `DropOldest` send channel and is per subscriber; events evicted by
|
||||
that channel are gone before the batcher sees them. The burst ceiling recorded in deferred-work
|
||||
register row 31 is set by the **shared publish stage upstream of the BroadcastHub** and by the
|
||||
per-subscriber channel capacity, neither of which batching touches.
|
||||
|
||||
**Options** (`ScadaBridge:Communication`, validated at startup):
|
||||
|
||||
- `GrpcStreamBatchMaxEvents`: 100 (default). Must be `> 0`; **1 disables batching** on this node
|
||||
with no wire change.
|
||||
- `GrpcStreamBatchWindow`: 25 ms (default). Must be non-negative and **strictly below 250 ms** —
|
||||
the end-to-end stream-latency budget the target-scale load test asserts a P99 against (measured
|
||||
P99 there: 4.57 ms). `TimeSpan.Zero` means "pack only what is already queued, never wait".
|
||||
|
||||
Measured cost of the defaults (`GrpcStreamBatchingIntegrationTests`, worst-case
|
||||
trickle-with-backlog workload where the window rather than the size cap closes every batch):
|
||||
P50 13.8 ms, P99 25.4 ms, max 25.8 ms — bounded by the window, ~10x inside the threshold. On a
|
||||
saturated stream the size cap binds instead: 600 queued events left the site in 6 frames.
|
||||
|
||||
**Telemetry**: `scadabridge.site.stream.batch_size` — a histogram of events per frame, tagged
|
||||
`stream=instance|site-alarms`, recorded **only** on batching-negotiated subscriptions (on an
|
||||
un-negotiated one it would degenerate into a per-event instrument on the hottest path in the
|
||||
product). It rides `ScadaBridgeTelemetry.MeterName`, which is already listed in
|
||||
`SiteServiceRegistration.ObservedMeters` — `ZbTelemetryOptions.Meters` is an allowlist and an
|
||||
unlisted meter exports nothing, silently.
|
||||
|
||||
##### Authentication (preshared key, 2026-07-22)
|
||||
|
||||
@@ -169,7 +237,7 @@ read the traffic". TLS is follow-on hardening and does not change this design.
|
||||
- **Placeholder rows are dropped at the relay**: `is_configured_placeholder` (field 23) is a **Debug View snapshot-only** concept emitted by `InstanceActor.BuildAlarmStatesSnapshot` for quiet bindings — it is never a real alarm transition (its timestamp may be `DateTimeOffset.MinValue`, the Protobuf `Timestamp` lower boundary). `StreamRelayActor.HandleAlarmStateChanged` therefore returns early — **never relaying a placeholder row to the live gRPC stream** — so field 23 is always `false` on the live stream and only ever carries `true` in the snapshot path.
|
||||
- **Client-side mapping (`SiteStreamGrpcClient.ConvertToDomainEvent`)**: reconstructs the domain `AlarmStateChanged` from the proto — `Kind` is parsed via `ParseAlarmKind`, the `Condition` is rebuilt with `severity` taken from the existing wire `priority`, and native metadata is repopulated from fields 8–23 (`native_source_canonical_name` → `NativeSourceCanonicalName`, `is_configured_placeholder` → `IsConfiguredPlaceholder`) — so central-side consumers receive the same domain event the site emitted.
|
||||
|
||||
> **Regeneration is manual (macOS-only).** `sitestream.proto` is **not** auto-compiled: the `<Protobuf>` include is commented out in the `.csproj`, and the generated C# is **vendored** under `SiteStreamGrpc/`. To regenerate after editing the proto: toggle the `<Protobuf>` include on, build so `Grpc.Tools` regenerates the C#, copy the generated files into `SiteStreamGrpc/`, then re-comment the include. Adding `AlarmStateUpdate` fields 8–23 and the four unary RPCs (`IngestAuditEvents`, `IngestCachedTelemetry`, `PullAuditEvents`, `PullSiteCalls`) plus their message types followed this process, as did WP2.3's two additive fields — `PullAuditEventsRequest.after_id` (field 3, `sitestream.proto`) and `DebugSnapshotRequestDto.alarms_only` (field 3, `site_command.proto`). The same applies to `site_command.proto`/`SiteCommandGrpc/` and `central_control.proto`/`CentralControlGrpc/`; `docker/regen-proto.sh [sitestream|centralcontrol|sitecommand|all]` automates the toggle-build-copy-untoggle and always restores the csproj.
|
||||
> **Regeneration is manual (macOS-only).** `sitestream.proto` is **not** auto-compiled: the `<Protobuf>` include is commented out in the `.csproj`, and the generated C# is **vendored** under `SiteStreamGrpc/`. To regenerate after editing the proto: toggle the `<Protobuf>` include on, build so `Grpc.Tools` regenerates the C#, copy the generated files into `SiteStreamGrpc/`, then re-comment the include. Adding `AlarmStateUpdate` fields 8–23 and the four unary RPCs (`IngestAuditEvents`, `IngestCachedTelemetry`, `PullAuditEvents`, `PullSiteCalls`) plus their message types followed this process, as did WP2.3's two additive fields — `PullAuditEventsRequest.after_id` (field 3, `sitestream.proto`) and `DebugSnapshotRequestDto.alarms_only` (field 3, `site_command.proto`) — and R2's batching additions (`InstanceStreamRequest.batching_supported` field 3, `SiteStreamRequest.batching_supported` field 2, `SiteStreamEvent.batch` field 4 and the new `SiteStreamEventBatch` message). The same applies to `site_command.proto`/`SiteCommandGrpc/` and `central_control.proto`/`CentralControlGrpc/`; `docker/regen-proto.sh [sitestream|centralcontrol|sitecommand|all]` automates the toggle-build-copy-untoggle and always restores the csproj.
|
||||
|
||||
#### gRPC Connection Keepalive
|
||||
|
||||
@@ -186,6 +254,8 @@ Keepalive settings are configurable via `CommunicationOptions`:
|
||||
- `GrpcKeepAlivePingTimeout`: 10 seconds (default)
|
||||
- `GrpcMaxStreamLifetime`: 4 hours (default)
|
||||
- `GrpcMaxConcurrentStreams`: 100 (default)
|
||||
- `GrpcStreamBatchMaxEvents`: 100 (default) — see Event Batching above
|
||||
- `GrpcStreamBatchWindow`: 25 ms (default) — see Event Batching above
|
||||
|
||||
### 6a. Debug Snapshot (Central → Site)
|
||||
- **Pattern**: Request/Response (one-shot, no subscription).
|
||||
|
||||
@@ -93,6 +93,20 @@ public static class ScadaBridgeTelemetry
|
||||
Meter.CreateCounter<long>("scadabridge.site.stream.events_dropped", unit: "1",
|
||||
description: "Events evicted from a site gRPC stream's bounded send channel, tagged by stream kind.");
|
||||
|
||||
// ---------------- Histograms ----------------
|
||||
|
||||
/// <summary>
|
||||
/// Distribution of how many events each site→central stream frame carried (R2 — gRPC
|
||||
/// event batching), tagged by stream kind. Recorded ONLY for subscriptions that
|
||||
/// negotiated batching, so the series' very existence says "this central speaks the
|
||||
/// batched wire". A distribution pinned at 1 means the coalescing window never sees a
|
||||
/// backlog (the site is quiet, or the window is too small to be earning anything);
|
||||
/// mass at the size cap means the cap, not the window, is the binding constraint.
|
||||
/// </summary>
|
||||
private static readonly Histogram<int> _siteStreamBatchSize =
|
||||
Meter.CreateHistogram<int>("scadabridge.site.stream.batch_size", unit: "1",
|
||||
description: "Events per site gRPC stream frame (1 = unbatched frame), tagged by stream kind.");
|
||||
|
||||
// ---------------- Observable gauges ----------------
|
||||
|
||||
/// <summary>Current count of open site connections, mutated via <see cref="Interlocked"/>.</summary>
|
||||
@@ -178,6 +192,16 @@ public static class ScadaBridgeTelemetry
|
||||
public static void RecordSiteStreamEventDropped(string streamKind) =>
|
||||
_siteStreamEventDrops.Add(1, new KeyValuePair<string, object?>("stream", streamKind));
|
||||
|
||||
/// <summary>
|
||||
/// Records how many events one site gRPC stream frame carried. Called once per emitted
|
||||
/// frame on a batching-negotiated subscription (never on an un-negotiated one, where it
|
||||
/// would degenerate into a per-event instrument on the hottest path in the product).
|
||||
/// </summary>
|
||||
/// <param name="streamKind">Stream kind tag (<c>instance</c> or <c>site-alarms</c>).</param>
|
||||
/// <param name="events">Events packed into the frame; 1 for a plain unbatched frame.</param>
|
||||
public static void RecordSiteStreamBatchSize(string streamKind, int events) =>
|
||||
_siteStreamBatchSize.Record(events, new KeyValuePair<string, object?>("stream", streamKind));
|
||||
|
||||
/// <summary>
|
||||
/// Registers the provider the StoreAndForward queue-depth gauge reads on each observation.
|
||||
/// A later task supplies a provider that reads the real StoreAndForward depth. A null
|
||||
|
||||
@@ -152,6 +152,26 @@ public class CommunicationOptions
|
||||
/// </summary>
|
||||
public int GrpcSiteAlarmStreamChannelCapacity { get; set; } = 20_000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum events coalesced into one site→central stream frame (R2 — gRPC event
|
||||
/// batching). At target scale a site emits ~37.5k events/s and every one of them used to
|
||||
/// cost its own gRPC message; batching amortises that framing overhead. Set to 1 to
|
||||
/// disable batching on this node without a wire change (every frame then carries exactly
|
||||
/// one event, which is also what an un-negotiated subscription gets).
|
||||
/// </summary>
|
||||
public int GrpcStreamBatchMaxEvents { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time the site lingers accumulating a stream batch once a backlog has been
|
||||
/// observed. Deliberately small: the target-scale load test measures end-to-end event
|
||||
/// latency against a 250 ms P99 threshold (measured P99 4.57 ms), and this window is the
|
||||
/// only latency batching can add — so it is validated strictly below that threshold.
|
||||
/// A lone event on a quiet stream is never delayed by it (see
|
||||
/// <c>SiteStreamEventBatcher</c>); the window applies only after a backlog is proven.
|
||||
/// <see cref="TimeSpan.Zero"/> means "pack only what is already queued, never wait".
|
||||
/// </summary>
|
||||
public TimeSpan GrpcStreamBatchWindow { get; set; } = TimeSpan.FromMilliseconds(25);
|
||||
|
||||
/// <summary>Akka.Remote transport heartbeat interval.</summary>
|
||||
public TimeSpan TransportHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
|
||||
@@ -12,6 +12,14 @@ namespace ZB.MOM.WW.ScadaBridge.Communication;
|
||||
/// </summary>
|
||||
public sealed class CommunicationOptionsValidator : OptionsValidatorBase<CommunicationOptions>
|
||||
{
|
||||
/// <summary>
|
||||
/// Exclusive upper bound on <see cref="CommunicationOptions.GrpcStreamBatchWindow"/> —
|
||||
/// the end-to-end site→central event latency budget the target-scale load test asserts
|
||||
/// a P99 against. The coalescing window is the only latency batching introduces, so it
|
||||
/// must stay strictly inside that budget rather than consuming it whole.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan StreamBatchWindowCeiling = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Validate(ValidationBuilder builder, CommunicationOptions options)
|
||||
{
|
||||
@@ -75,6 +83,23 @@ public sealed class CommunicationOptionsValidator : OptionsValidatorBase<Communi
|
||||
builder.RequireThat(options.GrpcMaxConcurrentStreams > 0,
|
||||
$"ScadaBridge:Communication:GrpcMaxConcurrentStreams must be positive (was {options.GrpcMaxConcurrentStreams}).");
|
||||
|
||||
// ── Site→central stream event batching (R2) ─────────────────────────────
|
||||
// 1 is legal and means "disabled" (one event per frame, the un-negotiated shape).
|
||||
builder.RequireThat(options.GrpcStreamBatchMaxEvents > 0,
|
||||
$"ScadaBridge:Communication:GrpcStreamBatchMaxEvents must be positive — 1 disables "
|
||||
+ $"batching (was {options.GrpcStreamBatchMaxEvents}).");
|
||||
|
||||
// The coalescing window is the ONLY latency batching can add, and the target-scale
|
||||
// load test holds end-to-end event latency to a 250 ms P99. Validate it strictly
|
||||
// below that so a misconfigured window cannot silently spend the entire budget.
|
||||
builder.RequireThat(
|
||||
options.GrpcStreamBatchWindow >= TimeSpan.Zero
|
||||
&& options.GrpcStreamBatchWindow < StreamBatchWindowCeiling,
|
||||
$"ScadaBridge:Communication:GrpcStreamBatchWindow must be non-negative and strictly "
|
||||
+ $"below {StreamBatchWindowCeiling.TotalMilliseconds:0} ms (the end-to-end stream "
|
||||
+ $"latency budget the coalescing window spends from); zero means \"pack only what is "
|
||||
+ $"already queued\" (was {options.GrpcStreamBatchWindow}).");
|
||||
|
||||
// The gRPC site→central transport needs at least one central endpoint to dial. gRPC is now
|
||||
// the only site→central transport (ClusterClient was removed in the migration's Phase 4), so
|
||||
// every site node must declare its central endpoints — there is no Akka fallback to ignore
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Per-subscriber coalescing pump that drains a stream's bounded send channel and writes
|
||||
/// it to the gRPC response stream, optionally packing several consecutive events into one
|
||||
/// <see cref="SiteStreamEventBatch"/> frame (R2 — gRPC event batching).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Where this sits.</b> Strictly DOWNSTREAM of <c>StreamRelayActor</c>'s bounded
|
||||
/// <c>DropOldest</c> channel, and one instance per subscriber. It therefore changes only
|
||||
/// how many gRPC frames a given set of events costs — it does <em>not</em> change the
|
||||
/// site's burst ceiling, which is set by the shared publish stage upstream of the
|
||||
/// BroadcastHub (deferred-work register row 31) and by the per-subscriber channel capacity.
|
||||
/// Events dropped by that channel are dropped before the batcher ever sees them.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Latency contract.</b> The pump never delays a lone event. It takes the first event,
|
||||
/// drains whatever is <em>already queued</em> behind it (which costs no time at all), and
|
||||
/// only then — having proven a backlog exists — lingers up to
|
||||
/// <paramref name="maxWindow"/> for more. A quiet stream is therefore byte-identical and
|
||||
/// latency-identical to the pre-batching wire: one plain
|
||||
/// <c>attribute_changed</c>/<c>alarm_changed</c> frame, emitted immediately. A saturated
|
||||
/// stream pays at most one window per batch, which is why the window is validated well
|
||||
/// under the 250 ms end-to-end latency threshold the target-scale load test measures
|
||||
/// against.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Ordering.</b> Events are emitted in the exact order they were read; a batch preserves
|
||||
/// that order inside <c>SiteStreamEventBatch.events</c>, and the client unpacks in order.
|
||||
/// Nothing is reordered or coalesced away — batching is purely a framing change.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class SiteStreamEventBatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Drains <paramref name="reader"/> until it completes or <paramref name="ct"/> is
|
||||
/// cancelled, writing frames through <paramref name="writeAsync"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Pass <paramref name="maxBatchEvents"/> = 1 to get the exact pre-batching behaviour
|
||||
/// (one frame per event, no window, no metric) — that is what an un-negotiated
|
||||
/// subscription uses, so an old central never sees a frame shape it cannot parse.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// When the channel writer completes with events still buffered, the buffered events
|
||||
/// are flushed as a final frame before the pump returns. Cancellation is deliberately
|
||||
/// NOT flushed: the token is cancelled precisely when the client is gone or the site is
|
||||
/// shutting down, so the write would fail anyway; the
|
||||
/// <see cref="OperationCanceledException"/> propagates to the caller exactly as the
|
||||
/// pre-batching <c>await foreach</c> did.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="reader">The subscriber's bounded send-channel reader.</param>
|
||||
/// <param name="correlationId">Correlation id stamped on an emitted batch frame.</param>
|
||||
/// <param name="maxBatchEvents">Hard cap on events per frame; 1 disables batching entirely.</param>
|
||||
/// <param name="maxWindow">Maximum time to linger accumulating a batch once a backlog is observed.</param>
|
||||
/// <param name="writeAsync">Writes one frame to the gRPC response stream.</param>
|
||||
/// <param name="onFrameEmitted">Optional observer of each emitted frame's event count (the batch-size histogram).</param>
|
||||
/// <param name="ct">Cancels the pump (client disconnect, duplicate replacement, shutdown, stream lifetime).</param>
|
||||
/// <returns>A task that completes when the channel is drained and closed.</returns>
|
||||
internal static async Task PumpAsync(
|
||||
ChannelReader<SiteStreamEvent> reader,
|
||||
string correlationId,
|
||||
int maxBatchEvents,
|
||||
TimeSpan maxWindow,
|
||||
Func<SiteStreamEvent, CancellationToken, Task> writeAsync,
|
||||
Action<int>? onFrameEmitted,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var buffer = new List<SiteStreamEvent>(Math.Max(1, Math.Min(maxBatchEvents, 256)));
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (!await reader.WaitToReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
// Writer completed and the channel is empty — normal end of stream.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reader.TryRead(out var first))
|
||||
{
|
||||
// Raced another reader (there is only one, but WaitToReadAsync can also
|
||||
// wake on completion); loop round and re-evaluate.
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer.Clear();
|
||||
buffer.Add(first);
|
||||
|
||||
var writerCompleted = false;
|
||||
|
||||
if (maxBatchEvents > 1)
|
||||
{
|
||||
// Free drain: everything already sitting in the channel costs no latency.
|
||||
while (buffer.Count < maxBatchEvents && reader.TryRead(out var queued))
|
||||
{
|
||||
buffer.Add(queued);
|
||||
}
|
||||
|
||||
// Linger ONLY when a real backlog was observed. A single event on an
|
||||
// otherwise idle stream is emitted immediately — the window must never
|
||||
// become a floor on latency for the quiet case.
|
||||
if (buffer.Count > 1 && buffer.Count < maxBatchEvents && maxWindow > TimeSpan.Zero)
|
||||
{
|
||||
writerCompleted = await LingerAsync(
|
||||
reader, buffer, maxBatchEvents, maxWindow, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
await EmitAsync(buffer, correlationId, writeAsync, onFrameEmitted, ct).ConfigureAwait(false);
|
||||
|
||||
if (writerCompleted)
|
||||
{
|
||||
// Flush-on-close: the buffered events above were the tail of the stream.
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accumulates further events into <paramref name="buffer"/> for at most
|
||||
/// <paramref name="maxWindow"/> from the moment the backlog was observed, or until the
|
||||
/// size cap is reached.
|
||||
/// </summary>
|
||||
/// <param name="reader">The subscriber's send-channel reader.</param>
|
||||
/// <param name="buffer">Batch under construction; appended to in arrival order.</param>
|
||||
/// <param name="maxBatchEvents">Hard cap on events per frame.</param>
|
||||
/// <param name="maxWindow">Maximum lingering time for this batch.</param>
|
||||
/// <param name="ct">Cancels the pump.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> when the channel writer completed while lingering (the caller
|
||||
/// must emit the buffer and then stop), otherwise <see langword="false"/>.
|
||||
/// </returns>
|
||||
private static async Task<bool> LingerAsync(
|
||||
ChannelReader<SiteStreamEvent> reader,
|
||||
List<SiteStreamEvent> buffer,
|
||||
int maxBatchEvents,
|
||||
TimeSpan maxWindow,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
|
||||
while (buffer.Count < maxBatchEvents)
|
||||
{
|
||||
var remaining = maxWindow - Stopwatch.GetElapsedTime(started);
|
||||
if (remaining <= TimeSpan.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var lingerCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
lingerCts.CancelAfter(remaining);
|
||||
|
||||
bool more;
|
||||
try
|
||||
{
|
||||
more = await reader.WaitToReadAsync(lingerCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// The window elapsed — close the batch with what we have.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!more)
|
||||
{
|
||||
// Writer completed with the buffer non-empty: flush it, then stop.
|
||||
return true;
|
||||
}
|
||||
|
||||
while (buffer.Count < maxBatchEvents && reader.TryRead(out var queued))
|
||||
{
|
||||
buffer.Add(queued);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes <paramref name="buffer"/> as a single frame: a plain event frame when the
|
||||
/// buffer holds exactly one event (identical to the pre-batching wire), otherwise a
|
||||
/// <see cref="SiteStreamEventBatch"/> frame carrying them in order.
|
||||
/// </summary>
|
||||
/// <param name="buffer">Events to emit, in arrival order. Never empty.</param>
|
||||
/// <param name="correlationId">Correlation id for the enclosing batch frame.</param>
|
||||
/// <param name="writeAsync">Writes one frame to the gRPC response stream.</param>
|
||||
/// <param name="onFrameEmitted">Optional observer of the emitted frame's event count.</param>
|
||||
/// <param name="ct">Cancels the write.</param>
|
||||
/// <returns>A task that completes when the frame has been written.</returns>
|
||||
private static async Task EmitAsync(
|
||||
List<SiteStreamEvent> buffer,
|
||||
string correlationId,
|
||||
Func<SiteStreamEvent, CancellationToken, Task> writeAsync,
|
||||
Action<int>? onFrameEmitted,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (buffer.Count == 1)
|
||||
{
|
||||
await writeAsync(buffer[0], ct).ConfigureAwait(false);
|
||||
onFrameEmitted?.Invoke(1);
|
||||
return;
|
||||
}
|
||||
|
||||
var batch = new SiteStreamEventBatch();
|
||||
foreach (var evt in buffer)
|
||||
{
|
||||
// The enclosing frame carries the correlation id once for the whole batch;
|
||||
// clearing it on the inner events is the byte saving batching exists for.
|
||||
// No consumer reads the inner value (see SiteStreamGrpcClient.ForEachEvent).
|
||||
evt.CorrelationId = string.Empty;
|
||||
batch.Events.Add(evt);
|
||||
}
|
||||
|
||||
await writeAsync(
|
||||
new SiteStreamEvent { CorrelationId = correlationId, Batch = batch }, ct).ConfigureAwait(false);
|
||||
onFrameEmitted?.Invoke(buffer.Count);
|
||||
}
|
||||
}
|
||||
@@ -199,19 +199,25 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
||||
var request = new InstanceStreamRequest
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
InstanceUniqueName = instanceUniqueName
|
||||
InstanceUniqueName = instanceUniqueName,
|
||||
// R2 batch negotiation. Declaring support is safe against ANY site: one that
|
||||
// predates batching ignores the unknown field and keeps sending per-event
|
||||
// frames, which ForEachEvent handles as the single-event case.
|
||||
BatchingSupported = true
|
||||
};
|
||||
|
||||
void Deliver(SiteStreamEvent single)
|
||||
{
|
||||
var domainEvent = ConvertToDomainEvent(single);
|
||||
if (domainEvent != null)
|
||||
onEvent(domainEvent);
|
||||
}
|
||||
|
||||
await ConsumeStreamAsync(
|
||||
correlationId,
|
||||
cts,
|
||||
() => _client.SubscribeInstance(request, cancellationToken: cts.Token),
|
||||
evt =>
|
||||
{
|
||||
var domainEvent = ConvertToDomainEvent(evt);
|
||||
if (domainEvent != null)
|
||||
onEvent(domainEvent);
|
||||
},
|
||||
frame => ForEachEvent(frame, Deliver),
|
||||
onError,
|
||||
onCompleted);
|
||||
}
|
||||
@@ -270,19 +276,23 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
||||
|
||||
var request = new SiteStreamRequest
|
||||
{
|
||||
CorrelationId = correlationId
|
||||
CorrelationId = correlationId,
|
||||
// R2 batch negotiation — see SubscribeAsync.
|
||||
BatchingSupported = true
|
||||
};
|
||||
|
||||
void Deliver(SiteStreamEvent single)
|
||||
{
|
||||
// Site-wide stream is alarm-only by contract; defensively ignore anything else.
|
||||
if (ConvertToAlarmEvent(single) is { } alarm)
|
||||
onAlarmEvent(alarm);
|
||||
}
|
||||
|
||||
await ConsumeStreamAsync(
|
||||
correlationId,
|
||||
cts,
|
||||
() => _client.SubscribeSite(request, cancellationToken: cts.Token),
|
||||
evt =>
|
||||
{
|
||||
// Site-wide stream is alarm-only by contract; defensively ignore anything else.
|
||||
if (ConvertToAlarmEvent(evt) is { } alarm)
|
||||
onAlarmEvent(alarm);
|
||||
},
|
||||
frame => ForEachEvent(frame, Deliver),
|
||||
onError,
|
||||
onCompleted,
|
||||
onConnected);
|
||||
@@ -451,6 +461,39 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unpacks one wire frame into the per-event pipeline (R2 — gRPC event batching).
|
||||
/// A plain <c>attribute_changed</c>/<c>alarm_changed</c> frame is delivered as-is; a
|
||||
/// <see cref="SiteStreamEventBatch"/> frame is unpacked <b>in order</b> into the same
|
||||
/// callback, so every downstream consumer (<c>SiteAlarmAggregatorActor</c>,
|
||||
/// <c>DebugStreamBridgeActor</c>, the consumer-keepalive/orphan logic, per-event
|
||||
/// <c>Timestamp</c> fidelity) sees no difference between a batched and an unbatched site.
|
||||
/// <para>
|
||||
/// Unpacking is deliberately NON-RECURSIVE: the server never nests a batch inside a
|
||||
/// batch, and a nested or unknown inner case from a malformed peer is skipped rather
|
||||
/// than followed. Internal for testability.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="frame">The wire frame received from the site.</param>
|
||||
/// <param name="handler">Invoked once per contained event, in arrival order.</param>
|
||||
internal static void ForEachEvent(SiteStreamEvent frame, Action<SiteStreamEvent> handler)
|
||||
{
|
||||
if (frame.EventCase != SiteStreamEvent.EventOneofCase.Batch)
|
||||
{
|
||||
handler(frame);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var inner in frame.Batch.Events)
|
||||
{
|
||||
if (inner.EventCase is SiteStreamEvent.EventOneofCase.AttributeChanged
|
||||
or SiteStreamEvent.EventOneofCase.AlarmChanged)
|
||||
{
|
||||
handler(inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a proto SiteStreamEvent to the corresponding domain message.
|
||||
/// Internal for testability.
|
||||
|
||||
@@ -29,6 +29,8 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
private readonly TimeSpan _maxStreamLifetime;
|
||||
private readonly int _instanceChannelCapacity;
|
||||
private readonly int _siteAlarmChannelCapacity;
|
||||
private readonly int _streamBatchMaxEvents;
|
||||
private readonly TimeSpan _streamBatchWindow;
|
||||
private volatile bool _ready;
|
||||
// Flipped by CancelAllStreams() when the host enters
|
||||
// CoordinatedShutdown so SubscribeInstance refuses new streams with
|
||||
@@ -75,7 +77,8 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
ILogger<SiteStreamGrpcServer> logger,
|
||||
int maxConcurrentStreams = 100)
|
||||
: this(streamSubscriber, logger, maxConcurrentStreams, TimeSpan.FromHours(4),
|
||||
DefaultInstanceChannelCapacity, DefaultSiteAlarmChannelCapacity)
|
||||
DefaultInstanceChannelCapacity, DefaultSiteAlarmChannelCapacity,
|
||||
DefaultStreamBatchMaxEvents, DefaultStreamBatchWindow)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -85,6 +88,12 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
/// <summary>Fallback site-wide alarm send-channel capacity when no options are bound.</summary>
|
||||
internal const int DefaultSiteAlarmChannelCapacity = 20_000;
|
||||
|
||||
/// <summary>Fallback stream-batch size cap when no options are bound (R2).</summary>
|
||||
internal const int DefaultStreamBatchMaxEvents = 100;
|
||||
|
||||
/// <summary>Fallback stream-batch coalescing window when no options are bound (R2).</summary>
|
||||
internal static readonly TimeSpan DefaultStreamBatchWindow = TimeSpan.FromMilliseconds(25);
|
||||
|
||||
/// <summary>
|
||||
/// DI constructor — binds <see cref="CommunicationOptions.GrpcMaxConcurrentStreams"/>
|
||||
/// and <see cref="CommunicationOptions.GrpcMaxStreamLifetime"/> so the documented
|
||||
@@ -102,7 +111,9 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
options.Value.GrpcMaxConcurrentStreams,
|
||||
options.Value.GrpcMaxStreamLifetime,
|
||||
options.Value.GrpcInstanceStreamChannelCapacity,
|
||||
options.Value.GrpcSiteAlarmStreamChannelCapacity)
|
||||
options.Value.GrpcSiteAlarmStreamChannelCapacity,
|
||||
options.Value.GrpcStreamBatchMaxEvents,
|
||||
options.Value.GrpcStreamBatchWindow)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -112,7 +123,9 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
int maxConcurrentStreams,
|
||||
TimeSpan maxStreamLifetime,
|
||||
int instanceChannelCapacity,
|
||||
int siteAlarmChannelCapacity)
|
||||
int siteAlarmChannelCapacity,
|
||||
int streamBatchMaxEvents,
|
||||
TimeSpan streamBatchWindow)
|
||||
{
|
||||
_streamSubscriber = streamSubscriber;
|
||||
_logger = logger;
|
||||
@@ -120,6 +133,11 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
_maxStreamLifetime = maxStreamLifetime;
|
||||
_instanceChannelCapacity = Math.Max(1, instanceChannelCapacity);
|
||||
_siteAlarmChannelCapacity = Math.Max(1, siteAlarmChannelCapacity);
|
||||
// Floored/clamped rather than thrown on: CommunicationOptionsValidator already
|
||||
// fails the boot on a bad value, and a degenerate one must not blow up deep
|
||||
// inside a live RPC on a host composed without validation (tests, embedded use).
|
||||
_streamBatchMaxEvents = Math.Max(1, streamBatchMaxEvents);
|
||||
_streamBatchWindow = streamBatchWindow < TimeSpan.Zero ? TimeSpan.Zero : streamBatchWindow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -230,6 +248,12 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
/// <summary>Effective site-wide alarm send-channel capacity. Exposed for tests.</summary>
|
||||
internal int SiteAlarmChannelCapacity => _siteAlarmChannelCapacity;
|
||||
|
||||
/// <summary>Effective stream-batch size cap (R2). Exposed for tests.</summary>
|
||||
internal int StreamBatchMaxEvents => _streamBatchMaxEvents;
|
||||
|
||||
/// <summary>Effective stream-batch coalescing window (R2). Exposed for tests.</summary>
|
||||
internal TimeSpan StreamBatchWindow => _streamBatchWindow;
|
||||
|
||||
/// <summary>
|
||||
/// Total events evicted from stream send channels on this node since start (both stream
|
||||
/// kinds). Exported as <c>scadabridge.site.stream.events_dropped</c>; exposed here so a
|
||||
@@ -251,7 +275,10 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
relay => _streamSubscriber.Subscribe(request.InstanceUniqueName, relay),
|
||||
request.InstanceUniqueName,
|
||||
_instanceChannelCapacity,
|
||||
streamKind: "instance");
|
||||
streamKind: "instance",
|
||||
// R2 batch negotiation: proto3 defaults this to false, so a central built
|
||||
// before batching existed keeps getting one frame per event.
|
||||
batchingSupported: request.BatchingSupported);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task SubscribeSite(
|
||||
@@ -271,7 +298,8 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
// DropOldest meant an alarm burst during a WAN stall silently evicted operator-
|
||||
// visible transitions to make room for diagnostics traffic.
|
||||
_siteAlarmChannelCapacity,
|
||||
streamKind: "site-alarms");
|
||||
streamKind: "site-alarms",
|
||||
batchingSupported: request.BatchingSupported);
|
||||
|
||||
/// <summary>
|
||||
/// Shared streaming pipeline behind <see cref="SubscribeInstance"/> and
|
||||
@@ -289,6 +317,12 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
/// <param name="description">Human-readable subscription description for logging.</param>
|
||||
/// <param name="channelCapacity">Send-channel capacity for this stream kind.</param>
|
||||
/// <param name="streamKind">Telemetry tag for this stream kind (<c>instance</c>/<c>site-alarms</c>).</param>
|
||||
/// <param name="batchingSupported">
|
||||
/// Whether the SUBSCRIBING CLIENT declared it understands the <c>SiteStreamEventBatch</c>
|
||||
/// oneof case (R2). False — the proto3 default an older central necessarily sends —
|
||||
/// pins this stream to one event per frame, so a peer that predates batching can never
|
||||
/// receive a frame case its generated code drops on the floor.
|
||||
/// </param>
|
||||
private async Task RunSubscriptionStreamAsync(
|
||||
string correlationId,
|
||||
IServerStreamWriter<SiteStreamEvent> responseStream,
|
||||
@@ -296,7 +330,8 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
Func<IActorRef, string> subscribe,
|
||||
string description,
|
||||
int channelCapacity,
|
||||
string streamKind)
|
||||
string streamKind,
|
||||
bool batchingSupported)
|
||||
{
|
||||
if (!_ready)
|
||||
throw new RpcException(new GrpcStatus(StatusCode.Unavailable, "Server not ready"));
|
||||
@@ -318,11 +353,29 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
StatusCode.InvalidArgument, "correlation_id is missing or not a valid identifier"));
|
||||
}
|
||||
|
||||
// Duplicate prevention -- cancel existing stream for this correlationId
|
||||
// Duplicate prevention -- cancel existing stream for this correlationId.
|
||||
//
|
||||
// CANCEL ONLY, never Dispose. The replaced stream's CTS belongs to its own
|
||||
// handler's `using var streamCts`, which is still running and still reads
|
||||
// `streamCts.Token` (at the pump call below, and previously at the
|
||||
// `ReadAllAsync(streamCts.Token)` it replaced). Disposing it from here raced that
|
||||
// read and surfaced as an unhandled ObjectDisposedException escaping the RPC —
|
||||
// observed as a full-suite-load-only failure of
|
||||
// GrpcStreamIntegrationTests.Pipeline_DuplicateCorrelationId_ReplacesStream, and a
|
||||
// pre-existing hazard (the same Dispose + the same first-token-read relationship
|
||||
// exist unchanged before R2). Cancellation alone is what replacement needs; the
|
||||
// owning handler's `using` still disposes it exactly once on every exit path.
|
||||
if (_activeStreams.TryRemove(correlationId, out var existingEntry))
|
||||
{
|
||||
existingEntry.Cts.Cancel();
|
||||
existingEntry.Cts.Dispose();
|
||||
try
|
||||
{
|
||||
existingEntry.Cts.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Its owner finished and disposed it between the TryRemove and here —
|
||||
// already terminal, nothing to cancel. Mirrors CancelAllStreams().
|
||||
}
|
||||
}
|
||||
|
||||
// Check max concurrent streams after duplicate removal.
|
||||
@@ -422,10 +475,24 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
ScadaBridgeTelemetry.SiteConnectionOpened();
|
||||
try
|
||||
{
|
||||
await foreach (var evt in channel.Reader.ReadAllAsync(streamCts.Token))
|
||||
{
|
||||
await responseStream.WriteAsync(evt, streamCts.Token);
|
||||
}
|
||||
// R2 — event batching. The pump replaces the old per-event
|
||||
// `await foreach (…) WriteAsync(evt)` loop and is byte-for-byte identical to
|
||||
// it when maxBatchEvents == 1, which is exactly what an un-negotiated
|
||||
// subscription gets. It sits DOWNSTREAM of the bounded DropOldest channel
|
||||
// above, so it changes framing only — never the site's burst ceiling.
|
||||
await SiteStreamEventBatcher.PumpAsync(
|
||||
channel.Reader,
|
||||
correlationId,
|
||||
maxBatchEvents: batchingSupported ? _streamBatchMaxEvents : 1,
|
||||
maxWindow: _streamBatchWindow,
|
||||
(evt, token) => responseStream.WriteAsync(evt, token),
|
||||
// Recorded only on a negotiated stream: on an un-negotiated one every
|
||||
// frame carries exactly one event, so the histogram would degenerate
|
||||
// into a per-event instrument on the product's hottest path.
|
||||
batchingSupported
|
||||
? size => ScadaBridgeTelemetry.RecordSiteStreamBatchSize(streamKind, size)
|
||||
: null,
|
||||
streamCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
|
||||
@@ -20,6 +20,14 @@ service SiteStreamService {
|
||||
message InstanceStreamRequest {
|
||||
string correlation_id = 1;
|
||||
string instance_unique_name = 2;
|
||||
// Client-declared BATCH NEGOTIATION (R2, event batching). When true the client
|
||||
// understands the SiteStreamEventBatch oneof case and the server may coalesce
|
||||
// consecutive events into one frame. proto3 defaults this to false, so an OLD
|
||||
// central that never sets it keeps receiving one frame per event — that default
|
||||
// IS the negotiation, and it is what makes new-site↔old-central safe. A NEW
|
||||
// central sets it against an OLD site, which ignores the unknown field and
|
||||
// keeps sending per-event frames the new client also accepts. Additive-only.
|
||||
bool batching_supported = 3;
|
||||
}
|
||||
|
||||
// Request for the site-wide, alarm-only SubscribeSite stream. Unlike
|
||||
@@ -27,6 +35,8 @@ message InstanceStreamRequest {
|
||||
// transitions for every instance on the site.
|
||||
message SiteStreamRequest {
|
||||
string correlation_id = 1;
|
||||
// See InstanceStreamRequest.batching_supported. Additive-only.
|
||||
bool batching_supported = 2;
|
||||
}
|
||||
|
||||
message SiteStreamEvent {
|
||||
@@ -34,9 +44,26 @@ message SiteStreamEvent {
|
||||
oneof event {
|
||||
AttributeValueUpdate attribute_changed = 2;
|
||||
AlarmStateUpdate alarm_changed = 3;
|
||||
// Coalesced frame (R2). Emitted ONLY when the subscription request set
|
||||
// batching_supported = true. A batch is never nested inside a batch, and a
|
||||
// single event is always sent as a plain attribute_changed/alarm_changed
|
||||
// frame — so a quiet stream's wire shape is byte-identical to before.
|
||||
SiteStreamEventBatch batch = 4;
|
||||
}
|
||||
}
|
||||
|
||||
// Coalesced carrier for several consecutive stream events (R2). Ordering is
|
||||
// significant: events appear in the exact order the site produced them, and the
|
||||
// client unpacks them in order into the same per-event pipeline, so per-event
|
||||
// Timestamp fidelity and downstream sequencing are unchanged.
|
||||
//
|
||||
// The inner events deliberately leave correlation_id EMPTY — the enclosing
|
||||
// SiteStreamEvent carries it once for the whole frame, which is the byte saving
|
||||
// batching exists for. No consumer reads the inner correlation_id.
|
||||
message SiteStreamEventBatch {
|
||||
repeated SiteStreamEvent events = 1;
|
||||
}
|
||||
|
||||
enum Quality {
|
||||
QUALITY_UNSPECIFIED = 0;
|
||||
QUALITY_GOOD = 1;
|
||||
|
||||
@@ -26,97 +26,102 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
string.Concat(
|
||||
"ChdQcm90b3Mvc2l0ZXN0cmVhbS5wcm90bxIKc2l0ZXN0cmVhbRofZ29vZ2xl",
|
||||
"L3Byb3RvYnVmL3RpbWVzdGFtcC5wcm90bxoeZ29vZ2xlL3Byb3RvYnVmL3dy",
|
||||
"YXBwZXJzLnByb3RvIk0KFUluc3RhbmNlU3RyZWFtUmVxdWVzdBIWCg5jb3Jy",
|
||||
"YXBwZXJzLnByb3RvImkKFUluc3RhbmNlU3RyZWFtUmVxdWVzdBIWCg5jb3Jy",
|
||||
"ZWxhdGlvbl9pZBgBIAEoCRIcChRpbnN0YW5jZV91bmlxdWVfbmFtZRgCIAEo",
|
||||
"CSIrChFTaXRlU3RyZWFtUmVxdWVzdBIWCg5jb3JyZWxhdGlvbl9pZBgBIAEo",
|
||||
"CSKoAQoPU2l0ZVN0cmVhbUV2ZW50EhYKDmNvcnJlbGF0aW9uX2lkGAEgASgJ",
|
||||
"Ej0KEWF0dHJpYnV0ZV9jaGFuZ2VkGAIgASgLMiAuc2l0ZXN0cmVhbS5BdHRy",
|
||||
"aWJ1dGVWYWx1ZVVwZGF0ZUgAEjUKDWFsYXJtX2NoYW5nZWQYAyABKAsyHC5z",
|
||||
"aXRlc3RyZWFtLkFsYXJtU3RhdGVVcGRhdGVIAEIHCgVldmVudCLIAQoUQXR0",
|
||||
"cmlidXRlVmFsdWVVcGRhdGUSHAoUaW5zdGFuY2VfdW5pcXVlX25hbWUYASAB",
|
||||
"KAkSFgoOYXR0cmlidXRlX3BhdGgYAiABKAkSFgoOYXR0cmlidXRlX25hbWUY",
|
||||
"AyABKAkSDQoFdmFsdWUYBCABKAkSJAoHcXVhbGl0eRgFIAEoDjITLnNpdGVz",
|
||||
"dHJlYW0uUXVhbGl0eRItCgl0aW1lc3RhbXAYBiABKAsyGi5nb29nbGUucHJv",
|
||||
"dG9idWYuVGltZXN0YW1wIq8FChBBbGFybVN0YXRlVXBkYXRlEhwKFGluc3Rh",
|
||||
"bmNlX3VuaXF1ZV9uYW1lGAEgASgJEhIKCmFsYXJtX25hbWUYAiABKAkSKQoF",
|
||||
"c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy",
|
||||
"aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90",
|
||||
"b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs",
|
||||
"YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkSDAoEa2luZBgIIAEoCRIO",
|
||||
"CgZhY3RpdmUYCSABKAgSFAoMYWNrbm93bGVkZ2VkGAogASgIEhEKCWNvbmZp",
|
||||
"cm1lZBgLIAEoCBIUCgxzaGVsdmVfc3RhdGUYDCABKAkSEgoKc3VwcHJlc3Nl",
|
||||
"ZBgNIAEoCBIYChBzb3VyY2VfcmVmZXJlbmNlGA4gASgJEhcKD2FsYXJtX3R5",
|
||||
"cGVfbmFtZRgPIAEoCRIQCghjYXRlZ29yeRgQIAEoCRIVCg1vcGVyYXRvcl91",
|
||||
"c2VyGBEgASgJEhgKEG9wZXJhdG9yX2NvbW1lbnQYEiABKAkSNwoTb3JpZ2lu",
|
||||
"YWxfcmFpc2VfdGltZRgTIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3Rh",
|
||||
"bXASFQoNY3VycmVudF92YWx1ZRgUIAEoCRITCgtsaW1pdF92YWx1ZRgVIAEo",
|
||||
"CRIkChxuYXRpdmVfc291cmNlX2Nhbm9uaWNhbF9uYW1lGBYgASgJEiEKGWlz",
|
||||
"X2NvbmZpZ3VyZWRfcGxhY2Vob2xkZXIYFyABKAgSLAoIYWNrX3RpbWUYGCAB",
|
||||
"KAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIr0ECg1BdWRpdEV2ZW50",
|
||||
"RHRvEhAKCGV2ZW50X2lkGAEgASgJEjMKD29jY3VycmVkX2F0X3V0YxgCIAEo",
|
||||
"CzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASDwoHY2hhbm5lbBgDIAEo",
|
||||
"CRIMCgRraW5kGAQgASgJEhYKDmNvcnJlbGF0aW9uX2lkGAUgASgJEhYKDnNv",
|
||||
"dXJjZV9zaXRlX2lkGAYgASgJEhoKEnNvdXJjZV9pbnN0YW5jZV9pZBgHIAEo",
|
||||
"CRIVCg1zb3VyY2Vfc2NyaXB0GAggASgJEg0KBWFjdG9yGAkgASgJEg4KBnRh",
|
||||
"cmdldBgKIAEoCRIOCgZzdGF0dXMYCyABKAkSMAoLaHR0cF9zdGF0dXMYDCAB",
|
||||
"KAsyGy5nb29nbGUucHJvdG9idWYuSW50MzJWYWx1ZRIwCgtkdXJhdGlvbl9t",
|
||||
"cxgNIAEoCzIbLmdvb2dsZS5wcm90b2J1Zi5JbnQzMlZhbHVlEhUKDWVycm9y",
|
||||
"X21lc3NhZ2UYDiABKAkSFAoMZXJyb3JfZGV0YWlsGA8gASgJEhcKD3JlcXVl",
|
||||
"c3Rfc3VtbWFyeRgQIAEoCRIYChByZXNwb25zZV9zdW1tYXJ5GBEgASgJEhkK",
|
||||
"EXBheWxvYWRfdHJ1bmNhdGVkGBIgASgIEg0KBWV4dHJhGBMgASgJEhQKDGV4",
|
||||
"ZWN1dGlvbl9pZBgUIAEoCRIbChNwYXJlbnRfZXhlY3V0aW9uX2lkGBUgASgJ",
|
||||
"EhMKC3NvdXJjZV9ub2RlGBYgASgJIjwKD0F1ZGl0RXZlbnRCYXRjaBIpCgZl",
|
||||
"dmVudHMYASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZlbnREdG8iJwoJSW5n",
|
||||
"ZXN0QWNrEhoKEmFjY2VwdGVkX2V2ZW50X2lkcxgBIAMoCSKJAwoWU2l0ZUNh",
|
||||
"bGxPcGVyYXRpb25hbER0bxIcChR0cmFja2VkX29wZXJhdGlvbl9pZBgBIAEo",
|
||||
"CRIPCgdjaGFubmVsGAIgASgJEg4KBnRhcmdldBgDIAEoCRITCgtzb3VyY2Vf",
|
||||
"c2l0ZRgEIAEoCRIOCgZzdGF0dXMYBSABKAkSEwoLcmV0cnlfY291bnQYBiAB",
|
||||
"KAUSEgoKbGFzdF9lcnJvchgHIAEoCRIwCgtodHRwX3N0YXR1cxgIIAEoCzIb",
|
||||
"Lmdvb2dsZS5wcm90b2J1Zi5JbnQzMlZhbHVlEjIKDmNyZWF0ZWRfYXRfdXRj",
|
||||
"GAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIyCg51cGRhdGVk",
|
||||
"X2F0X3V0YxgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMwoP",
|
||||
"dGVybWluYWxfYXRfdXRjGAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVz",
|
||||
"dGFtcBITCgtzb3VyY2Vfbm9kZRgMIAEoCSKAAQoVQ2FjaGVkVGVsZW1ldHJ5",
|
||||
"UGFja2V0Ei4KC2F1ZGl0X2V2ZW50GAEgASgLMhkuc2l0ZXN0cmVhbS5BdWRp",
|
||||
"dEV2ZW50RHRvEjcKC29wZXJhdGlvbmFsGAIgASgLMiIuc2l0ZXN0cmVhbS5T",
|
||||
"aXRlQ2FsbE9wZXJhdGlvbmFsRHRvIkoKFENhY2hlZFRlbGVtZXRyeUJhdGNo",
|
||||
"EjIKB3BhY2tldHMYASADKAsyIS5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRy",
|
||||
"eVBhY2tldCJtChZQdWxsQXVkaXRFdmVudHNSZXF1ZXN0Ei0KCXNpbmNlX3V0",
|
||||
"YxgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASEgoKYmF0Y2hf",
|
||||
"c2l6ZRgCIAEoBRIQCghhZnRlcl9pZBgDIAEoCSJcChdQdWxsQXVkaXRFdmVu",
|
||||
"dHNSZXNwb25zZRIpCgZldmVudHMYASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0",
|
||||
"RXZlbnREdG8SFgoObW9yZV9hdmFpbGFibGUYAiABKAgiawoUUHVsbFNpdGVD",
|
||||
"YWxsc1JlcXVlc3QSLQoJc2luY2VfdXRjGAEgASgLMhouZ29vZ2xlLnByb3Rv",
|
||||
"YnVmLlRpbWVzdGFtcBISCgpiYXRjaF9zaXplGAIgASgFEhAKCGFmdGVyX2lk",
|
||||
"GAMgASgJImkKFVB1bGxTaXRlQ2FsbHNSZXNwb25zZRI4CgxvcGVyYXRpb25h",
|
||||
"bHMYASADKAsyIi5zaXRlc3RyZWFtLlNpdGVDYWxsT3BlcmF0aW9uYWxEdG8S",
|
||||
"FgoObW9yZV9hdmFpbGFibGUYAiABKAgqXAoHUXVhbGl0eRIXChNRVUFMSVRZ",
|
||||
"X1VOU1BFQ0lGSUVEEAASEAoMUVVBTElUWV9HT09EEAESFQoRUVVBTElUWV9V",
|
||||
"TkNFUlRBSU4QAhIPCgtRVUFMSVRZX0JBRBADKl0KDkFsYXJtU3RhdGVFbnVt",
|
||||
"EhsKF0FMQVJNX1NUQVRFX1VOU1BFQ0lGSUVEEAASFgoSQUxBUk1fU1RBVEVf",
|
||||
"Tk9STUFMEAESFgoSQUxBUk1fU1RBVEVfQUNUSVZFEAIqhQEKDkFsYXJtTGV2",
|
||||
"ZWxFbnVtEhQKEEFMQVJNX0xFVkVMX05PTkUQABITCg9BTEFSTV9MRVZFTF9M",
|
||||
"T1cQARIXChNBTEFSTV9MRVZFTF9MT1dfTE9XEAISFAoQQUxBUk1fTEVWRUxf",
|
||||
"SElHSBADEhkKFUFMQVJNX0xFVkVMX0hJR0hfSElHSBAEMoYEChFTaXRlU3Ry",
|
||||
"ZWFtU2VydmljZRJVChFTdWJzY3JpYmVJbnN0YW5jZRIhLnNpdGVzdHJlYW0u",
|
||||
"SW5zdGFuY2VTdHJlYW1SZXF1ZXN0Ghsuc2l0ZXN0cmVhbS5TaXRlU3RyZWFt",
|
||||
"RXZlbnQwARJNCg1TdWJzY3JpYmVTaXRlEh0uc2l0ZXN0cmVhbS5TaXRlU3Ry",
|
||||
"ZWFtUmVxdWVzdBobLnNpdGVzdHJlYW0uU2l0ZVN0cmVhbUV2ZW50MAESRwoR",
|
||||
"SW5nZXN0QXVkaXRFdmVudHMSGy5zaXRlc3RyZWFtLkF1ZGl0RXZlbnRCYXRj",
|
||||
"aBoVLnNpdGVzdHJlYW0uSW5nZXN0QWNrElAKFUluZ2VzdENhY2hlZFRlbGVt",
|
||||
"ZXRyeRIgLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gaFS5zaXRl",
|
||||
"c3RyZWFtLkluZ2VzdEFjaxJaCg9QdWxsQXVkaXRFdmVudHMSIi5zaXRlc3Ry",
|
||||
"ZWFtLlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QaIy5zaXRlc3RyZWFtLlB1bGxB",
|
||||
"dWRpdEV2ZW50c1Jlc3BvbnNlElQKDVB1bGxTaXRlQ2FsbHMSIC5zaXRlc3Ry",
|
||||
"ZWFtLlB1bGxTaXRlQ2FsbHNSZXF1ZXN0GiEuc2l0ZXN0cmVhbS5QdWxsU2l0",
|
||||
"ZUNhbGxzUmVzcG9uc2VCK6oCKFpCLk1PTS5XVy5TY2FkYUJyaWRnZS5Db21t",
|
||||
"dW5pY2F0aW9uLkdycGNiBnByb3RvMw=="));
|
||||
"CRIaChJiYXRjaGluZ19zdXBwb3J0ZWQYAyABKAgiRwoRU2l0ZVN0cmVhbVJl",
|
||||
"cXVlc3QSFgoOY29ycmVsYXRpb25faWQYASABKAkSGgoSYmF0Y2hpbmdfc3Vw",
|
||||
"cG9ydGVkGAIgASgIItsBCg9TaXRlU3RyZWFtRXZlbnQSFgoOY29ycmVsYXRp",
|
||||
"b25faWQYASABKAkSPQoRYXR0cmlidXRlX2NoYW5nZWQYAiABKAsyIC5zaXRl",
|
||||
"c3RyZWFtLkF0dHJpYnV0ZVZhbHVlVXBkYXRlSAASNQoNYWxhcm1fY2hhbmdl",
|
||||
"ZBgDIAEoCzIcLnNpdGVzdHJlYW0uQWxhcm1TdGF0ZVVwZGF0ZUgAEjEKBWJh",
|
||||
"dGNoGAQgASgLMiAuc2l0ZXN0cmVhbS5TaXRlU3RyZWFtRXZlbnRCYXRjaEgA",
|
||||
"QgcKBWV2ZW50IkMKFFNpdGVTdHJlYW1FdmVudEJhdGNoEisKBmV2ZW50cxgB",
|
||||
"IAMoCzIbLnNpdGVzdHJlYW0uU2l0ZVN0cmVhbUV2ZW50IsgBChRBdHRyaWJ1",
|
||||
"dGVWYWx1ZVVwZGF0ZRIcChRpbnN0YW5jZV91bmlxdWVfbmFtZRgBIAEoCRIW",
|
||||
"Cg5hdHRyaWJ1dGVfcGF0aBgCIAEoCRIWCg5hdHRyaWJ1dGVfbmFtZRgDIAEo",
|
||||
"CRINCgV2YWx1ZRgEIAEoCRIkCgdxdWFsaXR5GAUgASgOMhMuc2l0ZXN0cmVh",
|
||||
"bS5RdWFsaXR5Ei0KCXRpbWVzdGFtcBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1",
|
||||
"Zi5UaW1lc3RhbXAirwUKEEFsYXJtU3RhdGVVcGRhdGUSHAoUaW5zdGFuY2Vf",
|
||||
"dW5pcXVlX25hbWUYASABKAkSEgoKYWxhcm1fbmFtZRgCIAEoCRIpCgVzdGF0",
|
||||
"ZRgDIAEoDjIaLnNpdGVzdHJlYW0uQWxhcm1TdGF0ZUVudW0SEAoIcHJpb3Jp",
|
||||
"dHkYBCABKAUSLQoJdGltZXN0YW1wGAUgASgLMhouZ29vZ2xlLnByb3RvYnVm",
|
||||
"LlRpbWVzdGFtcBIpCgVsZXZlbBgGIAEoDjIaLnNpdGVzdHJlYW0uQWxhcm1M",
|
||||
"ZXZlbEVudW0SDwoHbWVzc2FnZRgHIAEoCRIMCgRraW5kGAggASgJEg4KBmFj",
|
||||
"dGl2ZRgJIAEoCBIUCgxhY2tub3dsZWRnZWQYCiABKAgSEQoJY29uZmlybWVk",
|
||||
"GAsgASgIEhQKDHNoZWx2ZV9zdGF0ZRgMIAEoCRISCgpzdXBwcmVzc2VkGA0g",
|
||||
"ASgIEhgKEHNvdXJjZV9yZWZlcmVuY2UYDiABKAkSFwoPYWxhcm1fdHlwZV9u",
|
||||
"YW1lGA8gASgJEhAKCGNhdGVnb3J5GBAgASgJEhUKDW9wZXJhdG9yX3VzZXIY",
|
||||
"ESABKAkSGAoQb3BlcmF0b3JfY29tbWVudBgSIAEoCRI3ChNvcmlnaW5hbF9y",
|
||||
"YWlzZV90aW1lGBMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIV",
|
||||
"Cg1jdXJyZW50X3ZhbHVlGBQgASgJEhMKC2xpbWl0X3ZhbHVlGBUgASgJEiQK",
|
||||
"HG5hdGl2ZV9zb3VyY2VfY2Fub25pY2FsX25hbWUYFiABKAkSIQoZaXNfY29u",
|
||||
"ZmlndXJlZF9wbGFjZWhvbGRlchgXIAEoCBIsCghhY2tfdGltZRgYIAEoCzIa",
|
||||
"Lmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAivQQKDUF1ZGl0RXZlbnREdG8S",
|
||||
"EAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgLMhou",
|
||||
"Z29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJEgwK",
|
||||
"BGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291cmNl",
|
||||
"X3NpdGVfaWQYBiABKAkSGgoSc291cmNlX2luc3RhbmNlX2lkGAcgASgJEhUK",
|
||||
"DXNvdXJjZV9zY3JpcHQYCCABKAkSDQoFYWN0b3IYCSABKAkSDgoGdGFyZ2V0",
|
||||
"GAogASgJEg4KBnN0YXR1cxgLIAEoCRIwCgtodHRwX3N0YXR1cxgMIAEoCzIb",
|
||||
"Lmdvb2dsZS5wcm90b2J1Zi5JbnQzMlZhbHVlEjAKC2R1cmF0aW9uX21zGA0g",
|
||||
"ASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSFQoNZXJyb3JfbWVz",
|
||||
"c2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVzdF9z",
|
||||
"dW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoRcGF5",
|
||||
"bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkSFAoMZXhlY3V0",
|
||||
"aW9uX2lkGBQgASgJEhsKE3BhcmVudF9leGVjdXRpb25faWQYFSABKAkSEwoL",
|
||||
"c291cmNlX25vZGUYFiABKAkiPAoPQXVkaXRFdmVudEJhdGNoEikKBmV2ZW50",
|
||||
"cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0byInCglJbmdlc3RB",
|
||||
"Y2sSGgoSYWNjZXB0ZWRfZXZlbnRfaWRzGAEgAygJIokDChZTaXRlQ2FsbE9w",
|
||||
"ZXJhdGlvbmFsRHRvEhwKFHRyYWNrZWRfb3BlcmF0aW9uX2lkGAEgASgJEg8K",
|
||||
"B2NoYW5uZWwYAiABKAkSDgoGdGFyZ2V0GAMgASgJEhMKC3NvdXJjZV9zaXRl",
|
||||
"GAQgASgJEg4KBnN0YXR1cxgFIAEoCRITCgtyZXRyeV9jb3VudBgGIAEoBRIS",
|
||||
"CgpsYXN0X2Vycm9yGAcgASgJEjAKC2h0dHBfc3RhdHVzGAggASgLMhsuZ29v",
|
||||
"Z2xlLnByb3RvYnVmLkludDMyVmFsdWUSMgoOY3JlYXRlZF9hdF91dGMYCSAB",
|
||||
"KAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjIKDnVwZGF0ZWRfYXRf",
|
||||
"dXRjGAogASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIzCg90ZXJt",
|
||||
"aW5hbF9hdF91dGMYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1w",
|
||||
"EhMKC3NvdXJjZV9ub2RlGAwgASgJIoABChVDYWNoZWRUZWxlbWV0cnlQYWNr",
|
||||
"ZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZl",
|
||||
"bnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFtLlNpdGVD",
|
||||
"YWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gSMgoH",
|
||||
"cGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5UGFj",
|
||||
"a2V0Im0KFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2VfdXRjGAEg",
|
||||
"ASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRjaF9zaXpl",
|
||||
"GAIgASgFEhAKCGFmdGVyX2lkGAMgASgJIlwKF1B1bGxBdWRpdEV2ZW50c1Jl",
|
||||
"c3BvbnNlEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVu",
|
||||
"dER0bxIWCg5tb3JlX2F2YWlsYWJsZRgCIAEoCCJrChRQdWxsU2l0ZUNhbGxz",
|
||||
"UmVxdWVzdBItCglzaW5jZV91dGMYASABKAsyGi5nb29nbGUucHJvdG9idWYu",
|
||||
"VGltZXN0YW1wEhIKCmJhdGNoX3NpemUYAiABKAUSEAoIYWZ0ZXJfaWQYAyAB",
|
||||
"KAkiaQoVUHVsbFNpdGVDYWxsc1Jlc3BvbnNlEjgKDG9wZXJhdGlvbmFscxgB",
|
||||
"IAMoCzIiLnNpdGVzdHJlYW0uU2l0ZUNhbGxPcGVyYXRpb25hbER0bxIWCg5t",
|
||||
"b3JlX2F2YWlsYWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5T",
|
||||
"UEVDSUZJRUQQABIQCgxRVUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VS",
|
||||
"VEFJThACEg8KC1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoX",
|
||||
"QUxBUk1fU1RBVEVfVU5TUEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JN",
|
||||
"QUwQARIWChJBTEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVu",
|
||||
"dW0SFAoQQUxBUk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxAB",
|
||||
"EhcKE0FMQVJNX0xFVkVMX0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdI",
|
||||
"EAMSGQoVQUxBUk1fTEVWRUxfSElHSF9ISUdIEAQyhgQKEVNpdGVTdHJlYW1T",
|
||||
"ZXJ2aWNlElUKEVN1YnNjcmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0",
|
||||
"YW5jZVN0cmVhbVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVu",
|
||||
"dDABEk0KDVN1YnNjcmliZVNpdGUSHS5zaXRlc3RyZWFtLlNpdGVTdHJlYW1S",
|
||||
"ZXF1ZXN0Ghsuc2l0ZXN0cmVhbS5TaXRlU3RyZWFtRXZlbnQwARJHChFJbmdl",
|
||||
"c3RBdWRpdEV2ZW50cxIbLnNpdGVzdHJlYW0uQXVkaXRFdmVudEJhdGNoGhUu",
|
||||
"c2l0ZXN0cmVhbS5Jbmdlc3RBY2sSUAoVSW5nZXN0Q2FjaGVkVGVsZW1ldHJ5",
|
||||
"EiAuc2l0ZXN0cmVhbS5DYWNoZWRUZWxlbWV0cnlCYXRjaBoVLnNpdGVzdHJl",
|
||||
"YW0uSW5nZXN0QWNrEloKD1B1bGxBdWRpdEV2ZW50cxIiLnNpdGVzdHJlYW0u",
|
||||
"UHVsbEF1ZGl0RXZlbnRzUmVxdWVzdBojLnNpdGVzdHJlYW0uUHVsbEF1ZGl0",
|
||||
"RXZlbnRzUmVzcG9uc2USVAoNUHVsbFNpdGVDYWxscxIgLnNpdGVzdHJlYW0u",
|
||||
"UHVsbFNpdGVDYWxsc1JlcXVlc3QaIS5zaXRlc3RyZWFtLlB1bGxTaXRlQ2Fs",
|
||||
"bHNSZXNwb25zZUIrqgIoWkIuTU9NLldXLlNjYWRhQnJpZGdlLkNvbW11bmlj",
|
||||
"YXRpb24uR3JwY2IGcHJvdG8z"));
|
||||
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
|
||||
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, },
|
||||
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.Quality), typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateEnum), typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.InstanceStreamRequest), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.InstanceStreamRequest.Parser, new[]{ "CorrelationId", "InstanceUniqueName" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamRequest), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamRequest.Parser, new[]{ "CorrelationId" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged" }, new[]{ "Event" }, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.InstanceStreamRequest), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.InstanceStreamRequest.Parser, new[]{ "CorrelationId", "InstanceUniqueName", "BatchingSupported" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamRequest), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamRequest.Parser, new[]{ "CorrelationId", "BatchingSupported" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged", "Batch" }, new[]{ "Event" }, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEventBatch), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEventBatch.Parser, new[]{ "Events" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AttributeValueUpdate), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AttributeValueUpdate.Parser, new[]{ "InstanceUniqueName", "AttributePath", "AttributeName", "Value", "Quality", "Timestamp" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateUpdate), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message", "Kind", "Active", "Acknowledged", "Confirmed", "ShelveState", "Suppressed", "SourceReference", "AlarmTypeName", "Category", "OperatorUser", "OperatorComment", "OriginalRaiseTime", "CurrentValue", "LimitValue", "NativeSourceCanonicalName", "IsConfiguredPlaceholder", "AckTime" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ExecutionId", "ParentExecutionId", "SourceNode" }, null, null, null, null),
|
||||
@@ -201,6 +206,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
public InstanceStreamRequest(InstanceStreamRequest other) : this() {
|
||||
correlationId_ = other.correlationId_;
|
||||
instanceUniqueName_ = other.instanceUniqueName_;
|
||||
batchingSupported_ = other.batchingSupported_;
|
||||
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -234,6 +240,27 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "batching_supported" field.</summary>
|
||||
public const int BatchingSupportedFieldNumber = 3;
|
||||
private bool batchingSupported_;
|
||||
/// <summary>
|
||||
/// Client-declared BATCH NEGOTIATION (R2, event batching). When true the client
|
||||
/// understands the SiteStreamEventBatch oneof case and the server may coalesce
|
||||
/// consecutive events into one frame. proto3 defaults this to false, so an OLD
|
||||
/// central that never sets it keeps receiving one frame per event — that default
|
||||
/// IS the negotiation, and it is what makes new-site↔old-central safe. A NEW
|
||||
/// central sets it against an OLD site, which ignores the unknown field and
|
||||
/// keeps sending per-event frames the new client also accepts. Additive-only.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool BatchingSupported {
|
||||
get { return batchingSupported_; }
|
||||
set {
|
||||
batchingSupported_ = value;
|
||||
}
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public override bool Equals(object other) {
|
||||
@@ -251,6 +278,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
}
|
||||
if (CorrelationId != other.CorrelationId) return false;
|
||||
if (InstanceUniqueName != other.InstanceUniqueName) return false;
|
||||
if (BatchingSupported != other.BatchingSupported) return false;
|
||||
return Equals(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -260,6 +288,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
int hash = 1;
|
||||
if (CorrelationId.Length != 0) hash ^= CorrelationId.GetHashCode();
|
||||
if (InstanceUniqueName.Length != 0) hash ^= InstanceUniqueName.GetHashCode();
|
||||
if (BatchingSupported != false) hash ^= BatchingSupported.GetHashCode();
|
||||
if (_unknownFields != null) {
|
||||
hash ^= _unknownFields.GetHashCode();
|
||||
}
|
||||
@@ -286,6 +315,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
output.WriteRawTag(18);
|
||||
output.WriteString(InstanceUniqueName);
|
||||
}
|
||||
if (BatchingSupported != false) {
|
||||
output.WriteRawTag(24);
|
||||
output.WriteBool(BatchingSupported);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(output);
|
||||
}
|
||||
@@ -304,6 +337,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
output.WriteRawTag(18);
|
||||
output.WriteString(InstanceUniqueName);
|
||||
}
|
||||
if (BatchingSupported != false) {
|
||||
output.WriteRawTag(24);
|
||||
output.WriteBool(BatchingSupported);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(ref output);
|
||||
}
|
||||
@@ -320,6 +357,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
if (InstanceUniqueName.Length != 0) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeStringSize(InstanceUniqueName);
|
||||
}
|
||||
if (BatchingSupported != false) {
|
||||
size += 1 + 1;
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
size += _unknownFields.CalculateSize();
|
||||
}
|
||||
@@ -338,6 +378,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
if (other.InstanceUniqueName.Length != 0) {
|
||||
InstanceUniqueName = other.InstanceUniqueName;
|
||||
}
|
||||
if (other.BatchingSupported != false) {
|
||||
BatchingSupported = other.BatchingSupported;
|
||||
}
|
||||
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -365,6 +408,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
InstanceUniqueName = input.ReadString();
|
||||
break;
|
||||
}
|
||||
case 24: {
|
||||
BatchingSupported = input.ReadBool();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -392,6 +439,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
InstanceUniqueName = input.ReadString();
|
||||
break;
|
||||
}
|
||||
case 24: {
|
||||
BatchingSupported = input.ReadBool();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,6 +491,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public SiteStreamRequest(SiteStreamRequest other) : this() {
|
||||
correlationId_ = other.correlationId_;
|
||||
batchingSupported_ = other.batchingSupported_;
|
||||
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -461,6 +513,21 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "batching_supported" field.</summary>
|
||||
public const int BatchingSupportedFieldNumber = 2;
|
||||
private bool batchingSupported_;
|
||||
/// <summary>
|
||||
/// See InstanceStreamRequest.batching_supported. Additive-only.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool BatchingSupported {
|
||||
get { return batchingSupported_; }
|
||||
set {
|
||||
batchingSupported_ = value;
|
||||
}
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public override bool Equals(object other) {
|
||||
@@ -477,6 +544,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
return true;
|
||||
}
|
||||
if (CorrelationId != other.CorrelationId) return false;
|
||||
if (BatchingSupported != other.BatchingSupported) return false;
|
||||
return Equals(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -485,6 +553,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
public override int GetHashCode() {
|
||||
int hash = 1;
|
||||
if (CorrelationId.Length != 0) hash ^= CorrelationId.GetHashCode();
|
||||
if (BatchingSupported != false) hash ^= BatchingSupported.GetHashCode();
|
||||
if (_unknownFields != null) {
|
||||
hash ^= _unknownFields.GetHashCode();
|
||||
}
|
||||
@@ -507,6 +576,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
output.WriteRawTag(10);
|
||||
output.WriteString(CorrelationId);
|
||||
}
|
||||
if (BatchingSupported != false) {
|
||||
output.WriteRawTag(16);
|
||||
output.WriteBool(BatchingSupported);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(output);
|
||||
}
|
||||
@@ -521,6 +594,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
output.WriteRawTag(10);
|
||||
output.WriteString(CorrelationId);
|
||||
}
|
||||
if (BatchingSupported != false) {
|
||||
output.WriteRawTag(16);
|
||||
output.WriteBool(BatchingSupported);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(ref output);
|
||||
}
|
||||
@@ -534,6 +611,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
if (CorrelationId.Length != 0) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeStringSize(CorrelationId);
|
||||
}
|
||||
if (BatchingSupported != false) {
|
||||
size += 1 + 1;
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
size += _unknownFields.CalculateSize();
|
||||
}
|
||||
@@ -549,6 +629,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
if (other.CorrelationId.Length != 0) {
|
||||
CorrelationId = other.CorrelationId;
|
||||
}
|
||||
if (other.BatchingSupported != false) {
|
||||
BatchingSupported = other.BatchingSupported;
|
||||
}
|
||||
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -572,6 +655,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
CorrelationId = input.ReadString();
|
||||
break;
|
||||
}
|
||||
case 16: {
|
||||
BatchingSupported = input.ReadBool();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -595,6 +682,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
CorrelationId = input.ReadString();
|
||||
break;
|
||||
}
|
||||
case 16: {
|
||||
BatchingSupported = input.ReadBool();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -645,6 +736,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
case EventOneofCase.AlarmChanged:
|
||||
AlarmChanged = other.AlarmChanged.Clone();
|
||||
break;
|
||||
case EventOneofCase.Batch:
|
||||
Batch = other.Batch.Clone();
|
||||
break;
|
||||
}
|
||||
|
||||
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
||||
@@ -692,12 +786,31 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "batch" field.</summary>
|
||||
public const int BatchFieldNumber = 4;
|
||||
/// <summary>
|
||||
/// Coalesced frame (R2). Emitted ONLY when the subscription request set
|
||||
/// batching_supported = true. A batch is never nested inside a batch, and a
|
||||
/// single event is always sent as a plain attribute_changed/alarm_changed
|
||||
/// frame — so a quiet stream's wire shape is byte-identical to before.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEventBatch Batch {
|
||||
get { return eventCase_ == EventOneofCase.Batch ? (global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEventBatch) event_ : null; }
|
||||
set {
|
||||
event_ = value;
|
||||
eventCase_ = value == null ? EventOneofCase.None : EventOneofCase.Batch;
|
||||
}
|
||||
}
|
||||
|
||||
private object event_;
|
||||
/// <summary>Enum of possible cases for the "event" oneof.</summary>
|
||||
public enum EventOneofCase {
|
||||
None = 0,
|
||||
AttributeChanged = 2,
|
||||
AlarmChanged = 3,
|
||||
Batch = 4,
|
||||
}
|
||||
private EventOneofCase eventCase_ = EventOneofCase.None;
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
@@ -731,6 +844,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
if (CorrelationId != other.CorrelationId) return false;
|
||||
if (!object.Equals(AttributeChanged, other.AttributeChanged)) return false;
|
||||
if (!object.Equals(AlarmChanged, other.AlarmChanged)) return false;
|
||||
if (!object.Equals(Batch, other.Batch)) return false;
|
||||
if (EventCase != other.EventCase) return false;
|
||||
return Equals(_unknownFields, other._unknownFields);
|
||||
}
|
||||
@@ -742,6 +856,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
if (CorrelationId.Length != 0) hash ^= CorrelationId.GetHashCode();
|
||||
if (eventCase_ == EventOneofCase.AttributeChanged) hash ^= AttributeChanged.GetHashCode();
|
||||
if (eventCase_ == EventOneofCase.AlarmChanged) hash ^= AlarmChanged.GetHashCode();
|
||||
if (eventCase_ == EventOneofCase.Batch) hash ^= Batch.GetHashCode();
|
||||
hash ^= (int) eventCase_;
|
||||
if (_unknownFields != null) {
|
||||
hash ^= _unknownFields.GetHashCode();
|
||||
@@ -773,6 +888,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
output.WriteRawTag(26);
|
||||
output.WriteMessage(AlarmChanged);
|
||||
}
|
||||
if (eventCase_ == EventOneofCase.Batch) {
|
||||
output.WriteRawTag(34);
|
||||
output.WriteMessage(Batch);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(output);
|
||||
}
|
||||
@@ -795,6 +914,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
output.WriteRawTag(26);
|
||||
output.WriteMessage(AlarmChanged);
|
||||
}
|
||||
if (eventCase_ == EventOneofCase.Batch) {
|
||||
output.WriteRawTag(34);
|
||||
output.WriteMessage(Batch);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(ref output);
|
||||
}
|
||||
@@ -814,6 +937,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
if (eventCase_ == EventOneofCase.AlarmChanged) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeMessageSize(AlarmChanged);
|
||||
}
|
||||
if (eventCase_ == EventOneofCase.Batch) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeMessageSize(Batch);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
size += _unknownFields.CalculateSize();
|
||||
}
|
||||
@@ -842,6 +968,12 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
}
|
||||
AlarmChanged.MergeFrom(other.AlarmChanged);
|
||||
break;
|
||||
case EventOneofCase.Batch:
|
||||
if (Batch == null) {
|
||||
Batch = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEventBatch();
|
||||
}
|
||||
Batch.MergeFrom(other.Batch);
|
||||
break;
|
||||
}
|
||||
|
||||
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
||||
@@ -885,6 +1017,15 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
AlarmChanged = subBuilder;
|
||||
break;
|
||||
}
|
||||
case 34: {
|
||||
global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEventBatch subBuilder = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEventBatch();
|
||||
if (eventCase_ == EventOneofCase.Batch) {
|
||||
subBuilder.MergeFrom(Batch);
|
||||
}
|
||||
input.ReadMessage(subBuilder);
|
||||
Batch = subBuilder;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -926,6 +1067,212 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
AlarmChanged = subBuilder;
|
||||
break;
|
||||
}
|
||||
case 34: {
|
||||
global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEventBatch subBuilder = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEventBatch();
|
||||
if (eventCase_ == EventOneofCase.Batch) {
|
||||
subBuilder.MergeFrom(Batch);
|
||||
}
|
||||
input.ReadMessage(subBuilder);
|
||||
Batch = subBuilder;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coalesced carrier for several consecutive stream events (R2). Ordering is
|
||||
/// significant: events appear in the exact order the site produced them, and the
|
||||
/// client unpacks them in order into the same per-event pipeline, so per-event
|
||||
/// Timestamp fidelity and downstream sequencing are unchanged.
|
||||
///
|
||||
/// The inner events deliberately leave correlation_id EMPTY — the enclosing
|
||||
/// SiteStreamEvent carries it once for the whole frame, which is the byte saving
|
||||
/// batching exists for. No consumer reads the inner correlation_id.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
|
||||
public sealed partial class SiteStreamEventBatch : pb::IMessage<SiteStreamEventBatch>
|
||||
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
||||
, pb::IBufferMessage
|
||||
#endif
|
||||
{
|
||||
private static readonly pb::MessageParser<SiteStreamEventBatch> _parser = new pb::MessageParser<SiteStreamEventBatch>(() => new SiteStreamEventBatch());
|
||||
private pb::UnknownFieldSet _unknownFields;
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public static pb::MessageParser<SiteStreamEventBatch> Parser { get { return _parser; } }
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public static pbr::MessageDescriptor Descriptor {
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[3]; }
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
pbr::MessageDescriptor pb::IMessage.Descriptor {
|
||||
get { return Descriptor; }
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public SiteStreamEventBatch() {
|
||||
OnConstruction();
|
||||
}
|
||||
|
||||
partial void OnConstruction();
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public SiteStreamEventBatch(SiteStreamEventBatch other) : this() {
|
||||
events_ = other.events_.Clone();
|
||||
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public SiteStreamEventBatch Clone() {
|
||||
return new SiteStreamEventBatch(this);
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "events" field.</summary>
|
||||
public const int EventsFieldNumber = 1;
|
||||
private static readonly pb::FieldCodec<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent> _repeated_events_codec
|
||||
= pb::FieldCodec.ForMessage(10, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent.Parser);
|
||||
private readonly pbc::RepeatedField<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent> events_ = new pbc::RepeatedField<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent>();
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public pbc::RepeatedField<global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent> Events {
|
||||
get { return events_; }
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public override bool Equals(object other) {
|
||||
return Equals(other as SiteStreamEventBatch);
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool Equals(SiteStreamEventBatch other) {
|
||||
if (ReferenceEquals(other, null)) {
|
||||
return false;
|
||||
}
|
||||
if (ReferenceEquals(other, this)) {
|
||||
return true;
|
||||
}
|
||||
if(!events_.Equals(other.events_)) return false;
|
||||
return Equals(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public override int GetHashCode() {
|
||||
int hash = 1;
|
||||
hash ^= events_.GetHashCode();
|
||||
if (_unknownFields != null) {
|
||||
hash ^= _unknownFields.GetHashCode();
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public override string ToString() {
|
||||
return pb::JsonFormatter.ToDiagnosticString(this);
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public void WriteTo(pb::CodedOutputStream output) {
|
||||
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
||||
output.WriteRawMessage(this);
|
||||
#else
|
||||
events_.WriteTo(output, _repeated_events_codec);
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(output);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
|
||||
events_.WriteTo(ref output, _repeated_events_codec);
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(ref output);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public int CalculateSize() {
|
||||
int size = 0;
|
||||
size += events_.CalculateSize(_repeated_events_codec);
|
||||
if (_unknownFields != null) {
|
||||
size += _unknownFields.CalculateSize();
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public void MergeFrom(SiteStreamEventBatch other) {
|
||||
if (other == null) {
|
||||
return;
|
||||
}
|
||||
events_.Add(other.events_);
|
||||
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public void MergeFrom(pb::CodedInputStream input) {
|
||||
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
||||
input.ReadRawMessage(this);
|
||||
#else
|
||||
uint tag;
|
||||
while ((tag = input.ReadTag()) != 0) {
|
||||
if ((tag & 7) == 4) {
|
||||
// Abort on any end group tag.
|
||||
return;
|
||||
}
|
||||
switch(tag) {
|
||||
default:
|
||||
_unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
|
||||
break;
|
||||
case 10: {
|
||||
events_.AddEntriesFrom(input, _repeated_events_codec);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) {
|
||||
uint tag;
|
||||
while ((tag = input.ReadTag()) != 0) {
|
||||
if ((tag & 7) == 4) {
|
||||
// Abort on any end group tag.
|
||||
return;
|
||||
}
|
||||
switch(tag) {
|
||||
default:
|
||||
_unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input);
|
||||
break;
|
||||
case 10: {
|
||||
events_.AddEntriesFrom(ref input, _repeated_events_codec);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -948,7 +1295,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public static pbr::MessageDescriptor Descriptor {
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[3]; }
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[4]; }
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
@@ -1340,7 +1687,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public static pbr::MessageDescriptor Descriptor {
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[4]; }
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[5]; }
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
@@ -2461,7 +2808,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public static pbr::MessageDescriptor Descriptor {
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[5]; }
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[6]; }
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
@@ -3476,7 +3823,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public static pbr::MessageDescriptor Descriptor {
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[6]; }
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[7]; }
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
@@ -3663,7 +4010,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public static pbr::MessageDescriptor Descriptor {
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[7]; }
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[8]; }
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
@@ -3856,7 +4203,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public static pbr::MessageDescriptor Descriptor {
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[8]; }
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[9]; }
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
@@ -4514,7 +4861,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public static pbr::MessageDescriptor Descriptor {
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[9]; }
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[10]; }
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
@@ -4767,7 +5114,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public static pbr::MessageDescriptor Descriptor {
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[10]; }
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[11]; }
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
@@ -4965,7 +5312,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public static pbr::MessageDescriptor Descriptor {
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[11]; }
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[12]; }
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
@@ -5257,7 +5604,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public static pbr::MessageDescriptor Descriptor {
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[12]; }
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[13]; }
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
@@ -5490,7 +5837,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public static pbr::MessageDescriptor Descriptor {
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[13]; }
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[14]; }
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
@@ -5780,7 +6127,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public static pbr::MessageDescriptor Descriptor {
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[14]; }
|
||||
get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor.MessageTypes[15]; }
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
|
||||
@@ -90,6 +90,73 @@ public class CommunicationOptionsValidatorTests
|
||||
Assert.Contains("GrpcMaxConcurrentStreams", result.FailureMessage);
|
||||
}
|
||||
|
||||
// ── R2: site→central stream event batching ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void DefaultStreamBatchOptions_AreValid()
|
||||
{
|
||||
var options = new CommunicationOptions();
|
||||
Assert.Equal(100, options.GrpcStreamBatchMaxEvents);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(25), options.GrpcStreamBatchWindow);
|
||||
Assert.True(Validate(options).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamBatchMaxEventsOfOne_IsValid_AndMeansBatchingDisabled()
|
||||
{
|
||||
var result = Validate(new CommunicationOptions { GrpcStreamBatchMaxEvents = 1 });
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonPositiveStreamBatchMaxEvents_IsRejected()
|
||||
{
|
||||
var result = Validate(new CommunicationOptions { GrpcStreamBatchMaxEvents = 0 });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("GrpcStreamBatchMaxEvents", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroStreamBatchWindow_IsValid()
|
||||
{
|
||||
// Zero = "pack only what is already queued, never wait" — a legitimate posture for
|
||||
// a latency-critical deployment that still wants the framing saving.
|
||||
var result = Validate(new CommunicationOptions { GrpcStreamBatchWindow = TimeSpan.Zero });
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeStreamBatchWindow_IsRejected()
|
||||
{
|
||||
var result = Validate(new CommunicationOptions
|
||||
{
|
||||
GrpcStreamBatchWindow = TimeSpan.FromMilliseconds(-1)
|
||||
});
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("GrpcStreamBatchWindow", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamBatchWindowAtOrAboveTheLatencyBudget_IsRejected()
|
||||
{
|
||||
// The coalescing window is the only latency batching adds and the target-scale
|
||||
// load test holds end-to-end stream latency to a 250 ms P99 — a window that could
|
||||
// spend the whole budget must not boot.
|
||||
foreach (var window in new[] { TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(1) })
|
||||
{
|
||||
var result = Validate(new CommunicationOptions { GrpcStreamBatchWindow = window });
|
||||
Assert.True(result.Failed, $"{window} was accepted");
|
||||
Assert.Contains("GrpcStreamBatchWindow", result.FailureMessage);
|
||||
}
|
||||
|
||||
// Just inside the ceiling is accepted — the bound is exclusive, not a round-down.
|
||||
Assert.True(Validate(new CommunicationOptions
|
||||
{
|
||||
GrpcStreamBatchWindow = CommunicationOptionsValidator.StreamBatchWindowCeiling
|
||||
- TimeSpan.FromMilliseconds(1)
|
||||
}).Succeeded);
|
||||
}
|
||||
|
||||
// ── Aggregated live alarm cache options (plan #10, Task 6) ───────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -20,6 +20,17 @@ public class ProtoContractTests
|
||||
SiteStreamEvent.EventOneofCase.AlarmChanged
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Oneof variants that are NOT per-event payloads and so are deliberately absent from
|
||||
/// <see cref="HandledCases"/>. <c>Batch</c> (R2) is a framing envelope: it is unpacked
|
||||
/// by <see cref="SiteStreamGrpcClient.ForEachEvent"/> into the per-event cases above
|
||||
/// BEFORE conversion, and never reaches <c>ConvertToDomainEvent</c> as a whole frame.
|
||||
/// </summary>
|
||||
private static readonly SiteStreamEvent.EventOneofCase[] FramingCases =
|
||||
[
|
||||
SiteStreamEvent.EventOneofCase.Batch
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void AllOneofVariants_HaveConversionHandlers()
|
||||
{
|
||||
@@ -27,9 +38,37 @@ public class ProtoContractTests
|
||||
.Where(c => c != SiteStreamEvent.EventOneofCase.None)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(allCases.Length, HandledCases.Length);
|
||||
var accountedFor = HandledCases.Concat(FramingCases).ToArray();
|
||||
Assert.Equal(allCases.Length, accountedFor.Length);
|
||||
foreach (var c in allCases)
|
||||
Assert.Contains(c, HandledCases);
|
||||
Assert.Contains(c, accountedFor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchFrame_IsUnpackedIntoPerEventCases_NotConvertedWhole()
|
||||
{
|
||||
// The framing case's contract: ForEachEvent hands the per-event cases to the
|
||||
// handler in order, and ConvertToDomainEvent is never asked to make sense of the
|
||||
// envelope itself (it would return null, silently dropping the whole batch).
|
||||
var inner = new[]
|
||||
{
|
||||
CreateTestEvent(SiteStreamEvent.EventOneofCase.AttributeChanged),
|
||||
CreateTestEvent(SiteStreamEvent.EventOneofCase.AlarmChanged)
|
||||
};
|
||||
var frame = new SiteStreamEvent
|
||||
{
|
||||
CorrelationId = "test",
|
||||
Batch = new SiteStreamEventBatch { Events = { inner } }
|
||||
};
|
||||
|
||||
Assert.Null(SiteStreamGrpcClient.ConvertToDomainEvent(frame));
|
||||
|
||||
var seen = new List<SiteStreamEvent.EventOneofCase>();
|
||||
SiteStreamGrpcClient.ForEachEvent(frame, e => seen.Add(e.EventCase));
|
||||
|
||||
Assert.Equal(
|
||||
[SiteStreamEvent.EventOneofCase.AttributeChanged, SiteStreamEvent.EventOneofCase.AlarmChanged],
|
||||
seen);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Channels;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the per-subscriber coalescing pump behind R2 (gRPC event batching).
|
||||
/// The pump is the only new behaviour on the site→central hot path, so its contract is
|
||||
/// pinned directly rather than only through the server: the size cap, the time cap, the
|
||||
/// flush when the channel writer completes, the "never reorder" guarantee, and the
|
||||
/// un-negotiated (maxBatchEvents == 1) shape that keeps an older central working.
|
||||
/// </summary>
|
||||
public class SiteStreamEventBatcherTests
|
||||
{
|
||||
private const string Corr = "corr-batch";
|
||||
|
||||
private static SiteStreamEvent Event(int seq) => new()
|
||||
{
|
||||
CorrelationId = Corr,
|
||||
AttributeChanged = new AttributeValueUpdate
|
||||
{
|
||||
InstanceUniqueName = "SiteA.Pump01",
|
||||
AttributePath = "Modules.IO",
|
||||
AttributeName = "Seq",
|
||||
Value = seq.ToString(),
|
||||
Quality = Quality.Good,
|
||||
Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UnixEpoch.AddSeconds(seq))
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>Flattens an emitted frame into the sequence numbers it carried, in order.</summary>
|
||||
private static IEnumerable<int> Seqs(SiteStreamEvent frame)
|
||||
{
|
||||
if (frame.EventCase == SiteStreamEvent.EventOneofCase.Batch)
|
||||
{
|
||||
foreach (var inner in frame.Batch.Events)
|
||||
yield return int.Parse(inner.AttributeChanged.Value);
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return int.Parse(frame.AttributeChanged.Value);
|
||||
}
|
||||
|
||||
private sealed record PumpRun(List<SiteStreamEvent> Frames, List<int> FrameSizes, Task Completion);
|
||||
|
||||
private static PumpRun StartPump(
|
||||
ChannelReader<SiteStreamEvent> reader,
|
||||
int maxBatchEvents,
|
||||
TimeSpan window,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var frames = new List<SiteStreamEvent>();
|
||||
var sizes = new List<int>();
|
||||
var task = SiteStreamEventBatcher.PumpAsync(
|
||||
reader,
|
||||
Corr,
|
||||
maxBatchEvents,
|
||||
window,
|
||||
(evt, _) =>
|
||||
{
|
||||
lock (frames) { frames.Add(evt); }
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
size => { lock (frames) { sizes.Add(size); } },
|
||||
ct);
|
||||
return new PumpRun(frames, sizes, task);
|
||||
}
|
||||
|
||||
// ── Size cap ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SizeCap_SplitsABacklogIntoFramesOfAtMostMaxEvents()
|
||||
{
|
||||
// A backlog already sitting in the channel is drained without waiting, but never
|
||||
// beyond the size cap — 250 queued events at a cap of 100 must come out as
|
||||
// 100 + 100 + 50, in order, with nothing lost or duplicated.
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
for (var i = 0; i < 250; i++)
|
||||
Assert.True(channel.Writer.TryWrite(Event(i)));
|
||||
channel.Writer.Complete();
|
||||
|
||||
var run = StartPump(channel.Reader, maxBatchEvents: 100, window: TimeSpan.FromMilliseconds(25));
|
||||
await run.Completion;
|
||||
|
||||
Assert.All(run.FrameSizes, s => Assert.True(s <= 100, $"frame carried {s} events (cap 100)"));
|
||||
Assert.Equal([100, 100, 50], run.FrameSizes);
|
||||
Assert.Equal(Enumerable.Range(0, 250), run.Frames.SelectMany(Seqs));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SizeCapOfOne_EmitsPlainPerEventFrames_TheUnnegotiatedShape()
|
||||
{
|
||||
// maxBatchEvents == 1 is what an un-negotiated subscription (an older central)
|
||||
// gets. Every event must ride its own plain frame — never a Batch case, which
|
||||
// that central's generated code cannot parse.
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
for (var i = 0; i < 5; i++)
|
||||
channel.Writer.TryWrite(Event(i));
|
||||
channel.Writer.Complete();
|
||||
|
||||
var run = StartPump(channel.Reader, maxBatchEvents: 1, window: TimeSpan.FromMilliseconds(25));
|
||||
await run.Completion;
|
||||
|
||||
Assert.Equal(5, run.Frames.Count);
|
||||
Assert.All(run.Frames, f =>
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, f.EventCase));
|
||||
Assert.All(run.Frames, f => Assert.Equal(Corr, f.CorrelationId));
|
||||
Assert.Equal(Enumerable.Range(0, 5), run.Frames.SelectMany(Seqs));
|
||||
}
|
||||
|
||||
// ── Time cap ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task TimeCap_ClosesAnUnderfullBatchWhenTheWindowElapses()
|
||||
{
|
||||
// Two events arrive (a backlog, so the pump lingers), then the source goes quiet
|
||||
// well short of the size cap. The window — not the cap — must close the batch,
|
||||
// and it must do so within a bounded time rather than waiting for a 100th event
|
||||
// that never comes.
|
||||
var window = TimeSpan.FromMilliseconds(120);
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
channel.Writer.TryWrite(Event(0));
|
||||
channel.Writer.TryWrite(Event(1));
|
||||
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
var run = StartPump(channel.Reader, maxBatchEvents: 100, window);
|
||||
|
||||
SiteStreamEvent frame;
|
||||
while (true)
|
||||
{
|
||||
lock (run.Frames)
|
||||
{
|
||||
if (run.Frames.Count > 0) { frame = run.Frames[0]; break; }
|
||||
}
|
||||
Assert.True(Stopwatch.GetElapsedTime(started) < TimeSpan.FromSeconds(5),
|
||||
"the window never closed the underfull batch");
|
||||
await Task.Delay(5);
|
||||
}
|
||||
|
||||
var elapsed = Stopwatch.GetElapsedTime(started);
|
||||
|
||||
channel.Writer.Complete();
|
||||
await run.Completion;
|
||||
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.Batch, frame.EventCase);
|
||||
Assert.Equal([0, 1], Seqs(frame));
|
||||
// The batch waited (it did not close instantly on the two queued events) and it
|
||||
// closed on the window, not on a cap it never reached.
|
||||
Assert.True(elapsed >= window - TimeSpan.FromMilliseconds(20),
|
||||
$"batch closed after {elapsed.TotalMilliseconds:0.0} ms, before the {window.TotalMilliseconds:0} ms window");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoneEventOnAQuietStream_IsNeverDelayedByTheWindow()
|
||||
{
|
||||
// The latency contract: the window applies only AFTER a backlog has been observed.
|
||||
// A single event on an idle stream must be emitted immediately as a plain frame,
|
||||
// so per-event latency on a quiet site is unchanged by batching.
|
||||
var window = TimeSpan.FromSeconds(5);
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
var run = StartPump(channel.Reader, maxBatchEvents: 100, window);
|
||||
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
channel.Writer.TryWrite(Event(7));
|
||||
|
||||
while (true)
|
||||
{
|
||||
lock (run.Frames)
|
||||
{
|
||||
if (run.Frames.Count > 0) break;
|
||||
}
|
||||
Assert.True(Stopwatch.GetElapsedTime(started) < TimeSpan.FromSeconds(3),
|
||||
"a lone event was held by the coalescing window");
|
||||
await Task.Delay(2);
|
||||
}
|
||||
|
||||
var elapsed = Stopwatch.GetElapsedTime(started);
|
||||
channel.Writer.Complete();
|
||||
await run.Completion;
|
||||
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, run.Frames[0].EventCase);
|
||||
Assert.True(elapsed < TimeSpan.FromSeconds(1),
|
||||
$"lone event took {elapsed.TotalMilliseconds:0.0} ms against a {window.TotalSeconds:0} s window");
|
||||
}
|
||||
|
||||
// ── Flush on stream close ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task WriterCompletion_FlushesTheInFlightBatchBeforeReturning()
|
||||
{
|
||||
// The channel writer completing mid-window (the site stopping the relay actor and
|
||||
// calling channel.Writer.TryComplete()) must flush what is already buffered rather
|
||||
// than silently discarding it while waiting out the window.
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
channel.Writer.TryWrite(Event(0));
|
||||
channel.Writer.TryWrite(Event(1));
|
||||
|
||||
// A long window guarantees the pump is lingering, not already past the emit.
|
||||
var run = StartPump(channel.Reader, maxBatchEvents: 100, window: TimeSpan.FromSeconds(30));
|
||||
|
||||
await Task.Delay(100);
|
||||
lock (run.Frames)
|
||||
{
|
||||
Assert.Empty(run.Frames); // still lingering
|
||||
}
|
||||
|
||||
channel.Writer.Complete();
|
||||
await run.Completion.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Single(run.Frames);
|
||||
Assert.Equal([0, 1], Seqs(run.Frames[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriterCompletionWithNothingBuffered_ReturnsWithoutEmitting()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
var run = StartPump(channel.Reader, maxBatchEvents: 100, window: TimeSpan.FromMilliseconds(25));
|
||||
|
||||
channel.Writer.Complete();
|
||||
await run.Completion.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Empty(run.Frames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_EndsThePumpWithOperationCanceled()
|
||||
{
|
||||
// Client disconnect / duplicate replacement / site shutdown. The pump must
|
||||
// surface OperationCanceledException exactly as the pre-batching await-foreach
|
||||
// loop did, so SiteStreamGrpcServer's existing catch and finally are unchanged.
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var run = StartPump(channel.Reader, maxBatchEvents: 100, window: TimeSpan.FromMilliseconds(25), cts.Token);
|
||||
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => run.Completion.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
|
||||
// ── Ordering ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Ordering_IsPreservedAcrossManyBatchesUnderAProducerRace()
|
||||
{
|
||||
// Batching is a framing change and nothing else: with a producer writing
|
||||
// concurrently with the pump, the flattened output must be the exact input
|
||||
// sequence — no reordering, no loss, no duplication, across many frames.
|
||||
const int total = 5_000;
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
var run = StartPump(channel.Reader, maxBatchEvents: 32, window: TimeSpan.FromMilliseconds(5));
|
||||
|
||||
var producer = Task.Run(async () =>
|
||||
{
|
||||
for (var i = 0; i < total; i++)
|
||||
{
|
||||
channel.Writer.TryWrite(Event(i));
|
||||
if (i % 250 == 0) await Task.Yield();
|
||||
}
|
||||
channel.Writer.Complete();
|
||||
});
|
||||
|
||||
await producer;
|
||||
await run.Completion.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.Equal(Enumerable.Range(0, total), run.Frames.SelectMany(Seqs));
|
||||
Assert.All(run.FrameSizes, s => Assert.InRange(s, 1, 32));
|
||||
Assert.Equal(total, run.FrameSizes.Sum());
|
||||
}
|
||||
|
||||
// ── Frame shape ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BatchFrame_CarriesTheCorrelationIdOnceAndBlanksItOnInnerEvents()
|
||||
{
|
||||
// The byte saving batching exists for: the correlation id is stamped once on the
|
||||
// enclosing frame, not repeated on every packed event. No consumer reads the
|
||||
// inner value (SiteStreamGrpcClient.ForEachEvent ignores it).
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
for (var i = 0; i < 4; i++) channel.Writer.TryWrite(Event(i));
|
||||
channel.Writer.Complete();
|
||||
|
||||
var run = StartPump(channel.Reader, maxBatchEvents: 100, window: TimeSpan.FromMilliseconds(25));
|
||||
await run.Completion;
|
||||
|
||||
var frame = Assert.Single(run.Frames);
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.Batch, frame.EventCase);
|
||||
Assert.Equal(Corr, frame.CorrelationId);
|
||||
Assert.All(frame.Batch.Events, e => Assert.Equal(string.Empty, e.CorrelationId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PerEventTimestampsSurviveBatching()
|
||||
{
|
||||
// End-to-end latency measurement rides the per-event Timestamp; coalescing must
|
||||
// not rewrite it to a single frame-level stamp.
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
for (var i = 0; i < 3; i++) channel.Writer.TryWrite(Event(i));
|
||||
channel.Writer.Complete();
|
||||
|
||||
var run = StartPump(channel.Reader, maxBatchEvents: 100, window: TimeSpan.FromMilliseconds(25));
|
||||
await run.Completion;
|
||||
|
||||
var frame = Assert.Single(run.Frames);
|
||||
Assert.Equal(
|
||||
[
|
||||
DateTimeOffset.UnixEpoch,
|
||||
DateTimeOffset.UnixEpoch.AddSeconds(1),
|
||||
DateTimeOffset.UnixEpoch.AddSeconds(2)
|
||||
],
|
||||
frame.Batch.Events.Select(e => e.AttributeChanged.Timestamp.ToDateTimeOffset()));
|
||||
}
|
||||
}
|
||||
@@ -508,6 +508,125 @@ public class SiteStreamGrpcClientTests
|
||||
}
|
||||
}
|
||||
|
||||
// ── R2: batch unpacking on the client ───────────────────────────────────────
|
||||
|
||||
private static SiteStreamEvent Attr(string value, DateTimeOffset ts) => new()
|
||||
{
|
||||
AttributeChanged = new AttributeValueUpdate
|
||||
{
|
||||
InstanceUniqueName = "SiteA.Pump01",
|
||||
AttributePath = "Modules.IO",
|
||||
AttributeName = "Seq",
|
||||
Value = value,
|
||||
Quality = Quality.Good,
|
||||
Timestamp = Timestamp.FromDateTimeOffset(ts)
|
||||
}
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ForEachEvent_PlainFrame_IsDeliveredAsIs()
|
||||
{
|
||||
// An OLD SITE (or any un-negotiated stream) sends one event per frame. The new
|
||||
// client's unpack path must pass it straight through — this is the new-central ↔
|
||||
// old-site skew direction.
|
||||
var frame = Attr("1", DateTimeOffset.UnixEpoch);
|
||||
var seen = new List<SiteStreamEvent>();
|
||||
|
||||
SiteStreamGrpcClient.ForEachEvent(frame, seen.Add);
|
||||
|
||||
Assert.Same(frame, Assert.Single(seen));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForEachEvent_BatchFrame_UnpacksInOrderPreservingPerEventTimestamps()
|
||||
{
|
||||
// Order and per-event Timestamp fidelity are the two properties the downstream
|
||||
// consumers (SiteAlarmAggregatorActor, DebugStreamBridgeActor) and the end-to-end
|
||||
// latency measurement depend on.
|
||||
var t0 = new DateTimeOffset(2026, 8, 15, 9, 0, 0, TimeSpan.Zero);
|
||||
var frame = new SiteStreamEvent
|
||||
{
|
||||
CorrelationId = "corr-batch",
|
||||
Batch = new SiteStreamEventBatch
|
||||
{
|
||||
Events =
|
||||
{
|
||||
Attr("0", t0),
|
||||
Attr("1", t0.AddMilliseconds(3)),
|
||||
Attr("2", t0.AddMilliseconds(11))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var seen = new List<SiteStreamEvent>();
|
||||
SiteStreamGrpcClient.ForEachEvent(frame, seen.Add);
|
||||
|
||||
Assert.Equal(["0", "1", "2"], seen.Select(e => e.AttributeChanged.Value));
|
||||
Assert.Equal(
|
||||
[t0, t0.AddMilliseconds(3), t0.AddMilliseconds(11)],
|
||||
seen.Select(e => e.AttributeChanged.Timestamp.ToDateTimeOffset()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForEachEvent_IgnoresNestedAndUnknownInnerCases()
|
||||
{
|
||||
// The server never nests a batch inside a batch. A nested (or empty) inner frame
|
||||
// from a malformed or hostile peer must be skipped, not followed — unpacking is
|
||||
// deliberately non-recursive so a crafted frame cannot drive unbounded recursion.
|
||||
var frame = new SiteStreamEvent
|
||||
{
|
||||
CorrelationId = "corr-nested",
|
||||
Batch = new SiteStreamEventBatch
|
||||
{
|
||||
Events =
|
||||
{
|
||||
Attr("0", DateTimeOffset.UnixEpoch),
|
||||
new SiteStreamEvent { Batch = new SiteStreamEventBatch { Events = { Attr("hidden", DateTimeOffset.UnixEpoch) } } },
|
||||
new SiteStreamEvent(),
|
||||
Attr("1", DateTimeOffset.UnixEpoch)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var seen = new List<SiteStreamEvent>();
|
||||
SiteStreamGrpcClient.ForEachEvent(frame, seen.Add);
|
||||
|
||||
Assert.Equal(["0", "1"], seen.Select(e => e.AttributeChanged.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConsumeStream_MixedBatchedAndPlainFrames_DeliverEveryEventInOrder()
|
||||
{
|
||||
// A reconnect can straddle a site upgrade, so one stream may legitimately carry
|
||||
// both frame shapes. Driving the real ConsumeStreamAsync with the real unpack
|
||||
// proves the combination is flat and ordered from the consumer's point of view.
|
||||
var client = SiteStreamGrpcClient.CreateForTesting();
|
||||
var cts = new CancellationTokenSource();
|
||||
var delivered = new List<string>();
|
||||
|
||||
void Deliver(SiteStreamEvent e) => delivered.Add(e.AttributeChanged.Value);
|
||||
|
||||
await client.ConsumeStreamAsync(
|
||||
"corr-mixed",
|
||||
cts,
|
||||
() => FakeCall(new StubStreamReader(
|
||||
Attr("0", DateTimeOffset.UnixEpoch),
|
||||
new SiteStreamEvent
|
||||
{
|
||||
CorrelationId = "corr-mixed",
|
||||
Batch = new SiteStreamEventBatch
|
||||
{
|
||||
Events = { Attr("1", DateTimeOffset.UnixEpoch), Attr("2", DateTimeOffset.UnixEpoch) }
|
||||
}
|
||||
},
|
||||
Attr("3", DateTimeOffset.UnixEpoch))),
|
||||
frame => SiteStreamGrpcClient.ForEachEvent(frame, Deliver),
|
||||
_ => { },
|
||||
() => { });
|
||||
|
||||
Assert.Equal(["0", "1", "2", "3"], delivered);
|
||||
}
|
||||
|
||||
private static AsyncServerStreamingCall<SiteStreamEvent> FakeCall(StubStreamReader reader) =>
|
||||
FakeCall(reader, Task.FromResult(new Metadata()));
|
||||
|
||||
|
||||
@@ -580,4 +580,277 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
var server = CreateServer();
|
||||
Assert.Equal(0, server.DroppedStreamEventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DuplicateReplacement_CancelsTheReplacedStream_WithoutDisposingItsCts()
|
||||
{
|
||||
// Regression: the duplicate-replacement path used to Cancel AND Dispose the
|
||||
// replaced stream's CancellationTokenSource. That CTS belongs to the replaced
|
||||
// handler's own `using var streamCts`, which is still running and still has to
|
||||
// read `streamCts.Token` — so the Dispose raced that read and escaped the RPC as
|
||||
// an unhandled ObjectDisposedException. It surfaced only under full-suite load
|
||||
// (GrpcStreamIntegrationTests.Pipeline_DuplicateCorrelationId_ReplacesStream) and
|
||||
// predates R2: the same Dispose and the same first-token-read relationship existed
|
||||
// when the handler still used `ReadAllAsync(streamCts.Token)`.
|
||||
//
|
||||
// The race is made DETERMINISTIC here by gating the first stream inside its setup
|
||||
// window (its _activeStreams entry is registered before Subscribe is called), so
|
||||
// the replacement always lands before the first stream reads its token.
|
||||
using var gate = new ManualResetEventSlim(false);
|
||||
var calls = 0;
|
||||
var subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var n = Interlocked.Increment(ref calls);
|
||||
if (n == 1)
|
||||
gate.Wait(TimeSpan.FromSeconds(15));
|
||||
return $"sub-dup-race-{n}";
|
||||
});
|
||||
|
||||
var server = new SiteStreamGrpcServer(subscriber, _logger);
|
||||
server.SetReady(Sys);
|
||||
|
||||
using var cts1 = new CancellationTokenSource();
|
||||
var stream1 = Task.Run(() => server.SubscribeInstance(
|
||||
MakeRequest("corr-dup-race"),
|
||||
Substitute.For<IServerStreamWriter<SiteStreamEvent>>(),
|
||||
CreateMockContext(cts1.Token)));
|
||||
|
||||
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
||||
await WaitForConditionAsync(() => Volatile.Read(ref calls) == 1);
|
||||
|
||||
using var cts2 = new CancellationTokenSource();
|
||||
var stream2 = Task.Run(() => server.SubscribeInstance(
|
||||
MakeRequest("corr-dup-race"),
|
||||
Substitute.For<IServerStreamWriter<SiteStreamEvent>>(),
|
||||
CreateMockContext(cts2.Token)));
|
||||
|
||||
// The replacement has taken the slot (and cancelled stream 1's CTS) by the time
|
||||
// its own Subscribe has been called.
|
||||
await WaitForConditionAsync(() => Volatile.Read(ref calls) == 2);
|
||||
|
||||
gate.Set();
|
||||
|
||||
// Pre-fix this threw ObjectDisposedException out of the RPC. Post-fix the replaced
|
||||
// stream observes a plain cancellation and unwinds through its normal finally.
|
||||
await stream1;
|
||||
|
||||
cts2.Cancel();
|
||||
await stream2;
|
||||
|
||||
Assert.Equal(0, server.ActiveStreamCount);
|
||||
}
|
||||
|
||||
// ── R2: gRPC event batching, and its negotiation ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BatchOptions_AreBoundFromOptions_AndClampDegenerateValues()
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new CommunicationOptions());
|
||||
var server = new SiteStreamGrpcServer(_subscriber, _logger, options);
|
||||
|
||||
Assert.Equal(100, server.StreamBatchMaxEvents);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(25), server.StreamBatchWindow);
|
||||
|
||||
// CommunicationOptionsValidator fails the boot on these, but a host composed
|
||||
// without validation must not blow up deep inside a live RPC.
|
||||
var degenerate = new SiteStreamGrpcServer(_subscriber, _logger,
|
||||
Microsoft.Extensions.Options.Options.Create(new CommunicationOptions
|
||||
{
|
||||
GrpcStreamBatchMaxEvents = 0,
|
||||
GrpcStreamBatchWindow = TimeSpan.FromMilliseconds(-5),
|
||||
}));
|
||||
Assert.Equal(1, degenerate.StreamBatchMaxEvents);
|
||||
Assert.Equal(TimeSpan.Zero, degenerate.StreamBatchWindow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnnegotiatedSubscription_NeverEmitsABatchFrame()
|
||||
{
|
||||
// OLD-CENTRAL ↔ NEW-SITE skew. proto3 defaults batching_supported to false, which
|
||||
// is exactly what a central built before R2 sends. The site must then keep to one
|
||||
// event per frame — a Batch frame would arrive at that central as
|
||||
// EventOneofCase.None and be silently dropped by its ConvertToDomainEvent.
|
||||
var (server, capture, cts, streamTask, relay) =
|
||||
await StartCapturingStreamAsync(batchingSupported: false);
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
relay.Tell(new Commons.Messages.Streaming.AttributeValueChanged(
|
||||
"Site1.Pump01", "Path", "Attr", i, "Good", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
await WaitForConditionAsync(() => CountEvents(capture) >= 50, 10_000);
|
||||
|
||||
cts.Cancel();
|
||||
await streamTask;
|
||||
|
||||
lock (capture)
|
||||
{
|
||||
Assert.All(capture, f => Assert.NotEqual(SiteStreamEvent.EventOneofCase.Batch, f.EventCase));
|
||||
Assert.Equal(50, capture.Count);
|
||||
}
|
||||
|
||||
GC.KeepAlive(server);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NegotiatedSubscription_CoalescesABacklogIntoFewerFramesThanEvents()
|
||||
{
|
||||
// NEW-CENTRAL ↔ NEW-SITE. A burst pushed at the relay faster than the pump drains
|
||||
// it must come out in strictly fewer frames than events, with every event
|
||||
// preserved in order.
|
||||
const int burst = 400;
|
||||
var (server, capture, cts, streamTask, relay) =
|
||||
await StartCapturingStreamAsync(batchingSupported: true);
|
||||
|
||||
for (var i = 0; i < burst; i++)
|
||||
{
|
||||
relay.Tell(new Commons.Messages.Streaming.AttributeValueChanged(
|
||||
"Site1.Pump01", "Path", "Attr", i, "Good", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
await WaitForConditionAsync(() => CountEvents(capture) >= burst, 15_000);
|
||||
|
||||
cts.Cancel();
|
||||
await streamTask;
|
||||
|
||||
List<SiteStreamEvent> frames;
|
||||
lock (capture) { frames = [.. capture]; }
|
||||
|
||||
Assert.Equal(burst, frames.Sum(CountFrameEvents));
|
||||
Assert.True(frames.Count < burst,
|
||||
$"batching produced {frames.Count} frames for {burst} events — no coalescing happened");
|
||||
Assert.Contains(frames, f => f.EventCase == SiteStreamEvent.EventOneofCase.Batch);
|
||||
|
||||
// Order is preserved end to end: the values arrive 0..burst-1 exactly once each.
|
||||
var values = frames.SelectMany(FlattenAttributeValues).ToArray();
|
||||
Assert.Equal(Enumerable.Range(0, burst).Select(i => i.ToString()), values);
|
||||
|
||||
GC.KeepAlive(server);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchSizeHistogram_IsRecordedOnlyForNegotiatedStreams()
|
||||
{
|
||||
// scadabridge.site.stream.batch_size rides ScadaBridgeTelemetry.MeterName, which is
|
||||
// already in SiteServiceRegistration.ObservedMeters — an unlisted meter exports
|
||||
// nothing, silently. Assert the instrument actually fires, and that it does NOT
|
||||
// fire on an un-negotiated stream (where it would degenerate into a per-event
|
||||
// instrument on the hottest path in the product).
|
||||
var measurements = new List<int>();
|
||||
using var listener = new MeterListener();
|
||||
listener.InstrumentPublished = (instrument, l) =>
|
||||
{
|
||||
if (instrument.Meter.Name == ScadaBridgeTelemetry.MeterName &&
|
||||
instrument.Name == "scadabridge.site.stream.batch_size")
|
||||
{
|
||||
l.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<int>((_, m, _, _) =>
|
||||
{
|
||||
lock (measurements) { measurements.Add(m); }
|
||||
});
|
||||
listener.Start();
|
||||
|
||||
// Un-negotiated: no measurements at all.
|
||||
var (_, plainCapture, plainCts, plainTask, plainRelay) =
|
||||
await StartCapturingStreamAsync(batchingSupported: false, correlationId: "corr-hist-off");
|
||||
plainRelay.Tell(new Commons.Messages.Streaming.AttributeValueChanged(
|
||||
"Site1.Pump01", "Path", "Attr", 1, "Good", DateTimeOffset.UtcNow));
|
||||
await WaitForConditionAsync(() => CountEvents(plainCapture) >= 1);
|
||||
plainCts.Cancel();
|
||||
await plainTask;
|
||||
|
||||
lock (measurements) { Assert.Empty(measurements); }
|
||||
|
||||
// Negotiated: one measurement per emitted frame, each within the size cap.
|
||||
var (_, capture, cts, streamTask, relay) =
|
||||
await StartCapturingStreamAsync(batchingSupported: true, correlationId: "corr-hist-on");
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
relay.Tell(new Commons.Messages.Streaming.AttributeValueChanged(
|
||||
"Site1.Pump01", "Path", "Attr", i, "Good", DateTimeOffset.UtcNow));
|
||||
}
|
||||
await WaitForConditionAsync(() => CountEvents(capture) >= 20, 10_000);
|
||||
cts.Cancel();
|
||||
await streamTask;
|
||||
|
||||
lock (measurements)
|
||||
{
|
||||
Assert.NotEmpty(measurements);
|
||||
Assert.Equal(20, measurements.Sum());
|
||||
Assert.All(measurements, m => Assert.InRange(m, 1, SiteStreamGrpcServer.DefaultStreamBatchMaxEvents));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Total events carried across all captured frames (unpacking batch frames).</summary>
|
||||
private static int CountEvents(List<SiteStreamEvent> capture)
|
||||
{
|
||||
lock (capture) { return capture.Sum(CountFrameEvents); }
|
||||
}
|
||||
|
||||
private static int CountFrameEvents(SiteStreamEvent frame) =>
|
||||
frame.EventCase == SiteStreamEvent.EventOneofCase.Batch ? frame.Batch.Events.Count : 1;
|
||||
|
||||
private static IEnumerable<string> FlattenAttributeValues(SiteStreamEvent frame)
|
||||
{
|
||||
if (frame.EventCase == SiteStreamEvent.EventOneofCase.Batch)
|
||||
{
|
||||
foreach (var inner in frame.Batch.Events)
|
||||
yield return inner.AttributeChanged.Value;
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return frame.AttributeChanged.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a SubscribeInstance stream with the given batch negotiation, capturing every
|
||||
/// written frame and handing back the relay actor so the test can drive domain events.
|
||||
/// </summary>
|
||||
private async Task<(SiteStreamGrpcServer Server, List<SiteStreamEvent> Capture,
|
||||
CancellationTokenSource Cts, Task StreamTask, IActorRef Relay)>
|
||||
StartCapturingStreamAsync(bool batchingSupported, string correlationId = "corr-batch")
|
||||
{
|
||||
IActorRef? capturedActor = null;
|
||||
var subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
capturedActor = ci.Arg<IActorRef>();
|
||||
return "sub-batch";
|
||||
});
|
||||
|
||||
var server = new SiteStreamGrpcServer(subscriber, _logger,
|
||||
Microsoft.Extensions.Options.Options.Create(new CommunicationOptions()));
|
||||
server.SetReady(Sys);
|
||||
|
||||
var capture = new List<SiteStreamEvent>();
|
||||
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
writer.WriteAsync(Arg.Any<SiteStreamEvent>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask)
|
||||
.AndDoes(ci =>
|
||||
{
|
||||
var frame = ci.Arg<SiteStreamEvent>();
|
||||
lock (capture) { capture.Add(frame); }
|
||||
});
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var context = CreateMockContext(cts.Token);
|
||||
|
||||
var request = new InstanceStreamRequest
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
InstanceUniqueName = "Site1.Pump01",
|
||||
BatchingSupported = batchingSupported
|
||||
};
|
||||
|
||||
var streamTask = Task.Run(() => server.SubscribeInstance(request, writer, context));
|
||||
await WaitForConditionAsync(() => capturedActor != null);
|
||||
|
||||
return (server, capture, cts, streamTask, capturedActor!);
|
||||
}
|
||||
}
|
||||
|
||||
+436
@@ -0,0 +1,436 @@
|
||||
using System.Diagnostics;
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Google.Protobuf;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using Xunit.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for R2 — gRPC event batching on the site→central
|
||||
/// <c>SiteStreamService</c> stream.
|
||||
///
|
||||
/// <para>
|
||||
/// The chain assembled here is the real one, mocking only the HTTP/2 transport:
|
||||
/// domain event → real <see cref="SiteStreamManager"/> broadcast → real
|
||||
/// <see cref="SiteStreamGrpcServer"/> handler → real <c>StreamRelayActor</c> → real
|
||||
/// coalescing pump → <b>proto serialize/parse round-trip</b> (what the wire actually
|
||||
/// carries) → real <see cref="SiteStreamGrpcClient.ForEachEvent"/> unpack → real
|
||||
/// <c>ConvertToDomainEvent</c>. The serialize/parse step is what makes these
|
||||
/// version-skew claims real rather than in-memory object graph assertions.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Version skew is covered in both directions.</b> Negotiation is a single additive
|
||||
/// request field (<c>batching_supported</c>), whose proto3 default of false IS the
|
||||
/// compatibility mechanism: an old central cannot set it, so a new site never sends it a
|
||||
/// frame case its generated code would drop; a new central always sets it, and an old
|
||||
/// site ignores the unknown field and keeps sending per-event frames the new client
|
||||
/// accepts unchanged.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class GrpcStreamBatchingIntegrationTests(ITestOutputHelper output) : TestKit
|
||||
{
|
||||
private const string Instance = "SiteA.Pump01";
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end latency threshold the target-scale load test asserts a P99 against
|
||||
/// (measured P99 there: 4.57 ms). The coalescing window is the only latency batching
|
||||
/// can add, so the batched pipe must stay comfortably inside the same budget.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan LatencyThreshold = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
// ── Round trip: batched frames deliver every event, in order ────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task NegotiatedStream_RoundTripsEveryEventThroughTheWire_InOrder()
|
||||
{
|
||||
var (server, manager, frames, cts, streamTask) = await StartAsync(batchingSupported: true);
|
||||
|
||||
const int total = 600;
|
||||
var t0 = new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
for (var i = 0; i < total; i++)
|
||||
{
|
||||
manager.PublishAttributeValueChanged(new AttributeValueChanged(
|
||||
Instance, "Modules.IO", "Seq", i, "Good", t0.AddMilliseconds(i)));
|
||||
}
|
||||
|
||||
await WaitForConditionAsync(() => TotalEvents(frames) >= total, 30_000);
|
||||
cts.Cancel();
|
||||
await streamTask;
|
||||
|
||||
var wire = SnapshotThroughTheWire(frames);
|
||||
|
||||
// Batching actually happened — otherwise this test proves nothing about batching.
|
||||
Assert.Contains(wire, f => f.EventCase == SiteStreamEvent.EventOneofCase.Batch);
|
||||
Assert.True(wire.Count < total,
|
||||
$"{wire.Count} frames for {total} events — no coalescing happened");
|
||||
|
||||
var delivered = Unpack(wire);
|
||||
Assert.Equal(total, delivered.Count);
|
||||
|
||||
// Every event, exactly once, in the order the site produced it — and with its OWN
|
||||
// timestamp, not a frame-level one (end-to-end latency measurement rides it).
|
||||
Assert.Equal(
|
||||
Enumerable.Range(0, total).Select(i => i.ToString()),
|
||||
delivered.Select(e => e.Value));
|
||||
Assert.Equal(
|
||||
Enumerable.Range(0, total).Select(i => t0.AddMilliseconds(i)),
|
||||
delivered.Select(e => e.Timestamp));
|
||||
|
||||
output.WriteLine($"round-trip: {total} events in {wire.Count} frames " +
|
||||
$"(mean {(double)total / wire.Count:0.0} events/frame)");
|
||||
|
||||
GC.KeepAlive(server);
|
||||
}
|
||||
|
||||
// ── Latency cost of the default window ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultWindow_KeepsPerEventLatencyFarBelowTheThreshold()
|
||||
{
|
||||
// A trickle-with-backlog workload is the case the coalescing window actually
|
||||
// bites on: each burst is far short of the 100-event size cap, so the batch is
|
||||
// closed by the 25 ms window rather than by the cap. That makes this the WORST
|
||||
// case for added latency, not the best.
|
||||
var options = new CommunicationOptions();
|
||||
var (server, manager, frames, cts, streamTask) = await StartAsync(
|
||||
batchingSupported: true, options: options);
|
||||
|
||||
const int bursts = 150;
|
||||
const int perBurst = 8;
|
||||
var stamps = new Dictionary<int, DateTimeOffset>();
|
||||
|
||||
var seq = 0;
|
||||
for (var b = 0; b < bursts; b++)
|
||||
{
|
||||
for (var i = 0; i < perBurst; i++)
|
||||
{
|
||||
var ts = DateTimeOffset.UtcNow;
|
||||
stamps[seq] = ts;
|
||||
manager.PublishAttributeValueChanged(new AttributeValueChanged(
|
||||
Instance, "Modules.IO", "Seq", seq, "Good", ts));
|
||||
seq++;
|
||||
}
|
||||
await Task.Delay(5);
|
||||
}
|
||||
|
||||
var total = seq;
|
||||
await WaitForConditionAsync(() => TotalEvents(frames) >= total, 60_000);
|
||||
cts.Cancel();
|
||||
await streamTask;
|
||||
|
||||
// Latency = the event's own site-side timestamp → the instant the frame carrying
|
||||
// it was handed to the response stream. That interval contains the coalescing
|
||||
// window and nothing else the pre-batching pipe did not already have.
|
||||
var latencies = new List<double>();
|
||||
lock (frames)
|
||||
{
|
||||
foreach (var (frame, writtenAt) in frames)
|
||||
{
|
||||
foreach (var evt in Flatten(frame))
|
||||
{
|
||||
var s = int.Parse(evt.AttributeChanged.Value);
|
||||
latencies.Add((writtenAt - stamps[s]).TotalMilliseconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
latencies.Sort();
|
||||
var p50 = latencies[(int)(latencies.Count * 0.50)];
|
||||
var p99 = latencies[(int)(latencies.Count * 0.99)];
|
||||
var max = latencies[^1];
|
||||
|
||||
output.WriteLine(
|
||||
$"window={options.GrpcStreamBatchWindow.TotalMilliseconds:0} ms cap={options.GrpcStreamBatchMaxEvents} " +
|
||||
$"events={latencies.Count} P50={p50:0.00} ms P99={p99:0.00} ms max={max:0.00} ms");
|
||||
|
||||
Assert.Equal(total, latencies.Count);
|
||||
Assert.True(p99 < LatencyThreshold.TotalMilliseconds,
|
||||
$"P99 {p99:0.00} ms exceeded the {LatencyThreshold.TotalMilliseconds:0} ms end-to-end threshold");
|
||||
|
||||
GC.KeepAlive(server);
|
||||
}
|
||||
|
||||
// ── Version skew: OLD central ↔ NEW site ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task OldCentral_AgainstNewSite_NeverReceivesABatchFrame()
|
||||
{
|
||||
// An old central's InstanceStreamRequest bytes simply have no field 3 — build
|
||||
// exactly those bytes and let the NEW site parse them, so the negotiation default
|
||||
// is exercised off the wire rather than asserted on an object.
|
||||
var oldCentralBytes = BuildLegacyInstanceRequest("corr-old-central", Instance);
|
||||
var request = InstanceStreamRequest.Parser.ParseFrom(oldCentralBytes);
|
||||
Assert.False(request.BatchingSupported);
|
||||
|
||||
var (server, manager, frames, cts, streamTask) = await StartAsync(request);
|
||||
|
||||
const int total = 300;
|
||||
for (var i = 0; i < total; i++)
|
||||
{
|
||||
manager.PublishAttributeValueChanged(new AttributeValueChanged(
|
||||
Instance, "Modules.IO", "Seq", i, "Good", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
await WaitForConditionAsync(() => TotalEvents(frames) >= total, 30_000);
|
||||
cts.Cancel();
|
||||
await streamTask;
|
||||
|
||||
var wire = SnapshotThroughTheWire(frames);
|
||||
|
||||
// One event per frame, and — checked at the byte level, since that is what the
|
||||
// old peer's parser sees — never the field-4 batch tag.
|
||||
Assert.Equal(total, wire.Count);
|
||||
Assert.All(wire, f =>
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, f.EventCase));
|
||||
Assert.All(wire, f => Assert.DoesNotContain(4, FieldNumbers(f)));
|
||||
|
||||
GC.KeepAlive(server);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchFrameRidesFieldFour_WhichAPreBatchingParserWouldDropSilently()
|
||||
{
|
||||
// WHY negotiation is mandatory rather than "just send batches". A batch frame is a
|
||||
// length-delimited field 4: an older generated parser skips it into unknown fields
|
||||
// and reports EventOneofCase.None, whose ConvertToDomainEvent returns null — the
|
||||
// whole batch would vanish with no error anywhere. The proto3 default on
|
||||
// batching_supported is what guarantees such a peer never receives one.
|
||||
var batch = new SiteStreamEvent
|
||||
{
|
||||
CorrelationId = "corr-shape",
|
||||
Batch = new SiteStreamEventBatch
|
||||
{
|
||||
Events = { MakeAttributeEvent(1), MakeAttributeEvent(2) }
|
||||
}
|
||||
};
|
||||
|
||||
var fields = FieldNumbers(batch);
|
||||
Assert.Contains(4, fields);
|
||||
Assert.DoesNotContain(2, fields);
|
||||
Assert.DoesNotContain(3, fields);
|
||||
|
||||
// Field 4 is length-delimited (wire type 2) — the shape an unknown-field-tolerant
|
||||
// parser can skip without corrupting the rest of the message.
|
||||
Assert.Equal(2u, WireTypeOfField(batch, 4));
|
||||
|
||||
// And the per-event frames a pre-batching site emits still parse and convert on the
|
||||
// NEW client (the other skew direction, at the same byte level).
|
||||
var plain = SiteStreamEvent.Parser.ParseFrom(MakeAttributeEvent(7).ToByteArray());
|
||||
Assert.NotNull(SiteStreamGrpcClient.ConvertToDomainEvent(plain));
|
||||
}
|
||||
|
||||
// ── Version skew: NEW central ↔ OLD site ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task NewCentral_AgainstOldSite_StillReceivesEveryEvent()
|
||||
{
|
||||
// An old site ignores batching_supported and emits per-event frames. That emission
|
||||
// shape is exactly what the current server produces with batching off, so drive
|
||||
// the real server that way and feed the result through the NEW client's unpack —
|
||||
// which must handle the single-event case identically to before R2.
|
||||
var (server, manager, frames, cts, streamTask) = await StartAsync(batchingSupported: false);
|
||||
|
||||
const int total = 200;
|
||||
var t0 = new DateTimeOffset(2026, 8, 15, 13, 0, 0, TimeSpan.Zero);
|
||||
for (var i = 0; i < total; i++)
|
||||
{
|
||||
manager.PublishAttributeValueChanged(new AttributeValueChanged(
|
||||
Instance, "Modules.IO", "Seq", i, "Good", t0.AddMilliseconds(i)));
|
||||
}
|
||||
|
||||
await WaitForConditionAsync(() => TotalEvents(frames) >= total, 30_000);
|
||||
cts.Cancel();
|
||||
await streamTask;
|
||||
|
||||
var wire = SnapshotThroughTheWire(frames);
|
||||
Assert.Equal(total, wire.Count);
|
||||
|
||||
var delivered = Unpack(wire);
|
||||
Assert.Equal(
|
||||
Enumerable.Range(0, total).Select(i => i.ToString()),
|
||||
delivered.Select(e => e.Value));
|
||||
Assert.Equal(
|
||||
Enumerable.Range(0, total).Select(i => t0.AddMilliseconds(i)),
|
||||
delivered.Select(e => e.Timestamp));
|
||||
|
||||
GC.KeepAlive(server);
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static SiteStreamEvent MakeAttributeEvent(int seq) => new()
|
||||
{
|
||||
CorrelationId = "corr-shape",
|
||||
AttributeChanged = new AttributeValueUpdate
|
||||
{
|
||||
InstanceUniqueName = Instance,
|
||||
AttributePath = "Modules.IO",
|
||||
AttributeName = "Seq",
|
||||
Value = seq.ToString(),
|
||||
Quality = Quality.Good,
|
||||
Timestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(DateTimeOffset.UnixEpoch)
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Serializes an <c>InstanceStreamRequest</c> the way a central built BEFORE R2 would:
|
||||
/// fields 1 and 2 only, with no <c>batching_supported</c> on the wire at all.
|
||||
/// </summary>
|
||||
private static byte[] BuildLegacyInstanceRequest(string correlationId, string instance)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
var output = new CodedOutputStream(ms);
|
||||
output.WriteTag(1, WireFormat.WireType.LengthDelimited);
|
||||
output.WriteString(correlationId);
|
||||
output.WriteTag(2, WireFormat.WireType.LengthDelimited);
|
||||
output.WriteString(instance);
|
||||
output.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Top-level field numbers present in a serialized message.</summary>
|
||||
private static HashSet<int> FieldNumbers(IMessage message)
|
||||
{
|
||||
var fields = new HashSet<int>();
|
||||
var input = new CodedInputStream(message.ToByteArray());
|
||||
uint tag;
|
||||
while ((tag = input.ReadTag()) != 0)
|
||||
{
|
||||
fields.Add(WireFormat.GetTagFieldNumber(tag));
|
||||
input.SkipLastField();
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/// <summary>Wire type of the given top-level field number in a serialized message.</summary>
|
||||
private static uint WireTypeOfField(IMessage message, int fieldNumber)
|
||||
{
|
||||
var input = new CodedInputStream(message.ToByteArray());
|
||||
uint tag;
|
||||
while ((tag = input.ReadTag()) != 0)
|
||||
{
|
||||
if (WireFormat.GetTagFieldNumber(tag) == fieldNumber)
|
||||
return (uint)WireFormat.GetTagWireType(tag);
|
||||
input.SkipLastField();
|
||||
}
|
||||
throw new InvalidOperationException($"field {fieldNumber} not present");
|
||||
}
|
||||
|
||||
private static IEnumerable<SiteStreamEvent> Flatten(SiteStreamEvent frame)
|
||||
{
|
||||
if (frame.EventCase == SiteStreamEvent.EventOneofCase.Batch)
|
||||
{
|
||||
foreach (var inner in frame.Batch.Events) yield return inner;
|
||||
yield break;
|
||||
}
|
||||
yield return frame;
|
||||
}
|
||||
|
||||
private static int TotalEvents(List<(SiteStreamEvent Frame, DateTimeOffset WrittenAt)> frames)
|
||||
{
|
||||
lock (frames) { return frames.Sum(f => Flatten(f.Frame).Count()); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the captured frames through a real protobuf serialize/parse round-trip — the
|
||||
/// step that makes every claim in this file about wire compatibility a wire claim.
|
||||
/// </summary>
|
||||
private static List<SiteStreamEvent> SnapshotThroughTheWire(
|
||||
List<(SiteStreamEvent Frame, DateTimeOffset WrittenAt)> frames)
|
||||
{
|
||||
lock (frames)
|
||||
{
|
||||
return [.. frames.Select(f => SiteStreamEvent.Parser.ParseFrom(f.Frame.ToByteArray()))];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Unpacks wire frames through the REAL client path into domain events.</summary>
|
||||
private static List<AttributeValueChanged> Unpack(IEnumerable<SiteStreamEvent> wire)
|
||||
{
|
||||
var delivered = new List<AttributeValueChanged>();
|
||||
foreach (var frame in wire)
|
||||
{
|
||||
SiteStreamGrpcClient.ForEachEvent(frame, e =>
|
||||
{
|
||||
if (SiteStreamGrpcClient.ConvertToDomainEvent(e) is AttributeValueChanged a)
|
||||
delivered.Add(a);
|
||||
});
|
||||
}
|
||||
return delivered;
|
||||
}
|
||||
|
||||
private Task<(SiteStreamGrpcServer Server, SiteStreamManager Manager,
|
||||
List<(SiteStreamEvent Frame, DateTimeOffset WrittenAt)> Frames,
|
||||
CancellationTokenSource Cts, Task StreamTask)>
|
||||
StartAsync(bool batchingSupported, CommunicationOptions? options = null)
|
||||
=> StartAsync(new InstanceStreamRequest
|
||||
{
|
||||
CorrelationId = "corr-batching",
|
||||
InstanceUniqueName = Instance,
|
||||
BatchingSupported = batchingSupported
|
||||
}, options);
|
||||
|
||||
/// <summary>
|
||||
/// Brings up a real site broadcast hub + real gRPC server handler for the supplied
|
||||
/// subscription request, capturing every written frame with the instant it was written.
|
||||
/// </summary>
|
||||
private async Task<(SiteStreamGrpcServer Server, SiteStreamManager Manager,
|
||||
List<(SiteStreamEvent Frame, DateTimeOffset WrittenAt)> Frames,
|
||||
CancellationTokenSource Cts, Task StreamTask)>
|
||||
StartAsync(InstanceStreamRequest request, CommunicationOptions? options = null)
|
||||
{
|
||||
var manager = new SiteStreamManager(
|
||||
new SiteRuntimeOptions { StreamBufferSize = 4096 },
|
||||
NullLogger<SiteStreamManager>.Instance);
|
||||
manager.Initialize(Sys);
|
||||
|
||||
var server = new SiteStreamGrpcServer(
|
||||
manager,
|
||||
NullLogger<SiteStreamGrpcServer>.Instance,
|
||||
Options.Create(options ?? new CommunicationOptions()));
|
||||
server.SetReady(Sys);
|
||||
|
||||
var frames = new List<(SiteStreamEvent, DateTimeOffset)>();
|
||||
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
writer.WriteAsync(Arg.Any<SiteStreamEvent>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask)
|
||||
.AndDoes(ci =>
|
||||
{
|
||||
var frame = ci.Arg<SiteStreamEvent>();
|
||||
var at = DateTimeOffset.UtcNow;
|
||||
lock (frames) { frames.Add((frame, at)); }
|
||||
});
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var context = Substitute.For<ServerCallContext>();
|
||||
context.CancellationToken.Returns(cts.Token);
|
||||
|
||||
var streamTask = Task.Run(() => server.SubscribeInstance(request, writer, context));
|
||||
|
||||
// The publish must not race the materialized subscription.
|
||||
await WaitForConditionAsync(() => manager.SubscriptionCount == 1);
|
||||
|
||||
return (server, manager, frames, cts, streamTask);
|
||||
}
|
||||
|
||||
private static async Task WaitForConditionAsync(Func<bool> condition, int timeoutMs = 5000)
|
||||
{
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
while (!condition() && Stopwatch.GetElapsedTime(started) < TimeSpan.FromMilliseconds(timeoutMs))
|
||||
{
|
||||
await Task.Delay(10);
|
||||
}
|
||||
Assert.True(condition(), $"Condition not met within {timeoutMs}ms");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user