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>
This commit is contained in:
156
tools/opcuacli-dotnet/OpcUaFailoverHelper.cs
Normal file
156
tools/opcuacli-dotnet/OpcUaFailoverHelper.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user