Files
lmxopcua/tools/opcuacli-dotnet/OpcUaFailoverHelper.cs
Joseph Doherty afd6c33d9d Add client-side failover to CLI tool for redundancy testing
All commands gain --failover-urls (-F) to specify alternate endpoints.
Short-lived commands try each URL in order on initial connect. The
subscribe command monitors KeepAlive and automatically reconnects to
the next available server, re-creating the subscription on failover.
Verified with live service start/stop: primary down triggers failover
to secondary, primary restart allows failback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 14:41:06 -04:00

157 lines
5.0 KiB
C#

using Opc.Ua;
using Opc.Ua.Client;
namespace OpcUaCli;
/// <summary>
/// Manages OPC UA client sessions with automatic failover across a set of redundant server endpoints.
/// </summary>
public sealed class OpcUaFailoverHelper : IDisposable
{
private readonly string[] _endpointUrls;
private readonly string? _username;
private readonly string? _password;
private readonly string _security;
private Session? _session;
private int _currentIndex;
private bool _disposed;
/// <summary>
/// Gets the active session, or null if not connected.
/// </summary>
public Session? Session => _session;
/// <summary>
/// Gets the endpoint URL the session is currently connected to.
/// </summary>
public string? CurrentEndpointUrl => _currentIndex < _endpointUrls.Length ? _endpointUrls[_currentIndex] : null;
/// <summary>
/// Raised when a failover occurs, providing the old and new endpoint URLs.
/// </summary>
public event Action<string?, string>? OnFailover;
public OpcUaFailoverHelper(string[] endpointUrls, string? username = null, string? password = null, string security = "none")
{
if (endpointUrls.Length == 0)
throw new ArgumentException("At least one endpoint URL is required.");
_endpointUrls = endpointUrls;
_username = username;
_password = password;
_security = security;
_currentIndex = 0;
}
/// <summary>
/// Connects to the first reachable server in the endpoint list.
/// </summary>
public async Task<Session> ConnectAsync()
{
for (int attempt = 0; attempt < _endpointUrls.Length; attempt++)
{
var idx = (_currentIndex + attempt) % _endpointUrls.Length;
var url = _endpointUrls[idx];
try
{
Console.WriteLine($" [failover] Connecting to {url}...");
_session = await OpcUaHelper.ConnectAsync(url, _username, _password, _security);
_currentIndex = idx;
Console.WriteLine($" [failover] Connected to {url}");
return _session;
}
catch (Exception ex)
{
Console.WriteLine($" [failover] Failed to connect to {url}: {ex.Message}");
}
}
throw new InvalidOperationException("All redundant servers are unreachable.");
}
/// <summary>
/// Attempts to fail over to the next available server in the set.
/// Closes the old session if still open.
/// </summary>
/// <returns>The new session.</returns>
public async Task<Session> FailoverAsync()
{
var oldUrl = CurrentEndpointUrl;
// Close old session
if (_session != null)
{
try { _session.Close(); } catch { }
_session.Dispose();
_session = null;
}
// Try the next server first, then cycle through all
_currentIndex = (_currentIndex + 1) % _endpointUrls.Length;
var newSession = await ConnectAsync();
OnFailover?.Invoke(oldUrl, CurrentEndpointUrl!);
return newSession;
}
/// <summary>
/// Installs a KeepAlive handler that triggers automatic failover when the session drops.
/// Returns a task that completes when the session is lost and failover is needed.
/// </summary>
public void InstallKeepAliveHandler(Action<Session> onReconnected)
{
if (_session == null) return;
_session.KeepAlive += async (session, e) =>
{
if (e.Status == null || ServiceResult.IsGood(e.Status))
return;
Console.WriteLine($" [failover] Session lost (status={e.Status}). Attempting failover...");
try
{
var newSession = await FailoverAsync();
onReconnected(newSession);
}
catch (Exception ex)
{
Console.WriteLine($" [failover] All servers unreachable: {ex.Message}");
}
};
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_session != null)
{
try { _session.Close(); } catch { }
_session.Dispose();
}
}
}
/// <summary>
/// Parses the --failover-urls option into an array of endpoint URLs.
/// If failover URLs are provided, the primary URL is prepended to form the full set.
/// If not provided, returns only the primary URL (no failover).
/// </summary>
public static class FailoverUrlParser
{
public static string[] Parse(string primaryUrl, string? failoverUrls)
{
if (string.IsNullOrWhiteSpace(failoverUrls))
return new[] { primaryUrl };
var urls = new List<string> { primaryUrl };
foreach (var url in failoverUrls.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = url.Trim();
if (!string.IsNullOrEmpty(trimmed) && trimmed != primaryUrl)
urls.Add(trimmed);
}
return urls.ToArray();
}
}