Add Galaxy platform scope filter so multi-node deployments can restrict the OPC UA address space to only objects hosted by the local platform, reducing memory footprint and MXAccess subscription count from the full Galaxy (49 objects / 4206 attributes) down to the local subtree (3 objects / 386 attributes on the dev Galaxy).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-16 00:39:11 -04:00
parent c76ab8fdee
commit bc282b6788
13 changed files with 610 additions and 107 deletions

View File

@@ -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");

View File

@@ -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.
/// </summary>
public bool ExtendedAttributes { get; set; } = false;
/// <summary>
/// Gets or sets the scope of Galaxy objects loaded into the OPC UA address space.
/// <c>Galaxy</c> loads all deployed objects (default). <c>LocalPlatform</c> loads only
/// objects hosted by the platform deployed on this machine.
/// </summary>
public GalaxyScope Scope { get; set; } = GalaxyScope.Galaxy;
/// <summary>
/// Gets or sets an explicit platform node name for <see cref="GalaxyScope.LocalPlatform" /> filtering.
/// When <see langword="null" />, the local machine name (<c>Environment.MachineName</c>) is used.
/// </summary>
public string? PlatformName { get; set; }
}
}

View File

@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// Controls how much of the Galaxy object hierarchy is loaded into the OPC UA address space.
/// </summary>
public enum GalaxyScope
{
/// <summary>
/// Load all deployed objects from the entire Galaxy (default, backward-compatible behavior).
/// </summary>
Galaxy,
/// <summary>
/// Load only objects hosted by the local platform and the structural areas needed to reach them.
/// </summary>
LocalPlatform
}
}

View File

@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Maps a deployed Galaxy platform to the hostname where it executes.
/// </summary>
public class PlatformInfo
{
/// <summary>
/// Gets or sets the gobject_id of the platform object in the Galaxy repository.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the hostname (node_name) where the platform is deployed.
/// </summary>
public string NodeName { get; set; } = "";
}
}

View File

@@ -19,6 +19,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
private readonly GalaxyRepositoryConfiguration _config;
/// <summary>
/// When <see cref="Configuration.GalaxyScope.LocalPlatform" /> filtering is active, caches the set of
/// gobject_ids that passed the hierarchy filter so <see cref="GetAttributesAsync" /> can apply the same scope.
/// Populated by <see cref="GetHierarchyAsync" /> and consumed by <see cref="GetAttributesAsync" />.
/// </summary>
private HashSet<int>? _scopeFilteredGobjectIds;
/// <summary>
/// Initializes a new repository service that reads Galaxy metadata from the configured SQL database.
/// </summary>
@@ -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
}
}
/// <summary>
/// Queries the platform table for deployed platform-to-hostname mappings used by
/// <see cref="Configuration.GalaxyScope.LocalPlatform" /> filtering.
/// </summary>
private async Task<List<PlatformInfo>> GetPlatformsAsync(CancellationToken ct = default)
{
var results = new List<PlatformInfo>();
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;
}
/// <summary>
/// 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";

View File

@@ -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
{
/// <summary>
/// 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.
/// </summary>
public static class PlatformScopeFilter
{
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(PlatformScopeFilter));
private const int CategoryWinPlatform = 1;
private const int CategoryAppEngine = 3;
/// <summary>
/// Filters the hierarchy to objects hosted by the platform whose <c>node_name</c> matches
/// <paramref name="platformName" />, plus ancestor areas that keep the tree connected.
/// </summary>
/// <param name="hierarchy">The full Galaxy object hierarchy.</param>
/// <param name="platforms">Deployed platform-to-hostname mappings from the <c>platform</c> table.</param>
/// <param name="platformName">The target hostname to match (case-insensitive).</param>
/// <returns>
/// 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.
/// </returns>
public static (List<GalaxyObjectInfo> Hierarchy, HashSet<int> GobjectIds) Filter(
List<GalaxyObjectInfo> hierarchy,
List<PlatformInfo> 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<GalaxyObjectInfo>(), new HashSet<int>());
}
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<int> { 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<int>(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<int>(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);
}
/// <summary>
/// Filters attributes to retain only those belonging to objects in the given set.
/// </summary>
public static List<GalaxyAttributeInfo> FilterAttributes(
List<GalaxyAttributeInfo> attributes,
HashSet<int> 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;
}
}
}

View File

@@ -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,