NativeAlarmProjector dropped the pending local ack on EVERY driver transition, so an operator ack followed by the alarm returning to normal (a Clear carrying the optimistic ack) inside the confirmation window made a later "not confirmed" report a no-op — the condition stayed inactive+acked while Galaxy showed UNACK_RTN. Only a transition that states the ack (Acknowledge, ActiveAcknowledged, or an un-acking Raise/Retrigger/InactiveUnacknowledged) now settles it; a Clear / same-message Raise / Unspecified update leaves it revertible, and the revert projects inactive+unacked. Tests: projector + host end-to-end (ack, Clear, confirmation fails -> inactive + unacked); tightened the confirmed-ack positive controls; pinned that the confirmation receives the same request instance as AcknowledgeAsync. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
31 KiB
Galaxy Driver
The Galaxy driver bridges OtOpcUa to AVEVA System Platform (Wonderware) Galaxies. It is a Tier-A in-process driver that runs in the OtOpcUa server's .NET 10 AnyCPU process and speaks gRPC to a separately installed mxaccessgw server (sibling repo at c:\Users\dohertj2\Desktop\mxaccessgw\). The gateway owns the MXAccess COM apartment, the STA + Win32 message pump, the Galaxy Repository SQL reader, and the Historian SDK — all the bits that need x86 / .NET Framework 4.8 / COM interop. The driver itself is platform-agnostic and contains no COM, no STA thread, and no x86 bitness constraint.
For the driver spec (capability surface, config shape, addressing), see docs/v2/driver-specs.md §1. For the gateway setup recipe, see docs/v2/Galaxy.ParityRig.md. For tracing, metrics, and soak profile, see docs/v2/Galaxy.Performance.md.
Note
: the related docs
Galaxy-Repository.mdandGalaxy-Test-Fixture.mddescribe the previous v1 / out-of-process topology and now live underdocs/v1/drivers/. For current testing useGalaxy.ParityRig.mdand themxaccessgwrepo.
Architecture
+---------------------------------------+
| OtOpcUa.Server (.NET 10 AnyCPU) |
| GalaxyDriver (in-process) |
| ITagDiscovery / IReadable / |
| IWritable / ISubscribable / |
| IRediscoverable / |
| IHostConnectivityProbe / |
| IAlarmSource |
+-------------------+-------------------+
|
gRPC (default http://localhost:5120)
|
v
+---------------------------------------+
| mxaccessgw (sibling repo) |
| +-------------------------------+ |
| | MxGateway.Worker (x86 net48) | |
| | STA + WM_APP pump | |
| | ArchestrA.MxAccess COM | |
| | Galaxy Repository SQL | |
| | Wonderware Historian SDK | |
| +-------------------------------+ |
+---------------------------------------+
History reads are served server-side by IHistorianDataSource (HistorianGateway); Galaxy has no history capability of its own.
IAlarmSource was retired with PR 7.2 and restored in PR B.2 of the
alarms-over-gateway epic (docs/plans/alarms-over-gateway.md).
Alarm transitions arrive on the same gateway StreamEvents channel as
data-change events under the new MX_EVENT_FAMILY_ON_ALARM_TRANSITION
family; acknowledgements route through the gateway's
AcknowledgeAlarm RPC. The previous value-driven sub-attribute path
remains as a fallback for Galaxy templates without $Alarm*
extensions — the server-side AlarmConditionService dedups when both
paths fire on the same condition. See docs/AlarmTracking.md
for the v2-final architecture.
Project Layout
The driver ships as a single project: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ (.NET 10, AnyCPU). Sub-folders:
| Folder | Role |
|---|---|
Browse/ |
Static-side discovery: GalaxyDiscoverer walks the gateway's hierarchy + attribute-set RPCs, DataTypeMap and SecurityMap translate Galaxy types and security classifications into OPC UA equivalents, AlarmRefBuilder extracts alarm-bearing attribute references for the server-layer alarm engine. IGalaxyHierarchySource + GatewayGalaxyHierarchySource + TracedGalaxyHierarchySource decorate the gateway browse RPC; IGalaxyDeployWatchSource + GatewayGalaxyDeployWatchSource + DeployWatcher drive IRediscoverable. |
Runtime/ |
Live data path: EventPump runs the gateway's StreamEvents RPC and fans out to subscribers via a bounded channel; GalaxyMxSession is the read-side handle; GatewayGalaxySubscriber + GatewayGalaxyDataWriter (each with a Traced* decorator) implement ISubscribable / IWritable; SubscriptionRegistry tracks subscription state for replay; ReconnectSupervisor owns the backoff loop and triggers ReplaySubscriptions on session loss; StatusCodeMap translates gateway StatusCodes to OPC UA; MxValueDecoder / MxValueEncoder handle scalar + array marshalling; GalaxyTelemetry + GalaxySubscriptionHandle round out the surface. |
Health/ |
HostStatusAggregator rolls per-platform probe state into the driver's IHostConnectivityProbe view; PerPlatformProbeWatcher listens on the gateway's per-host status stream; HostConnectivityForwarder pushes transitions out to the server's connectivity bus. |
Config/ |
GalaxyDriverOptions and the four nested option records (GalaxyGatewayOptions, GalaxyMxAccessOptions, GalaxyRepositoryOptions, GalaxyReconnectOptions). |
Project root files:
GalaxyDriver.cs—IDriver+ capability-interface implementation; composes the Browse / Runtime / Health collaborators.GalaxyDriverFactoryExtensions.cs— DI registration helper used by the server's driver bootstrap.
Capability Surface
GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IDisposable, IAsyncDisposable.
| Capability | Implementation entry point |
|---|---|
ITagDiscovery |
Browse/GalaxyDiscoverer.cs |
IRediscoverable |
Browse/DeployWatcher.cs — consumed as an operator prompt: a Galaxy redeploy raises a "re-browse" chip on /hosts. ⚠️ It does not rebuild the address space — re-browse the device under /raw and commit (#518) |
IReadable |
Runtime/GalaxyMxSession.cs |
IWritable |
Runtime/GatewayGalaxyDataWriter.cs |
ISubscribable |
Runtime/GatewayGalaxySubscriber.cs (driven by EventPump) |
IHostConnectivityProbe |
Health/HostStatusAggregator.cs |
IAlarmSource |
Runtime/GatewayGalaxyAlarmFeed.cs (transitions) + Runtime/GatewayGalaxyAlarmAcknowledger.cs (acks) |
Configuration
DriverConfig JSON binds to Config/GalaxyDriverOptions.cs. The four sections are:
Gateway— endpoint, API key secret ref (secret:galaxy/<name>·secret:otopcua/galaxy/<name>·env:OTOPCUA_GALAXY_<NAME>· legacyenv:GALAXY_MXGW_<NAME>·dev:KEY· literal; anything else, incl.file:, is refused — #815 (an already-deployed driver keeps resolving a pre-#815 ref on init and every gateway reconnect in deprecated warn-only mode until its next deploy),docs/security.md§"Credential references are namespaced per driver"), TLS knobs, connect/call/stream timeouts.StreamTimeoutSeconds = 0keeps the long-livedStreamEventsRPC open for the driver's lifetime.MxAccess—ClientName(must be unique per OtOpcUa instance — redundancy pairs enforce uniqueness at install time),PublishingIntervalMs(forwarded asbuffered_update_interval_mson subscribe),WriteUserIdfor ArchestrA secured-write,EventPumpChannelCapacity(default 50_000 — one second of headroom at 50k tags / 1Hz; tune via thegalaxy.events.droppedmetric).Repository—DiscoverPageSize,WatchDeployEvents.Reconnect—InitialBackoffMs,MaxBackoffMs,ReplayOnSessionLost(defaulttrue: after a gateway session is recreated, re-issueSubscribeBulkfor every tracked subscription;falseleaves them stranded — Bad + Degraded, see Reconnect + Replay).AlarmAck(optional, lmxopcua#749) —FallbackUser,FallbackDomain,FallbackPasswordSecretRef. See Alarm acknowledge credential.ConfirmTimeoutMs(default10000, max120000,0= do not wait, negative = default; #869) — see Ack confirmation.
Alarm acknowledge credential
mxaccessgw 488649c+ (client/contracts 0.3.0) acknowledges a native alarm by running
AuthenticateUser as the operator and writing the comment to <alarm>.AckMsg, so the Historian
ACK_ALM row records a real Galaxy account. AcknowledgeAlarmRequest therefore carries
operator_user + operator_domain + operator_password, and the gateway rejects an empty
operator_user. The OPC UA data plane is machine-to-machine (API key or anonymous) and carries no
Galaxy password, so GalaxyAckCredentialResolver picks the account per acknowledge:
- The API key's own Galaxy ack identity — set on AdminUI
/apikeys("Ack identity"). Stored only in the encrypted Secrets store asotopcua/apikeys/<keyId>/galaxy-ack-user(DOMAIN\useroruser) andotopcua/apikeys/<keyId>/galaxy-ack-password(GalaxyAckIdentitySecrets) — never in the config DB or the deployment artifact. A key with a user but no password secret is refused, not silently downgraded to the fallback. - The driver's fallback account —
AlarmAck.FallbackUser/FallbackDomain/FallbackPasswordSecretRefinDriverConfig, used by keys without an identity and by anonymous sessions. The password ref must besecret:galaxy/NAME/secret:otopcua/galaxy/NAME(or the${secret:…}spelling; production),env:OTOPCUA_GALAXY_NAME/env:GALAXY_MXGW_NAME, ordev:VALUE; a bare literal, afile:reference or a reference outside the Galaxy namespace is refused (#815) — the password is sent to the authored gateway endpoint, so it must not be able to name another subsystem's secret. - Neither → the driver throws
DriverOperationRefusedException; nothing is sent to the gateway. The refusal is excluded from the resilience breaker (it says nothing about gateway health). How the client learns of it (#760):- Synchronously when the driver can tell without I/O, on a driver with no fallback account —
an anonymous session (#760), or a keyed session whose key has no identity (#765): the Part 9
Acknowledgecall itself failsBadUserAccessDeniedwith the reason, and nothing is applied (IAlarmAcknowledgePreflight, published per condition byDriverHostActorintoNativeAlarmAckPreflightView). The keyed case readsGalaxyAckIdentityDirectory, a node-wide cached set of the key ids that have agalaxy-ack-usersecret, listed from this node's Secrets store (metadata only — nothing decrypted) everyDrivers:Galaxy:AckIdentityCache:RefreshIntervalSeconds(default 30) byGalaxyAckIdentityRefreshService, and early whenever an identity secret changes in-process (the Secrets cache invalidator is decorated, so AdminUI/api-keys,/admin/secretsand replication all signal it) or a refusal asks for one. ⚠️ It fails open: before the first refresh, once the snapshot is older thanStaleAfterSeconds(default three intervals — e.g. the store cannot be listed), and from a signalled identity change until the refresh that follows it, every keyed ack takes the revert path below instead of being refused. An identity written straight into the store by thesecretCLI is seen at the next refresh; the refusal itself requests one, so a retry a moment later goes through. - By revert otherwise — a keyed session the directory cannot vouch for (see above), a key whose
identity is broken (a user with no password secret), or MXAccess rejecting the account (a non-zero
native
hresult, e.g. a wrong password, now thrown as a refusal): the call returnedGood, thenDriverHostActorre-projects the condition unacknowledged (a fresh condition event,Retain=true) and logs the reason. A driver transition projected after the ack that states the ack state wins over a late failure; a Clear only carries it through and leaves it revertible (#869).
- Synchronously when the driver can tell without I/O, on a driver with no fallback account —
an anonymous session (#760), or a keyed session whose key has no identity (#765): the Part 9
Ack confirmation (#869)
The gateway answers AcknowledgeAlarm OK with hresult 0 in two cases: the AckMsg write's
OnWriteComplete reported success (applied — empty diagnostic_message), or the write was sent and no
completion arrived inside the worker's window (sent but unconfirmed — diagnostic_message says so,
e.g. AckMsg write to '…' sent; OnWriteComplete not observed within 5000 ms.). The H6 live gate
(2026-11-11, VD03) caught the second case: OtOpcUa showed the condition Acknowledged while Galaxy's
.Acked stayed False and no ACK row was ever historized.
GatewayGalaxyAlarmAcknowledger.EvaluateReplyreturns Applied for an OK reply with no diagnostic and Unconfirmed for one with a diagnostic (any unexplained diagnostic counts — the safe direction), and logs the gateway diagnostic on every branch that carries one.GalaxyAckConfirmationTrackerregisters a watch for the alarm before the RPC (the alarm feed can deliver the confirming transition ahead of the unary reply) and, for an unconfirmed reply, waits up toAlarmAck.ConfirmTimeoutMsfor anAcknowledge/ActiveAcknowledgedtransition of that alarm on the alarm feed (live or snapshot re-statement; the reference match is case-insensitive). Whoever acked it, Galaxy now shows it acknowledged — that is the truth the condition must match.- The wait is the optional
IAlarmAcknowledgeConfirmationcapability.DriverInstanceActorcalls it afterAcknowledgeAsyncreturned, off its mailbox (a detached task) and outside the resilience pipeline (a wait on an upstream event: it must not be cut by the Tier-A 5 sAlarmAcknowledgetimeout nor count toward the breaker). The OPC UA method thread and the node-managerLockare never involved — the method already returned when the fire-and-forget route started. - On timeout the confirmation throws
DriverOperationRefusedException(… not confirm … (BadTimeout; gateway: <diagnostic>)), the driver logs a Warning with the diagnostic, and the actor reportsAlarmAckFailed: the host re-projects the condition unacknowledged — the same #760 revert path. What the OPC UA client sees: theAcknowledgecall has already returnedGood(it cannot be changed after the fact, exactly as for every other #760 revert); the failure reaches the client as a fresh condition event withAckedState = false, and theBadTimeoutverdict is recorded in the driver and host Warning logs. A confirming transition that arrives after the revert simply re-acknowledges the condition. A confirmation in flight when the driver actor stops is abandoned and reverted the same way. - Limits to know:
- A gateway reconnect detaches the feed; an ack in flight across it is confirmed only if the new feed's
snapshot re-statement (
ActiveAcknowledged) arrives inside the window, otherwise it reverts (fails safe). - Acking an alarm that already returned to normal (
UNACK_RTN) is confirmed by the worker'sAcknowledge(ACK_RTN) transition; if the gateway reconciles it as aClearinstead, nothing confirms and the correctly-applied ack reverts to unacked. Unverified on hardware — include it in the next live gate. - The RPC itself still runs under the actor's 5 s ack bound and the Tier-A
AlarmAcknowledgetimeout (5 s). The gateway worker's write-completion window (DefaultWriteCompletionTimeout, 1.5 s) plusAuthenticateUsermust stay well under that, or the unconfirmed reply is never seen and the ack reverts as a timeout that also counts toward the breaker.
- A gateway reconnect detaches the feed; an ack in flight across it is confirmed only if the new feed's
snapshot re-statement (
ConfirmTimeoutMs = 0restores the pre-#869 behaviour (an unconfirmed ack counts as acknowledged, with a Warning) — an escape hatch only. Why the gateway-side ack did not stick on VD03 is tracked upstream (mxaccessgw#226).
The comment written to AckMsg is suffixed with the OPC UA identity —
"<comment> [OPC UA: <principal>, key <keyId>]" or "[OPC UA: anonymous]" — so the client is not
lost when the Historian's Operator is a shared fallback account. The password is never logged: the
acknowledger scrubs it from every gateway message it echoes and GalaxyAckCredential.ToString()
omits it.
⚠️ Secrets are node-local (optionally Akka-replicated within one mesh). The per-key secrets and
the fallback secret must exist on the node that serves the OPC UA session and hosts the Galaxy
driver; on a split admin/driver topology create them on the driver nodes too (/admin/secrets or
the secret CLI) under exactly those names.
Full per-field descriptions live in Config/GalaxyDriverOptions.cs. The full JSON skeleton is reproduced in docs/v2/driver-specs.md §1.
Reconnect + Replay
ReconnectSupervisor owns an exponential-backoff loop bounded by Reconnect.InitialBackoffMs / MaxBackoffMs. On session loss it calls GalaxyDriver.ReopenAsync, which invokes GalaxyMxSession.RecreateAsync to dispose the stale/faulted session and client before rebuilding (OpenSessionAsync + RegisterAsync). Previously ConnectAsync was a no-op when a stale session handle was still present, so the reopen supervisor looped forever without recovering. After a successful reopen — when ReplayOnSessionLost = true (the default) — ReplayAsync re-issues SubscribeBulk for every subscription still holding the previous session's handles and rebinds the registry with the new ones (the gateway's batched ReplaySubscriptions RPC is not used).
ReplayOnSessionLost = false strands the subscriptions, visibly (#862). A recreated gateway session carries none of the old session's advises, so with replay disabled every subscription that existed before the reconnect stays registered but receives nothing. That used to be silent — each node kept its last value as Good and health returned to Healthy; the only trace was a Warning. Now, following the quality contract:
- every tag of every stranded subscription is published
BadNoCommunicationonce per reconnect, under that subscription's own handle (value null — the driver keeps no value cache, and a Bad sample's value is not client-observable; the next Good value comes from MXAccess's initial value when the tag is advised again); GetHealth()reportsDegradedwith a message namingReconnect.ReplayOnSessionLost=falseand the stranded count, after the supervisor's own reconnect Degraded has ended;LastSuccessfulReadis not advanced by those publishes. If no other data flows (no other live subscription), the host's comms-loss watchdog sees Degraded + a frozenLastSuccessfulReadand re-initializes the driver after 90 s — which re-subscribes everything, i.e. a delayed replay. SetReplayOnSessionLost = trueto recover immediately instead;- Degraded clears when the last stranded subscription is unsubscribed (the host unsubscribes the old handle on every data re-subscribe, e.g. a deploy, and on re-init) or the runtime is torn down. A
SubscribeAsyncissued after the reconnect is an ordinary new-session subscription: it receives data normally and does not count as stranded.
The per-platform ScanState probe watcher is not replayed when ReplayOnSessionLost = false either (unchanged), so /hosts per-platform status also stops updating until a re-init.
The session-less alarm feed (GatewayGalaxyAlarmFeed) and alarm acknowledger (GatewayGalaxyAlarmAcknowledger) run on a separate always-on _ownedMxClient that is intentionally not recreated when ReconnectSupervisor rebuilds the worker session. The feed has its own re-invoke loop (~5 s backoff) that reopens StreamAlarms after any stream fault, and gRPC.NET's channel auto-reconnect recovers the underlying HTTP/2 connection after a gateway restart — so both the alarm stream and individual ack calls recover without client replacement. The acknowledger is completely stateless between calls: each AcknowledgeAsync issues a single unary RPC and returns; no dead-client state is latched on failure. Channel-level keepalive hardening (TCP keep-alive intervals, gRPC ping frames) would require exposing additional knobs on MxGatewayClientOptions in the sibling mxaccessgw repo — a future option if flaky long-lived connections are observed in production.
Every (re)open of StreamAlarms replays the gateway's active-alarm snapshot. GatewayGalaxyAlarmFeed maps each entry to a transition flagged IsRestatement (#656): Active → Retrigger (upstream's unack is authoritative), ActiveAcked → ActiveAcknowledged (one atomic active+acked projection — the old Acknowledge mapping projected an acked alarm inactive on a cold host), Inactive → Clear. The host writes no /alerts row for a replayed entry that changes nothing the condition shows, so a gateway reconnect no longer duplicates history; live Transition messages are never flagged. The gateway folds UnackRtn and AckRtn into Inactive, so an inactive-unacked alarm cannot be distinguished at reconnect (gateway contract limit).
Testing
- Unit tests:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/— fakes the gateway gRPC surface; covers Browse, Runtime, Health, and Config in isolation. - Parity rig + dev-rig walkthrough: see docs/v2/Galaxy.ParityRig.md. The rig stands up a real
mxaccessgwagainst a live Galaxy and exercises the full read / write / subscribe / rediscover path. - Performance + soak: see docs/v2/Galaxy.Performance.md.
Browse Tree Shape
GalaxyDiscoverer builds the OPC UA address space as a true nested tree by parent_gobject_id: each gobject folder is parented under its Galaxy parent folder rather than being emitted flat under the driver root. Parentage falls back gracefully — a gobject with parent_gobject_id of 0, a parent not present in the returned set, a self-referencing parent, or a cycle attaches directly to the driver root. This matches the Galaxy ArchestrA object hierarchy as seen in the IDE.
Operational Notes
- MXAccess
ClientNamecollisions: two OtOpcUa instances sharing aClientNamecause the older Wonderware session to lose subscription state. Redundancy pairs (decision #149) enforce uniqueness via install scripts. - Channel saturation:
galaxy.events.dropped > 0indicatesEventPumpis back-pressured. RaiseEventPumpChannelCapacityor investigate downstream slowness in the server-side fan-out. - Connectivity surface: per-platform probe state is exposed through
IHostConnectivityProbeand aggregated by the server's connectivity bus — there is no driver-private dashboard surface anymore. The Admin UI's Host Status panel is the consumer. - A stream the gateway ends cleanly is treated as a fault. A closed or evicted gateway session completes the
StreamEventsRPC without an error, so nothing throws.EventPumpreports that to theReconnectSupervisorexactly as it reports a transport fault. Before this it exited silently: the supervisor was never told,GetHealth()kept reportingHealthy, and because the comms-loss watchdog requires a non-Healthy state to trip, it never fired either — the driver went permanently deaf with no signal anywhere. Nothing else in the system can observe this, so do not remove the notification on the grounds that "a clean end isn't an error". - Replay gaps (
galaxy.events.replay_gaps): the gateway signals that events were evicted from its replay ring before we could read them. The driver responds by re-issuingSubscribeBulkfor every tracked subscription — MXAccess delivers an initial value per item on subscribe, so re-subscribing is the re-snapshot the contract asks for. It does not restart the event pump (the stream carrying the sentinel is healthy) and it honoursReconnect:ReplayOnSessionLost=falseby logging and standing down, on the grounds that an operator who delegated recovery to the gateway did not ask for a re-subscribe on a different trigger. ⚠️ This cannot currently fire: the sentinel is only emitted on a stream opened withafter_worker_sequence > 0, and the driver opens with0, which the gateway treats as "fresh stream, not a resume". Enabling resume is a bigger change than it looks — the sequence is only meaningful within one gateway session, and the reconnect path recreates the session, so a remembered sequence replayed against a fresh session would set the server's live watermark above every event it is about to send, silencing the stream rather than recovering it. - Undecodable values (
galaxy.events.undecodable): a value kind this driver has no CLR representation for is published asBadTypeMismatchwith a null value, and the first occurrence of each distinct kind is logged. It is not published as a null carrying the event's own quality: an MXAccess event can be Good-quality while the payload is one we cannot map, and a good null is indistinguishable from a genuine null reading — a client would consume a value it never received as a real one, with the failure visible only in a log. Non-zero here means the gateway is sending a wire typeMxValueDecoderhas no mapping for and those readings are being lost; the fix is a decoder mapping, not a quality change. - Initial values can overtake the subscribe reply (#859) (
galaxy.events.early_parked/early_replayed/early_overflow): mxaccessgw gives no reply-before-event ordering — the worker frames the SubscribeBulk reply on a thread-pool continuation while the STA is already pumping the OnDataChange that Advise queued, and the reply (unary Invoke) and the event (StreamEvents) are separate HTTP/2 streams anyway. An event for an unregistered handle is therefore only "stale after unsubscribe" when no subscribe is in flight. Every SubscribeBulk call site (SubscribeAsync, the subscribe-onceReadAsync— which waits only for the initial value and would otherwise readBadTimeout— the reconnect/replay-gapResubscribeAllAsync(one window per subscription, not across the whole replay),PerPlatformProbeWatcher) opensSubscriptionRegistry.BeginPendingSubscribe()before the RPC and closes it right after registering, never later; inside the window unknown-handle events are parked, promoted by theRegister/Rebindthat binds the handle — map writes and promotion are one critical section, so no later event can resolve in between — and replayed on the pump's dispatch thread before any later event for that handle. Held events (parked + promoted-but-unreplayed) are bounded at 100 000 together. Unclaimed parked events are trimmed by window sequence, not only when every window has closed (concurrent reads keep some window open almost permanently): each window gets a monotonically increasing sequence when it opens, each parked event is stamped with the newest sequence, and once every window that opened at or before that stamp has closed the event is discarded — outside any window the stale drop is unchanged. Sessions (#861): item handles are per gateway session and the next session reuses the numbers. On reconnectReopenAsynccallsSubscriptionRegistry.StartNewSession()both before and after the gateway session swap (ReplayAsyncdoes it only when no reopen ran), and the replay re-subscribes only subscriptions still holding the previous session's handles (one registered after the swap is already live). The pump is always restarted, even with nothing to replay.StartNewSession()discards everything held, invalidates the reverse map (subscriptions are kept; new-session events park until the replay's per-subscriptionRebindpromotes them, instead of resolving to the tag that owned the number before), and bumps a session number. Registration and rebind are refused for a stale session, so aSubscribeAsyncwhose reply straddled the reconnect is re-issued on the new session (up to 3 attempts), a subscribe-once read returnsBadCommunicationError, and dead old-session handle numbers are never sent toUnsubscribeBulk(a read rebound by the replay releases the handles the registry holds, not its original ones); an old session's pump can no longer resolve through the map. Reads listen on the driver, not on a pump instance, so a read survives the reconnect's pump restart.PerPlatformProbeWatcherrecords the session with each probe handle: only current-session handles are published (fencedTryUpsert), unsubscribed or purged, and a seed reply that straddled a reconnect is re-issued. Purges are fenced by the session captured before the unsubscribe RPC. Interim (I-3): a completedUnsubscribeBulkpurges anything parked for its handles, so a trailing event of a removed item is not promoted onto a reused handle number within one session. It cannot catch an event still in flight after the unsubscribe, and it also drops a genuine early value for a number reused that fast (a timeout rather than another tag's value). The complete fix needs a worker sequence on the SubscribeBulk reply so the driver can tell pre-subscribe events from post-subscribe ones — an optional cross-repo change in mxaccessgw. - Duplicate
attributeRef(#605 / 06-S-13): two authored RawPaths dialling the same Galaxy attribute are both dialled, but the reverse map is first-wins, so value fan-out and alarmConditionIdsurface under the first RawPath only. The loser is logged at Warning and recorded in the skipped-tag ledger with the winner path in the reason. - Subscribe-once reads are bounded (#605 / 06-S-3):
ReadAsyncon the production path (SubscribeBulk, first event, Unsubscribe) carries an internal 30 s deadline in addition to the caller token; slots that never publish come backBadTimeout, soCancellationToken.Nonecan no longer hang on a silent tag. - Supervisory advise is retried after a thrown failure (#605 / 06-S-12): the writer marks an item handle advised before the call; a thrown advise (not just a non-OK status) now unmarks it, fails the write closed and re-advises on the next write.
- Array writes (#610): a Galaxy array tag is client-writable only for the
MxValueEncoderarray types (OPC UABoolean/Int32/Int64/Float/Double/String/DateTime) and only with a declaredarrayLength— with none the node is unbounded, and a short whole-array write would resize the attribute. The element type must match the node or be a numeric type that converts to it exactly — a numeric array of another element type is converted, checked, to the node's type in the node manager (anInt32list to anInt64array arrives aslong[]), and any unrepresentable element refuses the writeBadTypeMismatchinline. An array written to a scalar Galaxy node (a browse-committed array attribute withoutisArray) is still dispatched, as before #610. Separately,MxValueEncoderitself refusesuint[]/ulong[]: CLR array covariance let them match theint[]/long[]arms and be written bit-reinterpreted. Encoder-level only — Galaxy array writes have no live gate yet. See Uns.md §Array writes, including the upgrade note.