fix(GWC-26): attach the alarm monitor's lease before SubscribeAlarms
RunMonitorAsync issued SubscribeAlarms and the first reconcile before the internal distributor subscriber was attached (via ISessionManager .ReadAlarmEventsAsync). The pump has been running since MarkReady started the dashboard mirror and only fans to subscribers registered at fan-out time, so every transition raised in that two-round-trip window bypassed the alarm feed — and a missed Acknowledge was never repaired, because ApplyReconcile broadcast presence deltas only. - The monitor now takes the internal lease directly from its session BEFORE SubscribeAlarms and drains it after the first reconcile; window transitions buffer in the lease's bounded channel. Processing them after ApplyReconcile is order-safe (ApplyTransition handles alarms the snapshot already placed). - ISessionManager.ReadAlarmEventsAsync removed — zero remaining callers. - ApplyReconcile broadcasts an Acknowledge feed transition when a both-present alarm's state advanced to ActiveAcked. This is a feed-level repair on the AlarmFeedMessage/StreamAlarms surface rebuilt from the worker's own snapshot, not MxEvent emission, so the "never synthesize events" rule is untouched; the reasoning is recorded on ApplyReconcile. The alarm-monitor test fakes now hand the monitor a real Ready GatewaySession with a dashboard mirror, which is what makes the window reproducible. Docs: docs/Sessions.md and gateway.md alarm-monitor ordering notes. Refs: archreview/2026-07-12/remediation/10-gateway-core.md GWC-26
This commit is contained in:
@@ -61,8 +61,8 @@ Full design + implementation for each row lives in the linked domain doc under i
|
|||||||
|---|---|:-:|:-:|---|---|---|
|
|---|---|:-:|:-:|---|---|---|
|
||||||
| GWC-24 | Medium | P1 | M | GWC-21 (coord, old tracker) | Not started | Unbounded event staging channel: sustained slow drain grows memory silently and invisibly |
|
| GWC-24 | Medium | P1 | M | GWC-21 (coord, old tracker) | Not started | Unbounded event staging channel: sustained slow drain grows memory silently and invisibly |
|
||||||
| GWC-25 | Medium | P0 | S | CLI-35/36 (coord) | Not started | Empty-ring ReplayGap sentinel carries `oldest_available_sequence = 0` |
|
| GWC-25 | Medium | P0 | S | CLI-35/36 (coord) | Not started | Empty-ring ReplayGap sentinel carries `oldest_available_sequence = 0` |
|
||||||
| GWC-26 | Low | P2 | M | GWC-27 | Not started | Alarm monitor attaches its subscriber after SubscribeAlarms; window transitions bypass the feed |
|
| GWC-26 | Low | P2 | M | GWC-27 | Done | Alarm monitor attaches its subscriber after SubscribeAlarms; window transitions bypass the feed |
|
||||||
| GWC-27 | Low | P2 | S | GWC-26 | Not started | `AttachInternalEventSubscriber` bypasses the readiness gate; premature attach poisons the distributor |
|
| GWC-27 | Low | P2 | S | GWC-26 | Done | `AttachInternalEventSubscriber` bypasses the readiness gate; premature attach poisons the distributor |
|
||||||
| GWC-28 | Low | P2 | S | GWC-10 (coord, old tracker) | Not started | Gateway→worker envelope `sequence` stamped at creation, not at write |
|
| GWC-28 | Low | P2 | S | GWC-10 (coord, old tracker) | Not started | Gateway→worker envelope `sequence` stamped at creation, not at write |
|
||||||
| GWC-29 | Low | — | S | — | Not started | `Invoke` deep-clones the entire request only to discard the cloned command |
|
| GWC-29 | Low | — | S | — | Not started | `Invoke` deep-clones the entire request only to discard the cloned command |
|
||||||
| GWC-30 | Info | — | S | — | Not started | Frame reader allocates a fresh 4-byte length-prefix array per frame |
|
| GWC-30 | Info | — | S | — | Not started | Frame reader allocates a fresh 4-byte length-prefix array per frame |
|
||||||
@@ -161,3 +161,4 @@ Sequence these together rather than piecemeal — several are one change set spa
|
|||||||
| 2026-07-13 | Operator bring-up complete: dedicated CI ed25519 key installed in windev `administrators_authorized_keys` (authorized into `dohertj2`, which owns the working MXAccess/toolchain env — a fresh OS account would break the build; the key is independently revocable), Gitea secrets `WINDEV_SSH_KEY`/`WINDEV_SSH_KNOWN_HOSTS` + variable `WINDEV_SSH_USER=dohertj2` stored, runner→`10.100.0.48:22` egress verified on the `traefik` net, issue-write confirmed. **TST-25/TST-26 → `Done`:** credentialed `windows-x86` ran GREEN on `d769244` (Gitea run #37) — Linux runner SSHed windev, checked out the SHA in `C:\build\mxaccessgw-ci` under lock, ran the x86 Worker build + `Worker.Tests`, exit 0; `nightly-windev` correctly skipped on the push event. Branch merged to `main`. Follow-ups (old tracker): revisit **TST-05** (scheduled live smoke — now covered by `nightly-windev`) and **TST-24** (client wire tests) which this unlocks. |
|
| 2026-07-13 | Operator bring-up complete: dedicated CI ed25519 key installed in windev `administrators_authorized_keys` (authorized into `dohertj2`, which owns the working MXAccess/toolchain env — a fresh OS account would break the build; the key is independently revocable), Gitea secrets `WINDEV_SSH_KEY`/`WINDEV_SSH_KNOWN_HOSTS` + variable `WINDEV_SSH_USER=dohertj2` stored, runner→`10.100.0.48:22` egress verified on the `traefik` net, issue-write confirmed. **TST-25/TST-26 → `Done`:** credentialed `windows-x86` ran GREEN on `d769244` (Gitea run #37) — Linux runner SSHed windev, checked out the SHA in `C:\build\mxaccessgw-ci` under lock, ran the x86 Worker build + `Worker.Tests`, exit 0; `nightly-windev` correctly skipped on the push event. Branch merged to `main`. Follow-ups (old tracker): revisit **TST-05** (scheduled live smoke — now covered by `nightly-windev`) and **TST-24** (client wire tests) which this unlocks. |
|
||||||
| 2026-07-13 | Ran the TST-25 acceptance checks (scripts/ci/README.md) — they caught **two real CI defects, both fixed** on `fix/tst-25-ci-key-log-leak`: (1) **CI SSH key leaked in cleartext** in the `windows-x86` step env echo (Gitea's line-oriented masker missed the multiline PEM) — rotated the CI key on windev (old pubkey revoked), stored the key **base64-encoded** so the masker redacts it to `***` (confirmed on run #38), taught `run-windev-ci.sh` to decode, dropped the redundant public known-hosts secret from the job env; (2) **bootstrap lock race** — `run-windev-ci.sh`'s pre-hand-off `git fetch`/`checkout` ran outside the worktree lock, so concurrent runs collided on `.git/index.lock`; the bootstrap now holds the lock (ps1 re-uses it via `MXGW_CI_LOCK_HELD`), retest confirmed clean serialization. Also **deflaked** `SessionManagerTests` fail-fast timing assertions (absolute `<100ms` wall-clock bound flaked under CI load; now anchored to the configured timeout / dropped for the zero-timeout case). Checks passed: unreachable-host fast-fail (exit 255/15s), deliberate-red propagation (Worker.Tests failure → exit 1), lock concurrency (2nd run waits), no-key-in-logs (masked). Merge target `df7e20d` verified GREEN via the local windev path (Worker build + 356 tests); merged to `main` `19cbf7b`. Check 6 (forced-failure nightly issue): issue endpoint+token proven live at bring-up (#124); in-CI forced-failure probe abandoned to shared-runner congestion (residual `if: failure()` gating is standard Actions). |
|
| 2026-07-13 | Ran the TST-25 acceptance checks (scripts/ci/README.md) — they caught **two real CI defects, both fixed** on `fix/tst-25-ci-key-log-leak`: (1) **CI SSH key leaked in cleartext** in the `windows-x86` step env echo (Gitea's line-oriented masker missed the multiline PEM) — rotated the CI key on windev (old pubkey revoked), stored the key **base64-encoded** so the masker redacts it to `***` (confirmed on run #38), taught `run-windev-ci.sh` to decode, dropped the redundant public known-hosts secret from the job env; (2) **bootstrap lock race** — `run-windev-ci.sh`'s pre-hand-off `git fetch`/`checkout` ran outside the worktree lock, so concurrent runs collided on `.git/index.lock`; the bootstrap now holds the lock (ps1 re-uses it via `MXGW_CI_LOCK_HELD`), retest confirmed clean serialization. Also **deflaked** `SessionManagerTests` fail-fast timing assertions (absolute `<100ms` wall-clock bound flaked under CI load; now anchored to the configured timeout / dropped for the zero-timeout case). Checks passed: unreachable-host fast-fail (exit 255/15s), deliberate-red propagation (Worker.Tests failure → exit 1), lock concurrency (2nd run waits), no-key-in-logs (masked). Merge target `df7e20d` verified GREEN via the local windev path (Worker build + 356 tests); merged to `main` `19cbf7b`. Check 6 (forced-failure nightly issue): issue endpoint+token proven live at bring-up (#124); in-CI forced-failure probe abandoned to shared-runner congestion (residual `if: failure()` gating is standard Actions). |
|
||||||
| 2026-07-13 | New finding **TST-30** (`Low`/`P2`) added — surfaced during TST-25 acceptance: CI runs on a single shared `gitea-runner` (`maxParallel=1`, co-located `10.100.0.35`) interleaved with `dohertj2/lmxopcua`, and Gitea 1.26 exposes no run cancel/delete, so queue latency is unbounded under cross-repo contention and the runner is a single point of failure. Design: add a second/labelled runner + document the no-cancel reality and the `run-windev-ci.sh` bypass. Roll-ups updated (Testing Low 2→3, total 47→48; P2 9→10). |
|
| 2026-07-13 | New finding **TST-30** (`Low`/`P2`) added — surfaced during TST-25 acceptance: CI runs on a single shared `gitea-runner` (`maxParallel=1`, co-located `10.100.0.35`) interleaved with `dohertj2/lmxopcua`, and Gitea 1.26 exposes no run cancel/delete, so queue latency is unbounded under cross-repo contention and the runner is a single point of failure. Design: add a second/labelled runner + document the no-cancel reality and the `run-windev-ci.sh` bypass. Roll-ups updated (Testing Low 2→3, total 47→48; P2 9→10). |
|
||||||
|
| 2026-08-07 | **GWC-27 → `Done`, GWC-26 → `Done`** (branch `fix/gwc-26-27-alarm-attach`). GWC-27: `GatewaySession.AttachInternalEventSubscriber` now mirrors `AttachEventSubscriber`'s readiness gate under `_syncRoot`, before `EnsureDistributorCreated`, so a premature attach can no longer latch a poisoned distributor. GWC-26: the alarm monitor takes its internal lease directly from the session **before** `SubscribeAlarms` and drains it after the first reconcile; `ISessionManager.ReadAlarmEventsAsync` removed (zero remaining callers); `ApplyReconcile` now broadcasts an `Acknowledge` feed transition for a both-present alarm whose state advanced to `ActiveAcked` (feed-level repair on `AlarmFeedMessage`, not `MxEvent` synthesis). New tests `GatewaySessionTests.AttachInternalEventSubscriberBeforeReadyThrowsAndDoesNotPoisonDistributor` and `GatewayAlarmMonitorAttachOrderTests` (`TransitionsDuringSubscribeWindow_StillReachTheAlarmFeed`, `ApplyReconcileBroadcastsAcknowledgeDelta`); the alarm-monitor fakes now hand the monitor a real Ready `GatewaySession` with a dashboard mirror so the window is actually reproducible. Verification: NonWindows build 0 warnings/0 errors; `GatewayAlarmMonitor` 16 passed, `SessionManagerTests` 38 passed, `GatewaySessionTests` 19 passed, `AlarmFailoverEndToEndTests` 2 passed. |
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ This document turns the 2026-07-12 re-review's **new** Gateway Server Core findi
|
|||||||
|----|-----|------|-----|-----|--------|-------|
|
|----|-----|------|-----|-----|--------|-------|
|
||||||
| GWC-24 | Medium | P1 | M | GWC-21 (coord) | Not started | Unbounded event staging channel: sustained slow drain grows memory silently and invisibly |
|
| GWC-24 | Medium | P1 | M | GWC-21 (coord) | Not started | Unbounded event staging channel: sustained slow drain grows memory silently and invisibly |
|
||||||
| GWC-25 | Medium | P0 | S | CLI-35/36 (coord) | Not started | Empty-ring ReplayGap sentinel carries `oldest_available_sequence = 0`, dead-streaming a compliant client |
|
| GWC-25 | Medium | P0 | S | CLI-35/36 (coord) | Not started | Empty-ring ReplayGap sentinel carries `oldest_available_sequence = 0`, dead-streaming a compliant client |
|
||||||
| GWC-26 | Low | P2 | M | GWC-27 | Not started | Alarm monitor attaches its subscriber after SubscribeAlarms; window transitions bypass the feed, missed Acknowledge never repaired |
|
| GWC-26 | Low | P2 | M | GWC-27 | Done | Alarm monitor attaches its subscriber after SubscribeAlarms; window transitions bypass the feed, missed Acknowledge never repaired |
|
||||||
| GWC-27 | Low | P2 | S | GWC-26 | Not started | `AttachInternalEventSubscriber` bypasses the readiness gate; premature attach poisons the distributor permanently |
|
| GWC-27 | Low | P2 | S | GWC-26 | Done | `AttachInternalEventSubscriber` bypasses the readiness gate; premature attach poisons the distributor permanently |
|
||||||
| GWC-28 | Low | P2 | S | GWC-10 (coord) | Not started | Gateway→worker envelope `sequence` stamped at creation, not at write — non-monotonic on the wire under concurrent invokes |
|
| GWC-28 | Low | P2 | S | GWC-10 (coord) | Not started | Gateway→worker envelope `sequence` stamped at creation, not at write — non-monotonic on the wire under concurrent invokes |
|
||||||
| GWC-29 | Low | — | S | — | Not started | `Invoke` deep-clones the entire request only to discard the cloned command |
|
| GWC-29 | Low | — | S | — | Not started | `Invoke` deep-clones the entire request only to discard the cloned command |
|
||||||
| GWC-30 | Info | — | S | — | Not started | Frame reader allocates a fresh 4-byte length-prefix array per frame |
|
| GWC-30 | Info | — | S | — | Not started | Frame reader allocates a fresh 4-byte length-prefix array per frame |
|
||||||
|
|||||||
+5
-1
@@ -197,7 +197,11 @@ Event streaming uses `AttachEventSubscriber` which returns a disposable lease. W
|
|||||||
|
|
||||||
`FailFast` event backpressure faults the whole session only in single-subscriber mode; in multi-subscriber mode it degrades to a per-subscriber disconnect so one slow consumer never faults a session shared by others. The session passes its mode to the `SessionEventDistributor` at construction, so this decision is made on the fixed mode rather than a live subscriber-count snapshot.
|
`FailFast` event backpressure faults the whole session only in single-subscriber mode; in multi-subscriber mode it degrades to a per-subscriber disconnect so one slow consumer never faults a session shared by others. The session passes its mode to the `SessionEventDistributor` at construction, so this decision is made on the fixed mode rather than a live subscriber-count snapshot.
|
||||||
|
|
||||||
The single worker event channel has exactly one direct reader: the `SessionEventDistributor` pump (`MapWorkerEventsAsync`). Both gateway-owned internal consumers — the dashboard mirror and the central alarm monitor — attach as distributor subscribers rather than draining the worker channel themselves. `GatewaySession.AttachInternalEventSubscriber` mirrors the dashboard-mirror lease (`isInternal: true`): the alarm monitor's `SessionManager.ReadAlarmEventsAsync` registers one so it consumes the same mapped `MxEvent`s the pump fans to every subscriber, without counting against `MaxEventSubscribersPerSession` and without a slow reconcile faulting the session. This is what keeps the alarm feed and the dashboard from splitting the stream between two raw drains (which would silently lose Acknowledge and provider-mode transitions); the worker channel is single-reader and a second `WorkerClient.ReadEventsAsync` consumer throws so a regression fails loudly.
|
The single worker event channel has exactly one direct reader: the `SessionEventDistributor` pump (`MapWorkerEventsAsync`). Both gateway-owned internal consumers — the dashboard mirror and the central alarm monitor — attach as distributor subscribers rather than draining the worker channel themselves. `GatewaySession.AttachInternalEventSubscriber` mirrors the dashboard-mirror lease (`isInternal: true`): the alarm monitor calls it directly on its session so it consumes the same mapped `MxEvent`s the pump fans to every subscriber, without counting against `MaxEventSubscribersPerSession` and without a slow reconcile faulting the session. This is what keeps the alarm feed and the dashboard from splitting the stream between two raw drains (which would silently lose Acknowledge and provider-mode transitions); the worker channel is single-reader and a second `WorkerClient.ReadEventsAsync` consumer throws so a regression fails loudly.
|
||||||
|
|
||||||
|
The monitor takes that lease **before** it issues `SubscribeAlarms`, so no transition window is missed: the pump has been running since `MarkReady` started the dashboard mirror, and the distributor only fans to subscribers registered at fan-out time, so a lease taken after the subscribe + first-reconcile round trips would lose every transition raised inside that window. Transitions arriving while the monitor subscribes and reconciles buffer in the lease's bounded channel and are applied after the reconcile, which is order-safe because a live transition can update an alarm the reconciled snapshot already holds.
|
||||||
|
|
||||||
|
`AttachInternalEventSubscriber` enforces the same readiness gate as `AttachEventSubscriber` — a session (or worker) that is not `Ready` throws `SessionNotReady` *before* the distributor is constructed. A premature attach would otherwise start the pump against a source that throws, completing every subscriber with that error and latching the distributor for the session's whole lifetime.
|
||||||
|
|
||||||
Sessions open with `MxGateway:Sessions:DefaultLeaseSeconds` (default 1800) added to the open timestamp. Unary client activity refreshes the lease by the same duration. `ExtendLease` and `IsLeaseExpired` cooperate with `SessionManager.CloseExpiredLeasesAsync`, which iterates a registry snapshot and closes any session whose lease has expired with `LeaseExpiredReason`. `SessionLeaseMonitorHostedService` runs that sweep every `MxGateway:Sessions:LeaseSweepIntervalSeconds` seconds (default 30).
|
Sessions open with `MxGateway:Sessions:DefaultLeaseSeconds` (default 1800) added to the open timestamp. Unary client activity refreshes the lease by the same duration. `ExtendLease` and `IsLeaseExpired` cooperate with `SessionManager.CloseExpiredLeasesAsync`, which iterates a registry snapshot and closes any session whose lease has expired with `LeaseExpiredReason`. `SessionLeaseMonitorHostedService` runs that sweep every `MxGateway:Sessions:LeaseSweepIntervalSeconds` seconds (default 30).
|
||||||
|
|
||||||
|
|||||||
+14
@@ -154,6 +154,20 @@ session. The worker event channel is single-reader and asserts it (a second
|
|||||||
`WorkerClient.ReadEventsAsync` consumer throws), so a regression cannot silently
|
`WorkerClient.ReadEventsAsync` consumer throws), so a regression cannot silently
|
||||||
split the event stream between two drains.
|
split the event stream between two drains.
|
||||||
|
|
||||||
|
The monitor takes that internal lease **before** it sends `SubscribeAlarms`, and
|
||||||
|
drains it after the first reconcile. The pump is already running by then (the
|
||||||
|
dashboard mirror starts it at `MarkReady`) and the distributor fans only to
|
||||||
|
subscribers registered at fan-out time, so attaching after the subscribe +
|
||||||
|
reconcile round trips would drop every transition raised in that window —
|
||||||
|
including an `Acknowledge`, which the presence-only reconcile deltas would never
|
||||||
|
repair. Transitions arriving during the window buffer in the lease's bounded
|
||||||
|
channel instead. As defense in depth for any window this ordering cannot cover
|
||||||
|
(worker restart, internal-subscriber overflow disconnect), a reconcile that finds
|
||||||
|
a known alarm now reported `ActiveAcked` broadcasts an `Acknowledge` transition on
|
||||||
|
the alarm feed. That is a feed-level repair rebuilt from the worker's own
|
||||||
|
snapshot on the `StreamAlarms` surface — it is not an `MxEvent` and never reaches
|
||||||
|
`StreamEvents`, so the "never synthesize events" rule is untouched.
|
||||||
|
|
||||||
### Alarm providers and failover
|
### Alarm providers and failover
|
||||||
|
|
||||||
The alarm feed has two providers, both implemented worker-side:
|
The alarm feed has two providers, both implemented worker-side:
|
||||||
|
|||||||
@@ -210,6 +210,17 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Attach the internal distributor subscriber BEFORE subscribing (GWC-26). The pump
|
||||||
|
// has been running since MarkReady started the dashboard mirror, and the distributor
|
||||||
|
// only fans to subscribers registered at the time of the fan-out, so a subscriber
|
||||||
|
// taken after SubscribeAlarms + the first reconcile would silently lose every
|
||||||
|
// transition raised inside that two-round-trip window — and a missed Acknowledge is
|
||||||
|
// never repaired by the presence-only reconcile deltas. Transitions arriving while we
|
||||||
|
// subscribe and reconcile simply buffer in this lease's bounded channel; if it ever
|
||||||
|
// overflowed, the internal subscriber is disconnected (it never faults the session),
|
||||||
|
// the enumeration below ends, and the supervisor loop restarts the lifecycle.
|
||||||
|
using IEventSubscriberLease alarmLease = session.AttachInternalEventSubscriber();
|
||||||
|
|
||||||
await SubscribeAlarmsAsync(session.SessionId, subscription, stoppingToken).ConfigureAwait(false);
|
await SubscribeAlarmsAsync(session.SessionId, subscription, stoppingToken).ConfigureAwait(false);
|
||||||
await ReconcileAsync(session.SessionId, stoppingToken).ConfigureAwait(false);
|
await ReconcileAsync(session.SessionId, stoppingToken).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -228,9 +239,11 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
// Consume mapped MxEvents through the session's single distributor pump (as an
|
// Consume mapped MxEvents through the session's single distributor pump (as an
|
||||||
// internal, non-counted subscriber) rather than opening a second raw drain of the
|
// internal, non-counted subscriber) rather than opening a second raw drain of the
|
||||||
// worker event channel — a second drain would split events with the dashboard
|
// worker event channel — a second drain would split events with the dashboard
|
||||||
// mirror pump and silently lose Acknowledge/mode-change transitions.
|
// mirror pump and silently lose Acknowledge/mode-change transitions. The lease was
|
||||||
await foreach (MxEvent mxEvent in _sessionManager
|
// taken above, before SubscribeAlarms; draining it only now is order-safe because
|
||||||
.ReadAlarmEventsAsync(session.SessionId, linked.Token)
|
// ApplyTransition handles alarms the reconcile already placed in the cache.
|
||||||
|
await foreach (MxEvent mxEvent in alarmLease.Reader
|
||||||
|
.ReadAllAsync(linked.Token)
|
||||||
.ConfigureAwait(false))
|
.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
if (mxEvent is { BodyCase: MxEvent.BodyOneofCase.OnAlarmTransition }
|
if (mxEvent is { BodyCase: MxEvent.BodyOneofCase.OnAlarmTransition }
|
||||||
@@ -511,6 +524,12 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
|
|
||||||
// Replaces the cache with the worker's authoritative snapshot, broadcasting
|
// Replaces the cache with the worker's authoritative snapshot, broadcasting
|
||||||
// a synthetic transition for any alarm the live stream missed.
|
// a synthetic transition for any alarm the live stream missed.
|
||||||
|
//
|
||||||
|
// Repair scope (GWC-26): presence deltas (Clear/Raise) plus acked-state deltas. These are
|
||||||
|
// ALARM FEED transitions (AlarmFeedMessage on the StreamAlarms/dashboard surface), rebuilt
|
||||||
|
// from the worker's own authoritative snapshot to repair what the live feed missed. They are
|
||||||
|
// not MxEvents and never reach the gRPC StreamEvents path, so this feed-level repair does not
|
||||||
|
// breach the "never synthesize events" rule, which governs MxEvent emission.
|
||||||
private void ApplyReconcile(IEnumerable<ActiveAlarmSnapshot> snapshots)
|
private void ApplyReconcile(IEnumerable<ActiveAlarmSnapshot> snapshots)
|
||||||
{
|
{
|
||||||
Dictionary<string, ActiveAlarmSnapshot> next = new(StringComparer.Ordinal);
|
Dictionary<string, ActiveAlarmSnapshot> next = new(StringComparer.Ordinal);
|
||||||
@@ -536,12 +555,23 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
|
|
||||||
foreach (KeyValuePair<string, ActiveAlarmSnapshot> incoming in next)
|
foreach (KeyValuePair<string, ActiveAlarmSnapshot> incoming in next)
|
||||||
{
|
{
|
||||||
if (!_alarms.ContainsKey(incoming.Key))
|
if (!_alarms.TryGetValue(incoming.Key, out ActiveAlarmSnapshot? existing))
|
||||||
{
|
{
|
||||||
Broadcast(
|
Broadcast(
|
||||||
new AlarmFeedMessage { Transition = TransitionFromSnapshot(incoming.Value, AlarmTransitionKind.Raise) },
|
new AlarmFeedMessage { Transition = TransitionFromSnapshot(incoming.Value, AlarmTransitionKind.Raise) },
|
||||||
incoming.Key);
|
incoming.Key);
|
||||||
}
|
}
|
||||||
|
else if (existing.CurrentState != incoming.Value.CurrentState
|
||||||
|
&& incoming.Value.CurrentState == AlarmConditionState.ActiveAcked)
|
||||||
|
{
|
||||||
|
// The alarm was already known but the worker now reports it acknowledged: the
|
||||||
|
// live Acknowledge transition never reached the feed. Without this the acked
|
||||||
|
// state is absorbed silently by the snapshot replace below and subscribers show
|
||||||
|
// the alarm unacked until it clears.
|
||||||
|
Broadcast(
|
||||||
|
new AlarmFeedMessage { Transition = TransitionFromSnapshot(incoming.Value, AlarmTransitionKind.Acknowledge) },
|
||||||
|
incoming.Key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_alarms.Clear();
|
_alarms.Clear();
|
||||||
|
|||||||
@@ -43,18 +43,6 @@ public interface ISessionManager
|
|||||||
string sessionId,
|
string sessionId,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads mapped events for the central alarm monitor by attaching an internal
|
|
||||||
/// (non-counted) distributor subscriber, so the alarm feed shares the one worker-event
|
|
||||||
/// pump instead of opening a second raw drain of the single worker event channel.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="sessionId">Identifier of the session.</param>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
/// <returns>The mapped <see cref="MxEvent"/>s fanned by the session's distributor.</returns>
|
|
||||||
IAsyncEnumerable<MxEvent> ReadAlarmEventsAsync(
|
|
||||||
string sessionId,
|
|
||||||
CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>Closes a session and terminates its worker process.</summary>
|
/// <summary>Closes a session and terminates its worker process.</summary>
|
||||||
/// <param name="sessionId">Identifier of the session to close.</param>
|
/// <param name="sessionId">Identifier of the session to close.</param>
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -191,22 +190,6 @@ public sealed class SessionManager : ISessionManager
|
|||||||
return session.ReadEventsAsync(cancellationToken);
|
return session.ReadEventsAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async IAsyncEnumerable<MxEvent> ReadAlarmEventsAsync(
|
|
||||||
string sessionId,
|
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
GatewaySession session = GetRequiredSession(sessionId);
|
|
||||||
using IEventSubscriberLease lease = session.AttachInternalEventSubscriber();
|
|
||||||
|
|
||||||
await foreach (MxEvent mxEvent in lease.Reader
|
|
||||||
.ReadAllAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
yield return mxEvent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<SessionCloseResult> CloseSessionAsync(
|
public async Task<SessionCloseResult> CloseSessionAsync(
|
||||||
string sessionId,
|
string sessionId,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using ZB.MOM.WW.MxGateway.Server.Alarms;
|
|||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
|
namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
|
||||||
|
|
||||||
@@ -420,6 +421,9 @@ public sealed class AlarmFailoverEndToEndTests
|
|||||||
string? ownerKeyId,
|
string? ownerKeyId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// The monitor attaches its internal subscriber directly on this session, so the
|
||||||
|
// session has to be a genuinely Ready one with a worker client feeding the
|
||||||
|
// distributor pump — EmitEvent writes into that worker's event stream.
|
||||||
GatewaySession session = new(
|
GatewaySession session = new(
|
||||||
Guid.NewGuid().ToString("N"),
|
Guid.NewGuid().ToString("N"),
|
||||||
"Galaxy",
|
"Galaxy",
|
||||||
@@ -432,6 +436,8 @@ public sealed class AlarmFailoverEndToEndTests
|
|||||||
TimeSpan.FromSeconds(30),
|
TimeSpan.FromSeconds(30),
|
||||||
TimeSpan.FromSeconds(30),
|
TimeSpan.FromSeconds(30),
|
||||||
DateTimeOffset.UtcNow);
|
DateTimeOffset.UtcNow);
|
||||||
|
session.AttachWorkerClient(new ChannelWorkerClient(session.SessionId, _events.Reader));
|
||||||
|
session.MarkReady();
|
||||||
return Task.FromResult(session);
|
return Task.FromResult(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,29 +466,9 @@ public sealed class AlarmFailoverEndToEndTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
string sessionId,
|
string sessionId,
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
{
|
|
||||||
await foreach (WorkerEvent workerEvent in _events.Reader.ReadAllAsync(cancellationToken))
|
|
||||||
{
|
|
||||||
yield return workerEvent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async IAsyncEnumerable<MxEvent> ReadAlarmEventsAsync(
|
|
||||||
string sessionId,
|
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await foreach (WorkerEvent workerEvent in _events.Reader.ReadAllAsync(cancellationToken))
|
|
||||||
{
|
|
||||||
if (workerEvent.Event is not null)
|
|
||||||
{
|
|
||||||
yield return workerEvent.Event;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
|
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
|
||||||
@@ -509,4 +495,50 @@ public sealed class AlarmFailoverEndToEndTests
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Ready worker client whose event stream is the fake session manager's channel.</summary>
|
||||||
|
private sealed class ChannelWorkerClient(string sessionId, ChannelReader<WorkerEvent> events) : IWorkerClient
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string SessionId { get; } = sessionId;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int? ProcessId { get; } = 4321;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
WorkerCommand command,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await foreach (WorkerEvent workerEvent in events.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
yield return workerEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Kill(string reason)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Grpc;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||||
|
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GWC-26 regression tests for the alarm monitor's startup ordering. Unlike the
|
||||||
|
/// sibling alarm-monitor tests, the session manager here hands the monitor a REAL
|
||||||
|
/// <see cref="GatewaySession"/> that is driven to Ready with a dashboard mirror, so the
|
||||||
|
/// distributor pump is already running when the monitor attaches — the production
|
||||||
|
/// condition under which a late internal subscriber silently misses everything the pump
|
||||||
|
/// already fanned.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GatewayAlarmMonitorAttachOrderTests
|
||||||
|
{
|
||||||
|
private const string AlarmReference = "Galaxy!Area.Tank01.Hi";
|
||||||
|
private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The monitor must take its internal distributor lease BEFORE issuing
|
||||||
|
/// <c>SubscribeAlarms</c>, so transitions the worker emits during the
|
||||||
|
/// subscribe + first-reconcile window buffer in the lease instead of being fanned to a
|
||||||
|
/// subscriber set the monitor has not joined yet. The test parks the monitor inside its
|
||||||
|
/// <c>SubscribeAlarms</c> round trip, emits a Raise and an Acknowledge, waits until the
|
||||||
|
/// dashboard mirror proves the pump has already fanned both, and only then releases the
|
||||||
|
/// monitor: with a late attach both transitions are lost to the alarm feed forever.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task TransitionsDuringSubscribeWindow_StillReachTheAlarmFeed()
|
||||||
|
{
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
await using FakeSessionManager sessions = new();
|
||||||
|
sessions.HoldSubscribeUntilReleased();
|
||||||
|
using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
|
||||||
|
|
||||||
|
using CancellationTokenSource cts = new();
|
||||||
|
await monitor.StartAsync(cts.Token);
|
||||||
|
await sessions.WaitForSubscribeStartAsync(WaitTimeout);
|
||||||
|
|
||||||
|
// A live feed subscriber, drained past its baseline ProviderStatus so it is registered
|
||||||
|
// before any window transition is broadcast.
|
||||||
|
List<AlarmFeedMessage> received = [];
|
||||||
|
TaskCompletionSource baselineReceived = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
using CancellationTokenSource streamCts = new();
|
||||||
|
Task reader = ReadFeedAsync(monitor, received, baselineReceived, streamCts.Token);
|
||||||
|
await baselineReceived.Task.WaitAsync(WaitTimeout);
|
||||||
|
|
||||||
|
// The window: the worker reports a raise and an acknowledge while the monitor is still
|
||||||
|
// waiting for its SubscribeAlarms reply.
|
||||||
|
sessions.EmitEvent(Transition(1, AlarmTransitionKind.Raise));
|
||||||
|
sessions.EmitEvent(Transition(2, AlarmTransitionKind.Acknowledge));
|
||||||
|
|
||||||
|
// The dashboard mirror is an independent distributor subscriber: once it has both
|
||||||
|
// events, the pump has provably already fanned them, so a subscriber that registers
|
||||||
|
// after this point can never receive them.
|
||||||
|
await WaitUntilAsync(() => sessions.Broadcaster.Captures.Count == 2, WaitTimeout);
|
||||||
|
|
||||||
|
sessions.ReleaseSubscribe();
|
||||||
|
|
||||||
|
AlarmFeedMessage raise = await WaitForAsync(
|
||||||
|
received,
|
||||||
|
m => m.PayloadCase == AlarmFeedMessage.PayloadOneofCase.Transition
|
||||||
|
&& m.Transition.TransitionKind == AlarmTransitionKind.Raise,
|
||||||
|
WaitTimeout);
|
||||||
|
AlarmFeedMessage acknowledge = await WaitForAsync(
|
||||||
|
received,
|
||||||
|
m => m.PayloadCase == AlarmFeedMessage.PayloadOneofCase.Transition
|
||||||
|
&& m.Transition.TransitionKind == AlarmTransitionKind.Acknowledge,
|
||||||
|
WaitTimeout);
|
||||||
|
|
||||||
|
Assert.Equal(AlarmReference, raise.Transition.AlarmFullReference);
|
||||||
|
Assert.Equal(AlarmReference, acknowledge.Transition.AlarmFullReference);
|
||||||
|
|
||||||
|
await streamCts.CancelAsync();
|
||||||
|
await reader;
|
||||||
|
await cts.CancelAsync();
|
||||||
|
await monitor.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defense in depth for any window the attach reorder cannot cover (worker restart,
|
||||||
|
/// internal-subscriber overflow disconnect): when a reconcile snapshot reports an alarm
|
||||||
|
/// the cache already holds but with the state advanced to
|
||||||
|
/// <see cref="AlarmConditionState.ActiveAcked"/>, the monitor broadcasts an
|
||||||
|
/// <see cref="AlarmTransitionKind.Acknowledge"/> feed transition. Before the fix the
|
||||||
|
/// acked state was absorbed silently by the snapshot replace, leaving live subscribers
|
||||||
|
/// showing the alarm unacked until it cleared.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyReconcileBroadcastsAcknowledgeDelta()
|
||||||
|
{
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
await using FakeSessionManager sessions = new();
|
||||||
|
using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
|
||||||
|
|
||||||
|
using CancellationTokenSource cts = new();
|
||||||
|
await monitor.StartAsync(cts.Token);
|
||||||
|
await sessions.WaitForSubscribeStartAsync(WaitTimeout);
|
||||||
|
|
||||||
|
// Seed the cache with an active (unacked) alarm and keep the reconcile snapshot in
|
||||||
|
// agreement, so a periodic reconcile pass cannot clear it out from under the test.
|
||||||
|
sessions.SetReconcileSnapshot(Snapshot(AlarmConditionState.Active));
|
||||||
|
sessions.EmitEvent(Transition(1, AlarmTransitionKind.Raise));
|
||||||
|
await WaitUntilAsync(
|
||||||
|
() => monitor.CurrentAlarms.Any(alarm => alarm.AlarmFullReference == AlarmReference),
|
||||||
|
WaitTimeout);
|
||||||
|
|
||||||
|
// Subscribe AFTER the raise: this reader's snapshot carries the alarm, so every
|
||||||
|
// transition it observes from here on is a reconcile-derived broadcast.
|
||||||
|
List<AlarmFeedMessage> received = [];
|
||||||
|
TaskCompletionSource snapshotComplete = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
using CancellationTokenSource streamCts = new();
|
||||||
|
Task reader = ReadFeedAsync(monitor, received, snapshotComplete, streamCts.Token, untilSnapshotComplete: true);
|
||||||
|
await snapshotComplete.Task.WaitAsync(WaitTimeout);
|
||||||
|
|
||||||
|
// The worker now reports the same alarm acknowledged. A provider-mode event forces an
|
||||||
|
// immediate reconcile pass so the test does not wait on the periodic timer.
|
||||||
|
sessions.SetReconcileSnapshot(Snapshot(AlarmConditionState.ActiveAcked));
|
||||||
|
sessions.EmitEvent(new MxEvent
|
||||||
|
{
|
||||||
|
Family = MxEventFamily.OnAlarmProviderModeChanged,
|
||||||
|
WorkerSequence = 2,
|
||||||
|
OnAlarmProviderModeChanged = new OnAlarmProviderModeChangedEvent
|
||||||
|
{
|
||||||
|
Mode = AlarmProviderMode.Alarmmgr,
|
||||||
|
Reason = "probe",
|
||||||
|
At = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
AlarmFeedMessage acknowledge = await WaitForAsync(
|
||||||
|
received,
|
||||||
|
m => m.PayloadCase == AlarmFeedMessage.PayloadOneofCase.Transition
|
||||||
|
&& m.Transition.TransitionKind == AlarmTransitionKind.Acknowledge,
|
||||||
|
WaitTimeout);
|
||||||
|
Assert.Equal(AlarmReference, acknowledge.Transition.AlarmFullReference);
|
||||||
|
|
||||||
|
lock (received)
|
||||||
|
{
|
||||||
|
AlarmFeedMessage[] transitions = received
|
||||||
|
.Where(m => m.PayloadCase == AlarmFeedMessage.PayloadOneofCase.Transition)
|
||||||
|
.ToArray();
|
||||||
|
AlarmFeedMessage single = Assert.Single(transitions);
|
||||||
|
Assert.Equal(AlarmTransitionKind.Acknowledge, single.Transition.TransitionKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
await streamCts.CancelAsync();
|
||||||
|
await reader;
|
||||||
|
await cts.CancelAsync();
|
||||||
|
await monitor.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GatewayAlarmMonitor CreateMonitor(FakeSessionManager sessions, GatewayMetrics metrics)
|
||||||
|
{
|
||||||
|
AlarmsOptions options = new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
SubscriptionExpression = @"\\NODE\Galaxy!Area",
|
||||||
|
};
|
||||||
|
return new GatewayAlarmMonitor(
|
||||||
|
sessions,
|
||||||
|
new StubWatchListResolver(),
|
||||||
|
metrics,
|
||||||
|
Microsoft.Extensions.Options.Options.Create(new GatewayOptions { Alarms = options }),
|
||||||
|
NullLogger<GatewayAlarmMonitor>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drains the monitor's feed into received, signalling gate on the first message (the
|
||||||
|
// baseline ProviderStatus) or, when untilSnapshotComplete is set, on SnapshotComplete.
|
||||||
|
private static Task ReadFeedAsync(
|
||||||
|
GatewayAlarmMonitor monitor,
|
||||||
|
List<AlarmFeedMessage> received,
|
||||||
|
TaskCompletionSource gate,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
bool untilSnapshotComplete = false)
|
||||||
|
{
|
||||||
|
return Task.Run(
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (AlarmFeedMessage message in monitor.StreamAsync(null, cancellationToken))
|
||||||
|
{
|
||||||
|
bool opensGate = !untilSnapshotComplete
|
||||||
|
|| message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.SnapshotComplete;
|
||||||
|
|
||||||
|
// Record only what arrives AFTER the gate opened: everything up to and
|
||||||
|
// including the gate message is this subscriber's snapshot preamble, not
|
||||||
|
// a live broadcast, so assertions stay about broadcasts alone.
|
||||||
|
lock (received)
|
||||||
|
{
|
||||||
|
if (gate.Task.IsCompleted)
|
||||||
|
{
|
||||||
|
received.Add(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opensGate)
|
||||||
|
{
|
||||||
|
gate.TrySetResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected when the test cancels the stream.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxEvent Transition(ulong sequence, AlarmTransitionKind kind) => new()
|
||||||
|
{
|
||||||
|
Family = MxEventFamily.OnAlarmTransition,
|
||||||
|
WorkerSequence = sequence,
|
||||||
|
OnAlarmTransition = new OnAlarmTransitionEvent
|
||||||
|
{
|
||||||
|
AlarmFullReference = AlarmReference,
|
||||||
|
SourceObjectReference = "Tank01",
|
||||||
|
AlarmTypeName = "AnalogLimitAlarm.Hi",
|
||||||
|
TransitionKind = kind,
|
||||||
|
Severity = 500,
|
||||||
|
SourceProvider = AlarmProviderMode.Alarmmgr,
|
||||||
|
TransitionTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ActiveAlarmSnapshot Snapshot(AlarmConditionState state) => new()
|
||||||
|
{
|
||||||
|
AlarmFullReference = AlarmReference,
|
||||||
|
SourceObjectReference = "Tank01",
|
||||||
|
AlarmTypeName = "AnalogLimitAlarm.Hi",
|
||||||
|
CurrentState = state,
|
||||||
|
Severity = 500,
|
||||||
|
SourceProvider = AlarmProviderMode.Alarmmgr,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<AlarmFeedMessage> WaitForAsync(
|
||||||
|
List<AlarmFeedMessage> received,
|
||||||
|
Func<AlarmFeedMessage, bool> predicate,
|
||||||
|
TimeSpan timeout)
|
||||||
|
{
|
||||||
|
DateTime deadline = DateTime.UtcNow + timeout;
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
lock (received)
|
||||||
|
{
|
||||||
|
AlarmFeedMessage? match = received.FirstOrDefault(predicate);
|
||||||
|
if (match is not null)
|
||||||
|
{
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException("No matching AlarmFeedMessage was received in time.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
DateTime deadline = DateTime.UtcNow + timeout;
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
if (condition())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException("Condition was not met in time.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><see cref="IAlarmWatchListResolver"/> that resolves an empty watch-list.</summary>
|
||||||
|
private sealed class StubWatchListResolver : IAlarmWatchListResolver
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<IReadOnlyList<AlarmSubtagTarget>> ResolveAsync(
|
||||||
|
AlarmsOptions options,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<IReadOnlyList<AlarmSubtagTarget>>([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Session manager that hands the monitor a real <see cref="GatewaySession"/> driven to
|
||||||
|
/// Ready with a dashboard mirror, so the distributor pump is running before the monitor
|
||||||
|
/// attaches. <see cref="EmitEvent"/> pushes worker events through the fake worker client
|
||||||
|
/// into that pump, exactly as a live worker would.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FakeSessionManager : ISessionManager, IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly Channel<WorkerEvent> _events = Channel.CreateUnbounded<WorkerEvent>();
|
||||||
|
private readonly TaskCompletionSource _subscribeStarted =
|
||||||
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private readonly object _sync = new();
|
||||||
|
private TaskCompletionSource _subscribeGate = CreateReleasedGate();
|
||||||
|
private ActiveAlarmSnapshot[] _reconcileSnapshot = [];
|
||||||
|
private GatewaySession? _session;
|
||||||
|
|
||||||
|
/// <summary>Dashboard mirror attached to the session; proves what the pump has fanned.</summary>
|
||||||
|
public RecordingDashboardEventBroadcaster Broadcaster { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Re-arms the gate so <c>SubscribeAlarms</c> parks until <see cref="ReleaseSubscribe"/>.</summary>
|
||||||
|
public void HoldSubscribeUntilReleased() =>
|
||||||
|
_subscribeGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
/// <summary>Releases a gate armed by <see cref="HoldSubscribeUntilReleased"/>.</summary>
|
||||||
|
public void ReleaseSubscribe() => _subscribeGate.TrySetResult();
|
||||||
|
|
||||||
|
/// <summary>Completes once the monitor's <c>SubscribeAlarms</c> command has arrived.</summary>
|
||||||
|
/// <param name="timeout">The maximum time to wait.</param>
|
||||||
|
/// <returns>A task that completes when the command arrives.</returns>
|
||||||
|
public Task WaitForSubscribeStartAsync(TimeSpan timeout) => _subscribeStarted.Task.WaitAsync(timeout);
|
||||||
|
|
||||||
|
/// <summary>Sets the active-alarm snapshot every <c>QueryActiveAlarms</c> reconcile returns.</summary>
|
||||||
|
/// <param name="snapshots">The snapshots to report.</param>
|
||||||
|
public void SetReconcileSnapshot(params ActiveAlarmSnapshot[] snapshots)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
_reconcileSnapshot = snapshots;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pushes a worker event into the session's distributor pump.</summary>
|
||||||
|
/// <param name="mxEvent">The event to push.</param>
|
||||||
|
public void EmitEvent(MxEvent mxEvent) =>
|
||||||
|
_events.Writer.TryWrite(new WorkerEvent { Event = mxEvent });
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<GatewaySession> OpenSessionAsync(
|
||||||
|
SessionOpenRequest request,
|
||||||
|
string? clientIdentity,
|
||||||
|
string? ownerKeyId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
GatewaySession session = new(
|
||||||
|
sessionId: "session-alarm-attach-order",
|
||||||
|
backendName: "Galaxy",
|
||||||
|
pipeName: "mxaccess-gateway-1-session-alarm-attach-order",
|
||||||
|
nonce: "nonce",
|
||||||
|
clientIdentity: clientIdentity,
|
||||||
|
ownerKeyId: ownerKeyId,
|
||||||
|
clientSessionName: request.ClientSessionName,
|
||||||
|
clientCorrelationId: request.ClientCorrelationId,
|
||||||
|
commandTimeout: TimeSpan.FromSeconds(30),
|
||||||
|
startupTimeout: TimeSpan.FromSeconds(30),
|
||||||
|
shutdownTimeout: TimeSpan.FromSeconds(30),
|
||||||
|
leaseDuration: TimeSpan.FromMinutes(30),
|
||||||
|
openedAt: DateTimeOffset.UtcNow,
|
||||||
|
eventStreaming: new SessionEventStreaming(
|
||||||
|
new MxAccessGrpcMapper(),
|
||||||
|
new EventOptions { QueueCapacity = 64 },
|
||||||
|
NullLogger<SessionEventDistributor>.Instance,
|
||||||
|
TimeProvider.System,
|
||||||
|
new GatewayMetrics(),
|
||||||
|
Broadcaster));
|
||||||
|
session.AttachWorkerClient(new ChannelWorkerClient(session.SessionId, _events.Reader));
|
||||||
|
|
||||||
|
// MarkReady starts the dashboard mirror, and with it the distributor pump — the
|
||||||
|
// production precondition this regression depends on.
|
||||||
|
session.MarkReady();
|
||||||
|
_session = session;
|
||||||
|
return Task.FromResult(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
string sessionId,
|
||||||
|
WorkerCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = new()
|
||||||
|
{
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (command.Command?.Kind)
|
||||||
|
{
|
||||||
|
case MxCommandKind.SubscribeAlarms:
|
||||||
|
_subscribeStarted.TrySetResult();
|
||||||
|
await _subscribeGate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
case MxCommandKind.QueryActiveAlarms:
|
||||||
|
QueryActiveAlarmsReplyPayload payload = new();
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
payload.Snapshots.AddRange(_reconcileSnapshot.Select(snapshot => snapshot.Clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.QueryActiveAlarms = payload;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WorkerCommandReply { Reply = reply };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
|
||||||
|
{
|
||||||
|
session = _session;
|
||||||
|
return session is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<SessionCloseResult> CloseSessionAsync(string sessionId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_events.Writer.TryComplete();
|
||||||
|
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<SessionCloseResult> KillWorkerAsync(string sessionId, string reason, CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<int> CloseExpiredLeasesAsync(DateTimeOffset now, CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(0);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <summary>Disposes the session the fake handed out.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_events.Writer.TryComplete();
|
||||||
|
if (_session is not null)
|
||||||
|
{
|
||||||
|
await _session.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskCompletionSource CreateReleasedGate()
|
||||||
|
{
|
||||||
|
TaskCompletionSource gate = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
gate.SetResult();
|
||||||
|
return gate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Worker client whose event stream is a test-driven channel.</summary>
|
||||||
|
private sealed class ChannelWorkerClient(string sessionId, ChannelReader<WorkerEvent> events) : IWorkerClient
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string SessionId { get; } = sessionId;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int? ProcessId { get; } = 4321;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
WorkerCommand command,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await foreach (WorkerEvent workerEvent in events.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
yield return workerEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Kill(string reason)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ using ZB.MOM.WW.MxGateway.Server.Alarms;
|
|||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
|
namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
|
||||||
|
|
||||||
@@ -733,6 +734,9 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
|||||||
string? ownerKeyId,
|
string? ownerKeyId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// The monitor attaches its internal subscriber directly on this session, so the
|
||||||
|
// session has to be a genuinely Ready one with a worker client feeding the
|
||||||
|
// distributor pump — EmitEvent writes into that worker's event stream.
|
||||||
GatewaySession session = new(
|
GatewaySession session = new(
|
||||||
Guid.NewGuid().ToString("N"),
|
Guid.NewGuid().ToString("N"),
|
||||||
"Galaxy",
|
"Galaxy",
|
||||||
@@ -745,6 +749,8 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
|||||||
TimeSpan.FromSeconds(30),
|
TimeSpan.FromSeconds(30),
|
||||||
TimeSpan.FromSeconds(30),
|
TimeSpan.FromSeconds(30),
|
||||||
DateTimeOffset.UtcNow);
|
DateTimeOffset.UtcNow);
|
||||||
|
session.AttachWorkerClient(new ChannelWorkerClient(session.SessionId, _events.Reader));
|
||||||
|
session.MarkReady();
|
||||||
return Task.FromResult(session);
|
return Task.FromResult(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,29 +779,9 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
string sessionId,
|
string sessionId,
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
{
|
|
||||||
await foreach (WorkerEvent workerEvent in _events.Reader.ReadAllAsync(cancellationToken))
|
|
||||||
{
|
|
||||||
yield return workerEvent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async IAsyncEnumerable<MxEvent> ReadAlarmEventsAsync(
|
|
||||||
string sessionId,
|
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await foreach (WorkerEvent workerEvent in _events.Reader.ReadAllAsync(cancellationToken))
|
|
||||||
{
|
|
||||||
if (workerEvent.Event is not null)
|
|
||||||
{
|
|
||||||
yield return workerEvent.Event;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
|
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
|
||||||
@@ -822,4 +808,50 @@ public sealed class GatewayAlarmMonitorProviderModeTests
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Ready worker client whose event stream is the fake session manager's channel.</summary>
|
||||||
|
private sealed class ChannelWorkerClient(string sessionId, ChannelReader<WorkerEvent> events) : IWorkerClient
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string SessionId { get; } = sessionId;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int? ProcessId { get; } = 4321;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
WorkerCommand command,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await foreach (WorkerEvent workerEvent in events.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
yield return workerEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Kill(string reason)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -378,14 +378,6 @@ public sealed class DashboardSessionAdminServiceTests
|
|||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IAsyncEnumerable<MxEvent> ReadAlarmEventsAsync(
|
|
||||||
string sessionId,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<SessionCloseResult> CloseSessionAsync(
|
public Task<SessionCloseResult> CloseSessionAsync(
|
||||||
string sessionId,
|
string sessionId,
|
||||||
|
|||||||
@@ -755,14 +755,6 @@ public sealed class EventStreamServiceTests
|
|||||||
return _sessions[sessionId].ReadEventsAsync(cancellationToken);
|
return _sessions[sessionId].ReadEventsAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IAsyncEnumerable<MxEvent> ReadAlarmEventsAsync(
|
|
||||||
string sessionId,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<SessionCloseResult> CloseSessionAsync(
|
public Task<SessionCloseResult> CloseSessionAsync(
|
||||||
string sessionId,
|
string sessionId,
|
||||||
|
|||||||
@@ -948,14 +948,6 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IAsyncEnumerable<MxEvent> ReadAlarmEventsAsync(
|
|
||||||
string sessionId,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<SessionCloseResult> CloseSessionAsync(
|
public Task<SessionCloseResult> CloseSessionAsync(
|
||||||
string sessionId,
|
string sessionId,
|
||||||
|
|||||||
@@ -616,14 +616,6 @@ public sealed class MxAccessGatewayServiceTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IAsyncEnumerable<MxEvent> ReadAlarmEventsAsync(
|
|
||||||
string sessionId,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<SessionCloseResult> CloseSessionAsync(
|
public Task<SessionCloseResult> CloseSessionAsync(
|
||||||
string sessionId,
|
string sessionId,
|
||||||
|
|||||||
@@ -352,11 +352,6 @@ public sealed class GatewaySessionDashboardMirrorTests
|
|||||||
string sessionId,
|
string sessionId,
|
||||||
CancellationToken cancellationToken) => session.ReadEventsAsync(cancellationToken);
|
CancellationToken cancellationToken) => session.ReadEventsAsync(cancellationToken);
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IAsyncEnumerable<MxEvent> ReadAlarmEventsAsync(
|
|
||||||
string sessionId,
|
|
||||||
CancellationToken cancellationToken) => throw new NotSupportedException();
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<SessionCloseResult> CloseSessionAsync(
|
public Task<SessionCloseResult> CloseSessionAsync(
|
||||||
string sessionId,
|
string sessionId,
|
||||||
|
|||||||
-8
@@ -579,14 +579,6 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
|||||||
return AsyncEnumerable.Empty<WorkerEvent>();
|
return AsyncEnumerable.Empty<WorkerEvent>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IAsyncEnumerable<MxEvent> ReadAlarmEventsAsync(
|
|
||||||
string sessionId,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return AsyncEnumerable.Empty<MxEvent>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<SessionCloseResult> CloseSessionAsync(
|
public Task<SessionCloseResult> CloseSessionAsync(
|
||||||
string sessionId,
|
string sessionId,
|
||||||
|
|||||||
Reference in New Issue
Block a user