Audit (three parallel agent passes) found 43 markdown files carrying stale references to the deleted Galaxy.Host/Proxy/Shared projects after the v2-mxgw merge. This commit lands the prioritized fixes. Track 1 — high-traffic in-place rewrites (3 files, ~454 lines deleted) - README.md (202 → 91 lines): drops .NET 4.8 / x86 / TopShelf install text; leads with the multi-driver .NET 10 server identity and points at scripts/install/Install-Services.ps1 and the parity rig. - docs/v2/driver-specs.md §1 Galaxy (~289 → ~66 lines): replaces the Tier-C out-of-process spec with a Tier-A in-process description matching the current GalaxyDriver code, with the four-section GalaxyDriverOptions JSON shape pulled verbatim from Config/GalaxyDriverOptions.cs. - docs/drivers/Galaxy.md (211 → 92 lines): full rewrite around the current Browse/Runtime/Health/Config sub-folders. Track 2 — historical banners (5 files) - lmx_mxgw.md, lmx_mxgw_impl.md, lmx_backend.md, docs/v2/Galaxy.ParityMatrix.md, docs/v2/implementation/phase-2-galaxy-out-of-process.md each get a "✅ Completed 2026-04-30 — historical record" banner block. lmx_mxgw.md also fixes two dead links (`docs/Galaxy.Driver.md` and `docs/v2/Galaxy.Driver.md`) → `docs/drivers/Galaxy.md`. Track 3 — v1 archive sweep (10 git mv + 1 new index + 2 in-place scrubs) - Moved 10 v1 docs under docs/v1/ preserving subpath structure: AlarmTracking, Configuration, DataTypeMapping, HistoricalDataAccess, Subscriptions (top-level); drivers/Galaxy-Repository, drivers/Galaxy-Test-Fixture; reqs/GalaxyRepositoryReqs, reqs/MxAccessClientReqs, reqs/ServiceHostReqs. - New docs/v1/README.md is the shared archive banner + per-file table. - docs/README.md repointed to the v1 paths and updated to reflect the v2 two-process deploy shape (Server + Admin + optional OtOpcUaWonderwareHistorian). - docs/v2/Galaxy.ParityRig.md got a historical banner + four inline scrubs marking the OtOpcUaGalaxyHost service / Driver.Galaxy.Host EXE / Driver.Galaxy.ParityTests project as deleted-in-PR-7.2. The repo's live-reading surface (README + CLAUDE.md + docs/v2/) now describes only the post-PR-7.2 architecture. v1 docs are preserved as a labelled archive under docs/v1/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5.8 KiB
Subscriptions
Driver-side data-change subscriptions live behind ISubscribable (src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ISubscribable.cs). The interface is deliberately mechanism-agnostic: it covers native subscriptions (Galaxy MXAccess advisory, OPC UA monitored items on an upstream server, TwinCAT ADS notifications) and driver-internal polled subscriptions (Modbus, AB CIP, S7, FOCAS). Core sees the same event shape regardless — drivers fire OnDataChange and Core dispatches to the matching OPC UA monitored items.
Driver vs virtual dispatch
Per ADR-002, DriverNodeManager routes subscriptions across both driver tags and virtual (scripted) tags through the same ISubscribable contract. The per-variable NodeSourceKind (registered from DriverAttributeInfo at discovery) selects the backend:
NodeSourceKind.Driver— subscribes via the driver'sISubscribable, wrapped byCapabilityInvoker(the rest of this doc).NodeSourceKind.Virtual— subscribes viaVirtualTagSource(src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs), which forwards change events emitted byVirtualTagEngineasOnDataChange. The ref-counting, initial-value, and transfer-restoration behaviour below applies identically.
Because both kinds expose ISubscribable, Core's dispatch, ref-count map, and monitored-item fan-out are unchanged across the source branch.
ISubscribable surface
Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences,
TimeSpan publishingInterval,
CancellationToken cancellationToken);
Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken);
event EventHandler<DataChangeEventArgs>? OnDataChange;
A single SubscribeAsync call may batch many attributes and returns an opaque handle the caller passes back to UnsubscribeAsync. The driver may emit an immediate OnDataChange for each subscribed reference (the OPC UA initial-data convention) and then a push per change.
Every subscribe / unsubscribe call goes through CapabilityInvoker.ExecuteAsync(DriverCapability.Subscribe, host, …) so the per-host pipeline applies.
Reference counting at Core
Multiple OPC UA clients can monitor the same variable simultaneously. Rather than open duplicate driver subscriptions, Core maintains a ref-count per (driver, fullReference) pair: the first OPC UA monitored-item for a reference triggers ISubscribable.SubscribeAsync with that single reference; each additional monitored-item just increments the count; decrement-to-zero triggers UnsubscribeAsync. Transferred subscriptions (client reconnect → resume session) replay against the same ref-count map so active driver subscriptions are preserved across session migration.
Threading
The STA thread story is now driver-specific, not a server-wide concern:
- Galaxy runs its MXAccess COM objects on a dedicated STA thread with a Win32 message pump (
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/StaPump.cs) inside the standaloneDriver.Galaxy.HostWindows service. The Proxy driver (Driver.Galaxy.Proxy) connects to the Host via named pipe and re-exposes the data on a free-threaded surface to Core. Core never touches COM. - Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS are free-threaded — they run their polling loops on ordinary
Tasks. TheirOnDataChangefires on thread-pool threads. - OPC UA Client delegates to the OPC Foundation stack's subscription loop.
The common contract: drivers are responsible for marshalling from whatever native thread the backend uses onto thread-pool threads before raising OnDataChange. Core's dispatch path acquires the OPC UA framework Lock and calls ClearChangeMasks on the corresponding BaseDataVariableState to notify subscribed clients.
Dispatch
Core's subscription dispatch path:
ISubscribable.OnDataChangefires on a thread-pool thread with aDataChangeEventArgs(subscriptionHandle, fullReference, DataValueSnapshot).- Core looks up the variable by
fullReferencein the driver'sDriverNodeManagervariable map. - Under the OPC UA framework
Lock, the variable'sValue/StatusCode/Timestampare updated andClearChangeMasks(SystemContext, false)is called. - The OPC Foundation stack then enqueues data-change notifications for every monitored-item attached to that variable, honoring each subscription's sampling + filter configuration.
Batch coalescing — coalescing multiple pushes for the same reference between publish cycles — is done driver-side when the backend natively supports it (Galaxy keeps the v1 coalescing dictionary); otherwise the SDK's own data-change filter suppresses no-change notifications.
Initial values
A freshly-built variable carries StatusCode = BadWaitingForInitialData until the driver delivers the first value. Drivers whose backends supply an initial read (Galaxy AdviseSupervisory, TwinCAT AddDeviceNotification) fire OnDataChange immediately after SubscribeAsync returns. Polled drivers fire the first push when their first poll cycle completes.
Transferred subscription restoration
When an OPC UA session is resumed (client reconnect with TransferSubscriptions), Core walks the transferred monitored-items and ensures every referenced (driver, fullReference) has a live driver subscription. References already active (in-process migration) skip re-subscribing; references that lost their driver-side handle during the session gap are re-subscribed via SubscribeAsync.
Key source files
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ISubscribable.cs— capability contractsrc/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs— pipeline wrappingsrc/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/StaPump.cs— Galaxy STA thread + message pump- Per-driver subscribe implementations in each
Driver.*project