/* ========================================================================
* 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;
}
}