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>
157 lines
5.0 KiB
C#
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();
|
|
}
|
|
}
|