diff --git a/docs/AddressSpace.md b/docs/AddressSpace.md index db1ebf1..2c0e3d2 100644 --- a/docs/AddressSpace.md +++ b/docs/AddressSpace.md @@ -64,6 +64,12 @@ For array attributes, the `[]` suffix present in `full_tag_reference` is strippe The hierarchy query returns objects ordered by `parent_gobject_id, tag_name`, but this does not guarantee that a parent appears before all of its children in all cases. `LmxNodeManager.TopologicalSort` performs a depth-first traversal to produce a list where every parent is guaranteed to precede its children. This allows the build loop to look up parent nodes from `_nodeMap` without forward references. +## Platform Scope Filtering + +When `GalaxyRepository.Scope` is set to `LocalPlatform`, the hierarchy and attributes passed to `BuildAddressSpace` are pre-filtered by `PlatformScopeFilter` inside `GalaxyRepositoryService`. The node manager receives only the local platform's objects and their ancestor areas, so the resulting browse tree is a subset of the full Galaxy. The filtering is transparent to `LmxNodeManager` — it builds nodes from whatever data it receives. + +Clients browsing a `LocalPlatform`-scoped server will see only the areas and objects hosted by that platform. Areas that exist in the Galaxy but contain no local descendants are excluded. See [Galaxy Repository — Platform Scope Filter](GalaxyRepository.md#platform-scope-filter) for the filtering algorithm and configuration. + ## Incremental Sync On address space rebuild (triggered by a Galaxy deploy change), `SyncAddressSpace` uses `AddressSpaceDiff` to identify which `gobject_id` values have changed between the old and new snapshots. Only the affected subtrees are torn down and rebuilt, preserving unchanged nodes and their active subscriptions. Affected subscriptions are snapshot before teardown and replayed after rebuild. diff --git a/docs/Configuration.md b/docs/Configuration.md index 9c19183..9c9b51e 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -88,6 +88,8 @@ Controls the Galaxy repository database connection used to build the OPC UA addr | `ChangeDetectionIntervalSeconds` | `int` | `30` | How often the service polls for Galaxy deploy changes | | `CommandTimeoutSeconds` | `int` | `30` | SQL command timeout for repository queries | | `ExtendedAttributes` | `bool` | `false` | Load extended Galaxy attribute metadata into the OPC UA model | +| `Scope` | `GalaxyScope` | `"Galaxy"` | Controls how much of the Galaxy hierarchy is loaded. `Galaxy` loads all deployed objects (default). `LocalPlatform` loads only objects hosted by the platform deployed on this machine. See [Galaxy Repository — Platform Scope Filter](GalaxyRepository.md#platform-scope-filter) | +| `PlatformName` | `string?` | `null` | Explicit platform hostname for `LocalPlatform` filtering. When null, uses `Environment.MachineName`. Only used when `Scope` is `LocalPlatform` | ### Dashboard @@ -242,6 +244,7 @@ Three boolean properties act as feature flags that control optional subsystems: - **`OpcUa.AlarmFilter.ObjectFilters`** -- List of wildcard template-name patterns that scope alarm tracking to matching objects and their descendants. An empty list preserves the current unfiltered behavior; a non-empty list includes an object only when any name in its template derivation chain matches any pattern, then propagates the inclusion to every descendant in the containment hierarchy. `*` is the only wildcard, matching is case-insensitive, and the Galaxy `$` prefix on template names is normalized so operators can write `TestMachine*` instead of `$TestMachine*`. Each list entry may itself contain comma-separated patterns (`"TestMachine*, Pump_*"`) for convenience. When the list is non-empty but `AlarmTrackingEnabled` is `false`, the validator emits a warning because the filter has no effect. See [Alarm Tracking](AlarmTracking.md#template-based-alarm-object-filter) for the full matching algorithm and telemetry. - **`Historian.Enabled`** -- When `true`, the service calls `HistorianPluginLoader.TryLoad(config)` to load the `ZB.MOM.WW.LmxOpcUa.Historian.Aveva` plugin from the `Historian/` subfolder next to the host exe and registers the resulting `IHistorianDataSource` with the OPC UA server host. Disabled by default because not all deployments have a Historian instance -- when disabled the plugin is not probed and the Wonderware SDK DLLs are not required on the host. If the flag is `true` but the plugin or its SDK dependencies cannot be loaded, the server still starts and every history read returns `BadHistoryOperationUnsupported` with a warning in the log. - **`GalaxyRepository.ExtendedAttributes`** -- When `true`, the repository loads additional Galaxy attribute metadata beyond the core set needed for the address space. Disabled by default to minimize startup query time. +- **`GalaxyRepository.Scope`** -- When set to `LocalPlatform`, the repository filters the hierarchy and attributes to only include objects hosted by the platform whose `node_name` matches this machine (or the explicit `PlatformName` override). Ancestor areas are retained to keep the browse tree connected. Default is `Galaxy` (load everything). See [Galaxy Repository — Platform Scope Filter](GalaxyRepository.md#platform-scope-filter). ## Configuration Validation @@ -319,7 +322,9 @@ Integration tests use this constructor to inject substitute implementations of ` "ConnectionString": "Server=localhost;Database=ZB;Integrated Security=true;", "ChangeDetectionIntervalSeconds": 30, "CommandTimeoutSeconds": 30, - "ExtendedAttributes": false + "ExtendedAttributes": false, + "Scope": "Galaxy", + "PlatformName": null }, "Dashboard": { "Enabled": true, diff --git a/docs/GalaxyRepository.md b/docs/GalaxyRepository.md index 5bd3fc7..06305d9 100644 --- a/docs/GalaxyRepository.md +++ b/docs/GalaxyRepository.md @@ -12,6 +12,8 @@ | `ChangeDetectionIntervalSeconds` | `30` | Polling frequency for deploy change detection | | `CommandTimeoutSeconds` | `30` | SQL command timeout for all queries | | `ExtendedAttributes` | `false` | When true, loads primitive-level attributes in addition to dynamic attributes | +| `Scope` | `Galaxy` | `Galaxy` loads all deployed objects. `LocalPlatform` filters to the local platform's subtree only | +| `PlatformName` | `null` | Explicit platform hostname for `LocalPlatform` filtering. When null, uses `Environment.MachineName` | The connection uses Windows Authentication because the Galaxy Repository database is local to the System Platform node and secured through domain credentials. @@ -69,6 +71,54 @@ The Galaxy maintains two package references for each object: The queries filter on `deployed_package_id <> 0` because the OPC UA server must mirror what is actually running in the Galaxy runtime. Using `checked_in_package_id` would expose attributes and objects that exist in the IDE but have not been deployed, causing mismatches between the OPC UA address space and the MXAccess runtime. +## Platform Scope Filter + +When `Scope` is set to `LocalPlatform`, the repository applies a post-query C# filter to restrict the address space to objects hosted by the local platform. This reduces memory footprint, MXAccess subscription count, and address space size on multi-node Galaxy deployments where each OPC UA server instance only needs to serve its own platform's objects. + +### How it works + +1. **Platform lookup** -- A separate `const string` SQL query (`PlatformLookupSql`) reads `platform_gobject_id` and `node_name` from the `platform` table for all deployed platforms. This runs once per hierarchy load. + +2. **Platform matching** -- The configured `PlatformName` (or `Environment.MachineName` when null) is matched case-insensitively against the `node_name` column. If no match is found, a warning is logged listing the available platforms, and the address space is empty. + +3. **Host chain collection** -- The filter collects the matching platform's `gobject_id`, then iterates the hierarchy to find all `$AppEngine` (category 3) objects whose `HostedByGobjectId` equals the platform. This produces the full set of host gobject_ids under the local platform. + +4. **Object inclusion** -- All non-area objects whose `HostedByGobjectId` is in the host set are included, along with the hosts themselves. + +5. **Area retention** -- `ParentGobjectId` chains are walked upward from included objects to pull in ancestor areas, keeping the browse tree connected. Areas that contain no local descendants are excluded. + +6. **Attribute filtering** -- The set of included `gobject_id` values is cached after `GetHierarchyAsync` and reused by `GetAttributesAsync` to filter attributes to the same scope. + +### Design rationale + +The filter is applied in C# rather than SQL because the project convention `GR-006` requires `const string` SQL queries with no dynamic SQL. The hierarchy query already returns `HostedByGobjectId` and `CategoryId` on every row, so all information needed for filtering is already in memory after the query runs. The only new SQL is the lightweight platform lookup query. + +### Configuration + +```json +"GalaxyRepository": { + "Scope": "LocalPlatform", + "PlatformName": null +} +``` + +- Set `Scope` to `"LocalPlatform"` to enable filtering. Default is `"Galaxy"` (load everything, backward compatible). +- Set `PlatformName` to an explicit hostname to target a specific platform, or leave null to use the local machine name. + +### Startup log + +When `LocalPlatform` is active, the startup log shows the filtering result: + +``` +GalaxyRepository.Scope="LocalPlatform", PlatformName=MYNODE +GetHierarchyAsync returned 49 objects +GetPlatformsAsync returned 2 platform(s) +Scope filter targeting platform 'MYNODE' (gobject_id=1042) +Scope filter retained 25 of 49 objects for platform 'MYNODE' +GetAttributesAsync returned 4206 attributes (extended=true) +Scope filter retained 2100 of 4206 attributes +``` + ## Change Detection Polling `ChangeDetectionService` runs a background polling loop that calls `GetLastDeployTimeAsync` at the configured interval. It compares the returned timestamp against the last known value: @@ -87,5 +137,7 @@ The polling approach is used because the Galaxy Repository database does not pro ## Key source files - `src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs` -- SQL queries and data access +- `src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/PlatformScopeFilter.cs` -- Platform-based hierarchy and attribute filtering - `src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/ChangeDetectionService.cs` -- Deploy timestamp polling loop -- `src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs` -- Connection and polling settings +- `src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs` -- Connection, polling, and scope settings +- `src/ZB.MOM.WW.LmxOpcUa.Host/Domain/PlatformInfo.cs` -- Platform-to-hostname DTO diff --git a/docs/stability-review-20260413.md b/docs/stability-review-20260413.md deleted file mode 100644 index ad48fae..0000000 --- a/docs/stability-review-20260413.md +++ /dev/null @@ -1,104 +0,0 @@ -# Stability Review - 2026-04-13 - -## Scope - -Re-review of the updated `lmxopcua` codebase with emphasis on stability, shutdown behavior, async usage, latent deadlock patterns, and silent failure modes. - -Validation run for this review: - -```powershell -dotnet test tests\ZB.MOM.WW.LmxOpcUa.Tests\ZB.MOM.WW.LmxOpcUa.Tests.csproj --no-restore -``` - -Result: `471/471` tests passed in approximately `3m18s`. - -## Confirmed Findings - -### 1. Probe state is published before the subscription succeeds - -Severity: High - -File references: - -- `src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs:193` -- `src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs:201` -- `src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs:222` -- `src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs:225` -- `src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs:343` - -`SyncAsync` adds entries to `_byProbe` and `_probeByGobjectId` before `SubscribeAsync` completes. If the advise call fails, the catch block logs the failure but leaves the probe registered internally. `Tick()` later treats that entry as a real advised probe that never produced an initial callback and transitions it from `Unknown` to `Stopped`. - -That creates a false-negative health signal: a host can be marked stopped even though the real problem was "subscription never established". In this codebase that distinction matters because runtime-host state is later used to suppress or degrade published node quality. - -Recommendation: only commit the new probe entry after a successful subscribe, or roll the dictionaries back in the catch path. Add a regression test for subscribe failure in `GalaxyRuntimeProbeManagerTests`. - -### 2. Service startup still ignores dashboard bind failure - -Severity: Medium - -File references: - -- `src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusWebServer.cs:50` -- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs:307` -- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs:308` - -`StatusWebServer.Start()` now correctly returns `bool`, but `OpcUaService.Start` still ignores that result. The service can therefore continue through startup and report success even when the dashboard failed to bind. - -This is not a process-crash bug, but it is still an operational stability issue because the service advertises a successful start while one of its enabled endpoints is unavailable. - -Recommendation: decide whether dashboard startup failure is fatal or degraded mode, then implement that policy explicitly. At minimum, surface the failure in service startup state instead of dropping the return value. - -### 3. Sync-over-async remains on critical request and rebuild paths - -Severity: Medium - -File references: - -- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs:572` -- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs:1708` -- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs:1782` -- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs:2022` -- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs:2100` -- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs:2154` -- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs:2220` - -The updated code removed some blocking work from lock scopes, but several service-critical paths still call async MX access operations synchronously with `.GetAwaiter().GetResult()`. That pattern appears in address-space rebuild, direct read/write handling, and historian reads. - -I did not reproduce a deadlock in tests, but the pattern is still a stability risk because request threads now inherit backend latency directly and can stall hard if the underlying async path hangs, blocks on its own scheduler, or experiences slow reconnect behavior. - -Recommendation: keep the short synchronous boundary only where the external API forces it, and isolate backend calls behind bounded timeouts or dedicated worker threads. Rebuild-time probe synchronization is the highest-value place to reduce blocking first. - -### 4. Several background subscribe paths are still fire-and-forget - -Severity: Low - -File references: - -- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs:858` -- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs:1362` -- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs:2481` - -Alarm auto-subscribe and transferred-subscription restore still dispatch `SubscribeAsync(...)` and attach a fault-only continuation. That is better than dropping exceptions completely, but these operations are still not lifecycle-coordinated. A rebuild or shutdown can move on while subscription work is still in flight. - -The practical outcome is transient mismatch rather than memory corruption: expected subscriptions can arrive late, and shutdown/rebuild sequencing is harder to reason about under backend slowness. - -Recommendation: track these tasks when ordering matters, or centralize them behind a subscription queue with explicit cancellation and shutdown semantics. - -## Verified Improvements Since The Previous Review - -The following areas that were previously risky now look materially better in the current code: - -- `StaComThread` now checks `PostThreadMessage` failures and faults pending work instead of leaving callers parked indefinitely. -- `HistoryContinuationPointManager` now purges expired continuation points on retrieve and release, not only on store. -- `ChangeDetectionService`, MX monitor, and the status web server now retain background task handles and wait briefly during stop. -- `StatusWebServer` no longer swallows startup failure silently; it returns a success flag and logs the failure. -- Connection string validation now redacts credentials before logging. - -## Overall Assessment - -The updated code is in better shape than the previous pass. The most serious prior shutdown and leak hazards have been addressed, and the full automated test suite is currently green. - -The remaining stability work is concentrated in two areas: - -1. Correctness around failed runtime-probe subscription. -2. Reducing synchronous waits and untracked background subscription work in the OPC UA node manager. diff --git a/service_info.md b/service_info.md index 7a67643..0ef58c5 100644 --- a/service_info.md +++ b/service_info.md @@ -826,6 +826,107 @@ The earlier service_info.md entry referenced `tools/opcuacli-dotnet/bin/Debug/ne The code changes in this deploy are defensive and do not alter the runtime-status feature's control flow except in one place (subscribe rollback, which only triggers when `SubscribeAsync` throws). The 471/471 baseline on the probe manager tests plus the three new rollback regression tests give high confidence that the runtime-status behavior is preserved. If a human operator runs the IDE OffScan/OnScan cycle and observes an anomaly, the fix is most likely isolated to `GalaxyRuntimeProbeManager.SyncAsync` — see Finding 1 above — and can be reverted by restoring `C:\publish\lmxopcua\backups\20260414-003948-instance{1,2}\ZB.MOM.WW.LmxOpcUa.Host.exe`. +## Galaxy Platform Scope Filter + +Updated: `2026-04-16 00:21-00:27 America/New_York` + +Both instances updated with a new `GalaxyRepository.Scope` configuration flag that controls whether the OPC UA server loads the entire Galaxy or only objects hosted by the local platform. Reduces address space size, MXAccess subscription count, and memory footprint on multi-node Galaxy deployments. + +Backups: +- `C:\publish\lmxopcua\backups\20260416-002120-instance1` +- `C:\publish\lmxopcua\backups\20260416-002120-instance2` + +Configuration preserved: +- Both `appsettings.json` updated with new fields only (`Scope`, `PlatformName`). All existing settings preserved. + +Deployed binary (both instances): +- `ZB.MOM.WW.LmxOpcUa.Host.exe` +- Last write time: `2026-04-16 00:23 -04:00` +- Size: `7,993,344` + +Windows services: +- `LmxOpcUa` — Running, PID `15204` +- `LmxOpcUa2` — Running, PID `9544` + +### Code changes + +- `Configuration/GalaxyScope.cs` (new) — enum: `Galaxy` (default, all deployed objects), `LocalPlatform` (only objects hosted by the local platform's subtree). +- `Domain/PlatformInfo.cs` (new) — DTO mapping `platform.platform_gobject_id` to `platform.node_name`. +- `Configuration/GalaxyRepositoryConfiguration.cs` — added `Scope: GalaxyScope` (default `Galaxy`) and `PlatformName: string?` (optional override for `Environment.MachineName`). +- `GalaxyRepository/PlatformScopeFilter.cs` (new) — stateless C# filter applied after the existing SQL queries (preserves `GR-006: const string, no dynamic SQL` convention). Algorithm: (1) resolve the local platform's `gobject_id` via a new `PlatformLookupSql` query against the `platform` table, (2) collect all AppEngine hosts under that platform, (3) include all objects hosted by any host in the set, (4) walk `ParentGobjectId` chains upward to retain ancestor areas for a connected browse tree. +- `GalaxyRepository/GalaxyRepositoryService.cs` — added `PlatformLookupSql` const query, `GetPlatformsAsync()` method, post-query filtering in `GetHierarchyAsync`/`GetAttributesAsync` with cached `_scopeFilteredGobjectIds` for cross-method consistency. +- `Configuration/ConfigurationValidator.cs` — logs `Scope` and effective `PlatformName` at startup. + +### Configuration + +New fields in `GalaxyRepository` section of `appsettings.json`: + +```json +"GalaxyRepository": { + "Scope": "Galaxy", + "PlatformName": null +} +``` + +- `Scope`: `"Galaxy"` (default) loads all deployed objects. `"LocalPlatform"` filters to the local platform only. +- `PlatformName`: When null, uses `Environment.MachineName`. Set explicitly to target a specific platform by hostname. + +Both instances deployed with `"Scope": "Galaxy"` (full Galaxy, backward-compatible default). + +### Tests + +8 new unit tests in `PlatformScopeFilterTests.cs`: +- Two-platform Galaxy filtering (platform A, platform B) +- Case-insensitive node name matching +- No matching platform → empty result +- Ancestor area inclusion for connected tree +- Area exclusion when no local descendants +- Attribute filtering by gobject_id set +- Original order preservation + +Full suite: **494/494** tests passing (8 new, 0 regressions). + +### Live verification + +**LocalPlatform scope test** (instance1, temporarily set to `"Scope": "LocalPlatform"`): +``` +Startup log: + GalaxyRepository.Scope="LocalPlatform", PlatformName=DESKTOP-6JL3KKO + GalaxyRepository.PlatformName not set — using Environment.MachineName 'DESKTOP-6JL3KKO' + GetHierarchyAsync returned 49 objects + GetPlatformsAsync returned 1 platform(s) + Scope filter targeting platform 'DESKTOP-6JL3KKO' (gobject_id=1042) + Scope filter retained 3 of 49 objects for platform 'DESKTOP-6JL3KKO' + GetAttributesAsync returned 4206 attributes (extended=true) + Scope filter retained 386 of 4206 attributes + Address space built: 2 objects, 386 variables, 386 tag references, 0 alarm tags, 2 runtime hosts + +CLI browse ZB: + DEV → DevAppEngine, DevPlatform (only local platform subtree) + TestArea, TestArea2 → absent (filtered out) + +CLI read DEV.ScanState: + Value: True, Status: 0x00000000 (Good) +``` + +**Galaxy scope comparison** (instance2, `"Scope": "Galaxy"`): +``` +CLI browse ZB/DEV: + DevAppEngine, DevPlatform, TestArea, TestArea2 (full Galaxy) +``` + +**Galaxy scope restored** (instance1, set back to `"Scope": "Galaxy"`): +``` +CLI browse ZB/DEV: + DevAppEngine, DevPlatform, TestArea, TestArea2 (full Galaxy restored) +``` + +**Redundancy baseline preserved** (both instances): +``` +instance1 → Warm, ServiceLevel=200, urn:localhost:LmxOpcUa:instance1 +instance2 → Warm, ServiceLevel=150, urn:localhost:LmxOpcUa:instance2 +``` + ## Notes The service deployment and restart succeeded. The live CLI checks confirm the endpoint is reachable and that the array node identifier has changed to the bracketless form. The array value on the live service still prints as blank even though the status is good, so if this environment should have populated `MoveInPartNumbers`, the runtime data path still needs follow-up investigation. diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs index dc0e028..1429d30 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs @@ -109,6 +109,22 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration SanitizeConnectionString(config.GalaxyRepository.ConnectionString), config.GalaxyRepository.ChangeDetectionIntervalSeconds, config.GalaxyRepository.CommandTimeoutSeconds, config.GalaxyRepository.ExtendedAttributes); + var effectivePlatformName = string.IsNullOrWhiteSpace(config.GalaxyRepository.PlatformName) + ? Environment.MachineName + : config.GalaxyRepository.PlatformName; + Log.Information( + "GalaxyRepository.Scope={Scope}, PlatformName={PlatformName}", + config.GalaxyRepository.Scope, + config.GalaxyRepository.Scope == GalaxyScope.LocalPlatform + ? effectivePlatformName + : "(n/a)"); + + if (config.GalaxyRepository.Scope == GalaxyScope.LocalPlatform && + string.IsNullOrWhiteSpace(config.GalaxyRepository.PlatformName)) + Log.Information( + "GalaxyRepository.PlatformName not set — using Environment.MachineName '{MachineName}'", + Environment.MachineName); + if (string.IsNullOrWhiteSpace(config.GalaxyRepository.ConnectionString)) { Log.Error("GalaxyRepository.ConnectionString must not be empty"); diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs index 0d010ff..8c5e678 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs @@ -25,5 +25,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration /// Gets or sets a value indicating whether extended Galaxy attribute metadata should be loaded into the OPC UA model. /// public bool ExtendedAttributes { get; set; } = false; + + /// + /// Gets or sets the scope of Galaxy objects loaded into the OPC UA address space. + /// Galaxy loads all deployed objects (default). LocalPlatform loads only + /// objects hosted by the platform deployed on this machine. + /// + public GalaxyScope Scope { get; set; } = GalaxyScope.Galaxy; + + /// + /// Gets or sets an explicit platform node name for filtering. + /// When , the local machine name (Environment.MachineName) is used. + /// + public string? PlatformName { get; set; } } } \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyScope.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyScope.cs new file mode 100644 index 0000000..24b11a1 --- /dev/null +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyScope.cs @@ -0,0 +1,18 @@ +namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration +{ + /// + /// Controls how much of the Galaxy object hierarchy is loaded into the OPC UA address space. + /// + public enum GalaxyScope + { + /// + /// Load all deployed objects from the entire Galaxy (default, backward-compatible behavior). + /// + Galaxy, + + /// + /// Load only objects hosted by the local platform and the structural areas needed to reach them. + /// + LocalPlatform + } +} diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/PlatformInfo.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/PlatformInfo.cs new file mode 100644 index 0000000..c46e87c --- /dev/null +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/PlatformInfo.cs @@ -0,0 +1,18 @@ +namespace ZB.MOM.WW.LmxOpcUa.Host.Domain +{ + /// + /// Maps a deployed Galaxy platform to the hostname where it executes. + /// + public class PlatformInfo + { + /// + /// Gets or sets the gobject_id of the platform object in the Galaxy repository. + /// + public int GobjectId { get; set; } + + /// + /// Gets or sets the hostname (node_name) where the platform is deployed. + /// + public string NodeName { get; set; } = ""; + } +} diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs index a50fad6..9a84740 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs @@ -19,6 +19,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository private readonly GalaxyRepositoryConfiguration _config; + /// + /// When filtering is active, caches the set of + /// gobject_ids that passed the hierarchy filter so can apply the same scope. + /// Populated by and consumed by . + /// + private HashSet? _scopeFilteredGobjectIds; + /// /// Initializes a new repository service that reads Galaxy metadata from the configured SQL database. /// @@ -77,6 +84,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository else Log.Information("GetHierarchyAsync returned {Count} objects", results.Count); + if (_config.Scope == GalaxyScope.LocalPlatform) + { + var platforms = await GetPlatformsAsync(ct); + var platformName = string.IsNullOrWhiteSpace(_config.PlatformName) + ? Environment.MachineName + : _config.PlatformName; + var (filtered, gobjectIds) = PlatformScopeFilter.Filter(results, platforms, platformName); + _scopeFilteredGobjectIds = gobjectIds; + return filtered; + } + + _scopeFilteredGobjectIds = null; return results; } @@ -102,6 +121,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository Log.Information("GetAttributesAsync returned {Count} attributes (extended={Extended})", results.Count, extended); + + if (_config.Scope == GalaxyScope.LocalPlatform && _scopeFilteredGobjectIds != null) + return PlatformScopeFilter.FilterAttributes(results, _scopeFilteredGobjectIds); + return results; } @@ -147,6 +170,33 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository } } + /// + /// Queries the platform table for deployed platform-to-hostname mappings used by + /// filtering. + /// + private async Task> GetPlatformsAsync(CancellationToken ct = default) + { + var results = new List(); + + using var conn = new SqlConnection(_config.ConnectionString); + await conn.OpenAsync(ct); + + using var cmd = new SqlCommand(PlatformLookupSql, conn) { CommandTimeout = _config.CommandTimeoutSeconds }; + using var reader = await cmd.ExecuteReaderAsync(ct); + + while (await reader.ReadAsync(ct)) + { + results.Add(new PlatformInfo + { + GobjectId = Convert.ToInt32(reader.GetValue(0)), + NodeName = reader.IsDBNull(1) ? "" : reader.GetString(1) + }); + } + + Log.Information("GetPlatformsAsync returned {Count} platform(s)", results.Count); + return results; + } + /// /// Reads a row from the standard attributes query (12 columns). /// Columns: gobject_id, tag_name, attribute_name, full_tag_reference, mx_data_type, @@ -464,6 +514,12 @@ FROM ( ) all_attributes ORDER BY tag_name, primitive_name, attribute_name"; + private const string PlatformLookupSql = @" +SELECT p.platform_gobject_id, p.node_name +FROM platform p +INNER JOIN gobject g ON g.gobject_id = p.platform_gobject_id +WHERE g.is_template = 0 AND g.deployed_package_id <> 0"; + private const string ChangeDetectionSql = "SELECT time_of_last_deploy FROM galaxy"; private const string TestConnectionSql = "SELECT 1"; diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/PlatformScopeFilter.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/PlatformScopeFilter.cs new file mode 100644 index 0000000..9773c79 --- /dev/null +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/PlatformScopeFilter.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Serilog; +using ZB.MOM.WW.LmxOpcUa.Host.Domain; + +namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository +{ + /// + /// Filters a Galaxy object hierarchy to retain only objects hosted by a specific platform + /// and the structural areas needed to keep the browse tree connected. + /// + public static class PlatformScopeFilter + { + private static readonly ILogger Log = Serilog.Log.ForContext(typeof(PlatformScopeFilter)); + + private const int CategoryWinPlatform = 1; + private const int CategoryAppEngine = 3; + + /// + /// Filters the hierarchy to objects hosted by the platform whose node_name matches + /// , plus ancestor areas that keep the tree connected. + /// + /// The full Galaxy object hierarchy. + /// Deployed platform-to-hostname mappings from the platform table. + /// The target hostname to match (case-insensitive). + /// + /// The filtered hierarchy and the set of included gobject_ids (for attribute filtering). + /// When no matching platform is found, returns an empty list and empty set. + /// + public static (List Hierarchy, HashSet GobjectIds) Filter( + List hierarchy, + List platforms, + string platformName) + { + // Find the platform gobject_id that matches the target hostname. + var matchingPlatform = platforms.FirstOrDefault( + p => string.Equals(p.NodeName, platformName, StringComparison.OrdinalIgnoreCase)); + + if (matchingPlatform == null) + { + Log.Warning( + "Scope filter found no deployed platform matching node name '{PlatformName}'; " + + "available platforms: [{Available}]", + platformName, + string.Join(", ", platforms.Select(p => $"{p.NodeName} (gobject_id={p.GobjectId})"))); + return (new List(), new HashSet()); + } + + var platformGobjectId = matchingPlatform.GobjectId; + Log.Information( + "Scope filter targeting platform '{PlatformName}' (gobject_id={GobjectId})", + platformName, platformGobjectId); + + // Build a lookup for the hierarchy by gobject_id. + var byId = hierarchy.ToDictionary(o => o.GobjectId); + + // Step 1: Collect all host gobject_ids under this platform. + // Walk outward from the platform to find AppEngines (and any deeper hosting objects). + var hostIds = new HashSet { platformGobjectId }; + bool changed; + do + { + changed = false; + foreach (var obj in hierarchy) + { + if (hostIds.Contains(obj.GobjectId)) + continue; + if (obj.HostedByGobjectId != 0 && hostIds.Contains(obj.HostedByGobjectId) + && (obj.CategoryId == CategoryAppEngine || obj.CategoryId == CategoryWinPlatform)) + { + hostIds.Add(obj.GobjectId); + changed = true; + } + } + } while (changed); + + // Step 2: Include all non-area objects hosted by any host in the set, plus the hosts themselves. + var includedIds = new HashSet(hostIds); + foreach (var obj in hierarchy) + { + if (includedIds.Contains(obj.GobjectId)) + continue; + if (!obj.IsArea && obj.HostedByGobjectId != 0 && hostIds.Contains(obj.HostedByGobjectId)) + includedIds.Add(obj.GobjectId); + } + + // Step 3: Walk ParentGobjectId chains upward to include ancestor areas so the tree stays connected. + var toWalk = new Queue(includedIds); + while (toWalk.Count > 0) + { + var id = toWalk.Dequeue(); + if (!byId.TryGetValue(id, out var obj)) + continue; + var parentId = obj.ParentGobjectId; + if (parentId != 0 && byId.ContainsKey(parentId) && includedIds.Add(parentId)) + toWalk.Enqueue(parentId); + } + + // Step 4: Return the filtered hierarchy preserving original order. + var filtered = hierarchy.Where(o => includedIds.Contains(o.GobjectId)).ToList(); + + Log.Information( + "Scope filter retained {FilteredCount} of {TotalCount} objects for platform '{PlatformName}'", + filtered.Count, hierarchy.Count, platformName); + + return (filtered, includedIds); + } + + /// + /// Filters attributes to retain only those belonging to objects in the given set. + /// + public static List FilterAttributes( + List attributes, + HashSet gobjectIds) + { + var filtered = attributes.Where(a => gobjectIds.Contains(a.GobjectId)).ToList(); + Log.Information( + "Scope filter retained {FilteredCount} of {TotalCount} attributes", + filtered.Count, attributes.Count); + return filtered; + } + } +} diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json b/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json index 73cd7fc..fc0beaf 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json @@ -31,7 +31,9 @@ "ConnectionString": "Server=localhost;Database=ZB;Integrated Security=true;", "ChangeDetectionIntervalSeconds": 30, "CommandTimeoutSeconds": 30, - "ExtendedAttributes": false + "ExtendedAttributes": false, + "Scope": "Galaxy", + "PlatformName": null }, "Dashboard": { "Enabled": true, diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/GalaxyRepository/PlatformScopeFilterTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/GalaxyRepository/PlatformScopeFilterTests.cs new file mode 100644 index 0000000..255b97e --- /dev/null +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/GalaxyRepository/PlatformScopeFilterTests.cs @@ -0,0 +1,196 @@ +using System.Collections.Generic; +using Shouldly; +using Xunit; +using ZB.MOM.WW.LmxOpcUa.Host.Domain; +using ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository; + +namespace ZB.MOM.WW.LmxOpcUa.Tests.GalaxyRepository +{ + public class PlatformScopeFilterTests + { + // Category constants matching the Galaxy schema. + private const int CatPlatform = 1; + private const int CatAppEngine = 3; + private const int CatUserDefined = 10; + private const int CatArea = 13; + + /// + /// Builds a two-platform Galaxy hierarchy for filtering tests. + /// + /// Structure: + /// Area1 (id=1, area, parent=0) + /// PlatformA (id=10, cat=1, hosted_by=0) ← node "NODEA" + /// EngineA (id=20, cat=3, hosted_by=10) + /// Obj1 (id=30, cat=10, hosted_by=20) + /// Obj2 (id=31, cat=10, hosted_by=20) + /// PlatformB (id=11, cat=1, hosted_by=0) ← node "NODEB" + /// EngineB (id=21, cat=3, hosted_by=11) + /// Obj3 (id=32, cat=10, hosted_by=21) + /// Area2 (id=2, area, parent=0) + /// Obj4 (id=33, cat=10, hosted_by=21) ← hosted by EngineB + /// + private static (List hierarchy, List platforms) CreateTwoPlatformGalaxy() + { + var hierarchy = new List + { + new() { GobjectId = 1, TagName = "Area1", ContainedName = "Area1", BrowseName = "Area1", ParentGobjectId = 0, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 }, + new() { GobjectId = 10, TagName = "PlatformA", ContainedName = "PlatformA", BrowseName = "PlatformA", ParentGobjectId = 1, IsArea = false, CategoryId = CatPlatform, HostedByGobjectId = 0 }, + new() { GobjectId = 20, TagName = "EngineA_001", ContainedName = "EngineA", BrowseName = "EngineA", ParentGobjectId = 10, IsArea = false, CategoryId = CatAppEngine, HostedByGobjectId = 10 }, + new() { GobjectId = 30, TagName = "Obj1_001", ContainedName = "Obj1", BrowseName = "Obj1", ParentGobjectId = 20, IsArea = false, CategoryId = CatUserDefined, HostedByGobjectId = 20 }, + new() { GobjectId = 31, TagName = "Obj2_001", ContainedName = "Obj2", BrowseName = "Obj2", ParentGobjectId = 20, IsArea = false, CategoryId = CatUserDefined, HostedByGobjectId = 20 }, + new() { GobjectId = 11, TagName = "PlatformB", ContainedName = "PlatformB", BrowseName = "PlatformB", ParentGobjectId = 1, IsArea = false, CategoryId = CatPlatform, HostedByGobjectId = 0 }, + new() { GobjectId = 21, TagName = "EngineB_001", ContainedName = "EngineB", BrowseName = "EngineB", ParentGobjectId = 11, IsArea = false, CategoryId = CatAppEngine, HostedByGobjectId = 11 }, + new() { GobjectId = 32, TagName = "Obj3_001", ContainedName = "Obj3", BrowseName = "Obj3", ParentGobjectId = 21, IsArea = false, CategoryId = CatUserDefined, HostedByGobjectId = 21 }, + new() { GobjectId = 2, TagName = "Area2", ContainedName = "Area2", BrowseName = "Area2", ParentGobjectId = 0, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 }, + new() { GobjectId = 33, TagName = "Obj4_001", ContainedName = "Obj4", BrowseName = "Obj4", ParentGobjectId = 2, IsArea = false, CategoryId = CatUserDefined, HostedByGobjectId = 21 }, + }; + + var platforms = new List + { + new() { GobjectId = 10, NodeName = "NODEA" }, + new() { GobjectId = 11, NodeName = "NODEB" }, + }; + + return (hierarchy, platforms); + } + + [Fact] + public void Filter_ReturnsOnlyObjectsUnderMatchingPlatform() + { + var (hierarchy, platforms) = CreateTwoPlatformGalaxy(); + + var (filtered, ids) = PlatformScopeFilter.Filter(hierarchy, platforms, "NODEA"); + + // Should include: Area1, PlatformA, EngineA, Obj1, Obj2 + // Should exclude: PlatformB, EngineB, Obj3, Area2, Obj4 + ids.ShouldContain(1); // Area1 (ancestor of PlatformA) + ids.ShouldContain(10); // PlatformA + ids.ShouldContain(20); // EngineA + ids.ShouldContain(30); // Obj1 + ids.ShouldContain(31); // Obj2 + ids.ShouldNotContain(11); // PlatformB + ids.ShouldNotContain(21); // EngineB + ids.ShouldNotContain(32); // Obj3 + ids.ShouldNotContain(33); // Obj4 + ids.ShouldNotContain(2); // Area2 (no local children) + filtered.Count.ShouldBe(5); + } + + [Fact] + public void Filter_ReturnsObjectsUnderPlatformB() + { + var (hierarchy, platforms) = CreateTwoPlatformGalaxy(); + + var (filtered, ids) = PlatformScopeFilter.Filter(hierarchy, platforms, "NODEB"); + + // Should include: Area1, PlatformB, EngineB, Obj3, Area2, Obj4 + ids.ShouldContain(1); // Area1 (ancestor of PlatformB) + ids.ShouldContain(11); // PlatformB + ids.ShouldContain(21); // EngineB + ids.ShouldContain(32); // Obj3 + ids.ShouldContain(2); // Area2 (has Obj4 hosted by EngineB) + ids.ShouldContain(33); // Obj4 + // Should exclude PlatformA's subtree + ids.ShouldNotContain(10); + ids.ShouldNotContain(20); + ids.ShouldNotContain(30); + ids.ShouldNotContain(31); + filtered.Count.ShouldBe(6); + } + + [Fact] + public void Filter_IsCaseInsensitiveOnNodeName() + { + var (hierarchy, platforms) = CreateTwoPlatformGalaxy(); + + var (filtered, _) = PlatformScopeFilter.Filter(hierarchy, platforms, "nodea"); + + filtered.Count.ShouldBe(5); + } + + [Fact] + public void Filter_ReturnsEmptyWhenNoMatchingPlatform() + { + var (hierarchy, platforms) = CreateTwoPlatformGalaxy(); + + var (filtered, ids) = PlatformScopeFilter.Filter(hierarchy, platforms, "UNKNOWN"); + + filtered.ShouldBeEmpty(); + ids.ShouldBeEmpty(); + } + + [Fact] + public void Filter_IncludesAncestorAreasForConnectedTree() + { + // An object nested several levels deep should pull in all ancestor areas. + var hierarchy = new List + { + new() { GobjectId = 1, TagName = "TopArea", ContainedName = "TopArea", BrowseName = "TopArea", ParentGobjectId = 0, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 }, + new() { GobjectId = 2, TagName = "SubArea", ContainedName = "SubArea", BrowseName = "SubArea", ParentGobjectId = 1, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 }, + new() { GobjectId = 10, TagName = "Plat", ContainedName = "Plat", BrowseName = "Plat", ParentGobjectId = 2, IsArea = false, CategoryId = CatPlatform, HostedByGobjectId = 0 }, + new() { GobjectId = 20, TagName = "Eng", ContainedName = "Eng", BrowseName = "Eng", ParentGobjectId = 10, IsArea = false, CategoryId = CatAppEngine, HostedByGobjectId = 10 }, + new() { GobjectId = 30, TagName = "Obj", ContainedName = "Obj", BrowseName = "Obj", ParentGobjectId = 20, IsArea = false, CategoryId = CatUserDefined, HostedByGobjectId = 20 }, + }; + var platforms = new List { new() { GobjectId = 10, NodeName = "LOCAL" } }; + + var (filtered, ids) = PlatformScopeFilter.Filter(hierarchy, platforms, "LOCAL"); + + ids.ShouldContain(1); // TopArea + ids.ShouldContain(2); // SubArea + ids.ShouldContain(10); // Platform + ids.ShouldContain(20); // Engine + ids.ShouldContain(30); // Object + filtered.Count.ShouldBe(5); + } + + [Fact] + public void Filter_ExcludesAreaWithNoLocalDescendants() + { + var hierarchy = new List + { + new() { GobjectId = 1, TagName = "UsedArea", ContainedName = "UsedArea", BrowseName = "UsedArea", ParentGobjectId = 0, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 }, + new() { GobjectId = 2, TagName = "EmptyArea", ContainedName = "EmptyArea", BrowseName = "EmptyArea", ParentGobjectId = 0, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 }, + new() { GobjectId = 10, TagName = "Plat", ContainedName = "Plat", BrowseName = "Plat", ParentGobjectId = 1, IsArea = false, CategoryId = CatPlatform, HostedByGobjectId = 0 }, + }; + var platforms = new List { new() { GobjectId = 10, NodeName = "LOCAL" } }; + + var (_, ids) = PlatformScopeFilter.Filter(hierarchy, platforms, "LOCAL"); + + ids.ShouldContain(1); // UsedArea (ancestor of Plat) + ids.ShouldNotContain(2); // EmptyArea (no local descendants) + } + + [Fact] + public void FilterAttributes_RetainsOnlyMatchingGobjectIds() + { + var gobjectIds = new HashSet { 10, 30 }; + var attributes = new List + { + new() { GobjectId = 10, TagName = "Plat", AttributeName = "Attr1", FullTagReference = "Plat.Attr1" }, + new() { GobjectId = 20, TagName = "Other", AttributeName = "Attr2", FullTagReference = "Other.Attr2" }, + new() { GobjectId = 30, TagName = "Obj", AttributeName = "Attr3", FullTagReference = "Obj.Attr3" }, + }; + + var filtered = PlatformScopeFilter.FilterAttributes(attributes, gobjectIds); + + filtered.Count.ShouldBe(2); + filtered.ShouldAllBe(a => gobjectIds.Contains(a.GobjectId)); + } + + [Fact] + public void Filter_PreservesOriginalOrder() + { + var (hierarchy, platforms) = CreateTwoPlatformGalaxy(); + + var (filtered, _) = PlatformScopeFilter.Filter(hierarchy, platforms, "NODEA"); + + // Verify the order matches the original hierarchy order for included items. + for (int i = 1; i < filtered.Count; i++) + { + var prevIndex = hierarchy.FindIndex(o => o.GobjectId == filtered[i - 1].GobjectId); + var currIndex = hierarchy.FindIndex(o => o.GobjectId == filtered[i].GobjectId); + prevIndex.ShouldBeLessThan(currIndex); + } + } + } +}