/* ========================================================================
* 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.CommandLine;
using System.Globalization;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Crdt;
using Crdt.Transport;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Client.Historian;
using Opc.Ua.Client.Redundancy;
using Opc.Ua.Configuration;
using Opc.Ua.Redundancy;
using Opc.Ua.Redundancy.Client;
using Raft;
using Raft.Configuration;
using Raft.Storage;
using Raft.Transport.NanoMsg;
namespace RedundantClient
{
///
/// Entry point for the managed client sample.
///
public static partial class Program
{
///
/// Starts the sample.
///
/// The command-line arguments supplied to the sample.
/// The process exit code returned by the command-line parser.
public static Task Main(string[] args)
{
var serverOption = new Option("--server", "-s")
{
Description = "Discovery URL of any server in the (optionally) redundant set.",
DefaultValueFactory = _ => "opc.tcp://localhost:62543/RedundantServer"
};
var noSecurityOption = new Option("--nosecurity")
{
Description = "Select endpoints with MessageSecurityMode.None."
};
var autoAcceptOption = new Option("--autoaccept")
{
Description = "Automatically accept untrusted server certificates for sample runs."
};
var durationOption = new Option("--duration", "-d")
{
Description = "How long to monitor before exiting. Use 00:00:00 to run until Ctrl+C.",
DefaultValueFactory = _ => TimeSpan.FromMinutes(2)
};
var suiteOption = new Option("--suite")
{
Description = "Run a browse/read/subscribe workload against the redundant session."
};
var historyOption = new Option("--history")
{
Description =
"Exercise raw, event, and processed history continuations across an active-server failover."
};
var identityOption = new Option("--identity")
{
Description =
"Cache factory-assigned NodeIds, then read and monitor them unchanged after server failover."
};
var historyFailoverDelayOption = new Option("--history-failover-delay")
{
Description =
"How long the history scenario pauses after opening continuations so an active replica can fail.",
DefaultValueFactory = _ => TimeSpan.FromSeconds(15)
};
var rootCommand = new RootCommand(
"OPC UA managed client sample that transparently handles server redundancy")
{
serverOption,
noSecurityOption,
autoAcceptOption,
durationOption,
suiteOption,
historyOption,
identityOption,
historyFailoverDelayOption
};
rootCommand.SetAction(async (parseResult, cancellationToken) => await RunAsync(
parseResult.GetValue(serverOption)!,
parseResult.GetValue(noSecurityOption),
parseResult.GetValue(autoAcceptOption),
parseResult.GetValue(durationOption),
parseResult.GetValue(suiteOption),
parseResult.GetValue(historyOption),
parseResult.GetValue(identityOption),
parseResult.GetValue(historyFailoverDelayOption),
cancellationToken).ConfigureAwait(false));
ParseResult parseResult = rootCommand.Parse(args);
return parseResult.InvokeAsync(new InvocationConfiguration(), CancellationToken.None);
}
private static async Task RunAsync(
string serverUrl,
bool noSecurity,
bool autoAccept,
TimeSpan duration,
bool suite,
bool history,
bool identity,
TimeSpan historyFailoverDelay,
CancellationToken ct)
{
ITelemetryContext telemetry = DefaultTelemetry.Create(builder => builder.SetMinimumLevel(LogLevel.Information));
using var telemetryDisposable = telemetry as IDisposable;
var application = new ApplicationInstance(telemetry)
{
ApplicationName = kApplicationName,
ApplicationType = ApplicationType.Client,
ConfigSectionName = kConfigSectionName,
CertificatePasswordProvider = new CertificatePasswordProvider([])
};
await using (application.ConfigureAwait(false))
{
// Resolve the configuration next to the application binaries (the
// file is copied to the output directory) so the sample runs from
// any working directory, e.g. `dotnet run --project ...` invoked
// from the repository root.
string configFilePath = System.IO.Path.Combine(
AppContext.BaseDirectory, kConfigSectionName + ".Config.xml");
ApplicationConfiguration configuration = await application
.LoadApplicationConfigurationAsync(configFilePath, silent: false, ct: ct)
.ConfigureAwait(false);
if (autoAccept)
{
configuration.CertificateManager.AcceptError = (_, _) => true;
}
bool haveCertificate = await application
.CheckApplicationInstanceCertificatesAsync(silent: false, ct: ct)
.ConfigureAwait(false);
if (!haveCertificate)
{
throw new InvalidOperationException("Application instance certificate invalid.");
}
// Wait for a reachable endpoint rather than failing if the server set is not
// up yet - the client and server containers start independently (no compose
// depends_on across the HA matrix), so the client tolerates a lagging server.
EndpointDescription selectedEndpoint = await SelectEndpointWithRetryAsync(
configuration, serverUrl, useSecurity: !noSecurity, telemetry, ct)
.ConfigureAwait(false);
var endpoint = new ConfiguredEndpoint(
null,
selectedEndpoint,
EndpointConfiguration.Create(configuration));
// A coordinated client replica set (CLIENT_MODE=eventual|strong) elects one
// active client that holds the session and shares its session secrets; the
// others stand by and take over on active-client loss. The default
// (CLIENT_MODE=independent) is a plain managed client that fails over on its own.
string clientMode = (Environment.GetEnvironmentVariable("CLIENT_MODE") ?? "independent")
.Trim().ToLowerInvariant();
if (clientMode is "eventual" or "strong")
{
if (history || identity)
{
throw new InvalidOperationException(
"The --history and --identity scenarios require CLIENT_MODE=independent so one managed " +
"session can retain its state across server failover.");
}
await RunCoordinatedClientAsync(
clientMode, configuration, endpoint, telemetry, serverUrl, suite, duration, ct)
.ConfigureAwait(false);
return;
}
Console.WriteLine("Connecting managed client to {0}", serverUrl);
// Create a normal managed session and opt it into server redundancy
// handling — that's it. WithServerRedundancy() lets the session discover
// the redundant set (if any) from the connected server and fail over
// transparently; against a server that is not configured for redundancy it
// simply behaves as a resilient reconnecting session. The caller does not
// need to know the server topology before connecting.
ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
.UseEndpoint(endpoint)
.WithSessionName(kApplicationName)
.WithUserIdentity(new UserIdentity())
.WithReconnectPolicy(options => options with
{
Strategy = BackoffStrategy.Constant,
InitialDelay = TimeSpan.FromMilliseconds(500),
MaxDelay = TimeSpan.FromMilliseconds(500),
MaxRetries = 3
})
.WithServerRedundancy()
.WithTokenReuseFailover()
.ConnectAsync(ct)
.ConfigureAwait(false);
await using (session.ConfigureAwait(false))
{
var haMonitor = new HaMonitor();
void OnConnState(object? s, ConnectionStateChangedEventArgs e)
=> haMonitor.OnConnectionStateChanged(
e, session.ConfiguredEndpoint?.EndpointUrl?.ToString());
session.ConnectionStateChanged += OnConnState;
await LogRedundancyInfoAsync(session, telemetry, ct).ConfigureAwait(false);
if (suite)
{
await RunClientSuiteAsync(session, haMonitor, ct).ConfigureAwait(false);
}
else
{
await SubscribeToCurrentTimeAsync(session, haMonitor, ct).ConfigureAwait(false);
}
if (identity)
{
await RunIdentityFailoverScenarioAsync(session, ct).ConfigureAwait(false);
session.ConnectionStateChanged -= OnConnState;
return;
}
if (history)
{
await RunHistorianFailoverScenarioAsync(
session,
historyFailoverDelay,
ct).ConfigureAwait(false);
session.ConnectionStateChanged -= OnConnState;
return;
}
Console.WriteLine(
"Monitoring ServerStatus.CurrentTime and the replicated HighAvailability.Counter / " +
"ActiveReplica. Failover and data-loss events are logged as they happen. " +
"Press Ctrl+C to stop.");
await RunForDurationAsync(duration, ct).ConfigureAwait(false);
session.ConnectionStateChanged -= OnConnState;
}
}
}
private static async Task LogRedundancyInfoAsync(
ISession session,
ITelemetryContext telemetry,
CancellationToken ct)
{
var handler = new DefaultServerRedundancyHandler(
new DefaultRedundantServerEndpointResolver(telemetry));
ServerRedundancyInfo info = await handler
.FetchRedundancyInfoAsync(session, ct)
.ConfigureAwait(false);
if (info.Mode == RedundancySupport.None)
{
Console.WriteLine(
"Server is not configured for redundancy (RedundancySupport=None); " +
"running as a single resilient session.");
return;
}
Console.WriteLine(
"Server reports RedundancySupport={0}, ServiceLevel={1} ({2}), CurrentServerId={3}.",
info.Mode,
info.ServiceLevel,
info.ServiceLevelSubrange,
info.CurrentServerId);
for (int ii = 0; ii < info.RedundantServers.Count; ii++)
{
RedundantServer server = info.RedundantServers[ii];
Console.WriteLine(
"Peer {0}: uri={1}, state={2}, serviceLevel={3}, endpoint={4}",
ii + 1,
server.ServerUri,
server.ServerState,
server.ServiceLevel,
server.Endpoint?.EndpointUrl?.ToString() ?? "(unresolved)");
}
}
private static async Task RunCoordinatedClientAsync(
string mode,
ApplicationConfiguration configuration,
ConfiguredEndpoint endpoint,
ITelemetryContext telemetry,
string serverUrl,
bool suite,
TimeSpan duration,
CancellationToken ct)
{
string nodeId = Environment.GetEnvironmentVariable("CLIENT_NODE_ID") ?? Dns.GetHostName();
bool strong = string.Equals(mode, "strong", StringComparison.Ordinal);
var haMonitor = new HaMonitor();
// Elect the active client with a real Raft quorum among the client replicas - the
// same building blocks the server uses, mirrored on the client side. A coordinated
// set shares the leader's protected session secrets through a networked store, so it
// fails closed without a record protector (see CreateClientRecordProtector).
DefaultRaftConsensus consensus = BuildClientRaftCluster();
AesCbcHmacRecordProtector protector = CreateClientRecordProtector();
ISharedKeyValueStore store = CreateClientSharedStore(strong, nodeId, consensus);
// Ownership of the election transfers to the RedundantClientSession's coordinator,
// which disposes it in DisposeAsync (see ClientReplicaCoordinator).
#pragma warning disable CA2000
var election = new RaftLeaderElection(consensus, telemetry.CreateLogger());
#pragma warning restore CA2000
try
{
RedundantClientSession session = new RedundantClientSessionBuilder(telemetry)
.WithNodeId(nodeId)
.WithStandbyMode(ClientStandbyMode.Cold)
.UseSession(token => ConnectLeaderSessionAsync(configuration, endpoint, telemetry, token))
.ConfigureLeader(async (leaderSession, fastActivated, cfgCt) =>
{
Console.WriteLine(
"ACTIVE CLIENT: replica '{0}' is now the active client; establishing monitoring.",
nodeId);
await LogRedundancyInfoAsync(leaderSession, telemetry, cfgCt).ConfigureAwait(false);
if (suite)
{
await RunClientSuiteAsync(leaderSession, haMonitor, cfgCt).ConfigureAwait(false);
}
else
{
await SubscribeToCurrentTimeAsync(leaderSession, haMonitor, cfgCt)
.ConfigureAwait(false);
}
})
.UseRedundancy(election, store, protector)
.Build();
await using (session.ConfigureAwait(false))
{
session.RoleChanged += haMonitor.OnRoleChanged;
await session.StartAsync(ct).ConfigureAwait(false);
Console.WriteLine(
"Coordinated client replica '{0}' started ({1} shared store) against {2}. Exactly one " +
"replica is active; on active-client loss a standby is promoted and resumes monitoring. " +
"Failover and data-loss events are logged as they happen. Press Ctrl+C to stop.",
nodeId, mode, serverUrl);
await RunForDurationAsync(duration, ct).ConfigureAwait(false);
session.RoleChanged -= haMonitor.OnRoleChanged;
}
}
finally
{
// The coordinator disposes the election on session dispose; dispose the shared
// store, the record protector, and the Raft consensus here (the stores hold the
// consensus with ownsConsensus:false).
if (store is IAsyncDisposable disposableStore)
{
await disposableStore.DisposeAsync().ConfigureAwait(false);
}
protector.Dispose();
await consensus.DisposeAsync().ConfigureAwait(false);
}
}
private static async ValueTask ConnectLeaderSessionAsync(
ApplicationConfiguration configuration,
ConfiguredEndpoint endpoint,
ITelemetryContext telemetry,
CancellationToken ct)
{
for (int attempt = 1; ; attempt++)
{
try
{
return await new ManagedSessionBuilder(configuration, telemetry)
.UseEndpoint(endpoint)
.WithSessionName(kApplicationName)
.WithUserIdentity(new UserIdentity())
.WithReconnectPolicy(options => options with
{
Strategy = BackoffStrategy.Constant,
InitialDelay = TimeSpan.FromMilliseconds(500),
MaxDelay = TimeSpan.FromMilliseconds(500),
MaxRetries = 3
})
.WithServerRedundancy()
.WithTokenReuseFailover()
.ConnectAsync(ct)
.ConfigureAwait(false);
}
catch (Exception ex) when (attempt < 30 && !ct.IsCancellationRequested)
{
Console.WriteLine(
"Active client connect attempt {0} failed ({1}); retrying...", attempt, ex.Message);
await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false);
}
}
}
private static ISharedKeyValueStore CreateClientSharedStore(
bool strong, string nodeId, IRaftConsensus consensus)
{
// CA2000: ownership of the stores created here transfers to the returned store (the
// Hybrid owns its inner stores via ownsStores:true) and to the caller, which disposes
// it. The shared Raft consensus is NOT owned by these stores (ownsConsensus:false); the
// caller disposes it after the store.
#pragma warning disable CA2000
var raftStore = new RaftSharedKeyValueStore(consensus, ownsConsensus: false);
if (strong)
{
return raftStore;
}
(IPAddress address, int port, List peers) = ReadClientGossip();
var gossip = new TcpGossipTransport(new TcpGossipTransportOptions { Address = address, Port = port });
gossip.AddPeers(peers);
var replicated = new ReplicatedSharedKeyValueStore(
ReplicaIdFromNodeId(nodeId), gossip, TimeProvider.System, CrdtReaderOptions.Default);
return new HybridSharedKeyValueStore(replicated, raftStore, default, ownsStores: true);
#pragma warning restore CA2000
}
private static AesCbcHmacRecordProtector CreateClientRecordProtector()
{
// A coordinated client set mirrors the leader's session secrets through a networked
// store, so ClientReplicaCoordinator fails closed on a NullRecordProtector. Use the
// shared CLIENT_RECORD_KEY when supplied; otherwise, for an explicit isolated demo
// (CLIENT_INSECURE=true), derive a well-known NON-SECRET demo key so all replicas
// agree - never do this in production.
string? recordKeyBase64 = Environment.GetEnvironmentVariable("CLIENT_RECORD_KEY");
if (!string.IsNullOrWhiteSpace(recordKeyBase64))
{
return new AesCbcHmacRecordProtector(Convert.FromBase64String(recordKeyBase64));
}
bool insecure = bool.TryParse(
Environment.GetEnvironmentVariable("CLIENT_INSECURE"), out bool value) &&
value;
if (insecure)
{
Console.Error.WriteLine(
"[HA][WARNING] CLIENT_INSECURE=true: protecting mirrored client session secrets with a " +
"well-known, NON-SECRET demo key derived from a constant. Use only for an isolated demo; " +
"set CLIENT_RECORD_KEY to a shared base64 32-byte key in production.");
byte[] demoKey = SHA256.HashData(
Encoding.UTF8.GetBytes("OPCFoundation/RedundantClient/insecure-demo/record-key"));
return new AesCbcHmacRecordProtector(demoKey);
}
throw new InvalidOperationException(
"A coordinated client set mirrors session secrets through a networked store and requires a " +
"record protector. Set CLIENT_RECORD_KEY to a shared base64 32-byte key (the same value on " +
"every client replica) to encrypt mirrored secrets, or set CLIENT_INSECURE=true to run this " +
"isolated demo without a real key.");
}
private static DefaultRaftConsensus BuildClientRaftCluster()
{
ulong raftId = ulong.TryParse(
Environment.GetEnvironmentVariable("CLIENT_RAFT_ID"), out ulong id) ? id : 1;
List peers = ReadEnvList("CLIENT_RAFT_PEERS");
int members = int.TryParse(
Environment.GetEnvironmentVariable("CLIENT_RAFT_MEMBERS"), out int m) ? m : peers.Count + 1;
string bind = Environment.GetEnvironmentVariable("CLIENT_RAFT_BIND") ?? "tcp://0.0.0.0:6561";
var memberIds = new List(members);
for (int i = 1; i <= members; i++)
{
memberIds.Add((ulong)i);
}
var transportOptions = new NanoMsgBusTransportOptions { BindAddress = bind };
foreach (string peer in peers)
{
transportOptions.Peers.Add(peer);
}
// The DefaultRaftConsensus adapter owns the node (which disposes the transport);
// MemoryStorage is volatile, so a restarted replica re-syncs from the leader.
#pragma warning disable CA2000
var transport = new NanoMsgBusTransport(transportOptions);
var storage = new MemoryStorage(new ConfState(memberIds));
return DefaultRaftConsensus.CreateCluster(
raftId,
transport,
storage,
new RaftNodeOptions { TickInterval = TimeSpan.FromMilliseconds(50) },
config =>
{
config.ElectionTick = 10;
config.PreVote = true;
config.CheckQuorum = true;
},
TimeSpan.FromSeconds(30));
#pragma warning restore CA2000
}
private static (IPAddress Address, int Port, List Peers) ReadClientGossip()
{
int port = int.TryParse(
Environment.GetEnvironmentVariable("CLIENT_GOSSIP_PORT"), out int p) ? p : 4841;
var peers = new List();
foreach (string peer in ReadEnvList("CLIENT_GOSSIP_PEERS"))
{
peers.Add(ParseGossipEndpoint(peer));
}
return (IPAddress.Any, port, peers);
}
private static IPEndPoint ParseGossipEndpoint(string hostPort)
{
int separator = hostPort.LastIndexOf(':');
if (separator <= 0 || separator == hostPort.Length - 1)
{
throw new FormatException($"Invalid gossip endpoint '{hostPort}'; expected host:port.");
}
string host = hostPort[..separator];
int port = int.Parse(hostPort[(separator + 1)..], CultureInfo.InvariantCulture);
IPAddress address = IPAddress.TryParse(host, out IPAddress? ip)
? ip
: Dns.GetHostAddresses(host)[0];
return new IPEndPoint(address, port);
}
private static ReplicaId ReplicaIdFromNodeId(string nodeId)
{
// Derive a stable replica identity from the node id so it survives restarts.
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(nodeId));
return new ReplicaId(new Guid(hash.AsSpan(0, 16).ToArray()));
}
private static List ReadEnvList(string key)
{
var items = new List();
string? value = Environment.GetEnvironmentVariable(key);
if (string.IsNullOrWhiteSpace(value))
{
return items;
}
items.AddRange(value.Split(
[',', ';'],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
return items;
}
private static async Task SelectEndpointWithRetryAsync(
ApplicationConfiguration configuration,
string serverUrl,
bool useSecurity,
ITelemetryContext telemetry,
CancellationToken ct)
{
const int maxAttempts = 60;
for (int attempt = 1; ; attempt++)
{
try
{
EndpointDescription? selected = await CoreClientUtils
.SelectEndpointAsync(configuration, serverUrl, useSecurity, telemetry, ct)
.ConfigureAwait(false);
if (selected != null)
{
return selected;
}
}
catch (Exception ex) when (attempt < maxAttempts && !ct.IsCancellationRequested)
{
Console.WriteLine(
"Waiting for server '{0}' (attempt {1}/{2}): {3}",
serverUrl, attempt, maxAttempts, ex.Message);
}
if (attempt >= maxAttempts)
{
throw new InvalidOperationException($"No endpoint could be selected for '{serverUrl}'.");
}
await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false);
}
}
private static async Task RunClientSuiteAsync(
TSession session, HaMonitor monitor, CancellationToken ct)
where TSession : ISession
{
// A compact browse / read / subscribe workload against the redundant session. It
// mirrors the samples/Reference/ConsoleReferenceClient ClientSamples suite but is kept
// inline so this sample stays self-contained and NativeAOT-publishable.
Console.WriteLine("Suite: browsing the Objects folder...");
var browseDescription = new BrowseDescription
{
NodeId = ObjectIds.ObjectsFolder,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
ResultMask = (uint)BrowseResultMask.All
};
BrowseDescription[] nodesToBrowse = [browseDescription];
BrowseResponse browseResponse = await session
.BrowseAsync(null, null, 0u, nodesToBrowse, ct)
.ConfigureAwait(false);
if (browseResponse.Results.Count > 0)
{
ArrayOf references = browseResponse.Results[0].References;
for (int ii = 0; ii < references.Count; ii++)
{
Console.WriteLine(" {0} ({1})", references[ii].DisplayName, references[ii].NodeClass);
}
}
Console.WriteLine("Suite: reading server status nodes...");
ReadValueId[] nodesToRead =
[
new ReadValueId { NodeId = VariableIds.Server_ServerStatus_State, AttributeId = Attributes.Value },
new ReadValueId { NodeId = VariableIds.Server_NamespaceArray, AttributeId = Attributes.Value },
new ReadValueId { NodeId = VariableIds.Server_ServerStatus_CurrentTime, AttributeId = Attributes.Value }
];
ReadResponse readResponse = await session
.ReadAsync(null, 0, TimestampsToReturn.Both, nodesToRead, ct)
.ConfigureAwait(false);
for (int ii = 0; ii < readResponse.Results.Count; ii++)
{
Console.WriteLine(" {0} = {1}", nodesToRead[ii].NodeId, readResponse.Results[ii].WrappedValue);
}
Console.WriteLine("Suite: subscribing to data changes...");
await SubscribeToCurrentTimeAsync(session, monitor, ct).ConfigureAwait(false);
}
private static async Task SubscribeToCurrentTimeAsync(
TSession session, HaMonitor monitor, CancellationToken ct)
where TSession : ISession
{
// The managed session uses the V2 subscription engine, which delivers
// notifications through an ISubscriptionNotificationHandler registered
// with the session's subscription manager. The classic
// Subscription.FastDataChangeCallback delegate is NOT invoked by the V2
// engine, so register a handler here to log the data changes.
if (!session.TryGetSubscriptionManager(
out Opc.Ua.Client.Subscriptions.ISubscriptionManager? manager))
{
Console.WriteLine(
"Session does not expose the V2 subscription manager; cannot monitor.");
return;
}
Opc.Ua.Client.Subscriptions.ISubscription subscription = manager.Add(
new MonitoringHandler(monitor),
new OptionsMonitor(
new Opc.Ua.Client.Subscriptions.SubscriptionOptions
{
PublishingInterval = TimeSpan.FromSeconds(1),
PublishingEnabled = true,
KeepAliveCount = 10,
LifetimeCount = 100
}));
subscription.TryAddMonitoredItem(
"ServerStatus.CurrentTime",
VariableIds.Server_ServerStatus_CurrentTime,
o => o with
{
SamplingInterval = TimeSpan.FromSeconds(1),
QueueSize = 10,
DiscardOldest = true
},
out _);
// Also monitor the replicated "Counter" value from the HA sample node
// manager. The active replica increments it and mirrors it to the
// standbys, so its value continues across a failover - a visible
// demonstration of distributed address-space state replication.
int haNamespaceIndex = session.NamespaceUris.GetIndex(
"http://opcfoundation.org/UA/Samples/HighAvailability");
if (haNamespaceIndex >= 0)
{
subscription.TryAddMonitoredItem(
"HighAvailability.Counter",
new NodeId("Counter", (ushort)haNamespaceIndex),
o => o with
{
SamplingInterval = TimeSpan.FromSeconds(1),
QueueSize = 10,
DiscardOldest = true
},
out _);
// Monitor which replica is currently serving this session. In
// active/active every replica writes its own node id here, so the
// value the client sees identifies the connected replica; when it
// changes, the session has failed over to a different server.
subscription.TryAddMonitoredItem(
"HighAvailability.ActiveReplica",
new NodeId("ActiveReplica", (ushort)haNamespaceIndex),
o => o with
{
SamplingInterval = TimeSpan.FromSeconds(1),
QueueSize = 10,
DiscardOldest = true
},
out _);
}
// The V2 engine creates the subscription and its monitored items on the
// server asynchronously; wait briefly so monitoring is active by the
// time this method returns.
for (int i = 0; i < 100 && !subscription.Created; i++)
{
await Task.Delay(50, ct).ConfigureAwait(false);
}
}
private static async Task RunHistorianFailoverScenarioAsync(
ManagedSession session,
TimeSpan failoverDelay,
CancellationToken ct)
{
if (failoverDelay < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(failoverDelay),
"The history failover delay cannot be negative.");
}
int namespaceIndex = session.NamespaceUris.GetIndex(kHighAvailabilityNamespaceUri);
if (namespaceIndex < 0)
{
throw new ServiceResultException(
StatusCodes.BadNodeIdUnknown,
$"The server does not expose '{kHighAvailabilityNamespaceUri}'.");
}
var counterNodeId = new NodeId("Counter", (ushort)namespaceIndex);
var eventNotifierId = new NodeId("HistoryEvents", (ushort)namespaceIndex);
var client = new HistoryClient(session);
EventFilter eventFilter = CreateHistoryEventFilter();
DateTime startTime = DateTime.UtcNow.AddMinutes(-5);
DateTime endTime = DateTime.UtcNow.AddSeconds(1);
await WaitForHistoricalDepthAsync(
client,
counterNodeId,
eventNotifierId,
eventFilter,
startTime,
endTime,
ct).ConfigureAwait(false);
DateTime visibilityMarkerTime = DateTime.UtcNow.AddMinutes(-4);
await InsertAndVerifyVisibilityMarkerAsync(
client,
counterNodeId,
visibilityMarkerTime,
"active",
ct).ConfigureAwait(false);
endTime = DateTime.UtcNow.AddSeconds(1);
startTime = endTime.AddSeconds(-30);
(
DateTime processedStartTime,
DateTime processedEndTime,
double processingInterval) = await GetProcessedWindowAsync(
client,
counterNodeId,
startTime,
endTime,
ct).ConfigureAwait(false);
var expectedProcessingInterval =
TimeSpan.FromMilliseconds(processingInterval);
IAsyncEnumerator raw = client.ReadRawAsync(
counterNodeId,
startTime,
endTime,
maxValuesPerNode: 1,
timestampsToReturn: TimestampsToReturn.Both,
cancellationToken: ct).GetAsyncEnumerator(ct);
IAsyncEnumerator events = client.ReadEventsAsync(
eventNotifierId,
startTime,
endTime,
eventFilter,
maxValuesPerNode: 1,
timestampsToReturn: TimestampsToReturn.Both,
cancellationToken: ct).GetAsyncEnumerator(ct);
IAsyncEnumerator processed = client.ReadProcessedAsync(
counterNodeId,
ObjectIds.AggregateFunction_Average,
processedStartTime,
processedEndTime,
processingInterval,
timestampsToReturn: TimestampsToReturn.Both,
cancellationToken: ct).GetAsyncEnumerator(ct);
var reconnected = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
int connectionInterrupted = 0;
void OnConnectionStateChanged(object? _, ConnectionStateChangedEventArgs args)
{
if (args.NewState is ConnectionState.Reconnecting or ConnectionState.Failover)
{
Interlocked.Exchange(ref connectionInterrupted, 1);
}
else if (args.NewState == ConnectionState.Connected &&
Volatile.Read(ref connectionInterrupted) != 0)
{
reconnected.TrySetResult(true);
}
}
session.ConnectionStateChanged += OnConnectionStateChanged;
try
{
await using (raw.ConfigureAwait(false))
await using (events.ConfigureAwait(false))
await using (processed.ConfigureAwait(false))
{
if (!await raw.MoveNextAsync().ConfigureAwait(false) ||
!await events.MoveNextAsync().ConfigureAwait(false) ||
!await processed.MoveNextAsync().ConfigureAwait(false))
{
throw new ServiceResultException(
StatusCodes.BadNoData,
"The server did not return the first page for every historical read.");
}
int rawCount = 0;
int eventCount = 0;
int processedCount = 0;
DateTime lastRawTime = DateTime.MinValue;
DateTime lastEventTime = DateTime.MinValue;
DateTime lastProcessedTime = DateTime.MinValue;
int lastRawCounter = 0;
int lastEventCounter = 0;
var eventIds = new HashSet();
ObserveRawValue(
raw.Current,
ref lastRawTime,
ref lastRawCounter,
ref rawCount);
ObserveHistoryEvent(
events.Current,
eventIds,
ref lastEventTime,
ref lastEventCounter,
ref eventCount);
ObserveProcessedValue(
processed.Current,
expectedProcessingInterval,
ref lastProcessedTime,
ref processedCount);
Console.WriteLine(
"HISTORY: portable continuations ready (raw, event, processed); " +
"remove the active server now.");
await Task.Delay(failoverDelay, ct).ConfigureAwait(false);
if (Volatile.Read(ref connectionInterrupted) != 0)
{
await reconnected.Task
.WaitAsync(TimeSpan.FromSeconds(90), ct)
.ConfigureAwait(false);
}
while (await raw.MoveNextAsync().ConfigureAwait(false))
{
ObserveRawValue(
raw.Current,
ref lastRawTime,
ref lastRawCounter,
ref rawCount);
}
while (await events.MoveNextAsync().ConfigureAwait(false))
{
ObserveHistoryEvent(
events.Current,
eventIds,
ref lastEventTime,
ref lastEventCounter,
ref eventCount);
}
while (await processed.MoveNextAsync().ConfigureAwait(false))
{
ObserveProcessedValue(
processed.Current,
expectedProcessingInterval,
ref lastProcessedTime,
ref processedCount);
}
if (rawCount < 5 || eventCount < 5 || processedCount < 3)
{
throw new ServiceResultException(
StatusCodes.BadNoData,
$"Insufficient resumed history: raw={rawCount}, event={eventCount}, " +
$"processed={processedCount}.");
}
await WaitForVisibilityMarkerAsync(
client,
counterNodeId,
visibilityMarkerTime,
"promoted",
ct).ConfigureAwait(false);
await WaitForPromotedWriterHistoryAsync(
client,
counterNodeId,
eventNotifierId,
eventFilter,
lastRawCounter,
lastEventCounter,
ct).ConfigureAwait(false);
Console.WriteLine(
"HISTORY HA OK: raw={0}, event={1}, processed={2}; portable continuations resumed " +
"without duplicates or gaps.",
rawCount,
eventCount,
processedCount);
}
}
finally
{
session.ConnectionStateChanged -= OnConnectionStateChanged;
}
}
private static async Task InsertAndVerifyVisibilityMarkerAsync(
HistoryClient client,
NodeId counterNodeId,
DateTime markerTime,
string replicaRole,
CancellationToken ct)
{
var marker = new DataValue(
Variant.From(kHistoryVisibilityMarker),
StatusCodes.Good,
markerTime,
markerTime);
ArrayOf statuses = await client.InsertAsync(
counterNodeId,
[marker],
ct).ConfigureAwait(false);
if (statuses.Count != 1 || StatusCode.IsBad(statuses[0]))
{
StatusCode status = statuses.Count == 1
? statuses[0]
: StatusCodes.BadUnexpectedError;
throw new ServiceResultException(
status,
"The historical visibility marker write was rejected.");
}
await VerifyVisibilityMarkerAsync(
client,
counterNodeId,
markerTime,
replicaRole,
ct).ConfigureAwait(false);
}
private static async Task WaitForVisibilityMarkerAsync(
HistoryClient client,
NodeId counterNodeId,
DateTime markerTime,
string replicaRole,
CancellationToken ct)
{
ServiceResultException? lastError = null;
for (int attempt = 0; attempt < 60; attempt++)
{
try
{
await VerifyVisibilityMarkerAsync(
client,
counterNodeId,
markerTime,
replicaRole,
ct).ConfigureAwait(false);
return;
}
catch (ServiceResultException exception)
{
lastError = exception;
}
await Task.Delay(TimeSpan.FromMilliseconds(500), ct).ConfigureAwait(false);
}
throw new ServiceResultException(
lastError?.StatusCode ?? StatusCodes.BadNoData,
$"The historical visibility marker was not readable from the {replicaRole} replica.");
}
private static async Task VerifyVisibilityMarkerAsync(
HistoryClient client,
NodeId counterNodeId,
DateTime markerTime,
string replicaRole,
CancellationToken ct)
{
int count = 0;
await foreach (DataValue value in client.ReadRawAsync(
counterNodeId,
markerTime.AddMilliseconds(-1),
markerTime.AddMilliseconds(1),
maxValuesPerNode: 1,
cancellationToken: ct).ConfigureAwait(false))
{
if (!value.WrappedValue.TryGetValue(out int marker) ||
marker != kHistoryVisibilityMarker ||
value.SourceTimestamp.ToDateTime() != markerTime)
{
throw new ServiceResultException(
StatusCodes.BadUnexpectedError,
"The historical visibility marker read returned an unknown value.");
}
count++;
}
if (count != 1)
{
throw new ServiceResultException(
StatusCodes.BadNoData,
$"Expected one historical visibility marker but received {count}.");
}
Console.WriteLine(
"HISTORY: write/read marker {0} visible on {1} replica.",
kHistoryVisibilityMarker,
replicaRole);
}
private static async Task WaitForPromotedWriterHistoryAsync(
HistoryClient client,
NodeId counterNodeId,
NodeId eventNotifierId,
EventFilter eventFilter,
int previousRawCounter,
int previousEventCounter,
CancellationToken ct)
{
for (int attempt = 0; attempt < 60; attempt++)
{
int newestRawCounter = previousRawCounter;
int newestEventCounter = previousEventCounter;
DateTime endTime = DateTime.UtcNow.AddSeconds(1);
DateTime startTime = endTime.AddSeconds(-30);
await foreach (DataValue value in client.ReadRawAsync(
counterNodeId,
startTime,
endTime,
maxValuesPerNode: 2,
cancellationToken: ct).ConfigureAwait(false))
{
if (!StatusCode.IsGood(value.StatusCode) ||
!value.WrappedValue.TryGetValue(out int counter))
{
throw new ServiceResultException(
StatusCodes.BadDecodingError,
"Promoted-writer raw history returned an unknown value.");
}
newestRawCounter = Math.Max(newestRawCounter, counter);
}
await foreach (HistoryEventFieldList historicalEvent in
client.ReadEventsAsync(
eventNotifierId,
startTime,
endTime,
eventFilter,
maxValuesPerNode: 2,
cancellationToken: ct).ConfigureAwait(false))
{
if (!TryGetHistoryEventCounter(
historicalEvent,
out int counter))
{
throw new ServiceResultException(
StatusCodes.BadDecodingError,
"Promoted-writer event history returned an unknown event.");
}
newestEventCounter = Math.Max(
newestEventCounter,
counter);
}
if (newestRawCounter > previousRawCounter &&
newestEventCounter > previousEventCounter)
{
Console.WriteLine(
"HISTORY: promoted writer added shared raw and event history " +
"(counter {0}, event {1}).",
newestRawCounter,
newestEventCounter);
return;
}
await Task.Delay(TimeSpan.FromMilliseconds(500), ct).ConfigureAwait(false);
}
throw new ServiceResultException(
StatusCodes.BadNoData,
"The promoted replica did not add new raw and event history within 30 seconds.");
}
private static async Task WaitForHistoricalDepthAsync(
HistoryClient client,
NodeId counterNodeId,
NodeId eventNotifierId,
EventFilter eventFilter,
DateTime startTime,
DateTime endTime,
CancellationToken ct)
{
for (int attempt = 0; attempt < 60; attempt++)
{
int rawCount = await CountAtLeastAsync(
client.ReadRawAsync(
counterNodeId,
startTime,
endTime,
maxValuesPerNode: 1,
cancellationToken: ct),
minimum: 5,
ct).ConfigureAwait(false);
int eventCount = await CountAtLeastAsync(
client.ReadEventsAsync(
eventNotifierId,
startTime,
endTime,
eventFilter,
maxValuesPerNode: 1,
cancellationToken: ct),
minimum: 5,
ct).ConfigureAwait(false);
if (rawCount >= 5 && eventCount >= 5)
{
return;
}
await Task.Delay(TimeSpan.FromMilliseconds(500), ct).ConfigureAwait(false);
endTime = DateTime.UtcNow.AddSeconds(1);
}
throw new ServiceResultException(
StatusCodes.BadNoData,
"The active server did not produce enough raw and event history within 30 seconds.");
}
private static async Task<(
DateTime StartTime,
DateTime EndTime,
double ProcessingInterval)> GetProcessedWindowAsync(
HistoryClient client,
NodeId counterNodeId,
DateTime startTime,
DateTime endTime,
CancellationToken ct)
{
var timestamps = new List();
await foreach (DataValue value in client.ReadRawAsync(
counterNodeId,
startTime,
endTime,
maxValuesPerNode: 2,
cancellationToken: ct).ConfigureAwait(false))
{
if (!StatusCode.IsGood(value.StatusCode) ||
!value.WrappedValue.TryGetValue(out int _))
{
throw new ServiceResultException(
StatusCodes.BadDecodingError,
"Raw history used to derive the processed window is invalid.");
}
timestamps.Add(value.SourceTimestamp.ToDateTime());
}
if (timestamps.Count < 5)
{
throw new ServiceResultException(
StatusCodes.BadNoData,
"At least five raw values are required to exercise processed paging.");
}
DateTime processedStart = timestamps[0];
DateTime processedEnd = timestamps[^1].AddTicks(1);
double interval =
(processedEnd - processedStart).TotalMilliseconds / 3;
if (interval <= 0 || !double.IsFinite(interval))
{
throw new ServiceResultException(
StatusCodes.BadUnexpectedError,
"The processed history window is invalid.");
}
return (processedStart, processedEnd, interval);
}
private static async Task CountAtLeastAsync(
IAsyncEnumerable values,
int minimum,
CancellationToken ct)
{
int count = 0;
await foreach (T _ in values.WithCancellation(ct).ConfigureAwait(false))
{
count++;
if (count >= minimum)
{
break;
}
}
return count;
}
private static void ObserveRawValue(
DataValue value,
ref DateTime lastTimestamp,
ref int lastCounter,
ref int count)
{
if (!value.WrappedValue.TryGetValue(out int counter))
{
throw new ServiceResultException(
StatusCodes.BadTypeMismatch,
"A raw history value did not contain an Int32 counter.");
}
var timestamp = value.SourceTimestamp.ToDateTime();
if (count > 0 &&
(timestamp <= lastTimestamp || counter != lastCounter + 1))
{
throw new ServiceResultException(
StatusCodes.BadUnexpectedError,
$"Raw history has a duplicate or gap after counter {lastCounter} at {lastTimestamp:O}.");
}
lastTimestamp = timestamp;
lastCounter = counter;
count++;
}
private static void ObserveHistoryEvent(
HistoryEventFieldList historicalEvent,
HashSet eventIds,
ref DateTime lastTimestamp,
ref int lastCounter,
ref int count)
{
if (historicalEvent.EventFields.Count != 3 ||
!historicalEvent.EventFields[0].TryGetValue(out ByteString eventId) ||
eventId.IsEmpty ||
!historicalEvent.EventFields[1].TryGetValue(out DateTimeUtc eventTime) ||
!historicalEvent.EventFields[2].TryGetValue(out LocalizedText message) ||
string.IsNullOrWhiteSpace(message.Text))
{
throw new ServiceResultException(
StatusCodes.BadDecodingError,
"A historical event did not contain EventId, Time, and Message.");
}
if (!eventIds.Add(eventId))
{
throw new ServiceResultException(
StatusCodes.BadUnexpectedError,
"Historical event paging returned a duplicate EventId.");
}
if (!TryParseHistoryEventCounter(message.Text, out int counter))
{
throw new ServiceResultException(
StatusCodes.BadDecodingError,
$"Historical event message '{message.Text}' did not contain a counter.");
}
var timestamp = eventTime.ToDateTime();
if (count > 0 &&
(timestamp <= lastTimestamp || counter != lastCounter + 1))
{
throw new ServiceResultException(
StatusCodes.BadUnexpectedError,
$"Event history has a duplicate or gap after counter {lastCounter} at {lastTimestamp:O}.");
}
lastTimestamp = timestamp;
lastCounter = counter;
count++;
}
private static bool TryGetHistoryEventCounter(
HistoryEventFieldList historicalEvent,
out int counter)
{
counter = 0;
return historicalEvent.EventFields.Count == 3 &&
historicalEvent.EventFields[2].TryGetValue(
out LocalizedText message) &&
TryParseHistoryEventCounter(message.Text, out counter);
}
private static bool TryParseHistoryEventCounter(
string? message,
out int counter)
{
counter = 0;
if (string.IsNullOrWhiteSpace(message))
{
return false;
}
const string marker = "archived counter ";
int markerIndex = message.LastIndexOf(marker, StringComparison.Ordinal);
return markerIndex >= 0 &&
int.TryParse(
message.AsSpan(markerIndex + marker.Length).TrimEnd('.'),
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out counter);
}
private static void ObserveProcessedValue(
DataValue value,
TimeSpan expectedInterval,
ref DateTime lastTimestamp,
ref int count)
{
if (!StatusCode.IsGood(value.StatusCode) ||
!value.WrappedValue.TryGetValue(out double aggregate) ||
!double.IsFinite(aggregate))
{
throw new ServiceResultException(
StatusCodes.BadDecodingError,
"Processed history returned a bad status or non-numeric aggregate.");
}
var timestamp = value.SourceTimestamp.ToDateTime();
if (count > 0)
{
TimeSpan interval = timestamp - lastTimestamp;
if (Math.Abs(
(interval - expectedInterval).TotalMilliseconds) > 1)
{
throw new ServiceResultException(
StatusCodes.BadUnexpectedError,
$"Processed history interval changed from {expectedInterval} to {interval}.");
}
}
lastTimestamp = timestamp;
count++;
}
private static EventFilter CreateHistoryEventFilter()
{
var filter = new EventFilter();
filter.AddSelectClause(
ObjectTypeIds.BaseEventType,
BrowseNames.EventId,
Attributes.Value);
filter.AddSelectClause(
ObjectTypeIds.BaseEventType,
BrowseNames.Time,
Attributes.Value);
filter.AddSelectClause(
ObjectTypeIds.BaseEventType,
BrowseNames.Message,
Attributes.Value);
return filter;
}
///
/// V2 subscription notification handler that logs data changes for the
/// monitored CurrentTime and replicated Counter values and forwards each
/// change to the for failover / data-loss analysis.
///
private sealed class MonitoringHandler : Opc.Ua.Client.Subscriptions.ISubscriptionNotificationHandler
{
///
/// Initializes a new instance of the class.
///
/// The monitor that evaluates high-availability data changes.
public MonitoringHandler(HaMonitor monitor)
{
m_monitor = monitor;
}
///
public ValueTask OnDataChangeNotificationAsync(
Opc.Ua.Client.Subscriptions.ISubscription subscription,
uint sequenceNumber,
DateTime publishTime,
ReadOnlyMemory notification,
Opc.Ua.Client.Subscriptions.PublishState publishStateMask,
IReadOnlyList stringTable)
{
ReadOnlySpan changes = notification.Span;
for (int ii = 0; ii < changes.Length; ii++)
{
Opc.Ua.Client.Subscriptions.DataValueChange change = changes[ii];
string name = change.MonitoredItem?.Name ?? "Value";
Console.WriteLine(
"{0}={1} Status={2}",
name,
change.Value.WrappedValue,
change.Value.StatusCode);
m_monitor.Observe(name, change.Value);
}
return default;
}
///
public ValueTask OnEventDataNotificationAsync(
Opc.Ua.Client.Subscriptions.ISubscription subscription,
uint sequenceNumber,
DateTime publishTime,
ReadOnlyMemory notification,
Opc.Ua.Client.Subscriptions.PublishState publishStateMask,
IReadOnlyList stringTable)
{
return default;
}
///
public ValueTask OnKeepAliveNotificationAsync(
Opc.Ua.Client.Subscriptions.ISubscription subscription,
uint sequenceNumber,
DateTime publishTime,
Opc.Ua.Client.Subscriptions.PublishState publishStateMask)
{
return default;
}
///
public ValueTask OnSubscriptionStateChangedAsync(
Opc.Ua.Client.Subscriptions.ISubscription subscription,
Opc.Ua.Client.Subscriptions.SubscriptionState state,
Opc.Ua.Client.Subscriptions.PublishState publishStateMask,
CancellationToken ct = default)
{
return default;
}
private readonly HaMonitor m_monitor;
}
///
/// Tracks the monitored high-availability values across reconnects and
/// failovers and logs the failover and data-loss events they reveal:
/// gaps in ServerStatus.CurrentTime (missed updates), regressions or
/// divergence of the replicated Counter (state that did not carry
/// over), continuity of the Counter (no data loss), and changes of
/// ActiveReplica (the replica now serving the session).
///
private sealed class HaMonitor
{
///
/// Records a connection-state transition and, on (re)connect, arms the
/// failover context so the next data-change assessment is framed as a
/// failover.
///
/// The connection-state transition reported by the managed session.
/// The endpoint URL associated with the connected session, if known.
public void OnConnectionStateChanged(ConnectionStateChangedEventArgs e, string? endpoint)
{
Console.WriteLine("Connection state: {0} -> {1}", e.PreviousState, e.NewState);
if (e.Error != null && ServiceResult.IsBad(e.Error))
{
Console.WriteLine(
"FAILOVER DETAIL: {0} ({1})",
e.Error.StatusCode,
e.Error.LocalizedText);
}
if (e.NewState is ConnectionState.Reconnecting or ConnectionState.Failover)
{
Console.WriteLine("FAILOVER: connection lost, selecting a healthy replica...");
}
else if (e.NewState == ConnectionState.Connected &&
e.PreviousState != ConnectionState.Connected)
{
lock (m_lock)
{
m_failoverContext = true;
}
Console.WriteLine("CONNECTED: session (re)connected to {0}.", endpoint ?? "(unknown)");
}
}
///
/// Records a coordinated-client role change. On promotion it arms the failover
/// context so the next data-change assessment is framed as a (client-side) failover;
/// on demotion it notes that a peer client took over.
///
/// Whether this coordinated client replica is now the leader.
public void OnRoleChanged(bool isLeader)
{
if (isLeader)
{
lock (m_lock)
{
m_failoverContext = true;
}
}
else
{
Console.WriteLine("STANDBY: this replica is no longer the active client; a peer took over.");
}
}
///
/// Dispatches a monitored value to the matching per-item analysis.
///
/// The monitored item name associated with the value.
/// The data value received from the subscription.
public void Observe(string name, DataValue value)
{
switch (name)
{
case "ServerStatus.CurrentTime":
if (value.WrappedValue.TryGetValue(out DateTimeUtc serverTime))
{
OnCurrentTime(serverTime.ToDateTime());
}
break;
case "HighAvailability.Counter":
if (value.WrappedValue.TryGetValue(out int counter))
{
OnCounter(counter);
}
break;
case "HighAvailability.ActiveReplica":
if (value.WrappedValue.TryGetValue(out string? replica) && replica != null)
{
OnActiveReplica(replica);
}
break;
}
}
private void OnCurrentTime(DateTime serverTime)
{
DateTime? last;
bool failover;
lock (m_lock)
{
last = m_lastServerTime;
m_lastServerTime = serverTime;
failover = m_failoverContext;
}
if (last.HasValue)
{
TimeSpan gap = serverTime - last.Value;
if (gap > kExpectedInterval * 3)
{
int missed = Math.Max(0, (int)(gap.TotalSeconds / kExpectedInterval.TotalSeconds) - 1);
Console.WriteLine(
"DATA LOSS: CurrentTime jumped {0:0.0}s ({1} update(s) missed{2}).",
gap.TotalSeconds,
missed,
failover ? " during failover" : string.Empty);
}
}
}
private void OnCounter(int value)
{
int? last;
bool failover;
lock (m_lock)
{
last = m_lastCounter;
m_lastCounter = value;
failover = m_failoverContext;
m_failoverContext = false;
}
if (!last.HasValue)
{
return;
}
if (value < last.Value)
{
Console.WriteLine(
"DATA LOSS: Counter regressed {0} -> {1} ({2} increment(s) lost){3}.",
last.Value,
value,
last.Value - value,
failover ? " across failover" : string.Empty);
}
else if (failover && value > last.Value + kCounterJumpSlack)
{
Console.WriteLine(
"DATA LOSS: Counter jumped {0} -> {1} across failover " +
"(replica values diverged; state did not carry over).",
last.Value,
value);
}
else if (failover)
{
Console.WriteLine(
"HA OK: Counter continued {0} -> {1} across failover (no data loss).",
last.Value,
value);
}
}
private void OnActiveReplica(string replica)
{
string? previous;
lock (m_lock)
{
previous = m_lastReplica;
m_lastReplica = replica;
}
if (previous == null)
{
Console.WriteLine("Connected replica: '{0}'.", replica);
}
else if (!string.Equals(previous, replica, StringComparison.Ordinal))
{
Console.WriteLine("FAILOVER: now served by replica '{0}' (was '{1}').", replica, previous);
}
}
private static readonly TimeSpan kExpectedInterval = TimeSpan.FromSeconds(1);
private const int kCounterJumpSlack = 5;
private readonly Lock m_lock = new();
private DateTime? m_lastServerTime;
private int? m_lastCounter;
private string? m_lastReplica;
private bool m_failoverContext;
}
private static async Task RunForDurationAsync(TimeSpan duration, CancellationToken ct)
{
try
{
await Task.Delay(
duration <= TimeSpan.Zero ? Timeout.InfiniteTimeSpan : duration,
ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Ctrl+C or the run duration elapsed; exit cleanly.
}
}
private const string kApplicationName = "RedundantClient";
private const string kConfigSectionName = "RedundantClient";
private const string kHighAvailabilityNamespaceUri =
"http://opcfoundation.org/UA/Samples/HighAvailability";
private const int kHistoryVisibilityMarker = -4390;
}
}