/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Opc.Ua.Client.AliasNames.Refresh; namespace Opc.Ua.Client.AliasNames { /// /// Caching alias-name resolver — wraps an /// with an in-memory /// alias-name → [] lookup (plus /// reverse lookup) populated by calling FindAlias / /// FindAliasVerbose once and reused on subsequent /// resolutions. /// /// /// /// Default refresh mode is /// — callers /// must invoke (or /// ) to populate the cache. This is /// the safe default for servers that do not allow subscriptions or /// cannot afford the overhead of a recurring poll. /// /// /// Opt in to automatic invalidation by setting /// to /// /// (read-based) or /// /// (subscription-based). Custom strategies — e.g. a Part 17 /// Annex D PubSub bridge — can be plugged in via /// . /// /// /// The resolver is — disposing it /// stops the active refresh strategy and releases any cached state. /// /// public sealed class AliasNameResolver : IAsyncDisposable { /// /// Initializes a new resolver over the supplied /// . /// public AliasNameResolver( AliasNameClient client, AliasNameResolverOptions? options = null) { Client = client ?? throw new ArgumentNullException(nameof(client)); Options = (options ?? new AliasNameResolverOptions()).Clone(); m_strategy = Options.RefreshStrategy ?? BuildBuiltInStrategy(Options); } /// The wrapped . public AliasNameClient Client { get; } /// /// The (cloned, immutable) configuration. /// public AliasNameResolverOptions Options { get; } /// /// Ensures the cache is populated; performs a refresh only on /// the first call (or after an invalidation). On the first call /// the configured is /// also started. /// public async Task EnsureLoadedAsync(CancellationToken ct = default) { if (Volatile.Read(ref m_loaded) == 1) { return; } await EnsureStrategyStartedAsync(ct).ConfigureAwait(false); await RefreshAsync(ct).ConfigureAwait(false); } private async Task EnsureStrategyStartedAsync(CancellationToken ct) { if (Volatile.Read(ref m_strategyStarted) == 1) { return; } await m_strategyStartLock.WaitAsync(ct).ConfigureAwait(false); try { if (Volatile.Read(ref m_strategyStarted) == 1) { return; } await m_strategy.StartAsync(Client, Invalidate, ct) .ConfigureAwait(false); Volatile.Write(ref m_strategyStarted, 1); } finally { m_strategyStartLock.Release(); } } /// /// Force-reloads the alias cache. /// public async Task RefreshAsync(CancellationToken ct = default) { var forward = new Dictionary(StringComparer.Ordinal); var serverUris = new Dictionary(StringComparer.Ordinal); var reverse = new Dictionary(); if (Options.UseVerbose) { IReadOnlyList verbose; try { verbose = await Client.FindAliasVerboseAsync( "%", NodeId.Null, ct).ConfigureAwait(false); } catch (NotSupportedException) { verbose = []; Options.UseVerbose = false; // fall back to non-verbose } if (verbose.Count == 0 && !Options.UseVerbose) { // try non-verbose fallback IReadOnlyList nonVerbose = await Client.FindAliasAsync("%", NodeId.Null, ct) .ConfigureAwait(false); PopulateFromNonVerbose(nonVerbose, forward, reverse); } else { PopulateFromVerbose(verbose, forward, serverUris, reverse); } } else { IReadOnlyList aliases = await Client.FindAliasAsync("%", NodeId.Null, ct) .ConfigureAwait(false); PopulateFromNonVerbose(aliases, forward, reverse); } await m_semaphore.WaitAsync(ct).ConfigureAwait(false); try { m_forward = forward; m_serverUris = serverUris; m_reverse = reverse; Volatile.Write(ref m_loaded, 1); } finally { m_semaphore.Release(); } } /// /// Returns every mapped to the /// alias name , or an empty list /// when no mapping exists. Loads the cache on demand. /// /// /// is null. /// public async Task> ResolveAsync( string aliasName, CancellationToken ct = default) { if (aliasName == null) { throw new ArgumentNullException(nameof(aliasName)); } await EnsureLoadedAsync(ct).ConfigureAwait(false); await m_semaphore.WaitAsync(ct).ConfigureAwait(false); try { if (m_forward.TryGetValue(aliasName, out ExpandedNodeId[]? targets)) { return targets; } return []; } finally { m_semaphore.Release(); } } /// /// Reverse lookup — returns the alias name for the supplied /// target NodeId, or null if no alias points at it. Loads /// the cache on demand. /// public async Task ResolveAliasNameAsync( ExpandedNodeId target, CancellationToken ct = default) { if (target.IsNull) { return null; } await EnsureLoadedAsync(ct).ConfigureAwait(false); await m_semaphore.WaitAsync(ct).ConfigureAwait(false); try { if (m_reverse.TryGetValue(target, out string? name)) { return name; } return null; } finally { m_semaphore.Release(); } } /// /// Returns the server URIs associated with the named alias's /// targets (parallel to the result of /// ). Only populated when the /// resolver was loaded via /// ; returns an /// empty list otherwise. /// /// /// is null. /// public async Task> ResolveServerUrisAsync( string aliasName, CancellationToken ct = default) { if (aliasName == null) { throw new ArgumentNullException(nameof(aliasName)); } await EnsureLoadedAsync(ct).ConfigureAwait(false); await m_semaphore.WaitAsync(ct).ConfigureAwait(false); try { if (m_serverUris.TryGetValue(aliasName, out string?[]? uris)) { return uris; } return []; } finally { m_semaphore.Release(); } } /// /// Invalidates the cache; the next resolve will refresh. /// public void Invalidate() { Volatile.Write(ref m_loaded, 0); } /// /// Stops the configured refresh strategy and releases the /// internal cache. Idempotent. Waits for any in-flight resolve / /// refresh / poll callback to release the internal lock before /// disposing it so concurrent calls cannot observe an /// . /// public async ValueTask DisposeAsync() { try { await m_strategy.DisposeAsync().ConfigureAwait(false); } catch { // Best-effort cleanup; the strategy implementation is // responsible for not throwing during dispose, but we // never want disposal of the resolver to throw. } try { await m_semaphore.WaitAsync().ConfigureAwait(false); } catch (ObjectDisposedException) { // Already disposed via a re-entrant call. return; } try { m_forward.Clear(); m_serverUris.Clear(); m_reverse.Clear(); } finally { m_semaphore.Release(); m_semaphore.Dispose(); m_strategyStartLock.Dispose(); } } private static IAliasNameRefreshStrategy BuildBuiltInStrategy( AliasNameResolverOptions options) { #pragma warning disable CS0618 // AutoOnLastChange aliases AutoOnLastChangePolling. switch (options.RefreshMode) { case AliasNameResolverRefreshMode.AutoOnLastChangePolling: return new PollingAliasNameRefreshStrategy( TimeSpan.FromMilliseconds( Math.Max(100, options.PublishingIntervalMs))); case AliasNameResolverRefreshMode.AutoOnLastChangeMonitoredItem: return new MonitoredItemAliasNameRefreshStrategy( new MonitoredItemAliasNameRefreshStrategyOptions { PublishingIntervalMs = options.PublishingIntervalMs, SamplingIntervalMs = options.LastChangeSamplingIntervalMs }); default: return new ManualAliasNameRefreshStrategy(); } #pragma warning restore CS0618 } private static void PopulateFromNonVerbose( IReadOnlyList aliases, Dictionary forward, Dictionary reverse) { foreach (AliasNameDataType a in aliases) { string key = a.AliasName.Name ?? string.Empty; int count = a.ReferencedNodes.Count; var arr = new ExpandedNodeId[count]; for (int i = 0; i < count; i++) { arr[i] = a.ReferencedNodes[i]; reverse[arr[i]] = key; } forward[key] = arr; } } private static void PopulateFromVerbose( IReadOnlyList aliases, Dictionary forward, Dictionary serverUris, Dictionary reverse) { foreach (AliasNameVerboseDataType a in aliases) { string key = a.AliasName.Name ?? string.Empty; int count = a.ReferencedNodes.Count; var arr = new ExpandedNodeId[count]; string?[] uris = new string?[count]; for (int i = 0; i < count; i++) { arr[i] = a.ReferencedNodes[i]; uris[i] = i < a.ServerUris.Count ? a.ServerUris[i] : null; reverse[arr[i]] = key; } forward[key] = arr; serverUris[key] = uris; } } private readonly SemaphoreSlim m_semaphore = new(1, 1); private readonly SemaphoreSlim m_strategyStartLock = new(1, 1); private readonly IAliasNameRefreshStrategy m_strategy; private Dictionary m_forward = new(StringComparer.Ordinal); private Dictionary m_serverUris = new(StringComparer.Ordinal); private Dictionary m_reverse = []; private int m_loaded; private int m_strategyStarted; } }