diff --git a/Applications/ConsoleReferencePubSubClient/ConsoleReferencePubSubClient.csproj b/Applications/ConsoleReferencePubSubClient/ConsoleReferencePubSubClient.csproj
index ef292bb712..761efde022 100644
--- a/Applications/ConsoleReferencePubSubClient/ConsoleReferencePubSubClient.csproj
+++ b/Applications/ConsoleReferencePubSubClient/ConsoleReferencePubSubClient.csproj
@@ -22,6 +22,7 @@
+
diff --git a/Applications/ConsoleReferencePubSubClient/Program.cs b/Applications/ConsoleReferencePubSubClient/Program.cs
index 9b0bc51767..5ac39003b0 100644
--- a/Applications/ConsoleReferencePubSubClient/Program.cs
+++ b/Applications/ConsoleReferencePubSubClient/Program.cs
@@ -49,7 +49,7 @@ namespace Quickstarts.ConsoleReferencePubSubClient
/// One executable exposes three command-line-selectable modes:
///
/// -
- /// publisher - publishes sample DataSets over UDP/UADP or MQTT (UADP/JSON).
+ /// publisher - publishes sample DataSets over UDP/UADP, Ethernet/UADP, MQTT, or Kafka (UADP/JSON).
///
/// -
/// subscriber - receives DataSets and logs each decoded message.
@@ -88,14 +88,16 @@ public static async Task Main(string[] args)
}
///
- /// Builds the publisher subcommand: a UDP/UADP or MQTT publisher
+ /// Builds the publisher subcommand: a UDP/UADP, Ethernet/UADP, MQTT, or Kafka publisher
/// that publishes a built-in sample DataSet.
///
private static Command BuildPublisherCommand(Action setExitCode)
{
var profileOption = new Option("--profile")
{
- Description = "Transport profile: udp-uadp | mqtt-uadp | mqtt-json.",
+ Description =
+ "Transport profile: udp-uadp | eth-uadp | mqtt-uadp | mqtt-json | " +
+ "kafka-uadp | kafka-json.",
DefaultValueFactory = _ => "udp-uadp"
};
var configFileOption = new Option("--config-file")
@@ -121,7 +123,8 @@ private static Command BuildPublisherCommand(Action setExitCode)
{
Description =
"Transport endpoint URL. Defaults: opc.udp://239.0.0.1:4840 (UDP), " +
- "mqtt://localhost:1883 (MQTT)."
+ "opc.eth://01-00-5E-7F-00-01 (Ethernet), mqtt://localhost:1883 (MQTT), " +
+ "kafka://localhost:9092 (Kafka)."
};
var intervalOption = new Option("--interval")
{
@@ -131,7 +134,7 @@ private static Command BuildPublisherCommand(Action setExitCode)
var command = new Command(
"publisher",
- "Publish a sample DataSet over UDP/UADP or MQTT (UADP/JSON).")
+ "Publish a sample DataSet over UDP/UADP, Ethernet/UADP, MQTT, or Kafka (UADP/JSON).")
{
profileOption,
configFileOption,
@@ -149,7 +152,7 @@ private static Command BuildPublisherCommand(Action setExitCode)
{
await Console.Error.WriteLineAsync(
$"Unknown --profile value '{profileArg}'. " +
- "Expected one of: udp-uadp, mqtt-uadp, mqtt-json.")
+ "Expected one of: udp-uadp, eth-uadp, mqtt-uadp, mqtt-json, kafka-uadp, kafka-json.")
.ConfigureAwait(false);
setExitCode(2);
return;
@@ -169,14 +172,16 @@ await Console.Error.WriteLineAsync(
}
///
- /// Builds the subscriber subcommand: a UDP/UADP or MQTT subscriber
+ /// Builds the subscriber subcommand: a UDP/UADP, Ethernet/UADP, MQTT, or Kafka subscriber
/// that logs each decoded DataSetMessage.
///
private static Command BuildSubscriberCommand(Action setExitCode)
{
var profileOption = new Option("--profile")
{
- Description = "Transport profile: udp-uadp | mqtt-uadp | mqtt-json.",
+ Description =
+ "Transport profile: udp-uadp | eth-uadp | mqtt-uadp | mqtt-json | " +
+ "kafka-uadp | kafka-json.",
DefaultValueFactory = _ => "udp-uadp"
};
var configFileOption = new Option("--config-file")
@@ -200,8 +205,10 @@ private static Command BuildSubscriberCommand(Action setExitCode)
};
var endpointOption = new Option("--endpoint")
{
- Description = "Transport endpoint URL. Defaults: opc.udp://239.0.0.1:4840 " +
- "(UDP), mqtt://localhost:1883 (MQTT)."
+ Description =
+ "Transport endpoint URL. Defaults: opc.udp://239.0.0.1:4840 (UDP), " +
+ "opc.eth://01-00-5E-7F-00-01 (Ethernet), mqtt://localhost:1883 (MQTT), " +
+ "kafka://localhost:9092 (Kafka)."
};
var command = new Command(
@@ -223,7 +230,7 @@ private static Command BuildSubscriberCommand(Action setExitCode)
{
await Console.Error.WriteLineAsync(
$"Unknown --profile value '{profileArg}'. " +
- "Expected one of: udp-uadp, mqtt-uadp, mqtt-json.")
+ "Expected one of: udp-uadp, eth-uadp, mqtt-uadp, mqtt-json, kafka-uadp, kafka-json.")
.ConfigureAwait(false);
setExitCode(2);
return;
@@ -373,13 +380,23 @@ private static async Task RunPublisherAsync(
.AddUdpTransport()
.AddSecurityKeyProvider(SampleSecurity.CreateKeyProvider())
.AddDataSetSource(PublisherConfigurationBuilder.DataSetName, sampleSource);
- if (profile == PublisherProfile.EthUadp)
+ switch (profile)
{
- publisher.AddEthTransport();
- }
- else if (profile != PublisherProfile.UdpUadp)
- {
- publisher.AddMqttTransport();
+ case PublisherProfile.UdpUadp:
+ break;
+ case PublisherProfile.EthUadp:
+ publisher.AddEthTransport();
+ break;
+ case PublisherProfile.MqttUadp:
+ case PublisherProfile.MqttJson:
+ publisher.AddMqttTransport();
+ break;
+ case PublisherProfile.KafkaUadp:
+ case PublisherProfile.KafkaJson:
+ publisher.AddKafkaTransport();
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(profile), profile, null);
}
publisher.ConfigureApplication(app =>
{
@@ -441,13 +458,23 @@ private static async Task RunSubscriberAsync(
sp => new ConsoleLoggingSink(
sp.GetRequiredService()
.CreateLogger()));
- if (profile == SubscriberProfile.EthUadp)
- {
- subscriber.AddEthTransport();
- }
- else if (profile != SubscriberProfile.UdpUadp)
+ switch (profile)
{
- subscriber.AddMqttTransport();
+ case SubscriberProfile.UdpUadp:
+ break;
+ case SubscriberProfile.EthUadp:
+ subscriber.AddEthTransport();
+ break;
+ case SubscriberProfile.MqttUadp:
+ case SubscriberProfile.MqttJson:
+ subscriber.AddMqttTransport();
+ break;
+ case SubscriberProfile.KafkaUadp:
+ case SubscriberProfile.KafkaJson:
+ subscriber.AddKafkaTransport();
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(profile), profile, null);
}
subscriber.ConfigureApplication(app =>
{
@@ -719,6 +746,12 @@ private static bool TryParsePublisherProfile(string? text, out PublisherProfile
case "eth-uadp":
profile = PublisherProfile.EthUadp;
return true;
+ case "kafka-uadp":
+ profile = PublisherProfile.KafkaUadp;
+ return true;
+ case "kafka-json":
+ profile = PublisherProfile.KafkaJson;
+ return true;
default:
profile = PublisherProfile.UdpUadp;
return false;
@@ -741,6 +774,12 @@ private static bool TryParseSubscriberProfile(string? text, out SubscriberProfil
case "eth-uadp":
profile = SubscriberProfile.EthUadp;
return true;
+ case "kafka-uadp":
+ profile = SubscriberProfile.KafkaUadp;
+ return true;
+ case "kafka-json":
+ profile = SubscriberProfile.KafkaJson;
+ return true;
default:
profile = SubscriberProfile.UdpUadp;
return false;
@@ -843,7 +882,17 @@ public enum PublisherProfile
///
/// Ethernet (Layer 2) transport with UADP message mapping.
///
- EthUadp = 3
+ EthUadp = 3,
+
+ ///
+ /// Kafka broker transport with UADP message mapping.
+ ///
+ KafkaUadp = 4,
+
+ ///
+ /// Kafka broker transport with JSON message mapping.
+ ///
+ KafkaJson = 5
}
///
@@ -869,7 +918,17 @@ public enum SubscriberProfile
///
/// Ethernet (Layer 2) transport with UADP message mapping.
///
- EthUadp = 3
+ EthUadp = 3,
+
+ ///
+ /// Kafka broker transport with UADP message mapping.
+ ///
+ KafkaUadp = 4,
+
+ ///
+ /// Kafka broker transport with JSON message mapping.
+ ///
+ KafkaJson = 5
}
///
diff --git a/Applications/ConsoleReferencePubSubClient/PublisherConfigurationBuilder.cs b/Applications/ConsoleReferencePubSubClient/PublisherConfigurationBuilder.cs
index 81b0997e9a..fbf1f0cb4a 100644
--- a/Applications/ConsoleReferencePubSubClient/PublisherConfigurationBuilder.cs
+++ b/Applications/ConsoleReferencePubSubClient/PublisherConfigurationBuilder.cs
@@ -31,12 +31,13 @@
using Opc.Ua;
using Opc.Ua.PubSub.Configuration;
using Opc.Ua.PubSub.Eth;
+using Opc.Ua.PubSub.Kafka;
namespace Quickstarts.ConsoleReferencePubSubClient
{
///
/// Builds minimal Part 14
- /// payloads for the three demo wire profiles using the fluent
+ /// payloads for the demo wire profiles using the fluent
/// . The payloads use the
/// "Simple" DataSet exposed by
/// (BoolToggle, Int32 counter, DateTime).
@@ -47,7 +48,9 @@ public static class PublisherConfigurationBuilder
public const string DefaultUdpEndpoint = "opc.udp://239.0.0.1:4840";
public const string DefaultEthEndpoint = "opc.eth://01-00-5E-7F-00-01";
public const string DefaultMqttEndpoint = "mqtt://localhost:1883";
+ public const string DefaultKafkaEndpoint = "kafka://localhost:9092";
private const string MqttQueueName = "Quickstarts/Reference/Simple";
+ private const string KafkaTopicName = "Quickstarts.Reference.Simple";
public static string DefaultEndpointFor(PublisherProfile profile)
{
@@ -55,7 +58,9 @@ public static string DefaultEndpointFor(PublisherProfile profile)
{
PublisherProfile.UdpUadp => DefaultUdpEndpoint,
PublisherProfile.EthUadp => DefaultEthEndpoint,
- _ => DefaultMqttEndpoint
+ PublisherProfile.KafkaUadp or PublisherProfile.KafkaJson => DefaultKafkaEndpoint,
+ PublisherProfile.MqttUadp or PublisherProfile.MqttJson => DefaultMqttEndpoint,
+ _ => throw new ArgumentOutOfRangeException(nameof(profile))
};
}
@@ -68,13 +73,16 @@ public static PubSubConfigurationDataType Build(
int intervalMs)
{
// UDP and Ethernet are datagram transports (no broker queue);
- // the MQTT profiles use broker transport settings instead.
+ // broker profiles use broker transport settings instead.
bool udp = profile is PublisherProfile.UdpUadp or PublisherProfile.EthUadp;
// UADP message security (SignAndEncrypt) is wired for the UADP
// profiles via the shared StaticSecurityKeyProvider. The JSON
// profile has no UADP security wrapper, so it stays unsecured.
- bool secured = profile != PublisherProfile.MqttJson;
+ bool secured = profile is not (PublisherProfile.MqttJson or PublisherProfile.KafkaJson);
+ string brokerQueueName = profile is PublisherProfile.KafkaUadp or PublisherProfile.KafkaJson
+ ? KafkaTopicName
+ : MqttQueueName;
string transportProfileUri = profile switch
{
@@ -82,6 +90,8 @@ public static PubSubConfigurationDataType Build(
PublisherProfile.EthUadp => EthProfiles.PubSubEthUadpTransport,
PublisherProfile.MqttUadp => Profiles.PubSubMqttUadpTransport,
PublisherProfile.MqttJson => Profiles.PubSubMqttJsonTransport,
+ PublisherProfile.KafkaUadp => KafkaProfiles.PubSubKafkaUadpTransport,
+ PublisherProfile.KafkaJson => KafkaProfiles.PubSubKafkaJsonTransport,
_ => throw new ArgumentOutOfRangeException(nameof(profile))
};
@@ -104,7 +114,7 @@ public static PubSubConfigurationDataType Build(
? new DatagramWriterGroupTransportDataType()
: new BrokerWriterGroupTransportDataType
{
- QueueName = MqttQueueName
+ QueueName = brokerQueueName
});
if (secured)
{
@@ -126,7 +136,7 @@ public static PubSubConfigurationDataType Build(
writer.WithTransportSettings(
new BrokerDataSetWriterTransportDataType
{
- QueueName = MqttQueueName,
+ QueueName = brokerQueueName,
RequestedDeliveryGuarantee
= BrokerTransportQualityOfService.BestEffort
});
@@ -138,7 +148,7 @@ public static PubSubConfigurationDataType Build(
private static IEncodeable WriterGroupMessageSettings(PublisherProfile profile)
{
- if (profile == PublisherProfile.MqttJson)
+ if (profile is PublisherProfile.MqttJson or PublisherProfile.KafkaJson)
{
return new JsonWriterGroupMessageDataType
{
@@ -163,7 +173,7 @@ private static IEncodeable WriterGroupMessageSettings(PublisherProfile profile)
private static IEncodeable WriterMessageSettings(PublisherProfile profile)
{
- if (profile == PublisherProfile.MqttJson)
+ if (profile is PublisherProfile.MqttJson or PublisherProfile.KafkaJson)
{
return new JsonDataSetWriterMessageDataType
{
diff --git a/Applications/ConsoleReferencePubSubClient/SubscriberConfigurationBuilder.cs b/Applications/ConsoleReferencePubSubClient/SubscriberConfigurationBuilder.cs
index fa600d8bdd..6fa2ddf7f8 100644
--- a/Applications/ConsoleReferencePubSubClient/SubscriberConfigurationBuilder.cs
+++ b/Applications/ConsoleReferencePubSubClient/SubscriberConfigurationBuilder.cs
@@ -31,12 +31,13 @@
using Opc.Ua;
using Opc.Ua.PubSub.Configuration;
using Opc.Ua.PubSub.Eth;
+using Opc.Ua.PubSub.Kafka;
namespace Quickstarts.ConsoleReferencePubSubClient
{
///
/// Builds minimal Part 14
- /// payloads for the three demo wire profiles using the fluent
+ /// payloads for the demo wire profiles using the fluent
/// . Each payload wires one
/// PubSubConnection > ReaderGroup > DataSetReader filtered on
/// PublisherId / WriterGroupId / DataSetWriterId.
@@ -48,7 +49,9 @@ public static class SubscriberConfigurationBuilder
public const string DefaultUdpEndpoint = "opc.udp://239.0.0.1:4840";
public const string DefaultEthEndpoint = "opc.eth://01-00-5E-7F-00-01";
public const string DefaultMqttEndpoint = "mqtt://localhost:1883";
+ public const string DefaultKafkaEndpoint = "kafka://localhost:9092";
private const string MqttQueueName = "Quickstarts/Reference/Simple";
+ private const string KafkaTopicName = "Quickstarts.Reference.Simple";
public static string DefaultEndpointFor(SubscriberProfile profile)
{
@@ -56,7 +59,9 @@ public static string DefaultEndpointFor(SubscriberProfile profile)
{
SubscriberProfile.UdpUadp => DefaultUdpEndpoint,
SubscriberProfile.EthUadp => DefaultEthEndpoint,
- _ => DefaultMqttEndpoint
+ SubscriberProfile.KafkaUadp or SubscriberProfile.KafkaJson => DefaultKafkaEndpoint,
+ SubscriberProfile.MqttUadp or SubscriberProfile.MqttJson => DefaultMqttEndpoint,
+ _ => throw new ArgumentOutOfRangeException(nameof(profile))
};
}
@@ -68,13 +73,16 @@ public static PubSubConfigurationDataType Build(
ushort dataSetWriterIdFilter)
{
// UDP and Ethernet are datagram transports (no broker queue);
- // the MQTT profiles use broker transport settings instead.
+ // broker profiles use broker transport settings instead.
bool udp = profile is SubscriberProfile.UdpUadp or SubscriberProfile.EthUadp;
// UADP message security (SignAndEncrypt) is wired for the UADP
// profiles via the shared StaticSecurityKeyProvider. The JSON
// profile has no UADP security wrapper, so it stays unsecured.
- bool secured = profile != SubscriberProfile.MqttJson;
+ bool secured = profile is not (SubscriberProfile.MqttJson or SubscriberProfile.KafkaJson);
+ string brokerQueueName = profile is SubscriberProfile.KafkaUadp or SubscriberProfile.KafkaJson
+ ? KafkaTopicName
+ : MqttQueueName;
string transportProfileUri = profile switch
{
@@ -82,6 +90,8 @@ public static PubSubConfigurationDataType Build(
SubscriberProfile.EthUadp => EthProfiles.PubSubEthUadpTransport,
SubscriberProfile.MqttUadp => Profiles.PubSubMqttUadpTransport,
SubscriberProfile.MqttJson => Profiles.PubSubMqttJsonTransport,
+ SubscriberProfile.KafkaUadp => KafkaProfiles.PubSubKafkaUadpTransport,
+ SubscriberProfile.KafkaJson => KafkaProfiles.PubSubKafkaJsonTransport,
_ => throw new ArgumentOutOfRangeException(nameof(profile))
};
@@ -121,7 +131,7 @@ public static PubSubConfigurationDataType Build(
reader.WithTransportSettings(
new BrokerDataSetReaderTransportDataType
{
- QueueName = MqttQueueName,
+ QueueName = brokerQueueName,
RequestedDeliveryGuarantee
= BrokerTransportQualityOfService.BestEffort
});
@@ -133,7 +143,7 @@ public static PubSubConfigurationDataType Build(
private static IEncodeable ReaderMessageSettings(SubscriberProfile profile)
{
- if (profile == SubscriberProfile.MqttJson)
+ if (profile is SubscriberProfile.MqttJson or SubscriberProfile.KafkaJson)
{
return new JsonDataSetReaderMessageDataType
{
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 720d72c24e..843c4514ee 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -12,56 +12,58 @@
+
+
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
+ true
+
+
+
+
+
+
+
+
+ $(PackageId).Debug
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Libraries/Opc.Ua.PubSub.Kafka/Properties/AssemblyInfo.cs b/Libraries/Opc.Ua.PubSub.Kafka/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000000..7798c9bd57
--- /dev/null
+++ b/Libraries/Opc.Ua.PubSub.Kafka/Properties/AssemblyInfo.cs
@@ -0,0 +1,32 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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;
+
+[assembly: CLSCompliant(false)]
diff --git a/Libraries/Opc.Ua.PubSub.Udp/DependencyInjection/UdpTransportBuilder.cs b/Libraries/Opc.Ua.PubSub.Udp/DependencyInjection/UdpTransportBuilder.cs
index e24b856b9a..41f34be0d7 100644
--- a/Libraries/Opc.Ua.PubSub.Udp/DependencyInjection/UdpTransportBuilder.cs
+++ b/Libraries/Opc.Ua.PubSub.Udp/DependencyInjection/UdpTransportBuilder.cs
@@ -116,6 +116,19 @@ public IPubSubBuilder WithSecurityKeyStore(IPubSubSecurityKeyStore store)
return PubSubBuilder.WithSecurityKeyStore(store);
}
+ ///
+ public IPubSubBuilder WithActivationCoordinator(
+ Opc.Ua.PubSub.Redundancy.IPubSubActivationCoordinator coordinator)
+ {
+ return PubSubBuilder.WithActivationCoordinator(coordinator);
+ }
+
+ ///
+ public IPubSubBuilder WithLeaseStore(Opc.Ua.PubSub.Redundancy.IPubSubLeaseStore leaseStore)
+ {
+ return PubSubBuilder.WithLeaseStore(leaseStore);
+ }
+
///
public IPubSubBuilder AddActionResponder(
PubSubActionTarget target,
diff --git a/Libraries/Opc.Ua.PubSub/Application/PubSubApplication.cs b/Libraries/Opc.Ua.PubSub/Application/PubSubApplication.cs
index d76a4d58f1..483b21f2de 100644
--- a/Libraries/Opc.Ua.PubSub/Application/PubSubApplication.cs
+++ b/Libraries/Opc.Ua.PubSub/Application/PubSubApplication.cs
@@ -40,6 +40,7 @@
using Opc.Ua.PubSub.Encoding;
using Opc.Ua.PubSub.Groups;
using Opc.Ua.PubSub.MetaData;
+using Opc.Ua.PubSub.Redundancy;
using Opc.Ua.PubSub.Scheduling;
using Opc.Ua.PubSub.Security;
using Opc.Ua.PubSub.StateMachine;
@@ -74,6 +75,7 @@ public sealed class PubSubApplication : IPubSubApplication
private readonly INetworkMessageDecoder[] m_decoderArray;
private readonly IPubSubSecurityPolicy[] m_securityPolicies;
private readonly IPubSubScheduler m_scheduler;
+ private readonly IPubSubActivationCoordinator m_activationCoordinator;
private readonly TimeProvider m_timeProvider;
private readonly IReadOnlyDictionary?
m_publishedDataSetSources;
@@ -134,6 +136,7 @@ private readonly Dictionary m_connectionNodeIdsByName
/// Optional per-connection maximum message size resolver.
/// Optional external configuration store.
/// Optional external runtime-state store.
+ /// Optional high-availability activation coordinator.
public PubSubApplication(
PubSubConfigurationSnapshot snapshot,
IEnumerable transportFactories,
@@ -150,7 +153,8 @@ public PubSubApplication(
IPubSubSecurityWrapperResolver? securityWrapperResolver = null,
Func? maxNetworkMessageSizeResolver = null,
IPubSubConfigurationStore? configurationStore = null,
- IPubSubRuntimeStateStore? runtimeStateStore = null)
+ IPubSubRuntimeStateStore? runtimeStateStore = null,
+ IPubSubActivationCoordinator? activationCoordinator = null)
: this(
snapshot,
transportFactories,
@@ -169,7 +173,8 @@ public PubSubApplication(
configurationStore,
runtimeStateStore,
dataSetSourceProvider: null,
- dataSetSinkProvider: null)
+ dataSetSinkProvider: null,
+ activationCoordinator)
{
}
@@ -206,6 +211,7 @@ public PubSubApplication(
/// Optional per-connection maximum message size resolver.
/// Optional external configuration store.
/// Optional external runtime-state store.
+ /// Optional high-availability activation coordinator.
public PubSubApplication(
PubSubConfigurationSnapshot snapshot,
IEnumerable transportFactories,
@@ -224,7 +230,8 @@ public PubSubApplication(
IPubSubSecurityWrapperResolver? securityWrapperResolver = null,
Func? maxNetworkMessageSizeResolver = null,
IPubSubConfigurationStore? configurationStore = null,
- IPubSubRuntimeStateStore? runtimeStateStore = null)
+ IPubSubRuntimeStateStore? runtimeStateStore = null,
+ IPubSubActivationCoordinator? activationCoordinator = null)
: this(
snapshot,
transportFactories,
@@ -243,7 +250,8 @@ public PubSubApplication(
configurationStore,
runtimeStateStore,
dataSetSourceProvider,
- dataSetSinkProvider)
+ dataSetSinkProvider,
+ activationCoordinator)
{
}
@@ -265,7 +273,8 @@ private PubSubApplication(
IPubSubConfigurationStore? configurationStore = null,
IPubSubRuntimeStateStore? runtimeStateStore = null,
IDataSetSourceProvider? dataSetSourceProvider = null,
- IDataSetSinkProvider? dataSetSinkProvider = null)
+ IDataSetSinkProvider? dataSetSinkProvider = null,
+ IPubSubActivationCoordinator? activationCoordinator = null)
{
if (snapshot is null)
{
@@ -312,6 +321,7 @@ private PubSubApplication(
m_decoderArray = decoders.ToArray();
m_securityPolicies = securityPolicies.ToArray();
m_scheduler = scheduler;
+ m_activationCoordinator = activationCoordinator ?? AlwaysActiveCoordinator.Instance;
m_timeProvider = timeProvider;
m_publishedDataSetSources = publishedDataSetSources;
m_subscribedDataSetSinks = subscribedDataSetSinks;
@@ -531,7 +541,8 @@ public void SetAddressSpaceNamespaceIndex(ushort namespaceIndex)
securityContext?.WrapOptions ?? UadpSecurityWrapOptions.SignAndEncrypt,
maxMessageSize,
requiredSecurityMode,
- m_scheduler);
+ m_scheduler,
+ m_activationCoordinator);
lock (m_gate)
{
for (int i = 0; i < m_actionHandlers.Count; i++)
@@ -598,6 +609,18 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default)
m_started = true;
connections = [.. m_connections];
}
+ try
+ {
+ await m_activationCoordinator.StartAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ lock (m_gate)
+ {
+ m_started = false;
+ }
+ throw;
+ }
_ = State.TryEnable();
foreach (PubSubConnection connection in connections)
{
@@ -638,6 +661,10 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default)
{
_ = State.TryResumeCascade();
}
+ foreach (PubSubConnection connection in connections)
+ {
+ await connection.ApplyActivationRolesAsync(cancellationToken).ConfigureAwait(false);
+ }
}
///
@@ -682,6 +709,14 @@ await connections[i].DisableAsync(cancellationToken)
}
}
_ = State.TryDisable();
+ try
+ {
+ await m_activationCoordinator.StopAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ m_logger.LogError(ex, "Failed to stop PubSub activation coordinator.");
+ }
}
///
diff --git a/Libraries/Opc.Ua.PubSub/Connections/PubSubConnection.cs b/Libraries/Opc.Ua.PubSub/Connections/PubSubConnection.cs
index 1eeed538af..5747778dfd 100644
--- a/Libraries/Opc.Ua.PubSub/Connections/PubSubConnection.cs
+++ b/Libraries/Opc.Ua.PubSub/Connections/PubSubConnection.cs
@@ -42,6 +42,7 @@
using Opc.Ua.PubSub.Encoding.Uadp;
using Opc.Ua.PubSub.Groups;
using Opc.Ua.PubSub.MetaData;
+using Opc.Ua.PubSub.Redundancy;
using Opc.Ua.PubSub.Scheduling;
using Opc.Ua.PubSub.Security;
using Opc.Ua.PubSub.StateMachine;
@@ -75,6 +76,7 @@ public sealed class PubSubConnection : IPubSubConnection, IAsyncDisposable
private readonly IPubSubScheduler m_scheduler;
private readonly IDataSetMetaDataRegistry m_metaDataRegistry;
private readonly IPubSubDiagnostics m_diagnostics;
+ private readonly IPubSubActivationCoordinator m_activationCoordinator;
private readonly UadpSecurityWrapper? m_securityWrapper;
private readonly UadpSecurityWrapOptions m_securityWrapOptions;
private readonly MessageSecurityMode m_requiredSecurityMode;
@@ -174,6 +176,7 @@ public PubSubConnection(
///
/// Optional scheduler used for periodic discovery announcements.
///
+ /// Optional high-availability activation coordinator.
public PubSubConnection(
PubSubConnectionDataType configuration,
IPubSubTransportFactory transportFactory,
@@ -189,7 +192,8 @@ public PubSubConnection(
UadpSecurityWrapOptions securityWrapOptions,
int maxNetworkMessageSize = 0,
MessageSecurityMode requiredSecurityMode = MessageSecurityMode.None,
- IPubSubScheduler? scheduler = null)
+ IPubSubScheduler? scheduler = null,
+ IPubSubActivationCoordinator? activationCoordinator = null)
{
if (configuration is null)
{
@@ -229,6 +233,7 @@ public PubSubConnection(
m_readerGroupViews = readerGroups.ToArrayOf(static group => group);
m_metaDataRegistry = metaDataRegistry;
m_diagnostics = diagnostics;
+ m_activationCoordinator = activationCoordinator ?? AlwaysActiveCoordinator.Instance;
m_telemetry = telemetry;
m_timeProvider = timeProvider;
m_scheduler = scheduler ?? new PubSubScheduler(telemetry, timeProvider);
@@ -250,6 +255,9 @@ public PubSubConnection(
foreach (WriterGroup wg in m_writerGroups)
{
State.AttachChild(wg.State);
+ wg.ConfigureActivationCoordinator(
+ BuildWriterGroupComponentId(Name, wg.Name),
+ m_activationCoordinator);
wg.EncodingProfileOverride = ResolveEncoderProfile();
wg.PubSubAddressing = new WriterGroup.PublisherIdHolder
{
@@ -261,6 +269,9 @@ public PubSubConnection(
foreach (ReaderGroup rg in m_readerGroups)
{
State.AttachChild(rg.State);
+ rg.ConfigureActivationCoordinator(
+ BuildReaderGroupComponentId(Name, rg.Name),
+ m_activationCoordinator);
}
}
@@ -454,6 +465,31 @@ public async ValueTask EnableAsync(CancellationToken cancellationToken = default
WriterGroup wg = m_writerGroups[i];
await wg.EnableAsync(cancellationToken).ConfigureAwait(false);
}
+ await ApplyActivationRolesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ internal async ValueTask ApplyActivationRolesAsync(CancellationToken cancellationToken = default)
+ {
+ for (int i = 0; i < m_readerGroups.Count; i++)
+ {
+ ReaderGroup rg = m_readerGroups[i];
+ await rg.ApplyActivationRoleAsync(cancellationToken).ConfigureAwait(false);
+ }
+ for (int i = 0; i < m_writerGroups.Count; i++)
+ {
+ WriterGroup wg = m_writerGroups[i];
+ await wg.ApplyActivationRoleAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private static string BuildWriterGroupComponentId(string connectionName, string groupName)
+ {
+ return string.Concat("pubsub:writergroup:", connectionName, ":", groupName);
+ }
+
+ private static string BuildReaderGroupComponentId(string connectionName, string groupName)
+ {
+ return string.Concat("pubsub:readergroup:", connectionName, ":", groupName);
}
///
diff --git a/Libraries/Opc.Ua.PubSub/DependencyInjection/IPubSubBuilder.cs b/Libraries/Opc.Ua.PubSub/DependencyInjection/IPubSubBuilder.cs
index 3915456340..954d59ac61 100644
--- a/Libraries/Opc.Ua.PubSub/DependencyInjection/IPubSubBuilder.cs
+++ b/Libraries/Opc.Ua.PubSub/DependencyInjection/IPubSubBuilder.cs
@@ -34,6 +34,7 @@
using Opc.Ua.PubSub.Application;
using Opc.Ua.PubSub.Configuration;
using Opc.Ua.PubSub.DataSets;
+using Opc.Ua.PubSub.Redundancy;
using Opc.Ua.PubSub.Security;
using Opc.Ua.PubSub.Security.Sks;
@@ -115,6 +116,22 @@ IPubSubBuilder ConfigureApplication(
/// Security-key store.
IPubSubBuilder WithSecurityKeyStore(IPubSubSecurityKeyStore store);
+ ///
+ /// Uses a custom PubSub activation coordinator that decides, per
+ /// component, whether this instance is active or on standby in a
+ /// redundant (high-availability) deployment (Part 14 §9.1.6).
+ ///
+ /// Activation coordinator.
+ IPubSubBuilder WithActivationCoordinator(IPubSubActivationCoordinator coordinator);
+
+ ///
+ /// Uses a custom PubSub lease store for leader election in a redundant
+ /// deployment. A distributed store enables genuine multi-instance
+ /// failover.
+ ///
+ /// Lease store.
+ IPubSubBuilder WithLeaseStore(IPubSubLeaseStore leaseStore);
+
///
/// Adds a responder-side PubSub Action handler.
///
diff --git a/Libraries/Opc.Ua.PubSub/DependencyInjection/OpcUaPubSubBuilderExtensions.cs b/Libraries/Opc.Ua.PubSub/DependencyInjection/OpcUaPubSubBuilderExtensions.cs
index f493039215..2c3b26e77e 100644
--- a/Libraries/Opc.Ua.PubSub/DependencyInjection/OpcUaPubSubBuilderExtensions.cs
+++ b/Libraries/Opc.Ua.PubSub/DependencyInjection/OpcUaPubSubBuilderExtensions.cs
@@ -44,6 +44,7 @@
using Opc.Ua.PubSub.Encoding.Json;
using Opc.Ua.PubSub.Encoding.Uadp;
using Opc.Ua.PubSub.MetaData;
+using Opc.Ua.PubSub.Redundancy;
using Opc.Ua.PubSub.Scheduling;
using Opc.Ua.PubSub.Security;
using Opc.Ua.PubSub.Security.Policies;
@@ -267,6 +268,7 @@ private static void RegisterCoreServices(IServiceCollection services)
services.TryAddSingleton();
services.TryAddSingleton();
services.TryAddSingleton();
+ services.TryAddSingleton(AlwaysActiveCoordinator.Instance);
services.TryAddSingleton();
services.TryAddSingleton();
@@ -300,7 +302,9 @@ private static void RegisterCoreServices(IServiceCollection services)
securityWrapperResolver:
sp.GetRequiredService(),
configurationStore: store,
- runtimeStateStore: sp.GetRequiredService());
+ runtimeStateStore: sp.GetRequiredService(),
+ activationCoordinator:
+ sp.GetRequiredService());
});
services.AddSingleton();
diff --git a/Libraries/Opc.Ua.PubSub/DependencyInjection/PubSubBuilder.cs b/Libraries/Opc.Ua.PubSub/DependencyInjection/PubSubBuilder.cs
index 5416a9248d..7726fcd342 100644
--- a/Libraries/Opc.Ua.PubSub/DependencyInjection/PubSubBuilder.cs
+++ b/Libraries/Opc.Ua.PubSub/DependencyInjection/PubSubBuilder.cs
@@ -37,6 +37,7 @@
using Opc.Ua.PubSub.Application;
using Opc.Ua.PubSub.Configuration;
using Opc.Ua.PubSub.DataSets;
+using Opc.Ua.PubSub.Redundancy;
using Opc.Ua.PubSub.Security;
using Opc.Ua.PubSub.Security.Sks;
using Opc.Ua.PubSub.Transports;
@@ -159,6 +160,30 @@ public IPubSubBuilder WithRuntimeStateStore(IPubSubRuntimeStateStore store)
return this;
}
+ ///
+ public IPubSubBuilder WithActivationCoordinator(IPubSubActivationCoordinator coordinator)
+ {
+ if (coordinator is null)
+ {
+ throw new ArgumentNullException(nameof(coordinator));
+ }
+
+ Services.AddSingleton(coordinator);
+ return this;
+ }
+
+ ///
+ public IPubSubBuilder WithLeaseStore(IPubSubLeaseStore leaseStore)
+ {
+ if (leaseStore is null)
+ {
+ throw new ArgumentNullException(nameof(leaseStore));
+ }
+
+ Services.AddSingleton(leaseStore);
+ return this;
+ }
+
///
public IPubSubBuilder WithSecurityKeyStore(IPubSubSecurityKeyStore store)
{
diff --git a/Libraries/Opc.Ua.PubSub/Groups/ReaderGroup.cs b/Libraries/Opc.Ua.PubSub/Groups/ReaderGroup.cs
index c65c3f70c1..98180ff6c0 100644
--- a/Libraries/Opc.Ua.PubSub/Groups/ReaderGroup.cs
+++ b/Libraries/Opc.Ua.PubSub/Groups/ReaderGroup.cs
@@ -35,6 +35,7 @@
using Opc.Ua.PubSub.Diagnostics;
using Opc.Ua.PubSub.Encoding;
using Opc.Ua.PubSub.Scheduling;
+using Opc.Ua.PubSub.Redundancy;
using Opc.Ua.PubSub.StateMachine;
namespace Opc.Ua.PubSub.Groups
@@ -58,6 +59,10 @@ public sealed class ReaderGroup : IReaderGroup, IAsyncDisposable
private readonly IPubSubScheduler? m_scheduler;
private readonly IPubSubDiagnostics? m_diagnostics;
private readonly ITelemetryContext m_telemetry;
+ private readonly System.Threading.Lock m_gate = new();
+ private IPubSubActivationCoordinator m_activationCoordinator = AlwaysActiveCoordinator.Instance;
+ private string m_componentId = string.Empty;
+ private bool m_roleChangedSubscribed;
private DataSetReaderTimeoutWatcher? m_timeoutWatcher;
///
@@ -91,12 +96,16 @@ public ReaderGroup(
/// Diagnostics sink for receive-timeout counter increments. When
/// no counters are emitted.
///
+ /// Optional high-availability activation coordinator.
+ /// Deterministic redundancy component id.
public ReaderGroup(
ReaderGroupDataType configuration,
ArrayOf readers,
ITelemetryContext telemetry,
IPubSubScheduler? scheduler,
- IPubSubDiagnostics? diagnostics)
+ IPubSubDiagnostics? diagnostics,
+ IPubSubActivationCoordinator? activationCoordinator = null,
+ string? componentId = null)
{
if (configuration is null)
{
@@ -110,6 +119,9 @@ public ReaderGroup(
m_readers = readers;
m_dataSetReaders = readers.ToArrayOf(static reader => reader);
Name = configuration.Name ?? string.Empty;
+ ConfigureActivationCoordinator(
+ componentId ?? string.Concat("pubsub:readergroup:", Name),
+ activationCoordinator);
m_telemetry = telemetry;
m_scheduler = scheduler;
m_diagnostics = diagnostics;
@@ -150,7 +162,7 @@ public async ValueTask DispatchAsync(
{
throw new ArgumentNullException(nameof(networkMessage));
}
- if (State.State == PubSubState.Disabled)
+ if (State.State != PubSubState.Operational)
{
return;
}
@@ -200,6 +212,8 @@ public async ValueTask EnableAsync(CancellationToken cancellationToken = default
_ = State.TryResumeCascade();
}
}
+ SubscribeRoleChanges();
+ await ApplyActivationRoleAsync(cancellationToken).ConfigureAwait(false);
if (m_scheduler is not null && m_diagnostics is not null && m_timeoutWatcher is null)
{
m_timeoutWatcher = new DataSetReaderTimeoutWatcher(
@@ -211,12 +225,119 @@ public async ValueTask EnableAsync(CancellationToken cancellationToken = default
}
}
+
+ internal void ConfigureActivationCoordinator(
+ string componentId,
+ IPubSubActivationCoordinator? activationCoordinator)
+ {
+ if (string.IsNullOrEmpty(componentId))
+ {
+ throw new ArgumentException("componentId is required.", nameof(componentId));
+ }
+
+ IPubSubActivationCoordinator previous;
+ bool unsubscribe;
+ lock (m_gate)
+ {
+ previous = m_activationCoordinator;
+ unsubscribe = m_roleChangedSubscribed;
+ m_activationCoordinator = activationCoordinator ?? AlwaysActiveCoordinator.Instance;
+ m_componentId = componentId;
+ m_roleChangedSubscribed = false;
+ }
+ if (unsubscribe)
+ {
+ previous.RoleChanged -= OnRoleChanged;
+ SubscribeRoleChanges();
+ }
+ }
+
+ internal async ValueTask ApplyActivationRoleAsync(CancellationToken cancellationToken = default)
+ {
+ IPubSubActivationCoordinator coordinator;
+ string componentId;
+ lock (m_gate)
+ {
+ coordinator = m_activationCoordinator;
+ componentId = m_componentId;
+ }
+
+ PubSubComponentRole role = await coordinator.GetRoleAsync(componentId, cancellationToken)
+ .ConfigureAwait(false);
+ ApplyActivationRole(role);
+ }
+
+ private void SubscribeRoleChanges()
+ {
+ IPubSubActivationCoordinator coordinator;
+ lock (m_gate)
+ {
+ if (m_roleChangedSubscribed)
+ {
+ return;
+ }
+
+ coordinator = m_activationCoordinator;
+ m_roleChangedSubscribed = true;
+ }
+ coordinator.RoleChanged += OnRoleChanged;
+ }
+
+ private void UnsubscribeRoleChanges()
+ {
+ IPubSubActivationCoordinator coordinator;
+ lock (m_gate)
+ {
+ if (!m_roleChangedSubscribed)
+ {
+ return;
+ }
+
+ coordinator = m_activationCoordinator;
+ m_roleChangedSubscribed = false;
+ }
+ coordinator.RoleChanged -= OnRoleChanged;
+ }
+
+ private void OnRoleChanged(object? sender, PubSubRoleChangedEventArgs e)
+ {
+ if (!string.Equals(e.ComponentId, m_componentId, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ ApplyActivationRole(e.Role);
+ }
+
+ private void ApplyActivationRole(PubSubComponentRole role)
+ {
+ if (role == PubSubComponentRole.Standby)
+ {
+ _ = State.TryPause(PubSubStateTransitionReason.ByParent);
+ return;
+ }
+
+ if (State.State == PubSubState.Paused)
+ {
+ _ = State.TryResume(PubSubStateTransitionReason.ByParent);
+ }
+ if (State.State == PubSubState.PreOperational)
+ {
+ _ = State.TryMarkOperational(PubSubStateTransitionReason.ByParent);
+ }
+ if (State.State == PubSubState.Operational)
+ {
+ _ = State.TryResumeCascade();
+ }
+ }
+
///
/// Disables the reader group and every child reader.
///
public async ValueTask DisableAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
+ UnsubscribeRoleChanges();
DataSetReaderTimeoutWatcher? watcher = m_timeoutWatcher;
m_timeoutWatcher = null;
if (watcher is not null)
diff --git a/Libraries/Opc.Ua.PubSub/Groups/WriterGroup.cs b/Libraries/Opc.Ua.PubSub/Groups/WriterGroup.cs
index 60b0d3677a..581e255932 100644
--- a/Libraries/Opc.Ua.PubSub/Groups/WriterGroup.cs
+++ b/Libraries/Opc.Ua.PubSub/Groups/WriterGroup.cs
@@ -35,6 +35,7 @@
using Opc.Ua.PubSub.DataSets;
using Opc.Ua.PubSub.Encoding;
using Opc.Ua.PubSub.Scheduling;
+using Opc.Ua.PubSub.Redundancy;
using Opc.Ua.PubSub.StateMachine;
using JsonDataSetMessageV2 = Opc.Ua.PubSub.Encoding.Json.JsonDataSetMessage;
using JsonNetworkMessageV2 = Opc.Ua.PubSub.Encoding.Json.JsonNetworkMessage;
@@ -65,6 +66,9 @@ public sealed class WriterGroup : IWriterGroup, IAsyncDisposable
private readonly TimeProvider m_timeProvider;
private readonly Dictionary m_writerState;
private readonly System.Threading.Lock m_gate = new();
+ private IPubSubActivationCoordinator m_activationCoordinator = AlwaysActiveCoordinator.Instance;
+ private string m_componentId = string.Empty;
+ private bool m_roleChangedSubscribed;
private IAsyncDisposable? m_schedule;
private long m_lastPublishedTicks;
private bool m_disposed;
@@ -78,13 +82,17 @@ public sealed class WriterGroup : IWriterGroup, IAsyncDisposable
/// Scheduler used to drive the publish loop.
/// Telemetry context.
/// Clock.
+ /// Optional high-availability activation coordinator.
+ /// Deterministic redundancy component id.
public WriterGroup(
WriterGroupDataType configuration,
ArrayOf writers,
PubSubSchedule schedule,
IPubSubScheduler scheduler,
ITelemetryContext telemetry,
- TimeProvider timeProvider)
+ TimeProvider timeProvider,
+ IPubSubActivationCoordinator? activationCoordinator = null,
+ string? componentId = null)
{
if (configuration is null)
{
@@ -106,6 +114,9 @@ public WriterGroup(
m_timeProvider = timeProvider;
WriterGroupId = configuration.WriterGroupId;
Name = configuration.Name ?? string.Empty;
+ ConfigureActivationCoordinator(
+ componentId ?? string.Concat("pubsub:writergroup:", Name),
+ activationCoordinator);
m_logger = telemetry.CreateLogger();
State = new PubSubStateMachine(
string.IsNullOrEmpty(Name) ? $"group-{WriterGroupId}" : Name,
@@ -167,6 +178,8 @@ public async ValueTask EnableAsync(CancellationToken cancellationToken = default
{
_ = State.TryResumeCascade();
}
+ SubscribeRoleChanges();
+ await ApplyActivationRoleAsync(cancellationToken).ConfigureAwait(false);
m_schedule = await m_scheduler.ScheduleAsync(
Schedule,
PublishOnceAsync,
@@ -180,6 +193,7 @@ public async ValueTask EnableAsync(CancellationToken cancellationToken = default
public async ValueTask DisableAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
+ UnsubscribeRoleChanges();
IAsyncDisposable? schedule;
lock (m_gate)
{
@@ -204,7 +218,7 @@ public async ValueTask PublishOnceAsync(CancellationToken cancellationToken = de
{
return;
}
- if (State.State == PubSubState.Disabled)
+ if (State.State != PubSubState.Operational)
{
return;
}
@@ -262,6 +276,112 @@ await PublishSink(networkMessage, cancellationToken)
}
}
+
+ internal void ConfigureActivationCoordinator(
+ string componentId,
+ IPubSubActivationCoordinator? activationCoordinator)
+ {
+ if (string.IsNullOrEmpty(componentId))
+ {
+ throw new ArgumentException("componentId is required.", nameof(componentId));
+ }
+
+ IPubSubActivationCoordinator previous;
+ bool unsubscribe;
+ lock (m_gate)
+ {
+ previous = m_activationCoordinator;
+ unsubscribe = m_roleChangedSubscribed;
+ m_activationCoordinator = activationCoordinator ?? AlwaysActiveCoordinator.Instance;
+ m_componentId = componentId;
+ m_roleChangedSubscribed = false;
+ }
+ if (unsubscribe)
+ {
+ previous.RoleChanged -= OnRoleChanged;
+ SubscribeRoleChanges();
+ }
+ }
+
+ internal async ValueTask ApplyActivationRoleAsync(CancellationToken cancellationToken = default)
+ {
+ IPubSubActivationCoordinator coordinator;
+ string componentId;
+ lock (m_gate)
+ {
+ coordinator = m_activationCoordinator;
+ componentId = m_componentId;
+ }
+
+ PubSubComponentRole role = await coordinator.GetRoleAsync(componentId, cancellationToken)
+ .ConfigureAwait(false);
+ ApplyActivationRole(role);
+ }
+
+ private void SubscribeRoleChanges()
+ {
+ IPubSubActivationCoordinator coordinator;
+ lock (m_gate)
+ {
+ if (m_roleChangedSubscribed)
+ {
+ return;
+ }
+
+ coordinator = m_activationCoordinator;
+ m_roleChangedSubscribed = true;
+ }
+ coordinator.RoleChanged += OnRoleChanged;
+ }
+
+ private void UnsubscribeRoleChanges()
+ {
+ IPubSubActivationCoordinator coordinator;
+ lock (m_gate)
+ {
+ if (!m_roleChangedSubscribed)
+ {
+ return;
+ }
+
+ coordinator = m_activationCoordinator;
+ m_roleChangedSubscribed = false;
+ }
+ coordinator.RoleChanged -= OnRoleChanged;
+ }
+
+ private void OnRoleChanged(object? sender, PubSubRoleChangedEventArgs e)
+ {
+ if (!string.Equals(e.ComponentId, m_componentId, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ ApplyActivationRole(e.Role);
+ }
+
+ private void ApplyActivationRole(PubSubComponentRole role)
+ {
+ if (role == PubSubComponentRole.Standby)
+ {
+ _ = State.TryPause(PubSubStateTransitionReason.ByParent);
+ return;
+ }
+
+ if (State.State == PubSubState.Paused)
+ {
+ _ = State.TryResume(PubSubStateTransitionReason.ByParent);
+ }
+ if (State.State == PubSubState.PreOperational)
+ {
+ _ = State.TryMarkOperational(PubSubStateTransitionReason.ByParent);
+ }
+ if (State.State == PubSubState.Operational)
+ {
+ _ = State.TryResumeCascade();
+ }
+ }
+
private async ValueTask BuildDataSetMessageAsync(
DataSetWriter writer,
CancellationToken cancellationToken)
diff --git a/Libraries/Opc.Ua.PubSub/Redundancy/AlwaysActiveCoordinator.cs b/Libraries/Opc.Ua.PubSub/Redundancy/AlwaysActiveCoordinator.cs
new file mode 100644
index 0000000000..c1d8c99aee
--- /dev/null
+++ b/Libraries/Opc.Ua.PubSub/Redundancy/AlwaysActiveCoordinator.cs
@@ -0,0 +1,80 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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.Threading;
+using System.Threading.Tasks;
+
+namespace Opc.Ua.PubSub.Redundancy
+{
+ ///
+ /// Default that reports every
+ /// component .
+ ///
+ ///
+ /// Registered by default so non-redundant deployments behave exactly as
+ /// before: no component is ever placed on standby and
+ /// never fires. Redundant deployments replace
+ /// this with a shared-store-backed coordinator.
+ ///
+ public sealed class AlwaysActiveCoordinator : IPubSubActivationCoordinator
+ {
+ ///
+ /// A shared stateless instance.
+ ///
+ public static AlwaysActiveCoordinator Instance { get; } = new();
+
+ ///
+ public event EventHandler? RoleChanged
+ {
+ add { }
+ remove { }
+ }
+
+ ///
+ public ValueTask StartAsync(CancellationToken cancellationToken = default)
+ {
+ return default;
+ }
+
+ ///
+ public ValueTask StopAsync(CancellationToken cancellationToken = default)
+ {
+ return default;
+ }
+
+ ///
+ public ValueTask GetRoleAsync(
+ string componentId,
+ CancellationToken cancellationToken = default)
+ {
+ return new ValueTask(PubSubComponentRole.Active);
+ }
+ }
+}
diff --git a/Libraries/Opc.Ua.PubSub/Redundancy/IPubSubActivationCoordinator.cs b/Libraries/Opc.Ua.PubSub/Redundancy/IPubSubActivationCoordinator.cs
new file mode 100644
index 0000000000..9a79ad1985
--- /dev/null
+++ b/Libraries/Opc.Ua.PubSub/Redundancy/IPubSubActivationCoordinator.cs
@@ -0,0 +1,91 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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.Threading;
+using System.Threading.Tasks;
+
+namespace Opc.Ua.PubSub.Redundancy
+{
+ ///
+ /// Coordinates which PubSub components are active on this instance in a
+ /// redundant (high-availability) deployment, per OPC UA Part 14 §9.1.6.
+ ///
+ ///
+ ///
+ /// A coordinator answers, per component, whether this instance should be
+ /// (drive the transport) or
+ /// (wait to take over), and
+ /// raises when that decision changes so the
+ /// runtime can pause or resume the component.
+ ///
+ ///
+ /// The default registration is ,
+ /// which reports every component active so non-redundant deployments are
+ /// unaffected. Redundant deployments register a shared-store-backed
+ /// coordinator (for example a lease-based one) via dependency injection.
+ /// Implementations are a provider extension point and must be injectable.
+ ///
+ ///
+ public interface IPubSubActivationCoordinator
+ {
+ ///
+ /// Starts coordination (for example begins acquiring and renewing
+ /// leadership leases). Idempotent.
+ ///
+ /// Cancellation token.
+ ValueTask StartAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Stops coordination and relinquishes any acquired leadership.
+ /// Idempotent.
+ ///
+ /// Cancellation token.
+ ValueTask StopAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns the current role of the identified component on this
+ /// instance.
+ ///
+ ///
+ /// Deterministic component identifier (for example
+ /// pubsub:writergroup:WriterGroup1).
+ ///
+ /// Cancellation token.
+ /// The component's current role.
+ ValueTask GetRoleAsync(
+ string componentId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Raised whenever the role of a component changes on this instance.
+ ///
+ event EventHandler? RoleChanged;
+ }
+}
diff --git a/Libraries/Opc.Ua.PubSub/Redundancy/IPubSubLeaseStore.cs b/Libraries/Opc.Ua.PubSub/Redundancy/IPubSubLeaseStore.cs
new file mode 100644
index 0000000000..1e7241da3b
--- /dev/null
+++ b/Libraries/Opc.Ua.PubSub/Redundancy/IPubSubLeaseStore.cs
@@ -0,0 +1,96 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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.Threading;
+using System.Threading.Tasks;
+
+namespace Opc.Ua.PubSub.Redundancy
+{
+ ///
+ /// Shared store used to elect the active instance of a redundant PubSub
+ /// component through leadership leases.
+ ///
+ ///
+ /// Implements the externalized coordination state required for OPC UA
+ /// Part 14 §9.1.6 HA deployments. A distributed implementation (for
+ /// example backed by the shared runtime-state store, a database, or a
+ /// broker primitive) lets multiple instances agree on a single active
+ /// owner per lease key, with a monotonic fencing token on each ownership
+ /// change. The default is
+ /// single-process and intended for tests and single-instance use.
+ ///
+ public interface IPubSubLeaseStore
+ {
+ ///
+ /// Attempts to acquire the lease for on
+ /// behalf of . Succeeds when the lease is
+ /// unheld or expired, or already held by the same owner (renewal).
+ ///
+ /// Contended resource key.
+ /// Requesting instance identifier.
+ /// Requested lease duration.
+ /// Cancellation token.
+ ///
+ /// The acquired lease, or when the lease is
+ /// currently held by another live owner.
+ ///
+ ValueTask TryAcquireAsync(
+ string leaseKey,
+ string ownerId,
+ TimeSpan duration,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Attempts to renew a previously acquired lease. Fails when the lease
+ /// has been taken over by another owner (fencing token advanced).
+ ///
+ /// The lease to renew.
+ /// New lease duration from now.
+ /// Cancellation token.
+ ///
+ /// The renewed lease, or when renewal was
+ /// rejected because ownership was lost.
+ ///
+ ValueTask TryRenewAsync(
+ PubSubLease lease,
+ TimeSpan duration,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Releases a held lease so another instance can acquire it
+ /// immediately. No-op when the lease was already lost.
+ ///
+ /// The lease to release.
+ /// Cancellation token.
+ ValueTask ReleaseAsync(
+ PubSubLease lease,
+ CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Libraries/Opc.Ua.PubSub/Redundancy/InMemoryPubSubLeaseStore.cs b/Libraries/Opc.Ua.PubSub/Redundancy/InMemoryPubSubLeaseStore.cs
new file mode 100644
index 0000000000..4a94b70e08
--- /dev/null
+++ b/Libraries/Opc.Ua.PubSub/Redundancy/InMemoryPubSubLeaseStore.cs
@@ -0,0 +1,139 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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;
+
+namespace Opc.Ua.PubSub.Redundancy
+{
+ ///
+ /// Single-process backed by an in-memory
+ /// dictionary. Intended for tests and single-instance deployments; a
+ /// distributed store is required for genuine multi-instance failover.
+ ///
+ public sealed class InMemoryPubSubLeaseStore : IPubSubLeaseStore
+ {
+ private readonly TimeProvider m_timeProvider;
+ private readonly System.Threading.Lock m_gate = new();
+ private readonly Dictionary m_leases = new(StringComparer.Ordinal);
+ private long m_fencingToken;
+
+ ///
+ /// Initializes a new .
+ ///
+ ///
+ /// Clock used to evaluate lease expiry. Defaults to
+ /// .
+ ///
+ public InMemoryPubSubLeaseStore(TimeProvider? timeProvider = null)
+ {
+ m_timeProvider = timeProvider ?? TimeProvider.System;
+ }
+
+ ///
+ public ValueTask TryAcquireAsync(
+ string leaseKey,
+ string ownerId,
+ TimeSpan duration,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrEmpty(leaseKey))
+ {
+ throw new ArgumentException("leaseKey is required.", nameof(leaseKey));
+ }
+ if (string.IsNullOrEmpty(ownerId))
+ {
+ throw new ArgumentException("ownerId is required.", nameof(ownerId));
+ }
+ cancellationToken.ThrowIfCancellationRequested();
+
+ DateTimeOffset now = m_timeProvider.GetUtcNow();
+ lock (m_gate)
+ {
+ if (m_leases.TryGetValue(leaseKey, out PubSubLease existing)
+ && existing.ExpiresAt > now
+ && !string.Equals(existing.OwnerId, ownerId, StringComparison.Ordinal))
+ {
+ return new ValueTask((PubSubLease?)null);
+ }
+
+ long token = string.Equals(existing.OwnerId, ownerId, StringComparison.Ordinal)
+ && existing.ExpiresAt > now
+ ? existing.FencingToken
+ : ++m_fencingToken;
+ var lease = new PubSubLease(leaseKey, ownerId, token, now + duration);
+ m_leases[leaseKey] = lease;
+ return new ValueTask(lease);
+ }
+ }
+
+ ///
+ public ValueTask TryRenewAsync(
+ PubSubLease lease,
+ TimeSpan duration,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ DateTimeOffset now = m_timeProvider.GetUtcNow();
+ lock (m_gate)
+ {
+ if (!m_leases.TryGetValue(lease.LeaseKey, out PubSubLease current)
+ || current.FencingToken != lease.FencingToken
+ || !string.Equals(current.OwnerId, lease.OwnerId, StringComparison.Ordinal)
+ || current.ExpiresAt <= now)
+ {
+ return new ValueTask((PubSubLease?)null);
+ }
+ var renewed = new PubSubLease(
+ lease.LeaseKey, lease.OwnerId, lease.FencingToken, now + duration);
+ m_leases[lease.LeaseKey] = renewed;
+ return new ValueTask(renewed);
+ }
+ }
+
+ ///
+ public ValueTask ReleaseAsync(
+ PubSubLease lease,
+ CancellationToken cancellationToken = default)
+ {
+ lock (m_gate)
+ {
+ if (m_leases.TryGetValue(lease.LeaseKey, out PubSubLease current)
+ && current.FencingToken == lease.FencingToken
+ && string.Equals(current.OwnerId, lease.OwnerId, StringComparison.Ordinal))
+ {
+ m_leases.Remove(lease.LeaseKey);
+ }
+ }
+ return default;
+ }
+ }
+}
diff --git a/Libraries/Opc.Ua.PubSub/Redundancy/LeaseActivationCoordinator.cs b/Libraries/Opc.Ua.PubSub/Redundancy/LeaseActivationCoordinator.cs
new file mode 100644
index 0000000000..47246a01f3
--- /dev/null
+++ b/Libraries/Opc.Ua.PubSub/Redundancy/LeaseActivationCoordinator.cs
@@ -0,0 +1,337 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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 Microsoft.Extensions.Logging;
+
+namespace Opc.Ua.PubSub.Redundancy
+{
+ ///
+ /// that elects the active
+ /// instance of each component through leadership leases held in a shared
+ /// , per OPC UA Part 14 §9.1.6.
+ ///
+ ///
+ /// Each component is backed by one lease. The instance that holds the
+ /// lease is ; the others are
+ /// and continuously attempt to
+ /// acquire it, taking over automatically when the active instance stops
+ /// renewing. A distributed lease store provides genuine cross-instance
+ /// failover; the default in-memory store is single-process.
+ ///
+ public sealed class LeaseActivationCoordinator : IPubSubActivationCoordinator, IAsyncDisposable
+ {
+ private readonly IPubSubLeaseStore m_leaseStore;
+ private readonly TimeProvider m_timeProvider;
+ private readonly ILogger m_logger;
+ private readonly string m_ownerId;
+ private readonly TimeSpan m_leaseDuration;
+ private readonly TimeSpan m_renewInterval;
+ private readonly TimeSpan m_retryInterval;
+ private readonly System.Threading.Lock m_gate = new();
+ private readonly Dictionary m_loops = new(StringComparer.Ordinal);
+ private CancellationTokenSource? m_cts;
+ private bool m_started;
+ private bool m_disposed;
+
+ ///
+ /// Initializes a new .
+ ///
+ /// Shared lease store used for election.
+ /// Telemetry context for logging.
+ ///
+ /// Stable identifier of this instance. Defaults to a random value.
+ ///
+ ///
+ /// Lease time-to-live. Defaults to 15 seconds.
+ ///
+ ///
+ /// Interval at which the active instance renews its leases. Defaults
+ /// to a third of .
+ ///
+ ///
+ /// Interval at which a standby instance retries acquisition. Defaults
+ /// to a third of .
+ ///
+ /// Clock used for scheduling.
+ public LeaseActivationCoordinator(
+ IPubSubLeaseStore leaseStore,
+ ITelemetryContext? telemetry = null,
+ string? ownerId = null,
+ TimeSpan? leaseDuration = null,
+ TimeSpan? renewInterval = null,
+ TimeSpan? retryInterval = null,
+ TimeProvider? timeProvider = null)
+ {
+ m_leaseStore = leaseStore ?? throw new ArgumentNullException(nameof(leaseStore));
+ m_timeProvider = timeProvider ?? TimeProvider.System;
+ m_logger = telemetry.CreateLogger();
+ m_ownerId = string.IsNullOrEmpty(ownerId)
+ ? Guid.NewGuid().ToString("N")
+ : ownerId!;
+ m_leaseDuration = leaseDuration is { } d && d > TimeSpan.Zero
+ ? d
+ : TimeSpan.FromSeconds(15);
+ m_renewInterval = renewInterval is { } r && r > TimeSpan.Zero
+ ? r
+ : TimeSpan.FromTicks(m_leaseDuration.Ticks / 3);
+ m_retryInterval = retryInterval is { } t && t > TimeSpan.Zero
+ ? t
+ : TimeSpan.FromTicks(m_leaseDuration.Ticks / 3);
+ }
+
+ ///
+ public event EventHandler? RoleChanged;
+
+ ///
+ public ValueTask StartAsync(CancellationToken cancellationToken = default)
+ {
+ lock (m_gate)
+ {
+ if (m_disposed)
+ {
+ throw new ObjectDisposedException(nameof(LeaseActivationCoordinator));
+ }
+ if (!m_started)
+ {
+ m_cts = new CancellationTokenSource();
+ m_started = true;
+ }
+ }
+ return default;
+ }
+
+ ///
+ public async ValueTask StopAsync(CancellationToken cancellationToken = default)
+ {
+ List loops;
+ CancellationTokenSource? cts;
+ lock (m_gate)
+ {
+ m_started = false;
+ cts = m_cts;
+ m_cts = null;
+ loops = [.. m_loops.Values];
+ m_loops.Clear();
+ }
+ if (cts is not null)
+ {
+ cts.Cancel();
+ }
+ foreach (ComponentLoop loop in loops)
+ {
+ await loop.StopAsync().ConfigureAwait(false);
+ }
+ cts?.Dispose();
+ }
+
+ ///
+ public ValueTask GetRoleAsync(
+ string componentId,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrEmpty(componentId))
+ {
+ throw new ArgumentException("componentId is required.", nameof(componentId));
+ }
+ ComponentLoop loop;
+ lock (m_gate)
+ {
+ if (m_disposed)
+ {
+ throw new ObjectDisposedException(nameof(LeaseActivationCoordinator));
+ }
+ if (!m_started || m_cts is null)
+ {
+ return new ValueTask(PubSubComponentRole.Standby);
+ }
+ if (!m_loops.TryGetValue(componentId, out ComponentLoop? existing))
+ {
+ existing = new ComponentLoop(this, componentId, m_cts.Token);
+ m_loops[componentId] = existing;
+ existing.Start();
+ }
+ loop = existing;
+ }
+ return new ValueTask(loop.Role);
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ lock (m_gate)
+ {
+ if (m_disposed)
+ {
+ return;
+ }
+ m_disposed = true;
+ }
+ await StopAsync().ConfigureAwait(false);
+ }
+
+ private void RaiseRoleChanged(string componentId, PubSubComponentRole role)
+ {
+ RoleChanged?.Invoke(this, new PubSubRoleChangedEventArgs(componentId, role));
+ }
+
+ private sealed class ComponentLoop
+ {
+ private readonly LeaseActivationCoordinator m_owner;
+ private readonly string m_componentId;
+ private readonly CancellationToken m_token;
+ private Task? m_task;
+ private volatile int m_role = (int)PubSubComponentRole.Standby;
+
+ public ComponentLoop(
+ LeaseActivationCoordinator owner,
+ string componentId,
+ CancellationToken token)
+ {
+ m_owner = owner;
+ m_componentId = componentId;
+ m_token = token;
+ }
+
+ public PubSubComponentRole Role => (PubSubComponentRole)m_role;
+
+ public void Start()
+ {
+ m_task = Task.Run(() => RunAsync(), CancellationToken.None);
+ }
+
+ public async ValueTask StopAsync()
+ {
+ Task? task = m_task;
+ if (task is not null)
+ {
+ try
+ {
+ await task.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on shutdown.
+ }
+ }
+ }
+
+ private async Task RunAsync()
+ {
+ PubSubLease? held = null;
+ try
+ {
+ while (!m_token.IsCancellationRequested)
+ {
+ if (held is null)
+ {
+ held = await m_owner.m_leaseStore.TryAcquireAsync(
+ m_componentId,
+ m_owner.m_ownerId,
+ m_owner.m_leaseDuration,
+ m_token).ConfigureAwait(false);
+ if (held is not null)
+ {
+ SetRole(PubSubComponentRole.Active);
+ await DelayAsync(m_owner.m_renewInterval).ConfigureAwait(false);
+ }
+ else
+ {
+ SetRole(PubSubComponentRole.Standby);
+ await DelayAsync(m_owner.m_retryInterval).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ PubSubLease? renewed = await m_owner.m_leaseStore.TryRenewAsync(
+ held.Value,
+ m_owner.m_leaseDuration,
+ m_token).ConfigureAwait(false);
+ if (renewed is null)
+ {
+ held = null;
+ SetRole(PubSubComponentRole.Standby);
+ await DelayAsync(m_owner.m_retryInterval).ConfigureAwait(false);
+ continue;
+ }
+ held = renewed;
+ await DelayAsync(m_owner.m_renewInterval).ConfigureAwait(false);
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on shutdown.
+ }
+ catch (Exception ex)
+ {
+ m_owner.m_logger.LogError(
+ ex,
+ "PubSub lease coordination loop for '{ComponentId}' faulted.",
+ m_componentId);
+ }
+ finally
+ {
+ if (held is not null)
+ {
+ try
+ {
+ await m_owner.m_leaseStore.ReleaseAsync(held.Value, CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ m_owner.m_logger.LogDebug(
+ ex,
+ "Releasing lease for '{ComponentId}' on shutdown failed.",
+ m_componentId);
+ }
+ }
+ }
+ }
+
+ private async Task DelayAsync(TimeSpan delay)
+ {
+ await m_owner.m_timeProvider.Delay(delay, m_token).ConfigureAwait(false);
+ }
+
+ private void SetRole(PubSubComponentRole role)
+ {
+ int previous = Interlocked.Exchange(ref m_role, (int)role);
+ if (previous != (int)role)
+ {
+ m_owner.RaiseRoleChanged(m_componentId, role);
+ }
+ }
+ }
+ }
+}
diff --git a/Libraries/Opc.Ua.PubSub/Redundancy/PubSubComponentRole.cs b/Libraries/Opc.Ua.PubSub/Redundancy/PubSubComponentRole.cs
new file mode 100644
index 0000000000..c58afbc74a
--- /dev/null
+++ b/Libraries/Opc.Ua.PubSub/Redundancy/PubSubComponentRole.cs
@@ -0,0 +1,50 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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/
+ * ======================================================================*/
+
+namespace Opc.Ua.PubSub.Redundancy
+{
+ ///
+ /// The redundancy role a PubSub component (connection, writer group, or
+ /// reader group) currently holds on this instance.
+ ///
+ public enum PubSubComponentRole
+ {
+ ///
+ /// The component is active on this instance and drives its transport
+ /// (publishes network messages / dispatches received messages).
+ ///
+ Active = 0,
+
+ ///
+ /// The component is on standby on this instance and does not drive
+ /// its transport; it waits to take over from a failed active peer.
+ ///
+ Standby = 1
+ }
+}
diff --git a/Libraries/Opc.Ua.PubSub/Redundancy/PubSubLease.cs b/Libraries/Opc.Ua.PubSub/Redundancy/PubSubLease.cs
new file mode 100644
index 0000000000..b03666e328
--- /dev/null
+++ b/Libraries/Opc.Ua.PubSub/Redundancy/PubSubLease.cs
@@ -0,0 +1,63 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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;
+
+namespace Opc.Ua.PubSub.Redundancy
+{
+ ///
+ /// An acquired leadership lease used to elect the active instance of a
+ /// redundant PubSub component.
+ ///
+ ///
+ /// The is a monotonically increasing value the
+ /// store assigns each time the lease changes owner; downstream consumers
+ /// can use it to reject stale writes from a superseded active instance
+ /// (fencing), per the externalized-state requirements of OPC UA Part 14
+ /// §9.1.6.
+ ///
+ ///
+ /// Stable key identifying the contended resource (for example a writer
+ /// group component id).
+ ///
+ ///
+ /// Identifier of the instance currently holding the lease.
+ ///
+ ///
+ /// Monotonic token incremented on every ownership change.
+ ///
+ ///
+ /// Absolute UTC time at which the lease expires unless renewed.
+ ///
+ public readonly record struct PubSubLease(
+ string LeaseKey,
+ string OwnerId,
+ long FencingToken,
+ DateTimeOffset ExpiresAt);
+}
diff --git a/Libraries/Opc.Ua.PubSub/Redundancy/PubSubRedundancyMode.cs b/Libraries/Opc.Ua.PubSub/Redundancy/PubSubRedundancyMode.cs
new file mode 100644
index 0000000000..ab4f722fb4
--- /dev/null
+++ b/Libraries/Opc.Ua.PubSub/Redundancy/PubSubRedundancyMode.cs
@@ -0,0 +1,69 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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/
+ * ======================================================================*/
+
+namespace Opc.Ua.PubSub.Redundancy
+{
+ ///
+ /// PubSub redundancy behaviour for a redundant set of publishers or
+ /// subscribers, per OPC UA Part 14 §9.1.6.
+ ///
+ ///
+ /// The mode selects how much runtime state a standby instance keeps
+ /// warm so it can take over from a failed active instance. Cold rebuilds
+ /// from the shared configuration/runtime-state stores on failover, warm
+ /// keeps the configuration enabled but paused, and hot additionally
+ /// tracks live sequence state so take-over introduces no gap.
+ ///
+ public enum PubSubRedundancyMode
+ {
+ ///
+ /// No redundancy — the instance is always active.
+ ///
+ None = 0,
+
+ ///
+ /// Cold standby: a standby instance rebuilds configuration and
+ /// runtime state from the shared stores only after the active
+ /// instance fails.
+ ///
+ Cold = 1,
+
+ ///
+ /// Warm standby: a standby instance keeps its configuration loaded
+ /// and paused, ready to resume quickly on failover.
+ ///
+ Warm = 2,
+
+ ///
+ /// Hot standby: a standby instance additionally tracks live
+ /// sequence/keep-alive state so take-over is seamless.
+ ///
+ Hot = 3
+ }
+}
diff --git a/Libraries/Opc.Ua.PubSub/Redundancy/PubSubRoleChangedEventArgs.cs b/Libraries/Opc.Ua.PubSub/Redundancy/PubSubRoleChangedEventArgs.cs
new file mode 100644
index 0000000000..c928f1d46b
--- /dev/null
+++ b/Libraries/Opc.Ua.PubSub/Redundancy/PubSubRoleChangedEventArgs.cs
@@ -0,0 +1,64 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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;
+
+namespace Opc.Ua.PubSub.Redundancy
+{
+ ///
+ /// Raised by an when the
+ /// redundancy role of a component changes on this instance.
+ ///
+ public sealed class PubSubRoleChangedEventArgs : EventArgs
+ {
+ ///
+ /// Initializes a new .
+ ///
+ ///
+ /// Deterministic component identifier whose role changed.
+ ///
+ /// The new role.
+ public PubSubRoleChangedEventArgs(string componentId, PubSubComponentRole role)
+ {
+ ComponentId = componentId ?? throw new ArgumentNullException(nameof(componentId));
+ Role = role;
+ }
+
+ ///
+ /// Deterministic component identifier whose role changed (for
+ /// example pubsub:writergroup:WriterGroup1).
+ ///
+ public string ComponentId { get; }
+
+ ///
+ /// The role now held by the component on this instance.
+ ///
+ public PubSubComponentRole Role { get; }
+ }
+}
diff --git a/Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj b/Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj
index 65904b2a35..07ecfab900 100644
--- a/Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj
+++ b/Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj
@@ -6,8 +6,12 @@
true
enable
disable
-
- $(NoWarn);IL2104
+
+ $(NoWarn);IL2104;IL3053
diff --git a/Tests/Opc.Ua.Core.Schema.Tests/SchemaValidationIntegrationTests.cs b/Tests/Opc.Ua.Core.Schema.Tests/SchemaValidationIntegrationTests.cs
index d492fcefc9..c6f5574055 100644
--- a/Tests/Opc.Ua.Core.Schema.Tests/SchemaValidationIntegrationTests.cs
+++ b/Tests/Opc.Ua.Core.Schema.Tests/SchemaValidationIntegrationTests.cs
@@ -27,6 +27,7 @@
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
+using System.Text.Json;
using System.Text.Json.Nodes;
using Json.Schema;
using NUnit.Framework;
@@ -161,9 +162,11 @@ private static JsonNode EncodeEncodeable(T value)
private static EvaluationResults Evaluate(IUaSchema schema, JsonNode instance)
{
- var jsonSchema = JsonSchema.FromText(schema.ToSchemaString());
+ var jsonSchema = JsonSchema.FromText(
+ schema.ToSchemaString(),
+ new BuildOptions { SchemaRegistry = new SchemaRegistry() });
return jsonSchema.Evaluate(
- instance,
+ JsonSerializer.SerializeToElement(instance),
new EvaluationOptions { OutputFormat = OutputFormat.List });
}
}
diff --git a/Tests/Opc.Ua.PubSub.Kafka.Tests/FakeKafkaClientAdapter.cs b/Tests/Opc.Ua.PubSub.Kafka.Tests/FakeKafkaClientAdapter.cs
new file mode 100644
index 0000000000..ae0c6058c7
--- /dev/null
+++ b/Tests/Opc.Ua.PubSub.Kafka.Tests/FakeKafkaClientAdapter.cs
@@ -0,0 +1,349 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua.PubSub.Kafka.Internal;
+
+namespace Opc.Ua.PubSub.Kafka.Tests
+{
+ ///
+ /// In-memory implementation of used by the Kafka tests.
+ ///
+ internal sealed class FakeKafkaClientAdapter : IKafkaClientAdapter
+ {
+ private readonly FakeKafkaBus m_bus;
+ private readonly TimeProvider m_timeProvider;
+ private readonly ConcurrentQueue m_produced = new();
+ private readonly System.Threading.Lock m_sync = new();
+ private readonly List m_subscriptions = new();
+ private readonly List m_unsubscriptions = new();
+ private bool m_isConnected;
+ private bool m_disposed;
+
+ public FakeKafkaClientAdapter(FakeKafkaBus? bus = null, TimeProvider? timeProvider = null)
+ {
+ m_bus = bus ?? FakeKafkaBus.Shared;
+ m_timeProvider = timeProvider ?? TimeProvider.System;
+ }
+
+ public IReadOnlyCollection ProducedMessages => m_produced;
+
+ public IReadOnlyList Subscriptions
+ {
+ get
+ {
+ lock (m_sync)
+ {
+ return m_subscriptions.ToArray();
+ }
+ }
+ }
+
+ public IReadOnlyList Unsubscriptions
+ {
+ get
+ {
+ lock (m_sync)
+ {
+ return m_unsubscriptions.ToArray();
+ }
+ }
+ }
+
+ public int ConnectCount { get; private set; }
+
+ public int DisconnectCount { get; private set; }
+
+ public Func? OnConnect { get; set; }
+
+ public Func? OnDisconnect { get; set; }
+
+ public Func? OnProduce { get; set; }
+
+ public bool IsConnected
+ {
+ get
+ {
+ lock (m_sync)
+ {
+ return m_isConnected;
+ }
+ }
+ }
+
+ public bool Disposed => m_disposed;
+
+ public event EventHandler? IncomingMessage;
+
+ public event EventHandler? ConnectionStateChanged;
+
+ public async ValueTask ConnectAsync(KafkaConnectionOptions options, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ ConnectCount++;
+ if (OnConnect is not null)
+ {
+ await OnConnect(options, cancellationToken).ConfigureAwait(false);
+ }
+ lock (m_sync)
+ {
+ m_isConnected = true;
+ }
+ ConnectionStateChanged?.Invoke(
+ this,
+ new KafkaConnectionStateChangedEventArgs(isConnected: true, reason: "Connected"));
+ }
+
+ public async ValueTask DisconnectAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ DisconnectCount++;
+ if (OnDisconnect is not null)
+ {
+ await OnDisconnect(cancellationToken).ConfigureAwait(false);
+ }
+ bool wasConnected;
+ lock (m_sync)
+ {
+ wasConnected = m_isConnected;
+ m_isConnected = false;
+ }
+ m_bus.Unsubscribe(this, Subscriptions);
+ if (wasConnected)
+ {
+ ConnectionStateChanged?.Invoke(
+ this,
+ new KafkaConnectionStateChangedEventArgs(isConnected: false, reason: "Disconnected"));
+ }
+ }
+
+ public ValueTask SubscribeAsync(IReadOnlyList topics, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ lock (m_sync)
+ {
+ foreach (string topic in topics)
+ {
+ if (!m_subscriptions.Contains(topic))
+ {
+ m_subscriptions.Add(topic);
+ }
+ }
+ }
+ m_bus.Subscribe(this, topics);
+ return default;
+ }
+
+ public ValueTask UnsubscribeAsync(IReadOnlyList topics, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ lock (m_sync)
+ {
+ foreach (string topic in topics)
+ {
+ m_unsubscriptions.Add(topic);
+ m_subscriptions.Remove(topic);
+ }
+ }
+ m_bus.Unsubscribe(this, topics);
+ return default;
+ }
+
+ public async ValueTask ProduceAsync(KafkaMessage message, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ KafkaMessage copy = Copy(message);
+ m_produced.Enqueue(copy);
+ if (OnProduce is not null)
+ {
+ await OnProduce(copy, cancellationToken).ConfigureAwait(false);
+ }
+ m_bus.Publish(copy, cancellationToken);
+ }
+
+ public void RaiseIncomingMessage(KafkaMessage message, DateTimeUtc receivedAt)
+ {
+ IncomingMessage?.Invoke(this, new KafkaIncomingMessageEventArgs(Copy(message), receivedAt));
+ }
+
+ public void RaiseConnectionStateChanged(bool isConnected, string? reason = null)
+ {
+ lock (m_sync)
+ {
+ m_isConnected = isConnected;
+ }
+ ConnectionStateChanged?.Invoke(
+ this,
+ new KafkaConnectionStateChangedEventArgs(isConnected, reason));
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ m_disposed = true;
+ m_bus.Unsubscribe(this, Subscriptions);
+ return default;
+ }
+
+ internal void Deliver(KafkaMessage message)
+ {
+ RaiseIncomingMessage(
+ message,
+ DateTimeUtc.From(m_timeProvider.GetUtcNow().UtcDateTime));
+ }
+
+ private static KafkaMessage Copy(KafkaMessage message)
+ {
+ IReadOnlyDictionary? headers = message.Headers is null
+ ? null
+ : message.Headers.ToDictionary(
+ static header => header.Key,
+ static header => header.Value,
+ StringComparer.Ordinal);
+ return new KafkaMessage(
+ message.Topic,
+ message.Key.ToArray(),
+ message.Value.ToArray(),
+ message.ContentType,
+ headers);
+ }
+ }
+
+ ///
+ /// Shared in-memory Kafka topic bus used by fake adapters.
+ ///
+ internal sealed class FakeKafkaBus
+ {
+ private readonly System.Threading.Lock m_sync = new();
+ private readonly Dictionary> m_subscribers = new(StringComparer.Ordinal);
+
+ public static FakeKafkaBus Shared { get; } = new FakeKafkaBus();
+
+ public void Subscribe(FakeKafkaClientAdapter adapter, IReadOnlyList topics)
+ {
+ lock (m_sync)
+ {
+ foreach (string topic in topics)
+ {
+ if (!m_subscribers.TryGetValue(topic, out List? subscribers))
+ {
+ subscribers = new List();
+ m_subscribers[topic] = subscribers;
+ }
+ if (!subscribers.Contains(adapter))
+ {
+ subscribers.Add(adapter);
+ }
+ }
+ }
+ }
+
+ public void Unsubscribe(FakeKafkaClientAdapter adapter, IReadOnlyList topics)
+ {
+ lock (m_sync)
+ {
+ foreach (string topic in topics)
+ {
+ if (m_subscribers.TryGetValue(topic, out List? subscribers))
+ {
+ subscribers.Remove(adapter);
+ if (subscribers.Count == 0)
+ {
+ m_subscribers.Remove(topic);
+ }
+ }
+ }
+ }
+ }
+
+ public void Publish(KafkaMessage message, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ FakeKafkaClientAdapter[] subscribers;
+ lock (m_sync)
+ {
+ subscribers = m_subscribers.TryGetValue(message.Topic, out List? targets)
+ ? targets.ToArray()
+ : Array.Empty();
+ }
+ foreach (FakeKafkaClientAdapter subscriber in subscribers)
+ {
+ subscriber.Deliver(message);
+ }
+ }
+ }
+
+ ///
+ /// Fake Kafka client factory that hands adapters to transports under test.
+ ///
+ internal sealed class FakeKafkaClientFactory : IKafkaClientFactory
+ {
+ private readonly Queue m_adapters = new();
+ private readonly FakeKafkaBus m_bus;
+
+ public FakeKafkaClientFactory(FakeKafkaBus? bus = null)
+ {
+ m_bus = bus ?? new FakeKafkaBus();
+ Adapter = new FakeKafkaClientAdapter(m_bus);
+ m_adapters.Enqueue(Adapter);
+ }
+
+ public FakeKafkaClientFactory(params FakeKafkaClientAdapter[] adapters)
+ {
+ if (adapters.Length == 0)
+ {
+ throw new ArgumentException("At least one adapter is required.", nameof(adapters));
+ }
+ m_bus = new FakeKafkaBus();
+ Adapter = adapters[0];
+ foreach (FakeKafkaClientAdapter adapter in adapters)
+ {
+ m_adapters.Enqueue(adapter);
+ }
+ }
+
+ public FakeKafkaClientAdapter Adapter { get; }
+
+ public int CreateCount { get; private set; }
+
+ IKafkaClientAdapter IKafkaClientFactory.Create(ITelemetryContext telemetry, TimeProvider timeProvider)
+ {
+ CreateCount++;
+ if (m_adapters.Count > 0)
+ {
+ return m_adapters.Dequeue();
+ }
+ return new FakeKafkaClientAdapter(m_bus, timeProvider);
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaBrokerTransportEdgeTests.cs b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaBrokerTransportEdgeTests.cs
new file mode 100644
index 0000000000..33ba7abad7
--- /dev/null
+++ b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaBrokerTransportEdgeTests.cs
@@ -0,0 +1,214 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.PubSub.Encoding;
+using Opc.Ua.PubSub.Tests;
+using Opc.Ua.PubSub.Transports;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.PubSub.Kafka.Tests
+{
+ ///
+ /// Edge-case coverage for Kafka transport guard rails and topic routing.
+ ///
+ [TestFixture]
+ [Category("Unit")]
+ [TestSpec("B.2", Summary = "Kafka broker transport edge cases")]
+ [CancelAfter(10000)]
+ public sealed class KafkaBrokerTransportEdgeTests
+ {
+ [Test]
+ public void ConstructorRejectsInvalidArguments()
+ {
+ KafkaEndpoint endpoint = KafkaEndpointParser.Parse(KafkaTestHelper.EndpointUrl);
+ PubSubConnectionDataType connection = KafkaTestHelper.NewConnection();
+ var options = new KafkaConnectionOptions { Endpoint = KafkaTestHelper.EndpointUrl };
+
+ Assert.That(() => new KafkaBrokerTransport(
+ null!, endpoint, PubSubTransportDirection.Send, options, new FakeKafkaClientFactory(),
+ NUnitTelemetryContext.Create(), TimeProvider.System), Throws.TypeOf());
+ Assert.That(() => new KafkaBrokerTransport(
+ connection, endpoint, PubSubTransportDirection.Send, null!, new FakeKafkaClientFactory(),
+ NUnitTelemetryContext.Create(), TimeProvider.System), Throws.TypeOf());
+ Assert.That(() => new KafkaBrokerTransport(
+ connection, endpoint, PubSubTransportDirection.Send, options, null!,
+ NUnitTelemetryContext.Create(), TimeProvider.System), Throws.TypeOf());
+ Assert.That(() => new KafkaBrokerTransport(
+ connection, endpoint, PubSubTransportDirection.Send, options, new FakeKafkaClientFactory(),
+ null!, TimeProvider.System), Throws.TypeOf());
+ Assert.That(() => new KafkaBrokerTransport(
+ connection, endpoint, PubSubTransportDirection.Send, options, new FakeKafkaClientFactory(),
+ NUnitTelemetryContext.Create(), null!), Throws.TypeOf());
+ }
+
+ [Test]
+ public void SendBeforeOpenThrowsInvalidOperationException()
+ {
+ var factory = new FakeKafkaClientFactory();
+ KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory);
+
+ Assert.That(
+ async () => await transport.SendAsync(new byte[] { 0x01 }, KafkaTestHelper.JsonTopic),
+ Throws.TypeOf());
+ }
+
+ [Test]
+ public async Task SendWithEmptyOrInvalidTopicThrowsAsync()
+ {
+ var factory = new FakeKafkaClientFactory();
+ await using KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory);
+ await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+
+ Assert.That(async () => await transport.SendAsync(new byte[] { 0x01 }, string.Empty),
+ Throws.TypeOf());
+ Assert.That(async () => await transport.SendAsync(new byte[] { 0x01 }, "bad\0topic"),
+ Throws.TypeOf());
+ }
+
+ [Test]
+ public async Task SendCancellationAndDisposeAreObservedAsync()
+ {
+ var factory = new FakeKafkaClientFactory();
+ KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory);
+ await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ Assert.That(async () => await transport.SendAsync(
+ new byte[] { 0x01 },
+ KafkaTestHelper.JsonTopic,
+ cts.Token),
+ Throws.InstanceOf());
+
+ await transport.DisposeAsync().ConfigureAwait(false);
+ Assert.That(async () => await transport.SendAsync(new byte[] { 0x01 }, KafkaTestHelper.JsonTopic),
+ Throws.TypeOf());
+ Assert.That(async () => await transport.OpenAsync(CancellationToken.None),
+ Throws.TypeOf());
+ }
+
+ [Test]
+ public async Task DisposeDuringSendDisconnectsCleanlyAsync()
+ {
+ var gate = new TaskCompletionSource();
+ var factory = new FakeKafkaClientFactory();
+ factory.Adapter.OnProduce = async (_, _) => await gate.Task.ConfigureAwait(false);
+ KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory);
+ await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+
+ Task sendTask = transport.SendAsync(new byte[] { 0x01 }, KafkaTestHelper.JsonTopic).AsTask();
+ Task disposeTask = transport.DisposeAsync().AsTask();
+ gate.SetResult(true);
+ await Task.WhenAll(sendTask, disposeTask).ConfigureAwait(false);
+
+ Assert.That(factory.Adapter.DisconnectCount, Is.EqualTo(1));
+ }
+
+ [Test]
+ public async Task ReceiveWithoutChannelYieldsNoFramesAsync()
+ {
+ var factory = new FakeKafkaClientFactory();
+ await using KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(
+ factory,
+ PubSubTransportDirection.Send);
+ await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(150));
+ int frames = 0;
+
+ await foreach (PubSubTransportFrame _ in transport.ReceiveAsync(cts.Token).ConfigureAwait(false))
+ {
+ frames++;
+ }
+
+ Assert.That(frames, Is.Zero);
+ }
+
+ [Test]
+ public async Task UnsubscribeStopsFakeBusDeliveryAsync()
+ {
+ var bus = new FakeKafkaBus();
+ var subscriberAdapter = new FakeKafkaClientAdapter(bus);
+ var publisherAdapter = new FakeKafkaClientAdapter(bus);
+ var subscriberFactory = new FakeKafkaClientFactory(subscriberAdapter);
+ var publisherFactory = new FakeKafkaClientFactory(publisherAdapter);
+ await using KafkaBrokerTransport subscriber = KafkaTestHelper.NewTransport(
+ subscriberFactory,
+ PubSubTransportDirection.Receive,
+ connection: KafkaTestHelper.NewConnection(writer: false, reader: true));
+ await using KafkaBrokerTransport publisher = KafkaTestHelper.NewTransport(publisherFactory);
+ subscriber.Subscriptions.Add(KafkaTestHelper.JsonTopic);
+ await subscriber.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+ await publisher.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+ await subscriberAdapter.UnsubscribeAsync([KafkaTestHelper.JsonTopic], CancellationToken.None)
+ .ConfigureAwait(false);
+
+ await publisher.SendAsync(new byte[] { 0x01 }, KafkaTestHelper.JsonTopic).ConfigureAwait(false);
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(150));
+ PubSubTransportFrame? frame = await KafkaTestHelper.ReceiveOneAsync(subscriber, cts.Token)
+ .ConfigureAwait(false);
+
+ Assert.That(frame, Is.Null);
+ Assert.That(subscriberAdapter.Unsubscriptions, Does.Contain(KafkaTestHelper.JsonTopic));
+ }
+
+ [Test]
+ public async Task ProducedRecordCarriesContentTypeHeaderAndPartitionKeyAsync()
+ {
+ var factory = new FakeKafkaClientFactory();
+ await using KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory);
+ await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+
+ await transport.SendAsync(new byte[] { 0x01 }, KafkaTestHelper.JsonTopic).ConfigureAwait(false);
+
+ KafkaMessage message = KafkaTestHelper.FirstProduced(factory.Adapter);
+ Assert.That(message.ContentType, Is.EqualTo("application/json"));
+ Assert.That(message.Key.ToArray(), Is.EqualTo(System.Text.Encoding.UTF8.GetBytes("42")));
+ }
+
+ [Test]
+ public void TopicProviderUsesExplicitAndFallbackTopics()
+ {
+ PubSubConnectionDataType connection = KafkaTestHelper.NewConnection(
+ dataTopic: "custom.data",
+ metadataTopic: "custom.metadata");
+ var factory = new FakeKafkaClientFactory();
+ KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory, connection: connection);
+ WriterGroupDataType writerGroup = connection.WriterGroups[0];
+ PublisherId publisherId = PublisherId.From(new Variant((uint)42));
+
+ Assert.That(transport.BuildDataTopic(publisherId, writerGroup, 3), Is.EqualTo("custom.data"));
+ Assert.That(transport.BuildMetaDataTopic(publisherId, 1, 3), Is.EqualTo("custom.metadata"));
+ Assert.That(transport.BuildDiscoveryTopic(publisherId, "status"), Is.EqualTo("opcua.json.status.42"));
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaBrokerTransportLifecycleTests.cs b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaBrokerTransportLifecycleTests.cs
new file mode 100644
index 0000000000..b029c36201
--- /dev/null
+++ b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaBrokerTransportLifecycleTests.cs
@@ -0,0 +1,196 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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 NUnit.Framework;
+using Opc.Ua.PubSub.Diagnostics;
+using Opc.Ua.PubSub.Tests;
+using Opc.Ua.PubSub.Transports;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.PubSub.Kafka.Tests
+{
+ ///
+ /// Lifecycle and state-event tests for the Kafka broker transport.
+ ///
+ [TestFixture]
+ [Category("Unit")]
+ [TestSpec("B.2", Summary = "Kafka transport lifecycle")]
+ [CancelAfter(10000)]
+ public sealed class KafkaBrokerTransportLifecycleTests
+ {
+ [Test]
+ public async Task OpenCloseCycleSucceedsAsync()
+ {
+ var factory = new FakeKafkaClientFactory();
+ await using KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory);
+
+ Assert.That(transport.IsConnected, Is.False);
+
+ await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+ Assert.That(transport.IsConnected, Is.True);
+ Assert.That(factory.Adapter.ConnectCount, Is.EqualTo(1));
+
+ await transport.CloseAsync(CancellationToken.None).ConfigureAwait(false);
+ Assert.That(transport.IsConnected, Is.False);
+ Assert.That(factory.Adapter.DisconnectCount, Is.EqualTo(1));
+ }
+
+ [Test]
+ public async Task OpenOnAlreadyOpenedTransportIsIdempotentAsync()
+ {
+ var factory = new FakeKafkaClientFactory();
+ await using KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory);
+
+ await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+ await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+
+ Assert.That(factory.Adapter.ConnectCount, Is.EqualTo(1));
+ }
+
+ [Test]
+ public async Task CloseOnUnopenedTransportDoesNotThrowAsync()
+ {
+ var factory = new FakeKafkaClientFactory();
+ await using KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory);
+
+ await transport.CloseAsync(CancellationToken.None).ConfigureAwait(false);
+
+ Assert.That(transport.IsConnected, Is.False);
+ Assert.That(factory.Adapter.DisconnectCount, Is.Zero);
+ }
+
+ [Test]
+ public async Task DoubleCloseIsIdempotentAsync()
+ {
+ var factory = new FakeKafkaClientFactory();
+ await using KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory);
+
+ await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+ await transport.CloseAsync(CancellationToken.None).ConfigureAwait(false);
+ await transport.CloseAsync(CancellationToken.None).ConfigureAwait(false);
+
+ Assert.That(factory.Adapter.DisconnectCount, Is.EqualTo(1));
+ }
+
+ [Test]
+ public async Task DisposeAsyncIsIdempotentAsync()
+ {
+ var factory = new FakeKafkaClientFactory();
+ KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory);
+ await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+
+ await transport.DisposeAsync().ConfigureAwait(false);
+ await transport.DisposeAsync().ConfigureAwait(false);
+
+ Assert.That(factory.Adapter.DisconnectCount, Is.EqualTo(1));
+ }
+
+ [Test]
+ public async Task StateChangedFiresOnOpenCloseAndAdapterEventsAsync()
+ {
+ var factory = new FakeKafkaClientFactory();
+ await using KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory);
+ var events = new List();
+ transport.StateChanged += (_, e) => events.Add(e);
+
+ await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+ factory.Adapter.RaiseConnectionStateChanged(false, "broker reset");
+ factory.Adapter.RaiseConnectionStateChanged(true, "reconnected");
+ await transport.CloseAsync(CancellationToken.None).ConfigureAwait(false);
+
+ Assert.That(events, Has.Count.GreaterThanOrEqualTo(4));
+ Assert.That(events[0].IsConnected, Is.True);
+ Assert.That(events, Has.Some.Matches(e =>
+ !e.IsConnected && e.Reason == "broker reset"));
+ Assert.That(events, Has.Some.Matches(e =>
+ e.IsConnected && e.Reason == "reconnected"));
+ Assert.That(events[^1].IsConnected, Is.False);
+ }
+
+ [Test]
+ public async Task SendAsyncRequiresTopicAsync()
+ {
+ var factory = new FakeKafkaClientFactory();
+ await using KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory);
+ await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+
+ Assert.That(
+ async () => await transport.SendAsync(new byte[] { 1, 2, 3 }, topic: null),
+ Throws.TypeOf());
+ }
+
+ [Test]
+ public async Task SendAsyncProducesMessageAndIncrementsDiagnosticsAsync()
+ {
+ var diagnostics = new PubSubDiagnostics(PubSubDiagnosticsLevel.Low);
+ var factory = new FakeKafkaClientFactory();
+ await using KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(
+ factory,
+ diagnostics: diagnostics);
+ await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+
+ byte[] payload = [9, 8, 7, 6];
+ await transport.SendAsync(payload, KafkaTestHelper.JsonTopic).ConfigureAwait(false);
+
+ KafkaMessage message = KafkaTestHelper.FirstProduced(factory.Adapter);
+ Assert.That(message.Topic, Is.EqualTo(KafkaTestHelper.JsonTopic));
+ Assert.That(message.Value.ToArray(), Is.EqualTo(payload));
+ Assert.That(message.ContentType, Is.EqualTo("application/json"));
+ Assert.That(diagnostics.Read(PubSubDiagnosticsCounterKind.SentNetworkMessages), Is.EqualTo(1));
+ }
+
+ [Test]
+ public async Task ReceiveAsyncDeliversIncomingMessagesAsync()
+ {
+ var factory = new FakeKafkaClientFactory();
+ await using KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(
+ factory,
+ PubSubTransportDirection.Receive);
+ transport.Subscriptions.Add(KafkaTestHelper.JsonTopic);
+ await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+
+ byte[] payload = [1, 2, 3];
+ factory.Adapter.RaiseIncomingMessage(
+ new KafkaMessage(KafkaTestHelper.JsonTopic, ReadOnlyMemory.Empty, payload, "application/json", null),
+ DateTimeUtc.From(DateTime.UtcNow));
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+
+ PubSubTransportFrame? frame = await KafkaTestHelper.ReceiveOneAsync(transport, cts.Token)
+ .ConfigureAwait(false);
+
+ Assert.That(frame, Is.Not.Null);
+ Assert.That(frame!.Value.Topic, Is.EqualTo(KafkaTestHelper.JsonTopic));
+ Assert.That(frame.Value.Payload.ToArray(), Is.EqualTo(payload));
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaBrokerTransportRoundTripTests.cs b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaBrokerTransportRoundTripTests.cs
new file mode 100644
index 0000000000..cde177f711
--- /dev/null
+++ b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaBrokerTransportRoundTripTests.cs
@@ -0,0 +1,127 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.PubSub.Tests;
+using Opc.Ua.PubSub.Transports;
+
+namespace Opc.Ua.PubSub.Kafka.Tests
+{
+ ///
+ /// Verifies deterministic in-memory Kafka producer to subscriber round trips.
+ ///
+ [TestFixture]
+ [Category("Unit")]
+ [TestSpec("B.2", Summary = "Kafka transport in-memory round trips")]
+ [CancelAfter(10000)]
+ public sealed class KafkaBrokerTransportRoundTripTests
+ {
+ [Test]
+ [TestCase(KafkaProfiles.PubSubKafkaJsonTransport, KafkaTestHelper.JsonTopic, "application/json")]
+ [TestCase(KafkaProfiles.PubSubKafkaUadpTransport, KafkaTestHelper.UadpTopic, "application/opcua+uadp")]
+ public async Task PublisherAndSubscriberRoundTripPayloadAsync(
+ string profile,
+ string topic,
+ string contentType)
+ {
+ var bus = new FakeKafkaBus();
+ var publisherAdapter = new FakeKafkaClientAdapter(bus);
+ var subscriberAdapter = new FakeKafkaClientAdapter(bus);
+ var publisherFactory = new FakeKafkaClientFactory(publisherAdapter);
+ var subscriberFactory = new FakeKafkaClientFactory(subscriberAdapter);
+ PubSubConnectionDataType pubConnection = KafkaTestHelper.NewConnection(profile: profile);
+ PubSubConnectionDataType subConnection = KafkaTestHelper.NewConnection(
+ writer: false,
+ reader: true,
+ profile: profile,
+ dataTopic: topic,
+ metadataTopic: KafkaTestHelper.MetadataTopic);
+ await using KafkaBrokerTransport publisher = KafkaTestHelper.NewTransport(
+ publisherFactory,
+ PubSubTransportDirection.Send,
+ connection: pubConnection);
+ await using KafkaBrokerTransport subscriber = KafkaTestHelper.NewTransport(
+ subscriberFactory,
+ PubSubTransportDirection.Receive,
+ connection: subConnection);
+ subscriber.Subscriptions.Add(topic);
+
+ await subscriber.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+ await publisher.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+ byte[] payload = [0xCA, 0xFE, 0xBA, 0xBE];
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ Task receiveTask = KafkaTestHelper.ReceiveOneAsync(subscriber, cts.Token);
+
+ await publisher.SendAsync(payload, topic).ConfigureAwait(false);
+ PubSubTransportFrame? frame = await receiveTask.ConfigureAwait(false);
+
+ Assert.That(frame, Is.Not.Null);
+ Assert.That(frame!.Value.Topic, Is.EqualTo(topic));
+ Assert.That(frame.Value.Payload.ToArray(), Is.EqualTo(payload));
+ KafkaMessage produced = KafkaTestHelper.FirstProduced(publisherAdapter);
+ Assert.That(produced.ContentType, Is.EqualTo(contentType));
+ }
+
+ [Test]
+ public async Task SubscriberReceivesDefaultReaderGroupSubscriptionsAsync()
+ {
+ var bus = new FakeKafkaBus();
+ var publisherAdapter = new FakeKafkaClientAdapter(bus);
+ var subscriberAdapter = new FakeKafkaClientAdapter(bus);
+ await using KafkaBrokerTransport publisher = KafkaTestHelper.NewTransport(
+ new FakeKafkaClientFactory(publisherAdapter));
+ await using KafkaBrokerTransport subscriber = KafkaTestHelper.NewTransport(
+ new FakeKafkaClientFactory(subscriberAdapter),
+ PubSubTransportDirection.Receive,
+ connection: KafkaTestHelper.NewConnection(
+ writer: false,
+ reader: true,
+ dataTopic: KafkaTestHelper.JsonTopic,
+ metadataTopic: KafkaTestHelper.MetadataTopic));
+
+ await subscriber.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+ await publisher.OpenAsync(CancellationToken.None).ConfigureAwait(false);
+ Assert.That(subscriberAdapter.Subscriptions, Does.Contain(KafkaTestHelper.JsonTopic));
+ Assert.That(subscriberAdapter.Subscriptions, Does.Contain(KafkaTestHelper.MetadataTopic));
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ Task receiveTask = KafkaTestHelper.ReceiveOneAsync(subscriber, cts.Token);
+ await publisher.SendAsync(new byte[] { 0x11, 0x22 }, KafkaTestHelper.MetadataTopic)
+ .ConfigureAwait(false);
+ PubSubTransportFrame? frame = await receiveTask.ConfigureAwait(false);
+
+ Assert.That(frame, Is.Not.Null);
+ Assert.That(frame!.Value.Topic, Is.EqualTo(KafkaTestHelper.MetadataTopic));
+ Assert.That(frame.Value.Payload.ToArray(), Is.EqualTo(new byte[] { 0x11, 0x22 }));
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaClientAdapterGuardTests.cs b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaClientAdapterGuardTests.cs
new file mode 100644
index 0000000000..08873cce4f
--- /dev/null
+++ b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaClientAdapterGuardTests.cs
@@ -0,0 +1,346 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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/
+ * ======================================================================*/
+
+#if NET10_0_OR_GREATER
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.PubSub.Kafka.Internal;
+using Opc.Ua.PubSub.Tests;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.PubSub.Kafka.Tests
+{
+ ///
+ /// Guard tests for the default Dekaf-backed Kafka adapter that do not require a broker.
+ ///
+ [TestFixture]
+ [Category("Unit")]
+ [TestSpec("B.2", Summary = "Kafka default adapter guard behavior")]
+ [CancelAfter(10000)]
+ public sealed class KafkaClientAdapterGuardTests
+ {
+ [Test]
+ public void ConstructorRejectsInvalidArguments()
+ {
+ Assert.That(
+ () => new DekafKafkaClientAdapter(null!, TimeProvider.System),
+ Throws.TypeOf());
+ Assert.That(
+ () => new DekafKafkaClientAdapter(NUnitTelemetryContext.Create(), null!),
+ Throws.TypeOf());
+ }
+
+ [Test]
+ public void FactoryCreatesDekafAdapter()
+ {
+ var factory = new DekafKafkaClientFactory();
+
+ object adapter = factory.Create(NUnitTelemetryContext.Create(), TimeProvider.System);
+
+ Assert.That(adapter, Is.InstanceOf());
+ }
+
+ [Test]
+ public async Task ConnectDisconnectTransitionsStateAsync()
+ {
+ await using var adapter = new DekafKafkaClientAdapter(NUnitTelemetryContext.Create(), TimeProvider.System);
+ var events = new List();
+ adapter.ConnectionStateChanged += (_, e) => events.Add(e);
+ var options = new KafkaConnectionOptions
+ {
+ Endpoint = KafkaTestHelper.EndpointUrl
+ };
+
+ await adapter.ConnectAsync(options, CancellationToken.None).ConfigureAwait(false);
+ await adapter.ConnectAsync(options, CancellationToken.None).ConfigureAwait(false);
+ await adapter.DisconnectAsync(CancellationToken.None).ConfigureAwait(false);
+ await adapter.DisconnectAsync(CancellationToken.None).ConfigureAwait(false);
+
+ Assert.That(adapter.IsConnected, Is.False);
+ Assert.That(events, Has.Count.EqualTo(2));
+ Assert.That(events[0].IsConnected, Is.True);
+ Assert.That(events[1].IsConnected, Is.False);
+ }
+
+ [Test]
+ public async Task ConnectValidatesCredentialsTlsAndSaslAsync()
+ {
+ await using var adapter = new DekafKafkaClientAdapter(NUnitTelemetryContext.Create(), TimeProvider.System);
+
+ Assert.That(async () => await adapter.ConnectAsync(null!, CancellationToken.None),
+ Throws.TypeOf());
+ Assert.That(async () => await adapter.ConnectAsync(new KafkaConnectionOptions
+ {
+ Endpoint = KafkaTestHelper.EndpointUrl,
+ SaslMechanism = KafkaSaslMechanism.Plain,
+ UserName = "alice"
+ }, CancellationToken.None), Throws.TypeOf());
+ Assert.That(async () => await adapter.ConnectAsync(new KafkaConnectionOptions
+ {
+ Endpoint = KafkaTestHelper.EndpointUrl,
+ SaslMechanism = KafkaSaslMechanism.OAuthBearer,
+ AllowCredentialsOverPlaintext = true
+ }, CancellationToken.None), Throws.TypeOf());
+ Assert.That(async () => await adapter.ConnectAsync(new KafkaConnectionOptions
+ {
+ Endpoint = KafkaTestHelper.EndpointUrl,
+ Tls = new KafkaTlsOptions
+ {
+ UseTls = true,
+ ClientCertificatePath = "client.pem"
+ }
+ }, CancellationToken.None), Throws.TypeOf());
+ }
+
+ [Test]
+ public async Task SubscribeUnsubscribeGuardPathsDoNotRequireBrokerAsync()
+ {
+ await using var adapter = new DekafKafkaClientAdapter(NUnitTelemetryContext.Create(), TimeProvider.System);
+ await adapter.ConnectAsync(new KafkaConnectionOptions { Endpoint = KafkaTestHelper.EndpointUrl },
+ CancellationToken.None).ConfigureAwait(false);
+
+ await adapter.SubscribeAsync(Array.Empty(), CancellationToken.None).ConfigureAwait(false);
+ await adapter.UnsubscribeAsync(Array.Empty(), CancellationToken.None).ConfigureAwait(false);
+ Assert.That(async () => await adapter.SubscribeAsync(null!, CancellationToken.None),
+ Throws.TypeOf());
+ Assert.That(async () => await adapter.UnsubscribeAsync(null!, CancellationToken.None),
+ Throws.TypeOf());
+ }
+
+ [Test]
+ public async Task ProduceAndDisposedGuardsDoNotRequireBrokerAsync()
+ {
+ var adapter = new DekafKafkaClientAdapter(NUnitTelemetryContext.Create(), TimeProvider.System);
+ await adapter.ConnectAsync(new KafkaConnectionOptions { Endpoint = KafkaTestHelper.EndpointUrl },
+ CancellationToken.None).ConfigureAwait(false);
+
+ Assert.That(async () => await adapter.ProduceAsync(default, CancellationToken.None),
+ Throws.TypeOf());
+
+ await adapter.DisposeAsync().ConfigureAwait(false);
+ Assert.That(async () => await adapter.ConnectAsync(new KafkaConnectionOptions(), CancellationToken.None),
+ Throws.TypeOf());
+ Assert.That(async () => await adapter.SubscribeAsync(Array.Empty(), CancellationToken.None),
+ Throws.TypeOf());
+ Assert.That(async () => await adapter.UnsubscribeAsync(Array.Empty(), CancellationToken.None),
+ Throws.TypeOf());
+ }
+
+ [Test]
+ public void PrivateMappingHelpersCoverKafkaConfigurationBranches()
+ {
+ var endpointOptions = new KafkaConnectionOptions { Endpoint = "kafka://broker1,broker2:19092" };
+ var groupOptions = new KafkaConnectionOptions { ClientId = "client-a" };
+ var passwordOptions = new KafkaConnectionOptions { PasswordBytes = System.Text.Encoding.UTF8.GetBytes("pw") };
+ var tlsOptions = new KafkaConnectionOptions
+ {
+ Tls = new KafkaTlsOptions
+ {
+ UseTls = true,
+ ValidateServerCertificate = false,
+ CaCertificatePath = "ca.pem",
+ ClientCertificatePath = "client.pem",
+ ClientKeyPath = "client.key"
+ }
+ };
+ var headersMessage = new KafkaMessage(
+ KafkaTestHelper.JsonTopic,
+ new byte[] { 0x01 },
+ new byte[] { 0x02 },
+ "application/json",
+ new Dictionary { ["x-opcua"] = "value" });
+
+ Assert.That(InvokePrivate("ResolveBootstrapServers", endpointOptions),
+ Is.EqualTo("broker1:9092,broker2:19092"));
+ Assert.That(InvokePrivate("ResolveGroupId", groupOptions), Does.StartWith("client-a-"));
+ Assert.That(InvokePrivate("ResolvePassword", passwordOptions), Is.EqualTo("pw"));
+ Assert.That(InvokePrivate