Claude-Session: https://claude.ai/code/session_019ZxXT5sxZs5XrtdwYzJDPV
22 KiB
MxGateway subscribe-storm prevention — design
Date: 2026-08-28
Status: Design — Lever 1 implemented (plan docs/plans/2026-08-28-tag-subscribe-inflight-guard-backoff.md, branch commits 6f7c6ad8..ab08ecd3; Levers 2–3 not yet implemented) — reviewed against both repos 2026-08-28 (ScadaBridge main @ a9b23556, MxAccessGateway @ 74ba889): no part of this design has landed in either repo; corrections from that review are folded in below (Lever 3 was re-scoped — see §5).
Scope: ScadaBridge (SiteRuntime, DataConnectionLayer) and MxAccessGateway (~/Desktop/MxAccessGateway: Contracts proto, Worker, Server)
Motivating incident: 2026-08-26 QA (vq03/vq04) — Z28061's 244 attributes went Bad/frozen for ~20 min. Root cause was a transient MxAccessGateway subscribe wedge under deploy/subscribe churn, not ScadaBridge or Galaxy. No dedicated incident record exists for 2026-08-26 yet — the analysis lives in this doc. Related prior record: docs/deployment/deployment-records/2026-08-20-wonder-app-vq03-vq04.md §4, the earlier ScadaBridge DCL hotfix da0bfe2b on the same connection (MxGateway adapter treating a graceful event-stream end as a disconnect — a ScadaBridge commit, not a gateway one; the 2026-08-26 freeze is a different failure mode, see §1).
1. Problem statement
Two independent defects compound into a self-sustaining freeze:
-
Amplifier (ScadaBridge).
InstanceActor.SendTagSubscribearms a fixed 5 s retry timer (SiteRuntimeOptions.TagSubscribeRetryIntervalMs = 5000) on send, with no in-flight guard and no backoff. When aSubscribeBulkround-trip takes longer than 5 s — which it does the moment the gateway is under load — a new full 244-tag subscribe is issued every 5 s while the previous one is still outstanding. With two instances (Z28061 + Z28062) this stacks ~6 concurrent 244-tag subscribes per 30 s window.src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:1234(SendTagSubscribe, called fromSubscribeToDcl:1224 and the retry handler),:1250-1254(timer arm — the only "guard" is that re-arming overwrites the previous per-connection timer; nothing checks_pendingTagSubscribesbefore issuing),:1264(stale-correlation drop),:1269(cancel-on-success),:314(RetryTagSubscribehandler),:391-394(PostStopcancels timers). State fields_tagsByConnection/_pendingTagSubscribes/_tagSubscribeRetryTimersat:185-188.SiteRuntimeOptions.cs:120(TagSubscribeRetryIntervalMs = 5000, doc comment explicitly says "fixed interval");SiteRuntimeOptionsValidator.cs:77-80checks only> 0.
-
Load (cross-repo). In supervisory mode (
WriteUserId == 0, how QA runs), one ScadaBridge subscribe = 1AddItemBulkRPC + ~244 per-itemAdviseSupervisoryRPCs (pipelined ×16). EachAdviseSupervisoryis a separate STA command on the worker's single-threaded apartment. The gateway's per-command deadline is 30 s (MxGateway:Sessions:DefaultCommandTimeoutSeconds), so under contention these bulk/per-item commands hit the deadline, cascadeDeadlineExceeded, and no advise is ever established → noOnDataChangereaches ScadaBridge → all tags Bad.- ScadaBridge:
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealMxGatewayClient.cs:108-160(SubscribeBulkAsync, supervisory branch). - Gateway (canonical proto
src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto; the copies underclients/andbuild/are generated mirrors):SubscribeBulkCommand(:325-328,server_handle=1,tag_addresses=2, no field 3) does AddItem + plainAdviseper tag in one STA pass —Worker/MxAccess/MxAccessSession.cs:628-652(SubscribeBulk, withAppendRemoveItemCleanuprollback),Worker/MxAccess/MxAccessCommandExecutor.cs:646(ExecuteSubscribeBulk). The only supervisory entry point is the per-itemAdviseSupervisoryCommand(proto :220-223, oneof 17;MxAccessSession.AdviseSupervisory:319-331). Note anAdviseItemBulkCommand(proto :312, oneof 29,repeated int32 item_handles) already exists for the plain kind — there is simply no supervisory counterpart, which is why ScadaBridge fans out per item. The STA really is one command at a time:Worker/Sta/StaRuntime.cs:58-63(single STA thread),StaCommandDispatcher.DrainAsync:233-268 (awaits each command fully before dequeuing the next).
- ScadaBridge:
Proof it is not MXAccess/Galaxy: a fresh LMXProxy 32-bit STA probe did AddItem + AdviseSupervisory for every sampled tag in 0–5 ms while both worker processes sat < 5 % CPU. The bottleneck is command-path fan-out and retry stacking, not the OT layer.
Goal / non-goals
- Goal: a subscribe or reconnect storm on one instance can no longer wedge the gateway session or freeze data; recovery is prompt and self-correcting.
- Non-goal: changing the alarm/event streaming path (
SiteStreamService,da0bfe2b), the write path, or anything about Galaxy/MXAccess itself. - Non-goal: all-or-nothing subscribe semantics; per-tag partial success is preserved.
2. Approach (chosen: full fix)
Three levers were considered; the full fix combines the two that address root cause, and folds in the third as a bounded safety knob:
| Lever | What | Repo | Role |
|---|---|---|---|
| 1 — retry backoff + in-flight guard | Stop re-sending a subscribe while one is in flight; exponential backoff with jitter on genuine failure | ScadaBridge | Removes the amplifier |
| 2 — bulk supervisory-advise RPC | SubscribeBulkCommand.supervisory flag → worker does AddItem + AdviseSupervisory in one STA pass; collapses ~244 RPCs → 1 |
Cross-repo, capability-negotiated | Removes the load |
| 3 — session command timeout | ScadaBridge sets an explicit command_timeout at OpenSession (the gateway's only existing override — it is per-session, not per-command; see §5) |
ScadaBridge | Bounded safety margin |
Levers 1 and 2 are each sufficient to break the feedback loop; together they are defense-in-depth (1 caps concurrency, 2 cuts the per-subscribe cost by ~244×). Lever 3 is a cheap knob that stops treating a merely-slow (but healthy) bulk op as a failure — with the caveat, found in review, that the gateway has no per-command override, so the knob is session-wide.
Rejected as standalone: raising DefaultCommandTimeoutSeconds globally (band-aid — lets the queue back up longer); an unnegotiated proto bump for Lever 2 (an old gateway would silently do a plain advise, breaking writes on a WriteUserId==0 connection).
3. Lever 1 — ScadaBridge retry backoff + in-flight guard
Where: InstanceActor (SiteRuntime). Per-connection subscribe state already lives here (_tagSubscribeRetryTimers, _pendingTagSubscribes).
Change:
- Track an in-flight flag per connection.
SendTagSubscribebecomes: if a subscribe for this connection is already outstanding and not past a hard "lost reply" ceiling, do not issue a new one — just re-arm the watchdog. This alone stops the 5 s stacking. - Replace the fixed 5 s re-arm with exponential backoff + jitter: floor
TagSubscribeRetryIntervalMs(keep 5 s default), doubling to a newTagSubscribeRetryMaxIntervalMs(default 60 s), reset to the floor on aSuccessreply. Mirrors the existing DCL pattern for unresolved tags (DataConnectionOptions.cs:17,24TagResolutionRetryInterval10 s /TagResolutionRetryMaxInterval5 min, validated floor> 0and max>= flooratDataConnectionOptionsValidator.cs:21-22,39-41; machinery inDataConnectionActor.cs—ScheduleTagResolutionRetry:2393-2405 with itsIsTimerActiveguard,ResetTagResolutionBackoff:2409,BackOffTagResolution:2417-2419, pure stepNextTagResolutionInterval~:2422) — reuse the shape for consistency. - Lost-reply safety: because a reply can be genuinely lost, the in-flight guard must expire. Keep a
TagSubscribeInFlightCeilingMs(default = current gateway command timeout + margin, e.g. 45 s) after which a new subscribe is allowed even if the old correlation never answered. This preserves today's "retry fires whether the response fails or is lost" guarantee without the 5 s stacking.
New options (SiteRuntimeOptions + validator):
TagSubscribeRetryMaxIntervalMs(default 60000; validate>= TagSubscribeRetryIntervalMs).TagSubscribeInFlightCeilingMs(default 45000; validate> 0).
Invariants preserved: a superseded correlation id is still ignored (HandleSubscribeTagsResponse :1262-1264 stale-reply drop); a Success still cancels the timer (:1269) and resets backoff; PostStop still cancels all timers (:391-394).
Live gate PASS 2026-08-28 (docker rig, main @
0589486f). OPC UA sim stopped and the instance's site node restarted so the boot-time subscribe could not be answered (failover landedrig-probe-1on site-a-b): the log shows exactly one outstandingSubscribeTagsRequestper in-flight ceiling —unanswered for 45091ms … presuming lost, re-sending, then47130ms— with the backoff climbingnext retry in 00:00:05 → 00:00:09.415 → 00:00:20.369(jittered), i.e. 3 requests in ~110 s where the pre-Lever-1 actor would have issued ~22. Sim restarted → DCLEntering Connected statein 4 s → all three attributesGoodwith fresh timestamps within ~95 s (debug snapshot --id 1492). Steady state on a healthy node: onesubscribingline per (instance, connection), nostill in flight/presuming lostat all. Note for future gates: an OPC UA outage alone never reaches this path — the DCL reconnects and re-subscribes transparently and the Instance Actor's subscribe stays confirmed; you must make the subscribe itself go unanswered (node restart / redeploy while the source is down).
4. Lever 2 — bulk supervisory-advise RPC (capability-negotiated)
Contract (mxaccess_gateway.proto), additive only:
message SubscribeBulkCommand {
int32 server_handle = 1;
repeated string tag_addresses = 2;
bool supervisory = 3; // NEW — advise supervisory instead of plain advise
}
Field 3 is unused today (verified); additive, field numbers never reused (house rule). Regen is the manual vendored-proto toggle both repos already use. Alternative considered: a new AdviseSupervisoryItemBulkCommand mirroring the existing plain AdviseItemBulkCommand (oneof 29) — rejected because it keeps the two-RPC AddItemBulk+AdviseBulk shape and two STA commands; the flag gives one RPC / one STA pass and reuses SubscribeBulkReply unchanged.
Capability negotiation (the back-compat guarantee): the gateway already advertises a capability list on OpenSessionReply.capabilities (field 5) — today exactly nine strings: unary-open-session, unary-close-session, unary-invoke, server-stream-events, bulk-subscribe-commands, bulk-read-commands, bulk-write-commands, unary-acknowledge-alarm, server-stream-active-alarms (Server/Grpc/MxAccessGatewayService.cs:59-67; bulk-subscribe-supervisory does not exist anywhere yet). The reply also carries gateway_protocol_version (field 8) — a capability string is still the right gate (feature-keyed, not version-keyed). Add:
reply.Capabilities.Add("bulk-subscribe-supervisory");
ScadaBridge reads the capability set at session open and only sets supervisory = true when bulk-subscribe-supervisory is present. Against an older gateway the capability is absent → ScadaBridge keeps today's AddItemBulk + per-item AdviseSupervisory path. This is the correctness gate: a plain advise on a WriteUserId==0 connection would silently break writes, so we must never send supervisory=true blind.
- ScadaBridge does not currently retain the capability list in the DCL adapter — verified:
RealMxGatewayClient.ConnectAsync(:83-84) doesOpenSessionAsyncthenRegisterAsyncand noCapabilitiesreference exists anywhere inDataConnectionLayer. This design adds: captureOpenSessionReply.capabilitiesinto theRealMxGatewayClientat open, expose aSupportsSupervisorySubscribeBulkpredicate.
Worker (Worker/MxAccess/…):
MxAccessSession.SubscribeBulkgains asupervisoryparameter; when true it calls the MXAccessAdviseSupervisory(and registersMxAccessAdviceKind.Supervisory, exactly as the per-item path at :319-331 does) instead of plainAdviseat :651-652 inside the same AddItem+advise loop. Per-item try/catch andAppendRemoveItemCleanuprollback are unchanged (partial success preserved). Long bulk commands hold the STA — keep the existingStaRuntime.PumpPendingMessages()(:94) behaviour the plain bulk path relies on.ExecuteSubscribeBulkpassessubscribeBulkCommand.Supervisorythrough (MxAccessCommandExecutor.cs:646). Reply shape (SubscribeBulkReply, per-item handles) is unchanged.
ScadaBridge (RealMxGatewayClient.SubscribeBulkAsync, :108):
- Supervisory branch (
UseSupervisoryAdvise:53,_writeUserId == 0): if the gateway advertises the capability, issue oneSubscribeBulkAsync(_serverHandle, tagPaths, supervisory: true, ct)instead ofAddItemBulkAsync(:128) + theBulkPipeline.RunAsync(:136-146) of per-itemEnsureSupervisoryAdvisedAsync(:142, method :591; parallelismMxSupervisoryAdviseParallelism= 16,DataConnectionOptions.cs:73). Result mapping (MapSubscribeResults, handle bookkeeping, failed-item handle eviction) reuses the existing plain-mode path. - Fallback branch unchanged. Delete the now-superseded "Cross-repo follow-up" comment (
RealMxGatewayClient.cs:126-127) since this design fulfills it. - Plain-advise mode (a configured write user) already used single-RPC
SubscribeBulkAsyncand is untouched.
Net effect: supervisory subscribe drops from 1 + N RPCs and N STA commands to 1 RPC / 1 STA command for N tags.
5. Lever 3 — explicit session command timeout (re-scoped in review)
Correction from the 2026-08-28 review: the gateway has no per-command timeout override. MxCommandRequest (proto :89-93) carries only session_id, client_correlation_id, command, and MxAccessGrpcRequestValidator.ValidateInvoke (:38-54) has no timeout handling. The only override is per session: OpenSessionRequest.command_timeout (field 4, google.protobuf.Duration), validated > 0 (MxAccessGrpcRequestValidator.cs:15), resolved by SessionManager.ResolveCommandTimeout (:583, called from :528) to MxGateway:Sessions:DefaultCommandTimeoutSeconds (default 30, SessionOptions.cs:8) when absent, then captured on GatewaySession.CommandTimeout (:223,293) and applied to every invoke on that session (:1151). The reply echoes the resolved value as OpenSessionReply.default_command_timeout (field 6). ScadaBridge's DCL never sets it and never reads it back (verified — no CommandTimeout anywhere in DataConnectionLayer; only _readTimeoutMs for reads).
Two ways to get a bulk-sized margin; (a) is chosen:
- (a) Session-scoped, ScadaBridge-only (chosen). New
DataConnectionOptions.MxCommandTimeout(default 60 s, validated> 0), sent asOpenSessionRequest.command_timeoutinRealMxGatewayClient.ConnectAsync. Trade: it governs every command on the session, writes included — a wedged write would take up to 60 s instead of 30 s to fault. Acceptable: ScadaBridge's ownWriteTimeoutstill bounds the script-facing write, and the gateway deadline is the backstop, not the primary. Log the echoeddefault_command_timeoutat open so a gateway that clamps or ignores the request is visible. - (b) Per-invoke override (cross-repo, deferred). Additive
google.protobuf.Duration command_timeout = 4onMxCommandRequest, honoured inGatewaySessioninvoke resolution, capability-advertised (per-command-timeout). Cleaner but a second wire change for a margin that Lever 2 makes mostly moot (a single-pass bulk subscribe completes well under a second). Revisit only if (a)'s write-side widening proves a problem.
This remains a margin, not a fix — it only stops a merely-slow-but-alive gateway from being treated as failed and re-storming.
6. Data flow (after)
InstanceActor.SendTagSubscribe(conn)
└─ in-flight guard: skip if a subscribe for conn is outstanding & within ceiling
└─ else: 1 SubscribeTagsRequest ─▶ DataConnectionActor
└─ RealMxGatewayClient.SubscribeBulkAsync(tagPaths)
├─ gateway advertises bulk-subscribe-supervisory?
│ yes → 1 RPC SubscribeBulk(supervisory=true) [session CommandTimeout = MxCommandTimeout, set at OpenSession]
│ └─ worker: AddItem + AdviseSupervisory per tag, ONE STA pass
│ no → legacy AddItemBulk + N×AdviseSupervisory (fallback)
└─ per-tag results ─▶ DCL resolves handles ─▶ OnDataChange flows
└─ Success reply → cancel timer, reset backoff to floor
└─ Failure/lost → backoff × jitter, re-arm (bounded by max interval)
7. Error handling & edge cases
- Old gateway / capability absent: ScadaBridge falls back to today's path; no behavior change, writes stay correct.
- Partial subscribe failure: unchanged — per-tag success/failure preserved end to end; failed items evict their handle mapping so a later retry re-adds cleanly.
- Lost subscribe reply: in-flight ceiling releases the guard after
TagSubscribeInFlightCeilingMs; a fresh subscribe then issues. No permanent stall. - Gateway restart mid-subscribe (the incident trigger): connection-level fault path (
DataConnectionActorreconnect state machine) is unchanged; on reconnect the first subscribe issues immediately (backoff reset), and with the in-flight guard the reconnect storm cannot stack. - Reconnect + backoff interaction: a connection restore must reset backoff to the floor so recovery is prompt (don't inherit a stretched interval from the pre-fault failures).
8. Testing
ScadaBridge (Lever 1):
- Unit (
InstanceActortests): a slow/never-answered subscribe issues exactly one in-flight request until the ceiling, not one per interval; backoff doubles on repeated failure and resets on success; a connection restore resets backoff. - Option validation:
TagSubscribeRetryMaxIntervalMs >= TagSubscribeRetryIntervalMs; ceiling> 0.
Cross-repo (Lever 2):
- Worker unit:
SubscribeBulk(supervisory:true)callsAdviseSupervisory(fake MXAccess session asserts advise kind); partial-failure and cleanup unchanged. - Gateway:
OpenSessionReply.capabilitiesincludesbulk-subscribe-supervisory. - ScadaBridge adapter: with capability present → single
SubscribeBulk(supervisory=true); absent → legacy fan-out. Handle bookkeeping identical across both paths (round-trip test). - Proto: additive-only guard (field 3 added, none renumbered).
Lever 3: RealMxGatewayClient.ConnectAsync sends OpenSessionRequest.command_timeout = MxCommandTimeout; the fake gateway asserts the value and the echoed default_command_timeout is logged; option validated > 0.
Live gate (QA rig, after deploy — not part of this doc's execution): reproduce the incident shape — redeploy Z28061 several times in quick succession while watching health site site-a; confirm successfullyResolved tracks to full without a Bad/frozen window, gateway log shows no AddItemBulk/AdviseSupervisory timed out, and (with Lever 2 active) the per-subscribe RPC count on the gateway session drops ~244×.
9. Rollout & sequencing
Levers are independent and independently shippable; recommended order:
- Lever 1 (ScadaBridge-only) — DONE 2026-08-28. Highest value / lowest blast radius — breaks the amplifier immediately; no gateway change. Ships and deploys as a ScadaBridge binary swap.
- Lever 2 (gateway first, then ScadaBridge). Deploy the gateway side (new capability + worker flag) before ScadaBridge starts requesting it — capability negotiation makes the order safe either way, but gateway-first means the optimization lights up as soon as ScadaBridge ships. Both are additive/wire-compatible.
- Lever 3 (ScadaBridge-only). Fold in with Lever 1 or Lever 2's ScadaBridge change; pure config surface.
No EF migrations, no schema, no new ports. MxAccessGateway proto regen uses the existing manual vendored-proto toggle in both repos.
Cross-repo index: update the ScadaBridge entry in ../scadaproj/CLAUDE.md if the OPC-UA/MxGateway wire relationship summary changes (per repo rule); update MxAccessGateway's own capability list docs for bulk-subscribe-supervisory.
10. Open questions
- Backoff defaults: 5 s floor → 60 s ceiling proposed; confirm the ceiling is acceptable for reconnect-recovery latency (a restore resets to floor, so steady-state recovery is unaffected).
- Lever 3 value: 60 s
MxCommandTimeoutassumes Lever 2 lands; without Lever 2 the legacy fan-out is N separate 30 s-bounded commands, so the session timeout barely matters for subscribe — it is the write-side widening (§5 trade) that decides whether 60 s is right. Size against the fallback path if shipping Lever 3 before Lever 2. - Incident record: write
docs/deployment/deployment-records/2026-08-26-wonder-app-vq03-vq04-freeze.md(or fold into the next QA deploy record) so the 2026-08-26 analysis is not only in a design doc. NativeAlarmActoris the same amplifier, still unguarded (final review of Lever 1, 2026-08-28).NativeAlarmActor.SendSubscribe(SiteRuntime/Actors/NativeAlarmActor.cs:147-165) arms a fixedNativeAlarmRetryIntervalMs × 2timer on send with no in-flight check and no backoff, andHandleSubscribeResponse(:319-337) never compares the correlation id — so a stale reply can cancel the timer guarding a newer request. One actor per native alarm source per instance, over the same DCL connections that wedged on 2026-08-26. Follow-up: port Lever 1'sTagSubscribeStateshape to it (or a Gitea issue) before the next incident finds it.- Cancel-race residue (Lever 1 M5):
ICancelable.Cancel()cannot retract aRetryTagSubscribealready in the mailbox, so a tick that races a Success issues one spurious full subscribe (self-healing, bounded to one). AConfirmedflag onTagSubscribeStatewould close it in ~4 lines; deferred until it is observed. - Should the in-flight guard also live in
DataConnectionActor(coalescing duplicate concurrent subscribe requests for the same connection across instances), as a second layer? Deferred —InstanceActoris where the storm originates and is sufficient; revisit only if multi-instance coalescing proves necessary.