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("CreateHeaders", headersMessage), Is.Not.Null); + Assert.That(InvokePrivate("CreateTlsConfig", new KafkaConnectionOptions()), Is.Not.Null); + Assert.That(InvokePrivate("CreateTlsConfig", tlsOptions), Is.Not.Null); + Assert.That(InvokePrivate("MapAcks", KafkaAcks.None).ToString(), Is.EqualTo("None")); + Assert.That(InvokePrivate("MapAcks", KafkaAcks.Leader).ToString(), Is.EqualTo("Leader")); + Assert.That(InvokePrivate("MapAcks", KafkaAcks.All).ToString(), Is.EqualTo("All")); + Assert.That(InvokePrivate("MapAutoOffsetReset", KafkaAutoOffsetReset.Earliest).ToString(), + Is.EqualTo("Earliest")); + Assert.That(InvokePrivate("MapAutoOffsetReset", KafkaAutoOffsetReset.Latest).ToString(), + Is.EqualTo("Latest")); + } + + [Test] + public void PrivateValidationHelpersThrowForMissingSaslInputs() + { + object exception = InvokePrivate( + "CreateUnsupportedSaslMechanismException", + KafkaSaslMechanism.OAuthBearer); + + Assert.That(() => InvokePrivate("RequireUserName", new KafkaConnectionOptions()), + Throws.TypeOf()); + Assert.That(() => InvokePrivate("ResolvePassword", new KafkaConnectionOptions()), + Throws.TypeOf()); + Assert.That(exception, Is.InstanceOf()); + } + + [Test] + public void PrivateProducerAndConsumerConfigHelpersCoverSecurityBranches() + { + var plaintext = new KafkaConnectionOptions + { + BootstrapServers = "broker.example.com:9092", + ClientId = "client-a", + GroupId = "group-a", + DeliveryGuarantee = KafkaQualityOfService.BestEffort, + AutoOffsetReset = KafkaAutoOffsetReset.Earliest, + EnableAutoCommit = false + }; + var tls = new KafkaConnectionOptions + { + BootstrapServers = "broker.example.com:9092", + ClientId = "client-tls", + GroupId = "group-tls", + SecurityProtocol = KafkaSecurityProtocol.Ssl, + Tls = new KafkaTlsOptions + { + UseTls = true, + ValidateServerCertificate = true, + CaCertificatePath = "ca.pem" + } + }; + var saslPlain = new KafkaConnectionOptions + { + BootstrapServers = "broker.example.com:9092", + UserName = "alice", + PasswordBytes = System.Text.Encoding.UTF8.GetBytes("password"), + SaslMechanism = KafkaSaslMechanism.Plain, + AllowCredentialsOverPlaintext = true + }; + var saslScram256 = new KafkaConnectionOptions + { + BootstrapServers = "broker.example.com:9092", + UserName = "alice", + PasswordBytes = System.Text.Encoding.UTF8.GetBytes("password"), + SaslMechanism = KafkaSaslMechanism.ScramSha256, + SecurityProtocol = KafkaSecurityProtocol.SaslSsl + }; + var saslScram512 = new KafkaConnectionOptions + { + BootstrapServers = "broker.example.com:9092", + UserName = "alice", + PasswordBytes = System.Text.Encoding.UTF8.GetBytes("password"), + SaslMechanism = KafkaSaslMechanism.ScramSha512, + Tls = new KafkaTlsOptions { UseTls = true } + }; + + InvokeConfig("ApplyProducerConfig", CreateProducerBuilder(), plaintext); + InvokeConfig("ApplyConsumerConfig", CreateConsumerBuilder(), plaintext); + InvokeConfig("ApplyProducerConfig", CreateProducerBuilder(), tls); + InvokeConfig("ApplyConsumerConfig", CreateConsumerBuilder(), tls); + InvokeConfig("ApplyProducerConfig", CreateProducerBuilder(), saslPlain); + InvokeConfig("ApplyConsumerConfig", CreateConsumerBuilder(), saslPlain); + InvokeConfig("ApplyProducerConfig", CreateProducerBuilder(), saslScram256); + InvokeConfig("ApplyConsumerConfig", CreateConsumerBuilder(), saslScram256); + InvokeConfig("ApplyProducerConfig", CreateProducerBuilder(), saslScram512); + InvokeConfig("ApplyConsumerConfig", CreateConsumerBuilder(), saslScram512); + + Assert.That( + () => InvokeConfig( + "ApplyProducerConfig", + CreateProducerBuilder(), + new KafkaConnectionOptions + { + BootstrapServers = "broker.example.com:9092", + SaslMechanism = KafkaSaslMechanism.Plain, + PasswordBytes = System.Text.Encoding.UTF8.GetBytes("password"), + AllowCredentialsOverPlaintext = true + }), + Throws.TypeOf()); + } + + private static T InvokePrivate(string methodName, params object?[] args) + { + MethodInfo method = typeof(DekafKafkaClientAdapter).GetMethod( + methodName, + BindingFlags.NonPublic | BindingFlags.Static)!; + try + { + return (T)method.Invoke(null, args)!; + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + throw ex.InnerException; + } + } + + private static void InvokeConfig(string methodName, object builder, KafkaConnectionOptions options) + { + MethodInfo method = typeof(DekafKafkaClientAdapter).GetMethod( + methodName, + BindingFlags.NonPublic | BindingFlags.Static)!; + try + { + method.Invoke(null, new[] { builder, options }); + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + throw ex.InnerException; + } + } + + private static object CreateProducerBuilder() + { + MethodInfo method = GetDekafKafkaMethod("CreateProducer") + .MakeGenericMethod(typeof(byte[]), typeof(byte[])); + return method.Invoke(null, null)!; + } + + private static object CreateConsumerBuilder() + { + MethodInfo method = GetDekafKafkaMethod("CreateConsumer") + .MakeGenericMethod(typeof(byte[]), typeof(byte[])); + return method.Invoke(null, null)!; + } + + private static MethodInfo GetDekafKafkaMethod(string name) + { + Type kafkaType = Type.GetType("Dekaf.Kafka, Dekaf", throwOnError: true)!; + foreach (MethodInfo method in kafkaType.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (method.Name == name && method.IsGenericMethodDefinition) + { + return method; + } + } + throw new MissingMethodException("Dekaf.Kafka", name); + } + } +} +#endif diff --git a/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaConnectionOptionsTests.cs b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaConnectionOptionsTests.cs new file mode 100644 index 0000000000..6b869227cf --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaConnectionOptionsTests.cs @@ -0,0 +1,158 @@ +/* ======================================================================== + * 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.Linq; +using System.Reflection; +using Microsoft.Extensions.Configuration; +using NUnit.Framework; +using Opc.Ua.PubSub.Tests; + +namespace Opc.Ua.PubSub.Kafka.Tests +{ + /// + /// Verifies Kafka connection option defaults and configuration binding. + /// + [TestFixture] + [Category("Unit")] + [TestSpec("B.2", Summary = "Kafka connection options")] + public sealed class KafkaConnectionOptionsTests + { + [Test] + public void DefaultsMatchSecureBrokerTransportDefaults() + { + var options = new KafkaConnectionOptions(); + + Assert.That(options.Endpoint, Is.EqualTo(string.Empty)); + Assert.That(options.BootstrapServers, Is.EqualTo(string.Empty)); + Assert.That(options.ClientId, Is.Null); + Assert.That(options.GroupId, Is.Null); + Assert.That(options.SecurityProtocol, Is.EqualTo(KafkaSecurityProtocol.Plaintext)); + Assert.That(options.SaslMechanism, Is.EqualTo(KafkaSaslMechanism.None)); + Assert.That(options.UserName, Is.Null); + Assert.That(options.PasswordSecretId, Is.Null); + Assert.That(options.AuthenticationProfileUri, Is.Null); + Assert.That(options.ResourceUri, Is.Null); + Assert.That(options.AllowCredentialsOverPlaintext, Is.False); + Assert.That(options.Tls, Is.Null); + Assert.That(options.DeliveryGuarantee, Is.EqualTo(KafkaQualityOfService.AtLeastOnce)); + Assert.That(options.AutoOffsetReset, Is.EqualTo(KafkaAutoOffsetReset.Latest)); + Assert.That(options.EnableAutoCommit, Is.True); + Assert.That(options.Topics.Prefix, Is.EqualTo("opcua")); + Assert.That(options.ConnectTimeout, Is.EqualTo(TimeSpan.FromSeconds(10))); + Assert.That(options.MessageTimeout, Is.EqualTo(TimeSpan.FromSeconds(30))); + Assert.That(options.MaxMessageSize, Is.EqualTo(1048576)); + } + + [Test] + public void ConfigurationBindingPopulatesScalarAndNestedProperties() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Endpoint"] = "kafkas://broker.example.com:19092", + ["BootstrapServers"] = "broker.example.com:19092", + ["ClientId"] = "pub-client", + ["GroupId"] = "sub-group", + ["SecurityProtocol"] = "SaslSsl", + ["SaslMechanism"] = "ScramSha512", + ["UserName"] = "alice", + ["PasswordSecretId"] = "InMemory:kafka-password", + ["AuthenticationProfileUri"] = "http://opcfoundation.org/UA/Security/UserToken/Server/Password", + ["ResourceUri"] = "kafka-resource", + ["AllowCredentialsOverPlaintext"] = "true", + ["DeliveryGuarantee"] = "ExactlyOnce", + ["AutoOffsetReset"] = "Earliest", + ["EnableAutoCommit"] = "false", + ["Topics:Prefix"] = "plant.a", + ["ConnectTimeout"] = "00:00:04", + ["MessageTimeout"] = "00:00:06", + ["MaxMessageSize"] = "4096", + ["Tls:UseTls"] = "true", + ["Tls:ValidateServerCertificate"] = "false", + ["Tls:CaCertificatePath"] = "certs/ca.pem", + ["Tls:ClientCertificatePath"] = "certs/client.pem", + ["Tls:ClientKeyPath"] = "certs/client.key" + }) + .Build(); + var options = new KafkaConnectionOptions(); + + configuration.Bind(options); + + Assert.That(options.Endpoint, Is.EqualTo("kafkas://broker.example.com:19092")); + Assert.That(options.BootstrapServers, Is.EqualTo("broker.example.com:19092")); + Assert.That(options.ClientId, Is.EqualTo("pub-client")); + Assert.That(options.GroupId, Is.EqualTo("sub-group")); + Assert.That(options.SecurityProtocol, Is.EqualTo(KafkaSecurityProtocol.SaslSsl)); + Assert.That(options.SaslMechanism, Is.EqualTo(KafkaSaslMechanism.ScramSha512)); + Assert.That(options.UserName, Is.EqualTo("alice")); + Assert.That(options.PasswordSecretId, Is.EqualTo("InMemory:kafka-password")); + Assert.That(options.AuthenticationProfileUri, Does.Contain("Password")); + Assert.That(options.ResourceUri, Is.EqualTo("kafka-resource")); + Assert.That(options.AllowCredentialsOverPlaintext, Is.True); + Assert.That(options.DeliveryGuarantee, Is.EqualTo(KafkaQualityOfService.ExactlyOnce)); + Assert.That(options.AutoOffsetReset, Is.EqualTo(KafkaAutoOffsetReset.Earliest)); + Assert.That(options.EnableAutoCommit, Is.False); + Assert.That(options.Topics.Prefix, Is.EqualTo("plant.a")); + Assert.That(options.ConnectTimeout, Is.EqualTo(TimeSpan.FromSeconds(4))); + Assert.That(options.MessageTimeout, Is.EqualTo(TimeSpan.FromSeconds(6))); + Assert.That(options.MaxMessageSize, Is.EqualTo(4096)); + Assert.That(options.Tls, Is.Not.Null); + Assert.That(options.Tls!.UseTls, Is.True); + Assert.That(options.Tls.ValidateServerCertificate, Is.False); + Assert.That(options.Tls.CaCertificatePath, Is.EqualTo("certs/ca.pem")); + Assert.That(options.Tls.ClientCertificatePath, Is.EqualTo("certs/client.pem")); + Assert.That(options.Tls.ClientKeyPath, Is.EqualTo("certs/client.key")); + } + + [Test] + public void OptionsTypeDoesNotExposePlainPasswordProperty() + { + PropertyInfo[] properties = typeof(KafkaConnectionOptions) + .GetProperties(BindingFlags.Public | BindingFlags.Instance); + IEnumerable propertyNames = properties.Select(static p => p.Name); + + Assert.That(propertyNames, Does.Not.Contain("Password")); + Assert.That(propertyNames, Does.Contain("PasswordSecretId")); + } + + [Test] + public void TlsOptionsDefaultsValidateBrokerCertificate() + { + var tls = new KafkaTlsOptions(); + + Assert.That(tls.UseTls, Is.False); + Assert.That(tls.ValidateServerCertificate, Is.True); + Assert.That(tls.CaCertificatePath, Is.Null); + Assert.That(tls.ClientCertificatePath, Is.Null); + Assert.That(tls.ClientKeyPath, Is.Null); + } + } +} diff --git a/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaEncodingTests.cs b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaEncodingTests.cs new file mode 100644 index 0000000000..165c932ddd --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaEncodingTests.cs @@ -0,0 +1,73 @@ +/* ======================================================================== + * 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 NUnit.Framework; +using Opc.Ua.PubSub.Tests; + +namespace Opc.Ua.PubSub.Kafka.Tests +{ + /// + /// Verifies profile and message encoding helpers for Kafka records. + /// + [TestFixture] + [Category("Unit")] + [TestSpec("B.2", Summary = "Kafka profile content types")] + public sealed class KafkaEncodingTests + { + [Test] + [TestCase(KafkaEncoding.Uadp, "uadp", "application/opcua+uadp")] + [TestCase(KafkaEncoding.Json, "json", "application/json")] + public void EncodingMapsToTopicSegmentAndContentType( + KafkaEncoding encoding, + string expectedSegment, + string expectedContentType) + { + Assert.That(encoding.ToTopicSegment(), Is.EqualTo(expectedSegment)); + Assert.That(encoding.ToContentType(), Is.EqualTo(expectedContentType)); + } + + [Test] + public void UndefinedEncodingThrowsArgumentOutOfRangeException() + { + Assert.That( + () => ((KafkaEncoding)123).ToContentType(), + Throws.TypeOf()); + Assert.That( + () => ((KafkaEncoding)123).ToTopicSegment(), + Throws.TypeOf()); + } + + [Test] + public void KafkaProfilesExposeJsonAndUadpProfileUris() + { + Assert.That(KafkaProfiles.PubSubKafkaJsonTransport, Does.Contain("pubsub-kafka-json")); + Assert.That(KafkaProfiles.PubSubKafkaUadpTransport, Does.Contain("pubsub-kafka-uadp")); + } + } +} diff --git a/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaEndpointParserTests.cs b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaEndpointParserTests.cs new file mode 100644 index 0000000000..8d6d43f129 --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaEndpointParserTests.cs @@ -0,0 +1,118 @@ +/* ======================================================================== + * 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 NUnit.Framework; +using Opc.Ua.PubSub.Tests; + +namespace Opc.Ua.PubSub.Kafka.Tests +{ + /// + /// Validates Kafka endpoint parsing for Part 14 Annex B.2 broker URLs. + /// + [TestFixture] + [Category("Unit")] + [TestSpec("B.2", Summary = "Kafka endpoint URL parsing")] + public sealed class KafkaEndpointParserTests + { + [Test] + public void ParseKafkaSchemeUsesDefaultPort() + { + KafkaEndpoint endpoint = KafkaEndpointParser.Parse("kafka://broker.example.com"); + + Assert.That(endpoint.BootstrapServers, Is.EqualTo("broker.example.com:9092")); + Assert.That(endpoint.UseTls, Is.False); + } + + [Test] + public void ParseKafkasSchemeUsesDefaultPortAndTls() + { + KafkaEndpoint endpoint = KafkaEndpointParser.Parse("kafkas://broker.example.com"); + + Assert.That(endpoint.BootstrapServers, Is.EqualTo("broker.example.com:9092")); + Assert.That(endpoint.UseTls, Is.True); + } + + [Test] + public void ParseCommaSeparatedBootstrapListNormalizesEntries() + { + KafkaEndpoint endpoint = KafkaEndpointParser.Parse( + "kafka://broker1.example.com:19092, broker2.example.com,broker3.example.com:29092/path"); + + Assert.That( + endpoint.BootstrapServers, + Is.EqualTo("broker1.example.com:19092,broker2.example.com:9092,broker3.example.com:29092")); + } + + [Test] + public void ParseIpv6BootstrapServersPreservesBrackets() + { + KafkaEndpoint endpoint = KafkaEndpointParser.Parse("kafka://[::1],[2001:db8::1]:19092"); + + Assert.That(endpoint.BootstrapServers, Is.EqualTo("[::1]:9092,[2001:db8::1]:19092")); + } + + [Test] + public void ParseSchemeIsCaseInsensitive() + { + KafkaEndpoint endpoint = KafkaEndpointParser.Parse("KAFKAS://broker.example.com:19092"); + + Assert.That(endpoint.UseTls, Is.True); + Assert.That(endpoint.BootstrapServers, Is.EqualTo("broker.example.com:19092")); + } + + [Test] + public void ParseNullUrlThrowsArgumentNullException() + { + Assert.That( + () => KafkaEndpointParser.Parse(null!), + Throws.TypeOf()); + } + + [Test] + [TestCase("")] + [TestCase("http://broker.example.com")] + [TestCase("kafka:/missing-slash")] + [TestCase("kafka://")] + [TestCase("kafka://broker.example.com,")] + [TestCase("kafka://:9092")] + [TestCase("kafka://broker.example.com:0")] + [TestCase("kafka://broker.example.com:70000")] + [TestCase("kafka://broker.example.com:abc")] + [TestCase("kafka://[::1")] + [TestCase("kafka://[]:9092")] + [TestCase("kafka://[::1]x")] + public void ParseMalformedUrlThrowsFormatException(string url) + { + Assert.That( + () => KafkaEndpointParser.Parse(url), + Throws.TypeOf()); + } + } +} diff --git a/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaHeaderTransportTests.cs b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaHeaderTransportTests.cs new file mode 100644 index 0000000000..85e8d7884e --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaHeaderTransportTests.cs @@ -0,0 +1,113 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.PubSub.Tests; +using Opc.Ua.PubSub.Transports; + +namespace Opc.Ua.PubSub.Kafka.Tests +{ + /// + /// Tests for promoting DataSet fields into Kafka record headers through + /// the capability on + /// . + /// + [TestFixture] + [Category("Unit")] + [TestSpec("B.2", Summary = "Kafka field promotion to record headers")] + [CancelAfter(10000)] + public sealed class KafkaHeaderTransportTests + { + [Test] + public async Task KafkaBrokerTransport_ImplementsHeaderCapability() + { + var factory = new FakeKafkaClientFactory(); + await using KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory); + Assert.That(transport, Is.InstanceOf()); + } + + [Test] + public async Task SendAsync_WithProperties_EmitsRecordHeaders() + { + var factory = new FakeKafkaClientFactory(); + await using KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory); + await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false); + + PubSubMessageProperty[] properties = + [ + new("Temperature", "21.5"), + new("Unit", "C") + ]; + + await transport.SendAsync(new byte[] { 1, 2, 3 }, KafkaTestHelper.JsonTopic, properties) + .ConfigureAwait(false); + + KafkaMessage message = KafkaTestHelper.FirstProduced(factory.Adapter); + Assert.That(message.Headers, Is.Not.Null); + Assert.That(message.Headers!, Has.Count.EqualTo(2)); + Assert.Multiple(() => + { + Assert.That(message.Headers!["Temperature"], Is.EqualTo("21.5")); + Assert.That(message.Headers!["Unit"], Is.EqualTo("C")); + }); + } + + [Test] + public async Task SendAsync_NoProperties_EmitsNoRecordHeaders() + { + var factory = new FakeKafkaClientFactory(); + await using KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory); + await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false); + + await transport + .SendAsync(new byte[] { 1 }, KafkaTestHelper.JsonTopic, + default(ArrayOf)) + .ConfigureAwait(false); + + KafkaMessage message = KafkaTestHelper.FirstProduced(factory.Adapter); + Assert.That(message.Headers, Is.Null); + } + + [Test] + public async Task PlainSendAsync_EmitsNoRecordHeaders() + { + var factory = new FakeKafkaClientFactory(); + await using KafkaBrokerTransport transport = KafkaTestHelper.NewTransport(factory); + await transport.OpenAsync(CancellationToken.None).ConfigureAwait(false); + + await transport.SendAsync(new byte[] { 1 }, KafkaTestHelper.JsonTopic) + .ConfigureAwait(false); + + KafkaMessage message = KafkaTestHelper.FirstProduced(factory.Adapter); + Assert.That(message.Headers, Is.Null); + } + } +} diff --git a/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaIntegrationDockerTests.cs b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaIntegrationDockerTests.cs new file mode 100644 index 0000000000..c2a24bd87d --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaIntegrationDockerTests.cs @@ -0,0 +1,167 @@ +/* ======================================================================== + * 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 NET8_0_OR_GREATER +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Opc.Ua.PubSub.Tests; +using Opc.Ua.PubSub.Transports; +using Opc.Ua.Tests; +using Testcontainers.Kafka; + +namespace Opc.Ua.PubSub.Kafka.Tests +{ + /// + /// Docker-backed Kafka broker integration test using Testcontainers. + /// + [TestFixture] + [Category("Integration")] + [TestSpec("B.2", Summary = "Kafka real broker round trip")] + [CancelAfter(60000)] + public sealed class KafkaIntegrationDockerTests + { + private KafkaContainer? m_container; + + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + try + { + // Dekaf (the net10.0 client) requires a Kafka 4.0+ broker for the + // KIP-848 ConsumerGroupHeartbeat API; the Apache image runs KRaft and + // enables the new consumer group protocol by default. The Confluent + // client used on other TFMs falls back to the classic protocol. + m_container = new KafkaBuilder("apache/kafka:4.0.0").Build(); + await m_container.StartAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Assert.Ignore($"Docker/Kafka not available: {ex.Message}"); + } + } + + [OneTimeTearDown] + public async Task OneTimeTearDownAsync() + { + if (m_container is not null) + { + await m_container.DisposeAsync().ConfigureAwait(false); + } + } + + [Test] + public async Task RealBrokerRoundTripsPayloadAsync() + { + Assert.That(m_container, Is.Not.Null); + // GetBootstrapAddress() returns a scheme-qualified URI such as + // "PLAINTEXT://127.0.0.1:49158"; extract the host:port authority so + // the kafka:// endpoint is well-formed. + var bootstrapUri = new Uri(m_container!.GetBootstrapAddress()); + string topic = "opcua-json-data-" + Guid.NewGuid().ToString("N"); + string url = $"kafka://{bootstrapUri.Host}:{bootstrapUri.Port}"; + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new System.Collections.Generic.Dictionary + { + ["OpcUa:PubSub:Kafka:AutoOffsetReset"] = "Earliest", + ["OpcUa:PubSub:Kafka:GroupId"] = "opcua-tests-" + Guid.NewGuid().ToString("N"), + ["OpcUa:PubSub:Kafka:ClientId"] = "opcua-kafka-tests" + }) + .Build(); + var services = new ServiceCollection(); + services.AddOpcUa().AddPubSub(pubsub => pubsub.AddKafkaTransport(configuration)); + await using ServiceProvider serviceProvider = services.BuildServiceProvider(); + KafkaPubSubTransportFactory factory = serviceProvider + .GetServices() + .OfType() + .Single(f => f.TransportProfileUri == KafkaProfiles.PubSubKafkaJsonTransport); + PubSubConnectionDataType pubConnection = KafkaTestHelper.NewConnection(url: url, dataTopic: topic); + PubSubConnectionDataType subConnection = KafkaTestHelper.NewConnection( + url: url, + writer: false, + reader: true, + dataTopic: topic, + metadataTopic: topic + ".metadata"); + await using IPubSubTransport publisher = factory.Create( + pubConnection, + NUnitTelemetryContext.Create(), + TimeProvider.System); + await using IPubSubTransport subscriber = factory.Create( + subConnection, + NUnitTelemetryContext.Create(), + TimeProvider.System); + if (subscriber is KafkaBrokerTransport kafkaSubscriber) + { + kafkaSubscriber.Subscriptions.Add(topic); + } + + byte[] payload = [0x10, 0x20, 0x30, 0x40]; + + // Open the publisher and produce one record before the subscriber + // subscribes so the topic already exists: the Dekaf consumer (net10, + // KIP-848 protocol) does not pick up a topic that is created after it + // has subscribed. The subscriber reads from the earliest offset once + // it is assigned the partition. + await publisher.OpenAsync(CancellationToken.None).ConfigureAwait(false); + await publisher.SendAsync(payload, topic).ConfigureAwait(false); + await subscriber.OpenAsync(CancellationToken.None).ConfigureAwait(false); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + Task receiveTask = KafkaTestHelper.ReceiveOneAsync(subscriber, cts.Token); + + // Resend periodically in case partition assignment lags; every resend + // carries the same topic and payload, so which copy is observed does not + // affect the assertions. + PubSubTransportFrame? frame = null; + while (frame is null && !cts.IsCancellationRequested) + { + Task finished = await Task.WhenAny( + receiveTask, + Task.Delay(TimeSpan.FromSeconds(2))).ConfigureAwait(false); + if (finished == receiveTask) + { + frame = await receiveTask.ConfigureAwait(false); + } + else + { + await publisher.SendAsync(payload, topic).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)); + } + } +} +#endif diff --git a/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaPubSubTransportFactoryTests.cs b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaPubSubTransportFactoryTests.cs new file mode 100644 index 0000000000..8212b6b42d --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaPubSubTransportFactoryTests.cs @@ -0,0 +1,320 @@ +/* ======================================================================== + * 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 Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Opc.Ua.PubSub.Tests; +using Opc.Ua.PubSub.Transports; +using Opc.Ua.Tests; + +namespace Opc.Ua.PubSub.Kafka.Tests +{ + /// + /// Verifies Kafka transport factory profile validation and connection resolution. + /// + [TestFixture] + [Category("Unit")] + [TestSpec("B.2", Summary = "Kafka transport factory")] + [CancelAfter(5000)] + public sealed class KafkaPubSubTransportFactoryTests + { + [Test] + public void ConstructorRejectsInvalidArguments() + { + Assert.That( + () => new KafkaPubSubTransportFactory( + string.Empty, + new FakeKafkaClientFactory(), + Options.Create(new KafkaConnectionOptions())), + Throws.TypeOf()); + Assert.That( + () => new KafkaPubSubTransportFactory( + Profiles.PubSubUdpUadpTransport, + new FakeKafkaClientFactory(), + Options.Create(new KafkaConnectionOptions())), + Throws.TypeOf()); + Assert.That( + () => new KafkaPubSubTransportFactory( + KafkaProfiles.PubSubKafkaJsonTransport, + clientFactory: null!, + Options.Create(new KafkaConnectionOptions())), + Throws.TypeOf()); + Assert.That( + () => new KafkaPubSubTransportFactory( + KafkaProfiles.PubSubKafkaJsonTransport, + new FakeKafkaClientFactory(), + defaultOptions: null!), + Throws.TypeOf()); + } + + [Test] + public void TransportProfileUriReturnsConstructorValue() + { + KafkaPubSubTransportFactory factory = KafkaTestHelper.NewFactory( + KafkaProfiles.PubSubKafkaUadpTransport); + + Assert.That(factory.TransportProfileUri, Is.EqualTo(KafkaProfiles.PubSubKafkaUadpTransport)); + } + + [Test] + public void CreateValidConnectionReturnsKafkaBrokerTransport() + { + IPubSubTransport transport = KafkaTestHelper.NewFactory().Create( + KafkaTestHelper.NewConnection(), + NUnitTelemetryContext.Create(), + TimeProvider.System); + + Assert.That(transport, Is.InstanceOf()); + Assert.That(transport.TransportProfileUri, Is.EqualTo(KafkaProfiles.PubSubKafkaJsonTransport)); + } + + [Test] + public void CreateReadsNetworkAddressUrlAndAppliesTlsEndpoint() + { + KafkaPubSubTransportFactory factory = KafkaTestHelper.NewFactory(); + PubSubConnectionDataType connection = KafkaTestHelper.NewConnection("kafkas://broker.example.com:19092"); + + var transport = (KafkaBrokerTransport)factory.Create( + connection, + NUnitTelemetryContext.Create(), + TimeProvider.System); + + Assert.That(transport.Endpoint.BootstrapServers, Is.EqualTo("broker.example.com:19092")); + Assert.That(transport.Endpoint.UseTls, Is.True); + Assert.That(transport.Options.Endpoint, Is.EqualTo("kafkas://broker.example.com:19092")); + Assert.That(transport.Options.BootstrapServers, Is.EqualTo("broker.example.com:19092")); + Assert.That(transport.Options.Tls, Is.Not.Null); + Assert.That(transport.Options.Tls!.UseTls, Is.True); + Assert.That(transport.Options.SecurityProtocol, Is.EqualTo(KafkaSecurityProtocol.Ssl)); + } + + [Test] + public void CreateAppliesBrokerTransportAuthenticationSettings() + { + KafkaPubSubTransportFactory factory = KafkaTestHelper.NewFactory(); + PubSubConnectionDataType connection = KafkaTestHelper.NewConnection(); + connection.TransportSettings = new ExtensionObject(new BrokerWriterGroupTransportDataType + { + AuthenticationProfileUri = "http://opcfoundation.org/UA/Security/UserToken/Server/Password", + ResourceUri = "alice" + }); + + var transport = (KafkaBrokerTransport)factory.Create( + connection, + NUnitTelemetryContext.Create(), + TimeProvider.System); + + Assert.That(transport.Options.AuthenticationProfileUri, Does.Contain("Password")); + Assert.That(transport.Options.ResourceUri, Is.EqualTo("alice")); + Assert.That(transport.Options.UserName, Is.EqualTo("alice")); + } + + [Test] + public void CreateDoesNotOverrideDefaultAuthenticationProfile() + { + var options = new KafkaConnectionOptions + { + AuthenticationProfileUri = "default-profile", + ResourceUri = "default-resource", + UserName = "configured" + }; + KafkaPubSubTransportFactory factory = KafkaTestHelper.NewFactory(options: options); + PubSubConnectionDataType connection = KafkaTestHelper.NewConnection(); + connection.TransportSettings = new ExtensionObject(new BrokerWriterGroupTransportDataType + { + AuthenticationProfileUri = "broker-profile", + ResourceUri = "broker-resource" + }); + + var transport = (KafkaBrokerTransport)factory.Create( + connection, + NUnitTelemetryContext.Create(), + TimeProvider.System); + + Assert.That(transport.Options.AuthenticationProfileUri, Is.EqualTo("default-profile")); + Assert.That(transport.Options.ResourceUri, Is.EqualTo("default-resource")); + Assert.That(transport.Options.UserName, Is.EqualTo("configured")); + } + + [Test] + public void CreateResolvesPasswordViaSecretRegistryMock() + { + byte[] expected = [0xAA, 0xBB, 0xCC]; + var secret = new TestSecret(expected); + var registry = new Mock(MockBehavior.Strict); + registry.Setup(r => r.TryGet(It.Is(id => + id.StoreType == InMemorySecretStore.DefaultStoreType && id.Name == "kafka-password"))) + .Returns(secret); + KafkaPubSubTransportFactory factory = KafkaTestHelper.NewFactory( + options: new KafkaConnectionOptions { PasswordSecretId = "InMemory:kafka-password" }, + secretRegistry: registry.Object); + + var transport = (KafkaBrokerTransport)factory.Create( + KafkaTestHelper.NewConnection(), + NUnitTelemetryContext.Create(), + TimeProvider.System); + + Assert.That(transport.Options.PasswordBytes, Is.EqualTo(expected)); + Assert.That(secret.DisposeCount, Is.EqualTo(1)); + registry.VerifyAll(); + } + + [Test] + public void CreatePasswordSecretFailuresThrowInvalidOperationException() + { + KafkaPubSubTransportFactory noRegistry = KafkaTestHelper.NewFactory(options: new KafkaConnectionOptions + { + PasswordSecretId = "InMemory:kafka-password" + }); + var registry = new Mock(MockBehavior.Strict); + registry.Setup(r => r.TryGet(It.IsAny())).Returns((ISecret?)null); + KafkaPubSubTransportFactory missingSecret = KafkaTestHelper.NewFactory( + options: new KafkaConnectionOptions { PasswordSecretId = "missing-secret" }, + secretRegistry: registry.Object); + + Assert.That( + () => noRegistry.Create(KafkaTestHelper.NewConnection(), NUnitTelemetryContext.Create(), TimeProvider.System), + Throws.TypeOf()); + Assert.That( + () => missingSecret.Create( + KafkaTestHelper.NewConnection(), + NUnitTelemetryContext.Create(), + TimeProvider.System), + Throws.TypeOf()); + } + + [Test] + public void CreateDeterminesDirectionFromWriterAndReaderGroups() + { + Assert.That(CreateDirection(KafkaTestHelper.NewConnection(writer: true, reader: false)), + Is.EqualTo(PubSubTransportDirection.Send)); + Assert.That(CreateDirection(KafkaTestHelper.NewConnection(writer: false, reader: true)), + Is.EqualTo(PubSubTransportDirection.Receive)); + Assert.That(CreateDirection(KafkaTestHelper.NewConnection(writer: true, reader: true)), + Is.EqualTo(PubSubTransportDirection.SendReceive)); + } + + [Test] + public void CreateNoGroupsDefaultsToSendReceiveDirection() + { + var connection = new PubSubConnectionDataType + { + Name = "Conn", + TransportProfileUri = KafkaProfiles.PubSubKafkaJsonTransport, + Address = new ExtensionObject(new NetworkAddressUrlDataType + { + Url = KafkaTestHelper.EndpointUrl + }) + }; + + Assert.That(CreateDirection(connection), Is.EqualTo(PubSubTransportDirection.SendReceive)); + } + + [Test] + public void CreateWriterGroupDeliveryGuaranteeOverridesDefaultOptions() + { + PubSubConnectionDataType connection = KafkaTestHelper.NewConnection( + requestedDeliveryGuarantee: BrokerTransportQualityOfService.ExactlyOnce); + + var transport = (KafkaBrokerTransport)KafkaTestHelper.NewFactory().Create( + connection, + NUnitTelemetryContext.Create(), + TimeProvider.System); + + Assert.That(transport.Options.DeliveryGuarantee, Is.EqualTo(KafkaQualityOfService.ExactlyOnce)); + } + + [Test] + public void CreateUadpFactoryProducesUadpTransport() + { + KafkaPubSubTransportFactory factory = KafkaTestHelper.NewFactory(KafkaProfiles.PubSubKafkaUadpTransport); + PubSubConnectionDataType connection = KafkaTestHelper.NewConnection( + profile: KafkaProfiles.PubSubKafkaUadpTransport); + + IPubSubTransport transport = factory.Create( + connection, + NUnitTelemetryContext.Create(), + TimeProvider.System); + + Assert.That(transport.TransportProfileUri, Is.EqualTo(KafkaProfiles.PubSubKafkaUadpTransport)); + } + + [Test] + public void CreateRejectsInvalidCreateArgumentsAndAddresses() + { + KafkaPubSubTransportFactory factory = KafkaTestHelper.NewFactory(); + PubSubConnectionDataType connection = KafkaTestHelper.NewConnection(); + var noAddress = new PubSubConnectionDataType { Name = "NoAddress" }; + var noUrl = new PubSubConnectionDataType + { + Name = "NoUrl", + Address = new ExtensionObject(new NetworkAddressUrlDataType()) + }; + + Assert.That(() => factory.Create(null!, NUnitTelemetryContext.Create(), TimeProvider.System), + Throws.TypeOf()); + Assert.That(() => factory.Create(connection, null!, TimeProvider.System), + Throws.TypeOf()); + Assert.That(() => factory.Create(connection, NUnitTelemetryContext.Create(), null!), + Throws.TypeOf()); + Assert.That(() => factory.Create(noAddress, NUnitTelemetryContext.Create(), TimeProvider.System), + Throws.TypeOf()); + Assert.That(() => factory.Create(noUrl, NUnitTelemetryContext.Create(), TimeProvider.System), + Throws.TypeOf()); + } + + private static PubSubTransportDirection CreateDirection(PubSubConnectionDataType connection) + { + return KafkaTestHelper.NewFactory() + .Create(connection, NUnitTelemetryContext.Create(), TimeProvider.System) + .Direction; + } + + private sealed class TestSecret : ISecret + { + private readonly byte[] m_bytes; + + public TestSecret(byte[] bytes) + { + m_bytes = bytes; + } + + public int DisposeCount { get; private set; } + + public ReadOnlySpan Bytes => m_bytes; + + public void Dispose() + { + DisposeCount++; + } + } + } +} diff --git a/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaQosMappingTests.cs b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaQosMappingTests.cs new file mode 100644 index 0000000000..4c383189ce --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaQosMappingTests.cs @@ -0,0 +1,84 @@ +/* ======================================================================== + * 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 NUnit.Framework; +using Opc.Ua.PubSub.Tests; + +namespace Opc.Ua.PubSub.Kafka.Tests +{ + /// + /// Verifies Part 14 Annex B.2 delivery guarantees map to Kafka producer settings. + /// + [TestFixture] + [Category("Unit")] + [TestSpec("B.2", Summary = "Kafka QoS to acks and idempotence mapping")] + public sealed class KafkaQosMappingTests + { + [Test] + [TestCase(KafkaQualityOfService.BestEffort, KafkaAcks.None, false)] + [TestCase(KafkaQualityOfService.AtMostOnce, KafkaAcks.Leader, false)] + [TestCase(KafkaQualityOfService.AtLeastOnce, KafkaAcks.All, true)] + [TestCase(KafkaQualityOfService.ExactlyOnce, KafkaAcks.All, true)] + public void QualityOfServiceMapsToKafkaDeliveryGuarantee( + KafkaQualityOfService qos, + KafkaAcks expectedAcks, + bool expectedIdempotence) + { + KafkaDeliveryGuarantee guarantee = qos.ToDeliveryGuarantee(); + + Assert.That(guarantee.Acks, Is.EqualTo(expectedAcks)); + Assert.That(guarantee.EnableIdempotence, Is.EqualTo(expectedIdempotence)); + } + + [Test] + [TestCase(BrokerTransportQualityOfService.NotSpecified, KafkaQualityOfService.AtLeastOnce)] + [TestCase(BrokerTransportQualityOfService.BestEffort, KafkaQualityOfService.BestEffort)] + [TestCase(BrokerTransportQualityOfService.AtMostOnce, KafkaQualityOfService.AtMostOnce)] + [TestCase(BrokerTransportQualityOfService.AtLeastOnce, KafkaQualityOfService.AtLeastOnce)] + [TestCase(BrokerTransportQualityOfService.ExactlyOnce, KafkaQualityOfService.ExactlyOnce)] + public void BrokerGuaranteeMapsAllPart14Values( + BrokerTransportQualityOfService brokerGuarantee, + KafkaQualityOfService expected) + { + KafkaQualityOfService actual = KafkaQualityOfServiceExtensions.FromBrokerGuarantee( + brokerGuarantee, + KafkaQualityOfService.AtLeastOnce); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void UndefinedQualityOfServiceThrowsArgumentOutOfRangeException() + { + Assert.That( + () => ((KafkaQualityOfService)123).ToDeliveryGuarantee(), + Throws.TypeOf()); + } + } +} diff --git a/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaTestHelper.cs b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaTestHelper.cs new file mode 100644 index 0000000000..b1e2476f55 --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaTestHelper.cs @@ -0,0 +1,189 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using NUnit.Framework; +using Opc.Ua.PubSub.Diagnostics; +using Opc.Ua.PubSub.Kafka.Internal; +using Opc.Ua.PubSub.Transports; +using Opc.Ua.Tests; + +namespace Opc.Ua.PubSub.Kafka.Tests +{ + internal static class KafkaTestHelper + { + public const string EndpointUrl = "kafka://broker.example.com:9092"; + public const string JsonTopic = "opcua.json.data.42.1.3"; + public const string UadpTopic = "opcua.uadp.data.42.1.3"; + public const string MetadataTopic = "opcua.json.metadata.42.1.3"; + + public static PubSubConnectionDataType NewConnection( + string url = EndpointUrl, + bool writer = true, + bool reader = false, + string profile = KafkaProfiles.PubSubKafkaJsonTransport, + string? dataTopic = null, + string? metadataTopic = null, + BrokerTransportQualityOfService requestedDeliveryGuarantee = + BrokerTransportQualityOfService.NotSpecified) + { + var connection = new PubSubConnectionDataType + { + Name = writer ? "Publisher" : "Subscriber", + TransportProfileUri = profile, + PublisherId = new Variant((uint)42), + Address = new ExtensionObject(new NetworkAddressUrlDataType { Url = url }) + }; + if (writer) + { + var group = new WriterGroupDataType + { + Name = "WriterGroup1", + WriterGroupId = 1, + MessageSettings = new ExtensionObject(CreateWriterGroupMessageSettings(profile)) + }; + if (requestedDeliveryGuarantee != BrokerTransportQualityOfService.NotSpecified) + { + group.TransportSettings = new ExtensionObject(new BrokerWriterGroupTransportDataType + { + RequestedDeliveryGuarantee = requestedDeliveryGuarantee + }); + } + var dataSetWriter = new DataSetWriterDataType + { + Name = "DataSetWriter1", + DataSetWriterId = 3 + }; + if (dataTopic is not null || metadataTopic is not null) + { + dataSetWriter.TransportSettings = new ExtensionObject(new BrokerDataSetWriterTransportDataType + { + QueueName = dataTopic, + MetaDataQueueName = metadataTopic + }); + } + group.DataSetWriters = group.DataSetWriters.AddItem(dataSetWriter); + connection.WriterGroups = connection.WriterGroups.AddItem(group); + } + if (reader) + { + var readerGroup = new ReaderGroupDataType { Name = "ReaderGroup1" }; + var dataSetReader = new DataSetReaderDataType { Name = "DataSetReader1" }; + if (dataTopic is not null || metadataTopic is not null) + { + dataSetReader.TransportSettings = new ExtensionObject(new BrokerDataSetReaderTransportDataType + { + QueueName = dataTopic, + MetaDataQueueName = metadataTopic + }); + } + readerGroup.DataSetReaders = readerGroup.DataSetReaders.AddItem(dataSetReader); + connection.ReaderGroups = connection.ReaderGroups.AddItem(readerGroup); + } + return connection; + } + + public static KafkaBrokerTransport NewTransport( + FakeKafkaClientFactory factory, + PubSubTransportDirection direction = PubSubTransportDirection.Send, + KafkaConnectionOptions? options = null, + PubSubConnectionDataType? connection = null, + IPubSubDiagnostics? diagnostics = null) + { + PubSubConnectionDataType conn = connection ?? NewConnection(); + return new KafkaBrokerTransport( + conn, + KafkaEndpointParser.Parse(EndpointUrl), + direction, + options ?? new KafkaConnectionOptions + { + Endpoint = EndpointUrl, + BootstrapServers = "broker.example.com:9092" + }, + factory, + NUnitTelemetryContext.Create(), + TimeProvider.System, + diagnostics); + } + + public static KafkaPubSubTransportFactory NewFactory( + string transportProfileUri = KafkaProfiles.PubSubKafkaJsonTransport, + IKafkaClientFactory? clientFactory = null, + KafkaConnectionOptions? options = null, + ISecretRegistry? secretRegistry = null, + IPubSubDiagnostics? diagnostics = null) + { + return new KafkaPubSubTransportFactory( + transportProfileUri, + clientFactory ?? new FakeKafkaClientFactory(), + Options.Create(options ?? new KafkaConnectionOptions()), + secretRegistry, + diagnostics); + } + + public static async Task ReceiveOneAsync( + IPubSubTransport transport, + CancellationToken cancellationToken) + { + try + { + await foreach (PubSubTransportFrame frame in transport.ReceiveAsync(cancellationToken) + .ConfigureAwait(false)) + { + return frame; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return null; + } + return null; + } + + public static KafkaMessage FirstProduced(FakeKafkaClientAdapter adapter) + { + Assert.That(adapter.ProducedMessages, Has.Count.EqualTo(1)); + var queue = (ConcurrentQueue)adapter.ProducedMessages; + bool hasValue = queue.TryPeek(out KafkaMessage message); + Assert.That(hasValue, Is.True); + return message; + } + + private static IEncodeable CreateWriterGroupMessageSettings(string profile) + { + return string.Equals(profile, KafkaProfiles.PubSubKafkaUadpTransport, StringComparison.Ordinal) + ? new UadpWriterGroupMessageDataType() + : new JsonWriterGroupMessageDataType(); + } + } +} diff --git a/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaTransportServiceCollectionExtensionsTests.cs b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaTransportServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..9aa044c789 --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Kafka.Tests/KafkaTransportServiceCollectionExtensionsTests.cs @@ -0,0 +1,180 @@ +/* ======================================================================== + * 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.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NUnit.Framework; +using Opc.Ua.PubSub.Kafka.Internal; +using Opc.Ua.PubSub.Tests; +using Opc.Ua.PubSub.Transports; + +namespace Opc.Ua.PubSub.Kafka.Tests +{ + /// + /// Verifies Kafka transport dependency injection registration and option binding. + /// + [TestFixture] + [Category("Unit")] + [TestSpec("B.2", Summary = "Kafka transport DI binding")] + public sealed class KafkaTransportServiceCollectionExtensionsTests + { + [Test] + public async Task AddKafkaTransportWithCallbackRegistersFactoriesAndOptionsAsync() + { + var services = new ServiceCollection(); + + services.AddOpcUa().AddPubSub(pubsub => pubsub.AddKafkaTransport(options => + { + options.Endpoint = "kafka://broker.example.com:9092"; + options.ClientId = "callback-client"; + options.GroupId = "callback-group"; + options.Topics.Prefix = "callback"; + options.DeliveryGuarantee = KafkaQualityOfService.ExactlyOnce; + })); + + await using ServiceProvider serviceProvider = services.BuildServiceProvider(); + KafkaConnectionOptions options = serviceProvider + .GetRequiredService>() + .Value; + KafkaPubSubTransportFactory[] factories = serviceProvider + .GetServices() + .OfType() + .ToArray(); + + Assert.That(options.ClientId, Is.EqualTo("callback-client")); + Assert.That(options.GroupId, Is.EqualTo("callback-group")); + Assert.That(options.Topics.Prefix, Is.EqualTo("callback")); + Assert.That(options.DeliveryGuarantee, Is.EqualTo(KafkaQualityOfService.ExactlyOnce)); + Assert.That(factories, Has.Length.EqualTo(2)); + Assert.That( + factories.Select(static f => f.TransportProfileUri), + Is.EquivalentTo(new[] + { + KafkaProfiles.PubSubKafkaJsonTransport, + KafkaProfiles.PubSubKafkaUadpTransport + })); + Assert.That(serviceProvider.GetRequiredService(), Is.Not.Null); + } + + [Test] + public async Task AddKafkaTransportWithConfigurationBindsDefaultSectionAsync() + { + var services = new ServiceCollection(); + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["OpcUa:PubSub:Kafka:Endpoint"] = "kafkas://broker.example.com:19092", + ["OpcUa:PubSub:Kafka:BootstrapServers"] = "broker.example.com:19092", + ["OpcUa:PubSub:Kafka:ClientId"] = "bound-client", + ["OpcUa:PubSub:Kafka:GroupId"] = "bound-group", + ["OpcUa:PubSub:Kafka:SecurityProtocol"] = "SaslSsl", + ["OpcUa:PubSub:Kafka:SaslMechanism"] = "Plain", + ["OpcUa:PubSub:Kafka:UserName"] = "alice", + ["OpcUa:PubSub:Kafka:PasswordSecretId"] = "InMemory:kafka-password", + ["OpcUa:PubSub:Kafka:AuthenticationProfileUri"] = "profile-uri", + ["OpcUa:PubSub:Kafka:ResourceUri"] = "resource-uri", + ["OpcUa:PubSub:Kafka:AllowCredentialsOverPlaintext"] = "true", + ["OpcUa:PubSub:Kafka:Topics:Prefix"] = "plant.a", + ["OpcUa:PubSub:Kafka:DeliveryGuarantee"] = "BestEffort", + ["OpcUa:PubSub:Kafka:AutoOffsetReset"] = "Earliest", + ["OpcUa:PubSub:Kafka:EnableAutoCommit"] = "false", + ["OpcUa:PubSub:Kafka:ConnectTimeout"] = "00:00:03", + ["OpcUa:PubSub:Kafka:MessageTimeout"] = "00:00:04", + ["OpcUa:PubSub:Kafka:MaxMessageSize"] = "2048", + ["OpcUa:PubSub:Kafka:Tls:UseTls"] = "true", + ["OpcUa:PubSub:Kafka:Tls:ValidateServerCertificate"] = "false", + ["OpcUa:PubSub:Kafka:Tls:CaCertificatePath"] = "ca.pem", + ["OpcUa:PubSub:Kafka:Tls:ClientCertificatePath"] = "client.pem", + ["OpcUa:PubSub:Kafka:Tls:ClientKeyPath"] = "client.key" + }) + .Build(); + + services.AddOpcUa().AddPubSub(pubsub => pubsub.AddKafkaTransport(configuration)); + + await using ServiceProvider serviceProvider = services.BuildServiceProvider(); + KafkaConnectionOptions options = serviceProvider + .GetRequiredService>() + .Value; + + Assert.That(options.Endpoint, Is.EqualTo("kafkas://broker.example.com:19092")); + Assert.That(options.BootstrapServers, Is.EqualTo("broker.example.com:19092")); + Assert.That(options.ClientId, Is.EqualTo("bound-client")); + Assert.That(options.GroupId, Is.EqualTo("bound-group")); + Assert.That(options.SecurityProtocol, Is.EqualTo(KafkaSecurityProtocol.SaslSsl)); + Assert.That(options.SaslMechanism, Is.EqualTo(KafkaSaslMechanism.Plain)); + Assert.That(options.UserName, Is.EqualTo("alice")); + Assert.That(options.PasswordSecretId, Is.EqualTo("InMemory:kafka-password")); + Assert.That(options.AuthenticationProfileUri, Is.EqualTo("profile-uri")); + Assert.That(options.ResourceUri, Is.EqualTo("resource-uri")); + Assert.That(options.AllowCredentialsOverPlaintext, Is.True); + Assert.That(options.Topics.Prefix, Is.EqualTo("plant.a")); + Assert.That(options.DeliveryGuarantee, Is.EqualTo(KafkaQualityOfService.BestEffort)); + Assert.That(options.AutoOffsetReset, Is.EqualTo(KafkaAutoOffsetReset.Earliest)); + Assert.That(options.EnableAutoCommit, Is.False); + Assert.That(options.ConnectTimeout, Is.EqualTo(TimeSpan.FromSeconds(3))); + Assert.That(options.MessageTimeout, Is.EqualTo(TimeSpan.FromSeconds(4))); + Assert.That(options.MaxMessageSize, Is.EqualTo(2048)); + Assert.That(options.Tls, Is.Not.Null); + Assert.That(options.Tls!.UseTls, Is.True); + Assert.That(options.Tls.ValidateServerCertificate, Is.False); + Assert.That(options.Tls.CaCertificatePath, Is.EqualTo("ca.pem")); + Assert.That(options.Tls.ClientCertificatePath, Is.EqualTo("client.pem")); + Assert.That(options.Tls.ClientKeyPath, Is.EqualTo("client.key")); + } + + [Test] + public async Task AddKafkaTransportWithExplicitSectionBindsOptionsAsync() + { + var services = new ServiceCollection(); + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Custom:Endpoint"] = "kafka://broker.example.com:9092", + ["Custom:Topics:Prefix"] = "custom" + }) + .Build(); + + services.AddOpcUa().AddPubSub(pubsub => + pubsub.AddKafkaTransport(configuration.GetSection("Custom"))); + + await using ServiceProvider serviceProvider = services.BuildServiceProvider(); + KafkaConnectionOptions options = serviceProvider + .GetRequiredService>() + .Value; + + Assert.That(options.Endpoint, Is.EqualTo("kafka://broker.example.com:9092")); + Assert.That(options.Topics.Prefix, Is.EqualTo("custom")); + } + } +} diff --git a/Tests/Opc.Ua.PubSub.Kafka.Tests/Opc.Ua.PubSub.Kafka.Tests.csproj b/Tests/Opc.Ua.PubSub.Kafka.Tests/Opc.Ua.PubSub.Kafka.Tests.csproj new file mode 100644 index 0000000000..e3d2da5e62 --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Kafka.Tests/Opc.Ua.PubSub.Kafka.Tests.csproj @@ -0,0 +1,42 @@ + + + Exe + $(TestsTargetFrameworks) + Opc.Ua.PubSub.Kafka.Tests + enable + false + $(NoWarn);CS1591;CA2007;CA2000;CA1014 + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubRealMessageValidationTests.cs b/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubRealMessageValidationTests.cs index 7e87bb3900..90d4b16075 100644 --- a/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubRealMessageValidationTests.cs +++ b/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubRealMessageValidationTests.cs @@ -218,9 +218,11 @@ private static DataSetMetaDataType CreateMetaData() private static EvaluationResults Evaluate(UaSchema 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.Schema.Tests/PubSubSchemaValidationIntegrationTests.cs b/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubSchemaValidationIntegrationTests.cs index 8f0d86f5a0..2eeb3c3a65 100644 --- a/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubSchemaValidationIntegrationTests.cs +++ b/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubSchemaValidationIntegrationTests.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; @@ -143,9 +144,11 @@ private static DataSetMetaDataType CreateMetaData() private static EvaluationResults Evaluate(UaSchema 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.Tests/Redundancy/PubSubActivationFailoverTests.cs b/Tests/Opc.Ua.PubSub.Tests/Redundancy/PubSubActivationFailoverTests.cs new file mode 100644 index 0000000000..735ad829f7 --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Tests/Redundancy/PubSubActivationFailoverTests.cs @@ -0,0 +1,480 @@ +/* ======================================================================== + * 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.Time.Testing; +using Moq; +using NUnit.Framework; +using Opc.Ua.PubSub.DataSets; +using Opc.Ua.PubSub.Encoding; +using Opc.Ua.PubSub.Encoding.Uadp; +using Opc.Ua.PubSub.Groups; +using Opc.Ua.PubSub.Redundancy; +using Opc.Ua.PubSub.Scheduling; +using Opc.Ua.PubSub.StateMachine; +using Opc.Ua.Tests; + +namespace Opc.Ua.PubSub.Tests.Redundancy +{ + /// + /// Covers the Part 14 §9.1.6 activation coordinator failover wiring used + /// by redundant PubSub publishers and subscribers. + /// + [TestFixture] + [TestSpec("9.1.6", Summary = "PubSub HA activation coordinator failover")] + public class PubSubActivationFailoverTests + { + private const string WriterComponentId = "pubsub:writergroup:conn:wg"; + private const string ReaderComponentId = "pubsub:readergroup:conn:rg"; + + [Test] + [TestSpec("9.1.6")] + public async Task LeaseActivationCoordinator_ElectsSingleActiveAndFailsOverOnStopAsync() + { + var clock = new FakeTimeProvider( + new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + var store = new InMemoryPubSubLeaseStore(clock); + TimeSpan ttl = TimeSpan.FromSeconds(9); + TimeSpan interval = TimeSpan.FromSeconds(1); + var firstEvents = new List(); + var secondEvents = new List(); + + await using var first = new LeaseActivationCoordinator( + store, + NUnitTelemetryContext.Create(), + ownerId: "publisher-a", + leaseDuration: ttl, + renewInterval: interval, + retryInterval: interval, + timeProvider: clock); + await using var second = new LeaseActivationCoordinator( + store, + NUnitTelemetryContext.Create(), + ownerId: "publisher-b", + leaseDuration: ttl, + renewInterval: interval, + retryInterval: interval, + timeProvider: clock); + first.RoleChanged += (_, e) => firstEvents.Add(e); + second.RoleChanged += (_, e) => secondEvents.Add(e); + + await first.StartAsync().ConfigureAwait(false); + await second.StartAsync().ConfigureAwait(false); + _ = await first.GetRoleAsync(WriterComponentId).ConfigureAwait(false); + _ = await second.GetRoleAsync(WriterComponentId).ConfigureAwait(false); + + await WaitForRolesAsync( + clock, + first, + second, + activeCount: 1).ConfigureAwait(false); + + PubSubComponentRole firstRole = await first.GetRoleAsync(WriterComponentId).ConfigureAwait(false); + LeaseActivationCoordinator active = firstRole == PubSubComponentRole.Active ? first : second; + LeaseActivationCoordinator standby = ReferenceEquals(active, first) ? second : first; + List standbyEvents = ReferenceEquals(standby, first) + ? firstEvents + : secondEvents; + + await active.StopAsync().ConfigureAwait(false); + clock.Advance(interval); + + await WaitForRoleAsync( + clock, + standby, + PubSubComponentRole.Active).ConfigureAwait(false); + + Assert.That( + standbyEvents, + Has.Some.Matches(e => + string.Equals(e.ComponentId, WriterComponentId, StringComparison.Ordinal) + && e.Role == PubSubComponentRole.Active)); + } + + [Test] + [TestSpec("9.1.6")] + public async Task InMemoryLeaseStore_IncrementsFencingTokenOnOwnershipChangeAsync() + { + var clock = new FakeTimeProvider( + new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + var store = new InMemoryPubSubLeaseStore(clock); + TimeSpan ttl = TimeSpan.FromSeconds(30); + + PubSubLease? firstLease = await store.TryAcquireAsync( + WriterComponentId, + "owner-a", + ttl).ConfigureAwait(false); + Assert.That(firstLease, Is.Not.Null); + PubSubLease first = firstLease.GetValueOrDefault(); + + PubSubLease? blockedLease = await store.TryAcquireAsync( + WriterComponentId, + "owner-b", + ttl).ConfigureAwait(false); + Assert.That(blockedLease, Is.Null); + + await store.ReleaseAsync(first).ConfigureAwait(false); + PubSubLease? secondLease = await store.TryAcquireAsync( + WriterComponentId, + "owner-b", + ttl).ConfigureAwait(false); + + Assert.That(secondLease, Is.Not.Null); + PubSubLease second = secondLease.GetValueOrDefault(); + Assert.That(second.FencingToken, Is.GreaterThan(first.FencingToken)); + } + + [Test] + [TestSpec("9.1.6")] + public async Task InMemoryLeaseStore_RejectsRenewalOfExpiredLeaseAsync() + { + var clock = new FakeTimeProvider( + new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + var store = new InMemoryPubSubLeaseStore(clock); + TimeSpan ttl = TimeSpan.FromSeconds(10); + + PubSubLease? acquired = await store.TryAcquireAsync( + WriterComponentId, + "owner-a", + ttl).ConfigureAwait(false); + Assert.That(acquired, Is.Not.Null); + PubSubLease lease = acquired.GetValueOrDefault(); + + PubSubLease? renewedInTime = await store.TryRenewAsync(lease, ttl).ConfigureAwait(false); + Assert.That(renewedInTime, Is.Not.Null); + + clock.Advance(ttl + TimeSpan.FromSeconds(1)); + + PubSubLease? renewedExpired = await store.TryRenewAsync( + renewedInTime.GetValueOrDefault(), + ttl).ConfigureAwait(false); + Assert.That(renewedExpired, Is.Null); + + PubSubLease? reacquired = await store.TryAcquireAsync( + WriterComponentId, + "owner-a", + ttl).ConfigureAwait(false); + Assert.That(reacquired, Is.Not.Null); + Assert.That( + reacquired.GetValueOrDefault().FencingToken, + Is.GreaterThan(lease.FencingToken)); + } + + [Test] + [TestSpec("9.1.6")] + public async Task WriterGroup_StandbyRolePausesPublishingUntilActiveAsync() + { + PubSubComponentRole role = PubSubComponentRole.Standby; + Mock coordinator = CreateCoordinatorMock( + WriterComponentId, + () => role); + var captured = new List(); + WriterGroup group = CreateWriterGroup(coordinator.Object, captured); + + await group.EnableAsync().ConfigureAwait(false); + await group.PublishOnceAsync().ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(group.State.State, Is.EqualTo(PubSubState.Paused)); + Assert.That(captured, Is.Empty); + }); + + role = PubSubComponentRole.Active; + coordinator.Raise( + c => c.RoleChanged += null!, + new PubSubRoleChangedEventArgs(WriterComponentId, PubSubComponentRole.Active)); + await group.PublishOnceAsync().ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(group.State.State, Is.EqualTo(PubSubState.Operational)); + Assert.That(captured, Has.Count.EqualTo(1)); + }); + } + + [Test] + [TestSpec("9.1.6")] + public async Task ReaderGroup_StandbyRoleSuppressesDispatchUntilActiveAsync() + { + PubSubComponentRole role = PubSubComponentRole.Standby; + Mock coordinator = CreateCoordinatorMock( + ReaderComponentId, + () => role); + var sink = new CountingSink(); + ReaderGroup group = CreateReaderGroup(coordinator.Object, sink); + PubSubNetworkMessage message = CreateNetworkMessage(); + + await group.EnableAsync().ConfigureAwait(false); + await group.DispatchAsync(message).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(group.State.State, Is.EqualTo(PubSubState.Paused)); + Assert.That(sink.CallCount, Is.Zero); + }); + + role = PubSubComponentRole.Active; + coordinator.Raise( + c => c.RoleChanged += null!, + new PubSubRoleChangedEventArgs(ReaderComponentId, PubSubComponentRole.Active)); + await group.DispatchAsync(message).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(group.State.State, Is.EqualTo(PubSubState.Operational)); + Assert.That(sink.CallCount, Is.EqualTo(1)); + }); + } + + private static Mock CreateCoordinatorMock( + string componentId, + Func role) + { + var coordinator = new Mock(MockBehavior.Strict); + coordinator + .Setup(c => c.GetRoleAsync(componentId, It.IsAny())) + .Returns(() => new ValueTask(role())); + coordinator + .Setup(c => c.StartAsync(It.IsAny())) + .Returns(default(ValueTask)); + coordinator + .Setup(c => c.StopAsync(It.IsAny())) + .Returns(default(ValueTask)); + return coordinator; + } + + private static WriterGroup CreateWriterGroup( + IPubSubActivationCoordinator coordinator, + List captured) + { + var dataSetConfig = new PublishedDataSetDataType + { + Name = "pds", + DataSetMetaData = new DataSetMetaDataType + { + Fields = [new FieldMetaData { Name = "value" }] + } + }; + var dataSet = new PublishedDataSet(dataSetConfig, new CountingSource()); + var writer = new DataSetWriter( + new DataSetWriterDataType + { + Name = "writer", + DataSetWriterId = 1, + DataSetName = "pds", + KeyFrameCount = 1 + }, + dataSet, + NUnitTelemetryContext.Create()); + var group = new WriterGroup( + new WriterGroupDataType + { + Name = "wg", + WriterGroupId = 1, + PublishingInterval = 100 + }, + [writer], + new PubSubSchedule( + TimeSpan.FromMilliseconds(100), + TimeSpan.Zero, + TimeSpan.Zero, + TimeSpan.Zero), + NoOpScheduler.Instance, + NUnitTelemetryContext.Create(), + TimeProvider.System, + coordinator, + WriterComponentId) + { + PublishSink = (message, _) => + { + captured.Add(message); + return default; + } + }; + return group; + } + + private static ReaderGroup CreateReaderGroup( + IPubSubActivationCoordinator coordinator, + ISubscribedDataSetSink sink) + { + var reader = new DataSetReader( + new DataSetReaderDataType + { + Name = "reader", + DataSetWriterId = 1 + }, + sink, + NUnitTelemetryContext.Create(), + TimeProvider.System); + return new ReaderGroup( + new ReaderGroupDataType { Name = "rg" }, + [reader], + NUnitTelemetryContext.Create(), + scheduler: null, + diagnostics: null, + coordinator, + ReaderComponentId); + } + + private static UadpNetworkMessage CreateNetworkMessage() + { + return new UadpNetworkMessage + { + DataSetMessages = + [ + new UadpDataSetMessage + { + DataSetWriterId = 1, + Fields = [new DataSetField { Name = "value", Value = new Variant(1) }] + } + ] + }; + } + + private static async Task WaitForRolesAsync( + FakeTimeProvider clock, + LeaseActivationCoordinator first, + LeaseActivationCoordinator second, + int activeCount) + { + await WaitForConditionAsync(clock, async () => + { + int count = 0; + if (await first.GetRoleAsync(WriterComponentId).ConfigureAwait(false) == PubSubComponentRole.Active) + { + count++; + } + if (await second.GetRoleAsync(WriterComponentId).ConfigureAwait(false) == PubSubComponentRole.Active) + { + count++; + } + return count == activeCount; + }).ConfigureAwait(false); + } + + private static async Task WaitForRoleAsync( + FakeTimeProvider clock, + LeaseActivationCoordinator coordinator, + PubSubComponentRole role) + { + await WaitForConditionAsync(clock, async () => + await coordinator.GetRoleAsync(WriterComponentId).ConfigureAwait(false) == role).ConfigureAwait(false); + } + + private static async Task WaitForConditionAsync( + FakeTimeProvider clock, + Func> condition) + { + for (int i = 0; i < 80; i++) + { + if (await condition().ConfigureAwait(false)) + { + return; + } + + clock.Advance(TimeSpan.FromSeconds(1)); + + // The coordinator loops run on background threads driven by the + // fake clock; a short real delay reliably yields CPU so they can + // observe the advance and apply the resulting role change before + // the next check. Task.Yield alone starves them on contended + // CI agents (observed flaky on the net48 Windows runner). + await Task.Delay(25).ConfigureAwait(false); + } + + Assert.Fail("The expected HA role transition did not occur."); + } + + private sealed class CountingSource : IPublishedDataSetSource + { + private int m_value; + + public DataSetMetaDataType BuildMetaData() + { + return new DataSetMetaDataType + { + Fields = [new FieldMetaData { Name = "value" }] + }; + } + + public ValueTask SampleAsync( + DataSetMetaDataType metaData, + CancellationToken cancellationToken = default) + { + int value = Interlocked.Increment(ref m_value); + return new ValueTask( + new PublishedDataSetSnapshot( + new ConfigurationVersionDataType(), + [new DataSetField { Name = "value", Value = new Variant(value) }], + DateTimeUtc.From(DateTimeOffset.UtcNow))); + } + } + + private sealed class CountingSink : ISubscribedDataSetSink + { + public int CallCount { get; private set; } + + public ValueTask WriteAsync( + IReadOnlyList fields, + CancellationToken cancellationToken = default) + { + CallCount++; + return default; + } + } + + private sealed class NoOpScheduler : IPubSubScheduler + { + public static NoOpScheduler Instance { get; } = new(); + + public ValueTask ScheduleAsync( + PubSubSchedule schedule, + Func action, + CancellationToken cancellationToken = default) + { + return new ValueTask(NoOpHandle.Instance); + } + + private sealed class NoOpHandle : IAsyncDisposable + { + public static NoOpHandle Instance { get; } = new(); + + public ValueTask DisposeAsync() + { + return default; + } + } + } + } +} diff --git a/UA.slnx b/UA.slnx index 9f6e0359e0..09b5783aea 100644 --- a/UA.slnx +++ b/UA.slnx @@ -72,6 +72,7 @@ + @@ -223,6 +224,7 @@ +