Files
ScadaBridge/docs/plans/2026-07-10-aggregated-live-alarm-stream-plan.md
T
Joseph Doherty d1ade5653b docs(plans): bookkeeping sync — reconcile stale trackers with merged code
A verified audit of all ~90 plan documents (2026-08-01) found ~30 .tasks.json
trackers and several plan headers still reporting 'pending'/'draft' for work
fully merged to main. Sync them so future audits don't re-litigate closed work:

- Flip ~380 stale task statuses to completed across March/May/June/July
  trackers (audit-log series, milestones M5-M10, playwright waves, stillpending,
  LocalDb, ClusterClient->gRPC DoD rows, and more), each verified against
  code/git evidence before flipping.
- Annotate obsolete-not-done rows: ClusterClient CLI transport (never built,
  HTTP shipped), TreeView Areas/Instances pages (replaced by Topology),
  template-tree drag-drop (dropped for M9 menu reorder), otopcua item C
  (premise superseded by #17).
- Flip stale headers: aggregated-live-alarm + kpi-rollups 'Draft not executed'
  -> Delivered 2026-07-10; otopcua cutover-scope SCOPING -> DECIDED;
  scadabridge-rename -> Implemented; LocalDb phase1/2 status strings ->
  merged 28ca04d7.
- Fix doc drift: T9/T10 'deferred' -> shipped as SMS (Teams dropped); waitfor
  sandbox follow-up shipped; followups #52/#53/#54/#162/#207 resolved; purge
  TODO closed by PendingDeploymentPurgeActor; live-gate pre-existing failures
  #28/#29/#31 fixed; auto-down boot-alone residual superseded by self-first
  seeds; supersession banners on keep-oldest SBR + ClusterClient-era designs;
  requirements-traceability 'Pending' clarified as frozen plan-generation
  status.

Deliberately left pending (genuinely open, tracked in the pending-work list):
opcua-tag-browser task 19 (live smoke), ipsen tasks 7-8 (vd03 verification),
selfform task 7 (vd03 overlay, user-held), live-gate observation 1
(external-system delete orphan bug), otopcua item A + maxDepth calibration.
2026-08-01 08:53:56 -04:00

187 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan: Aggregated Live Alarm Stream for Alarm Summary (deferred #10)
**Status:** **Delivered 2026-07-10** — shipped as designed (`SiteAlarmAggregatorActor`,
`ISiteAlarmLiveCache`, the `SubscribeSite` streaming RPC, and
`AlarmSummaryService.BuildFromLiveAlarms` are all in `src/`). The 15 s poll is retained as the
NotReporting authority behind the live stream, per the design below.
**Register row:** `docs/plans/2026-07-08-deferred-work-register.md` #10
**Revisit trigger that fired this plan:** *"Alarm Summary latency complaints or >~50 instances/site."*
**Owning component:** Central UI (#9) + CentralSite Communication (#5); no new component.
---
## 1. Problem
The operator Alarm Summary page (`/monitoring/alarms`, `RequireDeployment`, read-only)
currently rebuilds its whole view every 15 s by fanning out a per-instance
`DebugViewSnapshot` Ask to **every Enabled instance on the selected site** and
aggregating client-side (`AlarmSummaryService`, `MaxConcurrentFetches = 8`). At
current instance counts this is acceptable; past **~50 instances/site** the poll
does 50 cross-cluster Asks every 15 s (worst case 50 × ClusterClient round-trips
gated 8-at-a-time), so freshness degrades and the active central node carries a
periodic burst. We want an **aggregated live feed** so the page reflects alarm
transitions in near-real-time without re-polling the whole site.
## 2. Hard constraint (locked decision — do not violate)
`docs/plans/2026-05-29-native-alarms-design.md:24` marks **"no central alarm
tables, no central history"** as `[PERM]`. Therefore this plan introduces **no
persisted central alarm store** — only a **transient in-memory per-site live
cache** on the active central node, seeded from the existing snapshot fan-out and
kept warm by a live gRPC delta stream. If a persisted store is ever wanted, that
is a separate re-decision, not part of this plan.
## 3. Chosen approach
**A shared, reference-counted, per-site central live alarm cache**, seeded by the
existing snapshot fan-out and updated by a new **site-wide, alarm-only** gRPC
stream, pushed to the Blazor page via an in-process singleton event (the
`IDeploymentStatusNotifier` pattern — no new SignalR hub; the Blazor circuit
already pushes).
### Why site-wide RPC (Option B) over N per-instance streams (Option A)
The revisit trigger is *>~50 instances/site* — exactly where opening one gRPC
stream per instance (Option A) strains `GrpcMaxConcurrentStreams = 100` and
duplicates the fan-out problem in streaming form. A single **`SubscribeSite`**
stream per site (alarm events only; attributes are far higher-volume and the
summary never shows them) is the scalable answer and is an *additive* proto
change. It reuses the existing `SiteStreamManager` broadcast hub by adding a
subscribe variant that drops the per-instance `InstanceUniqueName` filter
(`SiteStreamManager.cs:101`) and keeps only `AlarmStateChanged` events.
### Lifecycle & failover
- **Shared per-site cache**, reference-counted by active viewers: first viewer of
a site starts the subscription + snapshot seed; last viewer leaving stops it
(with a short linger to avoid thrash). One gRPC stream per *site*, not per
browser circuit.
- **Seed-then-stream** ordering copied from `DebugStreamBridgeActor`: open the
stream first, buffer live deltas, run the snapshot fan-out once, flush buffer
with dedup, then live pass-through. Dedup identity = `(InstanceUniqueName,
AlarmName, SourceReference)` (matches `DebugStreamBridgeActor.AlarmKey`).
- **NodeA↔NodeB reconnect** with retry budget + stability window, reusing the
reconnect logic already proven in `DebugStreamBridgeActor`.
- **Placeholder reconciliation:** live stream drops `IsConfiguredPlaceholder`
rows (`StreamRelayActor.cs:63`); snapshots include them. Cache seeds
placeholders from the snapshot and never expects them on the live stream.
- **Instance-set drift:** on deploy/enable/disable/delete the site-wide stream
naturally starts/stops carrying that instance's events; a periodic (e.g. 60 s)
reconcile snapshot corrects any missed enable/disable so the cache can't drift
indefinitely. This replaces the old "re-query everything every 15 s" with
"stream + occasional reconcile."
## 4. Task breakdown
Classification legend: `trivial` / `small` (code review) / `standard` (spec+code
review) / `high-risk` (serial reviews + integration). Times are single-task
estimates.
### Task 1 — Site-wide alarm-only stream on the site runtime `[standard]`
- **Files:** `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Streaming/SiteStreamManager.cs`
- Add `SubscribeSiteAlarms(ISiteStreamSubscriber subscriber)` — same broadcast-hub
wiring as `Subscribe(...)` but **without** the `InstanceUniqueName` filter and
**filtering to `AlarmStateChanged` only** (skip `AttributeValueChanged`).
- Return an `IDisposable`/kill-switch symmetric with the existing per-instance
subscribe. Unit-test that alarm events for *all* instances arrive and attribute
events do not.
### Task 2 — `SubscribeSite` gRPC contract + server handler `[standard]`
- **Files:** `src/ZB.MOM.WW.ScadaBridge.Communication/Protos/sitestream.proto`,
`src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcServer.cs`,
`src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs`
- Add **additive** `rpc SubscribeSite(SiteStreamRequest) returns (stream
SiteStreamEvent)` + `SiteStreamRequest { correlation_id }` (no instance name).
Keep `bundleFormatVersion`/message numbering additive — do not renumber
existing fields.
- Server handler mirrors `SubscribeInstance` (bounded `Channel(1000,
DropOldest)`, `GrpcMaxConcurrentStreams`/`GrpcMaxStreamLifetime` limits, the
`StreamRelayActor` mapping, `SiteConnectionOpened/Closed` telemetry) but calls
`SubscribeSiteAlarms`. `StreamRelayActor` already drops placeholder rows and
emits enriched `AlarmStateUpdate` — reuse unchanged.
### Task 3 — Central per-site subscription client `[standard]`
- **Files:** `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs`,
`.../Grpc/SiteStreamGrpcClientFactory.cs`
- Add `SubscribeSiteAsync(correlationId, onAlarmEvent, onError, ct)` alongside
`SubscribeAsync`, reusing `ConvertToDomainEvent` (maps proto → domain
`AlarmStateChanged`). Factory already caches channels per `(site, endpoint)` for
failover — no change needed beyond the new call.
### Task 4 — Central live alarm cache actor/service `[high-risk]`
- **Files (new):** `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteAlarmAggregatorActor.cs`,
`src/ZB.MOM.WW.ScadaBridge.Communication/SiteAlarmLiveCacheService.cs`,
`src/ZB.MOM.WW.ScadaBridge.Communication/ISiteAlarmLiveCache.cs`
- **Files (edit):** `src/ZB.MOM.WW.ScadaBridge.Communication/ServiceCollectionExtensions.cs`
- Per-site aggregator: seed-then-stream (buffer → snapshot fan-out via existing
`CommunicationService.RequestDebugSnapshotAsync` → flush+dedup → live), holds a
`Dictionary<AlarmKey, AlarmStateChanged>` snapshot of current alarm state.
Reference-counted start/stop keyed by site. NodeA↔NodeB reconnect + periodic
reconcile snapshot (60 s). Reuse `DebugStreamBridgeActor` reconnect logic
(extract shared helper or copy with attribution).
- Expose `ISiteAlarmLiveCache` (singleton) with `Subscribe(siteId, onChange)` →
`IDisposable`, raising an `IDeploymentStatusNotifier`-style event on each
applied delta. This is the **highest-risk** task (concurrency, failover,
seed/stream ordering) — spawn a code-reviewer subagent and add integration
coverage.
### Task 5 — Wire Alarm Summary page to the live cache `[standard]`
- **Files:** `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/AlarmSummaryService.cs`,
`.../Services/IAlarmSummaryService.cs`,
`.../Components/Pages/Monitoring/AlarmSummary.razor`,
`.../ServiceCollectionExtensions.cs`
- Add `SubscribeSiteAlarmsAsync(siteId, onChange)` to the service, delegating to
`ISiteAlarmLiveCache`. Page: on site select, subscribe; `onChange` updates
`_rows` + recomputes `_rollup`/`_visibleRows` and `StateHasChanged()` (over the
Blazor circuit). **Keep the 15 s poll as a fallback** but gate it behind a
feature flag / degrade path when the live cache is healthy, so a stream failure
never blanks the page. Unsubscribe in `Dispose()`. `AlarmStateBadges` and all
client-side filters/sort are unchanged (they already consume `AlarmStateChanged`).
### Task 6 — Options, limits, and telemetry `[small]`
- **Files:** communication options class (grep `CommunicationOptions` /
`GrpcMaxConcurrentStreams`), `SiteAlarmLiveCacheService`
- Add options: live-cache linger, reconcile interval, per-site subscriber cap.
Validate (eager options validation is the house rule). Surface a gauge for
active per-site aggregators + reconnect count on the health snapshot, mirroring
`SiteConnectionUp` gauges.
### Task 7 — Tests + integration trace `[standard]`
- **Files:** `tests/.../Communication.Tests/*`, `tests/.../CentralUI.Tests/*`,
`tests/.../SiteRuntime.Tests/Streaming/SiteStreamManagerTests.cs`
- Unit: `SubscribeSiteAlarms` filtering; `SubscribeSite` server/client mapping;
aggregator seed/stream/dedup/placeholder/reconnect; page subscribe/unsubscribe.
- Integration: end-to-end site→central alarm delta reaches the cache; failover
flip re-seeds; last-viewer stop tears the stream down. Live-smoke on the docker
cluster (`bash docker/deploy.sh`) with >1 instance raising alarms.
### Task 8 — Docs `[trivial]`
- **Files:** `docs/requirements/Component-CentralUI.md` (Alarm Summary section —
replace "no aggregated live stream" with the shipped design),
`docs/requirements/Component-Communication.md` (new `SubscribeSite` RPC),
`docs/plans/2026-06-18-m7-opcua-mxgateway-ux-design.md` (mark T13 follow-up
delivered), and move register #10 to **Resolved**.
## 5. Risks / call-outs
- **`[PERM]` no-central-store** — the cache is explicitly transient/in-memory.
Reviewer must confirm no EF entity/table/migration is introduced.
- **Proto evolution** — `SubscribeSite` must be purely additive (new RPC + new
message; no field renumbering). PLAN-08's ClusterClient/gRPC contract-lock
tests apply.
- **Stream-vs-snapshot race** — the seed-then-stream dedup is the classic bug
surface; reuse the `DebugStreamBridgeActor` ordering exactly.
- **Failover gap** — a NodeA↔NodeB flip must re-seed (not silently serve stale);
the 60 s reconcile snapshot is the backstop.
- **Effort estimate:** ~69 focused tasks; Task 4 is the only genuinely hard one.
Everything else reuses existing, proven machinery
(`SiteStreamManager`/`SiteStreamGrpcClientFactory`/`DebugStreamBridgeActor`/
`IDeploymentStatusNotifier`/`AlarmSummaryService`).
## 6. Explicitly out of scope
- Persisted central alarm store or history (locked `[PERM]`).
- Aggregated attribute stream (summary needs alarms only).
- CLI/SignalR surface for the aggregated feed (Blazor-only; revisit if a CLI
consumer appears).
- Ack-back / write path (native alarms remain read-only mirrors).