From 4887461b64ead9f9bef5276adc11f9809930769c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 10 Jul 2026 06:07:19 -0400 Subject: [PATCH] chore(management): log debug-stream push failures; fix event-log filter docs; refresh stale SourceNode note (arch-review S5/C7/C8) Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj --- CLAUDE.md | 2 +- .../RemoteQuery/EventLogQueryRequest.cs | 5 +++++ .../DebugStreamHub.cs | 18 +++++++++++++++--- .../ManagementActor.cs | 5 ++++- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d44125f4..018edffa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -191,7 +191,7 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): ` - Cookie+JWT hybrid sessions: HttpOnly/Secure cookie carries an embedded JWT (HMAC-SHA256 shared symmetric key), 15-minute expiry with sliding refresh, 30-minute idle timeout. Cookies are the correct transport for Blazor Server (SignalR circuits). - LDAP failure: new logins fail; active sessions continue with current roles. - Load balancer in front of central UI; cookie-embedded JWT + shared Data Protection keys for failover transparency. -- Two-person MxGateway secured writes (M7): two new global roles — `Operator` (initiates) + `Verifier` (approves) — added alongside the canonical `Administrator`/`Designer`/`Deployer`/`Viewer`, with `RequireOperator`/`RequireVerifier` policies. An Operator submits a secured write from the Central UI Secured Writes page (`/operations/secured-writes`); it stays a `Pending` `PendingSecuredWrite` row until a *distinct* Verifier approves it (no-self-approval enforced server-side in the ManagementActor, plus a compare-and-swap race guard). Approval relays a `WriteTagRequest` to the site MxGateway; MxGateway-protocol connections only; each lifecycle event (submit/approve/reject/execute) emits a best-effort `AuditChannel.SecuredWrite` / `AuditKind.SecuredWrite*` central-direct-write row sharing the row id as `CorrelationId`. (SecuredWrite audit rows currently leave `SourceNode` NULL — a logged follow-up.) Pending secured writes expire server-side after a configurable TTL (`ManagementServiceOptions.SecuredWritePendingTtl`, default 24 h): an overdue `Pending` row is CAS'd to `Expired` (never relayed) — enforced at approve/reject and swept opportunistically on list (arch-review S2, `AuditKind.SecuredWriteExpire`). +- Two-person MxGateway secured writes (M7): two new global roles — `Operator` (initiates) + `Verifier` (approves) — added alongside the canonical `Administrator`/`Designer`/`Deployer`/`Viewer`, with `RequireOperator`/`RequireVerifier` policies. An Operator submits a secured write from the Central UI Secured Writes page (`/operations/secured-writes`); it stays a `Pending` `PendingSecuredWrite` row until a *distinct* Verifier approves it (no-self-approval enforced server-side in the ManagementActor, plus a compare-and-swap race guard). Approval relays a `WriteTagRequest` to the site MxGateway; MxGateway-protocol connections only; each lifecycle event (submit/approve/reject/execute) emits a best-effort `AuditChannel.SecuredWrite` / `AuditKind.SecuredWrite*` central-direct-write row sharing the row id as `CorrelationId`. (SecuredWrite audit rows stamp `SourceNode` via `ICentralAuditWriter`/`INodeIdentityProvider`.) Pending secured writes expire server-side after a configurable TTL (`ManagementServiceOptions.SecuredWritePendingTtl`, default 24 h): an overdue `Pending` row is CAS'd to `Expired` (never relayed) — enforced at approve/reject and swept opportunistically on list (arch-review S2, `AuditKind.SecuredWriteExpire`). ### Cluster & Failover - Keep-oldest split-brain resolver with `down-if-alone = on`, 15s stable-after. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/RemoteQuery/EventLogQueryRequest.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/RemoteQuery/EventLogQueryRequest.cs index d9a731be..a7493f6e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/RemoteQuery/EventLogQueryRequest.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/RemoteQuery/EventLogQueryRequest.cs @@ -5,6 +5,11 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; /// Supports filtering by event type, severity, instance, time range, and keyword search. /// Uses keyset pagination via continuation token (last event ID). /// +/// +/// Instance filter matched against the site event log's instance_id column, +/// which stores the instance UniqueName (InstanceActor.LogLifecycleEvent passes +/// _instanceUniqueName; EventLogQueryService matches instance_id = $instanceId). +/// public record EventLogQueryRequest( string CorrelationId, string SiteId, diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs index 2581e9f8..baa71751 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs @@ -198,13 +198,15 @@ public class DebugStreamHub : Hub // Use IHubContext for callbacks — the hub instance is transient (disposed after method returns), // but IHubContext is a singleton that remains valid for the lifetime of the connection. var hubClients = _hubContext.Clients; + // Capture the logger into a local: the callbacks outlive this (transient) + // hub instance, and ILogger is a singleton, so this is safe to hold. + var logger = _logger; var session = await _debugStreamService.StartStreamAsync( instanceId, onEvent: evt => { - // Fire-and-forget — if the client disconnects, SendAsync will fail silently - _ = evt switch + var send = evt switch { AttributeValueChanged changed => hubClients.Client(connectionId).SendAsync("OnAttributeChanged", changed), @@ -214,10 +216,20 @@ public class DebugStreamHub : Hub hubClients.Client(connectionId).SendAsync("OnSnapshot", snapshot), _ => Task.CompletedTask }; + // Fire-and-forget (deliberate — a slow/dead client must not block the + // stream), but log the push failure rather than swallowing it silently. + _ = send.ContinueWith( + t => logger.LogWarning(t.Exception?.GetBaseException(), + "Debug stream push to connection {ConnectionId} failed", connectionId), + CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); }, onTerminated: () => { - _ = hubClients.Client(connectionId).SendAsync("OnStreamTerminated", "Site disconnected"); + _ = hubClients.Client(connectionId).SendAsync("OnStreamTerminated", "Site disconnected") + .ContinueWith( + t => logger.LogWarning(t.Exception?.GetBaseException(), + "Debug stream termination push to connection {ConnectionId} failed", connectionId), + CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); }); Context.Items[SessionIdKey] = session.SessionId; diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 131a75e6..17fc4fd4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -3007,7 +3007,10 @@ public class ManagementActor : ReceiveActor cmd.SiteIdentifier, cmd.From, cmd.To, cmd.EventType, cmd.Severity, - cmd.InstanceName, // InstanceId filter + // Instance unique-name filter — the site event log's instance_id column + // stores the instance UniqueName (InstanceActor.LogLifecycleEvent passes + // _instanceUniqueName; EventLogQueryService matches instance_id = $instanceId). + cmd.InstanceName, cmd.Keyword, null, // ContinuationToken cmd.PageSize,