From 45b751d73b727481eef94b570f3d58f27a813e4d Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 20 Feb 2026 12:50:55 +0100 Subject: [PATCH 01/19] Add IBM MQ transport support, including custom checks and tests * Introduced `ServiceControl.Transports.IBMMQ` with necessary configurations, transport customization, and manifest file. * Added `ServiceControl.Transports.IBMMQ.Tests` to verify IBM MQ transport functionality. * Updated `ServiceControl.slnx`, `nuget.config`, and `Directory.Packages.props` to include IBM MQ dependencies and projects. --- nuget.config | 4 + src/Directory.Packages.props | 1 + ...rviceControl.Transports.IBMMQ.Tests.csproj | 32 +++++ .../ServiceControlAuditEndpointTests.cs | 8 ++ .../ServiceControlMonitoringEndpointTests.cs | 8 ++ .../ServiceControlPrimaryEndpointTests.cs | 8 ++ .../TransportTestsConfiguration.cs | 31 +++++ .../DeadLetterQueueCheck.cs | 121 ++++++++++++++++++ .../IBMMQTransportCustomization.cs | 34 +++++ .../NoOpQueueLengthProvider.cs | 16 +++ .../ServiceControl.Transports.IBMMQ.csproj | 31 +++++ .../TestConnectionDetails.cs | 54 ++++++++ .../transport.manifest | 14 ++ src/ServiceControl.slnx | 3 + 14 files changed, 365 insertions(+) create mode 100644 src/ServiceControl.Transports.IBMMQ.Tests/ServiceControl.Transports.IBMMQ.Tests.csproj create mode 100644 src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlAuditEndpointTests.cs create mode 100644 src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlMonitoringEndpointTests.cs create mode 100644 src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlPrimaryEndpointTests.cs create mode 100644 src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs create mode 100644 src/ServiceControl.Transports.IBMMQ/DeadLetterQueueCheck.cs create mode 100644 src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs create mode 100644 src/ServiceControl.Transports.IBMMQ/NoOpQueueLengthProvider.cs create mode 100644 src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj create mode 100644 src/ServiceControl.Transports.IBMMQ/TestConnectionDetails.cs create mode 100644 src/ServiceControl.Transports.IBMMQ/transport.manifest diff --git a/nuget.config b/nuget.config index d72d1d3d28..b6348b5d91 100644 --- a/nuget.config +++ b/nuget.config @@ -1,10 +1,14 @@ + + + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index fb2146a71b..8210e01534 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -52,6 +52,7 @@ + diff --git a/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControl.Transports.IBMMQ.Tests.csproj b/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControl.Transports.IBMMQ.Tests.csproj new file mode 100644 index 0000000000..b8c5306c4e --- /dev/null +++ b/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControl.Transports.IBMMQ.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlAuditEndpointTests.cs b/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlAuditEndpointTests.cs new file mode 100644 index 0000000000..eea8a60cf9 --- /dev/null +++ b/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlAuditEndpointTests.cs @@ -0,0 +1,8 @@ +namespace ServiceControl.Transport.Tests; + +using System; + +partial class ServiceControlAuditEndpointTests +{ + private static partial int GetTransportDefaultConcurrency() => Math.Max(8, Environment.ProcessorCount); +} \ No newline at end of file diff --git a/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlMonitoringEndpointTests.cs b/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlMonitoringEndpointTests.cs new file mode 100644 index 0000000000..c4804c273e --- /dev/null +++ b/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlMonitoringEndpointTests.cs @@ -0,0 +1,8 @@ +namespace ServiceControl.Transport.Tests; + +using System; + +partial class ServiceControlMonitoringEndpointTests +{ + private static partial int GetTransportDefaultConcurrency() => Math.Max(8, Environment.ProcessorCount); +} \ No newline at end of file diff --git a/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlPrimaryEndpointTests.cs b/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlPrimaryEndpointTests.cs new file mode 100644 index 0000000000..37e321292d --- /dev/null +++ b/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlPrimaryEndpointTests.cs @@ -0,0 +1,8 @@ +namespace ServiceControl.Transport.Tests; + +using System; + +partial class ServiceControlPrimaryEndpointTests +{ + private static partial int GetTransportDefaultConcurrency() => Math.Max(8, Environment.ProcessorCount); +} \ No newline at end of file diff --git a/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs b/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs new file mode 100644 index 0000000000..22d65e9135 --- /dev/null +++ b/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs @@ -0,0 +1,31 @@ +namespace ServiceControl.Transport.Tests +{ + using System; + using System.Threading.Tasks; + using Transports; + using Transports.IBMMQ; + + partial class TransportTestsConfiguration + { + public string ConnectionString { get; private set; } + + public ITransportCustomization TransportCustomization { get; private set; } + + public Task Configure() + { + TransportCustomization = new IBMMQTransportCustomization(); + ConnectionString = Environment.GetEnvironmentVariable(ConnectionStringKey); + + if (string.IsNullOrEmpty(ConnectionString)) + { + throw new Exception($"Environment variable {ConnectionStringKey} is required for IBM MQ transport tests to run"); + } + + return Task.CompletedTask; + } + + public Task Cleanup() => Task.CompletedTask; + + static string ConnectionStringKey = "ServiceControl_TransportTests_IBMMQ_ConnectionString"; + } +} \ No newline at end of file diff --git a/src/ServiceControl.Transports.IBMMQ/DeadLetterQueueCheck.cs b/src/ServiceControl.Transports.IBMMQ/DeadLetterQueueCheck.cs new file mode 100644 index 0000000000..6d65a5664a --- /dev/null +++ b/src/ServiceControl.Transports.IBMMQ/DeadLetterQueueCheck.cs @@ -0,0 +1,121 @@ +// namespace ServiceControl.Transports.IBMMQ; +// +// using System; +// using System.Configuration; +// using System.Diagnostics; +// using System.Threading; +// using System.Threading.Tasks; +// using Microsoft.Extensions.Logging; +// using NServiceBus.CustomChecks; +// using Transports; +// +// public class DeadLetterQueueCheck : CustomCheck +// { +// public DeadLetterQueueCheck(TransportSettings settings, ILogger logger) : +// base("Dead Letter Queue", "Transport", TimeSpan.FromHours(1)) +// { +// runCheck = settings.RunCustomChecks; +// if (!runCheck) +// { +// return; +// } +// +// logger.LogDebug("MSMQ Dead Letter Queue custom check starting"); +// +// categoryName = Read("Msmq/PerformanceCounterCategoryName", "MSMQ Queue"); +// counterName = Read("Msmq/PerformanceCounterName", "Messages in Queue"); +// counterInstanceName = Read("Msmq/PerformanceCounterInstanceName", "Computer Queues"); +// +// try +// { +// dlqPerformanceCounter = new PerformanceCounter(categoryName, counterName, counterInstanceName, readOnly: true); +// } +// catch (InvalidOperationException ex) +// { +// logger.LogError(ex, CounterMightBeLocalized("CategoryName", "CounterName", "CounterInstanceName"), categoryName, counterName, counterInstanceName); +// } +// +// this.logger = logger; +// } +// +// public override Task PerformCheck(CancellationToken cancellationToken = default) +// { +// if (!runCheck) +// { +// return CheckResult.Pass; +// } +// +// logger.LogDebug("Checking Dead Letter Queue length"); +// float currentValue; +// try +// { +// if (dlqPerformanceCounter == null) +// { +// throw new InvalidOperationException("Unable to create performance counter instance."); +// } +// +// currentValue = dlqPerformanceCounter.NextValue(); +// } +// catch (InvalidOperationException ex) +// { +// logger.LogWarning(ex, CounterMightBeLocalized("CategoryName", "CounterName", "CounterInstanceName"), categoryName, counterName, counterInstanceName); +// return CheckResult.Failed(CounterMightBeLocalized(categoryName, counterName, counterInstanceName)); +// } +// +// if (currentValue <= 0) +// { +// logger.LogDebug("No messages in Dead Letter Queue"); +// return CheckResult.Pass; +// } +// +// logger.LogWarning("{DeadLetterMessageCount} messages in the Dead Letter Queue on {MachineName}. This could indicate a problem with ServiceControl's retries. Please submit a support ticket to Particular if you would like help from our engineers to ensure no message loss while resolving these dead letter messages", currentValue, Environment.MachineName); +// return CheckResult.Failed($"{currentValue} messages in the Dead Letter Queue on {Environment.MachineName}. This could indicate a problem with ServiceControl's retries. Please submit a support ticket to Particular if you would like help from our engineers to ensure no message loss while resolving these dead letter messages."); +// } +// +// static string CounterMightBeLocalized(string categoryName, string counterName, string counterInstanceName) +// { +// return +// $"Unable to read the Dead Letter Queue length. The performance counter with category '{categoryName}' and name '{counterName}' and instance name '{counterInstanceName}' is not available. " +// + "It is possible that the counter category, name and instance name have been localized into different languages. " +// + @"Consider overriding the counter category, name and instance name in the application configuration file by adding: +// +// +// +// +// +// "; +// } +// +// // from ConfigFileSettingsReader since we cannot reference ServiceControl +// static string Read(string name, string defaultValue = default) +// { +// return Read("ServiceControl", name, defaultValue); +// } +// +// static string Read(string root, string name, string defaultValue = default) +// { +// return TryRead(root, name, out var value) ? value : defaultValue; +// } +// +// static bool TryRead(string root, string name, out string value) +// { +// var fullKey = $"{root}/{name}"; +// +// if (ConfigurationManager.AppSettings[fullKey] != null) +// { +// value = ConfigurationManager.AppSettings[fullKey]; +// return true; +// } +// +// value = default; +// return false; +// } +// +// PerformanceCounter dlqPerformanceCounter; +// string categoryName; +// string counterName; +// string counterInstanceName; +// bool runCheck; +// +// readonly ILogger logger; +// } diff --git a/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs b/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs new file mode 100644 index 0000000000..5b20dc9159 --- /dev/null +++ b/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs @@ -0,0 +1,34 @@ +namespace ServiceControl.Transports.IBMMQ; + +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using NServiceBus; +using NServiceBus.Transport.IbmMq; + +public class IBMMQTransportCustomization : TransportCustomization +{ + protected override void CustomizeTransportForPrimaryEndpoint(EndpointConfiguration endpointConfiguration, IbmMqTransport transportDefinition, TransportSettings transportSettings) => + transportDefinition.TransportTransactionMode = TransportTransactionMode.SendsAtomicWithReceive; + + protected override void CustomizeTransportForAuditEndpoint(EndpointConfiguration endpointConfiguration, IbmMqTransport transportDefinition, TransportSettings transportSettings) => + transportDefinition.TransportTransactionMode = TransportTransactionMode.ReceiveOnly; + + protected override void CustomizeTransportForMonitoringEndpoint(EndpointConfiguration endpointConfiguration, IbmMqTransport transportDefinition, TransportSettings transportSettings) => + transportDefinition.TransportTransactionMode = TransportTransactionMode.ReceiveOnly; + + protected override void AddTransportForMonitoringCore(IServiceCollection services, TransportSettings transportSettings) + { + services.AddSingleton(); + services.AddHostedService(provider => provider.GetRequiredService()); + } + + protected override IbmMqTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) + { + var transport = new IbmMqTransport(TestConnectionDetails.Apply); + transport.TransportTransactionMode = transport.GetSupportedTransactionModes().Contains(preferredTransactionMode) + ? preferredTransactionMode + : TransportTransactionMode.ReceiveOnly; + + return transport; + } +} \ No newline at end of file diff --git a/src/ServiceControl.Transports.IBMMQ/NoOpQueueLengthProvider.cs b/src/ServiceControl.Transports.IBMMQ/NoOpQueueLengthProvider.cs new file mode 100644 index 0000000000..0b3f000a09 --- /dev/null +++ b/src/ServiceControl.Transports.IBMMQ/NoOpQueueLengthProvider.cs @@ -0,0 +1,16 @@ +namespace ServiceControl.Transports.IBMMQ; + +using System.Threading; +using System.Threading.Tasks; + +class NoOpQueueLengthProvider : IProvideQueueLength +{ + public void TrackEndpointInputQueue(EndpointToQueueMapping queueToTrack) + { + //This is a no op for MSMQ since the endpoints report their queue length to SC using custom messages + } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj b/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj new file mode 100644 index 0000000000..d81ba19d66 --- /dev/null +++ b/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + true + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + diff --git a/src/ServiceControl.Transports.IBMMQ/TestConnectionDetails.cs b/src/ServiceControl.Transports.IBMMQ/TestConnectionDetails.cs new file mode 100644 index 0000000000..8247776c9e --- /dev/null +++ b/src/ServiceControl.Transports.IBMMQ/TestConnectionDetails.cs @@ -0,0 +1,54 @@ +#nullable enable +using System; +using System.Collections.Specialized; +using System.Web; +using NServiceBus.Transport.IbmMq; + +/// +/// Copied directly from: +/// +/// https://github.com/ParticularLabs/NServiceBus.IBMMQ/blob/main/src/Testing/TestConnectionDetails.cs +/// +/// +static class TestConnectionDetails +{ + // mq://admin:passw0rd@localhost:1414/QM1?appname=&sslkeyrepo=&cipherspec=&sslpeername=&topicprefix=DEV&channel=DEV.ADMIN.SVRCONN + static readonly Uri ConnectionUri = new(Environment.GetEnvironmentVariable("IBMMQ_CONNECTIONSTRING") ?? "mq://admin:passw0rd@localhost:1414"); + static readonly NameValueCollection Query = HttpUtility.ParseQueryString(ConnectionUri.Query); + + public static string Host => ConnectionUri.Host; + public static int Port => ConnectionUri.Port > 0 ? ConnectionUri.Port : 1414; + public static string User => Uri.UnescapeDataString(ConnectionUri.UserInfo.Split(':')[0]); + public static string Password => Uri.UnescapeDataString(ConnectionUri.UserInfo.Split(':')[1]); + public static string QueueManagerName => ConnectionUri.AbsolutePath.Trim('/') is { Length: > 0 } path ? Uri.UnescapeDataString(path) : "QM1"; + public static string Channel => Query["channel"] ?? "DEV.ADMIN.SVRCONN"; + public static string TopicPrefix => Query["topicprefix"] ?? "DEV"; + + + public static void Apply(IbmMqTransportOptions options) + { + options.Host = Host; + options.Port = Port; + options.User = User; + options.Password = Password; + options.QueueManagerName = QueueManagerName; + options.Channel = Channel; + + if (Query["appname"] is { } appName) + { + options.ApplicationName = appName; + } + if (Query["sslkeyrepo"] is { } sslKeyRepo) + { + options.SslKeyRepository = sslKeyRepo; + } + if (Query["cipherspec"] is { } cipherSpec) + { + options.CipherSpec = cipherSpec; + } + if (Query["sslpeername"] is { } sslPeerName) + { + options.SslPeerName = sslPeerName; + } + } +} diff --git a/src/ServiceControl.Transports.IBMMQ/transport.manifest b/src/ServiceControl.Transports.IBMMQ/transport.manifest new file mode 100644 index 0000000000..0cb1b0f720 --- /dev/null +++ b/src/ServiceControl.Transports.IBMMQ/transport.manifest @@ -0,0 +1,14 @@ +{ + "Definitions": [ + { + "Name": "IBMMQ", + "DisplayName": "IBM MQ", + "AssemblyName": "ServiceControl.Transports.IBMMQ", + "TypeName": "ServiceControl.Transports.IBMMQ.IBMMQTransportCustomization, ServiceControl.Transports.IBMMQ", + "SampleConnectionString": "mq://admin:passw0rd@localhost:1414/QM1?topicprefix=DEV&channel=DEV.ADMIN.SVRCONN&appname={APPNAME}&sslkeyrepo={SSLKEYREPO}&cipherspec={CIPHERSPEC}&sslpeername={SSLPEERNAME}", + "AvailableInSCMU": true, + "Aliases": [ + ] + } + ] +} \ No newline at end of file diff --git a/src/ServiceControl.slnx b/src/ServiceControl.slnx index 2ff353b79a..fc2f5dd384 100644 --- a/src/ServiceControl.slnx +++ b/src/ServiceControl.slnx @@ -13,6 +13,7 @@ + @@ -78,6 +79,7 @@ + @@ -89,6 +91,7 @@ + From ad32973939ef83689bb4b78a2f9ea5925ef9e194 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 20 Feb 2026 13:43:41 +0100 Subject: [PATCH 02/19] Update IBM MQ transport tests to set static transport concurrency values --- .../ServiceControlAuditEndpointTests.cs | 2 +- .../ServiceControlMonitoringEndpointTests.cs | 2 +- .../ServiceControlPrimaryEndpointTests.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlAuditEndpointTests.cs b/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlAuditEndpointTests.cs index eea8a60cf9..f70a724658 100644 --- a/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlAuditEndpointTests.cs +++ b/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlAuditEndpointTests.cs @@ -4,5 +4,5 @@ namespace ServiceControl.Transport.Tests; partial class ServiceControlAuditEndpointTests { - private static partial int GetTransportDefaultConcurrency() => Math.Max(8, Environment.ProcessorCount); + private static partial int GetTransportDefaultConcurrency() => 32; } \ No newline at end of file diff --git a/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlMonitoringEndpointTests.cs b/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlMonitoringEndpointTests.cs index c4804c273e..0a62a8e7d4 100644 --- a/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlMonitoringEndpointTests.cs +++ b/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlMonitoringEndpointTests.cs @@ -4,5 +4,5 @@ namespace ServiceControl.Transport.Tests; partial class ServiceControlMonitoringEndpointTests { - private static partial int GetTransportDefaultConcurrency() => Math.Max(8, Environment.ProcessorCount); + private static partial int GetTransportDefaultConcurrency() => 32; } \ No newline at end of file diff --git a/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlPrimaryEndpointTests.cs b/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlPrimaryEndpointTests.cs index 37e321292d..56579e59cd 100644 --- a/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlPrimaryEndpointTests.cs +++ b/src/ServiceControl.Transports.IBMMQ.Tests/ServiceControlPrimaryEndpointTests.cs @@ -4,5 +4,5 @@ namespace ServiceControl.Transport.Tests; partial class ServiceControlPrimaryEndpointTests { - private static partial int GetTransportDefaultConcurrency() => Math.Max(8, Environment.ProcessorCount); + private static partial int GetTransportDefaultConcurrency() => 10; } \ No newline at end of file From 29b7abb1ee59b787a1efdc569521cb7780f896f3 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 20 Feb 2026 13:48:00 +0100 Subject: [PATCH 03/19] Improve IBM MQ transport customization to support sanitized resource names in tests --- .../TransportTestsConfiguration.cs | 23 +++++++++++++++++-- .../IBMMQTransportCustomization.cs | 8 ++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs b/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs index 22d65e9135..63dac04060 100644 --- a/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs +++ b/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs @@ -1,4 +1,10 @@ -namespace ServiceControl.Transport.Tests +using System; +using NServiceBus; +using NServiceBus.Transport.IbmMq; +using ServiceControl.Transports; +using ServiceControl.Transports.IBMMQ; + +namespace ServiceControl.Transport.Tests { using System; using System.Threading.Tasks; @@ -13,7 +19,7 @@ partial class TransportTestsConfiguration public Task Configure() { - TransportCustomization = new IBMMQTransportCustomization(); + TransportCustomization = new TestIBMMQTransportCustomization(); ConnectionString = Environment.GetEnvironmentVariable(ConnectionStringKey); if (string.IsNullOrEmpty(ConnectionString)) @@ -28,4 +34,17 @@ public Task Configure() static string ConnectionStringKey = "ServiceControl_TransportTests_IBMMQ_ConnectionString"; } +} + + +sealed class TestIBMMQTransportCustomization : IBMMQTransportCustomization +{ + protected override IbmMqTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) + { + transportSettings.Set>(o => o.ResourceNameSanitizer = name => name + .Replace("ServiceControlMonitoring", "SCM") // Mitigate max queue name length + .Replace("-", ".") // dash is an illegal char + ); + return base.CreateTransport(transportSettings, preferredTransactionMode); + } } \ No newline at end of file diff --git a/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs b/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs index 5b20dc9159..086d139665 100644 --- a/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs +++ b/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs @@ -1,5 +1,6 @@ namespace ServiceControl.Transports.IBMMQ; +using System; using System.Linq; using Microsoft.Extensions.DependencyInjection; using NServiceBus; @@ -24,7 +25,12 @@ protected override void AddTransportForMonitoringCore(IServiceCollection service protected override IbmMqTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) { - var transport = new IbmMqTransport(TestConnectionDetails.Apply); + var overrides = transportSettings.Get>(); + var transport = new IbmMqTransport(o => + { + overrides(o); + TestConnectionDetails.Apply(o); + }); transport.TransportTransactionMode = transport.GetSupportedTransactionModes().Contains(preferredTransactionMode) ? preferredTransactionMode : TransportTransactionMode.ReceiveOnly; From 95b2da6f22b727d612f41f409263c8c718699978 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 20 Feb 2026 13:48:59 +0000 Subject: [PATCH 04/19] =?UTF-8?q?=E2=9C=A8=20Implement=20IBM=20MQ=20queue?= =?UTF-8?q?=20length=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace NoOpQueueLengthProvider with a real implementation that connects to the queue manager and queries CurrentDepth via MQOO_INQUIRE. Connection properties are parsed from the MQ URI connection string, matching the same format used by the transport manifest sample. --- .../IBMMQTransportCustomization.cs | 2 +- .../NoOpQueueLengthProvider.cs | 16 -- .../QueueLengthProvider.cs | 159 ++++++++++++++++++ 3 files changed, 160 insertions(+), 17 deletions(-) delete mode 100644 src/ServiceControl.Transports.IBMMQ/NoOpQueueLengthProvider.cs create mode 100644 src/ServiceControl.Transports.IBMMQ/QueueLengthProvider.cs diff --git a/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs b/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs index 086d139665..b59a150a10 100644 --- a/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs +++ b/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs @@ -19,7 +19,7 @@ protected override void CustomizeTransportForMonitoringEndpoint(EndpointConfigur protected override void AddTransportForMonitoringCore(IServiceCollection services, TransportSettings transportSettings) { - services.AddSingleton(); + services.AddSingleton(); services.AddHostedService(provider => provider.GetRequiredService()); } diff --git a/src/ServiceControl.Transports.IBMMQ/NoOpQueueLengthProvider.cs b/src/ServiceControl.Transports.IBMMQ/NoOpQueueLengthProvider.cs deleted file mode 100644 index 0b3f000a09..0000000000 --- a/src/ServiceControl.Transports.IBMMQ/NoOpQueueLengthProvider.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace ServiceControl.Transports.IBMMQ; - -using System.Threading; -using System.Threading.Tasks; - -class NoOpQueueLengthProvider : IProvideQueueLength -{ - public void TrackEndpointInputQueue(EndpointToQueueMapping queueToTrack) - { - //This is a no op for MSMQ since the endpoints report their queue length to SC using custom messages - } - - public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; -} \ No newline at end of file diff --git a/src/ServiceControl.Transports.IBMMQ/QueueLengthProvider.cs b/src/ServiceControl.Transports.IBMMQ/QueueLengthProvider.cs new file mode 100644 index 0000000000..9d957d77f2 --- /dev/null +++ b/src/ServiceControl.Transports.IBMMQ/QueueLengthProvider.cs @@ -0,0 +1,159 @@ +namespace ServiceControl.Transports.IBMMQ; + +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using IBM.WMQ; +using Microsoft.Extensions.Logging; + +class QueueLengthProvider : AbstractQueueLengthProvider +{ + public QueueLengthProvider(TransportSettings settings, Action store, ILogger logger) + : base(settings, store) + { + this.logger = logger; + + var connectionUri = new Uri(ConnectionString); + var query = HttpUtility.ParseQueryString(connectionUri.Query); + + queueManagerName = connectionUri.AbsolutePath.Trim('/') is { Length: > 0 } path + ? Uri.UnescapeDataString(path) + : "QM1"; + + connectionProperties = new Hashtable + { + [MQC.TRANSPORT_PROPERTY] = MQC.TRANSPORT_MQSERIES_MANAGED, + [MQC.HOST_NAME_PROPERTY] = connectionUri.Host, + [MQC.PORT_PROPERTY] = connectionUri.Port > 0 ? connectionUri.Port : 1414, + [MQC.CHANNEL_PROPERTY] = query["channel"] ?? "DEV.ADMIN.SVRCONN" + }; + + var userInfo = connectionUri.UserInfo; + if (!string.IsNullOrEmpty(userInfo)) + { + var parts = userInfo.Split(':'); + var user = Uri.UnescapeDataString(parts[0]); + + if (!string.IsNullOrWhiteSpace(user)) + { + connectionProperties[MQC.USE_MQCSP_AUTHENTICATION_PROPERTY] = true; + connectionProperties[MQC.USER_ID_PROPERTY] = user; + } + + if (parts.Length > 1) + { + var password = Uri.UnescapeDataString(parts[1]); + if (!string.IsNullOrWhiteSpace(password)) + { + connectionProperties[MQC.PASSWORD_PROPERTY] = password; + } + } + } + + if (query["sslkeyrepo"] is { } sslKeyRepo) + { + connectionProperties[MQC.SSL_CERT_STORE_PROPERTY] = sslKeyRepo; + } + + if (query["cipherspec"] is { } cipherSpec) + { + connectionProperties[MQC.SSL_CIPHER_SPEC_PROPERTY] = cipherSpec; + } + + if (query["sslpeername"] is { } sslPeerName) + { + connectionProperties[MQC.SSL_PEER_NAME_PROPERTY] = sslPeerName; + } + } + + public override void TrackEndpointInputQueue(EndpointToQueueMapping queueToTrack) => + endpointQueues.AddOrUpdate(queueToTrack.EndpointName, _ => queueToTrack.InputQueue, (_, currentValue) => + { + if (currentValue != queueToTrack.InputQueue) + { + sizes.TryRemove(currentValue, out var _); + } + + return queueToTrack.InputQueue; + }); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + FetchQueueLengths(); + + UpdateQueueLengthStore(); + + await Task.Delay(QueryDelayInterval, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // no-op + } + catch (Exception e) + { + logger.LogError(e, "Queue length query loop failure"); + } + } + } + + void UpdateQueueLengthStore() + { + var nowTicks = DateTime.UtcNow.Ticks; + + foreach (var endpointQueuePair in endpointQueues) + { + if (sizes.TryGetValue(endpointQueuePair.Value, out var size)) + { + Store( + [ + new QueueLengthEntry + { + DateTicks = nowTicks, + Value = size + } + ], + new EndpointToQueueMapping(endpointQueuePair.Key, endpointQueuePair.Value)); + } + } + } + + void FetchQueueLengths() + { + if (endpointQueues.IsEmpty) + { + return; + } + + using var queueManager = new MQQueueManager(queueManagerName, connectionProperties); + + foreach (var endpointQueuePair in endpointQueues) + { + var queueName = endpointQueuePair.Value; + try + { + using var queue = queueManager.AccessQueue(queueName, MQC.MQOO_INQUIRE | MQC.MQOO_FAIL_IF_QUIESCING); + sizes[queueName] = queue.CurrentDepth; + } + catch (Exception e) + { + logger.LogWarning(e, "Error querying queue length for {QueueName}", queueName); + } + } + } + + static readonly TimeSpan QueryDelayInterval = TimeSpan.FromMilliseconds(200); + + readonly ConcurrentDictionary endpointQueues = new(); + readonly ConcurrentDictionary sizes = new(); + + readonly string queueManagerName; + readonly Hashtable connectionProperties; + readonly ILogger logger; +} From 48c6285327fa2d17f15c1ba3c678b24072fc95ac Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Tue, 24 Feb 2026 12:59:56 +0100 Subject: [PATCH 05/19] Make queue length test work for IBMMQ transport --- .../TransportTestsConfiguration.cs | 58 ++++++++++--------- .../TransportTestFixture.cs | 9 ++- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs b/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs index 63dac04060..7ff5be9a29 100644 --- a/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs +++ b/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs @@ -1,49 +1,51 @@ -using System; +namespace ServiceControl.Transport.Tests; + +using System; +using System.Threading.Tasks; +using Transports; +using Transports.IBMMQ; using NServiceBus; using NServiceBus.Transport.IbmMq; -using ServiceControl.Transports; -using ServiceControl.Transports.IBMMQ; +using NUnit.Framework; -namespace ServiceControl.Transport.Tests +[SetUpFixture] +public class BootstrapFixture { - using System; - using System.Threading.Tasks; - using Transports; - using Transports.IBMMQ; - - partial class TransportTestsConfiguration - { - public string ConnectionString { get; private set; } + [OneTimeSetUp] + public void RunBeforeAnyTests() => TransportTestFixture.QueueNameSeparator = '.'; +} - public ITransportCustomization TransportCustomization { get; private set; } +class TransportTestsConfiguration +{ + public string ConnectionString { get; private set; } - public Task Configure() - { - TransportCustomization = new TestIBMMQTransportCustomization(); - ConnectionString = Environment.GetEnvironmentVariable(ConnectionStringKey); + public ITransportCustomization TransportCustomization { get; private set; } - if (string.IsNullOrEmpty(ConnectionString)) - { - throw new Exception($"Environment variable {ConnectionStringKey} is required for IBM MQ transport tests to run"); - } + public Task Configure() + { + TransportCustomization = new TestIBMMQTransportCustomization(); + ConnectionString = Environment.GetEnvironmentVariable(ConnectionStringKey); - return Task.CompletedTask; + if (string.IsNullOrEmpty(ConnectionString)) + { + throw new Exception($"Environment variable {ConnectionStringKey} is required for IBM MQ transport tests to run"); } - public Task Cleanup() => Task.CompletedTask; - - static string ConnectionStringKey = "ServiceControl_TransportTests_IBMMQ_ConnectionString"; + return Task.CompletedTask; } -} + public Task Cleanup() => Task.CompletedTask; + + static string ConnectionStringKey = "ServiceControl_TransportTests_IBMMQ_ConnectionString"; +} sealed class TestIBMMQTransportCustomization : IBMMQTransportCustomization { protected override IbmMqTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) { transportSettings.Set>(o => o.ResourceNameSanitizer = name => name - .Replace("ServiceControlMonitoring", "SCM") // Mitigate max queue name length - .Replace("-", ".") // dash is an illegal char + .Replace("ServiceControlMonitoring", "SCM") // Mitigate max queue name length + .Replace("-", ".") // dash is an illegal char ); return base.CreateTransport(transportSettings, preferredTransactionMode); } diff --git a/src/ServiceControl.Transports.Tests/TransportTestFixture.cs b/src/ServiceControl.Transports.Tests/TransportTestFixture.cs index de88f52cac..7c36817a98 100644 --- a/src/ServiceControl.Transports.Tests/TransportTestFixture.cs +++ b/src/ServiceControl.Transports.Tests/TransportTestFixture.cs @@ -21,6 +21,11 @@ abstract class TransportTestFixture { + /// + /// Not all transports support - as a separator, as this is not a source package this can be overridden. + /// + public static char QueueNameSeparator = '-'; + [SetUp] public virtual async Task Setup() { @@ -30,7 +35,7 @@ public virtual async Task Setup() configuration = new TransportTestsConfiguration(); testCancellationTokenSource = Debugger.IsAttached ? new CancellationTokenSource() : new CancellationTokenSource(TestTimeout); registrations = []; - queueSuffix = $"-{Path.GetRandomFileName().Replace(".", string.Empty)}"; + queueSuffix = $"{QueueNameSeparator}{Path.GetRandomFileName().Replace(".", string.Empty)}"; await configuration.Configure(); @@ -70,7 +75,7 @@ public virtual async Task Cleanup() protected IMessageDispatcher Dispatcher => dispatcherTransportInfrastructure.Dispatcher; - protected string GetTestQueueName(string name) => $"{name}-{queueSuffix}"; + protected string GetTestQueueName(string name) => $"{name}{queueSuffix}"; protected TaskCompletionSource CreateTaskCompletionSource() { From a979c5462dd7574491df9b436eb8838f3ac84692 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 9 Mar 2026 09:33:55 +0100 Subject: [PATCH 06/19] =?UTF-8?q?=F0=9F=93=A6=20Update=20NServiceBus.Trans?= =?UTF-8?q?port.IbmMq=20to=201.0.0-alpha.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve breaking API renames: namespace IbmMq → IBMMQ, types IbmMqTransport → IBMMQTransport, IbmMqTransportOptions → IBMMQTransportOptions. --- src/Directory.Packages.props | 2 +- .../TransportTestsConfiguration.cs | 6 +++--- .../IBMMQTransportCustomization.cs | 16 ++++++++-------- .../TestConnectionDetails.cs | 4 ++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 8210e01534..ad34f4bc3b 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -52,7 +52,7 @@ - + diff --git a/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs b/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs index 7ff5be9a29..2e293f7aa3 100644 --- a/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs +++ b/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs @@ -5,7 +5,7 @@ using Transports; using Transports.IBMMQ; using NServiceBus; -using NServiceBus.Transport.IbmMq; +using NServiceBus.Transport.IBMMQ; using NUnit.Framework; [SetUpFixture] @@ -41,9 +41,9 @@ public Task Configure() sealed class TestIBMMQTransportCustomization : IBMMQTransportCustomization { - protected override IbmMqTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) + protected override IBMMQTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) { - transportSettings.Set>(o => o.ResourceNameSanitizer = name => name + transportSettings.Set>(o => o.ResourceNameSanitizer = name => name .Replace("ServiceControlMonitoring", "SCM") // Mitigate max queue name length .Replace("-", ".") // dash is an illegal char ); diff --git a/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs b/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs index b59a150a10..2596b3ffd6 100644 --- a/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs +++ b/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs @@ -4,17 +4,17 @@ using System.Linq; using Microsoft.Extensions.DependencyInjection; using NServiceBus; -using NServiceBus.Transport.IbmMq; +using NServiceBus.Transport.IBMMQ; -public class IBMMQTransportCustomization : TransportCustomization +public class IBMMQTransportCustomization : TransportCustomization { - protected override void CustomizeTransportForPrimaryEndpoint(EndpointConfiguration endpointConfiguration, IbmMqTransport transportDefinition, TransportSettings transportSettings) => + protected override void CustomizeTransportForPrimaryEndpoint(EndpointConfiguration endpointConfiguration, IBMMQTransport transportDefinition, TransportSettings transportSettings) => transportDefinition.TransportTransactionMode = TransportTransactionMode.SendsAtomicWithReceive; - protected override void CustomizeTransportForAuditEndpoint(EndpointConfiguration endpointConfiguration, IbmMqTransport transportDefinition, TransportSettings transportSettings) => + protected override void CustomizeTransportForAuditEndpoint(EndpointConfiguration endpointConfiguration, IBMMQTransport transportDefinition, TransportSettings transportSettings) => transportDefinition.TransportTransactionMode = TransportTransactionMode.ReceiveOnly; - protected override void CustomizeTransportForMonitoringEndpoint(EndpointConfiguration endpointConfiguration, IbmMqTransport transportDefinition, TransportSettings transportSettings) => + protected override void CustomizeTransportForMonitoringEndpoint(EndpointConfiguration endpointConfiguration, IBMMQTransport transportDefinition, TransportSettings transportSettings) => transportDefinition.TransportTransactionMode = TransportTransactionMode.ReceiveOnly; protected override void AddTransportForMonitoringCore(IServiceCollection services, TransportSettings transportSettings) @@ -23,10 +23,10 @@ protected override void AddTransportForMonitoringCore(IServiceCollection service services.AddHostedService(provider => provider.GetRequiredService()); } - protected override IbmMqTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) + protected override IBMMQTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) { - var overrides = transportSettings.Get>(); - var transport = new IbmMqTransport(o => + var overrides = transportSettings.Get>(); + var transport = new IBMMQTransport(o => { overrides(o); TestConnectionDetails.Apply(o); diff --git a/src/ServiceControl.Transports.IBMMQ/TestConnectionDetails.cs b/src/ServiceControl.Transports.IBMMQ/TestConnectionDetails.cs index 8247776c9e..1602cfd01f 100644 --- a/src/ServiceControl.Transports.IBMMQ/TestConnectionDetails.cs +++ b/src/ServiceControl.Transports.IBMMQ/TestConnectionDetails.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Specialized; using System.Web; -using NServiceBus.Transport.IbmMq; +using NServiceBus.Transport.IBMMQ; /// /// Copied directly from: @@ -25,7 +25,7 @@ static class TestConnectionDetails public static string TopicPrefix => Query["topicprefix"] ?? "DEV"; - public static void Apply(IbmMqTransportOptions options) + public static void Apply(IBMMQTransportOptions options) { options.Host = Host; options.Port = Port; From f0684abb2ac7954e774696850b559c83bf6a1f89 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 9 Mar 2026 09:34:58 +0100 Subject: [PATCH 07/19] =?UTF-8?q?=E2=9A=9C=EF=B8=8F=20Update=20NuGet=20pac?= =?UTF-8?q?kage=20ID=20casing=20to=20NServiceBus.Transport.IBMMQ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Directory.Packages.props | 2 +- .../ServiceControl.Transports.IBMMQ.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index ad34f4bc3b..1b0dc915da 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -52,7 +52,7 @@ - + diff --git a/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj b/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj index d81ba19d66..93bb097b76 100644 --- a/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj +++ b/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj @@ -12,7 +12,7 @@ - + From 048a7cefb781793f49d6256935478ca32a991c92 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 9 Mar 2026 09:37:53 +0100 Subject: [PATCH 08/19] Updated nuget.config, contained local folder used during dev testing --- nuget.config | 4 ---- 1 file changed, 4 deletions(-) diff --git a/nuget.config b/nuget.config index b6348b5d91..d72d1d3d28 100644 --- a/nuget.config +++ b/nuget.config @@ -1,14 +1,10 @@ - - - - From 7ca78ffb982b3ddc79611a29c67236f6e5c7c1a9 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 9 Mar 2026 10:18:15 +0100 Subject: [PATCH 09/19] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20Add=20IBMMQ=20test?= =?UTF-8?q?=20category=20to=20CI=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add IBMMQ to test matrix (Linux only) - Add IncludeInIBMMQTestsAttribute and TestsFilter for test filtering - Pass connection string via env block on run-tests step - Unify TestConnectionDetails to read ServiceControl_TransportTests_IBMMQ_ConnectionString --- .github/workflows/ci.yml | 5 ++++- src/ServiceControl.Transports.IBMMQ.Tests/TestsFilter.cs | 1 + .../TransportTestsConfiguration.cs | 3 ++- .../TestConnectionDetails.cs | 7 ++++++- src/TestHelper/IncludeInIBMMQTestsAttribute.cs | 4 ++++ 5 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 src/ServiceControl.Transports.IBMMQ.Tests/TestsFilter.cs create mode 100644 src/TestHelper/IncludeInIBMMQTestsAttribute.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a8b91de03..562765acf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: os: [windows-latest, ubuntu-latest] - test-category: [ Default, SqlServer, AzureServiceBus, RabbitMQ, AzureStorageQueues, MSMQ, SQS, PrimaryRavenAcceptance, PrimaryRavenPersistence, PostgreSQL ] + test-category: [ Default, SqlServer, AzureServiceBus, RabbitMQ, AzureStorageQueues, MSMQ, SQS, PrimaryRavenAcceptance, PrimaryRavenPersistence, PostgreSQL, IBMMQ ] include: - os: windows-latest os-name: Windows @@ -27,6 +27,8 @@ jobs: exclude: - os: ubuntu-latest test-category: MSMQ + - os: windows-latest + test-category: IBMMQ fail-fast: false steps: - name: Check for secrets @@ -117,6 +119,7 @@ jobs: uses: Particular/run-tests-action@v1.7.0 env: ServiceControl_TESTS_FILTER: ${{ matrix.test-category }} + ServiceControl_TransportTests_IBMMQ_ConnectionString: ${{ secrets.SERVICECONTROL_TRANSPORTTESTS_IBMMQ_CONNECTIONSTRING }} PARTICULARSOFTWARE_LICENSE: ${{ secrets.LICENSETEXT }} AZURE_ACI_CREDENTIALS: ${{ secrets.AZURE_ACI_CREDENTIALS }} diff --git a/src/ServiceControl.Transports.IBMMQ.Tests/TestsFilter.cs b/src/ServiceControl.Transports.IBMMQ.Tests/TestsFilter.cs new file mode 100644 index 0000000000..76ebf15442 --- /dev/null +++ b/src/ServiceControl.Transports.IBMMQ.Tests/TestsFilter.cs @@ -0,0 +1 @@ +[assembly: IncludeInIBMMQTests()] \ No newline at end of file diff --git a/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs b/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs index 2e293f7aa3..6d52918c91 100644 --- a/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs +++ b/src/ServiceControl.Transports.IBMMQ.Tests/TransportTestsConfiguration.cs @@ -24,7 +24,8 @@ class TransportTestsConfiguration public Task Configure() { TransportCustomization = new TestIBMMQTransportCustomization(); - ConnectionString = Environment.GetEnvironmentVariable(ConnectionStringKey); + ConnectionString = Environment.GetEnvironmentVariable(ConnectionStringKey) + ?? Environment.GetEnvironmentVariable(ConnectionStringKey.ToUpperInvariant()); // Env keys are case sensitive, POSIX is all uppercase if (string.IsNullOrEmpty(ConnectionString)) { diff --git a/src/ServiceControl.Transports.IBMMQ/TestConnectionDetails.cs b/src/ServiceControl.Transports.IBMMQ/TestConnectionDetails.cs index 1602cfd01f..573ac0e28d 100644 --- a/src/ServiceControl.Transports.IBMMQ/TestConnectionDetails.cs +++ b/src/ServiceControl.Transports.IBMMQ/TestConnectionDetails.cs @@ -13,7 +13,12 @@ static class TestConnectionDetails { // mq://admin:passw0rd@localhost:1414/QM1?appname=&sslkeyrepo=&cipherspec=&sslpeername=&topicprefix=DEV&channel=DEV.ADMIN.SVRCONN - static readonly Uri ConnectionUri = new(Environment.GetEnvironmentVariable("IBMMQ_CONNECTIONSTRING") ?? "mq://admin:passw0rd@localhost:1414"); + static readonly Uri ConnectionUri = new( + Environment.GetEnvironmentVariable("ServiceControl_TransportTests_IBMMQ_ConnectionString") + ?? Environment.GetEnvironmentVariable("SERVICECONTROL_TRANSPORTTESTS_IBMMQ_CONNECTIONSTRING") + ?? "mq://admin:passw0rd@localhost:1414" + ); + static readonly NameValueCollection Query = HttpUtility.ParseQueryString(ConnectionUri.Query); public static string Host => ConnectionUri.Host; diff --git a/src/TestHelper/IncludeInIBMMQTestsAttribute.cs b/src/TestHelper/IncludeInIBMMQTestsAttribute.cs new file mode 100644 index 0000000000..2bb18aeb93 --- /dev/null +++ b/src/TestHelper/IncludeInIBMMQTestsAttribute.cs @@ -0,0 +1,4 @@ +public class IncludeInIBMMQTestsAttribute : IncludeInTestsAttribute +{ + protected override string Filter => "IBMMQ"; +} From 019005db1d8b636ebfcf4e129c8b3eae33629850 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 9 Mar 2026 10:38:49 +0100 Subject: [PATCH 10/19] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20Use=20IBM=20MQ=20con?= =?UTF-8?q?tainer=20with=20health=20check=20instead=20of=20secrets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 562765acf6..f230b50754 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,18 @@ jobs: connection-string-name: ServiceControl_TransportTests_ASQ_ConnectionString azure-credentials: ${{ secrets.AZURE_ACI_CREDENTIALS }} tag: ServiceControl + - name: Setup IBM MQ + if: matrix.test-category == 'IBMMQ' + run: | + docker run --name ibmmq -d -p 1414:1414 -p 9443:9443 ` + --health-cmd "dspmq" --health-interval 10s --health-timeout 5s --health-retries 10 --health-start-period 30s ` + -e LICENSE=accept -e MQ_QMGR_NAME=QM1 ` + icr.io/ibm-messaging/mq:latest + # Wait for container health check to pass + while ((docker inspect --format '{{.State.Health.Status}}' ibmmq) -ne 'healthy') { + Start-Sleep -Seconds 2 + } + echo "ServiceControl_TransportTests_IBMMQ_ConnectionString=mq://admin:passw0rd@localhost:1414/QM1?channel=DEV.ADMIN.SVRCONN&topicprefix=DEV" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf-8 -Append - name: Setup SQS environment variables if: matrix.test-category == 'SQS' run: | @@ -119,7 +131,6 @@ jobs: uses: Particular/run-tests-action@v1.7.0 env: ServiceControl_TESTS_FILTER: ${{ matrix.test-category }} - ServiceControl_TransportTests_IBMMQ_ConnectionString: ${{ secrets.SERVICECONTROL_TRANSPORTTESTS_IBMMQ_CONNECTIONSTRING }} PARTICULARSOFTWARE_LICENSE: ${{ secrets.LICENSETEXT }} AZURE_ACI_CREDENTIALS: ${{ secrets.AZURE_ACI_CREDENTIALS }} From c6373f2c1117e7315780544c21c754d8d8badf08 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 9 Mar 2026 10:51:52 +0100 Subject: [PATCH 11/19] =?UTF-8?q?=F0=9F=90=9B=20Set=20MQ=5FADMIN=5FPASSWOR?= =?UTF-8?q?D=20for=20IBM=20MQ=20CI=20container?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f230b50754..57ceb9f956 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,7 +110,7 @@ jobs: run: | docker run --name ibmmq -d -p 1414:1414 -p 9443:9443 ` --health-cmd "dspmq" --health-interval 10s --health-timeout 5s --health-retries 10 --health-start-period 30s ` - -e LICENSE=accept -e MQ_QMGR_NAME=QM1 ` + -e LICENSE=accept -e MQ_QMGR_NAME=QM1 -e MQ_ADMIN_PASSWORD=passw0rd ` icr.io/ibm-messaging/mq:latest # Wait for container health check to pass while ((docker inspect --format '{{.State.Health.Status}}' ibmmq) -ne 'healthy') { From 33ff6be9cfa7b008df81dc3201bab499e126aff6 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 9 Mar 2026 11:11:45 +0100 Subject: [PATCH 12/19] =?UTF-8?q?=F0=9F=90=9B=20Fix=20IBMMQ=20transport=20?= =?UTF-8?q?deploy=20folder=20and=20update=20approval=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix csproj artifact destination from Transports\MSMQ to Transports\IBMMQ - Remove Windows-only condition (was copy-pasted from MSMQ) - Add IBMMQ to TransportNames and packaging approval files --- .../ServiceControl.Transports.IBMMQ.csproj | 4 ++-- ...n_manifest_files_exist_in_specified_assembly.approved.txt | 1 + .../ApprovalFiles/APIApprovals.TransportNames.approved.txt | 5 +++++ .../DeploymentPackageTests.cs | 3 ++- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj b/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj index 93bb097b76..1483e059b1 100644 --- a/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj +++ b/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj @@ -24,8 +24,8 @@ - - + + diff --git a/src/ServiceControl.Transports.Tests/ApprovalFiles/TransportManifestLibraryTests.All_types_defined_in_manifest_files_exist_in_specified_assembly.approved.txt b/src/ServiceControl.Transports.Tests/ApprovalFiles/TransportManifestLibraryTests.All_types_defined_in_manifest_files_exist_in_specified_assembly.approved.txt index 6d201c38cc..397809505d 100644 --- a/src/ServiceControl.Transports.Tests/ApprovalFiles/TransportManifestLibraryTests.All_types_defined_in_manifest_files_exist_in_specified_assembly.approved.txt +++ b/src/ServiceControl.Transports.Tests/ApprovalFiles/TransportManifestLibraryTests.All_types_defined_in_manifest_files_exist_in_specified_assembly.approved.txt @@ -1,6 +1,7 @@ [ "AmazonSQS", "AzureStorageQueue", + "IBMMQ", "LearningTransport", "LearningTransport", "MSMQ", diff --git a/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/APIApprovals.TransportNames.approved.txt b/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/APIApprovals.TransportNames.approved.txt index bb2e392a4e..58499293d7 100644 --- a/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/APIApprovals.TransportNames.approved.txt +++ b/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/APIApprovals.TransportNames.approved.txt @@ -35,6 +35,11 @@ "ServiceControl.Transports.AzureStorageQueues.ServiceControlAzureStorageQueueTransport, ServiceControl.Transports.AzureStorageQueues" ] }, + { + "Name": "IBMMQ", + "DisplayName": "IBM MQ", + "Aliases": [] + }, { "Name": "LearningTransport", "DisplayName": "Learning Transport (Non-Production)", diff --git a/src/ServiceControlInstaller.Packaging.UnitTests/DeploymentPackageTests.cs b/src/ServiceControlInstaller.Packaging.UnitTests/DeploymentPackageTests.cs index 2a9390c7a3..b4fb53fd8e 100644 --- a/src/ServiceControlInstaller.Packaging.UnitTests/DeploymentPackageTests.cs +++ b/src/ServiceControlInstaller.Packaging.UnitTests/DeploymentPackageTests.cs @@ -88,7 +88,8 @@ public void Should_package_all_transports() "MSMQ", "AmazonSQS", "LearningTransport", - "PostgreSql"}; + "PostgreSql", + "IBMMQ"}; var bundledTransports = deploymentPackage.DeploymentUnits .Where(u => u.Category == "Transports") From 40c89e68b295016b2160a3642e057b7c570d17ca Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 9 Mar 2026 11:29:13 +0100 Subject: [PATCH 13/19] =?UTF-8?q?=F0=9F=90=9B=20Add=20IBMMQ=20to=20Develop?= =?UTF-8?q?mentTransportLocations=20for=20manifest=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ServiceControl.Transports/DevelopmentTransportLocations.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ServiceControl.Transports/DevelopmentTransportLocations.cs b/src/ServiceControl.Transports/DevelopmentTransportLocations.cs index b2b81e72b6..495b660609 100644 --- a/src/ServiceControl.Transports/DevelopmentTransportLocations.cs +++ b/src/ServiceControl.Transports/DevelopmentTransportLocations.cs @@ -25,6 +25,7 @@ static DevelopmentTransportLocations() ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Transports.SqlServer")); ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Transports.SQS")); ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Transports.PostgreSql")); + ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Transports.IBMMQ")); } } From 99ba2435126239c9bda52ab38030506b579457b0 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 9 Mar 2026 11:34:44 +0100 Subject: [PATCH 14/19] Sort transport manifest folder names --- src/ServiceControl.Transports/DevelopmentTransportLocations.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ServiceControl.Transports/DevelopmentTransportLocations.cs b/src/ServiceControl.Transports/DevelopmentTransportLocations.cs index 495b660609..95e7e8c021 100644 --- a/src/ServiceControl.Transports/DevelopmentTransportLocations.cs +++ b/src/ServiceControl.Transports/DevelopmentTransportLocations.cs @@ -19,13 +19,13 @@ static DevelopmentTransportLocations() { ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Transports.ASBS")); ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Transports.ASQ")); + ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Transports.IBMMQ")); ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Transports.Learning")); ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Transports.Msmq", "net10.0-windows")); ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Transports.RabbitMQ")); ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Transports.SqlServer")); ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Transports.SQS")); ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Transports.PostgreSql")); - ManifestFiles.Add(BuildManifestPath(srcFolder, "ServiceControl.Transports.IBMMQ")); } } From 1be5f5e7e16b6550854aaa6cfdb1fbed48cc921a Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 11 Mar 2026 09:21:38 +0100 Subject: [PATCH 15/19] Use TryGet instead of Get... could be that no override is registered --- .../IBMMQTransportCustomization.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs b/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs index 2596b3ffd6..a818034d14 100644 --- a/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs +++ b/src/ServiceControl.Transports.IBMMQ/IBMMQTransportCustomization.cs @@ -25,10 +25,13 @@ protected override void AddTransportForMonitoringCore(IServiceCollection service protected override IBMMQTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) { - var overrides = transportSettings.Get>(); var transport = new IBMMQTransport(o => { - overrides(o); + if (transportSettings.TryGet>(out var overrides)) + { + overrides(o); + } + TestConnectionDetails.Apply(o); }); transport.TransportTransactionMode = transport.GetSupportedTransactionModes().Contains(preferredTransactionMode) From 7b6e86a259b519d97dd489b7feb4aded960d9659 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 11 Mar 2026 09:30:34 +0100 Subject: [PATCH 16/19] =?UTF-8?q?=E2=9C=A8=20Reuse=20MQQueueManager=20conn?= =?UTF-8?q?ection=20in=20QueueLengthProvider=20with=20broker=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../QueueLengthProvider.cs | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/ServiceControl.Transports.IBMMQ/QueueLengthProvider.cs b/src/ServiceControl.Transports.IBMMQ/QueueLengthProvider.cs index 9d957d77f2..353e832ba5 100644 --- a/src/ServiceControl.Transports.IBMMQ/QueueLengthProvider.cs +++ b/src/ServiceControl.Transports.IBMMQ/QueueLengthProvider.cs @@ -1,3 +1,4 @@ +#nullable enable namespace ServiceControl.Transports.IBMMQ; using System; @@ -99,8 +100,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) catch (Exception e) { logger.LogError(e, "Queue length query loop failure"); + CloseConnection(); + await Task.Delay(ReconnectDelayInterval, stoppingToken).ConfigureAwait(false); } } + + CloseConnection(); } void UpdateQueueLengthStore() @@ -131,16 +136,22 @@ void FetchQueueLengths() return; } - using var queueManager = new MQQueueManager(queueManagerName, connectionProperties); + var manager = EnsureConnected(); foreach (var endpointQueuePair in endpointQueues) { var queueName = endpointQueuePair.Value; try { - using var queue = queueManager.AccessQueue(queueName, MQC.MQOO_INQUIRE | MQC.MQOO_FAIL_IF_QUIESCING); + using var queue = manager.AccessQueue(queueName, MQC.MQOO_INQUIRE | MQC.MQOO_FAIL_IF_QUIESCING); sizes[queueName] = queue.CurrentDepth; } + catch (MQException e) when (IsConnectionError(e)) + { + logger.LogWarning(e, "Lost connection to queue manager while querying {QueueName}", queueName); + CloseConnection(); + throw; + } catch (Exception e) { logger.LogWarning(e, "Error querying queue length for {QueueName}", queueName); @@ -148,7 +159,50 @@ void FetchQueueLengths() } } + MQQueueManager EnsureConnected() + { + if (queueManager is not null) + { + return queueManager; + } + + queueManager = new MQQueueManager(queueManagerName, connectionProperties); + logger.LogInformation("Connected to queue manager '{QueueManagerName}'", queueManagerName); + return queueManager; + } + + void CloseConnection() + { + if (queueManager is null) + { + return; + } + + try + { + queueManager.Disconnect(); + } + catch (Exception e) + { + logger.LogDebug(e, "Error disconnecting from queue manager"); + } + + queueManager = null; + } + + static bool IsConnectionError(MQException e) => e.ReasonCode + is MQC.MQRC_CONNECTION_BROKEN + or MQC.MQRC_CONNECTION_ERROR + or MQC.MQRC_CONNECTION_STOPPED + or MQC.MQRC_CONNECTION_QUIESCING + or MQC.MQRC_CONNECTION_NOT_AVAILABLE + or MQC.MQRC_Q_MGR_NOT_AVAILABLE + or MQC.MQRC_Q_MGR_NOT_ACTIVE; + static readonly TimeSpan QueryDelayInterval = TimeSpan.FromMilliseconds(200); + static readonly TimeSpan ReconnectDelayInterval = TimeSpan.FromSeconds(10); + + MQQueueManager? queueManager; readonly ConcurrentDictionary endpointQueues = new(); readonly ConcurrentDictionary sizes = new(); From 72348d8f062866357a044f498d2545e5eefb1d36 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 11 Mar 2026 09:33:17 +0100 Subject: [PATCH 17/19] =?UTF-8?q?=E2=9A=9C=EF=B8=8F=20Remove=20duplicate?= =?UTF-8?q?=20CopyToOutputDirectory=20for=20transport.manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ServiceControl.Transports.IBMMQ.csproj | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj b/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj index 1483e059b1..94ceac0087 100644 --- a/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj +++ b/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj @@ -19,9 +19,6 @@ - - PreserveNewest - From a4e9d48ce1f34b406d3ba4e7da854161ce66f798 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 11 Mar 2026 10:12:19 +0100 Subject: [PATCH 18/19] =?UTF-8?q?=E2=9C=A8=20Add=20IBM=20MQ=20Dead=20Lette?= =?UTF-8?q?r=20Queue=20custom=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract shared connection URI parsing into ConnectionProperties helper and implement DLQ depth check using MQQueueManager.DeadLetterQueueName. Also remove unused MSMQ-era package references. --- .../ConnectionProperties.cs | 66 +++++++ .../DeadLetterQueueCheck.cs | 187 +++++++----------- .../QueueLengthProvider.cs | 54 +---- .../ServiceControl.Transports.IBMMQ.csproj | 2 - 4 files changed, 133 insertions(+), 176 deletions(-) create mode 100644 src/ServiceControl.Transports.IBMMQ/ConnectionProperties.cs diff --git a/src/ServiceControl.Transports.IBMMQ/ConnectionProperties.cs b/src/ServiceControl.Transports.IBMMQ/ConnectionProperties.cs new file mode 100644 index 0000000000..4c3606b5e6 --- /dev/null +++ b/src/ServiceControl.Transports.IBMMQ/ConnectionProperties.cs @@ -0,0 +1,66 @@ +namespace ServiceControl.Transports.IBMMQ; + +using System; +using System.Collections; +using System.Web; +using IBM.WMQ; + +static class ConnectionProperties +{ + public static (string queueManagerName, Hashtable properties) Parse(string connectionString) + { + var connectionUri = new Uri(connectionString); + var query = HttpUtility.ParseQueryString(connectionUri.Query); + + var queueManagerName = connectionUri.AbsolutePath.Trim('/') is { Length: > 0 } path + ? Uri.UnescapeDataString(path) + : "QM1"; + + var properties = new Hashtable + { + [MQC.TRANSPORT_PROPERTY] = MQC.TRANSPORT_MQSERIES_MANAGED, + [MQC.HOST_NAME_PROPERTY] = connectionUri.Host, + [MQC.PORT_PROPERTY] = connectionUri.Port > 0 ? connectionUri.Port : 1414, + [MQC.CHANNEL_PROPERTY] = query["channel"] ?? "DEV.ADMIN.SVRCONN" + }; + + var userInfo = connectionUri.UserInfo; + if (!string.IsNullOrEmpty(userInfo)) + { + var parts = userInfo.Split(':'); + var user = Uri.UnescapeDataString(parts[0]); + + if (!string.IsNullOrWhiteSpace(user)) + { + properties[MQC.USE_MQCSP_AUTHENTICATION_PROPERTY] = true; + properties[MQC.USER_ID_PROPERTY] = user; + } + + if (parts.Length > 1) + { + var password = Uri.UnescapeDataString(parts[1]); + if (!string.IsNullOrWhiteSpace(password)) + { + properties[MQC.PASSWORD_PROPERTY] = password; + } + } + } + + if (query["sslkeyrepo"] is { } sslKeyRepo) + { + properties[MQC.SSL_CERT_STORE_PROPERTY] = sslKeyRepo; + } + + if (query["cipherspec"] is { } cipherSpec) + { + properties[MQC.SSL_CIPHER_SPEC_PROPERTY] = cipherSpec; + } + + if (query["sslpeername"] is { } sslPeerName) + { + properties[MQC.SSL_PEER_NAME_PROPERTY] = sslPeerName; + } + + return (queueManagerName, properties); + } +} diff --git a/src/ServiceControl.Transports.IBMMQ/DeadLetterQueueCheck.cs b/src/ServiceControl.Transports.IBMMQ/DeadLetterQueueCheck.cs index 6d65a5664a..5bc5041dd9 100644 --- a/src/ServiceControl.Transports.IBMMQ/DeadLetterQueueCheck.cs +++ b/src/ServiceControl.Transports.IBMMQ/DeadLetterQueueCheck.cs @@ -1,121 +1,66 @@ -// namespace ServiceControl.Transports.IBMMQ; -// -// using System; -// using System.Configuration; -// using System.Diagnostics; -// using System.Threading; -// using System.Threading.Tasks; -// using Microsoft.Extensions.Logging; -// using NServiceBus.CustomChecks; -// using Transports; -// -// public class DeadLetterQueueCheck : CustomCheck -// { -// public DeadLetterQueueCheck(TransportSettings settings, ILogger logger) : -// base("Dead Letter Queue", "Transport", TimeSpan.FromHours(1)) -// { -// runCheck = settings.RunCustomChecks; -// if (!runCheck) -// { -// return; -// } -// -// logger.LogDebug("MSMQ Dead Letter Queue custom check starting"); -// -// categoryName = Read("Msmq/PerformanceCounterCategoryName", "MSMQ Queue"); -// counterName = Read("Msmq/PerformanceCounterName", "Messages in Queue"); -// counterInstanceName = Read("Msmq/PerformanceCounterInstanceName", "Computer Queues"); -// -// try -// { -// dlqPerformanceCounter = new PerformanceCounter(categoryName, counterName, counterInstanceName, readOnly: true); -// } -// catch (InvalidOperationException ex) -// { -// logger.LogError(ex, CounterMightBeLocalized("CategoryName", "CounterName", "CounterInstanceName"), categoryName, counterName, counterInstanceName); -// } -// -// this.logger = logger; -// } -// -// public override Task PerformCheck(CancellationToken cancellationToken = default) -// { -// if (!runCheck) -// { -// return CheckResult.Pass; -// } -// -// logger.LogDebug("Checking Dead Letter Queue length"); -// float currentValue; -// try -// { -// if (dlqPerformanceCounter == null) -// { -// throw new InvalidOperationException("Unable to create performance counter instance."); -// } -// -// currentValue = dlqPerformanceCounter.NextValue(); -// } -// catch (InvalidOperationException ex) -// { -// logger.LogWarning(ex, CounterMightBeLocalized("CategoryName", "CounterName", "CounterInstanceName"), categoryName, counterName, counterInstanceName); -// return CheckResult.Failed(CounterMightBeLocalized(categoryName, counterName, counterInstanceName)); -// } -// -// if (currentValue <= 0) -// { -// logger.LogDebug("No messages in Dead Letter Queue"); -// return CheckResult.Pass; -// } -// -// logger.LogWarning("{DeadLetterMessageCount} messages in the Dead Letter Queue on {MachineName}. This could indicate a problem with ServiceControl's retries. Please submit a support ticket to Particular if you would like help from our engineers to ensure no message loss while resolving these dead letter messages", currentValue, Environment.MachineName); -// return CheckResult.Failed($"{currentValue} messages in the Dead Letter Queue on {Environment.MachineName}. This could indicate a problem with ServiceControl's retries. Please submit a support ticket to Particular if you would like help from our engineers to ensure no message loss while resolving these dead letter messages."); -// } -// -// static string CounterMightBeLocalized(string categoryName, string counterName, string counterInstanceName) -// { -// return -// $"Unable to read the Dead Letter Queue length. The performance counter with category '{categoryName}' and name '{counterName}' and instance name '{counterInstanceName}' is not available. " -// + "It is possible that the counter category, name and instance name have been localized into different languages. " -// + @"Consider overriding the counter category, name and instance name in the application configuration file by adding: -// -// -// -// -// -// "; -// } -// -// // from ConfigFileSettingsReader since we cannot reference ServiceControl -// static string Read(string name, string defaultValue = default) -// { -// return Read("ServiceControl", name, defaultValue); -// } -// -// static string Read(string root, string name, string defaultValue = default) -// { -// return TryRead(root, name, out var value) ? value : defaultValue; -// } -// -// static bool TryRead(string root, string name, out string value) -// { -// var fullKey = $"{root}/{name}"; -// -// if (ConfigurationManager.AppSettings[fullKey] != null) -// { -// value = ConfigurationManager.AppSettings[fullKey]; -// return true; -// } -// -// value = default; -// return false; -// } -// -// PerformanceCounter dlqPerformanceCounter; -// string categoryName; -// string counterName; -// string counterInstanceName; -// bool runCheck; -// -// readonly ILogger logger; -// } +namespace ServiceControl.Transports.IBMMQ; + +using System; +using System.Threading; +using System.Threading.Tasks; +using IBM.WMQ; +using Microsoft.Extensions.Logging; +using NServiceBus.CustomChecks; +using ServiceControl.Infrastructure; + +public class DeadLetterQueueCheck : CustomCheck +{ + public DeadLetterQueueCheck(TransportSettings settings) : base(id: "Dead Letter Queue", category: "Transport", repeatAfter: TimeSpan.FromHours(1)) + { + Logger.LogDebug("IBM MQ Dead Letter Queue custom check starting"); + + (queueManagerName, connectionProperties) = ConnectionProperties.Parse(settings.ConnectionString); + runCheck = settings.RunCustomChecks; + } + + public override Task PerformCheck(CancellationToken cancellationToken = default) + { + if (!runCheck) + { + return Task.FromResult(CheckResult.Pass); + } + + Logger.LogDebug("Checking Dead Letter Queue length"); + + try + { + using var queueManager = new MQQueueManager(queueManagerName, connectionProperties); + + var dlqName = queueManager.DeadLetterQueueName?.Trim(); + if (string.IsNullOrEmpty(dlqName)) + { + return Task.FromResult(CheckResult.Pass); + } + + using var dlq = queueManager.AccessQueue(dlqName, MQC.MQOO_INQUIRE | MQC.MQOO_FAIL_IF_QUIESCING); + var depth = dlq.CurrentDepth; + + if (depth > 0) + { + var message = $"{depth} messages in the Dead Letter Queue '{dlqName}' on queue manager '{queueManagerName}'. This could indicate a problem with ServiceControl's retries. Please submit a support ticket to Particular if you would like help from our engineers to ensure no message loss while resolving these dead letter messages."; + Logger.LogWarning("{DeadLetterMessageCount} messages in the Dead Letter Queue '{DeadLetterQueueName}' on queue manager '{QueueManagerName}'", depth, dlqName, queueManagerName); + return Task.FromResult(CheckResult.Failed(message)); + } + + Logger.LogDebug("No messages in Dead Letter Queue"); + return Task.FromResult(CheckResult.Pass); + } + catch (MQException e) + { + var message = $"Unable to check Dead Letter Queue on queue manager '{queueManagerName}'. Reason: {e.Message} (RC={e.ReasonCode})"; + Logger.LogWarning(e, "Unable to check Dead Letter Queue on queue manager '{QueueManagerName}'", queueManagerName); + return Task.FromResult(CheckResult.Failed(message)); + } + } + + readonly string queueManagerName; + readonly System.Collections.Hashtable connectionProperties; + readonly bool runCheck; + + static readonly ILogger Logger = LoggerUtil.CreateStaticLogger(typeof(DeadLetterQueueCheck)); +} diff --git a/src/ServiceControl.Transports.IBMMQ/QueueLengthProvider.cs b/src/ServiceControl.Transports.IBMMQ/QueueLengthProvider.cs index 353e832ba5..7dcf53bcc2 100644 --- a/src/ServiceControl.Transports.IBMMQ/QueueLengthProvider.cs +++ b/src/ServiceControl.Transports.IBMMQ/QueueLengthProvider.cs @@ -6,7 +6,6 @@ namespace ServiceControl.Transports.IBMMQ; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; -using System.Web; using IBM.WMQ; using Microsoft.Extensions.Logging; @@ -16,58 +15,7 @@ public QueueLengthProvider(TransportSettings settings, Action 0 } path - ? Uri.UnescapeDataString(path) - : "QM1"; - - connectionProperties = new Hashtable - { - [MQC.TRANSPORT_PROPERTY] = MQC.TRANSPORT_MQSERIES_MANAGED, - [MQC.HOST_NAME_PROPERTY] = connectionUri.Host, - [MQC.PORT_PROPERTY] = connectionUri.Port > 0 ? connectionUri.Port : 1414, - [MQC.CHANNEL_PROPERTY] = query["channel"] ?? "DEV.ADMIN.SVRCONN" - }; - - var userInfo = connectionUri.UserInfo; - if (!string.IsNullOrEmpty(userInfo)) - { - var parts = userInfo.Split(':'); - var user = Uri.UnescapeDataString(parts[0]); - - if (!string.IsNullOrWhiteSpace(user)) - { - connectionProperties[MQC.USE_MQCSP_AUTHENTICATION_PROPERTY] = true; - connectionProperties[MQC.USER_ID_PROPERTY] = user; - } - - if (parts.Length > 1) - { - var password = Uri.UnescapeDataString(parts[1]); - if (!string.IsNullOrWhiteSpace(password)) - { - connectionProperties[MQC.PASSWORD_PROPERTY] = password; - } - } - } - - if (query["sslkeyrepo"] is { } sslKeyRepo) - { - connectionProperties[MQC.SSL_CERT_STORE_PROPERTY] = sslKeyRepo; - } - - if (query["cipherspec"] is { } cipherSpec) - { - connectionProperties[MQC.SSL_CIPHER_SPEC_PROPERTY] = cipherSpec; - } - - if (query["sslpeername"] is { } sslPeerName) - { - connectionProperties[MQC.SSL_PEER_NAME_PROPERTY] = sslPeerName; - } + (queueManagerName, connectionProperties) = ConnectionProperties.Parse(ConnectionString); } public override void TrackEndpointInputQueue(EndpointToQueueMapping queueToTrack) => diff --git a/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj b/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj index 94ceac0087..2b2d9a9296 100644 --- a/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj +++ b/src/ServiceControl.Transports.IBMMQ/ServiceControl.Transports.IBMMQ.csproj @@ -13,8 +13,6 @@ - - From 45a04c9a571b628537c02ccd33c886747ebc612f Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Wed, 11 Mar 2026 11:07:45 +0100 Subject: [PATCH 19/19] =?UTF-8?q?=E2=9C=A8=20Add=20tests=20for=20IBM=20MQ?= =?UTF-8?q?=20DeadLetterQueueCheck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DeadLetterQueueCheckTests.cs | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 src/ServiceControl.Transports.IBMMQ.Tests/DeadLetterQueueCheckTests.cs diff --git a/src/ServiceControl.Transports.IBMMQ.Tests/DeadLetterQueueCheckTests.cs b/src/ServiceControl.Transports.IBMMQ.Tests/DeadLetterQueueCheckTests.cs new file mode 100644 index 0000000000..b05f681e9c --- /dev/null +++ b/src/ServiceControl.Transports.IBMMQ.Tests/DeadLetterQueueCheckTests.cs @@ -0,0 +1,148 @@ +namespace ServiceControl.Transport.Tests; + +using System; +using System.Collections; +using System.Threading.Tasks; +using System.Web; +using IBM.WMQ; +using NUnit.Framework; +using Transports; +using Transports.IBMMQ; + +[TestFixture] +class DeadLetterQueueCheckTests +{ + [Test] + public async Task Should_pass_when_custom_checks_disabled() + { + var settings = new TransportSettings + { + ConnectionString = ConnectionString, + RunCustomChecks = false + }; + + var check = new DeadLetterQueueCheck(settings); + var result = await check.PerformCheck().ConfigureAwait(false); + + Assert.That(result.HasFailed, Is.False); + } + + [Test] + public async Task Should_pass_when_dlq_is_empty() + { + DrainDeadLetterQueue(); + + var settings = new TransportSettings + { + ConnectionString = ConnectionString, + RunCustomChecks = true + }; + + var check = new DeadLetterQueueCheck(settings); + var result = await check.PerformCheck().ConfigureAwait(false); + + Assert.That(result.HasFailed, Is.False); + } + + [Test] + public async Task Should_fail_when_dlq_has_messages() + { + DrainDeadLetterQueue(); + PutMessageOnDeadLetterQueue(); + + try + { + var settings = new TransportSettings + { + ConnectionString = ConnectionString, + RunCustomChecks = true + }; + + var check = new DeadLetterQueueCheck(settings); + var result = await check.PerformCheck().ConfigureAwait(false); + + Assert.That(result.HasFailed, Is.True); + Assert.That(result.FailureReason, Does.Contain("messages in the Dead Letter Queue")); + } + finally + { + DrainDeadLetterQueue(); + } + } + + [Test] + public async Task Should_fail_when_connection_is_invalid() + { + var settings = new TransportSettings + { + ConnectionString = "mq://admin:passw0rd@localhost:19999/BOGUS", + RunCustomChecks = true + }; + + var check = new DeadLetterQueueCheck(settings); + var result = await check.PerformCheck().ConfigureAwait(false); + + Assert.That(result.HasFailed, Is.True); + Assert.That(result.FailureReason, Does.Contain("Unable to check Dead Letter Queue")); + Assert.That(result.FailureReason, Does.Contain("RC=")); + } + + static void PutMessageOnDeadLetterQueue() + { + var (qmName, props) = ParseConnectionString(); + using var qm = new MQQueueManager(qmName, props); + var dlqName = qm.DeadLetterQueueName.Trim(); + using var dlq = qm.AccessQueue(dlqName, MQC.MQOO_OUTPUT); + var msg = new MQMessage(); + msg.WriteString("DLQ test message"); + dlq.Put(msg); + } + + static void DrainDeadLetterQueue() + { + var (qmName, props) = ParseConnectionString(); + using var qm = new MQQueueManager(qmName, props); + var dlqName = qm.DeadLetterQueueName.Trim(); + using var dlq = qm.AccessQueue(dlqName, MQC.MQOO_INPUT_SHARED | MQC.MQOO_FAIL_IF_QUIESCING); + var gmo = new MQGetMessageOptions { WaitInterval = 0, Options = MQC.MQGMO_NO_WAIT }; + while (true) + { + try + { + dlq.Get(new MQMessage(), gmo); + } + catch (MQException e) when (e.ReasonCode == MQC.MQRC_NO_MSG_AVAILABLE) + { + break; + } + } + } + + static (string queueManagerName, Hashtable properties) ParseConnectionString() + { + var uri = new Uri(ConnectionString); + var query = HttpUtility.ParseQueryString(uri.Query); + + var qmName = uri.AbsolutePath.Trim('/') is { Length: > 0 } path + ? Uri.UnescapeDataString(path) + : "QM1"; + + var props = new Hashtable + { + [MQC.TRANSPORT_PROPERTY] = MQC.TRANSPORT_MQSERIES_MANAGED, + [MQC.HOST_NAME_PROPERTY] = uri.Host, + [MQC.PORT_PROPERTY] = uri.Port > 0 ? uri.Port : 1414, + [MQC.CHANNEL_PROPERTY] = query["channel"] ?? "DEV.ADMIN.SVRCONN", + [MQC.USE_MQCSP_AUTHENTICATION_PROPERTY] = true, + [MQC.USER_ID_PROPERTY] = Uri.UnescapeDataString(uri.UserInfo.Split(':')[0]), + [MQC.PASSWORD_PROPERTY] = Uri.UnescapeDataString(uri.UserInfo.Split(':')[1]) + }; + + return (qmName, props); + } + + static readonly string ConnectionString = + Environment.GetEnvironmentVariable("ServiceControl_TransportTests_IBMMQ_ConnectionString") + ?? Environment.GetEnvironmentVariable("SERVICECONTROL_TRANSPORTTESTS_IBMMQ_CONNECTIONSTRING") + ?? "mq://admin:passw0rd@localhost:1414"; +}